import { isError } from "es-toolkit"; import { resolve } from "node:path"; import type { CheckResult, RawTargetConfig } from "../../types"; import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types"; import type { CommandExpectConfig, CommandTargetConfig, ResolvedCommandTarget } from "./types"; import { checkDuration } from "../../expect/duration"; import { errorFailure } from "../../expect/failure"; import { parseSize } from "../../utils"; import { checkExitCode } from "./expect"; import { commandCheckerSchemas } from "./schema"; import { checkTextRules } from "./text"; import { validateCommandConfig } from "./validate"; export class CommandChecker implements CheckerDefinition { readonly configKey = "cmd"; readonly schemas = commandCheckerSchemas; readonly type = "cmd"; async execute(t: ResolvedCommandTarget, ctx: CheckerContext): Promise { const timestamp = new Date().toISOString(); const start = performance.now(); let proc: ReturnType; try { proc = Bun.spawn([t.cmd.exec, ...t.cmd.args], { cwd: t.cmd.cwd, env: t.cmd.env, stderr: "pipe", stdin: "ignore", stdout: "pipe", }); } catch (error) { const durationMs = Math.round(performance.now() - start); return { durationMs, failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)), matched: false, statusDetail: null, targetName: t.name, timestamp, }; } ctx.signal.addEventListener( "abort", () => { try { proc.kill(); } catch { /* best-effort kill */ } }, { once: true }, ); let outputResult: { exceeded: boolean; stderr: string; stdout: string }; try { outputResult = await readOutput( proc.stdout as ReadableStream, proc.stderr as ReadableStream, () => proc.kill(), t.cmd.maxOutputBytes, ); } catch { const durationMs = Math.round(performance.now() - start); return { durationMs, failure: errorFailure("exitCode", "execution", "输出读取失败"), matched: false, statusDetail: null, targetName: t.name, timestamp, }; } await proc.exited; const durationMs = Math.round(performance.now() - start); const exitCode = proc.exitCode ?? 1; if (outputResult.exceeded) { return { durationMs, failure: errorFailure("exitCode", "output", `输出超过限制 ${t.cmd.maxOutputBytes} 字节`), matched: false, statusDetail: `exitCode=${exitCode}`, targetName: t.name, timestamp, }; } if (ctx.signal.aborted) { return { durationMs, failure: errorFailure("exitCode", "timeout", `命令执行超时 (${t.timeoutMs}ms)`), matched: false, statusDetail: null, targetName: t.name, timestamp, }; } const exitCodeResult = checkExitCode(exitCode, t.expect?.exitCode ?? [0]); if (!exitCodeResult.matched) { return { durationMs, failure: exitCodeResult.failure, matched: false, statusDetail: `exitCode=${exitCode}`, targetName: t.name, timestamp, }; } const durationResult = checkDuration(durationMs, t.expect?.maxDurationMs); if (!durationResult.matched) { return { durationMs, failure: durationResult.failure, matched: false, statusDetail: `exitCode=${exitCode}`, targetName: t.name, timestamp, }; } if (t.expect?.stdout && t.expect.stdout.length > 0) { const stdoutResult = checkTextRules(outputResult.stdout, t.expect.stdout, "stdout"); if (!stdoutResult.matched) { return { durationMs, failure: stdoutResult.failure, matched: false, statusDetail: `exitCode=${exitCode}`, targetName: t.name, timestamp, }; } } if (t.expect?.stderr && t.expect.stderr.length > 0) { const stderrResult = checkTextRules(outputResult.stderr, t.expect.stderr, "stderr"); if (!stderrResult.matched) { return { durationMs, failure: stderrResult.failure, matched: false, statusDetail: `exitCode=${exitCode}`, targetName: t.name, timestamp, }; } } return { durationMs, failure: null, matched: true, statusDetail: `exitCode=${exitCode}`, targetName: t.name, timestamp, }; } resolve(target: RawTargetConfig, context: ResolveContext): ResolvedCommandTarget { const t = target as RawTargetConfig & { cmd: CommandTargetConfig; type: "cmd" }; const cmdDefaults = context.defaults["cmd"] as undefined | { cwd?: string; maxOutputBytes?: string }; const cwd = t.cmd.cwd ?? cmdDefaults?.cwd ?? "."; const resolvedCwd = resolve(context.configDir, cwd); const maxOutputBytes = parseSize(t.cmd.maxOutputBytes ?? cmdDefaults?.maxOutputBytes ?? "100MB"); const env = { ...process.env, ...(t.cmd.env ?? {}) } as Record; return { cmd: { args: t.cmd.args ?? [], cwd: resolvedCwd, env, exec: t.cmd.exec, maxOutputBytes, }, expect: target.expect as CommandExpectConfig | undefined, group: target.group ?? "default", intervalMs: context.defaultIntervalMs, name: t.name, timeoutMs: context.defaultTimeoutMs, type: "cmd", } satisfies ResolvedCommandTarget; } serialize(t: ResolvedCommandTarget): { config: string; target: string } { const parts = [t.cmd.exec, ...t.cmd.args]; return { config: JSON.stringify({ args: t.cmd.args, cwd: t.cmd.cwd, env: t.cmd.env, exec: t.cmd.exec, maxOutputBytes: t.cmd.maxOutputBytes, }), target: `exec ${parts.join(" ")}`, }; } validate(input: CheckerValidationInput) { return validateCommandConfig(input); } } async function readOutput( stdout: ReadableStream, stderr: ReadableStream, kill: () => void, maxBytes: number, ): Promise<{ exceeded: boolean; stderr: string; stdout: string }> { let totalBytes = 0; let exceeded = false; let killed = false; async function readStream(stream: ReadableStream): Promise { const reader = stream.getReader(); const decoder = new TextDecoder(); let text = ""; try { while (true) { const { done, value } = await reader.read(); if (done) break; totalBytes += value.byteLength; text += decoder.decode(value, { stream: true }); if (totalBytes > maxBytes && !killed) { exceeded = true; killed = true; try { kill(); } catch { /* best-effort kill */ } } } } catch { /* stream already closed */ } finally { try { reader.releaseLock(); } catch { /* already released */ } } return text; } const [out, err] = await Promise.all([readStream(stdout), readStream(stderr)]); return { exceeded, stderr: err, stdout: out }; }