From 607d340b711323c9cb1fa54e52148a5e0c9e51db Mon Sep 17 00:00:00 2001 From: jarek Date: Fri, 2 Jan 2026 12:24:43 +0100 Subject: [PATCH] 1.0.5 --- Dockerfile | 160 ++++++++++---- docker-entrypoint.sh | 2 +- src/app.html | 1 + src/lib/components/CodeEditor.svelte | 17 ++ src/lib/components/StackEnvVarsPanel.svelte | 202 +++++++++++------- src/lib/data/changelog.json | 1 + src/lib/server/db.ts | 33 +++ src/lib/server/stacks.ts | 165 ++++++++++---- src/routes/api/stacks/+server.ts | 35 ++- .../api/stacks/[name]/compose/+server.ts | 2 +- src/routes/api/stacks/[name]/env/+server.ts | 115 ++-------- .../api/stacks/[name]/env/raw/+server.ts | 23 +- .../notifications/NotificationModal.svelte | 18 +- src/routes/stacks/+page.svelte | 63 +++--- src/routes/stacks/StackModal.svelte | 157 ++++++++------ 15 files changed, 619 insertions(+), 375 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9a08239..edd2e4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,75 @@ -# Build stage - using Debian to avoid Alpine musl thread creation issues +# syntax=docker/dockerfile:1.4 +# ============================================================================= +# Dockhand Docker Image - Security-Hardened Build +# ============================================================================= +# This Dockerfile builds a custom Wolfi OS from scratch using apko, ensuring: +# - Full transparency (no dependency on pre-built Chainguard images) +# - Reproducible builds from open-source Wolfi packages +# - Minimal attack surface with only required packages +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Stage 1: OS Generator (Alpine + apko tool) +# ----------------------------------------------------------------------------- +# We use Alpine because it has a shell. This lets us download and run apko +# to build our custom Wolfi OS from scratch using open-source packages. +FROM alpine:3.21 AS os-builder + +WORKDIR /work + +# Install apko tool (latest stable release) +# apko is the tool Chainguard uses to build their images - we use it directly +ARG APKO_VERSION=0.30.34 +ARG TARGETARCH +RUN apk add --no-cache curl \ + && ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "amd64") \ + && curl -sL "https://github.com/chainguard-dev/apko/releases/download/v${APKO_VERSION}/apko_${APKO_VERSION}_linux_${ARCH}.tar.gz" \ + | tar -xz --strip-components=1 -C /usr/local/bin \ + && chmod +x /usr/local/bin/apko + +# Generate apko.yaml for current target architecture only +# We build single-arch to avoid multi-arch layer confusion in extraction +RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") \ + && printf '%s\n' \ + "contents:" \ + " repositories:" \ + " - https://packages.wolfi.dev/os" \ + " keyring:" \ + " - https://packages.wolfi.dev/os/wolfi-signing.rsa.pub" \ + " packages:" \ + " - wolfi-base" \ + " - ca-certificates" \ + " - busybox" \ + " - tzdata" \ + " - bun" \ + " - docker-cli" \ + " - docker-compose" \ + " - sqlite" \ + " - git" \ + " - openssh-client" \ + " - curl" \ + " - tini" \ + " - su-exec" \ + "entrypoint:" \ + " command: /bin/sh -l" \ + "archs:" \ + " - ${APKO_ARCH}" \ + > apko.yaml + +# Build the OS tarball and extract rootfs +# apko creates an OCI tarball - we need to extract the actual filesystem layer +RUN apko build apko.yaml dockhand-base:latest output.tar \ + && mkdir -p rootfs \ + && tar -xf output.tar \ + && LAYER=$(tar -tf output.tar | grep '.tar.gz$' | head -1) \ + && tar -xzf "$LAYER" -C rootfs + +# ----------------------------------------------------------------------------- +# Stage 2: Application Builder +# ----------------------------------------------------------------------------- +# Using Debian to avoid Alpine musl thread creation issues # Alpine's musl libc causes rayon/tokio thread pool panics during svelte-adapter-bun build -FROM oven/bun:1.3.5-debian AS builder +FROM oven/bun:1.3.5-debian AS app-builder WORKDIR /app @@ -15,72 +84,71 @@ RUN bun install --frozen-lockfile COPY . . # Build with parallelism - dedicated build VM has 16 CPUs and 32GB RAM -# Increased memory limits for parallel compilation with larger semi-space for GC RUN NODE_OPTIONS="--max-old-space-size=8192 --max-semi-space-size=128" bun run build -# Production stage - minimal Alpine with Bun runtime -FROM oven/bun:1.3.5-alpine +# Prepare production node_modules (do this in builder where we have compilers) +# This ensures native addons compile correctly before copying to hardened runtime +RUN rm -rf node_modules && bun install --production --frozen-lockfile \ + && rm -rf node_modules/@types node_modules/bun-types + +# ----------------------------------------------------------------------------- +# Stage 3: Final Image (Scratch + Custom Wolfi OS) +# ----------------------------------------------------------------------------- +FROM scratch + +# Install our custom-built Wolfi OS (now we have /bin/sh!) +COPY --from=os-builder /work/rootfs/ / WORKDIR /app -# Install runtime dependencies, create user -# Add sqlite for emergency scripts, git for stack git operations, curl for healthchecks -# Add docker-cli and docker-cli-compose for stack management (uses host's docker socket) -# Add openssh-client for SSH key authentication with git repositories -# Upgrade all packages to latest versions for security patches -RUN apk upgrade --no-cache \ - && apk add --no-cache curl git tini su-exec sqlite docker-cli docker-cli-compose openssh-client iproute2 \ - && addgroup -g 1001 dockhand \ +# Set up environment variables +ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \ + NODE_ENV=production \ + PORT=3000 \ + HOST=0.0.0.0 \ + DATA_DIR=/app/data \ + HOME=/home/dockhand \ + PUID=1001 \ + PGID=1001 + +# Create docker compose plugin symlink (we use `docker compose` syntax, Wolfi has standalone binary) +RUN mkdir -p /usr/libexec/docker/cli-plugins \ + && ln -s /usr/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose + +# Create dockhand user and group (using busybox commands) +RUN addgroup -g 1001 dockhand \ && adduser -u 1001 -G dockhand -h /home/dockhand -D dockhand -# Copy package files and install production dependencies -# This is needed because svelte-adapter-bun externalizes some packages (croner, etc.) -# that need to be available at runtime. Installing at build time is more reliable -# than Bun's auto-install which requires network access and writable cache. -COPY package.json bun.lock* ./ -RUN bun install --production --frozen-lockfile - -# Copy built application (Bun adapter output) -COPY --from=builder /app/build ./build - -# Copy bundled subprocess scripts (built by scripts/build-subprocesses.ts) -COPY --from=builder /app/build/subprocesses/ ./subprocesses/ +# Copy application files with correct ownership (avoids layer duplication from chown -R) +COPY --from=app-builder --chown=dockhand:dockhand /app/node_modules ./node_modules +COPY --from=app-builder --chown=dockhand:dockhand /app/package.json ./ +COPY --from=app-builder --chown=dockhand:dockhand /app/build ./build +COPY --from=app-builder --chown=dockhand:dockhand /app/build/subprocesses/ ./subprocesses/ # Copy database migrations -COPY drizzle/ ./drizzle/ -COPY drizzle-pg/ ./drizzle-pg/ +COPY --chown=dockhand:dockhand drizzle/ ./drizzle/ +COPY --chown=dockhand:dockhand drizzle-pg/ ./drizzle-pg/ # Copy legal documents -COPY LICENSE.txt PRIVACY.txt ./ +COPY --chown=dockhand:dockhand LICENSE.txt PRIVACY.txt ./ -# Copy entrypoint script +# Copy entrypoint script (root-owned, executable) COPY docker-entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/docker-entrypoint.sh -# Copy emergency scripts (only the emergency subfolder, not license generation scripts) -COPY scripts/emergency/ ./scripts/ +# Copy emergency scripts +COPY --chown=dockhand:dockhand scripts/emergency/ ./scripts/ RUN chmod +x ./scripts/*.sh ./scripts/**/*.sh 2>/dev/null || true -# Create directories with proper ownership +# Create data directories with correct ownership RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \ - && chown -R dockhand:dockhand /app /home/dockhand + && chown dockhand:dockhand /app/data /home/dockhand /home/dockhand/.dockhand /home/dockhand/.dockhand/stacks EXPOSE 3000 -# Runtime configuration -ENV NODE_ENV=production -ENV PORT=3000 -ENV HOST=0.0.0.0 -ENV DATA_DIR=/app/data -ENV HOME=/home/dockhand - -# User/group IDs - customize with -e PUID=1000 -e PGID=1000 -# The entrypoint will recreate the dockhand user with these IDs -ENV PUID=1001 -ENV PGID=1001 - HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:3000/ || exit 1 + CMD curl -f http://localhost:3000/ || exit 1 ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"] CMD ["bun", "run", "./build/index.js"] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 3f00ec4..6f750f5 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -80,7 +80,7 @@ else if [ "$PUID" != "1001" ] || [ "$PGID" != "1001" ]; then echo "Configuring user with PUID=$PUID PGID=$PGID" - # Remove existing dockhand user/group (only dockhand, not others) + # Remove existing dockhand user/group (using busybox commands) deluser dockhand 2>/dev/null || true delgroup dockhand 2>/dev/null || true diff --git a/src/app.html b/src/app.html index 5f6ec02..e05f702 100644 --- a/src/app.html +++ b/src/app.html @@ -15,3 +15,4 @@ +// Build trigger: 20260102-121809 diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte index 4cac179..47fca60 100644 --- a/src/lib/components/CodeEditor.svelte +++ b/src/lib/components/CodeEditor.svelte @@ -2,6 +2,8 @@ import { onMount, onDestroy } from 'svelte'; import { EditorState, StateField, StateEffect, RangeSet } from '@codemirror/state'; import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, gutter, GutterMarker, Decoration, WidgetType, type DecorationSet } from '@codemirror/view'; + // Note: Secret masking was removed - secrets are now excluded from the raw editor entirely + // and are only stored in the database (never written to .env file) import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, StreamLanguage, type StreamParser } from '@codemirror/language'; import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; @@ -787,6 +789,21 @@ updateVariableMarkers(markers); } }); + + // Sync external value changes to the editor (e.g., when parent clears the content) + $effect(() => { + const externalValue = value; + if (view) { + const currentContent = view.state.doc.toString(); + // Only update if the external value differs from editor content + // This prevents feedback loops from editor changes + if (externalValue !== currentContent) { + view.dispatch({ + changes: { from: 0, to: currentContent.length, insert: externalValue } + }); + } + } + });
- import { tick, untrack } from 'svelte'; + import { tick } from 'svelte'; import { Button } from '$lib/components/ui/button'; import StackEnvVarsEditor, { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte'; import CodeEditor from '$lib/components/CodeEditor.svelte'; import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; - import { Plus, Info, Upload, Trash2, List, FileText, AlertTriangle } from 'lucide-svelte'; + import { Plus, Info, Upload, Trash2, List, FileText, AlertTriangle, ShieldAlert } from 'lucide-svelte'; import * as Tooltip from '$lib/components/ui/tooltip'; interface Props { - variables: EnvVar[]; // Bindable - kept in sync with rawContent - rawContent?: string; // The actual content saved to disk - source of truth + variables: EnvVar[]; // Bindable - ALL variables (secrets + non-secrets) + rawContent: string; // Bindable - raw .env file content (comments preserved, no secrets) validation?: ValidationResult | null; readonly?: boolean; showSource?: boolean; @@ -45,14 +45,43 @@ let contentAreaRef: HTMLDivElement; let parseWarnings = $state([]); let editorTheme = $state<'light' | 'dark'>('dark'); + let hasMergedOnLoad = $state(false); - // Track previous variables to detect form changes - let prevVariablesJson = $state(''); + // Count of secrets (for display in hint) + const secretCount = $derived(variables.filter(v => v.isSecret && v.key.trim()).length); - // Track if initial sync has been done (to distinguish initial load from user action) - let initialized = $state(false); + /** + * Merge variables and rawContent on initial load. + * Called by parent after setting both variables and rawContent. + * This ensures both are in sync regardless of which view mode is active. + */ + export function mergeOnLoad() { + if (hasMergedOnLoad) return; + hasMergedOnLoad = true; - // Parse raw content to EnvVar array + // If rawContent exists, parse it and merge with variables (which may have secrets from DB) + if (rawContent.trim()) { + const { vars: rawVars } = parseRawContent(rawContent); + const rawVarsByKey = new Map(rawVars.map(v => [v.key, v])); + + // Secrets come from variables (DB), non-secrets come from rawContent (file) + // But if a var exists in variables but not in rawContent, keep it (could be new) + const secrets = variables.filter(v => v.isSecret); + const nonSecretsFromRaw = rawVars; + + // Also keep non-secrets from variables that aren't in raw (new vars added before first save) + const rawKeys = new Set(rawVars.map(v => v.key)); + const newNonSecrets = variables.filter(v => !v.isSecret && v.key.trim() && !rawKeys.has(v.key)); + + variables = [...nonSecretsFromRaw, ...newNonSecrets, ...secrets]; + } + // If no rawContent, variables is already correct (from DB), just need to generate raw + // for when user switches to text view (done in handleViewModeChange) + } + + /** + * Parse raw content to extract non-secret variables. + */ function parseRawContent(content: string): { vars: EnvVar[], warnings: string[] } { const result: EnvVar[] = []; const warnings: string[] = []; @@ -82,123 +111,124 @@ warnings.push(`Line ${lineNum}: "${key}" (invalid variable name)`); continue; } - result.push({ - key, - value, - isSecret: existingSecretKeys.has(key) || false - }); + result.push({ key, value, isSecret: false }); } } return { vars: result, warnings }; } - // Update rawContent when variables change - replace var lines by position, preserve comments - function syncRawContentFromVariables(newVars: EnvVar[]) { + /** + * Sync variables (non-secrets) TO rawContent. + * Preserves comments and formatting. Secrets are excluded. + */ + function syncVariablesToRaw() { + const nonSecretVars = variables.filter(v => v.key.trim() && !v.isSecret); + + // If no raw content exists, generate fresh + if (!rawContent.trim()) { + if (nonSecretVars.length > 0) { + rawContent = nonSecretVars.map(v => `${v.key.trim()}=${v.value}`).join('\n') + '\n'; + } + return; + } + + // Update existing raw content - preserve comments, update/add/remove variables + const varMap = new Map(nonSecretVars.map(v => [v.key.trim(), v])); + const usedKeys = new Set(); const lines = rawContent.split('\n'); const resultLines: string[] = []; - const varsWithKeys = newVars.filter(v => v.key.trim()); - let varIdx = 0; for (const line of lines) { const trimmed = line.trim(); + // Keep comments and blank lines if (!trimmed || trimmed.startsWith('#')) { resultLines.push(line); continue; } + // Check if this is a variable line const eqIndex = trimmed.indexOf('='); if (eqIndex > 0) { const key = trimmed.slice(0, eqIndex).trim(); if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { - // This is a valid variable line - replace with var at current index - if (varIdx < varsWithKeys.length) { - const v = varsWithKeys[varIdx]; - resultLines.push(`${v.key.trim()}=${v.value}`); - varIdx++; + const varData = varMap.get(key); + if (varData) { + // Update value + resultLines.push(`${key}=${varData.value}`); + usedKeys.add(key); } - // If we have fewer vars, this line is deleted + // If not in varMap, variable was deleted - skip line continue; } } - // Keep invalid lines as-is + resultLines.push(line); } - // Append any new variables - while (varIdx < varsWithKeys.length) { - const v = varsWithKeys[varIdx]; - resultLines.push(`${v.key.trim()}=${v.value}`); - varIdx++; + // Append new variables + for (const v of nonSecretVars) { + if (!usedKeys.has(v.key.trim())) { + resultLines.push(`${v.key.trim()}=${v.value}`); + } } let result = resultLines.join('\n'); if (result && !result.endsWith('\n')) { result += '\n'; } - return result; + rawContent = result; } - // When rawContent changes externally (text view, file load), update variables - $effect(() => { + /** + * Sync rawContent TO variables. + * Parses raw content for non-secrets, preserves existing secrets. + */ + function syncRawToVariables() { const { vars, warnings } = parseRawContent(rawContent); parseWarnings = warnings; - // Initial load with no .env file: don't overwrite DB-loaded variables - // Let the second $effect generate rawContent from the existing variables instead - if (!initialized && !rawContent.trim() && variables.length > 0) { - initialized = true; - return; + // Preserve existing secrets (they're not in rawContent) + const existingSecrets = variables.filter(v => v.isSecret); + + // Merge: non-secrets from raw + existing secrets + variables = [...vars, ...existingSecrets]; + } + + /** + * Call before saving. Ensures variables and rawContent are in sync. + * Always syncs variables→raw to get proper .env content for disk. + */ + export function prepareForSave(): { rawContent: string; variables: EnvVar[] } { + // If in text view, first sync raw→variables to capture edits + if (viewMode === 'text') { + syncRawToVariables(); } - initialized = true; + // Then sync variables→raw to ensure rawContent is up to date + syncVariablesToRaw(); - // When rawContent has content, merge parsed vars with existing DB secrets - // This handles the case where .env file exists but DB has additional secrets - let finalVars = vars; - if (rawContent.trim()) { - const parsedKeys = new Set(vars.map(v => v.key)); - const existingSecrets = untrack(() => - variables.filter(v => v.isSecret && !parsedKeys.has(v.key)) - ); - if (existingSecrets.length > 0) { - finalVars = [...vars, ...existingSecrets]; - } - } - - const newJson = JSON.stringify(finalVars.map(v => ({ key: v.key, value: v.value }))); - // Use untrack to read variables without creating a dependency on it - // This prevents the effect from running when variables changes (only rawContent should trigger it) - const currentNonEmptyJson = untrack(() => - JSON.stringify(variables.filter(v => v.key.trim()).map(v => ({ key: v.key, value: v.value }))) - ); - - if (newJson !== currentNonEmptyJson) { - variables = finalVars; - prevVariablesJson = newJson; - } - }); - - // When variables change from form edits, update rawContent - $effect(() => { - const currentJson = JSON.stringify(variables.map(v => ({ key: v.key, value: v.value }))); - - // Only sync if variables actually changed (not from parsing rawContent) - if (currentJson !== prevVariablesJson) { - prevVariablesJson = currentJson; - const newRaw = syncRawContentFromVariables(variables); - if (newRaw !== rawContent) { - rawContent = newRaw; - } - } - }); + return { + rawContent, + variables: variables.filter(v => v.key.trim()) + }; + } function handleTextChange(value: string) { rawContent = value; + syncRawToVariables(); // Sync to variables so parent's envVars updates (for compose decorations) onchange?.(); } function handleViewModeChange(newMode: 'form' | 'text') { + if (newMode === 'text' && viewMode === 'form') { + // Form → Text: sync variables to raw (preserves comments) + syncVariablesToRaw(); + } else if (newMode === 'form' && viewMode === 'text') { + // Text → Form: sync raw to variables (preserves secrets) + syncRawToVariables(); + } + viewMode = newMode; localStorage.setItem(STORAGE_KEY_VIEW_MODE, newMode); } @@ -233,6 +263,11 @@ const reader = new FileReader(); reader.onload = (e) => { rawContent = e.target?.result as string; + // Parse and merge with existing secrets + syncRawToVariables(); + // Switch to text view to show loaded content + viewMode = 'text'; + localStorage.setItem(STORAGE_KEY_VIEW_MODE, 'text'); onchange?.(); }; reader.readAsText(file); @@ -333,9 +368,14 @@ ${`{VAR:-default}`} optional ${`{VAR:?error}`} required w/ error
- {:else} -
- Raw .env file (comments preserved, saved exactly as typed) + {: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. +
{/if} diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index cb0081a..4bc6471 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -3,6 +3,7 @@ "version": "1.0.5", "date": "2026-01-01", "changes": [ + { "type": "feature", "text": "Custom hardened image built from scratch using Wolfi packages, eliminating Alpine vulnerabilities" }, { "type": "feature", "text": "Clicking container name opens container details" }, { "type": "feature", "text": "Clicking stack name opens stack editor (internal stacks)" }, { "type": "feature", "text": "Stack env editor now supports freestyle text entry for pasting env contents" }, diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 6426230..84de5d5 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -4064,6 +4064,39 @@ export async function getStackEnvVarsAsRecord( return Object.fromEntries(vars.map(v => [v.key, v.value])); } +/** + * Get only SECRET environment variables as a key-value record (for shell injection). + * Returns unmasked real values - used to inject secrets via shell environment at runtime. + * These secrets are NEVER written to .env files on disk. + * @param stackName - Name of the stack + * @param environmentId - Optional environment ID + */ +export async function getSecretEnvVarsAsRecord( + stackName: string, + environmentId?: number | null +): Promise> { + const vars = await getStackEnvVars(stackName, environmentId, false); + return Object.fromEntries( + vars.filter(v => v.isSecret).map(v => [v.key, v.value]) + ); +} + +/** + * Get only NON-SECRET environment variables as a key-value record. + * Used for .env file operations where secrets should be excluded. + * @param stackName - Name of the stack + * @param environmentId - Optional environment ID + */ +export async function getNonSecretEnvVarsAsRecord( + stackName: string, + environmentId?: number | null +): Promise> { + const vars = await getStackEnvVars(stackName, environmentId, false); + return Object.fromEntries( + vars.filter(v => !v.isSecret).map(v => [v.key, v.value]) + ); +} + /** * Set/replace all environment variables for a stack. * Deletes existing vars and inserts new ones in a transaction-like manner. diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index 4bcb09e..bdabab1 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -10,6 +10,9 @@ import { join, resolve } from 'node:path'; import { getEnvironment, getStackEnvVarsAsRecord, + getSecretEnvVarsAsRecord, + getNonSecretEnvVarsAsRecord, + getStackEnvVars, setStackEnvVars, getStackSource, upsertStackSource, @@ -257,7 +260,7 @@ export function listManagedStacks(): string[] { */ export async function getStackComposeFile( stackName: string -): Promise<{ success: boolean; content?: string; error?: string }> { +): Promise<{ success: boolean; content?: string; stackDir?: string; error?: string }> { const stacksDir = getStacksDir(); const stackDir = join(stacksDir, stackName); const composeFile = join(stackDir, 'docker-compose.yml'); @@ -266,7 +269,8 @@ export async function getStackComposeFile( if (await ymlFile.exists()) { return { success: true, - content: await ymlFile.text() + content: await ymlFile.text(), + stackDir }; } @@ -274,7 +278,8 @@ export async function getStackComposeFile( if (await yamlFile.exists()) { return { success: true, - content: await yamlFile.text() + content: await yamlFile.text(), + stackDir }; } @@ -351,7 +356,10 @@ interface ComposeCommandOptions { } /** - * Execute a docker compose command locally via Bun.spawn + * Execute a docker compose command locally via Bun.spawn. + * + * @param envVars - Non-secret environment variables (from .env file, passed for backward compat) + * @param secretVars - Secret environment variables (injected via shell env, NEVER written to disk) */ async function executeLocalCompose( operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', @@ -359,6 +367,7 @@ async function executeLocalCompose( composeContent: string, dockerHost?: string, envVars?: Record, + secretVars?: Record, forceRecreate?: boolean, removeVolumes?: boolean ): Promise { @@ -370,17 +379,23 @@ async function executeLocalCompose( const composeFile = join(stackDir, 'docker-compose.yml'); await Bun.write(composeFile, composeContent); - // Note: .env file is written when env vars are saved via API - // Docker compose automatically reads .env from the stack directory - // We only need to pass env vars to process environment for variable substitution - // in case the .env file doesn't exist yet (e.g., first deploy) + // Build spawn environment: + // 1. Start with process.env + // 2. Add DOCKER_HOST if specified + // 3. Add non-secret envVars (for backward compat when .env file doesn't exist) + // 4. Add secret envVars (CRITICAL: these are NEVER written to disk, only passed via shell env) const spawnEnv: Record = { ...(process.env as Record) }; if (dockerHost) { spawnEnv.DOCKER_HOST = dockerHost; } + // Non-secret vars (backup for when .env file doesn't exist yet) if (envVars) { Object.assign(spawnEnv, envVars); } + // SECRET vars: injected via shell environment at runtime (NEVER written to .env file) + if (secretVars) { + Object.assign(spawnEnv, secretVars); + } // Build command based on operation const args = ['docker', 'compose', '-p', stackName, '-f', composeFile]; @@ -505,7 +520,10 @@ async function executeLocalCompose( } /** - * Execute a docker compose command via Hawser agent + * Execute a docker compose command via Hawser agent. + * + * @param envVars - Non-secret environment variables (from .env file) + * @param secretVars - Secret environment variables (injected via shell env on Hawser, NEVER in .env) */ async function executeComposeViaHawser( operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', @@ -513,6 +531,7 @@ async function executeComposeViaHawser( composeContent: string, envId: number, envVars?: Record, + secretVars?: Record, forceRecreate?: boolean, removeVolumes?: boolean, stackFiles?: Record @@ -521,6 +540,11 @@ async function executeComposeViaHawser( // Import dockerFetch dynamically to avoid circular dependency const { dockerFetch } = await import('./docker.js'); + // Merge envVars and secretVars for passing to Hawser + // Hawser will inject ALL these as shell environment variables (secrets are NOT written to .env) + const allEnvVars = { ...(envVars || {}), ...(secretVars || {}) }; + const secretCount = secretVars ? Object.keys(secretVars).length : 0; + console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} EXECUTE COMPOSE VIA HAWSER`); console.log(`${logPrefix} ----------------------------------------`); @@ -528,9 +552,10 @@ async function executeComposeViaHawser( console.log(`${logPrefix} Environment ID:`, envId); console.log(`${logPrefix} Force recreate:`, forceRecreate ?? false); console.log(`${logPrefix} Remove volumes:`, removeVolumes ?? false); - console.log(`${logPrefix} Env vars count:`, envVars ? Object.keys(envVars).length : 0); - if (envVars && Object.keys(envVars).length > 0) { - console.log(`${logPrefix} Env vars being sent (masked):`, JSON.stringify(maskSecrets(envVars), null, 2)); + console.log(`${logPrefix} Non-secret env vars count:`, envVars ? Object.keys(envVars).length : 0); + console.log(`${logPrefix} Secret env vars count:`, secretCount); + if (allEnvVars && Object.keys(allEnvVars).length > 0) { + console.log(`${logPrefix} All env vars being sent (masked):`, JSON.stringify(maskSecrets(allEnvVars), null, 2)); } console.log(`${logPrefix} Compose content length:`, composeContent.length, 'chars'); console.log(`${logPrefix} Stack files count:`, stackFiles ? Object.keys(stackFiles).length : 0); @@ -539,22 +564,30 @@ async function executeComposeViaHawser( } try { - // Build files map - include .env file if envVars provided + // Build files map - include .env file ONLY for non-secret envVars + // Secrets are passed separately via allEnvVars and injected via shell env const files: Record = { ...(stackFiles || {}) }; if (envVars && Object.keys(envVars).length > 0) { - const envContent = Object.entries(envVars) - .map(([key, value]) => `${key}=${value}`) - .join('\n'); - files['.env'] = envContent; - console.log(`${logPrefix} Added .env file to files map with ${Object.keys(envVars).length} variables`); + if (files['.env']) { + // stackFiles already has .env (e.g., from git repo with comments) + // Don't overwrite - the envVars are already passed separately for variable substitution + console.log(`${logPrefix} Preserving existing .env from stackFiles (${files['.env'].length} chars), envVars passed separately for substitution`); + } else { + // No .env in stackFiles - generate one from NON-SECRET envVars only + const envContent = Object.entries(envVars) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + files['.env'] = envContent; + console.log(`${logPrefix} Generated .env file with ${Object.keys(envVars).length} non-secret variables`); + } } const body = JSON.stringify({ operation, projectName: stackName, composeFile: composeContent, - envVars: envVars || {}, - files, // All files including .env + envVars: allEnvVars, // All vars (including secrets) - Hawser injects via shell env + files, // Files including .env (secrets NOT in .env file) forceRecreate: forceRecreate || false, removeVolumes: removeVolumes || false }); @@ -610,13 +643,17 @@ async function executeComposeViaHawser( } /** - * Route compose command to appropriate executor based on connection type + * Route compose command to appropriate executor based on connection type. + * + * @param envVars - Non-secret environment variables (from .env file) + * @param secretVars - Secret environment variables (from DB, injected via shell env) */ async function executeComposeCommand( operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', options: ComposeCommandOptions, composeContent: string, - envVars?: Record + envVars?: Record, + secretVars?: Record ): Promise { const { stackName, envId, forceRecreate, removeVolumes, stackFiles } = options; @@ -631,6 +668,7 @@ async function executeComposeCommand( composeContent, undefined, envVars, + secretVars, forceRecreate, removeVolumes ); @@ -645,6 +683,7 @@ async function executeComposeCommand( composeContent, envId!, envVars, + secretVars, forceRecreate, removeVolumes, stackFiles @@ -659,6 +698,7 @@ async function executeComposeCommand( composeContent, dockerHost, envVars, + secretVars, forceRecreate, removeVolumes ); @@ -672,6 +712,7 @@ async function executeComposeCommand( composeContent, undefined, envVars, + secretVars, forceRecreate, removeVolumes ); @@ -842,12 +883,20 @@ async function withContainerFallback( // ============================================================================= /** - * Ensure we have a compose file for operations, throw appropriate error if not + * Ensure we have a compose file for operations, throw appropriate error if not. + * + * Returns: + * - content: The compose file content + * - envVars: Non-secret variables (from .env file, with DB fallback) + * - secretVars: Secret variables (from DB only, for shell injection) + * + * SECURITY: Secrets are NEVER written to .env files. They are stored in the database + * and injected via shell environment variables at runtime. */ async function requireComposeFile( stackName: string, envId?: number | null -): Promise<{ content: string; envVars: Record }> { +): Promise<{ content: string; envVars: Record; secretVars: Record }> { const composeResult = await getStackComposeFile(stackName); if (!composeResult.success) { @@ -859,11 +908,14 @@ async function requireComposeFile( throw new ComposeFileNotFoundError(stackName); } - // Get environment variables from database - const dbEnvVars = await getStackEnvVarsAsRecord(stackName, envId); + // Get SECRET variables from database (for shell injection at runtime) + // These are NEVER written to disk + const secretVars = await getSecretEnvVarsAsRecord(stackName, envId); - // Also read from .env file and merge (file + DB are equal sources) - // DB values take precedence for secrets, file values for new/changed vars + // Get non-secret variables from database (for backward compatibility) + const dbNonSecretVars = await getNonSecretEnvVarsAsRecord(stackName, envId); + + // Read non-secret vars from .env file (user can edit this file manually) const stackDir = join(getStacksDir(), stackName); const envFilePath = join(stackDir, '.env'); let fileEnvVars: Record = {}; @@ -890,10 +942,11 @@ async function requireComposeFile( } } - // Merge: file values as base, DB values override (DB is authoritative for managed vars) - const envVars = { ...fileEnvVars, ...dbEnvVars }; + // Merge non-secret vars: DB as fallback, file values override + // This ensures external edits to .env are respected during deployment + const envVars = { ...dbNonSecretVars, ...fileEnvVars }; - return { content: composeResult.content!, envVars }; + return { content: composeResult.content!, envVars, secretVars }; } /** @@ -905,8 +958,8 @@ export async function startStack( envId?: number | null ): Promise { try { - const { content, envVars } = await requireComposeFile(stackName, envId); - return executeComposeCommand('up', { stackName, envId }, content, envVars); + const { content, envVars, secretVars } = await requireComposeFile(stackName, envId); + return executeComposeCommand('up', { stackName, envId }, content, envVars, secretVars); } catch (err) { if (err instanceof ExternalStackError) { return withContainerFallback(stackName, envId, 'start'); @@ -924,8 +977,8 @@ export async function stopStack( envId?: number | null ): Promise { try { - const { content, envVars } = await requireComposeFile(stackName, envId); - return executeComposeCommand('stop', { stackName, envId }, content, envVars); + const { content, envVars, secretVars } = await requireComposeFile(stackName, envId); + return executeComposeCommand('stop', { stackName, envId }, content, envVars, secretVars); } catch (err) { if (err instanceof ExternalStackError) { return withContainerFallback(stackName, envId, 'stop'); @@ -943,8 +996,8 @@ export async function restartStack( envId?: number | null ): Promise { try { - const { content, envVars } = await requireComposeFile(stackName, envId); - return executeComposeCommand('restart', { stackName, envId }, content, envVars); + const { content, envVars, secretVars } = await requireComposeFile(stackName, envId); + return executeComposeCommand('restart', { stackName, envId }, content, envVars, secretVars); } catch (err) { if (err instanceof ExternalStackError) { return withContainerFallback(stackName, envId, 'restart'); @@ -963,8 +1016,8 @@ export async function downStack( removeVolumes = false ): Promise { try { - const { content, envVars } = await requireComposeFile(stackName, envId); - return executeComposeCommand('down', { stackName, envId, removeVolumes }, content, envVars); + const { content, envVars, secretVars } = await requireComposeFile(stackName, envId); + return executeComposeCommand('down', { stackName, envId, removeVolumes }, content, envVars, secretVars); } catch (err) { if (err instanceof ExternalStackError) { // For external stacks, down is the same as stop (no compose file to tear down) @@ -989,12 +1042,14 @@ export async function removeStack( // If compose file exists, run docker compose down first if (composeResult.success) { - const envVars = await getStackEnvVarsAsRecord(stackName, envId); + const envVars = await getNonSecretEnvVarsAsRecord(stackName, envId); + const secretVars = await getSecretEnvVarsAsRecord(stackName, envId); const downResult = await executeComposeCommand( 'down', { stackName, envId }, composeResult.content!, - envVars + envVars, + secretVars ); if (!downResult.success && !force) { return downResult; @@ -1198,9 +1253,9 @@ export async function pullStackImages( stackName: string, envId?: number | null ): Promise<{ success: boolean; output?: string; error?: string }> { - const { content, envVars } = await requireComposeFile(stackName, envId); + const { content, envVars, secretVars } = await requireComposeFile(stackName, envId); - return executeComposeCommand('pull', { stackName, envId }, content, envVars); + return executeComposeCommand('pull', { stackName, envId }, content, envVars, secretVars); } // ============================================================================= @@ -1242,6 +1297,30 @@ export async function writeStackEnvFile( await Bun.write(envFilePath, rawContent); } +/** + * Write raw environment content directly to the .env file (preserves comments/formatting) + */ +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); + + // Ensure stack directory exists + if (!existsSync(stackDir)) { + mkdirSync(stackDir, { recursive: true }); + } + + const envFilePath = join(stackDir, '.env'); + await Bun.write(envFilePath, rawContent); +} + /** * Save environment variables for a stack (both to database and .env file) * diff --git a/src/routes/api/stacks/+server.ts b/src/routes/api/stacks/+server.ts index 1d2ce02..dd3066a 100644 --- a/src/routes/api/stacks/+server.ts +++ b/src/routes/api/stacks/+server.ts @@ -1,5 +1,5 @@ import { json } from '@sveltejs/kit'; -import { listComposeStacks, deployStack, saveStackComposeFile, saveStackEnvVars } from '$lib/server/stacks'; +import { listComposeStacks, deployStack, saveStackComposeFile, saveStackEnvVars, writeRawStackEnvFile, saveStackEnvVarsToDb } from '$lib/server/stacks'; import { EnvironmentNotFoundError } from '$lib/server/docker'; import { upsertStackSource, getStackSources } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; @@ -78,7 +78,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { try { const body = await request.json(); - const { name, compose, start, envVars } = body; + const { name, compose, start, envVars, rawEnvContent } = body; if (!name || typeof name !== 'string') { return json({ error: 'Stack name is required' }, { status: 400 }); @@ -95,8 +95,18 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { return json({ error: result.error }, { status: 400 }); } - // Save environment variables if provided (to both DB and .env file) - if (envVars && Array.isArray(envVars) && envVars.length > 0) { + // 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 + if (rawEnvContent) { + // Write raw content directly to .env file (includes secrets with real values) + 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) await saveStackEnvVars(name, envVars, envIdNum); } @@ -111,11 +121,22 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { } // Save environment variables BEFORE deploying so they're available during start - if (envVars && Array.isArray(envVars) && envVars.length > 0) { + if (rawEnvContent || (envVars && Array.isArray(envVars) && envVars.length > 0)) { // First ensure the stack directory exists by saving compose file await saveStackComposeFile(name, compose, true); - // Save to both DB and .env file - await saveStackEnvVars(name, envVars, envIdNum); + + // NEW SIMPLIFIED: rawEnvContent contains ALL vars including secrets (with real values) + if (rawEnvContent) { + // Write raw content directly to .env file (includes secrets with real values) + 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) + await saveStackEnvVars(name, envVars, envIdNum); + } } // Deploy and start the stack diff --git a/src/routes/api/stacks/[name]/compose/+server.ts b/src/routes/api/stacks/[name]/compose/+server.ts index 08b5c11..ec632ac 100644 --- a/src/routes/api/stacks/[name]/compose/+server.ts +++ b/src/routes/api/stacks/[name]/compose/+server.ts @@ -19,7 +19,7 @@ export const GET: RequestHandler = async ({ params, cookies }) => { return json({ error: result.error }, { status: 404 }); } - return json({ content: result.content }); + return json({ content: result.content, stackDir: result.stackDir }); } catch (error: any) { console.error(`Error getting compose file for stack ${name}:`, error); return json({ error: error.message || 'Failed to get compose file' }, { status: 500 }); diff --git a/src/routes/api/stacks/[name]/env/+server.ts b/src/routes/api/stacks/[name]/env/+server.ts index 2855b65..4105e12 100644 --- a/src/routes/api/stacks/[name]/env/+server.ts +++ b/src/routes/api/stacks/[name]/env/+server.ts @@ -30,68 +30,14 @@ function parseEnvFile(content: string): Record { return result; } -/** - * Merge new variables into existing .env file content. - * - Keeps comments and formatting - * - Updates values for existing keys - * - REMOVES keys that are not in newVars (user deleted them) - * - Appends new keys at the end - */ -function mergeEnvFileContent( - existingContent: string, - newVars: { key: string; value: string }[] -): string { - const newVarsMap = new Map(newVars.map(v => [v.key, v.value])); - const handledKeys = new Set(); - const lines = existingContent.split('\n'); - const resultLines: string[] = []; - - for (const line of lines) { - const trimmed = line.trim(); - - // Keep comments and blank lines as-is - if (!trimmed || trimmed.startsWith('#')) { - resultLines.push(line); - continue; - } - - // Check if this is a variable line - const eqIndex = trimmed.indexOf('='); - if (eqIndex > 0) { - const key = trimmed.substring(0, eqIndex).trim(); - - if (newVarsMap.has(key)) { - // Update existing variable with new value from UI - resultLines.push(`${key}=${newVarsMap.get(key)}`); - handledKeys.add(key); - } - // If key not in newVarsMap, it was deleted - skip it (don't add to resultLines) - } else { - // Not a valid variable line, keep as-is - resultLines.push(line); - } - } - - // Append any new variables that weren't in the original file - for (const v of newVars) { - if (!handledKeys.has(v.key)) { - resultLines.push(`${v.key}=${v.value}`); - } - } - - // Ensure file ends with newline - let result = resultLines.join('\n'); - if (!result.endsWith('\n')) { - result += '\n'; - } - return result; -} - /** * GET /api/stacks/[name]/env?env=X * Get all environment variables for a stack. - * Merges variables from database with .env file (file values shown if different). - * Secrets are masked with '***' in the response. + * Merges variables from database with .env file (file values override for non-secrets). + * + * SECURITY: Secrets are returned as '***' (masked) - they are NEVER sent in plain text. + * Secrets are stored only in the database and injected via shell environment at runtime. + * The .env file only contains non-secret variables. */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const auth = await authorize(cookies); @@ -111,11 +57,11 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { try { const stackName = decodeURIComponent(params.name); - // Get variables from database + // Get variables from database (masked - secrets show as '***') const dbVariables = await getStackEnvVars(stackName, envIdNum, true); const dbByKey = new Map(dbVariables.map(v => [v.key, v])); - // Try to read .env file from stack directory + // Try to read .env file from stack directory (only contains non-secrets) const stacksDir = getStacksDir(); const envFilePath = join(stacksDir, stackName, '.env'); let fileVars: Record = {}; @@ -129,7 +75,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { } } - // Merge: start with DB variables, add any new keys from file + // Merge: DB variables (with secrets masked) + file variables (non-secrets only) + // For non-secrets: file value overrides DB value (user may have edited file) + // For secrets: only DB value exists (masked as '***') const mergedKeys = new Set([...dbByKey.keys(), ...Object.keys(fileVars)]); const variables: { key: string; value: string; isSecret: boolean }[] = []; @@ -138,15 +86,14 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { const fileValue = fileVars[key]; if (dbVar) { - // Variable exists in DB if (dbVar.isSecret) { - // Keep secret masked + // Secret: use masked value from DB, ignore any file value variables.push({ key, value: dbVar.value, isSecret: true }); - } else if (fileValue !== undefined && fileValue !== dbVar.value) { - // File has different value - use file value (user may have edited it) + } else if (fileValue !== undefined) { + // Non-secret with file value: file overrides (user may have edited) variables.push({ key, value: fileValue, isSecret: false }); } else { - // Use DB value + // Non-secret only in DB: use DB value variables.push({ key, value: dbVar.value, isSecret: false }); } } else if (fileValue !== undefined) { @@ -167,8 +114,12 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { * Set/replace all environment variables for a stack. * Body: { variables: [{ key, value, isSecret? }] } * - * Note: For secrets, if the value is '***' (the masked placeholder), the original + * SECURITY: Secrets are stored ONLY in the database, NEVER written to .env file. + * For secrets, if the value is '***' (the masked placeholder), the original * secret value from the database is preserved instead of overwriting with '***'. + * + * The .env file only contains non-secret variables (can be edited manually). + * Secrets are injected via shell environment variables at runtime. */ export const PUT: RequestHandler = async ({ params, url, cookies, request }) => { const auth = await authorize(cookies); @@ -234,34 +185,10 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) => }); } + // Save ALL variables (including secrets) to database + // Note: The .env file is written by PUT /env/raw endpoint, which preserves comments await setStackEnvVars(stackName, envIdNum, variablesToSave); - // Also write the .env file to the stack directory - // This allows users to see/edit variables outside of Dockhand - const stacksDir = getStacksDir(); - const stackDir = join(stacksDir, stackName); - const envFilePath = join(stackDir, '.env'); - - // Only write if stack directory exists - if (existsSync(stackDir)) { - // Read existing file to preserve comments and formatting - let existingContent = ''; - if (existsSync(envFilePath)) { - try { - existingContent = await Bun.file(envFilePath).text(); - } catch { - // File read failed, start fresh - } - } - - // Merge UI vars with existing file (preserves comments, keeps file vars) - const envContent = mergeEnvFileContent( - existingContent, - variablesToSave.map((v: { key: string; value: string }) => ({ key: v.key, value: v.value })) - ); - await Bun.write(envFilePath, envContent); - } - return json({ success: true, count: variablesToSave.length }); } catch (error) { console.error('Error setting stack env vars:', error); diff --git a/src/routes/api/stacks/[name]/env/raw/+server.ts b/src/routes/api/stacks/[name]/env/raw/+server.ts index 9a9880f..e5fac4d 100644 --- a/src/routes/api/stacks/[name]/env/raw/+server.ts +++ b/src/routes/api/stacks/[name]/env/raw/+server.ts @@ -1,7 +1,7 @@ import { json } from '@sveltejs/kit'; import { getStacksDir } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; -import { existsSync } from 'node:fs'; +import { existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import type { RequestHandler } from './$types'; @@ -82,9 +82,26 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) => return json({ error: 'Stack directory not found' }, { status: 404 }); } - // Ensure content ends with newline let content = body.content; - if (content && !content.endsWith('\n')) { + + // If content is empty, delete the .env file instead of writing empty file + if (!content || !content.trim()) { + if (existsSync(envFilePath)) { + rmSync(envFilePath); + return json({ success: true, deleted: true }); + } + return json({ success: true }); + } + + // Guard against writing masked secret placeholders (would corrupt the file) + if (content.match(/^[A-Za-z_][A-Za-z0-9_]*=\*\*\*$/m)) { + return json({ + error: 'Cannot write masked placeholder "***" to .env file - this would corrupt secret values' + }, { status: 400 }); + } + + // Ensure content ends with newline + if (!content.endsWith('\n')) { content += '\n'; } diff --git a/src/routes/settings/notifications/NotificationModal.svelte b/src/routes/settings/notifications/NotificationModal.svelte index a4debff..b6ece14 100644 --- a/src/routes/settings/notifications/NotificationModal.svelte +++ b/src/routes/settings/notifications/NotificationModal.svelte @@ -7,7 +7,8 @@ import { Badge } from '$lib/components/ui/badge'; import { TogglePill } from '$lib/components/ui/toggle-pill'; import { Checkbox } from '$lib/components/ui/checkbox'; - import { Plus, Check, RefreshCw, Mail, Zap, Info, Send, CheckCircle2, XCircle, Key, ChevronDown } from 'lucide-svelte'; + import { Plus, Check, RefreshCw, Mail, Zap, Info, Send, CheckCircle2, XCircle, Key, ChevronDown, HelpCircle } from 'lucide-svelte'; + import * as Tooltip from '$lib/components/ui/tooltip'; import { toast } from 'svelte-sonner'; import { focusFirstInput } from '$lib/utils'; @@ -343,7 +344,20 @@ {#if formType === 'smtp'}
-

SMTP configuration

+
+

SMTP configuration

+ + + + + + +

Gmail: smtp.gmail.com, port 587, TLS/SSL off. Use an App Password.

+

Outlook: smtp.office365.com, port 587, TLS/SSL off.

+
+
+
+
diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte index 24d6f3f..ca5caf2 100644 --- a/src/routes/stacks/+page.svelte +++ b/src/routes/stacks/+page.svelte @@ -24,6 +24,7 @@ import PageHeader from '$lib/components/PageHeader.svelte'; import { DataGrid } from '$lib/components/data-grid'; import type { DataGridSortState } from '$lib/components/data-grid/types'; + import { ErrorDialog } from '$lib/components/ui/error-dialog'; type SortField = 'name' | 'containers' | 'status' | 'cpu' | 'memory'; type SortDirection = 'asc' | 'desc'; @@ -281,14 +282,13 @@ let confirmPauseContainerId = $state(null); // Operation error state (for stack and container operations) - let operationError = $state<{ id: string; message: string } | null>(null); - let errorTimeouts: ReturnType[] = []; + let operationError = $state<{ id: string; title: string; message: string } | null>(null); - function clearErrorAfterDelay(id: string) { - const timeoutId = setTimeout(() => { - if (operationError?.id === id) operationError = null; - }, 5000); - errorTimeouts.push(timeoutId); + // Error dialog state (for showing detailed errors) + let errorDialogData = $state<{ title: string; message: string } | null>(null); + + function showErrorDialog(title: string, message: string) { + errorDialogData = { title, message }; } // Container inspect modal state @@ -673,9 +673,7 @@ if (!response.ok) { const data = await response.json(); const errorMsg = data.error || 'Failed to start stack'; - operationError = { id: name, message: errorMsg }; - toast.error(errorMsg); - clearErrorAfterDelay(name); + showErrorDialog(`Failed to start ${name}`, errorMsg); return; } toast.success(`Started ${name}`); @@ -683,9 +681,7 @@ } catch (error) { console.error('Failed to start stack:', error); const errorMsg = error instanceof Error ? error.message : 'Failed to start stack'; - operationError = { id: name, message: errorMsg }; - toast.error(errorMsg); - clearErrorAfterDelay(name); + showErrorDialog(`Failed to start ${name}`, errorMsg); } finally { stackActionLoading = null; } @@ -699,9 +695,7 @@ if (!response.ok) { const data = await response.json(); const errorMsg = data.error || 'Failed to stop stack'; - operationError = { id: name, message: errorMsg }; - toast.error(errorMsg); - clearErrorAfterDelay(name); + showErrorDialog(`Failed to stop ${name}`, errorMsg); return; } toast.success(`Stopped ${name}`); @@ -709,9 +703,7 @@ } catch (error) { console.error('Failed to stop stack:', error); const errorMsg = error instanceof Error ? error.message : 'Failed to stop stack'; - operationError = { id: name, message: errorMsg }; - toast.error(errorMsg); - clearErrorAfterDelay(name); + showErrorDialog(`Failed to stop ${name}`, errorMsg); } finally { stackActionLoading = null; } @@ -725,9 +717,7 @@ if (!response.ok) { const data = await response.json(); const errorMsg = data.error || 'Failed to restart stack'; - operationError = { id: name, message: errorMsg }; - toast.error(errorMsg); - clearErrorAfterDelay(name); + showErrorDialog(`Failed to restart ${name}`, errorMsg); return; } toast.success(`Restarted ${name}`); @@ -735,9 +725,7 @@ } catch (error) { console.error('Failed to restart stack:', error); const errorMsg = error instanceof Error ? error.message : 'Failed to restart stack'; - operationError = { id: name, message: errorMsg }; - toast.error(errorMsg); - clearErrorAfterDelay(name); + showErrorDialog(`Failed to restart ${name}`, errorMsg); } finally { stackActionLoading = null; } @@ -751,9 +739,7 @@ if (!response.ok) { const data = await response.json(); const errorMsg = data.error || 'Failed to bring down stack'; - operationError = { id: name, message: errorMsg }; - toast.error(errorMsg); - clearErrorAfterDelay(name); + showErrorDialog(`Failed to bring down ${name}`, errorMsg); return; } toast.success(`Brought down ${name}`); @@ -761,9 +747,7 @@ } catch (error) { console.error('Failed to bring down stack:', error); const errorMsg = error instanceof Error ? error.message : 'Failed to bring down stack'; - operationError = { id: name, message: errorMsg }; - toast.error(errorMsg); - clearErrorAfterDelay(name); + showErrorDialog(`Failed to bring down ${name}`, errorMsg); } finally { stackActionLoading = null; } @@ -789,9 +773,7 @@ if (!response.ok) { const data = await response.json(); const errorMsg = data.error || 'Failed to remove stack'; - operationError = { id: name, message: errorMsg }; - toast.error(errorMsg); - clearErrorAfterDelay(name); + showErrorDialog(`Failed to remove ${name}`, errorMsg); return; } toast.success(`Removed ${name}`); @@ -799,9 +781,7 @@ } catch (error) { console.error('Failed to remove stack:', error); const errorMsg = error instanceof Error ? error.message : 'Failed to remove stack'; - operationError = { id: name, message: errorMsg }; - toast.error(errorMsg); - clearErrorAfterDelay(name); + showErrorDialog(`Failed to remove ${name}`, errorMsg); } } @@ -1970,3 +1950,12 @@ onClose={() => showBatchOpModal = false} onComplete={handleBatchComplete} /> + +{#if errorDialogData} + errorDialogData = null} + /> +{/if} diff --git a/src/routes/stacks/StackModal.svelte b/src/routes/stacks/StackModal.svelte index a872d21..70ff749 100644 --- a/src/routes/stacks/StackModal.svelte +++ b/src/routes/stacks/StackModal.svelte @@ -7,11 +7,12 @@ 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 } from 'lucide-svelte'; + import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, ChevronsLeft, ChevronsRight, Variable, HelpCircle, GripVertical, FolderOpen } from 'lucide-svelte'; import * as Tooltip from '$lib/components/ui/tooltip'; import { currentEnvironment, appendEnvParam } from '$lib/stores/environment'; import { focusFirstInput } from '$lib/utils'; import * as Alert from '$lib/components/ui/alert'; + import { ErrorDialog } from '$lib/components/ui/error-dialog'; import ComposeGraphViewer from './ComposeGraphViewer.svelte'; // localStorage key for persisted split ratio @@ -41,20 +42,30 @@ // Environment variables state let envVars = $state([]); - let rawEnvContent = $state(''); + let rawEnvContent = $state(''); // Raw .env file content (comments preserved) let envValidation = $state(null); let validating = $state(false); let existingSecretKeys = $state>(new Set()); + let hadExistingDbVars = $state(false); // Track if DB had any vars on load (for proper cleanup) // Simple dirty flag - only set when user touches something let isDirty = $state(false); + // Error dialog state + let operationError = $state<{ title: string; message: string; details?: string } | null>(null); + + // Stack location (for edit mode) + let stackLocation = $state(null); + // CodeEditor reference for explicit marker updates let codeEditorRef: CodeEditor | null = $state(null); // ComposeGraphViewer reference for resize on panel toggle let graphViewerRef: ComposeGraphViewer | null = $state(null); + // EnvVarsPanel reference for sync before save + let envVarsPanelRef: StackEnvVarsPanel | null = $state(null); + // Resizable split panel state let splitRatio = $state(60); // percentage for compose panel let isDraggingSplit = $state(false); @@ -239,33 +250,37 @@ services: } composeContent = data.content; + stackLocation = data.stackDir || null; // Load environment variables (parsed) const envResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId)); if (envResponse.ok) { const envData = await envResponse.json(); envVars = envData.variables || []; + // Track if DB had any vars (for proper cleanup on clear-all) + hadExistingDbVars = envVars.length > 0; // Track existing secret keys (secrets loaded from DB cannot have visibility toggled) existingSecretKeys = new Set( envVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim()) ); } - // Load raw .env file content + // Load raw .env file content (for preserving comments) const rawEnvResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId)); if (rawEnvResponse.ok) { const rawEnvData = await rawEnvResponse.json(); rawEnvContent = rawEnvData.content || ''; + console.log('[loadComposeFile] rawEnvContent loaded:', rawEnvContent); } - - // Wait for $effects in StackEnvVarsPanel to settle (parses raw content, syncs variables) - await tick(); - // Reset dirty flag after loading completes - isDirty = false; } catch (e: any) { loadError = e.message; } finally { loading = false; + // Merge variables and rawContent after both are loaded + await tick(); + envVarsPanelRef?.mergeOnLoad(); + // Reset dirty flag after loading completes + isDirty = false; } } @@ -328,13 +343,13 @@ services: saving = true; error = null; + // Prepare env vars for creating - syncs variables and rawContent + const prepared = envVarsPanelRef?.prepareForSave() || { rawContent: '', variables: [] }; + try { const envId = $currentEnvironment?.id ?? null; - // Collect environment variables - const definedVars = envVars.filter(v => v.key.trim()); - - // Create the stack (include env vars so they're available before start) + // Create the stack (include env vars and raw content for .env file) const response = await fetch(appendEnvParam('/api/stacks', envId), { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -342,7 +357,10 @@ services: name: newStackName.trim(), compose: content, start, - envVars: definedVars.length > 0 ? definedVars.map(v => ({ + // Send raw env content (non-secrets only, preserves comments/formatting) + rawEnvContent: prepared.rawContent.trim() ? prepared.rawContent : undefined, + // Also send parsed vars for DB secret tracking (includes secrets) + envVars: prepared.variables.length > 0 ? prepared.variables.map(v => ({ key: v.key.trim(), value: v.value, isSecret: v.isSecret @@ -358,7 +376,11 @@ services: onSuccess(); handleClose(); } catch (e: any) { - error = e.message; + operationError = { + title: 'Failed to create stack', + message: e.message || 'An error occurred while creating the stack', + details: e.details + }; } finally { saving = false; } @@ -375,6 +397,9 @@ services: saving = true; error = null; + // Prepare env vars for saving - syncs variables and rawContent + const prepared = envVarsPanelRef?.prepareForSave() || { rawContent: '', variables: [] }; + try { const envId = $currentEnvironment?.id ?? null; @@ -397,56 +422,50 @@ services: throw new Error(data.error || 'Failed to save compose file'); } - // Save environment variables - // Always save to raw endpoint for consistency - // If no raw content but has env vars, generate raw content from vars (backward compat) - const definedVars = envVars.filter(v => v.key.trim()); - let contentToSave = rawEnvContent; + // Save raw content to .env file (non-secrets only, comments preserved) + const rawEnvResponse = await fetch( + appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId), + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: prepared.rawContent }) + } + ); - // Backward compatibility: if no raw file but has DB envs, generate raw content - if (!contentToSave.trim() && definedVars.length > 0) { - contentToSave = definedVars.map(v => `${v.key.trim()}=${v.value}`).join('\n') + '\n'; + if (!rawEnvResponse.ok) { + const rawEnvError = await rawEnvResponse.json().catch(() => ({ error: 'Failed to save environment file' })); + throw new Error(rawEnvError.error || 'Failed to save environment file'); } - // Save if there's any content - if (contentToSave.trim() || definedVars.length > 0) { - const rawEnvResponse = await fetch( - appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId), + // Save ALL vars to DB (includes secrets with real values) + const definedVars = prepared.variables; + if (definedVars.length > 0 || hadExistingDbVars) { + const envResponse = await fetch( + appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: contentToSave }) + body: JSON.stringify({ + variables: definedVars.map(v => ({ + key: v.key.trim(), + value: v.value, + isSecret: v.isSecret + })) + }) } ); - if (!rawEnvResponse.ok) { - console.error('Failed to save environment file'); + if (!envResponse.ok) { + // Log but don't fail - DB stores secret metadata + console.warn('Failed to save environment variable metadata to database'); } - // Also save to DB for secret tracking - if (definedVars.some(v => v.isSecret)) { - const envResponse = await fetch( - appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId), - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - variables: definedVars.map(v => ({ - key: v.key.trim(), - value: v.value, - isSecret: v.isSecret - })) - }) - } - ); - - if (!envResponse.ok) { - console.error('Failed to save secret markers to database'); - } - } + hadExistingDbVars = definedVars.length > 0; + existingSecretKeys = new Set( + definedVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim()) + ); } - rawEnvContent = contentToSave; // Sync raw content if it was generated isDirty = false; // Reset dirty flag after successful save onSuccess(); @@ -457,7 +476,11 @@ services: handleClose(); } } catch (e: any) { - error = e.message; + operationError = { + title: restart ? 'Failed to apply stack' : 'Failed to save stack', + message: e.message || (restart ? 'An error occurred while applying the stack' : 'An error occurred while saving the stack'), + details: e.details + }; } finally { saving = false; } @@ -481,16 +504,19 @@ services: newStackName = ''; error = null; loadError = null; + rawEnvContent = ''; errors = {}; composeContent = ''; envVars = []; - rawEnvContent = ''; envValidation = null; isDirty = false; existingSecretKeys = new Set(); + hadExistingDbVars = false; activeTab = 'editor'; showConfirmClose = false; codeEditorRef = null; + operationError = null; + stackLocation = null; onClose(); } @@ -575,6 +601,11 @@ services: {#if mode === 'create'} Create a new Docker Compose stack + {:else if stackLocation} + + + {stackLocation} + {:else} Edit compose file and view stack structure {/if} @@ -629,13 +660,6 @@ services:
- {#if error} - - - {error} - - {/if} - {#if errors.compose} @@ -757,6 +781,7 @@ services:
+ + +{#if operationError} + {@const errorDialogOpen = true} + operationError = null} + /> +{/if}