import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; import { closeSync, createWriteStream, existsSync, openSync, readSync, statSync } from "node:fs"; export interface CommandResult { command: string[]; cwd: string; exitCode: number | null; stdout: string; stderr: string; signal: NodeJS.Signals | null; timedOut: boolean; durationMs?: number; stdoutBytes?: number; stderrBytes?: number; stdoutTruncated?: boolean; stderrTruncated?: boolean; } export interface CommandProgress { elapsedMs: number; timeoutMs: number | null; pid: number | null; stdoutBytes: number; stderrBytes: number; lastOutputAgeMs: number | null; } interface ObservedCommandOptions { timeoutMs?: number; heartbeatMs?: number; killAfterMs?: number; env?: NodeJS.ProcessEnv; input?: string; maxCaptureChars?: number; onProgress?: (progress: CommandProgress) => void; } export function runCommand(command: string[], cwd: string, options: { timeoutMs?: number; env?: NodeJS.ProcessEnv; input?: string } = {}): CommandResult { const result = spawnSync(command[0], command.slice(1), { cwd, encoding: "utf8", env: options.env, input: options.input, maxBuffer: 1024 * 1024 * 8, timeout: options.timeoutMs, }); const error = result.error as (Error & { code?: string }) | undefined; const stderr = result.stderr ?? error?.message ?? ""; return { command, cwd, exitCode: result.status, stdout: result.stdout ?? "", stderr, signal: result.signal, timedOut: error?.code === "ETIMEDOUT", }; } export async function runCommandObserved(command: string[], cwd: string, options: ObservedCommandOptions = {}): Promise { const startedAt = Date.now(); const timeoutMs = options.timeoutMs; const heartbeatMs = Math.max(0, options.heartbeatMs ?? 0); const maxCaptureChars = Math.max(1, options.maxCaptureChars ?? 1024 * 1024); let stdout = ""; let stderr = ""; let stdoutBytes = 0; let stderrBytes = 0; let stdoutTruncated = false; let stderrTruncated = false; let lastOutputAt: number | null = null; let timedOut = false; let timeoutTimer: NodeJS.Timeout | undefined; let killTimer: NodeJS.Timeout | undefined; let heartbeatTimer: NodeJS.Timeout | undefined; const child = spawn(command[0], command.slice(1), { cwd, env: options.env, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], }); child.stdout.on("data", (chunk: Buffer | string) => { const text = chunk.toString(); stdoutBytes += Buffer.byteLength(text); lastOutputAt = Date.now(); const captured = appendBounded(stdout, text, maxCaptureChars); stdout = captured.text; stdoutTruncated = stdoutTruncated || captured.truncated; }); child.stderr.on("data", (chunk: Buffer | string) => { const text = chunk.toString(); stderrBytes += Buffer.byteLength(text); lastOutputAt = Date.now(); const captured = appendBounded(stderr, text, maxCaptureChars); stderr = captured.text; stderrTruncated = stderrTruncated || captured.truncated; }); child.stdin.end(options.input ?? ""); if (heartbeatMs > 0) { heartbeatTimer = setInterval(() => { options.onProgress?.({ elapsedMs: Date.now() - startedAt, timeoutMs: timeoutMs ?? null, pid: child.pid ?? null, stdoutBytes, stderrBytes, lastOutputAgeMs: lastOutputAt === null ? null : Date.now() - lastOutputAt, }); }, heartbeatMs); } if (timeoutMs !== undefined) { timeoutTimer = setTimeout(() => { timedOut = true; killProcessTree(child, "SIGTERM"); killTimer = setTimeout(() => { killProcessTree(child, "SIGKILL"); }, Math.max(100, options.killAfterMs ?? 5_000)); }, timeoutMs); } const completion = await new Promise<{ exitCode: number | null; signal: NodeJS.Signals | null; error?: Error }>((resolve) => { let resolved = false; const finish = (result: { exitCode: number | null; signal: NodeJS.Signals | null; error?: Error }) => { if (resolved) return; resolved = true; resolve(result); }; child.on("error", (error) => finish({ exitCode: 127, signal: null, error })); child.on("close", (code, signal) => finish({ exitCode: code, signal })); }); if (timeoutTimer !== undefined) clearTimeout(timeoutTimer); if (killTimer !== undefined) clearTimeout(killTimer); if (heartbeatTimer !== undefined) clearInterval(heartbeatTimer); if (completion.error !== undefined && stderr.length === 0) { stderr = completion.error.message; stderrBytes = Buffer.byteLength(stderr); } return { command, cwd, exitCode: completion.exitCode, stdout, stderr, signal: completion.signal, timedOut, durationMs: Date.now() - startedAt, stdoutBytes, stderrBytes, stdoutTruncated, stderrTruncated, }; } function appendBounded(current: string, next: string, maxChars: number): { text: string; truncated: boolean } { const combined = `${current}${next}`; if (combined.length <= maxChars) return { text: combined, truncated: false }; return { text: combined.slice(-maxChars), truncated: true }; } function killProcessTree(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): boolean { if (child.pid === undefined) return false; try { if (process.platform === "win32") return child.kill(signal); process.kill(-child.pid, signal); return true; } catch { try { return child.kill(signal); } catch { return false; } } } export function commandOk(command: string[], cwd: string): boolean { return runCommand(command, cwd).exitCode === 0; } export async function runCommandToFiles(command: string[], cwd: string, stdoutFile: string, stderrFile: string): Promise { const stdout = createWriteStream(stdoutFile, { flags: "a" }); const stderr = createWriteStream(stderrFile, { flags: "a" }); stdout.write(`$ ${command.map((part) => JSON.stringify(part)).join(" ")}\n`); const child = spawn(command[0], command.slice(1), { cwd, env: { ...process.env, UNIDESK_JOB_STDOUT_FILE: stdoutFile, UNIDESK_JOB_STDERR_FILE: stderrFile } }); child.stdout.pipe(stdout, { end: false }); child.stderr.pipe(stderr, { end: false }); const exitCode = await new Promise((resolve) => { child.on("close", (code) => resolve(code)); child.on("error", (error) => { stderr.write(`${error.stack ?? error.message}\n`); resolve(127); }); }); stdout.write(`\n[exit_code=${exitCode}]\n`); stdout.end(); stderr.end(); return exitCode; } export function tailFile(path: string, maxBytes = 8192): string { if (!existsSync(path)) return ""; const safeMaxBytes = Math.max(0, Math.floor(maxBytes)); if (safeMaxBytes === 0) return ""; const size = statSync(path).size; const bytesToRead = Math.min(size, safeMaxBytes); const buffer = Buffer.alloc(bytesToRead); const fd = openSync(path, "r"); try { readSync(fd, buffer, 0, bytesToRead, size - bytesToRead); } finally { closeSync(fd); } return buffer.toString("utf8"); }