@decocms/blocks-cli 7.0.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.
Files changed (93) hide show
  1. package/package.json +44 -0
  2. package/scripts/analyze-traces.mjs +1117 -0
  3. package/scripts/audit-observability-config.test.ts +446 -0
  4. package/scripts/audit-observability-config.ts +511 -0
  5. package/scripts/deco-migrate-cli.ts +444 -0
  6. package/scripts/fast-deploy-kv.test.ts +131 -0
  7. package/scripts/generate-blocks.test.ts +94 -0
  8. package/scripts/generate-blocks.ts +274 -0
  9. package/scripts/generate-invoke.test.ts +195 -0
  10. package/scripts/generate-invoke.ts +469 -0
  11. package/scripts/generate-loaders.ts +217 -0
  12. package/scripts/generate-schema.ts +1287 -0
  13. package/scripts/generate-sections.ts +237 -0
  14. package/scripts/htmx-analyze.ts +226 -0
  15. package/scripts/lib/blocks-dedupe.test.ts +179 -0
  16. package/scripts/lib/blocks-dedupe.ts +142 -0
  17. package/scripts/lib/cf-kv-rest.ts +78 -0
  18. package/scripts/lib/jsonc.ts +122 -0
  19. package/scripts/lib/kv-snapshot.ts +51 -0
  20. package/scripts/lib/read-decofile.ts +70 -0
  21. package/scripts/lib/sync-helpers.ts +44 -0
  22. package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
  23. package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
  24. package/scripts/migrate/analyzers/island-classifier.ts +96 -0
  25. package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
  26. package/scripts/migrate/analyzers/section-metadata.ts +147 -0
  27. package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
  28. package/scripts/migrate/colors.ts +46 -0
  29. package/scripts/migrate/config.test.ts +202 -0
  30. package/scripts/migrate/config.ts +186 -0
  31. package/scripts/migrate/phase-analyze.test.ts +63 -0
  32. package/scripts/migrate/phase-analyze.ts +782 -0
  33. package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
  34. package/scripts/migrate/phase-cleanup-audit.ts +105 -0
  35. package/scripts/migrate/phase-cleanup.test.ts +141 -0
  36. package/scripts/migrate/phase-cleanup.ts +1588 -0
  37. package/scripts/migrate/phase-compile.test.ts +193 -0
  38. package/scripts/migrate/phase-compile.ts +177 -0
  39. package/scripts/migrate/phase-report.ts +243 -0
  40. package/scripts/migrate/phase-scaffold.ts +593 -0
  41. package/scripts/migrate/phase-transform.ts +310 -0
  42. package/scripts/migrate/phase-verify.test.ts +127 -0
  43. package/scripts/migrate/phase-verify.ts +572 -0
  44. package/scripts/migrate/post-cleanup/rules.ts +1708 -0
  45. package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
  46. package/scripts/migrate/post-cleanup/runner.ts +137 -0
  47. package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
  48. package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
  49. package/scripts/migrate/post-cleanup/types.ts +106 -0
  50. package/scripts/migrate/source-layout.test.ts +111 -0
  51. package/scripts/migrate/source-layout.ts +103 -0
  52. package/scripts/migrate/templates/app-css.ts +366 -0
  53. package/scripts/migrate/templates/cache-config.ts +26 -0
  54. package/scripts/migrate/templates/commerce-loaders.ts +230 -0
  55. package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
  56. package/scripts/migrate/templates/cursor-rules.ts +70 -0
  57. package/scripts/migrate/templates/hooks.test.ts +141 -0
  58. package/scripts/migrate/templates/hooks.ts +152 -0
  59. package/scripts/migrate/templates/knip-config.ts +27 -0
  60. package/scripts/migrate/templates/lib-utils.test.ts +139 -0
  61. package/scripts/migrate/templates/lib-utils.ts +326 -0
  62. package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
  63. package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
  64. package/scripts/migrate/templates/package-json.ts +177 -0
  65. package/scripts/migrate/templates/routes.ts +237 -0
  66. package/scripts/migrate/templates/sdk-gen.ts +59 -0
  67. package/scripts/migrate/templates/section-loaders.ts +456 -0
  68. package/scripts/migrate/templates/server-entry.ts +561 -0
  69. package/scripts/migrate/templates/setup.ts +148 -0
  70. package/scripts/migrate/templates/tsconfig.ts +21 -0
  71. package/scripts/migrate/templates/types-gen.ts +174 -0
  72. package/scripts/migrate/templates/ui-components.ts +144 -0
  73. package/scripts/migrate/templates/vite-config.ts +101 -0
  74. package/scripts/migrate/transforms/dead-code.ts +455 -0
  75. package/scripts/migrate/transforms/deno-isms.ts +85 -0
  76. package/scripts/migrate/transforms/fresh-apis.ts +223 -0
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
  78. package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
  79. package/scripts/migrate/transforms/imports.ts +385 -0
  80. package/scripts/migrate/transforms/jsx.ts +317 -0
  81. package/scripts/migrate/transforms/section-conventions.ts +210 -0
  82. package/scripts/migrate/transforms/tailwind.ts +739 -0
  83. package/scripts/migrate/types.ts +244 -0
  84. package/scripts/migrate-blocks-to-kv.ts +104 -0
  85. package/scripts/migrate-post-cleanup.ts +191 -0
  86. package/scripts/migrate-to-cf-observability.test.ts +215 -0
  87. package/scripts/migrate-to-cf-observability.ts +699 -0
  88. package/scripts/migrate.ts +282 -0
  89. package/scripts/smoke-otlp-errorlog.ts +46 -0
  90. package/scripts/smoke-otlp-meter.ts +48 -0
  91. package/scripts/sync-blocks-to-kv.ts +153 -0
  92. package/scripts/tailwind-lint.ts +518 -0
  93. package/tsconfig.json +7 -0
