// The `archilyzer` CLI's machinery: a command table, the longest-path lookup // over it, and the usage text. Hand-rolled on purpose — there is no // commander/yargs in the workspace, and a table of a dozen rows does not earn // one. The table itself is `archilyzer.ts`; this file holds nothing that knows // what any command does, so it is unit-tested without importing one. import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { parseArgv, type FlagValue } from "./_parseFlags"; // A flag a command accepts. "boolean" never takes a value (`--nodata`), // "string" always needs one (`--preview `). export type FlagKind = "boolean" | "string"; export type CommandContext = { positionals: string[]; flags: Record; env: NodeJS.ProcessEnv; // A passthrough command's words after its path, verbatim (every other // command gets []). argv: string[]; }; export type Command = { // The words that select it: ["build", "site"]. path: string[]; // One line: the arguments after the path, then a short description. usage: string; // Every flag it accepts. Anything else is refused BEFORE `run`, because a // typo'd flag on a deploy must not quietly mean "production". flags?: Record; // At most this many positionals after the path (default 0). maxPositionals?: number; // The command parses its own arguments (a bin with its own flags, the MCP // server): runCli hands it everything after its path, untouched, as `argv`, // and checks nothing. Matched on the LEADING words of argv only, so a flag // value can never be mistaken for its path. `--help` as the first word after // the path still prints the usage line. passthrough?: boolean; // The exit code. run: (ctx: CommandContext) => Promise; }; /** * The command whose `path` is the LONGEST prefix of `positionals`, and the * positionals left after it — or null when no command matches. */ export function resolveCommand( table: readonly Command[], positionals: readonly string[], ): { command: Command; rest: string[] } | null { let best: Command | null = null; for (const c of table) { if (c.path.length > positionals.length) continue; if (!c.path.every((w, i) => positionals[i] === w)) continue; if (!best || c.path.length > best.path.length) best = c; } return best ? { command: best, rest: positionals.slice(best.path.length) } : null; } /** Every boolean flag any command declares — what parseArgv must not feed a value. */ export function booleanFlags(table: readonly Command[]): string[] { const out = new Set(["help"]); for (const c of table) { for (const [name, kind] of Object.entries(c.flags ?? {})) { if (kind === "boolean") out.add(name); } } return [...out]; } /** The usage text: one line per command, in table order. */ export function usage(table: readonly Command[], bin = "archilyzer"): string { const rows = table.map((c) => [`${bin} ${c.path.join(" ")}`, c.usage] as const); const width = Math.max(...rows.map(([head]) => head.length)); return [ `Usage:`, ...rows.map(([head, text]) => ` ${head.padEnd(width)} ${text}`), ].join("\n"); } /** * Why `flags` / `rest` do not fit `command`, as one sentence — or null. * `help` is always allowed (the caller prints usage for it). */ export function argumentProblem( command: Command, flags: Record, rest: readonly string[], ): string | null { const name = command.path.join(" "); const allowed = command.flags ?? {}; for (const [key, value] of Object.entries(flags)) { if (key === "help") continue; const kind = allowed[key]; if (!kind) { const known = Object.keys(allowed); return `${name}: unknown flag --${key}${ known.length ? ` (accepts ${known.map((k) => `--${k}`).join(", ")})` : " (it takes none)" }`; } if (kind === "boolean" && value !== true) { return `${name}: --${key} takes no value`; } if (kind === "string" && value === true) { return `${name}: --${key} needs a value`; } } const max = command.maxPositionals ?? 0; if (rest.length > max) { return `${name}: unexpected argument "${rest[max]}"`; } return null; } /** * Parse argv, resolve the command, check its arguments and run it. Returns the * exit code; every refusal prints to stderr and returns 2 (usage), which is * what a shell script tests for. */ export async function runCli( table: readonly Command[], argv: readonly string[], env: NodeJS.ProcessEnv = process.env, out: { log: (s: string) => void; error: (s: string) => void } = console, ): Promise { const through = passthroughCommand(table, argv); if (through) { const rest = argv.slice(through.path.length); if (rest[0] === "--help" || rest[0] === "-h") { out.log(usage([through])); return 0; } return through.run({ positionals: [], flags: {}, env, argv: rest }); } const { flags, positionals } = parseArgv(argv, booleanFlags(table)); if (positionals.length === 0) { (flags.help ? out.log : out.error)(usage(table)); return flags.help ? 0 : 2; } const hit = resolveCommand(table, positionals); if (!hit) { out.error(`archilyzer: unknown command "${positionals.join(" ")}"\n\n${usage(table)}`); return 2; } if (flags.help) { out.log(usage([hit.command])); return 0; } const problem = argumentProblem(hit.command, flags, hit.rest); if (problem) { out.error(`${problem}\n\n${usage([hit.command])}`); return 2; } return hit.command.run({ positionals: hit.rest, flags, env, argv: [] }); } /** * The passthrough command whose path is the longest run of LEADING words of * argv — or null. Only the leading words: `run digest --lane x` must never be * read as some command named by a flag's value. */ export function passthroughCommand( table: readonly Command[], argv: readonly string[], ): Command | null { let best: Command | null = null; for (const c of table) { if (!c.passthrough || c.path.length > argv.length) continue; if (!c.path.every((w, i) => argv[i] === w)) continue; if (!best || c.path.length > best.path.length) best = c; } return best; } /** * True when the module at `metaUrl` is the script node was started with — the * `scripts/worktree.mjs` idiom, so a bin can export `main` for the CLI to call * AND keep working as `tsx bin/.ts`. Both sides are realpath'd, because * `import.meta.url` is always the real file and argv[1] may reach it through a * workspace symlink. */ export function isEntryPoint(metaUrl: string): boolean { const argv1 = process.argv[1]; if (!argv1) return false; try { return realpathSync(fileURLToPath(metaUrl)) === realpathSync(argv1); } catch { return false; } } /** * Run `main` when `metaUrl` is the entry point, the way every bin always has: * a thrown error prints and exits 1; a returned number becomes the exit code * without cutting the process short (a bin that returns nothing exits 0 when * its work drains, exactly as before it exported anything). */ export function runIfEntryPoint( metaUrl: string, main: () => Promise, ): void { if (!isEntryPoint(metaUrl)) return; main().then( (code) => { if (typeof code === "number") process.exitCode = code; }, (err: unknown) => { console.error(err); process.exit(1); }, ); }