@decocms/start 4.3.0 → 4.5.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,611 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Cloudflare-native observability codemod
4
+ *
5
+ * Rewrites a migrated site's `wrangler.jsonc` so Cloudflare ships
6
+ * `console.*` logs and OTel traces directly to HyperDX (or any other
7
+ * OTLP destination provisioned in the CF dashboard) — replacing the
8
+ * in-Worker OTLP exporter that `@decocms/start` ≤ 4.3.x bundled.
9
+ *
10
+ * Behavior:
11
+ * - dry-run by default — prints the proposed `observability` block
12
+ * plus a unified diff against the existing one. Safe to run
13
+ * unattended in CI.
14
+ * - `--write` performs the in-place edit. The script:
15
+ * 1. locates the existing `"observability": { ... }` block
16
+ * (matching balanced braces, JSONC-comment-aware),
17
+ * 2. replaces it with the canonical CF-native block, OR
18
+ * 3. appends a new block before the trailing `}` if no
19
+ * observability key exists yet,
20
+ * 4. validates the result parses as JSON (after stripping
21
+ * comments) before writing.
22
+ * - Idempotent: running twice produces the same file.
23
+ *
24
+ * Usage (from a migrated site directory):
25
+ * npx -p @decocms/start deco-cf-observability # dry-run
26
+ * npx -p @decocms/start deco-cf-observability --write # apply
27
+ * npx -p @decocms/start deco-cf-observability --logs hyperdx-logs --traces hyperdx-traces --write
28
+ *
29
+ * Options:
30
+ * --source <dir> Site directory containing wrangler.jsonc (default: cwd)
31
+ * --write Apply the change. Otherwise prints diff and exits.
32
+ * --logs <name> Logs destination name (default: "hyperdx-logs")
33
+ * --traces <name> Traces destination name (default: "hyperdx-traces")
34
+ * --traces-rate <r> head_sampling_rate for traces (default: 0.1)
35
+ * --logs-rate <r> head_sampling_rate for logs (default: 1.0)
36
+ * --no-persist Set persist:false (default — saves CF dashboard storage cost)
37
+ * --persist Set persist:true (keep traces/logs in the CF dashboard)
38
+ * --help, -h Show this help
39
+ *
40
+ * Exit codes:
41
+ * 0 — no change needed (already CF-native), or dry-run completed
42
+ * 1 — change required and `--write` not passed (CI signal)
43
+ * 2 — file invalid / can't parse / can't safely edit
44
+ */
45
+
46
+ import * as fs from "node:fs";
47
+ import * as path from "node:path";
48
+
49
+ interface CliOpts {
50
+ source: string;
51
+ write: boolean;
52
+ logsDest: string;
53
+ tracesDest: string;
54
+ tracesRate: number;
55
+ logsRate: number;
56
+ persist: boolean;
57
+ help: boolean;
58
+ }
59
+
60
+ function parseArgs(argv: string[]): CliOpts {
61
+ const opts: CliOpts = {
62
+ source: ".",
63
+ write: false,
64
+ logsDest: "hyperdx-logs",
65
+ tracesDest: "hyperdx-traces",
66
+ tracesRate: 0.1,
67
+ logsRate: 1.0,
68
+ persist: false,
69
+ help: false,
70
+ };
71
+ for (let i = 0; i < argv.length; i++) {
72
+ const flag = argv[i];
73
+ switch (flag) {
74
+ case "--source":
75
+ opts.source = argv[++i] ?? ".";
76
+ break;
77
+ case "--write":
78
+ opts.write = true;
79
+ break;
80
+ case "--logs":
81
+ opts.logsDest = argv[++i] ?? opts.logsDest;
82
+ break;
83
+ case "--traces":
84
+ opts.tracesDest = argv[++i] ?? opts.tracesDest;
85
+ break;
86
+ case "--traces-rate":
87
+ opts.tracesRate = Number(argv[++i] ?? opts.tracesRate);
88
+ break;
89
+ case "--logs-rate":
90
+ opts.logsRate = Number(argv[++i] ?? opts.logsRate);
91
+ break;
92
+ case "--persist":
93
+ opts.persist = true;
94
+ break;
95
+ case "--no-persist":
96
+ opts.persist = false;
97
+ break;
98
+ case "--help":
99
+ case "-h":
100
+ opts.help = true;
101
+ break;
102
+ }
103
+ }
104
+ return opts;
105
+ }
106
+
107
+ function showHelp(): void {
108
+ console.log(`
109
+ @decocms/start — Cloudflare-native observability codemod
110
+
111
+ Rewrites wrangler.jsonc to ship logs and traces via Cloudflare's
112
+ platform-managed OTLP export (observability.{logs,traces}.destinations)
113
+ instead of the in-Worker exporter SDK.
114
+
115
+ Usage:
116
+ npx -p @decocms/start deco-cf-observability [options]
117
+
118
+ Options:
119
+ --source <dir> Site directory (default: .)
120
+ --write Apply the edit. Without it, prints diff and exits 1.
121
+ --logs <name> Logs destination (default: hyperdx-logs)
122
+ --traces <name> Traces destination (default: hyperdx-traces)
123
+ --traces-rate <r> head_sampling_rate for traces (default: 0.1)
124
+ --logs-rate <r> head_sampling_rate for logs (default: 1.0)
125
+ --persist Keep the dashboard storage tier (default: --no-persist)
126
+ --help, -h This message
127
+
128
+ After running with --write you must:
129
+ 1. Provision the destinations in the CF dashboard (one-time per account)
130
+ 2. Deploy the Worker
131
+ 3. Validate signals are landing in HyperDX
132
+ 4. Delete the now-orphaned secrets:
133
+ wrangler secret delete OTEL_EXPORTER_OTLP_ENDPOINT \\
134
+ OTEL_EXPORTER_OTLP_HEADERS \\
135
+ OTEL_SAMPLING_CONFIG \\
136
+ OTEL_LOG_MIN_SEVERITY
137
+ `);
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // JSONC handling (no external deps — vendored mini-stripper)
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /**
145
+ * Strip line and block comments from a JSONC string so the result parses
146
+ * with vanilla `JSON.parse`. Preserves quoted strings (handles escaped
147
+ * quotes), preserves whitespace/newlines so line numbers in error
148
+ * messages stay stable.
149
+ */
150
+ function stripJsoncComments(src: string): string {
151
+ let out = "";
152
+ let i = 0;
153
+ let inString = false;
154
+ let stringQuote = "";
155
+ while (i < src.length) {
156
+ const ch = src[i];
157
+ const next = src[i + 1];
158
+ if (inString) {
159
+ out += ch;
160
+ if (ch === "\\" && i + 1 < src.length) {
161
+ out += next;
162
+ i += 2;
163
+ continue;
164
+ }
165
+ if (ch === stringQuote) {
166
+ inString = false;
167
+ }
168
+ i++;
169
+ continue;
170
+ }
171
+ if (ch === '"' || ch === "'") {
172
+ inString = true;
173
+ stringQuote = ch;
174
+ out += ch;
175
+ i++;
176
+ continue;
177
+ }
178
+ if (ch === "/" && next === "/") {
179
+ // Line comment — skip to newline (preserve newline for line counts).
180
+ while (i < src.length && src[i] !== "\n") i++;
181
+ continue;
182
+ }
183
+ if (ch === "/" && next === "*") {
184
+ // Block comment — skip to */, preserving newlines for line counts.
185
+ i += 2;
186
+ while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) {
187
+ if (src[i] === "\n") out += "\n";
188
+ i++;
189
+ }
190
+ i += 2;
191
+ continue;
192
+ }
193
+ out += ch;
194
+ i++;
195
+ }
196
+ return out;
197
+ }
198
+
199
+ /**
200
+ * Locate the value range of a top-level JSON object key inside JSONC text.
201
+ * Returns the absolute character span of the value (the contents between
202
+ * the opening `{` and matching closing `}`) and the span of the entire
203
+ * `"key": value` pair, including the key and surrounding whitespace
204
+ * adequate for clean removal.
205
+ *
206
+ * Returns `null` when the key isn't found at the top level.
207
+ *
208
+ * Brace-counting is JSONC-aware (skips comments and strings).
209
+ */
210
+ function findTopLevelKeyRange(
211
+ src: string,
212
+ key: string,
213
+ ): { keyStart: number; valueEnd: number } | null {
214
+ // Walk to find the opening `{` of the top-level object first.
215
+ let i = 0;
216
+ let inString = false;
217
+ let stringQuote = "";
218
+
219
+ // Find first `{`
220
+ while (i < src.length) {
221
+ const ch = src[i];
222
+ const next = src[i + 1];
223
+ if (ch === "/" && next === "/") {
224
+ while (i < src.length && src[i] !== "\n") i++;
225
+ continue;
226
+ }
227
+ if (ch === "/" && next === "*") {
228
+ i += 2;
229
+ while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++;
230
+ i += 2;
231
+ continue;
232
+ }
233
+ if (ch === "{") break;
234
+ i++;
235
+ }
236
+ if (i >= src.length) return null;
237
+
238
+ // Now scan inside the top-level object, depth-tracked, looking for our key.
239
+ // Top-level keys appear at depth 1.
240
+ let depth = 1;
241
+ i++;
242
+ const needle = `"${key}"`;
243
+ while (i < src.length) {
244
+ const ch = src[i];
245
+ const next = src[i + 1];
246
+ if (inString) {
247
+ if (ch === "\\" && i + 1 < src.length) {
248
+ i += 2;
249
+ continue;
250
+ }
251
+ if (ch === stringQuote) inString = false;
252
+ i++;
253
+ continue;
254
+ }
255
+ if (ch === '"') {
256
+ // Possible key match. Check.
257
+ if (depth === 1 && src.startsWith(needle, i)) {
258
+ const keyStart = i;
259
+ // Advance past the matched key string.
260
+ i += needle.length;
261
+ // Skip whitespace + `:`
262
+ while (i < src.length && /\s/.test(src[i])) i++;
263
+ if (src[i] !== ":") return null;
264
+ i++;
265
+ while (i < src.length && /\s/.test(src[i])) i++;
266
+ // Now we're at the value. Find its end (handle objects, arrays, strings, primitives).
267
+ if (src[i] === "{" || src[i] === "[") {
268
+ const open = src[i];
269
+ const close = open === "{" ? "}" : "]";
270
+ let d = 1;
271
+ i++;
272
+ while (i < src.length && d > 0) {
273
+ const c2 = src[i];
274
+ const n2 = src[i + 1];
275
+ if (c2 === '"' || c2 === "'") {
276
+ const q = c2;
277
+ i++;
278
+ while (i < src.length) {
279
+ if (src[i] === "\\") {
280
+ i += 2;
281
+ continue;
282
+ }
283
+ if (src[i] === q) {
284
+ i++;
285
+ break;
286
+ }
287
+ i++;
288
+ }
289
+ continue;
290
+ }
291
+ if (c2 === "/" && n2 === "/") {
292
+ while (i < src.length && src[i] !== "\n") i++;
293
+ continue;
294
+ }
295
+ if (c2 === "/" && n2 === "*") {
296
+ i += 2;
297
+ while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++;
298
+ i += 2;
299
+ continue;
300
+ }
301
+ if (c2 === open) d++;
302
+ else if (c2 === close) d--;
303
+ i++;
304
+ }
305
+ return { keyStart, valueEnd: i };
306
+ }
307
+ // Primitive / string value — read until comma or closing brace at depth 1.
308
+ while (i < src.length && src[i] !== "," && src[i] !== "}" && src[i] !== "\n") i++;
309
+ return { keyStart, valueEnd: i };
310
+ }
311
+ inString = true;
312
+ stringQuote = '"';
313
+ i++;
314
+ continue;
315
+ }
316
+ if (ch === "/" && next === "/") {
317
+ while (i < src.length && src[i] !== "\n") i++;
318
+ continue;
319
+ }
320
+ if (ch === "/" && next === "*") {
321
+ i += 2;
322
+ while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++;
323
+ i += 2;
324
+ continue;
325
+ }
326
+ if (ch === "{") depth++;
327
+ else if (ch === "}") {
328
+ depth--;
329
+ if (depth === 0) return null;
330
+ }
331
+ i++;
332
+ }
333
+ return null;
334
+ }
335
+
336
+ /**
337
+ * Locate the end of the top-level object (position of the closing `}`).
338
+ * Used when appending a new key. JSONC-aware.
339
+ */
340
+ function findTopLevelObjectEnd(src: string): number | null {
341
+ let i = 0;
342
+ let inString = false;
343
+ let stringQuote = "";
344
+
345
+ // Find first `{`
346
+ while (i < src.length) {
347
+ const ch = src[i];
348
+ const next = src[i + 1];
349
+ if (ch === "/" && next === "/") {
350
+ while (i < src.length && src[i] !== "\n") i++;
351
+ continue;
352
+ }
353
+ if (ch === "/" && next === "*") {
354
+ i += 2;
355
+ while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++;
356
+ i += 2;
357
+ continue;
358
+ }
359
+ if (ch === "{") break;
360
+ i++;
361
+ }
362
+ if (i >= src.length) return null;
363
+ let depth = 1;
364
+ i++;
365
+ while (i < src.length) {
366
+ const ch = src[i];
367
+ const next = src[i + 1];
368
+ if (inString) {
369
+ if (ch === "\\" && i + 1 < src.length) {
370
+ i += 2;
371
+ continue;
372
+ }
373
+ if (ch === stringQuote) inString = false;
374
+ i++;
375
+ continue;
376
+ }
377
+ if (ch === '"' || ch === "'") {
378
+ inString = true;
379
+ stringQuote = ch;
380
+ i++;
381
+ continue;
382
+ }
383
+ if (ch === "/" && next === "/") {
384
+ while (i < src.length && src[i] !== "\n") i++;
385
+ continue;
386
+ }
387
+ if (ch === "/" && next === "*") {
388
+ i += 2;
389
+ while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++;
390
+ i += 2;
391
+ continue;
392
+ }
393
+ if (ch === "{") depth++;
394
+ else if (ch === "}") {
395
+ depth--;
396
+ if (depth === 0) return i;
397
+ }
398
+ i++;
399
+ }
400
+ return null;
401
+ }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // Block rendering
405
+ // ---------------------------------------------------------------------------
406
+
407
+ function renderObservabilityBlock(opts: CliOpts, indent = " "): string {
408
+ const persist = opts.persist;
409
+ return [
410
+ `"observability": {`,
411
+ `${indent}// Cloudflare ships console.* output OTLP-encoded to the`,
412
+ `${indent}// HyperDX destination provisioned at the account level. No`,
413
+ `${indent}// in-Worker exporter, no flush bug, no subrequest cost.`,
414
+ `${indent}"logs": {`,
415
+ `${indent}${indent}"enabled": true,`,
416
+ `${indent}${indent}"invocation_logs": true,`,
417
+ `${indent}${indent}"head_sampling_rate": ${opts.logsRate},`,
418
+ `${indent}${indent}"persist": ${persist},`,
419
+ `${indent}${indent}"destinations": ["${opts.logsDest}"]`,
420
+ `${indent}},`,
421
+ `${indent}// Auto-instruments fetch/KV/R2/DO + picks up @opentelemetry/api`,
422
+ `${indent}// global tracer spans (the bridge instrumentWorker installs).`,
423
+ `${indent}// Sampling is one global rate per Worker; URL-pattern sampling`,
424
+ `${indent}// requires opting back into the URLBasedSampler escape hatch.`,
425
+ `${indent}"traces": {`,
426
+ `${indent}${indent}"enabled": true,`,
427
+ `${indent}${indent}"head_sampling_rate": ${opts.tracesRate},`,
428
+ `${indent}${indent}"persist": ${persist},`,
429
+ `${indent}${indent}"destinations": ["${opts.tracesDest}"]`,
430
+ `${indent}}`,
431
+ `}`,
432
+ ].join("\n");
433
+ }
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // Detect "already CF-native"
437
+ // ---------------------------------------------------------------------------
438
+
439
+ function isAlreadyCfNative(src: string, opts: CliOpts): boolean {
440
+ // Cheap heuristic: the file mentions both destinations (under either
441
+ // logs or traces) and a `head_sampling_rate`. A more thorough parse
442
+ // is overkill for an idempotency check.
443
+ if (!src.includes(`"destinations"`)) return false;
444
+ if (!src.includes(opts.logsDest) && !src.includes(opts.tracesDest)) return false;
445
+ if (!src.includes("head_sampling_rate")) return false;
446
+ return true;
447
+ }
448
+
449
+ // ---------------------------------------------------------------------------
450
+ // Validation
451
+ // ---------------------------------------------------------------------------
452
+
453
+ function validateJson(src: string): { ok: true } | { ok: false; error: string } {
454
+ try {
455
+ JSON.parse(stripJsoncComments(src));
456
+ return { ok: true };
457
+ } catch (e) {
458
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
459
+ }
460
+ }
461
+
462
+ // ---------------------------------------------------------------------------
463
+ // Diff (tiny line-level)
464
+ // ---------------------------------------------------------------------------
465
+
466
+ function unifiedDiff(before: string, after: string, file: string): string {
467
+ const a = before.split("\n");
468
+ const b = after.split("\n");
469
+ // Find the changed window — it's always contiguous because we only edit
470
+ // one block. Keep it brain-simple: shrink both ends, print the rest with
471
+ // -/+ markers.
472
+ let i = 0;
473
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
474
+ let endA = a.length;
475
+ let endB = b.length;
476
+ while (endA > i && endB > i && a[endA - 1] === b[endB - 1]) {
477
+ endA--;
478
+ endB--;
479
+ }
480
+ const ctxStart = Math.max(0, i - 3);
481
+ const ctxAEnd = Math.min(a.length, endA + 3);
482
+ const ctxBEnd = Math.min(b.length, endB + 3);
483
+ const lines: string[] = [];
484
+ lines.push(`--- ${file} (before)`);
485
+ lines.push(`+++ ${file} (after)`);
486
+ for (let k = ctxStart; k < i; k++) lines.push(` ${a[k]}`);
487
+ for (let k = i; k < endA; k++) lines.push(`- ${a[k]}`);
488
+ for (let k = i; k < endB; k++) lines.push(`+ ${b[k]}`);
489
+ for (let k = endA; k < ctxAEnd; k++) lines.push(` ${a[k]}`);
490
+ // ctxBEnd guards equality at the tail; printing either tail context is fine.
491
+ void ctxBEnd;
492
+ return lines.join("\n");
493
+ }
494
+
495
+ // ---------------------------------------------------------------------------
496
+ // Main
497
+ // ---------------------------------------------------------------------------
498
+
499
+ /**
500
+ * Detect the column-0..keyStart whitespace of the line containing
501
+ * `keyStart`, so we can re-indent every line of the rendered block to
502
+ * match the file's existing nesting depth.
503
+ */
504
+ function detectLineIndent(src: string, position: number): string {
505
+ let lineStart = position;
506
+ while (lineStart > 0 && src[lineStart - 1] !== "\n") lineStart--;
507
+ let i = lineStart;
508
+ while (i < src.length && (src[i] === " " || src[i] === "\t")) i++;
509
+ return src.slice(lineStart, i);
510
+ }
511
+
512
+ function reindentBlockBody(block: string, indent: string): string {
513
+ const lines = block.split("\n");
514
+ // Leave line 0 alone — it's spliced in at the existing key position
515
+ // which is already indented by surrounding text. Re-indent the rest.
516
+ return [lines[0], ...lines.slice(1).map((l) => indent + l)].join("\n");
517
+ }
518
+
519
+ function applyEdit(src: string, opts: CliOpts): string {
520
+ const block = renderObservabilityBlock(opts);
521
+ const range = findTopLevelKeyRange(src, "observability");
522
+
523
+ if (range) {
524
+ // Replace the existing `"observability": {...}` (key + value) with the
525
+ // new block. Re-indent body lines to match the existing key's column
526
+ // so the final wrangler.jsonc stays cleanly formatted.
527
+ const indent = detectLineIndent(src, range.keyStart);
528
+ const indentedBlock = reindentBlockBody(block, indent);
529
+ return src.slice(0, range.keyStart) + indentedBlock + src.slice(range.valueEnd);
530
+ }
531
+
532
+ // No observability key — append before the closing `}` of the top-level object.
533
+ const end = findTopLevelObjectEnd(src);
534
+ if (end == null) {
535
+ throw new Error("wrangler.jsonc: could not locate top-level closing `}`");
536
+ }
537
+ // Determine if we need a leading comma on the new key.
538
+ const insertAt = end;
539
+ let scan = end - 1;
540
+ while (scan >= 0 && /\s/.test(src[scan])) scan--;
541
+ const prevChar = scan >= 0 ? src[scan] : "";
542
+ const needsComma = prevChar !== "{" && prevChar !== ",";
543
+ const baseIndent = " ";
544
+ const indented = block
545
+ .split("\n")
546
+ .map((l) => baseIndent + l)
547
+ .join("\n");
548
+ const insertion = `${needsComma ? "," : ""}\n${indented}\n`;
549
+ return src.slice(0, insertAt) + insertion + src.slice(insertAt);
550
+ }
551
+
552
+ function main(): void {
553
+ const opts = parseArgs(process.argv.slice(2));
554
+ if (opts.help) {
555
+ showHelp();
556
+ return;
557
+ }
558
+ const wranglerPath = path.join(path.resolve(opts.source), "wrangler.jsonc");
559
+ if (!fs.existsSync(wranglerPath)) {
560
+ console.error(`error: ${wranglerPath} does not exist`);
561
+ process.exit(2);
562
+ }
563
+
564
+ const before = fs.readFileSync(wranglerPath, "utf8");
565
+
566
+ if (isAlreadyCfNative(before, opts)) {
567
+ console.log(`✓ ${wranglerPath} already on CF-native observability — no change.`);
568
+ process.exit(0);
569
+ }
570
+
571
+ let after: string;
572
+ try {
573
+ after = applyEdit(before, opts);
574
+ } catch (e) {
575
+ console.error(`error: ${e instanceof Error ? e.message : String(e)}`);
576
+ process.exit(2);
577
+ }
578
+
579
+ const validation = validateJson(after);
580
+ if (!validation.ok) {
581
+ console.error(`error: result wouldn't parse as JSONC: ${validation.error}`);
582
+ console.error("aborting; no changes written.");
583
+ process.exit(2);
584
+ }
585
+
586
+ if (!opts.write) {
587
+ console.log(unifiedDiff(before, after, wranglerPath));
588
+ console.log("\nDry-run only. Re-run with --write to apply.");
589
+ process.exit(1);
590
+ }
591
+
592
+ fs.writeFileSync(wranglerPath, after, "utf8");
593
+ console.log(`✓ wrote ${wranglerPath}`);
594
+ console.log(`
595
+ Next steps:
596
+ 1. (one-time per CF account) provision destinations in the dashboard:
597
+ Logs: ${opts.logsDest} → HyperDX OTLP /v1/logs + Authorization header
598
+ Traces: ${opts.tracesDest} → HyperDX OTLP /v1/traces + Authorization header
599
+ 2. wrangler deploy
600
+ 3. Verify in HyperDX:
601
+ service:<your-site-name> AND SeverityNumber:* → log records arriving
602
+ service:<your-site-name> AND duration:* → spans arriving
603
+ 4. Delete now-orphaned secrets:
604
+ wrangler secret delete OTEL_EXPORTER_OTLP_ENDPOINT \\
605
+ OTEL_EXPORTER_OTLP_HEADERS \\
606
+ OTEL_SAMPLING_CONFIG \\
607
+ OTEL_LOG_MIN_SEVERITY
608
+ `);
609
+ }
610
+
611
+ main();
@@ -246,11 +246,16 @@ function bootstrap(ctx: { sourceDir: string }) {
246
246
  return true;
247
247
  };
248
248
 
249
- // Detect package manager
250
- const pm = process.env.npm_execpath?.includes("bun") ? "bun" : "npm";
249
+ // bun is the fleet-wide canonical package manager for decocms storefronts.
250
+ // We hardcode it here (instead of sniffing process.env.npm_execpath) so a
251
+ // freshly-migrated site always commits a bun.lock and never accidentally
252
+ // ships a package-lock.json that drifts vs bun.lock under CF Workers Builds.
253
+ // See MIGRATION_TOOLING_PLAN.md and the package-json template for the
254
+ // matching `packageManager` field that pins the version.
255
+ const pm = "bun";
251
256
  if (!run(`${pm} install`, "Install dependencies", true)) return;
252
- run("npx tsx node_modules/@decocms/start/scripts/generate-blocks.ts", "Generate CMS blocks");
253
- run("npx tsr generate", "Generate TanStack routes");
257
+ run("bunx tsx node_modules/@decocms/start/scripts/generate-blocks.ts", "Generate CMS blocks");
258
+ run("bunx tsr generate", "Generate TanStack routes");
254
259
 
255
260
  if (failures > 0) {
256
261
  console.log(