import fs from "fs/promises"; import path from "path"; import { spawn } from "child_process"; import { Transport, AbortError, CommandFailedError, MAX_SHELL_OUTPUT_LENGTH, ShellOutput, TransportError, } from "./transport-common.ts"; export class LocalTransport implements Transport { cwd = process.cwd(); async close() {} async writeFile(_: AbortSignal, file: string, contents: string) { return await fs.writeFile(file, contents, "utf8"); } async readFile(_: AbortSignal, file: string) { return await fs.readFile(file, "utf8"); } async modTime(_: AbortSignal, file: string) { try { const stat = await fs.stat(file); return stat.mtimeMs; } catch (e) { throw new TransportError(`Could not get modified time for ${file}: ${e}`); } } async resolvePath(_: AbortSignal, file: string) { try { return await fs.realpath(file); } catch { return path.resolve(file); } } async mkdir(_: AbortSignal, dirpath: string) { await fs.mkdir(dirpath, { recursive: true }); } async readdir(_: AbortSignal, dirpath: string) { const entries = await fs.readdir(dirpath, { withFileTypes: true }); return Promise.all( entries.map(async entry => { // For symlinks, resolve to determine if target is a directory if (entry.isSymbolicLink()) { const fullPath = path.join(dirpath, entry.name); try { const stat = await fs.stat(fullPath); // follows symlinks return { entry: entry.name, isDirectory: stat.isDirectory() }; } catch { // Broken symlink or permission error - treat as file return { entry: entry.name, isDirectory: false }; } } return { entry: entry.name, isDirectory: entry.isDirectory() }; }), ); } async pathExists(signal: AbortSignal, file: string) { try { await this.modTime(signal, file); return true; } catch { return false; } } async isDirectory(_: AbortSignal, file: string) { try { const stat = await fs.stat(file); return stat.isDirectory(); } catch { return false; } } async shell(signal: AbortSignal, cmd: string, timeout: number) { return new Promise((resolve, reject) => { const env = { ...process.env }; delete env["NODE_ENV"]; const child = spawn(cmd, { cwd: process.cwd(), shell: "bash", stdio: ["ignore", "pipe", "pipe"], detached: true, env, }); const output = new ShellOutput(); let aborted = false; let timedOut = false; let killed = false; function killGroup() { if (killed) return; killed = true; // Kill the entire process group to handle child processes try { // First, try to kill the process group with SIGTERM process.kill(-child.pid!, "SIGTERM"); } catch (e) { // Fallback to just killing the main process if process group doesn't exist child.kill("SIGTERM"); } // Fallback to SIGKILL if it doesn't exit quickly setTimeout(() => { try { process.kill(-child.pid!, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch {} } }, 500).unref?.(); } function onAbort() { aborted = true; killGroup(); } function cleanup() { signal.removeEventListener("abort", onAbort); clearTimeout(timeoutHandler); } const timeoutHandler = setTimeout(() => { timedOut = true; killGroup(); }, timeout); if (signal.aborted) onAbort(); signal.addEventListener("abort", onAbort); child.stdout.on("data", data => { if (!output.append(data)) killGroup(); }); child.stderr.on("data", data => { if (!output.append(data)) killGroup(); }); child.on("close", code => { cleanup(); if (aborted) { reject(new AbortError()); return; } const commandOutput = output.getOutput(); if (commandOutput == null) { reject( new CommandFailedError( `Command output exceeded the ${MAX_SHELL_OUTPUT_LENGTH} character limit and was terminated.`, ), ); return; } if (timedOut) { reject( new CommandFailedError( `Command timed out. output: ${commandOutput}`, ), ); return; } if (code === 0) { resolve(commandOutput); } else { if (code == null) { reject( new CommandFailedError( `Command killed by signal. output: ${commandOutput}`, ), ); } else { reject( new CommandFailedError( `Command exited with code: ${code} output: ${commandOutput}`, code, ), ); } } }); child.on("error", err => { cleanup(); if (aborted) { reject(new AbortError()); return; } reject(new CommandFailedError(`Command failed: ${err.message}`)); }); }); } }