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 { CommandTargetConfig, RawCommandExpectConfig, ResolvedCommandExpectConfig, ResolvedCommandTarget, } from "./types"; import { checkContentExpectations, resolveContentExpectations } from "../../expect/content"; import { errorFailure } from "../../expect/failure"; import { checkValueExpectation, resolveValueExpectation } from "../../expect/value"; import { parseSize } from "../../utils"; import { checkExitCode } from "./expect"; import { commandCheckerSchemas } from "./schema"; import { validateCommandConfig } from "./validate"; const STDOUT_PREVIEW_MAX = 1024; const STDERR_PREVIEW_MAX = 1024; export class CommandChecker implements CheckerDefinition { readonly configKey = "cmd"; readonly schemas = commandCheckerSchemas; readonly type = "cmd"; buildDetail(observation: Record): null | string { const exitCode = observation["exitCode"]; return typeof exitCode === "number" ? `exitCode=${exitCode}` : null; } 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 { detail: null, durationMs, failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)), matched: false, observation: null, targetId: t.id, 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 { detail: null, durationMs, failure: errorFailure("exitCode", "execution", "输出读取失败"), matched: false, observation: null, targetId: t.id, timestamp, }; } await proc.exited; const durationMs = Math.round(performance.now() - start); const exitCode = proc.exitCode ?? 1; const stdoutPreview = truncatePreview(outputResult.stdout, STDOUT_PREVIEW_MAX); const stderrPreview = truncatePreview(outputResult.stderr, STDERR_PREVIEW_MAX); const observation: Record = { error: null, exitCode, stderrPreview, stdoutPreview }; if (outputResult.exceeded) { const message = `输出超过限制 ${t.cmd.maxOutputBytes} 字节`; observation["error"] = message; return { detail: null, durationMs, failure: errorFailure("exitCode", "output", message), matched: false, observation, targetId: t.id, timestamp, }; } if (ctx.signal.aborted) { const message = `命令执行超时 (${t.timeoutMs}ms)`; observation["error"] = message; return { detail: null, durationMs, failure: errorFailure("exitCode", "timeout", message), matched: false, observation, targetId: t.id, timestamp, }; } const exitCodeResult = checkExitCode(exitCode, t.expect?.exitCode ?? [0]); if (!exitCodeResult.matched) { return { detail: null, durationMs, failure: exitCodeResult.failure, matched: false, observation, targetId: t.id, timestamp, }; } const durationResult = checkValueExpectation(durationMs, t.expect?.durationMs, { message: "durationMs mismatch", path: "durationMs", phase: "duration", }); if (!durationResult.matched) { return { detail: null, durationMs, failure: durationResult.failure, matched: false, observation, targetId: t.id, timestamp, }; } if (t.expect?.stdout && t.expect.stdout.length > 0) { const stdoutResult = checkContentExpectations(outputResult.stdout, t.expect.stdout, { path: "stdout", phase: "stdout", }); if (!stdoutResult.matched) { return { detail: null, durationMs, failure: stdoutResult.failure, matched: false, observation, targetId: t.id, timestamp, }; } } if (t.expect?.stderr && t.expect.stderr.length > 0) { const stderrResult = checkContentExpectations(outputResult.stderr, t.expect.stderr, { path: "stderr", phase: "stderr", }); if (!stderrResult.matched) { return { detail: null, durationMs, failure: stderrResult.failure, matched: false, observation, targetId: t.id, timestamp, }; } } return { detail: null, durationMs, failure: null, matched: true, observation, targetId: t.id, timestamp, }; } resolve(target: RawTargetConfig, context: ResolveContext): ResolvedCommandTarget { const t = target as RawTargetConfig & { cmd: CommandTargetConfig; type: "cmd" }; const cwd = t.cmd.cwd ?? "."; const resolvedCwd = resolve(context.configDir, cwd); const maxOutputBytes = parseSize(t.cmd.maxOutputBytes ?? "100MB"); const env = { ...process.env, ...(t.cmd.env ?? {}) } as Record; const rawExpect = target.expect as RawCommandExpectConfig | undefined; const resolvedExpect: ResolvedCommandExpectConfig = rawExpect ? { durationMs: resolveValueExpectation(rawExpect.durationMs), exitCode: rawExpect.exitCode ?? [0], stderr: resolveContentExpectations(rawExpect.stderr), stdout: resolveContentExpectations(rawExpect.stdout), } : { exitCode: [0] }; return { cmd: { args: t.cmd.args ?? [], cwd: resolvedCwd, env, exec: t.cmd.exec, maxOutputBytes, }, description: null, expect: resolvedExpect, group: target.group ?? "default", id: t.id, intervalMs: context.defaultIntervalMs, name: t.name ?? null, rawExpect, 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 }; } function truncatePreview(text: string, maxLen: number): string { if (text.length <= maxLen) return text; return text.slice(0, maxLen); }