import { type ChildProcess as ChildProcessHandle, spawn, spawnSync } from "node:child_process"; export interface ProcessRunOptions { cwd?: string | undefined; timeoutMs?: number | undefined; env?: NodeJS.ProcessEnv | undefined; stdin?: string | undefined; allowNonZeroExit?: boolean | undefined; maxBufferBytes?: number | undefined; outputMode?: "error" | "truncate" | undefined; } export interface ProcessRunResult { stdout: string; stderr: string; code: number | null; signal: NodeJS.Signals | null; timedOut: boolean; stdoutTruncated?: boolean | undefined; stderrTruncated?: boolean | undefined; } function commandLabel(command: string, args: readonly string[]): string { return [command, ...args].join(" "); } function normalizeSpawnError(command: string, args: readonly string[], error: unknown): Error { if (!(error instanceof Error)) { return new Error(`Failed to run ${commandLabel(command, args)}.`); } const maybeCode = (error as NodeJS.ErrnoException).code; if (maybeCode === "ENOENT") { return new Error(`Command not found: ${command}`); } return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`); } function isWindowsCommandNotFound(code: number | null, stderr: string): boolean { if (process.platform !== "win32") return false; if (code === 9009) return true; return /is not recognized as an internal or external command/i.test(stderr); } function normalizeExitError( command: string, args: readonly string[], result: ProcessRunResult, ): Error { if (isWindowsCommandNotFound(result.code, result.stderr)) { return new Error(`Command not found: ${command}`); } const reason = result.timedOut ? "timed out" : `failed (code=${result.code ?? "null"}, signal=${result.signal ?? "null"})`; const stderr = result.stderr.trim(); const detail = stderr.length > 0 ? ` ${stderr}` : ""; return new Error(`${commandLabel(command, args)} ${reason}.${detail}`); } function normalizeStdinError(command: string, args: readonly string[], error: unknown): Error { if (!(error instanceof Error)) { return new Error(`Failed to write stdin for ${commandLabel(command, args)}.`); } return new Error(`Failed to write stdin for ${commandLabel(command, args)}: ${error.message}`); } function normalizeBufferError( command: string, args: readonly string[], stream: "stdout" | "stderr", maxBufferBytes: number, ): Error { return new Error( `${commandLabel(command, args)} exceeded ${stream} buffer limit (${maxBufferBytes} bytes).`, ); } const DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024; /** * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe` * wrapper, leaving the actual command running. Use `taskkill /T` to kill the * entire process tree instead. */ function killChild(child: ChildProcessHandle, signal: NodeJS.Signals = "SIGTERM"): void { if (process.platform === "win32" && child.pid !== undefined) { try { spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); return; } catch { // fallback to direct kill } } child.kill(signal); } function appendChunkWithinLimit( target: string, currentBytes: number, chunk: Buffer, maxBytes: number, ): { next: string; nextBytes: number; truncated: boolean; } { const remaining = maxBytes - currentBytes; if (remaining <= 0) { return { next: target, nextBytes: currentBytes, truncated: true }; } if (chunk.length <= remaining) { return { next: `${target}${chunk.toString()}`, nextBytes: currentBytes + chunk.length, truncated: false, }; } return { next: `${target}${chunk.subarray(0, remaining).toString()}`, nextBytes: currentBytes + remaining, truncated: true, }; } export async function runProcess( command: string, args: readonly string[], options: ProcessRunOptions = {}, ): Promise { const timeoutMs = options.timeoutMs ?? 60_000; const maxBufferBytes = options.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; const outputMode = options.outputMode ?? "error"; return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: "pipe", shell: process.platform === "win32", }); let stdout = ""; let stderr = ""; let stdoutBytes = 0; let stderrBytes = 0; let stdoutTruncated = false; let stderrTruncated = false; let timedOut = false; let settled = false; let forceKillTimer: ReturnType | null = null; const timeoutTimer = setTimeout(() => { timedOut = true; killChild(child, "SIGTERM"); forceKillTimer = setTimeout(() => { killChild(child, "SIGKILL"); }, 1_000); }, timeoutMs); const finalize = (callback: () => void): void => { if (settled) return; settled = true; clearTimeout(timeoutTimer); if (forceKillTimer) { clearTimeout(forceKillTimer); } callback(); }; const fail = (error: Error): void => { killChild(child, "SIGTERM"); finalize(() => { reject(error); }); }; const appendOutput = (stream: "stdout" | "stderr", chunk: Buffer | string): Error | null => { const chunkBuffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk; const text = chunkBuffer.toString(); const byteLength = chunkBuffer.length; if (stream === "stdout") { if (outputMode === "truncate") { const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunkBuffer, maxBufferBytes); stdout = appended.next; stdoutBytes = appended.nextBytes; stdoutTruncated = stdoutTruncated || appended.truncated; return null; } stdout += text; stdoutBytes += byteLength; if (stdoutBytes > maxBufferBytes) { return normalizeBufferError(command, args, "stdout", maxBufferBytes); } } else { if (outputMode === "truncate") { const appended = appendChunkWithinLimit(stderr, stderrBytes, chunkBuffer, maxBufferBytes); stderr = appended.next; stderrBytes = appended.nextBytes; stderrTruncated = stderrTruncated || appended.truncated; return null; } stderr += text; stderrBytes += byteLength; if (stderrBytes > maxBufferBytes) { return normalizeBufferError(command, args, "stderr", maxBufferBytes); } } return null; }; child.stdout.on("data", (chunk: Buffer | string) => { const error = appendOutput("stdout", chunk); if (error) { fail(error); } }); child.stderr.on("data", (chunk: Buffer | string) => { const error = appendOutput("stderr", chunk); if (error) { fail(error); } }); child.once("error", (error) => { finalize(() => { reject(normalizeSpawnError(command, args, error)); }); }); child.once("close", (code, signal) => { const result: ProcessRunResult = { stdout, stderr, code, signal, timedOut, stdoutTruncated, stderrTruncated, }; finalize(() => { if (!options.allowNonZeroExit && (timedOut || (code !== null && code !== 0))) { reject(normalizeExitError(command, args, result)); return; } resolve(result); }); }); child.stdin.once("error", (error) => { fail(normalizeStdinError(command, args, error)); }); if (options.stdin !== undefined) { child.stdin.write(options.stdin, (error) => { if (error) { fail(normalizeStdinError(command, args, error)); return; } child.stdin.end(); }); return; } child.stdin.end(); }); }