#!/usr/bin/env bun // Writes cli/data/flags.json: the --flags every command accepts, so the dispatcher (cli/index.ts) can reject an // unknown flag (a typo like --dyr) with the usage before anything runs, also in the compiled sidecar (no sources). // For each command in cli/commands.ts it follows the command module and every local module it imports (static and // dynamic), and collects the literal flag reads: .has('name'), .get, .str, .num, .list, .many, .required. Reads with a computed // name are listed in DYNAMIC below (the scan fails on a new one until it is added there). `ws` forwards its flags to the // command it runs, so it accepts every flag. GLOBAL flags work on every command. The usage lines of each module's // header comment (`// storebound ...`) go along, for `storebound --help`. // bun tools/gen-flags.ts write cli/data/flags.json // bun tools/gen-flags.ts --check exit 1 when the file is stale (tests/cli-help.test.ts runs it) import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; const ROOT = resolve(import.meta.dir, '..'); const CLI = join(ROOT, 'cli'); const OUT = join(CLI, 'data', 'flags.json'); /** Flags every command accepts (read by the dispatcher, lib/ctx, lib/lock, or harmless where unused). */ export const GLOBAL = ['account', 'config', 'dry', 'force-unlock', 'help', 'json', 'only', 'verbose', 'yes']; /** Computed flag reads, per file (relative to cli/): the flags they can read. */ const DYNAMIC: Record = { 'prices.ts': ['out', 'csv'], 'play/rollout.ts': ['halt', 'resume', 'complete'], 'play/release.ts': ['internal', 'alpha', 'beta', 'production'], 'ads/apple.ts': ['adgroup', 'campaign', 'keyword'], 'lib/price-gate.ts': [], // withoutYes: a wrapper of ctx.args.has(n) (confirm without --yes) 'lib/declarations.ts': [], // a.get(k) on a Map, not the args 'lib/catalog.ts': [], // a.get(w.id) on a Map, not the args }; /** Commands that pass their flags on to another command. */ const FORWARDS = new Set(['ws']); // the receiver is captured so HTTP headers and URL search params (res.headers.get('content-type')) are not flags const READ = /([\w$.)\]]*)\.(?:has|get|str|num|list|many|required)\(\s*(['"])([a-z][a-z0-9-]*)\2/g; const NOT_ARGS = /(?:headers|searchParams|params|url|env)$/i; const COMPUTED = /\b(?:args|a|ctx\.args)\.(?:has|get|str|num|list|many|required)\(\s*(?![\s'"])/g; const IMPORT = /(?:\bfrom\s+|\bimport\s*\(\s*)'(\.{1,2}\/[^']+)'/g; function file(spec: string, from: string): string | null { const base = resolve(dirname(from), spec); for (const f of [base, base + '.ts', join(base, 'index.ts')]) if (f.endsWith('.ts') && existsSync(f)) return f; return null; } function scan(entry: string, errors: string[]): Set { const flags = new Set(), seen = new Set(), todo = [entry]; while (todo.length) { const f = todo.pop()!; if (seen.has(f)) continue; seen.add(f); const rel = relative(CLI, f); // lib/embedded.ts holds file texts (templates, shots pages: their own scripts read URL params), not flag reads if (rel === 'index.ts' || rel === 'commands.ts' || rel === join('lib', 'embedded.ts')) continue; const src = readFileSync(f, 'utf8'); for (const m of src.matchAll(READ)) if (!NOT_ARGS.test(m[1]!)) flags.add(m[3]!); for (const fl of DYNAMIC[rel] ?? []) flags.add(fl); if (!(rel in DYNAMIC)) for (const m of src.matchAll(COMPUTED)) { const line = src.slice(0, m.index).split('\n').length; errors.push(`cli/${rel}:${line}: flag read with a computed name; list the flags it can read in DYNAMIC (tools/gen-flags.ts)`); } for (const m of src.matchAll(IMPORT)) { const g = file(m[1]!, f); if (g && g.startsWith(CLI)) todo.push(g); } } return flags; } /** The usage lines of a module's header comment: `// storebound ...`. */ function usageLines(f: string, key: string): string[] { const out: string[] = []; for (const line of readFileSync(f, 'utf8').split('\n')) { if (!line.startsWith('//')) break; const m = /^\/\/\s+(storebound .*)$/.exec(line); if (m && (m[1]! + ' ').startsWith(`storebound ${key} `)) out.push(m[1]!.trimEnd()); } return out; } export function build(): { table: Record; usage: Record; errors: string[] } { const src = readFileSync(join(CLI, 'commands.ts'), 'utf8'); const errors: string[] = []; const per: Record = {}, usage: Record = {}; for (const m of src.matchAll(/^ {2}'([a-z][a-z0-9 -]*)': \[.*?import\('(\.\/[^']+)'\)/gm)) { const f = file(m[2]!, join(CLI, 'commands.ts')); if (!f) { errors.push(`${m[1]}: module ${m[2]} not found`); continue; } per[m[1]!] = [...scan(f, errors)].filter(x => !GLOBAL.includes(x)).sort(); const u = usageLines(f, m[1]!); if (u.length) usage[m[1]!] = u; } const all = [...new Set(Object.values(per).flat())].sort(); for (const k of FORWARDS) if (per[k]) per[k] = all; const table = Object.fromEntries(Object.keys(per).sort().map(k => [k, per[k]!])); return { table, usage: Object.fromEntries(Object.keys(usage).sort().map(k => [k, usage[k]!])), errors: [...new Set(errors)] }; } if (import.meta.main) { const { table, usage, errors } = build(); if (errors.length) { console.error(errors.join('\n')); process.exit(1); } const text = JSON.stringify({ global: GLOBAL, commands: table, usage }, null, 1) + '\n'; if (process.argv.includes('--check')) { const cur = existsSync(OUT) ? readFileSync(OUT, 'utf8') : ''; if (cur !== text) { console.error('cli/data/flags.json is stale: run bun tools/gen-flags.ts'); process.exit(1); } console.log(`flags: ${Object.keys(table).length} commands, fresh`); } else { writeFileSync(OUT, text); console.log(`flags: ${Object.keys(table).length} commands -> ${relative(ROOT, OUT)}`); } }