@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.
- package/package.json +6 -12
- package/scripts/generate-blocks-manifest.ts +5 -0
- package/scripts/generate-blocks.ts +8 -0
- package/scripts/generate-invoke.ts +5 -0
- package/scripts/generate-loaders.ts +5 -0
- package/scripts/generate-schema.ts +5 -0
- package/scripts/generate-sections.ts +5 -0
- package/scripts/generate.test.ts +568 -0
- package/scripts/generate.ts +1111 -0
- package/scripts/migrate/templates/package-json.test.ts +43 -0
- package/scripts/migrate/templates/package-json.ts +9 -10
|
@@ -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
|
+
}
|