@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,444 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * deco-migrate CLI — one command to clone, migrate, and verify a Deco site.
4
+ *
5
+ * Usage:
6
+ * npx tsx scripts/deco-migrate-cli.ts <repo-or-dir> [options]
7
+ *
8
+ * Examples:
9
+ * # Clone from GitHub, migrate, compare against golden reference:
10
+ * npx tsx scripts/deco-migrate-cli.ts https://github.com/org/my-site \
11
+ * --output ~/work/my-site-migrated \
12
+ * --ref ~/work/my-site-storefront
13
+ *
14
+ * # Migrate from local directory:
15
+ * npx tsx scripts/deco-migrate-cli.ts ./old-site --output ./migrated-site
16
+ *
17
+ * # Quick re-run (wipe + re-migrate):
18
+ * npx tsx scripts/deco-migrate-cli.ts ./old-site --output ./migrated-site --clean
19
+ *
20
+ * # Dry run:
21
+ * npx tsx scripts/deco-migrate-cli.ts ./old-site --dry-run --verbose
22
+ */
23
+
24
+ import * as fs from "node:fs";
25
+ import * as path from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { execSync, spawnSync } from "node:child_process";
28
+ import { banner, stat, red, green, yellow, cyan, bold, dim, icons } from "./migrate/colors";
29
+
30
+ const __filename = fileURLToPath(import.meta.url);
31
+ const __dirname = path.dirname(__filename);
32
+
33
+ interface CliOpts {
34
+ source: string;
35
+ output: string | null;
36
+ ref: string | null;
37
+ dryRun: boolean;
38
+ verbose: boolean;
39
+ clean: boolean;
40
+ skipBootstrap: boolean;
41
+ help: boolean;
42
+ branch: string | null;
43
+ }
44
+
45
+ function parseArgs(args: string[]): CliOpts {
46
+ const opts: CliOpts = {
47
+ source: "",
48
+ output: null,
49
+ ref: null,
50
+ dryRun: false,
51
+ verbose: false,
52
+ clean: false,
53
+ skipBootstrap: false,
54
+ help: false,
55
+ branch: null,
56
+ };
57
+
58
+ const positional: string[] = [];
59
+
60
+ for (let i = 0; i < args.length; i++) {
61
+ const arg = args[i];
62
+ switch (arg) {
63
+ case "--output":
64
+ case "-o":
65
+ opts.output = args[++i];
66
+ break;
67
+ case "--ref":
68
+ case "--reference":
69
+ opts.ref = args[++i];
70
+ break;
71
+ case "--branch":
72
+ case "-b":
73
+ opts.branch = args[++i];
74
+ break;
75
+ case "--dry-run":
76
+ opts.dryRun = true;
77
+ break;
78
+ case "--verbose":
79
+ case "-v":
80
+ opts.verbose = true;
81
+ break;
82
+ case "--clean":
83
+ opts.clean = true;
84
+ break;
85
+ case "--skip-bootstrap":
86
+ opts.skipBootstrap = true;
87
+ break;
88
+ case "--help":
89
+ case "-h":
90
+ opts.help = true;
91
+ break;
92
+ default:
93
+ if (!arg.startsWith("-")) positional.push(arg);
94
+ }
95
+ }
96
+
97
+ if (positional.length > 0) opts.source = positional[0];
98
+ return opts;
99
+ }
100
+
101
+ function showHelp() {
102
+ console.log(`
103
+ ${bold("deco-migrate")} — Clone, migrate, and verify a Deco storefront
104
+
105
+ ${bold("Usage:")}
106
+ npx tsx scripts/deco-migrate-cli.ts <repo-url-or-dir> [options]
107
+
108
+ ${bold("Arguments:")}
109
+ <repo-url-or-dir> Git repo URL or local directory path
110
+
111
+ ${bold("Options:")}
112
+ -o, --output <dir> Output directory (default: <name>-migrated)
113
+ --ref <dir> Golden reference directory to diff against
114
+ -b, --branch <name> Git branch to clone (default: main)
115
+ --dry-run Preview changes without writing files
116
+ -v, --verbose Show detailed output for every file
117
+ --clean Wipe output dir before migrating (for re-runs)
118
+ --skip-bootstrap Skip npm install + codegen after migration
119
+ -h, --help Show this help message
120
+
121
+ ${bold("Examples:")}
122
+ ${dim("# Clone from GitHub and migrate:")}
123
+ npx tsx scripts/deco-migrate-cli.ts https://github.com/org/my-site
124
+
125
+ ${dim("# Migrate local dir, compare against golden reference:")}
126
+ npx tsx scripts/deco-migrate-cli.ts ./casaevideo \\
127
+ --ref ./casaevideo-storefront
128
+
129
+ ${dim("# Quick re-run (wipe previous output first):")}
130
+ npx tsx scripts/deco-migrate-cli.ts ./casaevideo \\
131
+ -o ./casaevideo-migrated --clean
132
+
133
+ ${dim("# Dry run to preview what would change:")}
134
+ npx tsx scripts/deco-migrate-cli.ts ./casaevideo --dry-run -v
135
+ `);
136
+ }
137
+
138
+ function isGitUrl(source: string): boolean {
139
+ return (
140
+ source.startsWith("https://") ||
141
+ source.startsWith("git@") ||
142
+ source.startsWith("http://") ||
143
+ source.endsWith(".git")
144
+ );
145
+ }
146
+
147
+ function extractRepoName(source: string): string {
148
+ // https://github.com/org/my-site.git → my-site
149
+ // https://github.com/org/my-site → my-site
150
+ // ./path/to/my-site → my-site
151
+ const base = path.basename(source.replace(/\.git$/, ""));
152
+ return base || "site";
153
+ }
154
+
155
+ function run(cmd: string, cwd?: string, label?: string): boolean {
156
+ if (label) console.log(` ${dim("$")} ${dim(cmd)}`);
157
+ try {
158
+ execSync(cmd, {
159
+ cwd,
160
+ stdio: label ? "pipe" : "inherit",
161
+ timeout: 120_000,
162
+ });
163
+ if (label) console.log(` ${icons.success} ${label}`);
164
+ return true;
165
+ } catch (e: any) {
166
+ if (label) {
167
+ console.log(` ${icons.error} ${label}: ${e.message?.split("\n")[0] || "failed"}`);
168
+ }
169
+ return false;
170
+ }
171
+ }
172
+
173
+ function cloneRepo(source: string, dest: string, branch: string | null): boolean {
174
+ console.log(`\n Cloning ${cyan(source)}...`);
175
+ const branchArg = branch ? ` --branch ${branch}` : "";
176
+ const depthArg = " --depth 1";
177
+ const ok = run(
178
+ `git clone${depthArg}${branchArg} "${source}" "${dest}"`,
179
+ undefined,
180
+ "Clone repository",
181
+ );
182
+ if (!ok) return false;
183
+
184
+ // Strip remote to prevent accidental pushes
185
+ run(`git remote remove origin`, dest, "Remove git remote");
186
+ return true;
187
+ }
188
+
189
+ function copyLocal(source: string, dest: string): boolean {
190
+ console.log(`\n Copying ${cyan(source)} → ${cyan(dest)}...`);
191
+ try {
192
+ // Use cp -r, excluding .git and node_modules
193
+ execSync(
194
+ `rsync -a --exclude='.git' --exclude='node_modules' --exclude='_fresh' --exclude='.wrangler' "${source}/" "${dest}/"`,
195
+ { stdio: "pipe", timeout: 120_000 },
196
+ );
197
+ console.log(` ${icons.success} Copied source directory`);
198
+
199
+ // Init fresh git so the migration has a clean baseline
200
+ run(`git init`, dest);
201
+ run(`git add -A && git commit -m "pre-migration snapshot" --allow-empty`, dest);
202
+ return true;
203
+ } catch (e: any) {
204
+ console.log(` ${icons.error} Copy failed: ${e.message?.split("\n")[0]}`);
205
+ return false;
206
+ }
207
+ }
208
+
209
+ function runMigration(
210
+ dest: string,
211
+ scriptDir: string,
212
+ opts: { dryRun: boolean; verbose: boolean; skipBootstrap: boolean },
213
+ ): boolean {
214
+ const migrateScript = path.join(scriptDir, "migrate.ts");
215
+ const args = ["tsx", migrateScript, "--source", dest];
216
+ if (opts.dryRun) args.push("--dry-run");
217
+ if (opts.verbose) args.push("--verbose");
218
+
219
+ console.log("");
220
+ const result = spawnSync("npx", args, {
221
+ cwd: scriptDir.replace(/\/scripts$/, ""),
222
+ stdio: "inherit",
223
+ env: {
224
+ ...process.env,
225
+ SKIP_BOOTSTRAP: opts.skipBootstrap ? "1" : "",
226
+ },
227
+ timeout: 300_000,
228
+ });
229
+
230
+ return result.status === 0;
231
+ }
232
+
233
+ function diffAgainstRef(migrated: string, ref: string): void {
234
+ banner("Comparing against golden reference");
235
+ stat("Migrated", migrated);
236
+ stat("Reference", ref);
237
+
238
+ if (!fs.existsSync(ref)) {
239
+ console.log(`\n ${icons.error} Reference dir does not exist: ${ref}`);
240
+ return;
241
+ }
242
+
243
+ const migratedSrc = path.join(migrated, "src");
244
+ const refSrc = path.join(ref, "src");
245
+
246
+ if (!fs.existsSync(migratedSrc) || !fs.existsSync(refSrc)) {
247
+ console.log(`\n ${icons.error} One or both src/ directories missing`);
248
+ return;
249
+ }
250
+
251
+ // 1. File count comparison
252
+ console.log(`\n ${bold("File counts:")}`);
253
+ const countFiles = (dir: string, ext: string): number => {
254
+ try {
255
+ const result = execSync(
256
+ `find "${dir}" -name "*${ext}" -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/server/*" | wc -l`,
257
+ { encoding: "utf-8" },
258
+ );
259
+ return parseInt(result.trim(), 10);
260
+ } catch {
261
+ return 0;
262
+ }
263
+ };
264
+
265
+ for (const [label, subdir] of [
266
+ ["sections", "sections"],
267
+ ["components", "components"],
268
+ ["loaders", "loaders"],
269
+ ["hooks", "hooks"],
270
+ ["sdk", "sdk"],
271
+ ["types", "types"],
272
+ ] as const) {
273
+ const mDir = path.join(migratedSrc, subdir);
274
+ const rDir = path.join(refSrc, subdir);
275
+ const mCount = fs.existsSync(mDir) ? countFiles(mDir, ".tsx") + countFiles(mDir, ".ts") : 0;
276
+ const rCount = fs.existsSync(rDir) ? countFiles(rDir, ".tsx") + countFiles(rDir, ".ts") : 0;
277
+ const delta = mCount - rCount;
278
+ const deltaStr = delta === 0 ? green("=") : delta > 0 ? yellow(`+${delta}`) : red(`${delta}`);
279
+ console.log(` ${label.padEnd(14)} migrated: ${String(mCount).padStart(4)} ref: ${String(rCount).padStart(4)} (${deltaStr})`);
280
+ }
281
+
282
+ // 2. Key import pattern checks
283
+ console.log(`\n ${bold("Remaining old imports (migrated):")}`);
284
+ const grepCount = (dir: string, pattern: string): number => {
285
+ try {
286
+ const result = execSync(
287
+ `grep -rl '${pattern}' "${dir}" --include='*.ts' --include='*.tsx' 2>/dev/null | grep -v node_modules | grep -v '/server/' | wc -l`,
288
+ { encoding: "utf-8" },
289
+ );
290
+ return parseInt(result.trim(), 10);
291
+ } catch {
292
+ return 0;
293
+ }
294
+ };
295
+
296
+ const patterns = [
297
+ ["from \"preact", "preact imports"],
298
+ ["from \"@preact/", "@preact/* imports"],
299
+ ["from \"@deco/deco", "@deco/deco imports"],
300
+ ["from \"$fresh/", "$fresh imports"],
301
+ ['from "apps/', "apps/* imports"],
302
+ ['from "site/', "site/* imports"],
303
+ ['from "$store/', "$store/* imports"],
304
+ ["export const cache", "old cache exports"],
305
+ ];
306
+
307
+ for (const [pattern, label] of patterns) {
308
+ const count = grepCount(migratedSrc, pattern);
309
+ const icon = count === 0 ? icons.success : icons.warning;
310
+ console.log(` ${icon} ${label}: ${count} files`);
311
+ }
312
+
313
+ // 3. Missing scaffolded files
314
+ console.log(`\n ${bold("Scaffolded file parity:")}`);
315
+ const checkFiles = [
316
+ "setup.ts",
317
+ "cache-config.ts",
318
+ "worker-entry.ts",
319
+ "server.ts",
320
+ "router.tsx",
321
+ "runtime.ts",
322
+ "setup/commerce-loaders.ts",
323
+ "setup/section-loaders.ts",
324
+ "hooks/useCart.ts",
325
+ "hooks/useUser.ts",
326
+ "hooks/useWishlist.ts",
327
+ "types/widgets.ts",
328
+ "types/deco.ts",
329
+ "components/ui/Image.tsx",
330
+ "components/ui/Picture.tsx",
331
+ "styles/app.css",
332
+ "routes/__root.tsx",
333
+ "routes/$.tsx",
334
+ "routes/index.tsx",
335
+ ];
336
+
337
+ for (const file of checkFiles) {
338
+ const mExists = fs.existsSync(path.join(migratedSrc, file));
339
+ const rExists = fs.existsSync(path.join(refSrc, file));
340
+ if (mExists && rExists) {
341
+ console.log(` ${icons.success} ${file}`);
342
+ } else if (!mExists && rExists) {
343
+ console.log(` ${icons.error} ${file} — ${red("missing in migrated")}`);
344
+ } else if (mExists && !rExists) {
345
+ console.log(` ${icons.info} ${file} — ${dim("extra in migrated (not in ref)")}`);
346
+ }
347
+ }
348
+
349
+ // 4. public/ assets
350
+ console.log(`\n ${bold("public/ assets:")}`);
351
+ const mPublic = path.join(migrated, "public");
352
+ const rPublic = path.join(ref, "public");
353
+ const mPubCount = fs.existsSync(mPublic) ? countFiles(mPublic, "") : 0;
354
+ const rPubCount = fs.existsSync(rPublic) ? countFiles(rPublic, "") : 0;
355
+ console.log(` migrated: ${mPubCount} files, ref: ${rPubCount} files`);
356
+ }
357
+
358
+ async function main() {
359
+ const opts = parseArgs(process.argv.slice(2));
360
+
361
+ if (opts.help || !opts.source) {
362
+ showHelp();
363
+ process.exit(opts.help ? 0 : 1);
364
+ }
365
+
366
+ const scriptDir = path.resolve(__dirname, ".");
367
+ const repoName = extractRepoName(opts.source);
368
+ const outputDir = path.resolve(opts.output || `${repoName}-migrated`);
369
+
370
+ banner("deco-migrate CLI");
371
+ stat("Source", opts.source);
372
+ stat("Output", outputDir);
373
+ if (opts.ref) stat("Reference", path.resolve(opts.ref));
374
+ stat("Mode", opts.dryRun ? yellow("DRY RUN") : green("EXECUTE"));
375
+
376
+ // Clean output dir if requested
377
+ if (opts.clean && fs.existsSync(outputDir)) {
378
+ console.log(`\n Cleaning ${outputDir}...`);
379
+ fs.rmSync(outputDir, { recursive: true, force: true });
380
+ console.log(` ${icons.success} Cleaned output directory`);
381
+ }
382
+
383
+ // Check if output already exists
384
+ if (fs.existsSync(outputDir) && !opts.dryRun) {
385
+ const srcDir = path.join(outputDir, "src");
386
+ if (fs.existsSync(srcDir)) {
387
+ console.log(`\n ${icons.error} Output directory already exists and has src/: ${outputDir}`);
388
+ console.log(` ${dim("Use --clean to wipe it first, or choose a different --output")}`);
389
+ process.exit(1);
390
+ }
391
+ }
392
+
393
+ // Step 1: Get the source code
394
+ let acquired = false;
395
+ if (isGitUrl(opts.source)) {
396
+ acquired = cloneRepo(opts.source, outputDir, opts.branch);
397
+ } else {
398
+ const sourceDir = path.resolve(opts.source);
399
+ if (!fs.existsSync(sourceDir)) {
400
+ console.log(`\n ${icons.error} Source directory not found: ${sourceDir}`);
401
+ process.exit(1);
402
+ }
403
+ acquired = copyLocal(sourceDir, outputDir);
404
+ }
405
+
406
+ if (!acquired) {
407
+ console.log(`\n ${red("Failed to acquire source. Aborting.")}`);
408
+ process.exit(1);
409
+ }
410
+
411
+ // Step 2: Run migration
412
+ const migrationOk = runMigration(outputDir, scriptDir, {
413
+ dryRun: opts.dryRun,
414
+ verbose: opts.verbose,
415
+ skipBootstrap: opts.skipBootstrap || opts.dryRun,
416
+ });
417
+
418
+ // Step 3: Compare against reference (if provided)
419
+ if (opts.ref && !opts.dryRun) {
420
+ diffAgainstRef(outputDir, path.resolve(opts.ref));
421
+ }
422
+
423
+ // Final status
424
+ console.log("");
425
+ if (migrationOk) {
426
+ banner("Migration complete");
427
+ console.log(`\n ${green("Output:")} ${outputDir}`);
428
+ if (!opts.dryRun) {
429
+ console.log(`\n ${bold("Next steps:")}`);
430
+ console.log(` cd ${outputDir}`);
431
+ console.log(` npm install`);
432
+ console.log(` npm run generate:blocks`);
433
+ console.log(` npm run generate:schema`);
434
+ console.log(` npx tsr generate`);
435
+ console.log(` npm run dev`);
436
+ }
437
+ } else {
438
+ console.log(` ${yellow("Migration completed with issues.")} Check the report above.`);
439
+ console.log(` ${dim("Output:")} ${outputDir}`);
440
+ }
441
+ console.log("");
442
+ }
443
+
444
+ main();
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { computeRevision, KV_KEYS } from "@decocms/blocks/cms";
3
+ import { createKvRestClient, kvConfigFromEnv } from "./lib/cf-kv-rest";
4
+ import { buildSnapshot, verifySnapshotInKv, writeSnapshotToKv } from "./lib/kv-snapshot";
5
+ import {
6
+ changedBlockFiles,
7
+ changedBlockKeys,
8
+ purgePathsForChangedKeys,
9
+ } from "./lib/sync-helpers";
10
+
11
+ describe("kvConfigFromEnv", () => {
12
+ it("reads the three CF vars", () => {
13
+ expect(
14
+ kvConfigFromEnv({ CF_ACCOUNT_ID: "a", CF_KV_NAMESPACE_ID: "n", CF_API_TOKEN: "t" }),
15
+ ).toEqual({ accountId: "a", namespaceId: "n", token: "t" });
16
+ });
17
+
18
+ it("throws listing all missing vars", () => {
19
+ expect(() => kvConfigFromEnv({ CF_ACCOUNT_ID: "a" })).toThrow(
20
+ /CF_KV_NAMESPACE_ID, CF_API_TOKEN/,
21
+ );
22
+ });
23
+ });
24
+
25
+ describe("createKvRestClient", () => {
26
+ const config = { accountId: "acc", namespaceId: "ns", token: "tok" };
27
+
28
+ it("PUTs to the values endpoint with auth + body", async () => {
29
+ const fetchImpl = vi.fn(async () => new Response("", { status: 200 })) as unknown as typeof fetch;
30
+ const client = createKvRestClient({ ...config, fetchImpl });
31
+ await client.put(KV_KEYS.REVISION, "rev1");
32
+
33
+ const [url, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
34
+ expect(url).toContain("/accounts/acc/storage/kv/namespaces/ns/values/");
35
+ expect(url).toContain(encodeURIComponent(KV_KEYS.REVISION));
36
+ expect(init.method).toBe("PUT");
37
+ expect(init.headers.Authorization).toBe("Bearer tok");
38
+ expect(init.body).toBe("rev1");
39
+ });
40
+
41
+ it("GET returns null on 404", async () => {
42
+ const fetchImpl = vi.fn(async () => new Response("nope", { status: 404 })) as unknown as typeof fetch;
43
+ const client = createKvRestClient({ ...config, fetchImpl });
44
+ await expect(client.get("missing")).resolves.toBeNull();
45
+ });
46
+
47
+ it("GET returns the body text on 200", async () => {
48
+ const fetchImpl = vi.fn(async () => new Response("hello", { status: 200 })) as unknown as typeof fetch;
49
+ const client = createKvRestClient({ ...config, fetchImpl });
50
+ await expect(client.get("k")).resolves.toBe("hello");
51
+ });
52
+
53
+ it("throws on a non-404 error status", async () => {
54
+ const fetchImpl = vi.fn(async () => new Response("boom", { status: 500 })) as unknown as typeof fetch;
55
+ const client = createKvRestClient({ ...config, fetchImpl });
56
+ await expect(client.get("k")).rejects.toThrow(/500/);
57
+ });
58
+ });
59
+
60
+ describe("kv-snapshot helpers", () => {
61
+ const blocks = { Site: { name: "x" }, "pages-home": { path: "/" } };
62
+
63
+ it("buildSnapshot uses the runtime computeRevision", () => {
64
+ const snap = buildSnapshot(blocks);
65
+ expect(snap.revision).toBe(computeRevision(blocks));
66
+ expect(snap.snapshot).toBe(JSON.stringify(blocks));
67
+ expect(snap.count).toBe(2);
68
+ });
69
+
70
+ it("writes snapshot before revision, then verifies round-trip", async () => {
71
+ const store = new Map<string, string>();
72
+ const order: string[] = [];
73
+ const client = {
74
+ get: (k: string) => Promise.resolve(store.get(k) ?? null),
75
+ put: (k: string, v: string) => {
76
+ order.push(k);
77
+ store.set(k, v);
78
+ return Promise.resolve();
79
+ },
80
+ };
81
+ const snap = buildSnapshot(blocks);
82
+ await writeSnapshotToKv(client, snap);
83
+
84
+ expect(order).toEqual([KV_KEYS.SNAPSHOT, KV_KEYS.REVISION]);
85
+ await expect(verifySnapshotInKv(client, snap.revision)).resolves.toEqual({ ok: true });
86
+ });
87
+
88
+ it("verify fails when the revision mismatches", async () => {
89
+ const store = new Map<string, string>([
90
+ [KV_KEYS.SNAPSHOT, "{}"],
91
+ [KV_KEYS.REVISION, "other"],
92
+ ]);
93
+ const client = { get: (k: string) => Promise.resolve(store.get(k) ?? null), put: () => Promise.resolve() };
94
+ const res = await verifySnapshotInKv(client, "expected");
95
+ expect(res.ok).toBe(false);
96
+ });
97
+ });
98
+
99
+ describe("sync-helpers", () => {
100
+ it("changedBlockFiles filters to the blocks dir and .json", () => {
101
+ const out = [
102
+ ".deco/blocks/pages-home.json",
103
+ ".deco/blocks/Site.json",
104
+ "src/components/Foo.tsx",
105
+ "README.md",
106
+ ".deco/blocks/notjson.txt",
107
+ ].join("\n");
108
+ expect(changedBlockFiles(out, ".deco/blocks")).toEqual([
109
+ ".deco/blocks/pages-home.json",
110
+ ".deco/blocks/Site.json",
111
+ ]);
112
+ });
113
+
114
+ it("changedBlockKeys decodes URL-encoded filenames", () => {
115
+ expect(changedBlockKeys([".deco/blocks/pages-Home%20-%20LB-1.json"])).toEqual([
116
+ "pages-Home - LB-1",
117
+ ]);
118
+ });
119
+
120
+ it("purgePathsForChangedKeys collects page paths + always '/'", () => {
121
+ const blocks = {
122
+ "pages-home": { path: "/" },
123
+ "pages-pdp": { path: "/produto/:slug/p" },
124
+ Site: { name: "x" }, // no path
125
+ };
126
+ const paths = purgePathsForChangedKeys(blocks, ["pages-pdp", "Site"]);
127
+ expect(paths).toContain("/");
128
+ expect(paths).toContain("/produto/:slug/p");
129
+ expect(paths).not.toContain(undefined);
130
+ });
131
+ });
@@ -0,0 +1,94 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { readBlockDelta } from "./generate-blocks";
6
+
7
+ describe("readBlockDelta", () => {
8
+ let dir: string;
9
+
10
+ beforeEach(() => {
11
+ dir = mkdtempSync(path.join(tmpdir(), "deco-blocks-"));
12
+ });
13
+ afterEach(() => {
14
+ rmSync(dir, { recursive: true, force: true });
15
+ });
16
+
17
+ const write = (name: string, value: unknown) =>
18
+ writeFileSync(path.join(dir, name), JSON.stringify(value), "utf-8");
19
+
20
+ it("upserts only the changed files, keyed by single-decoded name", () => {
21
+ write("Site.json", { __resolveType: "site" });
22
+ write("pages-Home.json", { path: "/" });
23
+
24
+ const delta = readBlockDelta({
25
+ blocksDir: dir,
26
+ files: [{ name: "pages-Home.json", isDelete: false }],
27
+ silent: true,
28
+ });
29
+
30
+ // Only the changed file is present — the untouched Site block is not read.
31
+ expect(delta).toEqual({ "pages-Home": { path: "/" } });
32
+ });
33
+
34
+ it("decodes the filename exactly once (matches the runtime block key)", () => {
35
+ // Studio round-trips encodeURIComponent(blockKey) -> filename, so a key
36
+ // with a space lands on disk single-encoded.
37
+ write("pages-Home%20-%20LB.json", { path: "/lb" });
38
+
39
+ const delta = readBlockDelta({
40
+ blocksDir: dir,
41
+ files: [{ name: "pages-Home%20-%20LB.json", isDelete: false }],
42
+ silent: true,
43
+ });
44
+
45
+ expect(delta).toEqual({ "pages-Home - LB": { path: "/lb" } });
46
+ });
47
+
48
+ it("maps deletes to null so applyDelta removes the block", () => {
49
+ const delta = readBlockDelta({
50
+ blocksDir: dir,
51
+ files: [{ name: "pages-Gone.json", isDelete: true }],
52
+ silent: true,
53
+ });
54
+
55
+ expect(delta).toEqual({ "pages-Gone": null });
56
+ });
57
+
58
+ it("skips files that fail to parse (partial write in progress)", () => {
59
+ writeFileSync(path.join(dir, "pages-Half.json"), "{ not valid json", "utf-8");
60
+ write("pages-Good.json", { path: "/good" });
61
+
62
+ const delta = readBlockDelta({
63
+ blocksDir: dir,
64
+ files: [
65
+ { name: "pages-Half.json", isDelete: false },
66
+ { name: "pages-Good.json", isDelete: false },
67
+ ],
68
+ silent: true,
69
+ });
70
+
71
+ // The unparseable file is dropped; the valid one still comes through.
72
+ expect(delta).toEqual({ "pages-Good": { path: "/good" } });
73
+ });
74
+
75
+ it("skips a missing upsert target without throwing", () => {
76
+ const delta = readBlockDelta({
77
+ blocksDir: dir,
78
+ files: [{ name: "pages-Missing.json", isDelete: false }],
79
+ silent: true,
80
+ });
81
+
82
+ expect(delta).toEqual({});
83
+ });
84
+
85
+ it("ignores non-json entries", () => {
86
+ const delta = readBlockDelta({
87
+ blocksDir: dir,
88
+ files: [{ name: "notes.txt", isDelete: false }],
89
+ silent: true,
90
+ });
91
+
92
+ expect(delta).toEqual({});
93
+ });
94
+ });