@decocms/blocks-cli 7.11.1 → 7.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1111 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Unified, incremental orchestrator for the blocks-cli generators.
4
+ *
5
+ * One command replaces the 4–6 hand-chained `tsx node_modules/@decocms/
6
+ * blocks-cli/scripts/generate-*.ts` invocations every consumer site carries
7
+ * in package.json, and skips generators whose inputs did not change since
8
+ * the last successful run (the individual generators only do
9
+ * write-if-changed on their OUTPUT — they still redo the full computation
10
+ * every boot; this wrapper avoids invoking them at all).
11
+ *
12
+ * Usage (from site root):
13
+ * npx tsx node_modules/@decocms/blocks-cli/scripts/generate.ts [options]
14
+ *
15
+ * The individual generate-*.ts scripts remain available and unchanged —
16
+ * use them directly for one-off runs or exotic flags (e.g. --out-file
17
+ * overrides) that this orchestrator deliberately does not re-expose.
18
+ *
19
+ * ## Generators and flag mapping (single surface → per-script argv)
20
+ *
21
+ * | name | script | forwarded flags |
22
+ * |----------|-----------------------------|----------------------------------------------|
23
+ * | blocks | generate-blocks.ts | --blocks-dir |
24
+ * | manifest | generate-blocks-manifest.ts | --blocks-dir |
25
+ * | sections | generate-sections.ts | --sections-dir, --registry |
26
+ * | loaders | generate-loaders.ts | --loaders-dir, --actions-dir, --exclude, |
27
+ * | | | --prune-by-decofile |
28
+ * | invoke | generate-invoke.ts | --apps-dir |
29
+ * | schema | generate-schema.ts | --sections-dir → --sections, --loaders-dir → |
30
+ * | | | --loaders, --site, --namespace, --platform, |
31
+ * | | | --skip-apps |
32
+ *
33
+ * Orchestrator-only flags: --only, --skip, --force, --dry-run, --no-registry.
34
+ * Run with --help for the full reference.
35
+ *
36
+ * ## Which generators run by default (sensible per-artifact presence)
37
+ *
38
+ * - blocks: `.deco/blocks/` exists AND (@decocms/nextjs is NOT installed
39
+ * OR `.deco/blocks.gen.json` already exists). Next.js sites use
40
+ * the static-import manifest instead of the JSON snapshot.
41
+ * - manifest: `.deco/blocks/` exists AND (@decocms/nextjs IS installed OR
42
+ * `.deco/blocksManifest.gen.ts` already exists).
43
+ * - sections: the sections dir exists. `--registry` defaults ON when
44
+ * @decocms/nextjs is installed or the existing sections.gen.ts
45
+ * already carries a `sectionImports` map (adopt-what's-there);
46
+ * override with --registry / --no-registry.
47
+ * - loaders: the loaders dir or the actions dir exists.
48
+ * - invoke: an apps invoke.ts is resolvable (--apps-dir or the installed
49
+ * @decocms/apps-vtex package) AND @tanstack/react-start is
50
+ * installed (the emitted file imports it — Next.js sites can't
51
+ * consume it).
52
+ * - schema: the sections dir exists AND tsconfig.json exists (the
53
+ * generator hard-requires both).
54
+ *
55
+ * `--dry-run` prints exactly this decision table for the current site,
56
+ * including why each generator would run (fresh) or be skipped (cached).
57
+ *
58
+ * ## Two-stage DAG
59
+ *
60
+ * Stage 1 runs blocks, manifest, sections, loaders, invoke CONCURRENTLY.
61
+ * Verified disjoint by reading each script (2026-07-10):
62
+ * - blocks reads .deco/blocks/*.json writes .deco/blocks.gen.{json,ts}
63
+ * - manifest reads .deco/blocks (filenames) writes .deco/blocksManifest.gen.ts
64
+ * - sections reads src/sections/** writes .deco/sections.gen.ts
65
+ * - loaders reads src/loaders,src/actions (+ a writes .deco/loaders.gen.ts
66
+ * read-only .deco/blocks scan under
67
+ * --prune-by-decofile — shared READS
68
+ * are safe)
69
+ * - invoke reads node_modules/.../invoke.ts writes src/server/invoke.gen.ts
70
+ * No stage-1 generator writes another stage-1 generator's input.
71
+ *
72
+ * Stage 2 runs schema AFTER stage 1 settles. This is a real dependency, not
73
+ * just log hygiene: generate-schema.ts never reads the stage-1 *artifacts*
74
+ * directly (its directory walks exclude *.gen.* via codegenExclusions, and
75
+ * it only walks src/sections, src/loaders, src/apps), BUT its ts-morph type
76
+ * resolution follows imports from those files to ANY reachable module —
77
+ * including src/server/invoke.gen.ts, which sections commonly import and
78
+ * which stage 1's invoke generator REWRITES. Running schema concurrently
79
+ * risks resolving types through a half-written file. It is also by far the
80
+ * heavyweight (full type-check across src/), so sequencing it second keeps
81
+ * the cheap artifacts landing fast and the log ordering deterministic.
82
+ *
83
+ * ## Incremental cache — two tiers, git-index style
84
+ *
85
+ * COMMITTED tier: `.deco/generate.digests.json` — commit it alongside the
86
+ * generated artifacts it vouches for. One compact record per generator:
87
+ * - v: CACHE_SCHEMA_VERSION (format changes self-bust),
88
+ * - args: the exact argv forwarded to that generator,
89
+ * - cli: blocks-cli's own package version,
90
+ * - deco: the resolved versions of every @decocms/* package in the
91
+ * site's node_modules (a lockstep bump invalidates everything),
92
+ * - inputs: sha256 over the sorted (relPath, contentSha256) pairs of the
93
+ * generator's input set — CONTENT hashes, machine-independent.
94
+ * Because the record is content-addressed, a FRESH CLONE with unchanged
95
+ * inputs cache-hits every generator (schema's full ts-morph pass becomes a
96
+ * content-hash sweep of src/**). Serialization is deterministic (sorted
97
+ * generator keys, fixed field order, one record per line) so PR diffs stay
98
+ * small; on a merge conflict, resolve either way and rerun `generate` — it
99
+ * reconciles by regenerating whatever the chosen records don't vouch for.
100
+ *
101
+ * LOCAL tier: `.deco/.cache/stat-memo.json` (never committed — the
102
+ * orchestrator writes a `.deco/.cache/.gitignore` containing `*`, since
103
+ * sites commit `.deco/`). Maps (relPath, size, mtimeMs) → contentSha256 so
104
+ * warm local runs skip rehashing unchanged files. It NEVER influences
105
+ * correctness — it is purely a rehash-avoidance layer over the committed
106
+ * tier (like git's index, it trusts size+mtimeMs; a content edit that
107
+ * preserves both is not detected, same as git). Cached log lines gain a
108
+ * `content-verified` marker when the hit required actual content hashing
109
+ * (memo cold / stats moved) rather than pure memo lookups.
110
+ *
111
+ * Skip = the record matches AND every output file still exists (a deleted
112
+ * artifact is a miss even on a clean record). A generator that runs
113
+ * rewrites its record only AFTER success — a crashed run leaves no record,
114
+ * so the next run retries. `--force` bypasses all checks.
115
+ *
116
+ * schema's input set is deliberately BROAD: all of src/**\/*.{ts,tsx} +
117
+ * tsconfig.json + the installed @decocms/apps-* packages' src trees (when
118
+ * not --skip-apps). Do not try to narrow it: any type reachable from a
119
+ * section/loader Props type is an input to the emitted JSON Schema, and
120
+ * import reachability across src/ is not knowable without doing the same
121
+ * ts-morph work the generator itself does.
122
+ */
123
+ import * as cp from "node:child_process";
124
+ import { createHash } from "node:crypto";
125
+ import fs from "node:fs";
126
+ import { createRequire } from "node:module";
127
+ import path from "node:path";
128
+ import { fileURLToPath } from "node:url";
129
+ import { isExcludedCodegenFile } from "./lib/codegenExclusions";
130
+
131
+ // Bump when the digests file format or digest recipe changes — recorded in
132
+ // every digest record so old files self-bust instead of mis-validating.
133
+ export const CACHE_SCHEMA_VERSION = 2;
134
+
135
+ export const GENERATOR_NAMES = [
136
+ "blocks",
137
+ "manifest",
138
+ "sections",
139
+ "loaders",
140
+ "invoke",
141
+ "schema",
142
+ ] as const;
143
+ export type GeneratorName = (typeof GENERATOR_NAMES)[number];
144
+
145
+ // Accepted aliases for --only/--skip (the script file is called
146
+ // generate-blocks-manifest.ts; some sites name the npm script after it).
147
+ const NAME_ALIASES: Record<string, GeneratorName> = {
148
+ "blocks-manifest": "manifest",
149
+ blocksmanifest: "manifest",
150
+ meta: "schema",
151
+ };
152
+
153
+ const CACHE_DIR_REL = path.join(".deco", ".cache");
154
+ /** COMMITTED tier — content-hash digest records, one per generator. */
155
+ const DIGESTS_FILE_REL = path.join(".deco", "generate.digests.json");
156
+ /** LOCAL tier — (size, mtimeMs) → contentSha256 memo, never committed. */
157
+ const STAT_MEMO_FILE_REL = path.join(CACHE_DIR_REL, "stat-memo.json");
158
+ /** Pre-v2 machine-local cache — superseded, deleted on sight. */
159
+ const LEGACY_CACHE_FILE_REL = path.join(CACHE_DIR_REL, "generate.json");
160
+
161
+ const USAGE = `\
162
+ Usage: tsx node_modules/@decocms/blocks-cli/scripts/generate.ts [options]
163
+
164
+ Runs the blocks-cli generators (blocks, manifest, sections, loaders, invoke,
165
+ schema) as one incremental command. Generators whose inputs are unchanged
166
+ since the last successful run are skipped entirely. Stage 1 (blocks,
167
+ manifest, sections, loaders, invoke) runs concurrently; schema runs after.
168
+
169
+ Selection:
170
+ --only <names> Comma-separated subset to consider (${GENERATOR_NAMES.join(",")})
171
+ --skip <names> Comma-separated generators to exclude
172
+ --force Ignore the cache; run everything selected
173
+ --dry-run Print what would run / skip / stay disabled, then exit
174
+
175
+ Forwarded to the individual generators:
176
+ --blocks-dir <dir> blocks + manifest input (default .deco/blocks)
177
+ --sections-dir <dir> sections input + schema --sections (default src/sections)
178
+ --loaders-dir <dir> loaders input + schema --loaders (default src/loaders)
179
+ --actions-dir <dir> loaders actions input (default src/actions)
180
+ --apps-dir <dir> invoke's apps package location (default: auto-resolve
181
+ node_modules/@decocms/apps-vtex)
182
+ --registry sections: emit the lazy sectionImports registry
183
+ --no-registry sections: force the registry OFF (overrides the
184
+ Next.js-detected default)
185
+ --exclude <keys> loaders: comma-separated loader keys to skip
186
+ --prune-by-decofile <d> loaders: only emit CMS-referenced entries
187
+ --site <name> schema: site name (default storefront)
188
+ --namespace <ns> schema: section namespace (default site)
189
+ --platform <name> schema: platform (default cloudflare)
190
+ --skip-apps schema: skip app schema generation
191
+
192
+ Not re-exposed here (use the individual scripts): --out-file/--out overrides,
193
+ schema's --version/--apps, invoke's --out-file. Defaults match the scripts.
194
+
195
+ Cache: .deco/generate.digests.json holds committed, machine-independent
196
+ content-hash records — commit it with the generated artifacts so fresh
197
+ clones cache-hit. .deco/.cache/stat-memo.json (auto-gitignored) only speeds
198
+ up local rehashing. Pass --force (or delete the digests file) to rebuild.
199
+ `;
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // CLI parsing
203
+ // ---------------------------------------------------------------------------
204
+
205
+ export interface CliOptions {
206
+ only: GeneratorName[] | null;
207
+ skip: GeneratorName[];
208
+ force: boolean;
209
+ dryRun: boolean;
210
+ help: boolean;
211
+ blocksDir: string;
212
+ sectionsDir: string;
213
+ loadersDir: string;
214
+ actionsDir: string;
215
+ appsDir: string | null;
216
+ registry: boolean | null; // null = auto-detect
217
+ exclude: string | null;
218
+ pruneByDecofile: string | null;
219
+ site: string | null;
220
+ namespace: string | null;
221
+ platform: string | null;
222
+ skipApps: boolean;
223
+ }
224
+
225
+ function parseNames(raw: string, flag: string): GeneratorName[] {
226
+ return raw
227
+ .split(",")
228
+ .map((s) => s.trim().toLowerCase())
229
+ .filter(Boolean)
230
+ .map((s) => {
231
+ const name = (NAME_ALIASES[s] ?? s) as GeneratorName;
232
+ if (!GENERATOR_NAMES.includes(name)) {
233
+ throw new Error(
234
+ `Unknown generator "${s}" in ${flag}. Valid: ${GENERATOR_NAMES.join(", ")}`,
235
+ );
236
+ }
237
+ return name;
238
+ });
239
+ }
240
+
241
+ export function parseCliOptions(argv: string[]): CliOptions {
242
+ const opts: CliOptions = {
243
+ only: null,
244
+ skip: [],
245
+ force: false,
246
+ dryRun: false,
247
+ help: false,
248
+ blocksDir: path.join(".deco", "blocks"),
249
+ sectionsDir: path.join("src", "sections"),
250
+ loadersDir: path.join("src", "loaders"),
251
+ actionsDir: path.join("src", "actions"),
252
+ appsDir: null,
253
+ registry: null,
254
+ exclude: null,
255
+ pruneByDecofile: null,
256
+ site: null,
257
+ namespace: null,
258
+ platform: null,
259
+ skipApps: false,
260
+ };
261
+
262
+ const valueOf = (i: number, flag: string): string => {
263
+ const v = argv[i + 1];
264
+ if (!v || v.startsWith("--")) throw new Error(`${flag} requires a value`);
265
+ return v;
266
+ };
267
+
268
+ for (let i = 0; i < argv.length; i++) {
269
+ const a = argv[i];
270
+ switch (a) {
271
+ case "--help":
272
+ case "-h":
273
+ opts.help = true;
274
+ break;
275
+ case "--only":
276
+ opts.only = parseNames(valueOf(i, a), a);
277
+ i++;
278
+ break;
279
+ case "--skip":
280
+ opts.skip = parseNames(valueOf(i, a), a);
281
+ i++;
282
+ break;
283
+ case "--force":
284
+ opts.force = true;
285
+ break;
286
+ case "--dry-run":
287
+ opts.dryRun = true;
288
+ break;
289
+ case "--blocks-dir":
290
+ opts.blocksDir = valueOf(i, a);
291
+ i++;
292
+ break;
293
+ case "--sections-dir":
294
+ opts.sectionsDir = valueOf(i, a);
295
+ i++;
296
+ break;
297
+ case "--loaders-dir":
298
+ opts.loadersDir = valueOf(i, a);
299
+ i++;
300
+ break;
301
+ case "--actions-dir":
302
+ opts.actionsDir = valueOf(i, a);
303
+ i++;
304
+ break;
305
+ case "--apps-dir":
306
+ opts.appsDir = valueOf(i, a);
307
+ i++;
308
+ break;
309
+ case "--registry":
310
+ opts.registry = true;
311
+ break;
312
+ case "--no-registry":
313
+ opts.registry = false;
314
+ break;
315
+ case "--exclude":
316
+ opts.exclude = valueOf(i, a);
317
+ i++;
318
+ break;
319
+ case "--prune-by-decofile":
320
+ opts.pruneByDecofile = valueOf(i, a);
321
+ i++;
322
+ break;
323
+ case "--site":
324
+ opts.site = valueOf(i, a);
325
+ i++;
326
+ break;
327
+ case "--namespace":
328
+ opts.namespace = valueOf(i, a);
329
+ i++;
330
+ break;
331
+ case "--platform":
332
+ opts.platform = valueOf(i, a);
333
+ i++;
334
+ break;
335
+ case "--skip-apps":
336
+ opts.skipApps = true;
337
+ break;
338
+ default:
339
+ throw new Error(`Unknown option "${a}". Run with --help for usage.`);
340
+ }
341
+ }
342
+ return opts;
343
+ }
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // Filesystem helpers (stat-only — content hashing happens in the hasher,
347
+ // where the stat memo can skip it)
348
+ // ---------------------------------------------------------------------------
349
+
350
+ /** [relative path, size, mtimeMs] — the unit of input enumeration. */
351
+ type InputEntry = [string, number, number];
352
+
353
+ function statEntry(cwd: string, absPath: string): InputEntry | null {
354
+ try {
355
+ const st = fs.statSync(absPath);
356
+ if (!st.isFile()) return null;
357
+ return [path.relative(cwd, absPath).replaceAll("\\", "/"), st.size, st.mtimeMs];
358
+ } catch {
359
+ return null;
360
+ }
361
+ }
362
+
363
+ /** Top-level *.json files of a directory (the blocks-dir contract). */
364
+ function listTopLevelJson(cwd: string, dir: string): InputEntry[] {
365
+ let names: fs.Dirent[];
366
+ try {
367
+ names = fs.readdirSync(dir, { withFileTypes: true });
368
+ } catch {
369
+ return [];
370
+ }
371
+ const entries: InputEntry[] = [];
372
+ for (const e of names) {
373
+ if (!e.isFile() || !e.name.endsWith(".json")) continue;
374
+ const entry = statEntry(cwd, path.join(dir, e.name));
375
+ if (entry) entries.push(entry);
376
+ }
377
+ return entries;
378
+ }
379
+
380
+ /** Recursive walk collecting files with the given extensions. */
381
+ function walkTree(
382
+ cwd: string,
383
+ dir: string,
384
+ exts: string[],
385
+ excludeFile?: (name: string) => boolean,
386
+ ): InputEntry[] {
387
+ const entries: InputEntry[] = [];
388
+ const visit = (d: string) => {
389
+ let dirents: fs.Dirent[];
390
+ try {
391
+ dirents = fs.readdirSync(d, { withFileTypes: true });
392
+ } catch {
393
+ return;
394
+ }
395
+ for (const e of dirents) {
396
+ const full = path.join(d, e.name);
397
+ if (e.isDirectory()) {
398
+ if (e.name === "node_modules" || e.name === ".git") continue;
399
+ visit(full);
400
+ } else if (e.isFile() && exts.some((x) => e.name.endsWith(x))) {
401
+ if (excludeFile?.(e.name)) continue;
402
+ const entry = statEntry(cwd, full);
403
+ if (entry) entries.push(entry);
404
+ }
405
+ }
406
+ };
407
+ visit(dir);
408
+ return entries;
409
+ }
410
+
411
+ function sortEntries(entries: InputEntry[]): InputEntry[] {
412
+ return entries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
413
+ }
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // Version fingerprinting — a lockstep @decocms/* bump must invalidate all
417
+ // caches even when no site file changed (generator behavior lives in the
418
+ // packages, and generate-invoke/schema read node_modules sources directly).
419
+ // ---------------------------------------------------------------------------
420
+
421
+ function readPkgVersion(pkgDir: string): string | null {
422
+ try {
423
+ const raw = fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8");
424
+ const version = (JSON.parse(raw) as { version?: string }).version;
425
+ return typeof version === "string" ? version : null;
426
+ } catch {
427
+ return null;
428
+ }
429
+ }
430
+
431
+ function decoPackageVersions(cwd: string): Record<string, string> {
432
+ const versions: Record<string, string> = {};
433
+ const scopeDir = path.join(cwd, "node_modules", "@decocms");
434
+ let names: string[];
435
+ try {
436
+ names = fs.readdirSync(scopeDir);
437
+ } catch {
438
+ return versions;
439
+ }
440
+ for (const name of names.sort()) {
441
+ const v = readPkgVersion(path.join(scopeDir, name));
442
+ if (v) versions[`@decocms/${name}`] = v;
443
+ }
444
+ return versions;
445
+ }
446
+
447
+ /** blocks-cli's own version — resolved relative to THIS script, so it works
448
+ * both in the monorepo (0.0.0) and installed under a site's node_modules. */
449
+ function ownVersion(): string {
450
+ const selfDir = path.dirname(fileURLToPath(import.meta.url));
451
+ return readPkgVersion(path.resolve(selfDir, "..")) ?? "unknown";
452
+ }
453
+
454
+ function hasPackage(cwd: string, name: string): boolean {
455
+ return fs.existsSync(path.join(cwd, "node_modules", ...name.split("/"), "package.json"));
456
+ }
457
+
458
+ // ---------------------------------------------------------------------------
459
+ // Plan — which generators exist, their argv, inputs, outputs, enablement
460
+ // ---------------------------------------------------------------------------
461
+
462
+ export interface GeneratorPlan {
463
+ name: GeneratorName;
464
+ /** Absolute path of the sibling generate-*.ts script. */
465
+ script: string;
466
+ /** Exact argv forwarded to the script (also part of the digest). */
467
+ args: string[];
468
+ /** 1 = concurrent batch, 2 = after stage 1. */
469
+ stage: 1 | 2;
470
+ enabled: boolean;
471
+ disabledReason?: string;
472
+ /** Lazily computed sorted input fingerprint. */
473
+ inputs: () => InputEntry[];
474
+ /** Output artifacts (relative to cwd) — all must exist for a cache hit. */
475
+ outputs: string[];
476
+ }
477
+
478
+ /** Resolve generate-invoke's apps dir exactly like the generator does
479
+ * (minus the legacy ../apps-start/vtex fallback, which is dev-checkout-only). */
480
+ function resolveInvokeSource(cwd: string, appsDir: string | null): string | null {
481
+ const roots = appsDir
482
+ ? [path.resolve(cwd, appsDir)]
483
+ : [path.resolve(cwd, "node_modules/@decocms/apps-vtex")];
484
+ for (const root of roots) {
485
+ for (const c of [root, path.join(root, "src")]) {
486
+ if (fs.existsSync(path.join(c, "invoke.ts"))) return path.join(c, "invoke.ts");
487
+ }
488
+ }
489
+ return null;
490
+ }
491
+
492
+ export function buildPlan(cwd: string, opts: CliOptions): GeneratorPlan[] {
493
+ const scriptsDir = path.dirname(fileURLToPath(import.meta.url));
494
+ const blocksDirAbs = path.resolve(cwd, opts.blocksDir);
495
+ const sectionsDirAbs = path.resolve(cwd, opts.sectionsDir);
496
+ const loadersDirAbs = path.resolve(cwd, opts.loadersDir);
497
+ const actionsDirAbs = path.resolve(cwd, opts.actionsDir);
498
+
499
+ const blocksDirExists = fs.existsSync(blocksDirAbs);
500
+ const sectionsDirExists = fs.existsSync(sectionsDirAbs);
501
+ const isNext = hasPackage(cwd, "@decocms/nextjs");
502
+ const hasTanstackStart = hasPackage(cwd, "@tanstack/react-start");
503
+
504
+ const blocksGenJson = path.join(".deco", "blocks.gen.json");
505
+ const manifestGen = path.join(".deco", "blocksManifest.gen.ts");
506
+ const sectionsGen = path.join(".deco", "sections.gen.ts");
507
+
508
+ // --registry default: Next.js sites need the lazy sectionImports map
509
+ // (webpack has no import.meta.glob); also adopt an existing registry so
510
+ // regenerating never silently drops it. Explicit --registry/--no-registry wins.
511
+ let registry = opts.registry;
512
+ if (registry === null) {
513
+ let existingHasRegistry = false;
514
+ try {
515
+ existingHasRegistry = fs
516
+ .readFileSync(path.resolve(cwd, sectionsGen), "utf-8")
517
+ .includes("export const sectionImports");
518
+ } catch {}
519
+ registry = isNext || existingHasRegistry;
520
+ }
521
+
522
+ const invokeSource = resolveInvokeSource(cwd, opts.appsDir);
523
+
524
+ const disabled = (reason: string) => ({ enabled: false, disabledReason: reason });
525
+ const enabledIf = (cond: boolean, reason: string) =>
526
+ cond ? { enabled: true } : disabled(reason);
527
+
528
+ const plans: GeneratorPlan[] = [
529
+ {
530
+ name: "blocks",
531
+ script: path.join(scriptsDir, "generate-blocks.ts"),
532
+ args: ["--blocks-dir", opts.blocksDir],
533
+ stage: 1,
534
+ ...enabledIf(
535
+ blocksDirExists && (!isNext || fs.existsSync(path.resolve(cwd, blocksGenJson))),
536
+ !blocksDirExists
537
+ ? `${opts.blocksDir} does not exist`
538
+ : `@decocms/nextjs site without an existing ${blocksGenJson} (Next.js uses the manifest)`,
539
+ ),
540
+ inputs: () => sortEntries(listTopLevelJson(cwd, blocksDirAbs)),
541
+ outputs: [blocksGenJson, path.join(".deco", "blocks.gen.ts")],
542
+ },
543
+ {
544
+ name: "manifest",
545
+ script: path.join(scriptsDir, "generate-blocks-manifest.ts"),
546
+ args: ["--blocks-dir", opts.blocksDir],
547
+ stage: 1,
548
+ ...enabledIf(
549
+ blocksDirExists && (isNext || fs.existsSync(path.resolve(cwd, manifestGen))),
550
+ !blocksDirExists
551
+ ? `${opts.blocksDir} does not exist`
552
+ : `not a @decocms/nextjs site and no existing ${manifestGen}`,
553
+ ),
554
+ // The manifest only depends on the FILENAME set, but fingerprinting
555
+ // (path, size, mtime) is a cheap superset — a content-only edit
556
+ // re-runs a generator whose write-if-changed makes that a no-op.
557
+ inputs: () => sortEntries(listTopLevelJson(cwd, blocksDirAbs)),
558
+ outputs: [manifestGen],
559
+ },
560
+ {
561
+ name: "sections",
562
+ script: path.join(scriptsDir, "generate-sections.ts"),
563
+ args: ["--sections-dir", opts.sectionsDir, ...(registry ? ["--registry"] : [])],
564
+ stage: 1,
565
+ ...enabledIf(sectionsDirExists, `${opts.sectionsDir} does not exist`),
566
+ inputs: () =>
567
+ sortEntries(walkTree(cwd, sectionsDirAbs, [".ts", ".tsx"], isExcludedCodegenFile)),
568
+ outputs: [sectionsGen],
569
+ },
570
+ {
571
+ name: "loaders",
572
+ script: path.join(scriptsDir, "generate-loaders.ts"),
573
+ args: [
574
+ "--loaders-dir",
575
+ opts.loadersDir,
576
+ "--actions-dir",
577
+ opts.actionsDir,
578
+ ...(opts.exclude ? ["--exclude", opts.exclude] : []),
579
+ ...(opts.pruneByDecofile ? ["--prune-by-decofile", opts.pruneByDecofile] : []),
580
+ ],
581
+ stage: 1,
582
+ ...enabledIf(
583
+ fs.existsSync(loadersDirAbs) || fs.existsSync(actionsDirAbs),
584
+ `neither ${opts.loadersDir} nor ${opts.actionsDir} exists`,
585
+ ),
586
+ inputs: () =>
587
+ sortEntries([
588
+ ...walkTree(cwd, loadersDirAbs, [".ts", ".tsx"]),
589
+ ...walkTree(cwd, actionsDirAbs, [".ts", ".tsx"]),
590
+ // Under --prune-by-decofile the CMS JSONs decide which entries are
591
+ // emitted, so they become inputs too.
592
+ ...(opts.pruneByDecofile
593
+ ? walkTree(cwd, path.resolve(cwd, opts.pruneByDecofile), [".json"])
594
+ : []),
595
+ ]),
596
+ outputs: [path.join(".deco", "loaders.gen.ts")],
597
+ },
598
+ {
599
+ name: "invoke",
600
+ script: path.join(scriptsDir, "generate-invoke.ts"),
601
+ args: opts.appsDir ? ["--apps-dir", opts.appsDir] : [],
602
+ stage: 1,
603
+ ...enabledIf(
604
+ invokeSource !== null && hasTanstackStart,
605
+ invokeSource === null
606
+ ? "no apps invoke.ts found (@decocms/apps-vtex not installed and no --apps-dir)"
607
+ : "@tanstack/react-start not installed (invoke.gen.ts targets TanStack Start)",
608
+ ),
609
+ // invoke.ts is the file the generator parses; the surrounding package's
610
+ // action/type sources are fingerprinted via the @decocms/* version set
611
+ // (node_modules content only changes with a version change in practice).
612
+ inputs: () => sortEntries(invokeSource ? [statEntry(cwd, invokeSource)!].filter(Boolean) : []),
613
+ outputs: [path.join("src", "server", "invoke.gen.ts")],
614
+ },
615
+ {
616
+ name: "schema",
617
+ script: path.join(scriptsDir, "generate-schema.ts"),
618
+ args: [
619
+ "--sections",
620
+ opts.sectionsDir,
621
+ "--loaders",
622
+ opts.loadersDir,
623
+ ...(opts.site ? ["--site", opts.site] : []),
624
+ ...(opts.namespace ? ["--namespace", opts.namespace] : []),
625
+ ...(opts.platform ? ["--platform", opts.platform] : []),
626
+ ...(opts.skipApps ? ["--skip-apps"] : []),
627
+ ],
628
+ stage: 2,
629
+ ...enabledIf(
630
+ sectionsDirExists && fs.existsSync(path.join(cwd, "tsconfig.json")),
631
+ !sectionsDirExists
632
+ ? `${opts.sectionsDir} does not exist`
633
+ : "tsconfig.json not found (generate-schema requires it)",
634
+ ),
635
+ // BROAD BY DESIGN — see the header comment. Any type reachable from a
636
+ // section/loader Props type feeds the emitted schema, so the whole
637
+ // src/ tree + tsconfig + the installed @decocms/apps-* sources (their
638
+ // loaders are scanned when not --skip-apps) are inputs. Narrowing this
639
+ // would require the same import-graph resolution the generator does.
640
+ inputs: () => {
641
+ const entries = [
642
+ ...walkTree(cwd, path.join(cwd, "src"), [".ts", ".tsx"]),
643
+ ...[statEntry(cwd, path.join(cwd, "tsconfig.json"))].filter(
644
+ (e): e is InputEntry => e !== null,
645
+ ),
646
+ ];
647
+ if (!opts.skipApps) {
648
+ const scopeDir = path.join(cwd, "node_modules", "@decocms");
649
+ let names: string[] = [];
650
+ try {
651
+ names = fs.readdirSync(scopeDir).filter((n) => n.startsWith("apps-"));
652
+ } catch {}
653
+ for (const name of names.sort()) {
654
+ entries.push(...walkTree(cwd, path.join(scopeDir, name, "src"), [".ts", ".tsx"]));
655
+ }
656
+ }
657
+ return sortEntries(entries);
658
+ },
659
+ outputs: [path.join(".deco", "meta.gen.json")],
660
+ },
661
+ ];
662
+
663
+ // Apply --only / --skip on top of auto-enablement. --only also FORCES a
664
+ // generator on (explicit intent beats detection) unless its hard inputs
665
+ // are missing in a way that would just crash the child.
666
+ for (const plan of plans) {
667
+ if (opts.only && !opts.only.includes(plan.name)) {
668
+ plan.enabled = false;
669
+ plan.disabledReason = "not in --only";
670
+ } else if (opts.only?.includes(plan.name) && !plan.enabled) {
671
+ // Explicitly requested: run it and let the generator report its own
672
+ // error if inputs are truly missing — that is what the user asked for.
673
+ plan.enabled = true;
674
+ plan.disabledReason = undefined;
675
+ }
676
+ if (opts.skip.includes(plan.name)) {
677
+ plan.enabled = false;
678
+ plan.disabledReason = "listed in --skip";
679
+ }
680
+ }
681
+
682
+ return plans;
683
+ }
684
+
685
+ // ---------------------------------------------------------------------------
686
+ // Committed tier — .deco/generate.digests.json
687
+ // ---------------------------------------------------------------------------
688
+
689
+ /** One committed record per generator. Every field is machine-independent
690
+ * (content hashes, versions, argv) — never stat data. */
691
+ interface DigestRecord {
692
+ v: number;
693
+ args: string[];
694
+ cli: string;
695
+ /** Sorted `name@version` pairs of the installed @decocms/* packages. */
696
+ deco: string;
697
+ /** sha256 over the sorted [relPath, contentSha256] pairs of the inputs. */
698
+ inputs: string;
699
+ }
700
+
701
+ type DigestMap = Partial<Record<GeneratorName, DigestRecord>>;
702
+
703
+ const DIGESTS_NOTE =
704
+ "Generated by @decocms/blocks-cli's generate command - COMMIT this file. " +
705
+ "It records content hashes of each generator's inputs so a fresh clone " +
706
+ "with unchanged inputs skips every generator. On merge conflict, resolve " +
707
+ "either way and rerun generate: it regenerates whatever the kept records " +
708
+ "do not vouch for and rewrites this file.";
709
+
710
+ function loadDigests(cwd: string): DigestMap {
711
+ try {
712
+ const parsed = JSON.parse(fs.readFileSync(path.join(cwd, DIGESTS_FILE_REL), "utf-8")) as {
713
+ version?: number;
714
+ generators?: DigestMap;
715
+ };
716
+ if (parsed?.version !== CACHE_SCHEMA_VERSION || typeof parsed.generators !== "object") {
717
+ return {};
718
+ }
719
+ return parsed.generators ?? {};
720
+ } catch {
721
+ // Missing file, or unparseable (e.g. a merge conflict left <<<<<<<
722
+ // markers) — treat as empty; regeneration reconciles and rewrites it.
723
+ return {};
724
+ }
725
+ }
726
+
727
+ /** Deterministic, diff-friendly serialization: sorted generator keys, fixed
728
+ * field order, ONE compact record per line. Two runs over identical state
729
+ * produce byte-identical files, so conflicts only appear on real drift. */
730
+ function serializeDigests(generators: DigestMap): string {
731
+ const names = (Object.keys(generators) as GeneratorName[]).sort();
732
+ const lines = names.map((name) => {
733
+ const r = generators[name] as DigestRecord;
734
+ const record = JSON.stringify({
735
+ v: r.v,
736
+ args: r.args,
737
+ cli: r.cli,
738
+ deco: r.deco,
739
+ inputs: r.inputs,
740
+ });
741
+ return ` ${JSON.stringify(name)}: ${record}`;
742
+ });
743
+ return [
744
+ "{",
745
+ ` "//": ${JSON.stringify(DIGESTS_NOTE)},`,
746
+ ` "version": ${CACHE_SCHEMA_VERSION},`,
747
+ " \"generators\": {",
748
+ lines.join(",\n"),
749
+ " }",
750
+ "}",
751
+ "",
752
+ ].join("\n");
753
+ }
754
+
755
+ function saveDigests(cwd: string, generators: DigestMap): void {
756
+ fs.mkdirSync(path.join(cwd, ".deco"), { recursive: true });
757
+ fs.writeFileSync(path.join(cwd, DIGESTS_FILE_REL), serializeDigests(generators));
758
+ }
759
+
760
+ // ---------------------------------------------------------------------------
761
+ // Local tier — .deco/.cache/stat-memo.json (rehash avoidance ONLY)
762
+ // ---------------------------------------------------------------------------
763
+
764
+ /** mkdir .deco/.cache and drop a `.gitignore` containing `*` — sites commit
765
+ * `.deco/`, and the memo (machine-local mtimes) must never land in git. */
766
+ function ensureCacheDir(cwd: string): void {
767
+ const dir = path.join(cwd, CACHE_DIR_REL);
768
+ fs.mkdirSync(dir, { recursive: true });
769
+ const gitignore = path.join(dir, ".gitignore");
770
+ if (!fs.existsSync(gitignore)) fs.writeFileSync(gitignore, "*\n");
771
+ }
772
+
773
+ interface ContentHasher {
774
+ /** contentSha256 of an enumerated input, via the memo when stats match. */
775
+ hashEntry(entry: InputEntry): string;
776
+ /** Whether THIS RUN had to read the file's bytes (memo cold / stats moved).
777
+ * Sticky for the whole run, so every generator sharing that input reports
778
+ * `content-verified`, not just whichever one hashed it first. */
779
+ wasRehashed(relPath: string): boolean;
780
+ /** Persist the memo (skipped when nothing was rehashed). */
781
+ save(): void;
782
+ }
783
+
784
+ /** The stat memo trusts (size, mtimeMs) exactly like git's index trusts its
785
+ * stat cache: a content edit that preserves BOTH size and mtimeMs is not
786
+ * detected. That is the same trade git makes, and touching the file (or
787
+ * deleting .deco/.cache) recovers. Correctness never depends on the memo —
788
+ * it only decides whether we re-read bytes to compute the same sha256. */
789
+ function createContentHasher(cwd: string): ContentHasher {
790
+ let files: Record<string, [size: number, mtimeMs: number, sha256: string]> = {};
791
+ try {
792
+ const parsed = JSON.parse(
793
+ fs.readFileSync(path.join(cwd, STAT_MEMO_FILE_REL), "utf-8"),
794
+ ) as { version?: number; files?: typeof files };
795
+ if (parsed?.version === CACHE_SCHEMA_VERSION && typeof parsed.files === "object") {
796
+ files = parsed.files ?? {};
797
+ }
798
+ } catch {}
799
+ let dirty = false;
800
+ const rehashedPaths = new Set<string>();
801
+ return {
802
+ hashEntry([rel, size, mtimeMs]: InputEntry): string {
803
+ const memo = files[rel];
804
+ if (memo && memo[0] === size && memo[1] === mtimeMs) return memo[2];
805
+ let sha: string;
806
+ try {
807
+ sha = createHash("sha256").update(fs.readFileSync(path.join(cwd, rel))).digest("hex");
808
+ } catch {
809
+ // Vanished between stat and read — a sentinel that can never equal a
810
+ // recorded sha256 forces a re-run without poisoning the memo.
811
+ return `unreadable:${rel}`;
812
+ }
813
+ files[rel] = [size, mtimeMs, sha];
814
+ dirty = true;
815
+ rehashedPaths.add(rel);
816
+ return sha;
817
+ },
818
+ wasRehashed(relPath: string): boolean {
819
+ return rehashedPaths.has(relPath);
820
+ },
821
+ save() {
822
+ if (!dirty) return;
823
+ ensureCacheDir(cwd);
824
+ fs.writeFileSync(
825
+ path.join(cwd, STAT_MEMO_FILE_REL),
826
+ `${JSON.stringify({ version: CACHE_SCHEMA_VERSION, files })}\n`,
827
+ );
828
+ },
829
+ };
830
+ }
831
+
832
+ function computeDigestRecord(
833
+ plan: Pick<GeneratorPlan, "name" | "args" | "inputs">,
834
+ versions: Record<string, string>,
835
+ cliVersion: string,
836
+ hasher: ContentHasher,
837
+ ): DigestRecord {
838
+ const pairs = plan.inputs().map((e) => [e[0], hasher.hashEntry(e)]);
839
+ return {
840
+ v: CACHE_SCHEMA_VERSION,
841
+ args: plan.args,
842
+ cli: cliVersion,
843
+ deco: Object.entries(versions)
844
+ .map(([name, version]) => `${name}@${version}`)
845
+ .join(" "),
846
+ inputs: createHash("sha256").update(JSON.stringify(pairs)).digest("hex"),
847
+ };
848
+ }
849
+
850
+ /** First differing field, most-global first — the log's re-run reason. */
851
+ function recordMismatch(prev: DigestRecord | undefined, next: DigestRecord): string | null {
852
+ if (!prev) return "no committed digest";
853
+ if (prev.v !== next.v) return "digest schema changed";
854
+ if (prev.cli !== next.cli) return "blocks-cli version changed";
855
+ if (prev.deco !== next.deco) return "@decocms versions changed";
856
+ if (JSON.stringify(prev.args) !== JSON.stringify(next.args)) return "flags changed";
857
+ if (prev.inputs !== next.inputs) return "inputs changed";
858
+ return null;
859
+ }
860
+
861
+ // ---------------------------------------------------------------------------
862
+ // Child process execution
863
+ // ---------------------------------------------------------------------------
864
+
865
+ // The generators are argv-driven scripts (sections/loaders/invoke run at
866
+ // module top-level), so each runs as its own child process via the tsx CLI
867
+ // that ships as a blocks-cli dependency. This also gives stage 1 real
868
+ // multi-core parallelism and makes "skip" literally "never invoked".
869
+ function resolveTsxCli(): string {
870
+ return createRequire(import.meta.url).resolve("tsx/cli");
871
+ }
872
+
873
+ interface RunResult {
874
+ name: GeneratorName;
875
+ code: number;
876
+ ms: number;
877
+ stdout: string;
878
+ stderr: string;
879
+ }
880
+
881
+ function runGeneratorProcess(plan: GeneratorPlan, cwd: string): Promise<RunResult> {
882
+ const started = Date.now();
883
+ return new Promise((resolve) => {
884
+ const child = cp.spawn(process.execPath, [resolveTsxCli(), plan.script, ...plan.args], {
885
+ cwd,
886
+ stdio: ["ignore", "pipe", "pipe"],
887
+ });
888
+ let stdout = "";
889
+ let stderr = "";
890
+ child.stdout.on("data", (d) => {
891
+ stdout += d;
892
+ });
893
+ child.stderr.on("data", (d) => {
894
+ stderr += d;
895
+ });
896
+ child.on("error", (err) => {
897
+ resolve({
898
+ name: plan.name,
899
+ code: 1,
900
+ ms: Date.now() - started,
901
+ stdout,
902
+ stderr: `${stderr}\n${err.message}`,
903
+ });
904
+ });
905
+ child.on("close", (code) => {
906
+ resolve({ name: plan.name, code: code ?? 1, ms: Date.now() - started, stdout, stderr });
907
+ });
908
+ });
909
+ }
910
+
911
+ // ---------------------------------------------------------------------------
912
+ // Orchestration
913
+ // ---------------------------------------------------------------------------
914
+
915
+ type Decision =
916
+ | { kind: "disabled"; reason: string }
917
+ | { kind: "cached"; ms: number; verified: boolean }
918
+ | { kind: "run"; reason: string; record: DigestRecord };
919
+
920
+ function decide(
921
+ plan: GeneratorPlan,
922
+ digests: DigestMap,
923
+ cwd: string,
924
+ force: boolean,
925
+ versions: Record<string, string>,
926
+ cliVersion: string,
927
+ hasher: ContentHasher,
928
+ ): Decision {
929
+ if (!plan.enabled) return { kind: "disabled", reason: plan.disabledReason ?? "disabled" };
930
+ const started = Date.now();
931
+ const record = computeDigestRecord(plan, versions, cliVersion, hasher);
932
+ // Was any of THIS generator's inputs validated by reading bytes this run
933
+ // (memo cold / stats moved) rather than pure stat-memo lookups? Sticky per
934
+ // run — shared inputs mark every generator that fingerprints them. Only
935
+ // affects the log marker.
936
+ const verified = plan.inputs().some(([rel]) => hasher.wasRehashed(rel));
937
+ if (force) return { kind: "run", reason: "--force", record };
938
+ const mismatch = recordMismatch(digests[plan.name], record);
939
+ if (mismatch) return { kind: "run", reason: mismatch, record };
940
+ const missing = plan.outputs.find((o) => !fs.existsSync(path.resolve(cwd, o)));
941
+ if (missing) return { kind: "run", reason: `output missing (${missing})`, record };
942
+ return { kind: "cached", ms: Date.now() - started, verified };
943
+ }
944
+
945
+ function indent(text: string): string {
946
+ const trimmed = text.trimEnd();
947
+ if (!trimmed) return "";
948
+ return `${trimmed
949
+ .split("\n")
950
+ .map((l) => ` ${l}`)
951
+ .join("\n")}\n`;
952
+ }
953
+
954
+ export async function runGenerate(argv: string[], cwd = process.cwd()): Promise<number> {
955
+ let opts: CliOptions;
956
+ try {
957
+ opts = parseCliOptions(argv);
958
+ } catch (e) {
959
+ console.error(`[generate] ${(e as Error).message}`);
960
+ return 1;
961
+ }
962
+ if (opts.help) {
963
+ console.log(USAGE);
964
+ return 0;
965
+ }
966
+
967
+ const totalStarted = Date.now();
968
+ const plans = buildPlan(cwd, opts);
969
+ const versions = decoPackageVersions(cwd);
970
+ const cliVersion = ownVersion();
971
+ const digests = loadDigests(cwd);
972
+ const hasher = createContentHasher(cwd);
973
+
974
+ if (opts.dryRun) {
975
+ console.log("[generate] dry run — nothing will be written");
976
+ for (const plan of plans) {
977
+ // NOTE: stage-2 predictions here are made against the CURRENT tree; a
978
+ // real run computes stage-2 digests only after stage 1 lands (see
979
+ // runStage), because stage-1 outputs like src/server/invoke.gen.ts are
980
+ // part of schema's src/** input set.
981
+ const d = decide(plan, digests, cwd, opts.force, versions, cliVersion, hasher);
982
+ if (d.kind === "disabled") {
983
+ console.log(`[generate] ${plan.name}: skip — ${d.reason}`);
984
+ } else if (d.kind === "cached") {
985
+ console.log(`[generate] ${plan.name}: skip — cached`);
986
+ } else {
987
+ console.log(
988
+ `[generate] ${plan.name}: would run (${d.reason}) — ${path.basename(plan.script)} ${plan.args.join(" ")}`,
989
+ );
990
+ }
991
+ }
992
+ // Dry run writes NOTHING — not even the stat memo it warmed in memory.
993
+ return 0;
994
+ }
995
+
996
+ let fresh = 0;
997
+ let cached = 0;
998
+ let failed = 0;
999
+ let digestsDirty = false;
1000
+
1001
+ const finish = (plan: GeneratorPlan, record: DigestRecord, result: RunResult): void => {
1002
+ if (result.stdout.trim()) process.stdout.write(indent(result.stdout));
1003
+ if (result.stderr.trim()) process.stderr.write(indent(result.stderr));
1004
+ if (result.code === 0) {
1005
+ // Rewrite the record only AFTER success — a crashed run must leave no
1006
+ // record so the next run retries instead of trusting a poisoned cache.
1007
+ digests[plan.name] = record;
1008
+ digestsDirty = true;
1009
+ fresh++;
1010
+ console.log(`[generate] ${plan.name} ${result.ms}ms (fresh)`);
1011
+ } else {
1012
+ if (digests[plan.name]) {
1013
+ delete digests[plan.name];
1014
+ digestsDirty = true;
1015
+ }
1016
+ failed++;
1017
+ console.error(`[generate] ${plan.name} ${result.ms}ms FAILED (exit ${result.code})`);
1018
+ }
1019
+ };
1020
+
1021
+ // Decisions are made per stage, at stage start: stage-1 generators WRITE
1022
+ // files that sit inside stage-2 input sets (invoke emits
1023
+ // src/server/invoke.gen.ts, which schema fingerprints as part of src/**).
1024
+ // Digesting everything upfront would store a pre-stage-1 record for schema
1025
+ // and guarantee a spurious "inputs changed" re-run on the next boot.
1026
+ const runStage = async (stage: 1 | 2): Promise<void> => {
1027
+ const runnable: Array<{ plan: GeneratorPlan; record: DigestRecord }> = [];
1028
+ for (const plan of plans) {
1029
+ if (plan.stage !== stage) continue;
1030
+ const d = decide(plan, digests, cwd, opts.force, versions, cliVersion, hasher);
1031
+ if (d.kind === "disabled") {
1032
+ console.log(`[generate] ${plan.name} skipped (${d.reason})`);
1033
+ } else if (d.kind === "cached") {
1034
+ cached++;
1035
+ // content-verified = this hit re-read at least one file's bytes
1036
+ // (fresh clone / touched mtimes) instead of pure stat-memo lookups.
1037
+ console.log(
1038
+ `[generate] ${plan.name} ${d.ms}ms (cached${d.verified ? ", content-verified" : ""})`,
1039
+ );
1040
+ } else {
1041
+ runnable.push({ plan, record: d.record });
1042
+ }
1043
+ }
1044
+ // Stage-1 generators have disjoint inputs/outputs (verified — see the
1045
+ // header comment), so they run concurrently.
1046
+ const results = await Promise.all(
1047
+ runnable.map(async ({ plan, record }) => ({
1048
+ plan,
1049
+ record,
1050
+ result: await runGeneratorProcess(plan, cwd),
1051
+ })),
1052
+ );
1053
+ for (const { plan, record, result } of results) finish(plan, record, result);
1054
+ };
1055
+
1056
+ await runStage(1);
1057
+ if (failed > 0) {
1058
+ // schema reads types through files stage 1 just (re)wrote; don't build a
1059
+ // schema on top of a broken generation pass.
1060
+ for (const plan of plans) {
1061
+ if (plan.stage === 2 && plan.enabled) {
1062
+ console.error(`[generate] ${plan.name} skipped (stage 1 failed)`);
1063
+ }
1064
+ }
1065
+ } else {
1066
+ await runStage(2);
1067
+ }
1068
+
1069
+ try {
1070
+ if (digestsDirty) saveDigests(cwd, digests);
1071
+ hasher.save();
1072
+ // The pre-v2 machine-local cache file is superseded; drop it quietly.
1073
+ fs.rmSync(path.join(cwd, LEGACY_CACHE_FILE_REL), { force: true });
1074
+ } catch (e) {
1075
+ console.warn(`[generate] could not write cache: ${(e as Error).message}`);
1076
+ }
1077
+
1078
+ const totalMs = Date.now() - totalStarted;
1079
+ console.log(
1080
+ `[generate] total ${totalMs}ms (${fresh} fresh, ${cached} cached${failed ? `, ${failed} FAILED` : ""})`,
1081
+ );
1082
+ return failed > 0 ? 1 : 0;
1083
+ }
1084
+
1085
+ // ---------------------------------------------------------------------------
1086
+ // CLI shim — realpath comparison, same pattern as generate-schema.ts (works
1087
+ // through tsx / pnpm / macOS tmp symlinks).
1088
+ // ---------------------------------------------------------------------------
1089
+
1090
+ function isMainModule(): boolean {
1091
+ const entry = process.argv[1];
1092
+ if (!entry) return false;
1093
+ try {
1094
+ const entryPath = fs.realpathSync(path.resolve(entry));
1095
+ const selfPath = fs.realpathSync(fileURLToPath(import.meta.url));
1096
+ return entryPath === selfPath;
1097
+ } catch {
1098
+ return false;
1099
+ }
1100
+ }
1101
+
1102
+ if (isMainModule()) {
1103
+ runGenerate(process.argv.slice(2))
1104
+ .then((code) => {
1105
+ process.exitCode = code;
1106
+ })
1107
+ .catch((err) => {
1108
+ console.error(err);
1109
+ process.exit(1);
1110
+ });
1111
+ }