// WRITE COMMANDS.md FROM THE TWO COMMAND SURFACES' OWN HELP. // // archilyzer docs cli [--check] // // The `archilyzer` rows come from the command table (`COMMANDS`, archilyzer.ts): // each row's path and its one-line usage. The `pnpm ops` rows come from // `usage()` in scripts/archilyzer-ops.mjs — the text `pnpm ops --help` prints — // split into paragraphs: a paragraph that opens with action names documents // those actions; every other paragraph (the usage lines, --wait, preview, // the env) is printed as it stands. Each action also gets the examples the // script's header comment gives it (`// pnpm ops …` lines). An // action with neither is still listed, so the reference never omits one. // // `--check` writes nothing and returns 1 when the committed file differs from // what the two sources generate (the same claim cli-docs.test.ts makes). Both // modes then check OPERATING.md: every `pnpm ops …` / `pnpm archilyzer …` its // recipes name must exist. The sibling of env-docs.ts (ENVIRONMENT.md) and // file-schemas-docs.ts. import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { runIfEntryPoint } from "./_cli"; const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); export const COMMANDS_FILE = "COMMANDS.md"; const OPS_SCRIPT = path.join("scripts", "archilyzer-ops.mjs"); // What the renderer needs of a CLI row — structural, so the tests never import // the table. export type CliRow = { path: readonly string[]; usage: string; passthrough?: boolean }; /** A usage line split at its first double space: the arguments, then the text. */ export function splitUsage(usage: string): { args: string; text: string } { const at = usage.indexOf(" "); if (at < 0) return { args: "", text: usage.trim() }; return { args: usage.slice(0, at).trim(), text: usage.slice(at).trim() }; } /** * Plain help text as one Markdown table cell: backtick spans are kept as code, * and everything else is escaped so ``, `*`, `_` and `[x]` print as typed. * A `|` is escaped everywhere, code included (GFM reads it as a cell edge). */ export function mdCell(text: string): string { return text .split(/(`[^`]*`)/) .map((part, i) => i % 2 === 1 ? part.replace(/\|/g, "\\|") : part .replace(/\\/g, "\\\\") .replace(/\|/g, "\\|") .replace(//g, ">") .replace(/([*_[\]])/g, "\\$1"), ) .join(""); } function codeCell(text: string): string { return text ? `\`${text.replace(/\|/g, "\\|")}\`` : ""; } /** The action list `usage()` prints on its `Actions: a, b, c` line. */ export function opsActions(opsUsage: string): string[] { const line = opsUsage.split("\n").find((l) => l.startsWith("Actions: ")); if (!line) throw new Error("pnpm ops usage: no `Actions:` line"); return line .slice("Actions: ".length) .split(",") .map((a) => a.trim()) .filter(Boolean); } export type OpsParagraph = { actions: string[]; text: string }; /** * The usage text's paragraphs, each with the actions it opens with * ("build-site, build-deploy and deploy-site all take …" → three). A paragraph * that opens with no action name has `actions: []`. The `Actions:` line itself * is left out: the table replaces it. */ export function opsParagraphs(opsUsage: string, actions: readonly string[]): OpsParagraph[] { const known = new Set(actions); const out: OpsParagraph[] = []; for (const raw of opsUsage.split(/\n\s*\n/)) { const text = raw.replace(/\s+$/, ""); if (!text.trim() || text.startsWith("Actions: ")) continue; const opened: string[] = []; for (const word of text.trim().split(/[\s,]+/)) { if (known.has(word)) opened.push(word); else if (word === "and" && opened.length > 0) continue; else break; } out.push({ actions: opened, text }); } return out; } /** * The examples in the ops script's header comment, per action: every * `// pnpm ops …` line whose action is a known one, its padding * collapsed. (`get` and `list` lines are not actions; the usage block has them.) */ export function opsExamples(source: string, actions: readonly string[]): Map { const known = new Set(actions); const out = new Map(); for (const line of source.split("\n")) { const m = /^\/\/\s+pnpm ops ([a-z][a-z-]*)(\s+.*)?$/.exec(line); if (!m || !known.has(m[1])) continue; const rest = m[2] ? ` ${m[2].trim().replace(/\s{2,}/g, " ")}` : ""; out.set(m[1], [...(out.get(m[1]) ?? []), `pnpm ops ${m[1]}${rest}`]); } return out; } const oneLine = (s: string) => s.replace(/\s+/g, " ").trim(); export function renderCommandsMarkdown( cli: readonly CliRow[], opsUsage: string, examples: ReadonlyMap = new Map(), ): string { const actions = opsActions(opsUsage); const paragraphs = opsParagraphs(opsUsage, actions); const lines: string[] = [ "# Command reference", "", "", "", "Every `archilyzer` command and every `pnpm ops` action, from their own help. Recipes that chain them: [OPERATING.md](OPERATING.md).", "", "## `pnpm archilyzer`", "", "Run from the repo root (in the container: `docker compose exec editor pnpm archilyzer …`). `--help` after a command prints its line. A command marked *passthrough* parses its own flags.", "", "| command | arguments | what it does |", "|---|---|---|", ]; for (const row of cli) { const { args, text } = splitUsage(row.usage); const note = row.passthrough ? " *(passthrough)*" : ""; lines.push( `| \`archilyzer ${row.path.join(" ")}\` | ${codeCell(args)} | ${mdCell(oneLine(text))}${note} |`, ); } lines.push( "", "## `pnpm ops`", "", "Drives a running editor over HTTP (`/api/ops/*`, the same actions its pages run), gated by `WORKER_TOKEN`. A body is `--json ''` or `--file `; `--wait` follows a job to its end.", "", "| action | what it does | example |", "|---|---|---|", ); // One row per help paragraph that opens with actions, in the order of their // first action; an action no paragraph opens with gets a row of its own. An // action's examples go on the first row that names it. type Row = { actions: string[]; text: string; examples: string[] }; const rows: Row[] = []; const emitted = new Set(); for (const action of actions) { const mine = paragraphs.filter((p) => p.actions.includes(action)); if (mine.length === 0) rows.push({ actions: [action], text: "", examples: [] }); for (const p of mine) { if (emitted.has(p)) continue; emitted.add(p); rows.push({ actions: p.actions, text: oneLine(p.text), examples: [] }); } } for (const action of actions) { const row = rows.find((r) => r.actions.includes(action)); row?.examples.push(...(examples.get(action) ?? [])); } for (const r of rows) { const names = r.actions.map((a) => `\`${a}\``).join(", "); const ex = r.examples.map(codeCell).join("
"); lines.push(`| ${names} | ${r.text ? mdCell(r.text) : "—"} | ${ex} |`); } lines.push("", "### Usage, flags and environment", "", "```text"); for (const p of paragraphs) { if (p.actions.length === 0) lines.push(p.text, ""); } if (lines[lines.length - 1] === "") lines.pop(); lines.push("```", ""); return lines.join("\n"); } /** * Every `pnpm ops …` and `pnpm archilyzer …` a document names that is not a * real action, getter or command — what keeps OPERATING.md's recipes runnable. * A placeholder (`pnpm ops `) is not a name. */ export function unknownCommandsIn( markdown: string, cli: readonly CliRow[], opsUsage: string, ): string[] { const actions = new Set(opsActions(opsUsage)); const nouns = new Set([...opsUsage.matchAll(/pnpm ops get ([a-z][a-z-]*)/g)].map((m) => m[1])); const bad = new Set(); for (const m of markdown.matchAll(/pnpm ops ([^\s`'"]+)(?:[ \t]+([^\s`'"]+))?/g)) { const [, word, next] = m; if (word.startsWith("<") || word === "list") continue; if (word === "get") { if (!next || !nouns.has(next)) bad.add(`pnpm ops get ${next ?? ""}`.trim()); continue; } if (!actions.has(word)) bad.add(`pnpm ops ${word}`); } for (const m of markdown.matchAll(/pnpm archilyzer((?:[ \t]+[a-z][a-z-]*)+)/g)) { const words = m[1].trim().split(/\s+/); if (!cli.some((r) => r.path.every((w, i) => words[i] === w))) { bad.add(`pnpm archilyzer ${words.join(" ")}`); } } return [...bad]; } // The recipes the reference backs. export const RECIPES_FILE = "OPERATING.md"; /** `usage()` of scripts/archilyzer-ops.mjs — what `pnpm ops --help` prints. */ export async function loadOpsUsage(repo = REPO): Promise { const mod = (await import(pathToFileURL(path.join(repo, OPS_SCRIPT)).href)) as { usage?: () => string; }; if (typeof mod.usage !== "function") { throw new Error(`${OPS_SCRIPT} exports no usage()`); } return mod.usage(); } export async function generateCommandsMarkdown(repo = REPO): Promise { const { COMMANDS } = await import("./archilyzer"); const opsUsage = await loadOpsUsage(repo); const source = await readFile(path.join(repo, OPS_SCRIPT), "utf8"); return renderCommandsMarkdown(COMMANDS, opsUsage, opsExamples(source, opsActions(opsUsage))); } /** OPERATING.md's names that no command answers to (see unknownCommandsIn). */ export async function unknownRecipeCommands(repo = REPO): Promise { const { COMMANDS } = await import("./archilyzer"); const recipes = await readFile(path.join(repo, RECIPES_FILE), "utf8"); return unknownCommandsIn(recipes, COMMANDS, await loadOpsUsage(repo)); } // Writes COMMANDS.md (or, with `check`, compares it), then checks that every // command OPERATING.md names exists. 1 when either is wrong. export async function main(opts: { check?: boolean } = {}): Promise { const file = path.join(REPO, COMMANDS_FILE); const want = await generateCommandsMarkdown(); let code = 0; if (opts.check) { const have = await readFile(file, "utf8").catch(() => ""); if (have !== want) { console.error(`${COMMANDS_FILE} is stale — regenerate it with \`archilyzer docs cli\``); code = 1; } } else { await writeFile(file, want); console.log(`wrote ${COMMANDS_FILE}`); } const unknown = await unknownRecipeCommands(); if (unknown.length > 0) { console.error(`${RECIPES_FILE} names commands that do not exist: ${unknown.join("; ")}`); code = 1; } return code; } runIfEntryPoint(import.meta.url, () => main({ check: process.argv.includes("--check") }));