import { execFile } from "node:child_process"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); /** * Every call here runs `docker compose` with `cwd` set to a stack's own * directory (stacks//) rather than passing explicit -f/--env-file * flags — compose already auto-loads `compose.yaml` and `.env` from the * current directory, so a stack behaves identically whether it's started * by this app or by hand from the command line. * * This app's own container needs the Docker socket mounted and the * `docker compose` CLI installed (see manager/Dockerfile) — these calls * are executed by the HOST's dockerd via that socket, not by a nested * Docker daemon inside this container. */ export interface DockerCommandResult { stdout: string; stderr: string; } async function runDockerCompose(stackDir: string, args: string[]): Promise { const result = await execFileAsync("docker", ["compose", ...args], { cwd: stackDir, maxBuffer: 10 * 1024 * 1024, }); return result; } export async function composeUp(stackDir: string, options: { forceRecreate?: boolean } = {}): Promise { const args = ["up", "-d"]; if (options.forceRecreate) args.push("--force-recreate"); await runDockerCompose(stackDir, args); } export async function composeDown(stackDir: string, options: { removeVolumes?: boolean } = {}): Promise { const args = ["down"]; if (options.removeVolumes) args.push("-v"); await runDockerCompose(stackDir, args); } export async function composeRestart(stackDir: string): Promise { await runDockerCompose(stackDir, ["restart"]); } export async function composeLogs(stackDir: string, tailLines = 200): Promise { const result = await runDockerCompose(stackDir, ["logs", "--no-color", "--tail", String(tailLines)]); // Compose writes container logs to stderr, not stdout — this is normal // (docker's own `logs` command does the same), not a failure signal. return result.stdout + result.stderr; } export type StackRunState = "running" | "stopped" | "unknown"; /** Best-effort — a stack that's never been started at all reports "stopped", not an error. */ export async function composeStatus(stackDir: string): Promise { try { const result = await runDockerCompose(stackDir, ["ps", "--status", "running", "--format", "json"]); return result.stdout.trim().length > 0 ? "running" : "stopped"; } catch { return "unknown"; } } export async function composeExec(stackDir: string, serviceName: string, command: string[]): Promise { await runDockerCompose(stackDir, ["exec", "-T", serviceName, ...command]); } /** Applies pending Prisma migrations inside a freshly-started business container — retries briefly since the container's Node process may take a moment to come up after `composeUp`. */ export async function runMigrations(stackDir: string, serviceName: string): Promise { const maxAttempts = 6; let lastError: unknown; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { await composeExec(stackDir, serviceName, ["npx", "prisma", "migrate", "deploy"]); return; } catch (error) { lastError = error; await new Promise((resolve) => setTimeout(resolve, 2000)); } } throw lastError instanceof Error ? lastError : new Error("Migrations failed after retries."); } /** Caddy doesn't watch its sites/ directory — adding or removing a business's .caddy file needs this to take effect. */ export async function reloadCaddy(): Promise { const containerName = process.env.CADDY_CONTAINER_NAME ?? "next-chair-caddy"; await execFileAsync("docker", ["exec", containerName, "caddy", "reload", "--config", "/etc/caddy/Caddyfile"]); }