Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ Good when you want to see drift across the whole branch, edit one variable witho

`capy run -- <cmd>` 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).
Expand Down
174 changes: 174 additions & 0 deletions src/commands/composeForward.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>;

/**
* 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<string, { env_file?: unknown }> = 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 }) };
}
34 changes: 30 additions & 4 deletions src/commands/runCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, string | undefined>): Promise<ChildExit> {
function spawnChildDetailed(
args: string[],
env: Record<string, string | undefined>,
pipe?: string,
): Promise<ChildExit> {
// 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<string, string>,
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<boolean>((resolve) => {
Expand Down Expand Up @@ -101,8 +112,9 @@ export async function runChildThenSettle(
args: string[],
env: Record<string, string | undefined>,
reported: Promise<unknown> | undefined,
pipe?: string,
): Promise<number> {
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;
}
Expand Down Expand Up @@ -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();
}
}
114 changes: 114 additions & 0 deletions tests/commands/composeForward.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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();
});
});
2 changes: 2 additions & 0 deletions tests/commands/runCommandSystemStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {},
}));

Expand Down
Loading