diff --git a/src/app.html b/src/app.html index e05f702..d96ece1 100644 --- a/src/app.html +++ b/src/app.html @@ -13,6 +13,3 @@
%sveltekit.body%
- - -// Build trigger: 20260102-121809 diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte index 47fca60..25b0c68 100644 --- a/src/lib/components/CodeEditor.svelte +++ b/src/lib/components/CodeEditor.svelte @@ -214,7 +214,10 @@ variableMarkers?: VariableMarker[]; } - let { value = '', language = 'yaml', readonly = false, theme = 'dark', onchange, class: className = '', variableMarkers = [] }: Props = $props(); + let { value = '', language = 'yaml', readonly = false, theme = 'dark', onchange, class: className = '', variableMarkers: variableMarkersProp = [] }: Props = $props(); + + // Keep markers reactive - destructured props with defaults lose reactivity + const variableMarkers = $derived(variableMarkersProp); let container: HTMLDivElement; let view: EditorView | null = null; diff --git a/src/lib/components/StackEnvVarsPanel.svelte b/src/lib/components/StackEnvVarsPanel.svelte index 24ee844..715d063 100644 --- a/src/lib/components/StackEnvVarsPanel.svelte +++ b/src/lib/components/StackEnvVarsPanel.svelte @@ -17,6 +17,7 @@ placeholder?: { key: string; value: string }; infoText?: string; existingSecretKeys?: Set; + theme?: 'light' | 'dark'; class?: string; onchange?: () => void; } @@ -31,6 +32,7 @@ placeholder = { key: 'VARIABLE_NAME', value: 'value' }, infoText, existingSecretKeys = new Set(), + theme = 'dark', class: className = '', onchange }: Props = $props(); @@ -44,7 +46,6 @@ let confirmClearOpen = $state(false); let contentAreaRef: HTMLDivElement; let parseWarnings = $state([]); - let editorTheme = $state<'light' | 'dark'>('dark'); let hasMergedOnLoad = $state(false); // Count of secrets (for display in hint) @@ -370,9 +371,9 @@ {:else if secretCount > 0} -
- -
+
+ +
{secretCount} secret{secretCount === 1 ? '' : 's'} not shown. Secrets are never written to disk and are injected via shell environment when the stack starts.
@@ -429,7 +430,7 @@ + import * as Dialog from '$lib/components/ui/dialog'; + import { Button } from '$lib/components/ui/button'; + import { AlertCircle, Copy, Check, AlertTriangle, CheckCircle2, XCircle } from 'lucide-svelte'; + + interface Props { + open: boolean; + title: string; + message: string; + details?: string; + onClose: () => void; + } + + let { open = $bindable(), title, message, details, onClose }: Props = $props(); + let copied = $state(false); + + interface ParsedOutput { + warnings: string[]; + steps: { action: string; status: 'creating' | 'created' | 'starting' | 'started' | 'error' }[]; + error: string | null; + raw: string; + parsed: boolean; + } + + // Parse docker compose output into structured format + function parseDockerOutput(text: string): ParsedOutput { + const result: ParsedOutput = { + warnings: [], + steps: [], + error: null, + raw: text, + parsed: false + }; + + try { + const lines = text.split('\n').map(l => l.trim()).filter(Boolean); + + for (const line of lines) { + // Parse time="..." level=warning msg="..." + const warningMatch = line.match(/time="[^"]*"\s+level=warning\s+msg="([^"]+)"/); + if (warningMatch) { + result.warnings.push(warningMatch[1]); + result.parsed = true; + continue; + } + + // Parse container/network steps: "Network foo Creating" or "Container foo-1 Created" + const stepMatch = line.match(/^\s*(Network|Container|Volume)\s+(\S+)\s+(Creating|Created|Starting|Started|Stopping|Stopped|Removing|Removed)\s*$/i); + if (stepMatch) { + const [, type, name, status] = stepMatch; + const normalizedStatus = status.toLowerCase() as any; + result.steps.push({ + action: `${type} ${name}`, + status: normalizedStatus + }); + result.parsed = true; + continue; + } + + // Parse error lines + if (line.startsWith('Error') || line.includes('error') || line.includes('failed')) { + result.error = result.error ? `${result.error}\n${line}` : line; + result.parsed = true; + continue; + } + } + + // If we parsed something but have no clear error, check for remaining unparsed content + if (result.parsed && !result.error) { + const unparsed = lines.filter(line => { + if (line.match(/time="[^"]*"\s+level=warning/)) return false; + if (line.match(/^\s*(Network|Container|Volume)\s+\S+\s+(Creating|Created|Starting|Started|Stopping|Stopped|Removing|Removed)\s*$/i)) return false; + return true; + }); + if (unparsed.length > 0) { + result.error = unparsed.join('\n'); + } + } + } catch { + // Parsing failed, will show raw message + } + + return result; + } + + const parsed = $derived(parseDockerOutput(message)); + + async function copyError() { + const text = details ? `${message}\n\n${details}` : message; + await navigator.clipboard.writeText(text); + copied = true; + setTimeout(() => (copied = false), 2000); + } + + function handleClose() { + open = false; + onClose(); + } + + + !o && handleClose()}> + + + + + {title} + + +
+ {#if parsed.parsed} + + {#if parsed.warnings.length > 0} +
+ {#each parsed.warnings as warning} +
+ + {warning} +
+ {/each} +
+ {/if} + + {#if parsed.steps.length > 0} +
+ {#each parsed.steps as step} +
+ {#if step.status === 'created' || step.status === 'started' || step.status === 'removed' || step.status === 'stopped'} + + {:else if step.status === 'error'} + + {:else} +
+ {/if} + {step.action} + {step.status} +
+ {/each} +
+ {/if} + + {#if parsed.error} +
+ +
{parsed.error}
+
+ {/if} + {:else} + +
+ +
{message}
+
+ {/if} + + {#if details} +
{details}
+ {/if} +
+ + + +
+
diff --git a/src/lib/components/ui/error-dialog/index.ts b/src/lib/components/ui/error-dialog/index.ts new file mode 100644 index 0000000..7a28cca --- /dev/null +++ b/src/lib/components/ui/error-dialog/index.ts @@ -0,0 +1,3 @@ +import ErrorDialog from './error-dialog.svelte'; + +export { ErrorDialog }; diff --git a/src/lib/server/git.ts b/src/lib/server/git.ts index c7f109e..2bb1c64 100644 --- a/src/lib/server/git.ts +++ b/src/lib/server/git.ts @@ -58,7 +58,14 @@ async function buildGitEnv(credential: GitCredential | null): Promise { if (credential?.authType === 'ssh' && credential.sshPrivateKey) { // Create a temporary SSH key file (use absolute path so SSH can find it) const sshKeyPath = resolve(join(GIT_REPOS_DIR, `.ssh-key-${credential.id}`)); - await Bun.write(sshKeyPath, credential.sshPrivateKey); + + // Ensure SSH key ends with a newline (newer SSH versions are strict about this) + let keyContent = credential.sshPrivateKey; + if (!keyContent.endsWith('\n')) { + keyContent += '\n'; + } + + await Bun.write(sshKeyPath, keyContent); // Ensure SSH key has correct permissions (0600 = owner read/write only) // Bun.write's mode option doesn't always work reliably, so use chmodSync chmodSync(sshKeyPath, 0o600); diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index bdabab1..35d1021 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -263,24 +263,19 @@ export async function getStackComposeFile( ): Promise<{ success: boolean; content?: string; stackDir?: string; error?: string }> { const stacksDir = getStacksDir(); const stackDir = join(stacksDir, stackName); - const composeFile = join(stackDir, 'docker-compose.yml'); - const ymlFile = Bun.file(composeFile); - if (await ymlFile.exists()) { - return { - success: true, - content: await ymlFile.text(), - stackDir - }; - } + // Check all common compose file names (Docker Compose v1 and v2 naming conventions) + const composeFileNames = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml']; - const yamlFile = Bun.file(join(stackDir, 'docker-compose.yaml')); - if (await yamlFile.exists()) { - return { - success: true, - content: await yamlFile.text(), - stackDir - }; + for (const fileName of composeFileNames) { + const file = Bun.file(join(stackDir, fileName)); + if (await file.exists()) { + return { + success: true, + content: await file.text(), + stackDir + }; + } } return { @@ -1289,8 +1284,10 @@ export async function writeStackEnvFile( const stacksDir = getStacksDir(); const envFilePath = join(stacksDir, stackName, '.env'); + // SECURITY: Only write non-secret variables to .env file + // Secrets are stored in DB and injected via shell environment at runtime const rawContent = variables - .filter(v => v.key?.trim()) + .filter(v => v.key?.trim() && !v.isSecret) .map(v => `${v.key.trim()}=${v.value}`) .join('\n') + '\n'; @@ -1299,16 +1296,14 @@ export async function writeStackEnvFile( /** * Write raw environment content directly to the .env file (preserves comments/formatting) + * + * NOTE: Raw content should NOT contain secrets. Secrets are managed via the form view, + * stored in DB, and injected via shell environment at runtime. */ export async function writeRawStackEnvFile( stackName: string, rawContent: string ): Promise { - // Guard against writing masked secret placeholders (would corrupt the file) - if (rawContent.match(/^[A-Za-z_][A-Za-z0-9_]*=\*\*\*$/m)) { - throw new Error('Cannot write masked placeholder "***" to .env file - this would corrupt secret values'); - } - const stacksDir = getStacksDir(); const stackDir = join(stacksDir, stackName); diff --git a/src/routes/api/stacks/+server.ts b/src/routes/api/stacks/+server.ts index dd3066a..39a675e 100644 --- a/src/routes/api/stacks/+server.ts +++ b/src/routes/api/stacks/+server.ts @@ -96,17 +96,18 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { } // Save environment variables - // NEW SIMPLIFIED: rawEnvContent contains ALL vars including secrets (with real values) - // Secrets are visually masked in the UI but stored with real values in .env file + // - rawEnvContent: non-secret vars with comments → .env file + // - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata) if (rawEnvContent) { - // Write raw content directly to .env file (includes secrets with real values) + // Write raw content to .env file (should NOT contain secrets) await writeRawStackEnvFile(name, rawEnvContent); - // Save secret metadata to DB (for UI masking purposes) - if (envVars && Array.isArray(envVars) && envVars.length > 0) { - await saveStackEnvVarsToDb(name, envVars, envIdNum); - } - } else if (envVars && Array.isArray(envVars) && envVars.length > 0) { - // Fallback: generate from vars (no raw content provided) + } + // Save ALL vars to DB (secrets for shell injection at runtime) + if (envVars && Array.isArray(envVars) && envVars.length > 0) { + await saveStackEnvVarsToDb(name, envVars, envIdNum); + } + // Fallback: if no rawEnvContent, generate .env from non-secret vars + if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) { await saveStackEnvVars(name, envVars, envIdNum); } @@ -125,16 +126,18 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { // First ensure the stack directory exists by saving compose file await saveStackComposeFile(name, compose, true); - // NEW SIMPLIFIED: rawEnvContent contains ALL vars including secrets (with real values) + // - rawEnvContent: non-secret vars with comments → .env file + // - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata) if (rawEnvContent) { - // Write raw content directly to .env file (includes secrets with real values) + // Write raw content to .env file (should NOT contain secrets) await writeRawStackEnvFile(name, rawEnvContent); - // Save secret metadata to DB (for UI masking purposes) - if (envVars && Array.isArray(envVars) && envVars.length > 0) { - await saveStackEnvVarsToDb(name, envVars, envIdNum); - } - } else { - // Fallback: generate from vars (no raw content provided) + } + // Save ALL vars to DB (secrets for shell injection at runtime) + if (envVars && Array.isArray(envVars) && envVars.length > 0) { + await saveStackEnvVarsToDb(name, envVars, envIdNum); + } + // Fallback: if no rawEnvContent, generate .env from non-secret vars + if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) { await saveStackEnvVars(name, envVars, envIdNum); } } diff --git a/src/routes/api/stacks/[name]/compose/+server.ts b/src/routes/api/stacks/[name]/compose/+server.ts index ec632ac..fa2664a 100644 --- a/src/routes/api/stacks/[name]/compose/+server.ts +++ b/src/routes/api/stacks/[name]/compose/+server.ts @@ -49,11 +49,13 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) => let result; if (restart) { - // Deploy with docker compose up -d (only recreates changed services) + // Deploy with docker compose up -d --force-recreate + // Force recreate ensures env var changes are applied result = await deployStack({ name, compose: content, - envId: envIdNum + envId: envIdNum, + forceRecreate: true }); } else { // Just save the file without restarting diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte index ca5caf2..d0fdf8e 100644 --- a/src/routes/stacks/+page.svelte +++ b/src/routes/stacks/+page.svelte @@ -1055,8 +1055,6 @@ onDestroy(() => { document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('resume', handleVisibilityChange); - errorTimeouts.forEach(id => clearTimeout(id)); - errorTimeouts = []; }); diff --git a/src/routes/stacks/StackModal.svelte b/src/routes/stacks/StackModal.svelte index 70ff749..a2541b0 100644 --- a/src/routes/stacks/StackModal.svelte +++ b/src/routes/stacks/StackModal.svelte @@ -7,7 +7,7 @@ import CodeEditor, { type VariableMarker } from '$lib/components/CodeEditor.svelte'; import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte'; import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte'; - import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, ChevronsLeft, ChevronsRight, Variable, HelpCircle, GripVertical, FolderOpen } from 'lucide-svelte'; + import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, ChevronsLeft, ChevronsRight, Variable, HelpCircle, GripVertical, FolderOpen, Copy, Check } from 'lucide-svelte'; import * as Tooltip from '$lib/components/ui/tooltip'; import { currentEnvironment, appendEnvParam } from '$lib/stores/environment'; import { focusFirstInput } from '$lib/utils'; @@ -32,6 +32,7 @@ let newStackName = $state(''); let loading = $state(false); let saving = $state(false); + let savingWithRestart = $state(false); // Track which save action is in progress let error = $state(null); let loadError = $state(null); let errors = $state<{ stackName?: string; compose?: string }>({}); @@ -56,6 +57,15 @@ // Stack location (for edit mode) let stackLocation = $state(null); + let pathCopied = $state(false); + + function copyPath() { + if (stackLocation) { + navigator.clipboard.writeText(stackLocation); + pathCopied = true; + setTimeout(() => pathCopied = false, 2000); + } + } // CodeEditor reference for explicit marker updates let codeEditorRef: CodeEditor | null = $state(null); @@ -181,13 +191,18 @@ services: const displayName = $derived(mode === 'edit' ? stackName : (newStackName || 'New stack')); onMount(() => { - // Follow app theme from localStorage - const appTheme = localStorage.getItem('theme'); - if (appTheme === 'dark' || appTheme === 'light') { - editorTheme = appTheme; + // Load saved editor theme, or fall back to app theme / system preference + const savedEditorTheme = localStorage.getItem('dockhand-editor-theme'); + if (savedEditorTheme === 'dark' || savedEditorTheme === 'light') { + editorTheme = savedEditorTheme; } else { - // Fallback to system preference - editorTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + const appTheme = localStorage.getItem('theme'); + if (appTheme === 'dark' || appTheme === 'light') { + editorTheme = appTheme; + } else { + // Fallback to system preference + editorTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } } // Load saved split ratio @@ -395,6 +410,7 @@ services: } saving = true; + savingWithRestart = restart; error = null; // Prepare env vars for saving - syncs variables and rawContent @@ -602,9 +618,20 @@ services: {#if mode === 'create'} Create a new Docker Compose stack {:else if stackLocation} - - - {stackLocation} + + + {stackLocation} + {:else} Edit compose file and view stack structure @@ -612,9 +639,11 @@ services:
+
+
-
+
-
-
{#if activeTab === 'editor'}
@@ -840,8 +868,8 @@ services: {:else} - - {/if}