#!/usr/bin/env node // Root command facade (RL-04 / L-04). // // One entry point for the checks CI runs, so a contributor does not have to // know which directory each stack lives in. `node scripts/run.mjs --list` // prints every task and the exact commands it runs. // // Two rules this file exists to keep: // // 1. Cross-platform. No `make`, no shell syntax, no `cd &&`. Every step is // spawned directly with an explicit `cwd`, so there is no shell to quote // for and nothing that behaves differently on Windows. // 2. The facade orchestrates, it never becomes the only path. Each step // prints the command it runs, in the directory it runs it in, so a // Go-only contributor can read the output and type those commands // instead — and never needs Node to work on the server. // // Dependency-free by design: Node's standard library only, like // .superpowers/render-ledger.mjs. Adding a dependency here would mean // `npm run check` could not run until `npm install` had. import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const WIN = process.platform === "win32"; // npm and npx are batch shims on Windows; everything else is a real binary. const bin = (c) => (WIN && (c === "npm" || c === "npx") ? `${c}.cmd` : c); // Node refuses to spawn a .cmd/.bat with shell:false (the CVE-2024-27980 // mitigation): it fails with EINVAL and a null exit status. So the Windows npm // shims need a shell, and only they do -- every other command here is a real // binary, and a shell would put its quoting rules between us and the arguments. const needsShell = (c) => WIN && (c === "npm" || c === "npx"); /** A step that always runs. */ const step = (cmd, args, cwd = ".") => ({ cmd, args, cwd }); /** * A step that is skipped, with a printed reason, when `probe` is not on PATH. * Used for tools CI installs but a contributor may not have: golangci-lint has * no wrapper in this repo at all, and sqlc is pinned by Server/sqlc.version. */ const optional = (probe, cmd, args, cwd, why) => ({ cmd, args, cwd, probe, why }); /** * Tracked files matching `patterns`. shellcheck and actionlint take file lists, * and the only correct list is the tracked one: `.claude/worktrees/` holds * gitignored copies of the tree that a filesystem glob would happily lint. */ const tracked = (...patterns) => { const r = spawnSync("git", ["ls-files", "-z", ...patterns], { cwd: ROOT, encoding: "utf8" }); return r.status === 0 ? r.stdout.split("\0").filter(Boolean) : []; }; // `git diff --exit-code` after regenerating is what `make protocol-verify` and // `make sqlc-verify` reduce to. Inlined so neither needs make. const PROTOCOL_VERIFY = [ step("go", ["run", "./cmd/genprotocol"], "Server"), step( "git", ["diff", "--exit-code", "ws/message_types.go", "../Client/src/lib/protocolTypes.ts"], "Server", ), ]; const SQLC_VERIFY = [ optional( "sqlc", "sqlc", ["generate"], "Server", "sqlc not on PATH — install the version in Server/sqlc.version", ), optional("sqlc", "git", ["diff", "--exit-code", "db/dbgen"], "Server", "sqlc not on PATH"), ]; const CHECK_SERVER = [ step("go", ["build", "./..."], "Server"), step("go", ["build", "-tags", "otel", "./..."], "Server"), step("go", ["build", "-tags", "wazero", "./..."], "Server"), step("go", ["build", "-tags", "otel,wazero", "./..."], "Server"), step("go", ["vet", "./..."], "Server"), step("go", ["test", "-race", "./..."], "Server"), step("go", ["test", "-tags", "deadlock", "-count=1", "./ws/"], "Server"), optional( "golangci-lint", "golangci-lint", ["run", "./..."], "Server", "golangci-lint not on PATH — CI pins v2.11.3", ), ...PROTOCOL_VERIFY, ...SQLC_VERIFY, ]; const CHECK_CLIENT = [ step("npm", ["run", "typecheck"], "Client"), step("npm", ["run", "lint"], "Client"), step("npm", ["test"], "Client"), ]; // Matches ci.yml's Rust Unit Tests job exactly: --lib for tests, --all-targets // for clippy. They differ deliberately; do not "align" them. const CHECK_RUST = [ step("cargo", ["fmt", "--all", "--", "--check"], "Client/src-tauri"), step("cargo", ["test", "--lib"], "Client/src-tauri"), step("cargo", ["clippy", "--all-targets", "--", "-D", "warnings"], "Client/src-tauri"), ]; // RL-07. FINDINGS.md is not tracked, so there is no committed rendering to // drift — the gate is that generation must succeed. Rendering subsumes // `--check`: main() validates and exits 1 on a schema problem before it writes. // It also leaves the contributor a readable copy, which is the point of running // it locally. CI additionally renders twice and compares, to prove the output // is a pure function of the ledger; that needs a temp path, so it lives in // ci.yml rather than here. // `step`, not `optional`: this file is itself Node, so probing for it is theatre. const LEDGER_VERIFY = [step("node", [".superpowers/render-ledger.mjs"], ".")]; // Fast and dependency-free, so it goes first: a contradicted count should not // wait behind ten minutes of -race. const CHECK_DOCS = [step("node", ["scripts/check-doc-counts.mjs"], "."), ...LEDGER_VERIFY]; // Repository-wide formatting and script/workflow lint (RL-19 / L-13, S-05). // // No `gofmt -l` step here on purpose: `gofmt -l` prints offenders and still // exits 0, so it cannot fail a build. Go formatting is enforced by the // `formatters` block in Server/.golangci.yml, which runs inside the pinned // Lint check, and by .githooks/pre-commit on staged files. const CHECK_HYGIENE = [ step("npx", ["prettier", "--check", "."], "."), optional( "shellcheck", "shellcheck", tracked("*.sh", ".githooks/pre-commit", ".githooks/pre-push"), ".", "shellcheck not on PATH — no clean Windows install; CI runs it", ), optional( "actionlint", "actionlint", tracked(".github/workflows/*.yml"), ".", "actionlint not on PATH — no clean Windows install; CI runs it", ), ]; const TASKS = { bootstrap: [ step("npm", ["ci"], "."), step("npm", ["ci"], "Client"), step("npm", ["ci"], "tools/mcp-introspect"), ], "check:server": CHECK_SERVER, "check:client": CHECK_CLIENT, "check:rust": CHECK_RUST, "check:docs": CHECK_DOCS, "check:hygiene": CHECK_HYGIENE, check: [...CHECK_DOCS, ...CHECK_HYGIENE, ...CHECK_SERVER, ...CHECK_CLIENT, ...CHECK_RUST], generate: [ step("go", ["run", "./cmd/genprotocol"], "Server"), optional( "sqlc", "sqlc", ["generate"], "Server", "sqlc not on PATH — install the version in Server/sqlc.version", ), ], format: [ step("npx", ["prettier", "--write", "."], "."), optional("gofmt", "gofmt", ["-w", "."], "Server", "gofmt not on PATH"), step("cargo", ["fmt", "--all"], "Client/src-tauri"), ], "release:preflight": [ ...CHECK_DOCS, ...CHECK_HYGIENE, ...CHECK_SERVER, ...CHECK_CLIENT, ...CHECK_RUST, step("npm", ["run", "build"], "Client"), ], }; // Resolve against PATH directly instead of shelling out to `where`/`command`. // Both probes were unreliable: `where.exe` lives in C:\WINDOWS\System32, which a // Git Bash PATH does not always contain (it can carry only the subdirectories), // and a probe that cannot start reports "not installed" for a tool that is. That // turned every optional() step into a permanent SKIP on Windows. function onPath(cmd) { const exts = WIN ? (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";") : [""]; const dirs = (process.env.PATH || "").split(WIN ? ";" : ":"); return dirs.some((dir) => dir && exts.some((ext) => existsSync(join(dir, cmd + ext)))); } function runTask(name) { const steps = TASKS[name]; if (!steps) { console.error(`unknown task: ${name}\nknown: ${Object.keys(TASKS).join(", ")}`); process.exit(2); } const skipped = []; for (const s of steps) { if (s.probe && !onPath(s.probe)) { console.log(`\n--- SKIP ${s.cmd} ${s.args.join(" ")} (${s.why})`); skipped.push(s.probe); continue; } const where = s.cwd === "." ? "" : ` [in ${s.cwd}]`; console.log(`\n--- ${s.cmd} ${s.args.join(" ")}${where}`); // With shell:true Node deprecates a separate args array (DEP0190), because it // concatenates without escaping. So concatenate deliberately instead: the only // commands that take this branch are the npm shims, and no argument in this // file contains a space. const shell = needsShell(s.cmd); const r = shell ? spawnSync([bin(s.cmd), ...s.args].join(" "), { cwd: join(ROOT, s.cwd), stdio: "inherit", shell: true, }) : spawnSync(s.cmd, s.args, { cwd: join(ROOT, s.cwd), stdio: "inherit", shell: false, }); if (r.error && r.error.code !== "ENOENT") { console.error(`\nFAILED: ${s.cmd} could not be started: ${r.error.code}`); process.exit(1); } if (r.error && r.error.code === "ENOENT") { console.error(`\nFAILED: ${s.cmd} is not installed or not on PATH.`); process.exit(1); } if (r.status !== 0) { console.error(`\nFAILED: ${s.cmd} ${s.args.join(" ")}${where} exited ${r.status}`); process.exit(r.status ?? 1); } } if (skipped.length) { console.log( `\n${name}: passed, with ${[...new Set(skipped)].join(", ")} skipped (not installed). CI runs them.`, ); } else { console.log(`\n${name}: passed`); } } const arg = process.argv[2]; if (!arg || arg === "--list") { for (const [name, steps] of Object.entries(TASKS)) { console.log(`\n${name}`); for (const s of steps) { const where = s.cwd === "." ? "" : ` (in ${s.cwd})`; console.log(` ${s.probe ? "[optional] " : ""}${s.cmd} ${s.args.join(" ")}${where}`); } } console.log(""); process.exit(0); } if (!existsSync(join(ROOT, "Server")) || !existsSync(join(ROOT, "Client"))) { console.error("run this from the repository root"); process.exit(2); } runTask(arg);