storebound/tools/gen-flags.ts
CRT 36a8bf9625 CLI runs on Node 20+: npx storebound (bunx keeps working)
- cli/lib/rt.ts: the runtime layer on node: modules (spawn / spawnSync with Bun's defaults, exit codes and
  "Executable not found", which with PATHEXT, sleep, serve, Glob, hasher, gunzip, stdin, SQLite, packageRoot,
  config imports with a type-stripping fallback for Node 20); every Bun.* use in cli/ goes through it
- tools/gen-embedded.ts -> cli/lib/embedded.ts: templates/, cli/shots/*.html and Play's Data safety form embedded
  (replaces Bun text imports; the bundle and the compiled sidecar carry them), checked fresh by bun run ci
- package.json: bin dist/storebound.mjs (Node ESM bundle, tools/build-node.ts, prepack), engines node >=20
- bun run ci: node bundle + node smoke (real node: --version, help, check same as Bun, scan, release plan --help,
  init, MCP handshake + tool call); Forgejo CI sets up Node 20; tests/node-compat.test.ts guards cli/
- install writes npx MCP commands when run with Node; bin/storebound falls back to Node
- docs: npx storebound init (Node 20+) or bunx storebound init
2026-10-13 04:03:28 +08:00

106 lines
5.8 KiB
TypeScript

#!/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: <x>.has('name'), .get, .str, .num, .list, .many. 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 <command> ...`) go along, for `storebound <command> --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<string, string[]> = {
'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)\(\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)\(\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<string> {
const flags = new Set<string>(), seen = new Set<string>(), 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 <key> ...`. */
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<string, string[]>; usage: Record<string, string[]>; errors: string[] } {
const src = readFileSync(join(CLI, 'commands.ts'), 'utf8');
const errors: string[] = [];
const per: Record<string, string[]> = {}, usage: Record<string, string[]> = {};
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)}`);
}
}