import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { CHECK_PLAN_VERSION, checkProfiles, listCheckTasks } from "./check-plan.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); export async function runCheckRunner(argv, io = process) { const options = parseCheckRunnerArgs(argv); if (options.help) { writeJson(io.stdout, helpPayload()); return { exitCode: 0 }; } const selected = selectTasks(options); const payload = { ok: true, command: "check-runner", version: CHECK_PLAN_VERSION, profile: options.profile, groups: options.groups, ids: options.ids, taskCount: selected.length, tasks: selected.map(serializeTask) }; if (options.list || options.dryRun) { writeJson(io.stdout, { ...payload, mode: options.list ? "list" : "dry-run" }); return { exitCode: 0 }; } writeJson(io.stdout, { ...payload, mode: "run-start", tasks: undefined }); const startedAt = Date.now(); for (let index = 0; index < selected.length; index += 1) { const task = selected[index]; const resolved = resolveTaskCommand(task); writeJson(io.stdout, { ok: true, event: "task-start", index: index + 1, total: selected.length, task: serializeTask(task), resolvedCommand: resolved.command }); const exitCode = await runTask(resolved); if (exitCode !== 0) { writeJson(io.stdout, { ok: false, event: "task-failed", task: serializeTask(task), exitCode, elapsedMs: Date.now() - startedAt }); return { exitCode }; } } writeJson(io.stdout, { ok: true, event: "run-complete", profile: options.profile, taskCount: selected.length, elapsedMs: Date.now() - startedAt }); return { exitCode: 0 }; } export function parseCheckRunnerArgs(argv) { const options = { profile: "check", groups: [], ids: [], list: false, dryRun: false, help: false }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; const readValue = (name) => { const inline = arg.match(new RegExp(`^${name}=(.+)$`, "u")); if (inline) return inline[1]; const value = argv[index + 1]; if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`); index += 1; return value; }; if (arg === "--help" || arg === "-h") options.help = true; else if (arg === "--profile" || arg.startsWith("--profile=")) options.profile = readValue("--profile"); else if (arg === "--group" || arg.startsWith("--group=")) options.groups.push(...readValue("--group").split(",").filter(Boolean)); else if (arg === "--id" || arg.startsWith("--id=")) options.ids.push(...readValue("--id").split(",").filter(Boolean)); else if (arg === "--list") options.list = true; else if (arg === "--dry-run") options.dryRun = true; else throw new Error(`unknown check-runner argument: ${arg}`); } if (!checkProfiles[options.profile]) throw new Error(`unknown check profile: ${options.profile}`); return options; } export function selectTasks(options) { const groups = new Set(options.groups); const ids = new Set(options.ids); const profileTasks = listCheckTasks(options.profile); const availableGroups = new Set(profileTasks.map((task) => task.group)); const availableIds = new Set(profileTasks.map((task) => task.id)); for (const group of groups) { if (!availableGroups.has(group)) throw new Error(`unknown check group for profile ${options.profile}: ${group}`); } for (const id of ids) { if (!availableIds.has(id)) throw new Error(`unknown check task id for profile ${options.profile}: ${id}`); } const selected = profileTasks.filter((task) => { if (groups.size > 0 && !groups.has(task.group)) return false; if (ids.size > 0 && !ids.has(task.id)) return false; return true; }); if (selected.length === 0) throw new Error(`check-runner selected no tasks for profile ${options.profile}`); return selected; } export function resolveTaskCommand(task) { const [bin, ...args] = task.command; if (!bin) throw new Error(`task has empty command: ${task.id}`); const cwd = task.cwd ? path.resolve(repoRoot, task.cwd) : repoRoot; return { task, command: bin === "bun" ? resolveBunCommand(cwd) : bin, args, cwd }; } function runTask(task) { return new Promise((resolve) => { const child = spawn(task.command, task.args, { cwd: task.cwd, env: process.env, stdio: ["ignore", "inherit", "inherit"] }); child.on("error", (error) => { process.stderr.write(JSON.stringify({ ok: false, event: "task-spawn-error", task: serializeTask(task.task), error: error.message }, null, 2) + "\n"); resolve(127); }); child.on("exit", (code, signal) => { if (signal) { process.stderr.write(JSON.stringify({ ok: false, event: "task-signal", task: serializeTask(task.task), signal }, null, 2) + "\n"); resolve(128); return; } resolve(code ?? 0); }); }); } function resolveBunCommand(cwd) { for (const candidate of bunCandidates(cwd)) { if (!candidate) continue; if (!candidate.includes(path.sep)) return candidate; if (existsSync(candidate)) return candidate; } return "bun"; } function bunCandidates(cwd) { const exe = process.platform === "win32" ? "bun.exe" : "bun"; return [ process.env.HWLAB_BUN_COMMAND, process.env.HWLAB_BUN_BINARY, process.env.BUN_BINARY, path.join(cwd, "node_modules", ".bin", exe), path.join(repoRoot, "node_modules", ".bin", exe), path.join(os.homedir(), ".bun", "bin", exe), "/root/.bun/bin/bun", "/usr/local/bin/bun", "bun" ].filter(Boolean); } function serializeTask(task) { return { id: task.id, group: task.group, cwd: task.cwd ?? ".", command: task.command }; } function writeJson(stream, value) { stream.write(JSON.stringify(value, null, 2) + "\n"); } function helpPayload() { return { ok: true, command: "check-runner", usage: [ "node scripts/check-runner.mjs --profile check", "node scripts/check-runner.mjs --profile validate", "node scripts/check-runner.mjs --profile check --group cloud-api", "node scripts/check-runner.mjs --profile check --list", "node scripts/check-runner.mjs --profile check --dry-run" ], profiles: Object.keys(checkProfiles), groups: [...new Set(Object.values(checkProfiles).flat().map((task) => task.group))].sort() }; }