import { execFile } from "node:child_process"; /** * Minimal child-process runner shared by the stages. Array-args only — * never a shell — so URLs and paths cannot be interpreted, and secrets * can only travel via `env` (spec §6/§10). */ export interface CommandResult { exitCode: number; stdout: string; stderr: string; } export type RunCommand = ( command: string, args: string[], options: { cwd: string; env?: Record }, ) => Promise; const MAX_OUTPUT_BYTES = 64 * 1024 * 1024; /** Ten minutes: covers a cold `npm install` of a real repo; prevents hangs. */ const COMMAND_TIMEOUT_MS = 10 * 60 * 1000; export const runCommand: RunCommand = (command, args, options) => new Promise((resolve, reject) => { execFile( command, args, { cwd: options.cwd, env: { ...process.env, ...options.env }, maxBuffer: MAX_OUTPUT_BYTES, timeout: COMMAND_TIMEOUT_MS, }, (error, stdout, stderr) => { if (error === null) { resolve({ exitCode: 0, stdout, stderr }); return; } // Non-zero exit is a result, not an exception — stages map it to // named protocol errors. Spawn-level failures still reject. const exitCode = typeof error.code === "number" ? error.code : null; if (exitCode !== null) { resolve({ exitCode, stdout, stderr }); return; } if (error.killed === true) { resolve({ exitCode: 124, stdout, stderr: `${stderr}\n(timed out)` }); return; } reject(error); }, ); });