@decocms/blocks-cli 8.0.0 → 8.1.0-next.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 (100) hide show
  1. package/package.json +5 -3
  2. package/scripts/analyze-traces.mjs +1 -0
  3. package/scripts/cdn-rules.test.ts +105 -0
  4. package/scripts/cdn-rules.ts +192 -0
  5. package/scripts/deco-migrate-cli.ts +6 -7
  6. package/scripts/fast-deploy-kv.test.ts +172 -0
  7. package/scripts/generate-blocks.test.ts +11 -0
  8. package/scripts/generate-blocks.ts +0 -10
  9. package/scripts/generate-invoke.test.ts +2 -2
  10. package/scripts/generate-invoke.ts +9 -21
  11. package/scripts/generate-loaders.test.ts +2 -2
  12. package/scripts/generate-loaders.ts +0 -10
  13. package/scripts/generate-schema-loader-aliases.test.ts +186 -0
  14. package/scripts/generate-schema-workspaces.test.ts +180 -0
  15. package/scripts/generate-schema.test.ts +68 -29
  16. package/scripts/generate-schema.ts +101 -89
  17. package/scripts/generate-sections.test.ts +104 -35
  18. package/scripts/generate-sections.ts +57 -11
  19. package/scripts/generate-workspaces.test.ts +179 -0
  20. package/scripts/generate.test.ts +1 -2
  21. package/scripts/generate.ts +43 -45
  22. package/scripts/lib/blocks-dedupe.test.ts +1 -1
  23. package/scripts/lib/cf-kv-rest.ts +49 -1
  24. package/scripts/lib/installedPackages.ts +42 -0
  25. package/scripts/lib/invokeSource.ts +18 -0
  26. package/scripts/lib/kv-snapshot.ts +44 -0
  27. package/scripts/lib/read-decofile.ts +13 -2
  28. package/scripts/lib/upgrade-6-to-7.ts +1 -1
  29. package/scripts/lib/wrangler-config.test.ts +49 -0
  30. package/scripts/lib/wrangler-config.ts +31 -0
  31. package/scripts/migrate/analyzers/htmx-analyze.test.ts +6 -6
  32. package/scripts/migrate/analyzers/tailwind-config.ts +95 -1
  33. package/scripts/migrate/config.ts +2 -2
  34. package/scripts/migrate/delete-sets.ts +75 -0
  35. package/scripts/migrate/fast-deploy-scaffold.test.ts +130 -0
  36. package/scripts/migrate/icon-casing.test.ts +63 -0
  37. package/scripts/migrate/phase-analyze.test.ts +66 -4
  38. package/scripts/migrate/phase-analyze.ts +79 -60
  39. package/scripts/migrate/phase-cleanup.test.ts +3 -1
  40. package/scripts/migrate/phase-cleanup.ts +1 -1
  41. package/scripts/migrate/phase-compile.test.ts +3 -1
  42. package/scripts/migrate/phase-report.test.ts +2 -0
  43. package/scripts/migrate/phase-report.ts +3 -4
  44. package/scripts/migrate/phase-scaffold.ts +150 -45
  45. package/scripts/migrate/phase-transform.ts +107 -2
  46. package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +13 -0
  47. package/scripts/migrate/phase-verify.test.ts +3 -1
  48. package/scripts/migrate/phase-verify.ts +87 -33
  49. package/scripts/migrate/post-cleanup/rules.ts +29 -11
  50. package/scripts/migrate/post-cleanup/shim-classify.test.ts +1 -1
  51. package/scripts/migrate/post-cleanup/shim-classify.ts +2 -2
  52. package/scripts/migrate/source-layout.test.ts +43 -0
  53. package/scripts/migrate/source-layout.ts +32 -5
  54. package/scripts/migrate/templates/app-css.test.ts +24 -0
  55. package/scripts/migrate/templates/app-css.ts +27 -0
  56. package/scripts/migrate/templates/ci-workflows.test.ts +179 -0
  57. package/scripts/migrate/templates/ci-yml.ts +182 -0
  58. package/scripts/migrate/templates/cursor-rules.test.ts +3 -3
  59. package/scripts/migrate/templates/hooks.test.ts +3 -1
  60. package/scripts/migrate/templates/lib-utils.ts +3 -3
  61. package/scripts/migrate/templates/main-push-guard-yml.ts +45 -0
  62. package/scripts/migrate/templates/package-json.ts +10 -0
  63. package/scripts/migrate/templates/parity-yml.ts +136 -0
  64. package/scripts/migrate/templates/perf-yml.ts +449 -0
  65. package/scripts/migrate/templates/playwright-yml.ts +127 -0
  66. package/scripts/migrate/templates/react-doctor-yml.ts +46 -0
  67. package/scripts/migrate/templates/routes.test.ts +31 -0
  68. package/scripts/migrate/templates/routes.ts +19 -9
  69. package/scripts/migrate/templates/section-loaders.ts +3 -3
  70. package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +19 -0
  71. package/scripts/migrate/templates/server-entry.ts +19 -14
  72. package/scripts/migrate/templates/setup.ts +11 -3
  73. package/scripts/migrate/templates/sync-blocks-bot-yml.ts +201 -0
  74. package/scripts/migrate/templates/ui-components.ts +3 -0
  75. package/scripts/migrate/transforms/ctx-compat.test.ts +37 -0
  76. package/scripts/migrate/transforms/ctx-compat.ts +93 -1
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +1 -1
  78. package/scripts/migrate/transforms/htmx-on-events.ts +2 -2
  79. package/scripts/migrate/transforms/jsx-class-type.test.ts +28 -0
  80. package/scripts/migrate/transforms/jsx.ts +10 -0
  81. package/scripts/migrate/transforms/picture.test.ts +56 -0
  82. package/scripts/migrate/transforms/picture.ts +95 -0
  83. package/scripts/migrate/transforms/tailwind-renames.test.ts +29 -0
  84. package/scripts/migrate/transforms/tailwind-renames.ts +34 -0
  85. package/scripts/migrate/transforms/timer-types.test.ts +30 -0
  86. package/scripts/migrate/transforms/timer-types.ts +48 -0
  87. package/scripts/migrate/transforms/use-script-handlers.test.ts +37 -0
  88. package/scripts/migrate/transforms/use-script-handlers.ts +36 -0
  89. package/scripts/migrate/types.ts +13 -1
  90. package/scripts/migrate-blocks-to-kv.ts +16 -2
  91. package/scripts/migrate-to-cf-observability.test.ts +5 -5
  92. package/scripts/migrate-to-cf-observability.ts +1 -1
  93. package/scripts/migrate.ts +244 -6
  94. package/scripts/reconcile.test.ts +162 -0
  95. package/scripts/reconcile.ts +303 -0
  96. package/scripts/sync-blocks-bot.test.ts +276 -0
  97. package/scripts/sync-blocks-bot.ts +567 -0
  98. package/scripts/sync-blocks-to-kv.ts +20 -2
  99. package/scripts/upgrade-6-to-7.ts +0 -0
  100. package/scripts/lib/legacyArtifact.ts +0 -20
