diff --git a/README.md b/README.md index c3b6056e..63d3ff88 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,8 @@ Good when you want to see drift across the whole branch, edit one variable witho `capy run -- ` decrypts `.env` in memory and spawns your command with the values set as environment variables. Works in any runtime that reads env vars. See [docs.capy.sc/using/running-your-app](https://docs.capy.sc/using/running-your-app). +**Docker Compose:** `capy run -- docker compose up` works with an unchanged `env_file: .env`. Capy passes compose an extra override listing only the decrypted variable *names* for the services that read `.env`, so compose forwards the real values into those containers. Nothing is written to disk. + ## Deploying `capy deploy` walks through Vercel, Cloudflare, Docker, Fly, Railway, Render, Heroku, GitHub Actions, and AWS Lambda. See [docs.capy.sc/using/deploying](https://docs.capy.sc/using/deploying). diff --git a/src/commands/composeForward.ts b/src/commands/composeForward.ts new file mode 100644 index 00000000..af594653 --- /dev/null +++ b/src/commands/composeForward.ts @@ -0,0 +1,174 @@ +import { spawnSync } from 'child_process'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { basename, delimiter, join, resolve } from 'path'; + +/** + * `capy run -- docker compose …` support. + * + * Containers don't inherit the environment of the `docker compose` process: + * `env_file: .env` makes compose read the file on disk — ciphertext — so the + * values `capy run` decrypted never reach the container. Compose only forwards + * its own environment for `environment:` entries that name a variable without + * a value. So we hand compose one extra file, listing only the NAMES of the + * decrypted vars for every service whose `env_file` is the Capy-managed `.env`: + * + * {"services":{"app":{"environment":["DB_PASSWORD","APP_KEY"]}}} + * + * (JSON is valid YAML.) No values are ever written: compose resolves each name + * from the environment `capy run` gives it. On POSIX the override is piped in + * on fd 3 (`-f /dev/fd/3`), keeping stdin free for `run`/`exec`; on Windows, + * which has no /dev/fd, it goes in a temp file removed when the child exits. + */ + +/** Compose's global flags that take a value (`-f x` or `--file=x`). */ +const VALUE_FLAGS = new Set([ + '-f', '--file', '-p', '--project-name', '--project-directory', '--env-file', + '--profile', '--ansi', '--parallel', '--progress', +]); + +/** Default file names, in compose's own lookup order. */ +const DEFAULT_FILES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']; + +/** Where the override goes when it's piped rather than written. */ +export const OVERRIDE_FD = 3; + +export interface ComposeInvocation { + /** argv up to and including `compose` (`['docker','compose']` or `['docker-compose']`). */ + readonly head: readonly string[]; + /** Global flags between `compose` and the subcommand. */ + readonly globals: readonly string[]; + /** The subcommand and everything after it. */ + readonly rest: readonly string[]; +} + +/** Splits `docker compose …` / `docker-compose …` argv; undefined for anything else. */ +export function parseComposeArgs(args: readonly string[]): ComposeInvocation | undefined { + const bin = args[0] === undefined ? '' : basename(args[0]).replace(/\.exe$/i, ''); + const headLen = bin === 'docker' && args[1] === 'compose' ? 2 : bin === 'docker-compose' ? 1 : 0; + if (headLen === 0) return undefined; + const after = args.slice(headLen); + // `-f=x` / `--file=x` carry their own value, so only a bare value flag eats the next arg. + const subIdx = after.findIndex((a, i) => !a.startsWith('-') && !VALUE_FLAGS.has(after[i - 1] ?? '')); + const split = subIdx === -1 ? after.length : subIdx; + return { head: args.slice(0, headLen), globals: after.slice(0, split), rest: after.slice(split) }; +} + +/** Values of a global flag, in order (`-f a --file=b` → `['a','b']`). */ +function flagValues(globals: readonly string[], names: readonly string[]): string[] { + return globals.flatMap((a, i) => { + if (names.includes(a)) return globals[i + 1] === undefined ? [] : [globals[i + 1]]; + const prefix = names.filter((n) => n.startsWith('--')).map((n) => `${n}=`).find((p) => a.startsWith(p)); + return prefix ? [a.slice(prefix.length)] : []; + }); +} + +/** + * The files compose would load without our extra `-f` — because once any `-f` + * is passed, compose stops discovering defaults (including the override file). + * Undefined when we can't tell, in which case the caller leaves argv alone. + */ +export function resolveComposeFiles( + globals: readonly string[], + cwd: string, + env: NodeJS.ProcessEnv, + exists: (p: string) => boolean = existsSync, +): string[] | undefined { + const explicit = flagValues(globals, ['-f', '--file']); + if (explicit.length > 0) return explicit; + if (env.COMPOSE_FILE) return env.COMPOSE_FILE.split(env.COMPOSE_PATH_SEPARATOR || delimiter).filter(Boolean); + if (flagValues(globals, ['--project-directory']).length > 0) return undefined; + const main = DEFAULT_FILES.find((f) => exists(join(cwd, f))); + if (!main) return undefined; + const override = main.replace(/\.(ya?ml)$/, '.override.$1'); + return exists(join(cwd, override)) ? [main, override] : [main]; +} + +/** Service name → its `env_file` entries, from `docker compose config --no-interpolate`. */ +export type ComposeEnvFiles = Record; + +/** + * Asks compose itself which services read which env files, so we never parse + * YAML. `--no-interpolate` keeps `env_file` instead of inlining its values. + */ +export function readComposeEnvFiles(head: readonly string[], globals: readonly string[], cwd: string): ComposeEnvFiles | undefined { + const r = spawnSync(head[0], [...head.slice(1), ...globals, 'config', '--format', 'json', '--no-interpolate'], { + cwd, + encoding: 'utf-8', + shell: process.platform === 'win32', + }); + if (r.status !== 0 || !r.stdout) return undefined; + try { + const services: Record = JSON.parse(r.stdout).services ?? {}; + return Object.fromEntries( + Object.entries(services).map(([name, svc]) => { + const raw = svc.env_file === undefined ? [] : Array.isArray(svc.env_file) ? svc.env_file : [svc.env_file]; + const paths = raw.map((e) => (typeof e === 'string' ? e : (e as { path?: string })?.path)).filter((p): p is string => typeof p === 'string'); + return [name, paths]; + }), + ); + } catch { + return undefined; + } +} + +/** The override body: decrypted var NAMES for each service whose env_file is `envPath`. */ +export function buildOverride(envFiles: ComposeEnvFiles, envPath: string, keys: readonly string[], cwd: string): string | undefined { + const target = resolve(envPath); + const services = Object.keys(envFiles).filter((s) => envFiles[s].some((p) => resolve(cwd, p) === target)); + if (services.length === 0 || keys.length === 0) return undefined; + const sorted = [...keys].sort(); + return JSON.stringify({ services: Object.fromEntries(services.map((s) => [s, { environment: sorted }])) }); +} + +export interface ComposePlan { + /** argv to spawn instead of the user's. */ + readonly args: string[]; + /** Override body to write to fd {@link OVERRIDE_FD}, when piped. */ + readonly pipe?: string; + /** Removes the temp file, when not piped. */ + readonly cleanup: () => void; +} + +export interface ComposeDeps { + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + readonly platform: NodeJS.Platform; + readonly exists?: (p: string) => boolean; + readonly readEnvFiles?: typeof readComposeEnvFiles; +} + +/** + * Rewrites `docker compose …` so services reading `envPath` get the decrypted + * `keys` forwarded from capy's environment. Undefined = run argv unchanged. + */ +export function planComposeForward( + args: readonly string[], + envPath: string, + keys: readonly string[], + deps: ComposeDeps, +): ComposePlan | undefined { + const inv = parseComposeArgs(args); + if (!inv) return undefined; + const files = resolveComposeFiles(inv.globals, deps.cwd, deps.env, deps.exists); + if (!files) return undefined; + const envFiles = (deps.readEnvFiles ?? readComposeEnvFiles)(inv.head, inv.globals, deps.cwd); + if (!envFiles) return undefined; + const body = buildOverride(envFiles, envPath, keys, deps.cwd); + if (!body) return undefined; + + // Our -f goes last so it merges over the user's files; the user's own -f + // flags (if any) stay where they were, otherwise we name the defaults. + const userFiles = flagValues(inv.globals, ['-f', '--file']).length > 0 ? [] : files.flatMap((f) => ['-f', f]); + const build = (overridePath: string): string[] => [ + ...inv.head, ...inv.globals, ...userFiles, '-f', overridePath, ...inv.rest, + ]; + + if (deps.platform !== 'win32') { + return { args: build(`/dev/fd/${OVERRIDE_FD}`), pipe: body, cleanup: () => undefined }; + } + const dir = mkdtempSync(join(tmpdir(), 'capy-compose-')); + const file = join(dir, 'override.json'); + writeFileSync(file, body, { mode: 0o600 }); + return { args: build(file), cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} diff --git a/src/commands/runCommand.ts b/src/commands/runCommand.ts index 7c38debc..47e3e73e 100644 --- a/src/commands/runCommand.ts +++ b/src/commands/runCommand.ts @@ -7,6 +7,7 @@ import { getShellPinnedEnv } from '../config/prodPins'; import { resolveActiveUrl } from '../config/profileConfig'; import type { ParsedSecretsBlob } from '../crypto/deployRuntime'; import { ERROR_CODES } from '../types/index'; +import { planComposeForward, OVERRIDE_FD } from './composeForward'; /** * Writes `.capy/next-env.js`, a CommonJS module mapping each decrypted env var @@ -46,15 +47,25 @@ interface ChildExit { * is started; `spawnChild` is its exit-code-only face for the callers that * have nothing to settle afterwards. */ -function spawnChildDetailed(args: string[], env: Record): Promise { +function spawnChildDetailed( + args: string[], + env: Record, + pipe?: string, +): Promise { // On Windows, node_modules/.bin entries are .cmd shims that Node's spawn // won't execute without a shell — bare `cross-env`/`nodemon` fail with // ENOENT. shell:true delegates to cmd.exe so PATHEXT resolution kicks in. + // `pipe` is written to the child's fd OVERRIDE_FD (composeForward.ts). const child: ChildProcess = spawn(args[0], args.slice(1), { env: env as Record, - stdio: 'inherit', + stdio: pipe === undefined ? 'inherit' : ['inherit', 'inherit', 'inherit', 'pipe'], shell: process.platform === 'win32', }); + if (pipe !== undefined) { + const fd = child.stdio[OVERRIDE_FD] as NodeJS.WritableStream | null; + fd?.on('error', () => undefined); // child exited without reading it + fd?.end(pipe); + } // Resolves (once) the first time a signal is forwarded. Never rejects. const forwarded = new Promise((resolve) => { @@ -101,8 +112,9 @@ export async function runChildThenSettle( args: string[], env: Record, reported: Promise | undefined, + pipe?: string, ): Promise { - const exit = await spawnChildDetailed(args, env); + const exit = await spawnChildDetailed(args, env, pipe); if (reported !== undefined && !exit.interrupted) await reported.catch(() => undefined); return exit.code; } @@ -487,5 +499,19 @@ export async function runCommand(args: string[], devMode: boolean = false): Prom } const decryptedEnv = Object.fromEntries(decryptedResult.value); - return runChildThenSettle(args, { ...env, ...decryptedEnv }, projectKeyResult.reported); + + // `docker compose` doesn't pass its environment into containers, so + // `env_file: .env` would hand them ciphertext. Forward the decrypted names + // to the services that read this .env — see composeForward.ts. + const compose = planComposeForward(args, join(process.cwd(), '.env'), Object.keys(decryptedEnv), { + cwd: process.cwd(), + env: process.env, + platform: process.platform, + }); + if (compose) debug(`capy run: compose override → ${compose.args.join(' ')}`); + try { + return await runChildThenSettle(compose?.args ?? args, { ...env, ...decryptedEnv }, projectKeyResult.reported, compose?.pipe); + } finally { + compose?.cleanup(); + } } diff --git a/tests/commands/composeForward.test.ts b/tests/commands/composeForward.test.ts new file mode 100644 index 00000000..722631c2 --- /dev/null +++ b/tests/commands/composeForward.test.ts @@ -0,0 +1,114 @@ +/** + * `capy run -- docker compose …`: containers don't inherit compose's + * environment, so `env_file: .env` hands them ciphertext. planComposeForward + * adds a names-only override so compose forwards the decrypted values. + */ +import { describe, test, expect } from 'bun:test'; +import { existsSync, readFileSync } from 'fs'; +import { + parseComposeArgs, + resolveComposeFiles, + buildOverride, + planComposeForward, + type ComposeDeps, +} from '../../src/commands/composeForward'; + +const CWD = '/app'; +const ENV = '/app/.env'; + +function deps(over: Partial = {}): ComposeDeps { + return { + cwd: CWD, + env: {}, + platform: 'darwin', + exists: (p) => p === '/app/docker-compose.yml', + readEnvFiles: () => ({ app: ['/app/.env'], queue: ['.env'], db: [], web: ['/app/web.env'] }), + ...over, + }; +} + +describe('parseComposeArgs', () => { + test('splits docker compose argv into head, globals and the subcommand', () => { + expect(parseComposeArgs(['docker', 'compose', '-f', 'a.yml', '-p', 'x', 'run', '--rm', 'app', 'php'])).toEqual({ + head: ['docker', 'compose'], + globals: ['-f', 'a.yml', '-p', 'x'], + rest: ['run', '--rm', 'app', 'php'], + }); + }); + + test('handles the standalone docker-compose binary and --flag=value', () => { + expect(parseComposeArgs(['docker-compose', '--file=a.yml', 'up'])).toEqual({ + head: ['docker-compose'], + globals: ['--file=a.yml'], + rest: ['up'], + }); + }); + + test('ignores anything that is not compose', () => { + expect(parseComposeArgs(['docker', 'run', 'alpine'])).toBeUndefined(); + expect(parseComposeArgs(['php', 'artisan', 'serve'])).toBeUndefined(); + }); +}); + +describe('resolveComposeFiles', () => { + test('explicit -f flags win', () => { + expect(resolveComposeFiles(['-f', 'a.yml', '--file=b.yml'], CWD, {})).toEqual(['a.yml', 'b.yml']); + }); + + test('COMPOSE_FILE is used when no -f is given', () => { + expect(resolveComposeFiles([], CWD, { COMPOSE_FILE: 'a.yml:b.yml', COMPOSE_PATH_SEPARATOR: ':' })).toEqual(['a.yml', 'b.yml']); + }); + + test('defaults keep the override file compose would have loaded', () => { + const exists = (p: string) => p === '/app/compose.yaml' || p === '/app/compose.override.yaml'; + expect(resolveComposeFiles([], CWD, {}, exists)).toEqual(['compose.yaml', 'compose.override.yaml']); + }); + + test('gives up rather than guess', () => { + expect(resolveComposeFiles([], CWD, {}, () => false)).toBeUndefined(); + expect(resolveComposeFiles(['--project-directory', 'api'], CWD, {}, () => true)).toBeUndefined(); + }); +}); + +describe('buildOverride', () => { + test('lists names only, and only for services reading the Capy .env', () => { + const body = buildOverride({ app: ['/app/.env'], queue: ['.env'], db: [] }, ENV, ['DB_PASSWORD', 'APP_KEY'], CWD); + expect(JSON.parse(body as string)).toEqual({ + services: { + app: { environment: ['APP_KEY', 'DB_PASSWORD'] }, + queue: { environment: ['APP_KEY', 'DB_PASSWORD'] }, + }, + }); + }); + + test('nothing to do when no service reads the .env', () => { + expect(buildOverride({ db: [] }, ENV, ['X'], CWD)).toBeUndefined(); + }); +}); + +describe('planComposeForward', () => { + test('POSIX: names the default file, appends the override on fd 3, never writes a value', () => { + const plan = planComposeForward(['docker', 'compose', 'up', '-d'], ENV, ['DB_PASSWORD'], deps()); + expect(plan?.args).toEqual(['docker', 'compose', '-f', 'docker-compose.yml', '-f', '/dev/fd/3', 'up', '-d']); + expect(JSON.parse(plan?.pipe as string).services.app.environment).toEqual(['DB_PASSWORD']); + expect(plan?.pipe).not.toContain('supersecret'); + }); + + test("keeps the user's own -f flags and puts the override last", () => { + const plan = planComposeForward(['docker', 'compose', '-f', 'interp.yml', 'run', 'app'], ENV, ['K'], deps()); + expect(plan?.args).toEqual(['docker', 'compose', '-f', 'interp.yml', '-f', '/dev/fd/3', 'run', 'app']); + }); + + test('Windows: writes a names-only temp file and cleans it up', () => { + const plan = planComposeForward(['docker', 'compose', 'up'], ENV, ['K'], deps({ platform: 'win32' })); + const file = plan?.args[plan.args.indexOf('up') - 1] as string; + expect(plan?.pipe).toBeUndefined(); + expect(JSON.parse(readFileSync(file, 'utf-8')).services.app.environment).toEqual(['K']); + plan?.cleanup(); + expect(existsSync(file)).toBe(false); + }); + + test('leaves argv alone when compose config cannot be read', () => { + expect(planComposeForward(['docker', 'compose', 'up'], ENV, ['K'], deps({ readEnvFiles: () => undefined }))).toBeUndefined(); + }); +}); diff --git a/tests/commands/runCommandSystemStore.test.ts b/tests/commands/runCommandSystemStore.test.ts index 3178361e..3fbea948 100644 --- a/tests/commands/runCommandSystemStore.test.ts +++ b/tests/commands/runCommandSystemStore.test.ts @@ -27,6 +27,8 @@ mock.module('child_process', () => ({ }; return child; }, + // composeForward.ts (imported by runCommand) reads `docker compose config` with it. + spawnSync: () => ({ status: 1, stdout: '' }), ChildProcess: class {}, }));