@@ -0,0 +1,699 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Cloudflare-native observability codemod
4
+ *
5
+ * Rewrites a migrated site's `wrangler.jsonc` so the Cloudflare runtime
6
+ * captures `console.*` logs and auto-instrumented traces directly into
7
+ * the per-Worker observability dashboard. No in-Worker exporter, no
8
+ * external destination — the CF dashboard is the destination.
9
+ *
10
+ * The canonical block this script writes:
11
+ *
12
+ * "observability": {
13
+ * "enabled": true,
14
+ * "logs": { "enabled": true, "invocation_logs": true,
15
+ * "head_sampling_rate": 1, "persist": true },
16
+ * "traces": { "enabled": true,
17
+ * "head_sampling_rate": 0.1, "persist": true }
18
+ * }
19
+ *
20
+ * `enabled: true` at the top level is the master switch — without it
21
+ * Cloudflare captures nothing, regardless of the sub-block flags.
22
+ * `persist: true` keeps the data queryable in the CF dashboard
23
+ * (Workers Logs view + Traces view). Discovered the hard way during the
24
+ * lebiscuit canary cutover.
25
+ *
26
+ * Behavior:
27
+ * - dry-run by default — prints a unified diff against the existing
28
+ * observability block. Safe in CI.
29
+ * - `--write` performs the in-place edit. The script:
30
+ * 1. locates the existing `"observability": { ... }` block
31
+ * (matching balanced braces, JSONC-comment-aware),
32
+ * 2. replaces it with the canonical CF-native block, OR
33
+ * 3. appends a new block before the trailing `}` if no
34
+ * observability key exists yet,
35
+ * 4. validates the result parses as JSON (after stripping
36
+ * comments) before writing.
37
+ * - Idempotent: a wrangler.jsonc already on the canonical block is a
38
+ * no-op. A wrangler.jsonc with stale HyperDX-style destinations is
39
+ * rewritten to drop them.
40
+ * - Forwarding to an external destination (an OTel collector for
41
+ * ClickHouse, a third-party SaaS, etc.) is opt-in via
42
+ * `--destination-logs` / `--destination-traces`. The destination
43
+ * itself must be provisioned out-of-band in the CF dashboard.
44
+ *
45
+ * Usage (from a migrated site directory):
46
+ * npx -p @decocms/start deco-cf-observability # dry-run
47
+ * npx -p @decocms/start deco-cf-observability --write # apply
48
+ *
49
+ * # Opt-in: also forward to an account-level destination:
50
+ * npx -p @decocms/start deco-cf-observability --write \
51
+ * --destination-logs my-logs-dest --destination-traces my-traces-dest
52
+ *
53
+ * Options:
54
+ * --source <dir> Site directory containing wrangler.jsonc (default: cwd)
55
+ * --write Apply the change. Otherwise prints diff and exits 1.
56
+ * --destination-logs <n> Optional CF destination name to forward logs to.
57
+ * --destination-traces <n> Optional CF destination name to forward traces to.
58
+ * --traces-rate <r> head_sampling_rate for traces (default: 0.1)
59
+ * --logs-rate <r> head_sampling_rate for logs (default: 1.0)
60
+ * --no-persist Set persist:false (do not keep data in CF dashboard)
61
+ * --persist Set persist:true (default — required if no destination)
62
+ * --help, -h Show this help
63
+ *
64
+ * Exit codes:
65
+ * 0 — no change needed (already canonical), or dry-run completed cleanly
66
+ * 1 — change required and `--write` not passed (CI signal)
67
+ * 2 — file invalid / can't parse / can't safely edit
68
+ */
69
+
70
+ import * as fs from "node:fs";
71
+ import * as path from "node:path";
72
+
73
+ interface CliOpts {
74
+ source: string;
75
+ write: boolean;
76
+ /** Optional CF destination slug for logs. Empty = no forwarding. */
77
+ logsDest: string;
78
+ /** Optional CF destination slug for traces. Empty = no forwarding. */
79
+ tracesDest: string;
80
+ tracesRate: number;
81
+ logsRate: number;
82
+ persist: boolean;
83
+ help: boolean;
84
+ }
85
+
86
+ function parseArgs(argv: string[]): CliOpts {
87
+ const opts: CliOpts = {
88
+ source: ".",
89
+ write: false,
90
+ logsDest: "",
91
+ tracesDest: "",
92
+ tracesRate: 0.1,
93
+ logsRate: 1.0,
94
+ // CF dashboard persistence on by default — without either persist:true
95
+ // OR a destination, observability data is captured and discarded.
96
+ persist: true,
97
+ help: false,
98
+ };
99
+ for (let i = 0; i < argv.length; i++) {
100
+ const flag = argv[i];
101
+ switch (flag) {
102
+ case "--source":
103
+ opts.source = argv[++i] ?? ".";
104
+ break;
105
+ case "--write":
106
+ opts.write = true;
107
+ break;
108
+ case "--destination-logs":
109
+ opts.logsDest = argv[++i] ?? "";
110
+ break;
111
+ case "--destination-traces":
112
+ opts.tracesDest = argv[++i] ?? "";
113
+ break;
114
+ case "--traces-rate":
115
+ opts.tracesRate = Number(argv[++i] ?? opts.tracesRate);
116
+ break;
117
+ case "--logs-rate":
118
+ opts.logsRate = Number(argv[++i] ?? opts.logsRate);
119
+ break;
120
+ case "--persist":
121
+ opts.persist = true;
122
+ break;
123
+ case "--no-persist":
124
+ opts.persist = false;
125
+ break;
126
+ case "--help":
127
+ case "-h":
128
+ opts.help = true;
129
+ break;
130
+ }
131
+ }
132
+ return opts;
133
+ }
134
+
135
+ function showHelp(): void {
136
+ console.log(`
137
+ @decocms/start — Cloudflare-native observability codemod
138
+
139
+ Rewrites wrangler.jsonc so Cloudflare captures \`console.*\` logs and
140
+ auto-instrumented traces directly into the per-Worker dashboard. No
141
+ in-Worker exporter SDK, no external destination required.
142
+
143
+ Usage:
144
+ npx -p @decocms/start deco-cf-observability [options]
145
+
146
+ Options:
147
+ --source <dir> Site directory (default: .)
148
+ --write Apply the edit. Without it, prints diff and exits 1.
149
+ --destination-logs <n> Optional CF destination slug to also forward logs to.
150
+ --destination-traces <n> Optional CF destination slug to also forward traces to.
151
+ --traces-rate <r> head_sampling_rate for traces (default: 0.1)
152
+ --logs-rate <r> head_sampling_rate for logs (default: 1.0)
153
+ --persist Keep the dashboard storage tier (default)
154
+ --no-persist Drop the dashboard tier (only sane when forwarding)
155
+ --help, -h This message
156
+
157
+ After running with --write you must:
158
+ 1. Deploy the Worker (\`wrangler deploy\`).
159
+ 2. Verify the CF dashboard shows logs + traces within ~5 min:
160
+ Workers & Pages → <site> → Observability
161
+ 3. If migrating from an older app-side OTLP setup, delete the
162
+ now-orphaned secrets:
163
+ wrangler secret delete OTEL_EXPORTER_OTLP_ENDPOINT \\
164
+ OTEL_EXPORTER_OTLP_HEADERS \\
165
+ OTEL_SAMPLING_CONFIG \\
166
+ OTEL_LOG_MIN_SEVERITY
167
+ `);
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // JSONC handling (no external deps — vendored mini-stripper)
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Strip line and block comments from a JSONC string so the result parses
176
+ * with vanilla `JSON.parse`. Preserves quoted strings (handles escaped
177
+ * quotes), preserves whitespace/newlines so line numbers in error
178
+ * messages stay stable.
179
+ */
180
+ function stripJsoncComments(src: string): string {
181
+ let out = "";
182
+ let i = 0;
183
+ let inString = false;
184
+ let stringQuote = "";
185
+ while (i < src.length) {
186
+ const ch = src[i];
187
+ const next = src[i + 1];
188
+ if (inString) {
189
+ out += ch;
190
+ if (ch === "\\" && i + 1 < src.length) {
191
+ out += next;
192
+ i += 2;
193
+ continue;
194
+ }
195
+ if (ch === stringQuote) {
196
+ inString = false;
197
+ }
198
+ i++;
199
+ continue;
200
+ }
201
+ if (ch === '"' || ch === "'") {
202
+ inString = true;
203
+ stringQuote = ch;
204
+ out += ch;
205
+ i++;
206
+ continue;
207
+ }
208
+ if (ch === "/" && next === "/") {
209
+ // Line comment — skip to newline (preserve newline for line counts).
210
+ while (i < src.length && src[i] !== "\n") i++;
211
+ continue;
212
+ }
213
+ if (ch === "/" && next === "*") {
214
+ // Block comment — skip to */, preserving newlines for line counts.
215
+ i += 2;
216
+ while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) {
217
+ if (src[i] === "\n") out += "\n";
218
+ i++;
219
+ }
220
+ i += 2;
221
+ continue;
222
+ }
223
+ out += ch;
224
+ i++;
225
+ }
226
+ return out;
227
+ }
228
+
229
+ /**
230
+ * Locate the value range of a top-level JSON object key inside JSONC text.
231
+ * Returns the absolute character span of the value (the contents between
232
+ * the opening `{` and matching closing `}`) and the span of the entire
233
+ * `"key": value` pair, including the key and surrounding whitespace
234
+ * adequate for clean removal.
235
+ *
236
+ * Returns `null` when the key isn't found at the top level.
237
+ *
238
+ * Brace-counting is JSONC-aware (skips comments and strings).
239
+ */
240
+ function findTopLevelKeyRange(
241
+ src: string,
242
+ key: string,
243
+ ): { keyStart: number; valueEnd: number } | null {
244
+ // Walk to find the opening `{` of the top-level object first.
245
+ let i = 0;
246
+ let inString = false;
247
+ let stringQuote = "";
248
+
249
+ // Find first `{`
250
+ while (i < src.length) {
251
+ const ch = src[i];
252
+ const next = src[i + 1];
253
+ if (ch === "/" && next === "/") {
254
+ while (i < src.length && src[i] !== "\n") i++;
255
+ continue;
256
+ }
257
+ if (ch === "/" && next === "*") {
258
+ i += 2;
259
+ while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++;
260
+ i += 2;
261
+ continue;
262
+ }
263
+ if (ch === "{") break;
264
+ i++;
265
+ }
266
+ if (i >= src.length) return null;
267
+
268
+ // Now scan inside the top-level object, depth-tracked, looking for our key.
269
+ // Top-level keys appear at depth 1.
270
+ let depth = 1;
271
+ i++;
272
+ const needle = `"${key}"`;
273
+ while (i < src.length) {
274
+ const ch = src[i];
275
+ const next = src[i + 1];
276
+ if (inString) {
277
+ if (ch === "\\" && i + 1 < src.length) {
278
+ i += 2;
279
+ continue;
280
+ }
281
+ if (ch === stringQuote) inString = false;
282
+ i++;
283
+ continue;
284
+ }
285
+ if (ch === '"') {
286
+ // Possible key match. Check.
287
+ if (depth === 1 && src.startsWith(needle, i)) {
288
+ const keyStart = i;
289
+ // Advance past the matched key string.
290
+ i += needle.length;
291
+ // Skip whitespace + `:`
292
+ while (i < src.length && /\s/.test(src[i])) i++;
293
+ if (src[i] !== ":") return null;
294
+ i++;
295
+ while (i < src.length && /\s/.test(src[i])) i++;
296
+ // Now we're at the value. Find its end (handle objects, arrays, strings, primitives).
297
+ if (src[i] === "{" || src[i] === "[") {
298
+ const open = src[i];
299
+ const close = open === "{" ? "}" : "]";
300
+ let d = 1;
301
+ i++;
302
+ while (i < src.length && d > 0) {
303
+ const c2 = src[i];
304
+ const n2 = src[i + 1];
305
+ if (c2 === '"' || c2 === "'") {
306
+ const q = c2;
307
+ i++;
308
+ while (i < src.length) {
309
+ if (src[i] === "\\") {
310
+ i += 2;
311
+ continue;
312
+ }
313
+ if (src[i] === q) {
314
+ i++;
315
+ break;
316
+ }
317
+ i++;
318
+ }
319
+ continue;
320
+ }
321
+ if (c2 === "/" && n2 === "/") {
322
+ while (i < src.length && src[i] !== "\n") i++;
323
+ continue;
324
+ }
325
+ if (c2 === "/" && n2 === "*") {
326
+ i += 2;
327
+ while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++;
328
+ i += 2;
329
+ continue;
330
+ }
331
+ if (c2 === open) d++;
332
+ else if (c2 === close) d--;
333
+ i++;
334
+ }
335
+ return { keyStart, valueEnd: i };
336
+ }
337
+ // Primitive / string value — read until comma or closing brace at depth 1.
338
+ while (i < src.length && src[i] !== "," && src[i] !== "}" && src[i] !== "\n") i++;
339
+ return { keyStart, valueEnd: i };
340
+ }
341
+ inString = true;
342
+ stringQuote = '"';
343
+ i++;
344
+ continue;
345
+ }
346
+ if (ch === "/" && next === "/") {
347
+ while (i < src.length && src[i] !== "\n") i++;
348
+ continue;
349
+ }
350
+ if (ch === "/" && next === "*") {
351
+ i += 2;
352
+ while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++;
353
+ i += 2;
354
+ continue;
355
+ }
356
+ if (ch === "{") depth++;
357
+ else if (ch === "}") {
358
+ depth--;
359
+ if (depth === 0) return null;
360
+ }
361
+ i++;
362
+ }
363
+ return null;
364
+ }
365
+
366
+ /**
367
+ * Locate the end of the top-level object (position of the closing `}`).
368
+ * Used when appending a new key. JSONC-aware.
369
+ */
370
+ function findTopLevelObjectEnd(src: string): number | null {
371
+ let i = 0;
372
+ let inString = false;
373
+ let stringQuote = "";
374
+
375
+ // Find first `{`
376
+ while (i < src.length) {
377
+ const ch = src[i];
378
+ const next = src[i + 1];
379
+ if (ch === "/" && next === "/") {
380
+ while (i < src.length && src[i] !== "\n") i++;
381
+ continue;
382
+ }
383
+ if (ch === "/" && next === "*") {
384
+ i += 2;
385
+ while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++;
386
+ i += 2;
387
+ continue;
388
+ }
389
+ if (ch === "{") break;
390
+ i++;
391
+ }
392
+ if (i >= src.length) return null;
393
+ let depth = 1;
394
+ i++;
395
+ while (i < src.length) {
396
+ const ch = src[i];
397
+ const next = src[i + 1];
398
+ if (inString) {
399
+ if (ch === "\\" && i + 1 < src.length) {
400
+ i += 2;
401
+ continue;
402
+ }
403
+ if (ch === stringQuote) inString = false;
404
+ i++;
405
+ continue;
406
+ }
407
+ if (ch === '"' || ch === "'") {
408
+ inString = true;
409
+ stringQuote = ch;
410
+ i++;
411
+ continue;
412
+ }
413
+ if (ch === "/" && next === "/") {
414
+ while (i < src.length && src[i] !== "\n") i++;
415
+ continue;
416
+ }
417
+ if (ch === "/" && next === "*") {
418
+ i += 2;
419
+ while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) i++;
420
+ i += 2;
421
+ continue;
422
+ }
423
+ if (ch === "{") depth++;
424
+ else if (ch === "}") {
425
+ depth--;
426
+ if (depth === 0) return i;
427
+ }
428
+ i++;
429
+ }
430
+ return null;
431
+ }
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // Block rendering
435
+ // ---------------------------------------------------------------------------
436
+
437
+ function renderObservabilityBlock(opts: CliOpts, indent = " "): string {
438
+ const persist = opts.persist;
439
+ const lines: string[] = [
440
+ `"observability": {`,
441
+ `${indent}// Master switch — without enabled:true at the top level CF`,
442
+ `${indent}// captures nothing, regardless of the sub-block flags.`,
443
+ `${indent}"enabled": true,`,
444
+ `${indent}// Cloudflare captures every console.* call from the Worker`,
445
+ `${indent}// (structured JSON via @decocms/start's logger lands here too).`,
446
+ `${indent}// persist:true keeps them queryable in the CF dashboard.`,
447
+ `${indent}"logs": {`,
448
+ `${indent}${indent}"enabled": true,`,
449
+ `${indent}${indent}"invocation_logs": true,`,
450
+ `${indent}${indent}"head_sampling_rate": ${opts.logsRate},`,
451
+ `${indent}${indent}"persist": ${persist}`,
452
+ ];
453
+ if (opts.logsDest) {
454
+ // Replace the trailing line with a comma'd version, then append the
455
+ // destinations array. Keeps the block valid JSON either way.
456
+ lines[lines.length - 1] = `${indent}${indent}"persist": ${persist},`;
457
+ lines.push(`${indent}${indent}"destinations": ["${opts.logsDest}"]`);
458
+ }
459
+ lines.push(
460
+ `${indent}},`,
461
+ `${indent}// Cloudflare auto-instruments fetch/KV/R2/DO subrequests and`,
462
+ `${indent}// also picks up @opentelemetry/api global-tracer spans the`,
463
+ `${indent}// framework's withTracing() helper emits.`,
464
+ `${indent}"traces": {`,
465
+ `${indent}${indent}"enabled": true,`,
466
+ `${indent}${indent}"head_sampling_rate": ${opts.tracesRate},`,
467
+ `${indent}${indent}"persist": ${persist}`,
468
+ );
469
+ if (opts.tracesDest) {
470
+ lines[lines.length - 1] = `${indent}${indent}"persist": ${persist},`;
471
+ lines.push(`${indent}${indent}"destinations": ["${opts.tracesDest}"]`);
472
+ }
473
+ lines.push(`${indent}}`, `}`);
474
+ return lines.join("\n");
475
+ }
476
+
477
+ // ---------------------------------------------------------------------------
478
+ // Detect "already canonical"
479
+ // ---------------------------------------------------------------------------
480
+
481
+ /**
482
+ * A wrangler.jsonc is considered canonical when it has the master
483
+ * `enabled: true` switch under `observability`, both `logs` and
484
+ * `traces` sub-blocks present, AND the `destinations` arrays match
485
+ * what `--destination-logs` / `--destination-traces` requested (which
486
+ * is "absent" by default). This means a stale HyperDX-style
487
+ * destination array always triggers a rewrite even if the rest of the
488
+ * shape happens to match.
489
+ */
490
+ function isAlreadyCanonical(src: string, opts: CliOpts): boolean {
491
+ // Fast structural checks — full JSONC parse only if they pass.
492
+ if (!src.includes(`"observability"`)) return false;
493
+ if (!src.includes(`"enabled": true`) && !src.includes(`"enabled":true`)) {
494
+ return false;
495
+ }
496
+
497
+ let parsed: unknown;
498
+ try {
499
+ parsed = JSON.parse(stripJsoncComments(src));
500
+ } catch {
501
+ return false;
502
+ }
503
+ if (!parsed || typeof parsed !== "object") return false;
504
+ const obs = (parsed as Record<string, unknown>).observability;
505
+ if (!obs || typeof obs !== "object") return false;
506
+ const obsObj = obs as Record<string, unknown>;
507
+ if (obsObj.enabled !== true) return false;
508
+
509
+ const checkLeaf = (leaf: unknown, expectedDest: string): boolean => {
510
+ if (!leaf || typeof leaf !== "object") return false;
511
+ const l = leaf as Record<string, unknown>;
512
+ if (l.enabled !== true) return false;
513
+ const dests = l.destinations;
514
+ if (expectedDest === "") {
515
+ // Caller wants no destinations. Tolerate either absent or an
516
+ // empty array; reject any non-empty array.
517
+ if (dests === undefined) return true;
518
+ return Array.isArray(dests) && dests.length === 0;
519
+ }
520
+ if (!Array.isArray(dests) || dests.length !== 1) return false;
521
+ return dests[0] === expectedDest;
522
+ };
523
+
524
+ if (!checkLeaf(obsObj.logs, opts.logsDest)) return false;
525
+ if (!checkLeaf(obsObj.traces, opts.tracesDest)) return false;
526
+ return true;
527
+ }
528
+
529
+ // ---------------------------------------------------------------------------
530
+ // Validation
531
+ // ---------------------------------------------------------------------------
532
+
533
+ function validateJson(src: string): { ok: true } | { ok: false; error: string } {
534
+ try {
535
+ JSON.parse(stripJsoncComments(src));
536
+ return { ok: true };
537
+ } catch (e) {
538
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
539
+ }
540
+ }
541
+
542
+ // ---------------------------------------------------------------------------
543
+ // Diff (tiny line-level)
544
+ // ---------------------------------------------------------------------------
545
+
546
+ function unifiedDiff(before: string, after: string, file: string): string {
547
+ const a = before.split("\n");
548
+ const b = after.split("\n");
549
+ // Find the changed window — it's always contiguous because we only edit
550
+ // one block. Keep it brain-simple: shrink both ends, print the rest with
551
+ // -/+ markers.
552
+ let i = 0;
553
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
554
+ let endA = a.length;
555
+ let endB = b.length;
556
+ while (endA > i && endB > i && a[endA - 1] === b[endB - 1]) {
557
+ endA--;
558
+ endB--;
559
+ }
560
+ const ctxStart = Math.max(0, i - 3);
561
+ const ctxAEnd = Math.min(a.length, endA + 3);
562
+ const ctxBEnd = Math.min(b.length, endB + 3);
563
+ const lines: string[] = [];
564
+ lines.push(`--- ${file} (before)`);
565
+ lines.push(`+++ ${file} (after)`);
566
+ for (let k = ctxStart; k < i; k++) lines.push(` ${a[k]}`);
567
+ for (let k = i; k < endA; k++) lines.push(`- ${a[k]}`);
568
+ for (let k = i; k < endB; k++) lines.push(`+ ${b[k]}`);
569
+ for (let k = endA; k < ctxAEnd; k++) lines.push(` ${a[k]}`);
570
+ // ctxBEnd guards equality at the tail; printing either tail context is fine.
571
+ void ctxBEnd;
572
+ return lines.join("\n");
573
+ }
574
+
575
+ // ---------------------------------------------------------------------------
576
+ // Main
577
+ // ---------------------------------------------------------------------------
578
+
579
+ /**
580
+ * Detect the column-0..keyStart whitespace of the line containing
581
+ * `keyStart`, so we can re-indent every line of the rendered block to
582
+ * match the file's existing nesting depth.
583
+ */
584
+ function detectLineIndent(src: string, position: number): string {
585
+ let lineStart = position;
586
+ while (lineStart > 0 && src[lineStart - 1] !== "\n") lineStart--;
587
+ let i = lineStart;
588
+ while (i < src.length && (src[i] === " " || src[i] === "\t")) i++;
589
+ return src.slice(lineStart, i);
590
+ }
591
+
592
+ function reindentBlockBody(block: string, indent: string): string {
593
+ const lines = block.split("\n");
594
+ // Leave line 0 alone — it's spliced in at the existing key position
595
+ // which is already indented by surrounding text. Re-indent the rest.
596
+ return [lines[0], ...lines.slice(1).map((l) => indent + l)].join("\n");
597
+ }
598
+
599
+ function applyEdit(src: string, opts: CliOpts): string {
600
+ const block = renderObservabilityBlock(opts);
601
+ const range = findTopLevelKeyRange(src, "observability");
602
+
603
+ if (range) {
604
+ // Replace the existing `"observability": {...}` (key + value) with the
605
+ // new block. Re-indent body lines to match the existing key's column
606
+ // so the final wrangler.jsonc stays cleanly formatted.
607
+ const indent = detectLineIndent(src, range.keyStart);
608
+ const indentedBlock = reindentBlockBody(block, indent);
609
+ return src.slice(0, range.keyStart) + indentedBlock + src.slice(range.valueEnd);
610
+ }
611
+
612
+ // No observability key — append before the closing `}` of the top-level object.
613
+ const end = findTopLevelObjectEnd(src);
614
+ if (end == null) {
615
+ throw new Error("wrangler.jsonc: could not locate top-level closing `}`");
616
+ }
617
+ // Walk back from the closing `}` over whitespace to find the last
618
+ // non-whitespace character. We splice in two pieces:
619
+ // - the comma (if needed) goes immediately AFTER that char so it
620
+ // sits on the same line as the prior key, not on a line of its own
621
+ // - the new key + value goes right before the closing `}`
622
+ let scan = end - 1;
623
+ while (scan >= 0 && /\s/.test(src[scan])) scan--;
624
+ const prevChar = scan >= 0 ? src[scan] : "";
625
+ const needsComma = prevChar !== "{" && prevChar !== ",";
626
+ const baseIndent = " ";
627
+ const indented = block
628
+ .split("\n")
629
+ .map((l) => baseIndent + l)
630
+ .join("\n");
631
+ if (needsComma) {
632
+ const commaInsertAt = scan + 1;
633
+ const before = `${src.slice(0, commaInsertAt)},`;
634
+ // Preserve any whitespace/newlines that were between the prior key
635
+ // and the closing `}` so the new block lines up under existing
636
+ // indentation conventions.
637
+ const between = src.slice(commaInsertAt, end);
638
+ return `${before}${between}${indented}\n${src.slice(end)}`;
639
+ }
640
+ return `${src.slice(0, end)}${indented}\n${src.slice(end)}`;
641
+ }
642
+
643
+ function main(): void {
644
+ const opts = parseArgs(process.argv.slice(2));
645
+ if (opts.help) {
646
+ showHelp();
647
+ return;
648
+ }
649
+ const wranglerPath = path.join(path.resolve(opts.source), "wrangler.jsonc");
650
+ if (!fs.existsSync(wranglerPath)) {
651
+ console.error(`error: ${wranglerPath} does not exist`);
652
+ process.exit(2);
653
+ }
654
+
655
+ const before = fs.readFileSync(wranglerPath, "utf8");
656
+
657
+ if (isAlreadyCanonical(before, opts)) {
658
+ console.log(`${wranglerPath} already on the canonical CF observability block — no change.`);
659
+ process.exit(0);
660
+ }
661
+
662
+ let after: string;
663
+ try {
664
+ after = applyEdit(before, opts);
665
+ } catch (e) {
666
+ console.error(`error: ${e instanceof Error ? e.message : String(e)}`);
667
+ process.exit(2);
668
+ }
669
+
670
+ const validation = validateJson(after);
671
+ if (!validation.ok) {
672
+ console.error(`error: result wouldn't parse as JSONC: ${validation.error}`);
673
+ console.error("aborting; no changes written.");
674
+ process.exit(2);
675
+ }
676
+
677
+ if (!opts.write) {
678
+ console.log(unifiedDiff(before, after, wranglerPath));
679
+ console.log("\nDry-run only. Re-run with --write to apply.");
680
+ process.exit(1);
681
+ }
682
+
683
+ fs.writeFileSync(wranglerPath, after, "utf8");
684
+ console.log(`wrote ${wranglerPath}`);
685
+ console.log(`
686
+ Next steps:
687
+ 1. wrangler deploy
688
+ 2. Verify CF dashboard captures logs + traces (~5 min):
689
+ Workers & Pages → <site> → Observability
690
+ 3. If migrating from an older app-side OTLP setup, delete the
691
+ now-orphaned secrets:
692
+ wrangler secret delete OTEL_EXPORTER_OTLP_ENDPOINT \\
693
+ OTEL_EXPORTER_OTLP_HEADERS \\
694
+ OTEL_SAMPLING_CONFIG \\
695
+ OTEL_LOG_MIN_SEVERITY
696
+ `);
697
+ }
698
+
699
+ main();