// spawn.ts — Shared spawn helper with timeout and git safety env vars import { spawn } from "node:child_process"; export interface SpawnResult { stdout: string; stderr: string; code: number | null; killed: boolean; } /** * Spawn a command with timeout and structured error output. * Kills the process on timeout with SIGKILL. * * GIT_TERMINAL_PROMPT=0 is always set — prevents credential prompts from hanging. */ export function spawnWithTimeout( cmd: string, args: string[], opts: { cwd?: string; timeout?: number; env?: Record } = {}, ): Promise { const { cwd, timeout = 60_000, env: extraEnv } = opts; return new Promise((resolve) => { const env = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_TERMINAL_PROMPT_AUTOCHECK: "0", ...extraEnv, }; const proc = spawn(cmd, args, { cwd, env: env as Record }); let stdout = ""; let stderr = ""; let timedOut = false; let settled = false; const finish = (result: SpawnResult) => { if (settled) return; settled = true; clearTimeout(timer); resolve(result); }; const timer = setTimeout(() => { timedOut = true; proc.kill("SIGKILL"); }, timeout); proc.stdout.on("data", (d: Buffer) => (stdout += d)); proc.stderr.on("data", (d: Buffer) => (stderr += d)); proc.on("close", (code) => { finish({ stdout, stderr, code, killed: timedOut }); }); proc.on("error", (err) => { finish({ stdout, stderr: err.message, code: null, killed: timedOut }); }); }); } /** * Spawn and expect exit code 0. Throws structured error on failure. */ export async function spawnExpect( cmd: string, args: string[], opts: { cwd?: string; timeout?: number; env?: Record } = {}, ): Promise { const result = await spawnWithTimeout(cmd, args, opts); if (result.killed) { throw new SpawnTimeoutError(cmd, args, opts.timeout ?? 60_000); } if (result.code !== 0) { throw new SpawnExitError( cmd, args, result.code, result.stderr, result.stdout, ); } return result.stdout; } // ── Error types ──────────────────────────────────────────────────────────── export class SpawnTimeoutError extends Error { constructor( public readonly cmd: string, public readonly args: string[], public readonly timeoutMs: number, ) { super(`Command timed out after ${timeoutMs}ms: ${cmd} ${args.join(" ")}`); this.name = "SpawnTimeoutError"; } } export class SpawnExitError extends Error { constructor( public readonly cmd: string, public readonly args: string[], public readonly exitCode: number | null, public readonly stderr: string, public readonly stdout: string, ) { const stderrLine = stderr.trim().split("\n").pop() ?? ""; super( `Command failed (exit ${exitCode}): ${cmd} ${args.join(" ")}\n${stderrLine}`, ); this.name = "SpawnExitError"; } }