mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
276 lines
10 KiB
JavaScript
276 lines
10 KiB
JavaScript
#!/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",
|
||
|
|
),
|
||
|
|
// L-16. actionlint validates expression syntax and action inputs; it has no
|
||
|
|
// concept of who a condition admits or how long a job may run. This asserts
|
||
|
|
// the guards on workflows that spend. `step`, not `optional`: it is Node, and
|
||
|
|
// this file is Node. It lives in check:hygiene so it runs inside the pinned
|
||
|
|
// Repository Hygiene job rather than needing a new required check.
|
||
|
|
step("node", ["scripts/check-workflow-guards.mjs", "--selftest"], "."),
|
||
|
|
step("node", ["scripts/check-workflow-guards.mjs"], "."),
|
||
|
|
];
|
||
|
|
|
||
|
|
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);
|