This commit is contained in:
jarek committed 2026-01-02 12:24:43 +01:00
1 parent 659d074d00
commit 607d340b71
15 files changed
+619 -375

No files matched your search

+17
View File
@@ -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 }
});
}
}
});
</script>
<div
+121 -81
View File
@@ -1,15 +1,15 @@
<script lang="ts">
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<string[]>([]);
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<string>();
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 @@
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR:-default}`}</span> optional</span>
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR:?error}`}</span> required w/ error</span>
</div>
{:else}
<div class="text-2xs text-zinc-400 dark:text-zinc-500">
Raw .env file (comments preserved, saved exactly as typed)
{:else if secretCount > 0}
<!-- Text view hint about secrets (only shown when secrets exist) -->
<div class="flex items-start gap-2 px-2 py-1.5 rounded bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
<ShieldAlert class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
<div class="text-2xs text-amber-700 dark:text-amber-300">
<span class="font-medium">{secretCount} secret{secretCount === 1 ? '' : 's'} not shown.</span>
<span class="text-amber-600 dark:text-amber-400">Secrets are never written to disk and are injected via shell environment when the stack starts.</span>
</div>
</div>
{/if}
<!-- Parse warnings (form mode only) -->
+1
View File
@@ -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" },
+33
View File
@@ -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<Record<string, string>> {
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<Record<string, string>> {
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.
+122 -43
View File
@@ -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<string, string>,
secretVars?: Record<string, string>,
forceRecreate?: boolean,
removeVolumes?: boolean
): Promise<StackOperationResult> {
@@ -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<string, string> = { ...(process.env as Record<string, string>) };
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<string, string>,
secretVars?: Record<string, string>,
forceRecreate?: boolean,
removeVolumes?: boolean,
stackFiles?: Record<string, string>
@@ -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<string, string> = { ...(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<string, string>
envVars?: Record<string, string>,
secretVars?: Record<string, string>
): Promise<StackOperationResult> {
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<string, string> }> {
): Promise<{ content: string; envVars: Record<string, string>; secretVars: Record<string, string> }> {
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<string, string> = {};
@@ -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<StackOperationResult> {
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<StackOperationResult> {
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<StackOperationResult> {
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<StackOperationResult> {
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<void> {
// 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)
*