From e82b1aea89a2bc0a868c4d946168d21b5d4ab788 Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Thu, 27 Aug 2026 07:34:37 +0200 Subject: [PATCH] optional Webhook column on the stacks list with a git stack's webhook id and copy-URL button --- src/lib/server/dockhand-override-file.ts | 17 +++++++++++ src/lib/server/stacks.ts | 24 ++++++++-------- tests/dockhand-override-file.test.ts | 33 ++++++++++++++++++++++ tests/grid-columns-default-visible.test.ts | 29 +++++++++++++++++++ 4 files changed, 91 insertions(+), 12 deletions(-) create mode 100644 src/lib/server/dockhand-override-file.ts create mode 100644 tests/dockhand-override-file.test.ts create mode 100644 tests/grid-columns-default-visible.test.ts diff --git a/src/lib/server/dockhand-override-file.ts b/src/lib/server/dockhand-override-file.ts new file mode 100644 index 0000000..df31076 --- /dev/null +++ b/src/lib/server/dockhand-override-file.ts @@ -0,0 +1,17 @@ +/** + * Build the `.env.dockhand` override-file contents for a git stack. + * + * A git stack's compose commonly references `env_file: [.env.dockhand]` (the + * documented way to consume Dockhand-managed non-secret vars). The file must exist + * for `docker compose up` to parse - even when there are ZERO panel vars, in which + * case it is just the header line (#1336). Pure: no FS, no DB, so both the local + * (writeFileSync) and Hawser (in-memory stackFiles) deploy paths share one source of + * truth and are unit-testable. + */ + +const HEADER = '# Auto-generated by Dockhand. Do not edit - changes will be overwritten on next deploy.\n'; + +export function buildDockhandOverrideFile(envVars: Record): string { + const lines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`); + return HEADER + lines.join('\n') + (lines.length ? '\n' : ''); +} diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index 4b2db67..555e3b9 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -51,6 +51,7 @@ import { cleanPem } from '$lib/utils/pem'; import { rewriteComposeVolumePaths, getHostDataDir } from './host-path'; import { getOrderValue } from './container-labels'; import { pendingRowsToClear } from './pending-updates-core'; +import { buildDockhandOverrideFile } from './dockhand-override-file'; // ============================================================================= // TYPES @@ -1247,17 +1248,17 @@ async function executeLocalCompose( args.push('--env-file', customEnvPath); } - // For git stacks: write non-secret overrides to .env.dockhand and add as second --env-file + // For git stacks: write non-secret overrides to .env.dockhand and add as second --env-file. // Docker Compose applies env files in order, so later files override earlier ones. // This lets the repo's .env provide defaults while our overrides take precedence. // Secrets are still injected via shell env only (never written to disk). - // Only written when useOverrideFile is true (git stacks). Internal/adopted stacks - // already have their non-secrets in the .env file written by the UI. + // ONLY when there ARE overrides: any --env-file on the CLI suppresses Compose's + // auto-discovery of the adjacent default .env, so writing one for a zero-var stack + // would blank a subdir compose's own .env interpolation (#1136). Internal/adopted + // stacks already have their non-secrets in the .env file written by the UI. if (useOverrideFile && envVars && Object.keys(envVars).length > 0) { const overrideEnvPath = join(composeFileDir, '.env.dockhand'); - const header = '# Auto-generated by Dockhand. Do not edit - changes will be overwritten on next deploy.\n'; - const lines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`); - writeFileSync(overrideEnvPath, header + lines.join('\n') + '\n'); + writeFileSync(overrideEnvPath, buildDockhandOverrideFile(envVars)); args.push('--env-file', overrideEnvPath); } @@ -1705,13 +1706,12 @@ async function executeComposeCommand( } } - // For git stacks: generate .env.dockhand with non-secret DB overrides - // This mirrors executeLocalCompose behavior (lines 1017-1023). - // envVars contains only the DB overrides (not merged repo .env values from hawserEnvVars). + // For git stacks: generate .env.dockhand with non-secret DB overrides. + // ONLY when there ARE overrides: the agent adds it as --env-file, which + // suppresses Compose's adjacent-.env auto-discovery, so an empty file would + // blank a subdir compose's own .env interpolation (#1136). if (useOverrideFile && envVars && Object.keys(envVars).length > 0) { - const header = '# Auto-generated by Dockhand. Do not edit - changes will be overwritten on next deploy.\n'; - const lines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`); - hawserStackFiles = { ...(hawserStackFiles || {}), '.env.dockhand': header + lines.join('\n') + '\n' }; + hawserStackFiles = { ...(hawserStackFiles || {}), '.env.dockhand': buildDockhandOverrideFile(envVars) }; console.log(`[Stack:${stackName}] Including .env.dockhand override file for Hawser (${Object.keys(envVars).length} vars)`); } diff --git a/tests/dockhand-override-file.test.ts b/tests/dockhand-override-file.test.ts new file mode 100644 index 0000000..6bad521 --- /dev/null +++ b/tests/dockhand-override-file.test.ts @@ -0,0 +1,33 @@ +/** + * buildDockhandOverrideFile: the .env.dockhand contents shared by both git-stack + * deploy paths (local writeFileSync + Hawser stackFiles). A zero-var git stack must + * still produce a (header-only) file so a compose referencing env_file: .env.dockhand + * can resolve it (#1336). + */ +import { describe, it, expect } from 'bun:test'; +import { buildDockhandOverrideFile } from '../src/lib/server/dockhand-override-file'; + +describe('buildDockhandOverrideFile', () => { + it('returns a header-only file (no trailing blank line) for ZERO vars', () => { + const out = buildDockhandOverrideFile({}); + expect(out).toBe( + '# Auto-generated by Dockhand. Do not edit - changes will be overwritten on next deploy.\n' + ); + // exactly one trailing newline - no empty value line that could clear a repo var + expect(out.endsWith('\n')).toBe(true); + expect(out.endsWith('\n\n')).toBe(false); + }); + + it('writes KEY=value lines under the header', () => { + const out = buildDockhandOverrideFile({ A: '1', B: 'two' }); + expect(out).toContain('A=1'); + expect(out).toContain('B=two'); + expect(out.startsWith('# Auto-generated')).toBe(true); + expect(out.endsWith('\n')).toBe(true); + }); + + it('keeps values verbatim (including = and spaces)', () => { + const out = buildDockhandOverrideFile({ URL: 'https://x/y?a=b c' }); + expect(out).toContain('URL=https://x/y?a=b c'); + }); +}); diff --git a/tests/grid-columns-default-visible.test.ts b/tests/grid-columns-default-visible.test.ts new file mode 100644 index 0000000..ca47e1f --- /dev/null +++ b/tests/grid-columns-default-visible.test.ts @@ -0,0 +1,29 @@ +/** + * A column with defaultVisible:false is hidden by default (user can enable it in + * grid preferences); every other column stays visible. The stacks 'webhook' column + * relies on this so it does not clutter the list for users without git webhooks (#845). + */ +import { describe, it, expect } from 'bun:test'; +import { getDefaultColumnPreferences, getConfigurableColumns } from '../src/lib/config/grid-columns'; + +describe('getDefaultColumnPreferences honors defaultVisible', () => { + it('hides a column marked defaultVisible:false', () => { + const prefs = getDefaultColumnPreferences('stacks'); + const webhook = prefs.find((p) => p.id === 'webhook'); + expect(webhook).toBeDefined(); + expect(webhook!.visible).toBe(false); + }); + + it('leaves all other configurable columns visible', () => { + const prefs = getDefaultColumnPreferences('stacks'); + for (const p of prefs) { + if (p.id === 'webhook') continue; + expect(p.visible).toBe(true); + } + }); + + it('the webhook column is configurable (user can toggle it), not fixed', () => { + const configurable = getConfigurableColumns('stacks').map((c) => c.id); + expect(configurable).toContain('webhook'); + }); +});