import { spawn as nodeSpawn } from "node:child_process"; import { access, readdir, readFile, stat, writeFile } from "node:fs/promises"; import type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from "./backend.js"; /** * LocalExecBackend — wraps `node:fs/promises` + `node:child_process`. * * Behavior is IDENTICAL to today's local tools: * - `spawn` mirrors `realSpawn` in `packages/tool-shell/src/spawn.ts` — same * `sh -c` invocation, detached process-group kill on abort/timeout, * close-based resolution, and spawn-error → `{ exitCode: 1 }`. * - `readFile`/`writeFile`/`stat`/`readdir` use the same `node:fs/promises` * calls (utf8, `withFileTypes`) the tools make inline today, and throw the * same node errors (carrying `.code`) so the tools' existing error branches * work unchanged. * - `exists` swallows all errors and returns `false` (an existence check). * * This factors the inline node calls out behind the `ExecBackend` interface so * a remote (SshExecBackend) can swap in transparently. Stateless — safe to * share as a singleton. */ export function createLocalExecBackend(): ExecBackend { return { spawn: localSpawn, readFile: (path) => readFile(path, "utf8"), writeFile: (path, content) => writeFile(path, content, "utf8"), stat: async (path): Promise => { const s = await stat(path); return { isFile: s.isFile(), isDirectory: s.isDirectory() }; }, readdir: async (path): Promise => { const entries = await readdir(path, { encoding: "utf8", withFileTypes: true }); return entries.map((e): DirEntry => ({ name: e.name, isDirectory: e.isDirectory() })); }, exists: async (path): Promise => { try { await access(path); return true; } catch { return false; } }, }; } /** Default singleton — stateless, safe to share across calls. */ export const localExecBackend: ExecBackend = createLocalExecBackend(); /** * Run a shell command locally via `node:child_process`. * * Ported verbatim from `packages/tool-shell/src/spawn.ts` (`realSpawn`) so * behavior is byte-identical: `sh -c `, `detached: true` (own process * group), process-group `SIGKILL` on abort/timeout so a backgrounded grandchild * cannot hold the stdio pipes open, and resolve-once-with-cleanup to avoid * listener/timer leaks. */ function localSpawn(params: SpawnParams): Promise { return new Promise((resolve) => { // detached: true puts the child in its own process group (pgid = child.pid). // This lets us kill the entire group (child + any grandchildren that inherit // the pipes) via process.kill(-pgid, "SIGKILL") on abort/timeout, so a // backgrounded grandchild can't keep the stdio pipes open and stall the // promise on child.on("close"). const child = nodeSpawn("sh", ["-c", params.command], { cwd: params.cwd, stdio: ["ignore", "pipe", "pipe"], detached: true, }); let settled = false; let timedOut = false; let timer: ReturnType | undefined; /** Kill the entire child process group (best-effort — group may be gone). */ const killGroup = () => { if (child.pid !== undefined) { try { process.kill(-child.pid, "SIGKILL"); } catch { // Process group may already be gone — ignore. } } }; /** Remove the abort listener and clear the timeout timer (no leaks). */ const cleanup = () => { if (timer !== undefined) { clearTimeout(timer); timer = undefined; } params.signal.removeEventListener("abort", onAbort); }; /** Resolve once, then clean up so listeners/timers never leak. */ const settle = (result: ExecResult) => { if (settled) return; settled = true; cleanup(); resolve(result); }; const onAbort = () => { if (settled) return; killGroup(); // Resolve immediately — do NOT wait for child.on("close"), which may // never fire if a grandchild holds the pipes open. settle({ exitCode: null, timedOut: false, aborted: true }); }; params.signal.addEventListener("abort", onAbort, { once: true }); timer = setTimeout(() => { if (settled) return; timedOut = true; killGroup(); // Resolve immediately — same reasoning as abort. settle({ exitCode: null, timedOut: true, aborted: false }); }, params.timeout); child.stdout.on("data", (chunk: Buffer) => { params.onOutput(chunk.toString(), "stdout"); }); child.stderr.on("data", (chunk: Buffer) => { params.onOutput(chunk.toString(), "stderr"); }); // Normal-completion path: wait for "close" so all stdout/stderr is captured. // If abort/timeout already settled, this is a no-op (settled === true). child.on("close", (code) => { settle({ exitCode: code, timedOut, aborted: false }); }); // Spawn error (e.g. bad cwd, sh not found). Kill the group just in case // and resolve — never leave the promise pending. child.on("error", () => { killGroup(); settle({ exitCode: 1, timedOut: false, aborted: false }); }); }); }