Add script runner with stdout condition rule

Claude-Session: https://claude.ai/code/session_01JtuZTCMW9TyogEmBgGf8JT
This commit is contained in:
EugeneTes
2026-09-06 15:40:48 +02:00
parent 2b76a18267
commit 5f100a330a
2 changed files with 124 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { resolveShell, runScript, tailLines } from "./runner.js";
const cwd = process.cwd();
describe("runScript", () => {
it("reports output when the script prints", async () => {
expect(await runScript("echo hello", { cwd, timeoutSec: 10 })).toEqual({ kind: "output", text: "hello" });
});
it("reports silent on exit 0 with no output", async () => {
expect(await runScript("exit 0", { cwd, timeoutSec: 10 })).toEqual({ kind: "silent" });
});
it("reports failure with exit code and stderr tail", async () => {
const r = await runScript("echo boom >&2; exit 3", { cwd, timeoutSec: 10 });
expect(r).toEqual({ kind: "failed", exitCode: 3, stderrTail: "boom", timedOut: false });
});
it("kills a script that exceeds the timeout", async () => {
const r = await runScript("sleep 5; echo late", { cwd, timeoutSec: 1 });
expect(r.kind).toBe("failed");
if (r.kind === "failed") {
expect(r.timedOut).toBe(true);
expect(r.stderrTail).toMatch(/timed out after 1s/);
}
});
it("reports a missing shell as a failure instead of throwing", async () => {
const r = await runScript("echo hi", { cwd, timeoutSec: 5, shellPath: "/definitely/not/a/shell" });
expect(r.kind).toBe("failed");
if (r.kind === "failed") expect(r.stderrTail).toMatch(/failed to start/);
});
});
describe("resolveShell", () => {
it("prefers the override", () => {
expect(resolveShell("/x/bash", "win32")).toBe("/x/bash");
});
it("uses bash from PATH on non-Windows", () => {
expect(resolveShell(undefined, "linux")).toBe("bash");
});
});
describe("tailLines", () => {
it("keeps the last n lines", () => {
expect(tailLines("a\nb\nc\nd\n", 2)).toBe("c\nd");
});
});

75
src/scheduler/runner.ts Normal file
View File

@@ -0,0 +1,75 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
export type ScriptOutcome =
| { kind: "silent" }
| { kind: "output"; text: string }
| { kind: "failed"; exitCode: number | null; stderrTail: string; timedOut: boolean };
export interface RunScriptOptions {
cwd: string;
timeoutSec: number;
shellPath?: string;
env?: NodeJS.ProcessEnv;
}
const STDERR_TAIL_LINES = 20;
const WINDOWS_BASH_CANDIDATES = [
"C:\\Program Files\\Git\\bin\\bash.exe",
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
];
export function resolveShell(override?: string, platform: NodeJS.Platform = process.platform): string {
if (override) return override;
if (platform === "win32") {
for (const candidate of WINDOWS_BASH_CANDIDATES) if (existsSync(candidate)) return candidate;
}
return "bash";
}
export function tailLines(text: string, n: number): string {
return text.trimEnd().split("\n").slice(-n).join("\n");
}
export function runScript(command: string, opts: RunScriptOptions): Promise<ScriptOutcome> {
return new Promise((resolve) => {
const shell = resolveShell(opts.shellPath);
const child = spawn(shell, ["-c", command], {
cwd: opts.cwd,
env: { ...process.env, ...opts.env },
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
let stdout = "";
let stderr = "";
let timedOut = false;
child.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
child.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
const timer = setTimeout(() => {
timedOut = true;
child.kill();
}, opts.timeoutSec * 1000);
child.on("error", (err) => {
clearTimeout(timer);
resolve({ kind: "failed", exitCode: null, stderrTail: `failed to start ${shell}: ${err.message}`, timedOut: false });
});
child.on("close", (code) => {
clearTimeout(timer);
if (timedOut) {
const note = `(timed out after ${opts.timeoutSec}s)`;
resolve({ kind: "failed", exitCode: code, stderrTail: tailLines(`${stderr}\n${note}`, STDERR_TAIL_LINES), timedOut: true });
return;
}
if (code !== 0) {
resolve({ kind: "failed", exitCode: code, stderrTail: tailLines(stderr, STDERR_TAIL_LINES), timedOut: false });
return;
}
const text = stdout.trim();
resolve(text ? { kind: "output", text } : { kind: "silent" });
});
});
}