fable-agent/src/core/cli-runner.ts

61 lines
1.9 KiB
TypeScript

import { spawn } from "node:child_process";
const MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
export interface RunCliOptions {
stdin?: string;
windowsCmdShim?: boolean;
}
function isCmdShim(bin: string, force = false): boolean {
return process.platform === "win32" && (force || /(?:\.cmd|\.bat)$/i.test(bin));
}
export function runCli(
bin: string,
args: string[],
timeoutMs = 120_000,
options: RunCliOptions = {},
): Promise<string> {
return new Promise((resolve) => {
// ponytail: only Windows command shims need cmd.exe; real .exe paths keep argv semantics.
const command = isCmdShim(bin, options.windowsCmdShim) ? process.env.ComSpec ?? "cmd.exe" : bin;
const commandArgs = isCmdShim(bin, options.windowsCmdShim) ? ["/d", "/s", "/c", bin, ...args] : args;
const child = spawn(command, commandArgs, {
shell: false,
timeout: timeoutMs,
windowsHide: true,
});
let stdout = "";
let stderr = "";
let killedForOutput = false;
const append = (target: "stdout" | "stderr", chunk: Buffer) => {
if (stdout.length + stderr.length + chunk.length > MAX_OUTPUT_BYTES) {
killedForOutput = true;
child.kill();
return;
}
if (target === "stdout") stdout += chunk.toString();
else stderr += chunk.toString();
};
child.stdout.on("data", (d) => append("stdout", d));
child.stderr.on("data", (d) => append("stderr", d));
child.on("error", (err) => resolve(`[CLI error: ${err.message.slice(0, 200)}]`));
child.on("close", (code) => {
if (killedForOutput) {
resolve(`[CLI error: output exceeded ${MAX_OUTPUT_BYTES} bytes]`);
return;
}
if (code === 0) {
resolve(stdout.trim());
return;
}
resolve(`[CLI exited ${code ?? "unknown"}: ${(stderr || stdout).slice(0, 400) || "no output"}]`);
});
if (options.stdin !== undefined) child.stdin.end(options.stdin);
});
}