@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.
- package/package.json +44 -0
- package/scripts/analyze-traces.mjs +1117 -0
- package/scripts/audit-observability-config.test.ts +446 -0
- package/scripts/audit-observability-config.ts +511 -0
- package/scripts/deco-migrate-cli.ts +444 -0
- package/scripts/fast-deploy-kv.test.ts +131 -0
- package/scripts/generate-blocks.test.ts +94 -0
- package/scripts/generate-blocks.ts +274 -0
- package/scripts/generate-invoke.test.ts +195 -0
- package/scripts/generate-invoke.ts +469 -0
- package/scripts/generate-loaders.ts +217 -0
- package/scripts/generate-schema.ts +1287 -0
- package/scripts/generate-sections.ts +237 -0
- package/scripts/htmx-analyze.ts +226 -0
- package/scripts/lib/blocks-dedupe.test.ts +179 -0
- package/scripts/lib/blocks-dedupe.ts +142 -0
- package/scripts/lib/cf-kv-rest.ts +78 -0
- package/scripts/lib/jsonc.ts +122 -0
- package/scripts/lib/kv-snapshot.ts +51 -0
- package/scripts/lib/read-decofile.ts +70 -0
- package/scripts/lib/sync-helpers.ts +44 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
- package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
- package/scripts/migrate/analyzers/island-classifier.ts +96 -0
- package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
- package/scripts/migrate/analyzers/section-metadata.ts +147 -0
- package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
- package/scripts/migrate/colors.ts +46 -0
- package/scripts/migrate/config.test.ts +202 -0
- package/scripts/migrate/config.ts +186 -0
- package/scripts/migrate/phase-analyze.test.ts +63 -0
- package/scripts/migrate/phase-analyze.ts +782 -0
- package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
- package/scripts/migrate/phase-cleanup-audit.ts +105 -0
- package/scripts/migrate/phase-cleanup.test.ts +141 -0
- package/scripts/migrate/phase-cleanup.ts +1588 -0
- package/scripts/migrate/phase-compile.test.ts +193 -0
- package/scripts/migrate/phase-compile.ts +177 -0
- package/scripts/migrate/phase-report.ts +243 -0
- package/scripts/migrate/phase-scaffold.ts +593 -0
- package/scripts/migrate/phase-transform.ts +310 -0
- package/scripts/migrate/phase-verify.test.ts +127 -0
- package/scripts/migrate/phase-verify.ts +572 -0
- package/scripts/migrate/post-cleanup/rules.ts +1708 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
- package/scripts/migrate/post-cleanup/runner.ts +137 -0
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
- package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
- package/scripts/migrate/post-cleanup/types.ts +106 -0
- package/scripts/migrate/source-layout.test.ts +111 -0
- package/scripts/migrate/source-layout.ts +103 -0
- package/scripts/migrate/templates/app-css.ts +366 -0
- package/scripts/migrate/templates/cache-config.ts +26 -0
- package/scripts/migrate/templates/commerce-loaders.ts +230 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
- package/scripts/migrate/templates/cursor-rules.ts +70 -0
- package/scripts/migrate/templates/hooks.test.ts +141 -0
- package/scripts/migrate/templates/hooks.ts +152 -0
- package/scripts/migrate/templates/knip-config.ts +27 -0
- package/scripts/migrate/templates/lib-utils.test.ts +139 -0
- package/scripts/migrate/templates/lib-utils.ts +326 -0
- package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
- package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
- package/scripts/migrate/templates/package-json.ts +177 -0
- package/scripts/migrate/templates/routes.ts +237 -0
- package/scripts/migrate/templates/sdk-gen.ts +59 -0
- package/scripts/migrate/templates/section-loaders.ts +456 -0
- package/scripts/migrate/templates/server-entry.ts +561 -0
- package/scripts/migrate/templates/setup.ts +148 -0
- package/scripts/migrate/templates/tsconfig.ts +21 -0
- package/scripts/migrate/templates/types-gen.ts +174 -0
- package/scripts/migrate/templates/ui-components.ts +144 -0
- package/scripts/migrate/templates/vite-config.ts +101 -0
- package/scripts/migrate/transforms/dead-code.ts +455 -0
- package/scripts/migrate/transforms/deno-isms.ts +85 -0
- package/scripts/migrate/transforms/fresh-apis.ts +223 -0
- package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
- package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
- package/scripts/migrate/transforms/imports.ts +385 -0
- package/scripts/migrate/transforms/jsx.ts +317 -0
- package/scripts/migrate/transforms/section-conventions.ts +210 -0
- package/scripts/migrate/transforms/tailwind.ts +739 -0
- package/scripts/migrate/types.ts +244 -0
- package/scripts/migrate-blocks-to-kv.ts +104 -0
- package/scripts/migrate-post-cleanup.ts +191 -0
- package/scripts/migrate-to-cf-observability.test.ts +215 -0
- package/scripts/migrate-to-cf-observability.ts +699 -0
- package/scripts/migrate.ts +282 -0
- package/scripts/smoke-otlp-errorlog.ts +46 -0
- package/scripts/smoke-otlp-meter.ts +48 -0
- package/scripts/sync-blocks-to-kv.ts +153 -0
- package/scripts/tailwind-lint.ts +518 -0
- 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();
|