@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,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
|
+
});
|