Project Files
src / executor.ts
import * as child_process from "child_process";
import * as fs from "fs";
import * as os from "os";
export type Platform = "windows" | "macos" | "linux";
export interface ShellInfo {
path: string;
args: string[];
platform: Platform;
}
export interface ExecResult {
stdout: string;
stderr: string;
exitCode: number;
timedOut: boolean;
shell: string;
platform: Platform;
}
export interface ExecOptions {
cwd?: string;
timeoutMs?: number;
shellPath?: string;
env?: Record<string, string>;
}
function detectPlatform(): Platform {
if (process.platform === "win32") return "windows";
if (process.platform === "darwin") return "macos";
return "linux";
}
function resolveShell(override?: string): ShellInfo {
const platform = detectPlatform();
if (override && override.trim()) {
return { path: override.trim(), args: ["-c"], platform };
}
if (platform === "windows") {
const pwshCore = "C:\\Program Files\\PowerShell\\7\\pwsh.exe";
const pwshBuiltin =
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
if (fs.existsSync(pwshCore)) {
return {
path: pwshCore,
args: ["-NoProfile", "-NonInteractive", "-Command"],
platform,
};
}
if (fs.existsSync(pwshBuiltin)) {
return {
path: pwshBuiltin,
args: ["-NoProfile", "-NonInteractive", "-Command"],
platform,
};
}
return { path: "cmd.exe", args: ["/c"], platform };
}
for (const sh of ["/bin/bash", "/usr/bin/bash", "/bin/sh", "/usr/bin/sh"]) {
if (fs.existsSync(sh)) {
return { path: sh, args: ["-c"], platform };
}
}
return { path: "/bin/sh", args: ["-c"], platform };
}
function resolveCwd(cwd?: string): string {
if (!cwd) return os.homedir();
const expanded = cwd.replace(/^~(?=[/\\]|$)/, os.homedir());
try {
if (fs.existsSync(expanded) && fs.statSync(expanded).isDirectory())
return expanded;
} catch {}
return os.homedir();
}
function truncate(text: string, maxBytes: number): string {
const buf = Buffer.from(text, "utf-8");
if (buf.length <= maxBytes) return text;
return (
buf.slice(0, maxBytes).toString("utf-8") +
`\n[truncated - output exceeded ${maxBytes} bytes]`
);
}
function normalizeOutput(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
export function execCommand(
command: string,
options: ExecOptions = {},
): Promise<ExecResult> {
return new Promise((resolve) => {
const shellInfo = resolveShell(options.shellPath);
const cwd = resolveCwd(options.cwd);
const timeoutMs = Math.min(
options.timeoutMs ?? 30_000,
300_000,
);
const env: NodeJS.ProcessEnv = {
...process.env,
PYTHONUTF8: "1",
PYTHONIOENCODING: "utf-8",
...(options.env ?? {}),
};
let proc: child_process.ChildProcess;
try {
const isPowerShell =
shellInfo.path.toLowerCase().endsWith("powershell.exe") ||
shellInfo.path.toLowerCase().endsWith("pwsh.exe");
const finalCommand = isPowerShell
? `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ${command}`
: command;
proc = child_process.spawn(shellInfo.path, [...shellInfo.args, finalCommand], {
cwd,
env,
windowsHide: true,
});
} catch (spawnErr) {
resolve({
stdout: "",
stderr: spawnErr instanceof Error ? spawnErr.message : String(spawnErr),
exitCode: 1,
timedOut: false,
shell: shellInfo.path,
platform: shellInfo.platform,
});
return;
}
// Fixed: use capped buffers to avoid unbounded memory growth
const MAX_OUTPUT = 100_000; // bytes
let stdoutLen = 0;
let stderrLen = 0;
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let timedOut = false;
proc.stdout?.on("data", (chunk: Buffer) => {
if (stdoutLen < MAX_OUTPUT) {
const remaining = MAX_OUTPUT - stdoutLen;
if (chunk.length <= remaining) {
stdoutChunks.push(chunk);
stdoutLen += chunk.length;
} else {
stdoutChunks.push(chunk.slice(0, remaining));
stdoutLen = MAX_OUTPUT;
}
}
});
proc.stderr?.on("data", (chunk: Buffer) => {
if (stderrLen < MAX_OUTPUT) {
const remaining = MAX_OUTPUT - stderrLen;
if (chunk.length <= remaining) {
stderrChunks.push(chunk);
stderrLen += chunk.length;
} else {
stderrChunks.push(chunk.slice(0, remaining));
stderrLen = MAX_OUTPUT;
}
}
});
const timer = setTimeout(() => {
timedOut = true;
try {
proc.kill();
} catch {}
}, timeoutMs);
proc.on("close", (code) => {
clearTimeout(timer);
const stdoutBuf = Buffer.concat(stdoutChunks);
const stderrBuf = Buffer.concat(stderrChunks);
resolve({
stdout: truncate(normalizeOutput(stdoutBuf.toString("utf-8")), 100_000),
stderr: truncate(normalizeOutput(stderrBuf.toString("utf-8")), 100_000),
exitCode: code ?? 1,
timedOut,
shell: shellInfo.path,
platform: shellInfo.platform,
});
});
proc.on("error", (err) => {
clearTimeout(timer);
resolve({
stdout: "",
stderr: err.message,
exitCode: 1,
timedOut: false,
shell: shellInfo.path,
platform: shellInfo.platform,
});
});
});
}
export { resolveShell, detectPlatform };