@@ -30,8 +30,14 @@
30
30
  */
31
31
 
32
32
  import * as path from "node:path";
33
+ import { splitExactRedirects } from "@decocms/blocks/sdk/redirects";
33
34
  import { createKvRestClient, kvConfigFromEnv } from "./lib/cf-kv-rest";
34
- import { buildSnapshot, verifySnapshotInKv, writeSnapshotToKv } from "./lib/kv-snapshot";
35
+ import {
36
+ buildSnapshot,
37
+ syncRedirectsToKv,
38
+ verifySnapshotInKv,
39
+ writeSnapshotToKv,
40
+ } from "./lib/kv-snapshot";
35
41
  import { readDecofileFromDir } from "./lib/read-decofile";
36
42
 
37
43
  function parseArgs(argv: string[]) {
@@ -77,8 +83,15 @@ async function main() {
77
83
  process.exit(2);
78
84
  }
79
85
 
80
- const snap = buildSnapshot(blocks);
86
+ // Exact redirects live under their own `redirect:<id>:<path>` keys, not in
87
+ // the decofile — see splitExactRedirects. Must match what sync-blocks-to-kv
88
+ // writes, or a one-shot seed and a CI sync would disagree on the snapshot.
89
+ const split = splitExactRedirects(blocks);
90
+ const snap = buildSnapshot(split.blocks);
81
91
  console.log(`decofile: ${snap.count} blocks, revision ${snap.revision}, ${snap.snapshot.length} bytes`);
92
+ if (split.exact.length > 0) {
93
+ console.log(`redirects: ${split.exact.length} exact rule(s) as individual keys`);
94
+ }
82
95
 
83
96
  if (!opts.write) {
84
97
  console.log("\nDry-run only. Re-run with --write to populate KV.");
@@ -94,6 +107,7 @@ async function main() {
94
107
  }
95
108
 
96
109
  try {
110
+ await syncRedirectsToKv(client, split.exact, opts.deploymentId);
97
111
  await writeSnapshotToKv(client, snap, opts.deploymentId);
98
112
  const verify = await verifySnapshotInKv(client, snap.revision, opts.deploymentId);
99
113
  if (!verify.ok) {
@@ -46,13 +46,13 @@ describe("migrate-to-cf-observability codemod", () => {
46
46
  fs.writeFileSync(
47
47
  wranglerPath,
48
48
  `{
49
- "name": "lebiscuit-tanstack",
49
+ "name": "acme-tanstack",
50
50
  "compatibility_date": "2026-02-14",
51
51
  "main": "./src/worker-entry.ts",
52
52
  "kv_namespaces": [{ "binding": "SITES_KV", "id": "abc" }],
53
53
  "version_metadata": { "binding": "CF_VERSION_METADATA" },
54
54
  "analytics_engine_datasets": [
55
- { "binding": "DECO_METRICS", "dataset": "deco_metrics_lebiscuit" }
55
+ { "binding": "DECO_METRICS", "dataset": "deco_metrics_acme" }
56
56
  ],
57
57
  "observability": {
58
58
  "logs": {
@@ -77,7 +77,7 @@ describe("migrate-to-cf-observability codemod", () => {
77
77
  // No destinations by default.
78
78
  expect(result).not.toContain('"destinations"');
79
79
  // Original keys preserved.
80
- expect(result).toContain('"name": "lebiscuit-tanstack"');
80
+ expect(result).toContain('"name": "acme-tanstack"');
81
81
  expect(result).toContain('"binding": "DECO_METRICS"');
82
82
  // Result must be valid JSONC.
83
83
  expect(() => JSON.parse(stripJsoncComments(result))).not.toThrow();
@@ -87,7 +87,7 @@ describe("migrate-to-cf-observability codemod", () => {
87
87
  fs.writeFileSync(
88
88
  wranglerPath,
89
89
  `{
90
- "name": "lebiscuit-tanstack",
90
+ "name": "acme-tanstack",
91
91
  "main": "./src/worker-entry.ts",
92
92
  "observability": {
93
93
  "enabled": true,
@@ -146,7 +146,7 @@ describe("migrate-to-cf-observability codemod", () => {
146
146
  fs.writeFileSync(
147
147
  wranglerPath,
148
148
  `{
149
- "name": "lebiscuit-tanstack",
149
+ "name": "acme-tanstack",
150
150
  "main": "./src/worker-entry.ts",
151
151
  "observability": {
152
152
  "logs": { "enabled": true }
@@ -21,7 +21,7 @@
21
21
  * Cloudflare captures nothing, regardless of the sub-block flags.
22
22
  * `persist: true` keeps the data queryable in the CF dashboard
23
23
  * (Workers Logs view + Traces view). Discovered the hard way during the
24
- * lebiscuit canary cutover.
24
+ * production canary cutover.
25
25
  *
26
26
  * Behavior:
27
27
  * - dry-run by default — prints a unified diff against the existing
@@ -25,7 +25,9 @@
25
25
  */
26
26
 
27
27
  import { execSync } from "node:child_process";
28
+ import * as fs from "node:fs";
28
29
  import * as path from "node:path";
30
+ import { setKvNamespaceIdInJsonc } from "./lib/wrangler-config";
29
31
  import { banner, green, red, stat, yellow } from "./migrate/colors";
30
32
  import { loadConfig, validateConfig } from "./migrate/config";
31
33
  import { analyze } from "./migrate/phase-analyze";
@@ -118,6 +120,15 @@ function showHelp() {
118
120
  via \`deco-post-cleanup\` if needed)
119
121
  --help, -h Show this help message
120
122
 
123
+ Environment (all optional — each step is skipped, never fatal, when unset):
124
+ DECO_CONTROL_PLANE_TOKEN Provision the site via the control-plane's
125
+ SITE_CREATE: repo, org ownership, the site's OWN
126
+ Cloudflare KV namespace, and the deploy CR.
127
+ DECO_ORG Studio organization id that will own the site.
128
+ Required together with the token above.
129
+ DECO_CONTROL_PLANE_URL Override the control-plane base URL.
130
+ SUPABASE_ACCESS_TOKEN Flip the site's analytics provider.
131
+
121
132
  Examples:
122
133
  npx -p @decocms/blocks-cli deco-migrate --dry-run --verbose
123
134
  npx -p @decocms/blocks-cli deco-migrate --source ./my-site
@@ -156,18 +167,21 @@ async function main() {
156
167
  config: siteConfig,
157
168
  });
158
169
 
159
- // Phase 0: Source-layout detection (early-abort for unsupported layouts).
160
- // The analyzer assumes a classic root layout (sections/, islands/, ...);
161
- // running it on a modern src/ layout silently yields a near-empty
162
- // migration. Detect-and-abort here so the user gets an actionable error
163
- // before we touch any files.
170
+ // Phase 0: Source-layout detection. `classic` (root sections/) and `modern`
171
+ // (src/sections/) are both supported — the analyzer scans src/ natively for
172
+ // modern layouts. Only genuinely ambiguous (`mixed`) or unrecognizable
173
+ // (`empty`) layouts abort with an actionable error before we touch files.
164
174
  const layout = detectSourceLayout(sourceDir);
165
- if (layout !== "classic") {
175
+ ctx.layout = layout;
176
+ if (layout === "mixed" || layout === "empty") {
166
177
  console.error(red(`Error: ${layout} source layout`));
167
178
  console.error("");
168
179
  console.error(explainNonClassicLayout(layout, sourceDir));
169
180
  process.exit(2);
170
181
  }
182
+ if (layout === "modern") {
183
+ console.log(` Source layout: modern (src/) — scanning src/ natively`);
184
+ }
171
185
 
172
186
  try {
173
187
  // Phase 1: Analyze source
@@ -194,6 +208,8 @@ async function main() {
194
208
  // Phase 7: Bootstrap (install + generate)
195
209
  if (!ctx.dryRun) {
196
210
  bootstrap(ctx);
211
+ await provisionSite(ctx.sourceDir);
212
+ await provisionAnalytics(ctx.sourceDir);
197
213
  }
198
214
 
199
215
  // Phase 8: Compile (typecheck + optional build)
@@ -279,4 +295,226 @@ function bootstrap(ctx: { sourceDir: string }) {
279
295
  }
280
296
  }
281
297
 
298
+ /** A site slug: lower-case, the shape SITE_CREATE itself enforces. */
299
+ const SITE_SLUG = /^[a-z0-9][a-z0-9._-]*$/;
300
+
301
+ const CONTROL_PLANE_URL = "https://control-plane.decocms.com";
302
+
303
+ /** `owner/name` of the repo in `dir`, from its `origin` remote, or null. */
304
+ function repoFullName(dir: string): string | null {
305
+ try {
306
+ const url = execSync("git remote get-url origin", { cwd: dir, stdio: "pipe" })
307
+ .toString()
308
+ .trim();
309
+ const m = url.match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/);
310
+ return m ? `${m[1]}/${m[2]}` : null;
311
+ } catch {
312
+ return null;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Provision the site on the control-plane (SITE_CREATE) and write the KV
318
+ * namespace id it returns into the scaffolded `wrangler.jsonc`.
319
+ *
320
+ * SITE_CREATE is idempotent and create-if-absent at every step: it claims the
321
+ * admin `sites` row, the org ownership, the site's OWN Cloudflare KV namespace
322
+ * (`deco-kv-<site>` — never shared, since `index:live` is one key per namespace
323
+ * and two sites sharing one would clobber each other's live pointer), the
324
+ * production environment, and the Deco CR that makes the site actually deploy.
325
+ * So a migrated site comes out of the box deployable and fast-deploy-ready
326
+ * instead of needing a dashboard click per worker.
327
+ *
328
+ * Inert without credentials: prints the equivalent call and continues. A
329
+ * migration must never fail because an operator ran it without a token — and
330
+ * the id is not load-bearing anyway, since the builder re-forces the DECO_KV id
331
+ * from `CF_KV_NAMESPACE_ID` on every build.
332
+ */
333
+ async function provisionSite(sourceDir: string): Promise<void> {
334
+ logPhase("Provision site (control-plane)");
335
+
336
+ let siteName: string;
337
+ try {
338
+ siteName = JSON.parse(fs.readFileSync(path.join(sourceDir, "package.json"), "utf8")).name;
339
+ } catch {
340
+ console.log(` ${yellow("⚠")} Could not read package.json — skipping site provision`);
341
+ return;
342
+ }
343
+
344
+ const base = process.env.DECO_CONTROL_PLANE_URL || CONTROL_PLANE_URL;
345
+ const token = process.env.DECO_CONTROL_PLANE_TOKEN;
346
+ const org = process.env.DECO_ORG;
347
+ const repo = repoFullName(sourceDir);
348
+
349
+ const args: Record<string, string> = { name: siteName, org: org ?? "<DECO_ORG>" };
350
+ // Shared/existing-repo mode: the repo already exists (we just migrated it),
351
+ // so SITE_CREATE must reference it rather than generate one from a template.
352
+ if (repo) args.repo = repo;
353
+
354
+ if (!token || !org) {
355
+ const which = [!token && "DECO_CONTROL_PLANE_TOKEN", !org && "DECO_ORG"]
356
+ .filter(Boolean)
357
+ .join(" + ");
358
+ console.log(` ${yellow("⚠")} ${which} not set — skipping site provision`);
359
+ printSiteCreate(base, args);
360
+ return;
361
+ }
362
+
363
+ try {
364
+ const res = await fetch(`${base}/mcp`, {
365
+ method: "POST",
366
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
367
+ signal: AbortSignal.timeout(30_000),
368
+ body: JSON.stringify({
369
+ jsonrpc: "2.0",
370
+ id: 1,
371
+ method: "tools/call",
372
+ params: { name: "SITE_CREATE", arguments: args },
373
+ }),
374
+ });
375
+ if (!res.ok) {
376
+ console.log(
377
+ ` ${yellow("⚠")} SITE_CREATE failed (${res.status}): ${(await res.text()).slice(0, 160)}`,
378
+ );
379
+ printSiteCreate(base, args);
380
+ return;
381
+ }
382
+ const body = (await res.json()) as {
383
+ error?: { message?: string };
384
+ result?: { structuredContent?: unknown; content?: Array<{ text?: string }> };
385
+ };
386
+ if (body.error) {
387
+ console.log(` ${yellow("⚠")} SITE_CREATE error: ${body.error.message ?? "unknown"}`);
388
+ printSiteCreate(base, args);
389
+ return;
390
+ }
391
+ const out = readToolResult(body.result);
392
+ console.log(
393
+ ` ${green("✓")} ${siteName} provisioned (org ${org}${out?.cluster ? `, cluster ${out.cluster}` : ""})`,
394
+ );
395
+
396
+ if (out?.kvNamespaceId) {
397
+ applyKvNamespaceId(sourceDir, out.kvNamespaceId);
398
+ } else {
399
+ // SITE_CREATE omits it on a knative site, when CF isn't configured, or
400
+ // when provisioning failed — the absence is the signal, not a default.
401
+ console.log(
402
+ ` ${yellow("⚠")} no kvNamespaceId returned — DECO_KV id left blank in wrangler.jsonc`,
403
+ );
404
+ }
405
+ } catch (err: any) {
406
+ console.log(` ${yellow("⚠")} control-plane request failed: ${err.message}`);
407
+ printSiteCreate(base, args);
408
+ }
409
+ }
410
+
411
+ /** MCP tool output: `structuredContent` when present, else the JSON text block. */
412
+ function readToolResult(
413
+ result: { structuredContent?: unknown; content?: Array<{ text?: string }> } | undefined,
414
+ ): { kvNamespaceId?: string; cluster?: string } | null {
415
+ if (!result) return null;
416
+ if (result.structuredContent && typeof result.structuredContent === "object") {
417
+ return result.structuredContent as { kvNamespaceId?: string; cluster?: string };
418
+ }
419
+ const text = result.content?.[0]?.text;
420
+ if (!text) return null;
421
+ try {
422
+ return JSON.parse(text);
423
+ } catch {
424
+ return null;
425
+ }
426
+ }
427
+
428
+ /** Write the site's own KV namespace id into the scaffolded wrangler.jsonc. */
429
+ function applyKvNamespaceId(sourceDir: string, id: string): void {
430
+ const file = path.join(sourceDir, "wrangler.jsonc");
431
+ try {
432
+ const src = fs.readFileSync(file, "utf-8");
433
+ const next = setKvNamespaceIdInJsonc(src, id);
434
+ if (next === src) {
435
+ console.log(` ${yellow("⚠")} wrangler.jsonc has no DECO_KV binding — id not written`);
436
+ return;
437
+ }
438
+ fs.writeFileSync(file, next);
439
+ console.log(` ${green("✓")} wrangler.jsonc DECO_KV id → ${id}`);
440
+ } catch (err: any) {
441
+ console.log(` ${yellow("⚠")} could not write wrangler.jsonc: ${err.message}`);
442
+ }
443
+ }
444
+
445
+ function printSiteCreate(base: string, args: Record<string, string>): void {
446
+ console.log(` Run manually once you have a control-plane token:`);
447
+ console.log(
448
+ ` curl -s ${base}/mcp -XPOST -H 'content-type: application/json' \\\n` +
449
+ ` -H "authorization: Bearer $DECO_CONTROL_PLANE_TOKEN" \\\n` +
450
+ ` -d '${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "SITE_CREATE", arguments: args } })}'`,
451
+ );
452
+ }
453
+
454
+ // Supabase project ref for decocms — the central platform DB that tracks all sites.
455
+ const DECOCMS_SUPABASE_REF = "ozksgdmyrqcxcwhnbepg";
456
+
457
+ async function provisionAnalytics(sourceDir: string): Promise<void> {
458
+ logPhase("Provision analytics (decocms Supabase)");
459
+
460
+ let siteName: string;
461
+ try {
462
+ const pkg = JSON.parse(fs.readFileSync(path.join(sourceDir, "package.json"), "utf8"));
463
+ siteName = pkg.name;
464
+ } catch {
465
+ console.log(` ${yellow("⚠")} Could not read package.json — skipping analytics provision`);
466
+ return;
467
+ }
468
+
469
+ const token = process.env.SUPABASE_ACCESS_TOKEN;
470
+ if (!token) {
471
+ console.log(` ${yellow("⚠")} SUPABASE_ACCESS_TOKEN not set — skipping analytics provision`);
472
+ printAnalyticsSQL(siteName);
473
+ return;
474
+ }
475
+
476
+ // The Supabase Management API takes raw SQL with no bind parameters, so the
477
+ // only safe move is to refuse anything that isn't a plain site slug rather
478
+ // than interpolate whatever `package.json` happens to contain.
479
+ if (!SITE_SLUG.test(siteName)) {
480
+ console.log(
481
+ ` ${yellow("⚠")} "${siteName}" is not a plain site slug — skipping analytics provision`,
482
+ );
483
+ return;
484
+ }
485
+
486
+ const sql = `UPDATE public.sites SET metadata = metadata || '{"analytics": "onedollarstats"}'::jsonb WHERE name = '${siteName}'`;
487
+
488
+ try {
489
+ const res = await fetch(
490
+ `https://api.supabase.com/v1/projects/${DECOCMS_SUPABASE_REF}/database/query`,
491
+ {
492
+ method: "POST",
493
+ headers: {
494
+ Authorization: `Bearer ${token}`,
495
+ "Content-Type": "application/json",
496
+ },
497
+ body: JSON.stringify({ query: sql }),
498
+ },
499
+ );
500
+ if (res.ok) {
501
+ console.log(` ${green("✓")} ${siteName} → analytics: onedollarstats`);
502
+ } else {
503
+ const body = await res.text();
504
+ console.log(` ${yellow("⚠")} Supabase update failed (${res.status}): ${body.slice(0, 120)}`);
505
+ printAnalyticsSQL(siteName);
506
+ }
507
+ } catch (err: any) {
508
+ console.log(` ${yellow("⚠")} Supabase request failed: ${err.message}`);
509
+ printAnalyticsSQL(siteName);
510
+ }
511
+ }
512
+
513
+ function printAnalyticsSQL(siteName: string): void {
514
+ console.log(` Run manually on decocms Supabase (${DECOCMS_SUPABASE_REF}):`);
515
+ console.log(
516
+ ` UPDATE public.sites SET metadata = metadata || '{"analytics": "onedollarstats"}'::jsonb WHERE name = '${siteName}';`,
517
+ );
518
+ }
519
+
282
520
  main();
@@ -0,0 +1,162 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import {
7
+ detectTargetSnapshot,
8
+ isSkipped,
9
+ parseNameStatus,
10
+ type ReconcileManifest,
11
+ targetCandidates,
12
+ } from "./reconcile";
13
+
14
+ describe("parseNameStatus", () => {
15
+ it("splits plain and rename entries", () => {
16
+ expect(parseNameStatus("M\tsections/A.tsx\nR094\tislands/B.tsx\tislands/C.tsx\n"))
17
+ .toEqual([
18
+ { status: "M", path: "sections/A.tsx" },
19
+ { status: "R094", path: "islands/C.tsx", oldPath: "islands/B.tsx" },
20
+ ]);
21
+ });
22
+ });
23
+
24
+ describe("isSkipped", () => {
25
+ it("drops CMS content, lockfiles and binaries but keeps source", () => {
26
+ expect(isSkipped(".deco/blocks/pages-home.json")).toBe(true);
27
+ expect(isSkipped("deno.lock")).toBe(true);
28
+ expect(isSkipped("static/logo.png")).toBe(true);
29
+ expect(isSkipped("sections/Header.tsx")).toBe(false);
30
+ });
31
+
32
+ it("drops what the migration deletes — no target file to port into", () => {
33
+ expect(isSkipped("fresh.gen.ts")).toBe(true);
34
+ expect(isSkipped("manifest.gen.ts")).toBe(true);
35
+ expect(isSkipped("deno.json")).toBe(true);
36
+ expect(isSkipped("sdk/cart/vtex.ts")).toBe(true);
37
+ // routes/ is rescaffolded but a NEW upstream route is a real change — keep it.
38
+ expect(isSkipped("routes/blog.tsx")).toBe(false);
39
+ expect(isSkipped("redirects.csv")).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe("targetCandidates", () => {
44
+ it("ranks the conventional guess first, keeps other basename matches", () => {
45
+ const byBasename = new Map([
46
+ ["Cart.tsx", ["src/sections/Cart.tsx", "src/components/Cart.tsx"]],
47
+ ]);
48
+ expect(targetCandidates("islands/Cart.tsx", byBasename)).toEqual([
49
+ "src/components/Cart.tsx",
50
+ "src/sections/Cart.tsx",
51
+ ]);
52
+ });
53
+
54
+ it("returns nothing when the file does not exist on the target", () => {
55
+ expect(targetCandidates("sections/New.tsx", new Map())).toEqual([]);
56
+ });
57
+ });
58
+
59
+ describe("detectTargetSnapshot", () => {
60
+ it("takes the OLDEST add — git log is newest-first, re-adds must not win", () => {
61
+ expect(detectTargetSnapshot("bbb\naaa\n")).toBe("aaa");
62
+ expect(detectTargetSnapshot("")).toBeUndefined();
63
+ });
64
+ });
65
+
66
+ describe("reconcile end to end", () => {
67
+ let tmp: string;
68
+ const git = (cwd: string, ...args: string[]) =>
69
+ execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8" }).trim();
70
+
71
+ const write = (repo: string, rel: string, body: string) => {
72
+ fs.mkdirSync(path.join(repo, path.dirname(rel)), { recursive: true });
73
+ fs.writeFileSync(path.join(repo, rel), body);
74
+ };
75
+ const commit = (repo: string, msg: string) => {
76
+ git(repo, "add", "-A");
77
+ git(repo, "commit", "-m", msg);
78
+ return git(repo, "rev-parse", "HEAD");
79
+ };
80
+ const init = (name: string) => {
81
+ const repo = path.join(tmp, name);
82
+ fs.mkdirSync(repo, { recursive: true });
83
+ git(repo, "init", "-q", "-b", "main");
84
+ git(repo, "config", "user.email", "t@t.t");
85
+ git(repo, "config", "user.name", "t");
86
+ return repo;
87
+ };
88
+
89
+ beforeEach(() => {
90
+ // realpath: macOS /var → /private/var, which git resolves and we compare against.
91
+ tmp = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "reconcile-"));
92
+ });
93
+ afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
94
+
95
+ it("auto-detects the target snapshot, emits one patch per file, flags hand-fixes", () => {
96
+ const source = init("source");
97
+ write(source, "sections/Header.tsx", "export default () => <h1>a</h1>;\n");
98
+ write(source, "sections/Footer.tsx", "export default () => <footer/>;\n");
99
+ write(source, "deno.lock", "{}\n");
100
+ const snapshot = commit(source, "cut");
101
+
102
+ write(source, "sections/Header.tsx", "export default () => <h1>b</h1>;\n");
103
+ write(source, "sections/Footer.tsx", "export default () => <footer id='f'/>;\n");
104
+ write(source, "deno.lock", '{"v":2}\n');
105
+ const sourceHead = commit(source, "upstream work");
106
+
107
+ const target = init("target");
108
+ write(target, "src/sections/Header.tsx", "export default () => <h1>a</h1>;\n");
109
+ // The island/section re-export pair — Header has TWO target candidates.
110
+ write(target, "src/components/Header.tsx", "export default () => <h1>a</h1>;\n");
111
+ write(target, "src/sections/Footer.tsx", "export default () => <footer/>;\n");
112
+ // The marker deco-migrate leaves behind — this is what auto-detection finds.
113
+ write(target, "MIGRATION_REPORT.md", "# Migration\n");
114
+ const targetSnapshot = commit(target, "migrate to tanstack");
115
+
116
+ // One hand-fix touching both candidates → must count as ONE collision.
117
+ write(target, "src/sections/Header.tsx", "export default () => <h1>a fixed</h1>;\n");
118
+ write(target, "src/components/Header.tsx", "export default () => <h1>a fixed</h1>;\n");
119
+ commit(target, "fix: codemod ate the heading");
120
+
121
+ const out = path.join(tmp, "out");
122
+ execFileSync(
123
+ path.join(__dirname, "../../../node_modules/.bin/tsx"),
124
+ [
125
+ path.join(__dirname, "reconcile.ts"),
126
+ "--source", source,
127
+ "--target", target,
128
+ "--snapshot", snapshot,
129
+ // no --target-snapshot: exercise the MIGRATION_REPORT.md auto-detection
130
+ "--out", out,
131
+ ],
132
+ { encoding: "utf8" },
133
+ );
134
+
135
+ const manifest: ReconcileManifest = JSON.parse(
136
+ fs.readFileSync(path.join(out, "manifest.json"), "utf8"),
137
+ );
138
+
139
+ expect(manifest.sourceHead).toBe(sourceHead);
140
+ expect(manifest.targetSnapshot).toBe(targetSnapshot);
141
+ // deno.lock filtered out.
142
+ expect(manifest.files.map((f) => f.sourcePath).sort()).toEqual([
143
+ "sections/Footer.tsx",
144
+ "sections/Header.tsx",
145
+ ]);
146
+
147
+ const header = manifest.files.find((f) => f.sourcePath === "sections/Header.tsx")!;
148
+ expect(header.targetCandidates).toEqual([
149
+ "src/sections/Header.tsx", // conventional guess ranks first
150
+ "src/components/Header.tsx",
151
+ ]);
152
+ expect(header.collision).toHaveLength(1); // deduped by SHA, not 2
153
+ expect(header.collision[0]).toContain("codemod ate the heading");
154
+ expect(fs.readFileSync(path.join(out, header.patch), "utf8")).toContain("<h1>b</h1>");
155
+
156
+ const footer = manifest.files.find((f) => f.sourcePath === "sections/Footer.tsx")!;
157
+ expect(footer.collision).toEqual([]);
158
+ expect(footer.done).toBe(false);
159
+
160
+ expect(fs.readFileSync(path.join(out, "INDEX.md"), "utf8")).toContain(sourceHead);
161
+ });
162
+ });