commit bf30586f88acfc421419cc23e6925e331acfc77f
parent 9cbed498b846120abc958645fef892d22b30f332
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 11:04:26 -0400
common: the archilyzer CLI's parser and command table machinery
parseArgv beside parseFlags keeps positionals and never feeds a value to a
declared boolean flag; _cli.ts resolves the longest command path, refuses an
unknown or misshapen flag before a command runs, and prints the usage table.
common's test glob gains bin/.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 307 insertions(+), 3 deletions(-)
diff --git a/common/bin/_cli.test.ts b/common/bin/_cli.test.ts
@@ -0,0 +1,120 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { parseArgv } from "./_parseFlags";
+import {
+ argumentProblem,
+ booleanFlags,
+ resolveCommand,
+ runCli,
+ usage,
+ type Command,
+} from "./_cli";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common test
+//
+// The archilyzer CLI's parser and lookup. The command table itself
+// (archilyzer.ts) is not imported: these are the rules every row obeys.
+
+function cmd(path: string[], extra: Partial<Command> = {}): Command {
+ return {
+ path,
+ usage: `the ${path.join(" ")} command`,
+ run: async () => 0,
+ ...extra,
+ };
+}
+
+test("parseArgv keeps positionals in order and reads both flag spellings", () => {
+ assert.deepEqual(
+ parseArgv(["deploy", "site", "anilyzer", "--preview", "tags", "--x=1"]),
+ { positionals: ["deploy", "site", "anilyzer"], flags: { preview: "tags", x: "1" } },
+ );
+});
+
+test("a boolean flag never swallows the positional after it", () => {
+ assert.deepEqual(parseArgv(["build", "site", "--nodata", "jeralyzer"], ["nodata"]), {
+ positionals: ["build", "site", "jeralyzer"],
+ flags: { nodata: true },
+ });
+ // Without the declaration it would — which is why runCli declares them.
+ assert.deepEqual(parseArgv(["--nodata", "jeralyzer"]).flags, { nodata: "jeralyzer" });
+});
+
+test("a lone -- is skipped, so `pnpm run build -- --nodata` still reads the flag", () => {
+ assert.deepEqual(parseArgv(["build", "site", "--", "--nodata"], ["nodata"]), {
+ positionals: ["build", "site"],
+ flags: { nodata: true },
+ });
+});
+
+test("a bare trailing flag and -h are true", () => {
+ assert.deepEqual(parseArgv(["deploy", "--preview"]).flags, { preview: true });
+ assert.deepEqual(parseArgv(["-h"]).flags, { help: true });
+});
+
+test("resolveCommand picks the LONGEST matching path and returns the rest", () => {
+ const table = [cmd(["build"]), cmd(["build", "site"]), cmd(["build", "hub"])];
+ const hit = resolveCommand(table, ["build", "site", "anilyzer"]);
+ assert.deepEqual(hit?.command.path, ["build", "site"]);
+ assert.deepEqual(hit?.rest, ["anilyzer"]);
+ assert.deepEqual(resolveCommand(table, ["build", "all"])?.command.path, ["build"]);
+ assert.equal(resolveCommand(table, ["deploy"]), null);
+ // Table order does not matter.
+ assert.deepEqual(
+ resolveCommand([...table].reverse(), ["build", "hub"])?.command.path,
+ ["build", "hub"],
+ );
+});
+
+test("argumentProblem refuses an unknown flag, a valued boolean, a bare string flag, an extra positional", () => {
+ const c = cmd(["deploy", "site"], {
+ flags: { preview: "string", nodata: "boolean" },
+ maxPositionals: 1,
+ });
+ assert.equal(argumentProblem(c, { preview: "x" }, ["a"]), null);
+ assert.equal(argumentProblem(c, { help: true }, []), null);
+ assert.match(argumentProblem(c, { preveiw: "x" }, [])!, /unknown flag --preveiw \(accepts --preview, --nodata\)/);
+ assert.match(argumentProblem(c, { nodata: "yes" }, [])!, /--nodata takes no value/);
+ assert.match(argumentProblem(c, { preview: true }, [])!, /--preview needs a value/);
+ assert.match(argumentProblem(c, {}, ["a", "b"])!, /unexpected argument "b"/);
+ assert.match(argumentProblem(cmd(["index"]), { x: true }, [])!, /it takes none/);
+});
+
+test("booleanFlags is the union of every boolean flag, plus help", () => {
+ const table = [
+ cmd(["a"], { flags: { nodata: "boolean", preview: "string" } }),
+ cmd(["b"], { flags: { check: "boolean" } }),
+ ];
+ assert.deepEqual(booleanFlags(table).sort(), ["check", "help", "nodata"]);
+});
+
+test("usage lists every command on its own line", () => {
+ const u = usage([cmd(["index"]), cmd(["build", "site"])]);
+ assert.match(u, /^Usage:/);
+ assert.match(u, /archilyzer index\s+the index command/);
+ assert.match(u, /archilyzer build site\s+the build site command/);
+});
+
+test("runCli runs the command with the rest, and refuses with 2 before running", async () => {
+ const seen: unknown[] = [];
+ const table = [
+ cmd(["build", "site"], {
+ flags: { nodata: "boolean" },
+ maxPositionals: 1,
+ run: async (ctx) => {
+ seen.push(ctx.positionals, ctx.flags);
+ return 7;
+ },
+ }),
+ ];
+ const quiet = { log: () => {}, error: () => {} };
+ assert.equal(await runCli(table, ["build", "site", "x", "--nodata"], {}, quiet), 7);
+ assert.deepEqual(seen, [["x"], { nodata: true }]);
+ assert.equal(await runCli(table, ["build", "site", "--bogus"], {}, quiet), 2);
+ assert.equal(await runCli(table, ["nope"], {}, quiet), 2);
+ assert.equal(await runCli(table, [], {}, quiet), 2);
+ assert.equal(await runCli(table, ["--help"], {}, quiet), 0);
+ assert.equal(await runCli(table, ["build", "site", "--help"], {}, quiet), 0);
+ assert.equal(seen.length, 2, "no refused call reached run");
+});
diff --git a/common/bin/_cli.ts b/common/bin/_cli.ts
@@ -0,0 +1,136 @@
+// 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 { parseArgv, type FlagValue } from "./_parseFlags";
+
+// A flag a command accepts. "boolean" never takes a value (`--nodata`),
+// "string" always needs one (`--preview <branch>`).
+export type FlagKind = "boolean" | "string";
+
+export type CommandContext = {
+ positionals: string[];
+ flags: Record<string, FlagValue>;
+ env: NodeJS.ProcessEnv;
+};
+
+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<string, FlagKind>;
+ // At most this many positionals after the path (default 0).
+ maxPositionals?: number;
+ // The exit code.
+ run: (ctx: CommandContext) => Promise<number>;
+};
+
+/**
+ * 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<string>(["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<string, FlagValue>,
+ 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<number> {
+ 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 });
+}
diff --git a/common/bin/_parseFlags.ts b/common/bin/_parseFlags.ts
@@ -1,6 +1,6 @@
// Tiny argv parser: supports `--key value` and `--key=value`. Unknown
-// positional args are dropped. Good enough for the bin shims; reach for
-// commander/yargs if we ever need subcommands.
+// positional args are dropped. Good enough for the flag-driven bin shims; the
+// `archilyzer` CLI's subcommands use parseArgv below, which keeps positionals.
export function parseFlags(argv: string[]): Record<string, string> {
const out: Record<string, string> = {};
for (let i = 0; i < argv.length; i++) {
@@ -21,3 +21,51 @@ export function parseFlags(argv: string[]): Record<string, string> {
}
return out;
}
+
+// A flag's value: the string it was given, or `true` for a bare `--flag`.
+export type FlagValue = string | true;
+
+/**
+ * The `archilyzer` CLI's argv parser: positionals in order, plus flags.
+ *
+ * `--key=value` always carries its value. `--key value` takes the next word as
+ * the value UNLESS `key` is one of `booleans` (so `build site --nodata jeralyzer`
+ * keeps `jeralyzer` a positional) or the next word is itself a flag; a bare
+ * `--key` is `true`. A lone `--` is skipped rather than treated as the end of
+ * the flags, because `pnpm run build -- --nodata` hands the script its `--`
+ * verbatim and the flag after it must still be read as a flag. `-h` is `--help`.
+ */
+export function parseArgv(
+ argv: readonly string[],
+ booleans: Iterable<string> = [],
+): { flags: Record<string, FlagValue>; positionals: string[] } {
+ const bool = new Set(booleans);
+ const flags: Record<string, FlagValue> = {};
+ const positionals: string[] = [];
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === "--") continue;
+ if (a === "-h") {
+ flags.help = true;
+ continue;
+ }
+ if (!a.startsWith("--")) {
+ positionals.push(a);
+ continue;
+ }
+ const eq = a.indexOf("=");
+ if (eq > 2) {
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
+ continue;
+ }
+ const key = a.slice(2);
+ const next = argv[i + 1];
+ if (!bool.has(key) && next !== undefined && !next.startsWith("-")) {
+ flags[key] = next;
+ i++;
+ } else {
+ flags[key] = true;
+ }
+ }
+ return { flags, positionals };
+}
diff --git a/common/package.json b/common/package.json
@@ -45,7 +45,7 @@
"./styles/*": "./styles/*.ts"
},
"scripts": {
- "test": "tsx --test \"*.test.ts\" \"{lib,controller,jobs,social,ytdlp,components,views,publish}/*.test.ts\" \"{lib,controller,jobs,social,ytdlp,components,views,publish}/*/*.test.ts\""
+ "test": "tsx --test \"*.test.ts\" \"{lib,controller,jobs,social,ytdlp,components,views,publish,bin}/*.test.ts\" \"{lib,controller,jobs,social,ytdlp,components,views,publish,bin}/*/*.test.ts\""
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1080.0",