@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.
- package/package.json +5 -3
- package/scripts/analyze-traces.mjs +1 -0
- package/scripts/cdn-rules.test.ts +105 -0
- package/scripts/cdn-rules.ts +192 -0
- package/scripts/deco-migrate-cli.ts +6 -7
- package/scripts/fast-deploy-kv.test.ts +172 -0
- package/scripts/generate-blocks.test.ts +11 -0
- package/scripts/generate-blocks.ts +0 -10
- package/scripts/generate-invoke.test.ts +2 -2
- package/scripts/generate-invoke.ts +9 -21
- package/scripts/generate-loaders.test.ts +2 -2
- package/scripts/generate-loaders.ts +0 -10
- package/scripts/generate-schema-loader-aliases.test.ts +186 -0
- package/scripts/generate-schema-workspaces.test.ts +180 -0
- package/scripts/generate-schema.test.ts +68 -29
- package/scripts/generate-schema.ts +101 -89
- package/scripts/generate-sections.test.ts +104 -35
- package/scripts/generate-sections.ts +57 -11
- package/scripts/generate-workspaces.test.ts +179 -0
- package/scripts/generate.test.ts +1 -2
- package/scripts/generate.ts +43 -45
- package/scripts/lib/blocks-dedupe.test.ts +1 -1
- package/scripts/lib/cf-kv-rest.ts +49 -1
- package/scripts/lib/installedPackages.ts +42 -0
- package/scripts/lib/invokeSource.ts +18 -0
- package/scripts/lib/kv-snapshot.ts +44 -0
- package/scripts/lib/read-decofile.ts +13 -2
- package/scripts/lib/upgrade-6-to-7.ts +1 -1
- package/scripts/lib/wrangler-config.test.ts +49 -0
- package/scripts/lib/wrangler-config.ts +31 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +6 -6
- package/scripts/migrate/analyzers/tailwind-config.ts +95 -1
- package/scripts/migrate/config.ts +2 -2
- package/scripts/migrate/delete-sets.ts +75 -0
- package/scripts/migrate/fast-deploy-scaffold.test.ts +130 -0
- package/scripts/migrate/icon-casing.test.ts +63 -0
- package/scripts/migrate/phase-analyze.test.ts +66 -4
- package/scripts/migrate/phase-analyze.ts +79 -60
- package/scripts/migrate/phase-cleanup.test.ts +3 -1
- package/scripts/migrate/phase-cleanup.ts +1 -1
- package/scripts/migrate/phase-compile.test.ts +3 -1
- package/scripts/migrate/phase-report.test.ts +2 -0
- package/scripts/migrate/phase-report.ts +3 -4
- package/scripts/migrate/phase-scaffold.ts +150 -45
- package/scripts/migrate/phase-transform.ts +107 -2
- package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +13 -0
- package/scripts/migrate/phase-verify.test.ts +3 -1
- package/scripts/migrate/phase-verify.ts +87 -33
- package/scripts/migrate/post-cleanup/rules.ts +29 -11
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +1 -1
- package/scripts/migrate/post-cleanup/shim-classify.ts +2 -2
- package/scripts/migrate/source-layout.test.ts +43 -0
- package/scripts/migrate/source-layout.ts +32 -5
- package/scripts/migrate/templates/app-css.test.ts +24 -0
- package/scripts/migrate/templates/app-css.ts +27 -0
- package/scripts/migrate/templates/ci-workflows.test.ts +179 -0
- package/scripts/migrate/templates/ci-yml.ts +182 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +3 -3
- package/scripts/migrate/templates/hooks.test.ts +3 -1
- package/scripts/migrate/templates/lib-utils.ts +3 -3
- package/scripts/migrate/templates/main-push-guard-yml.ts +45 -0
- package/scripts/migrate/templates/package-json.ts +10 -0
- package/scripts/migrate/templates/parity-yml.ts +136 -0
- package/scripts/migrate/templates/perf-yml.ts +449 -0
- package/scripts/migrate/templates/playwright-yml.ts +127 -0
- package/scripts/migrate/templates/react-doctor-yml.ts +46 -0
- package/scripts/migrate/templates/routes.test.ts +31 -0
- package/scripts/migrate/templates/routes.ts +19 -9
- package/scripts/migrate/templates/section-loaders.ts +3 -3
- package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +19 -0
- package/scripts/migrate/templates/server-entry.ts +19 -14
- package/scripts/migrate/templates/setup.ts +11 -3
- package/scripts/migrate/templates/sync-blocks-bot-yml.ts +201 -0
- package/scripts/migrate/templates/ui-components.ts +3 -0
- package/scripts/migrate/transforms/ctx-compat.test.ts +37 -0
- package/scripts/migrate/transforms/ctx-compat.ts +93 -1
- package/scripts/migrate/transforms/htmx-on-events.test.ts +1 -1
- package/scripts/migrate/transforms/htmx-on-events.ts +2 -2
- package/scripts/migrate/transforms/jsx-class-type.test.ts +28 -0
- package/scripts/migrate/transforms/jsx.ts +10 -0
- package/scripts/migrate/transforms/picture.test.ts +56 -0
- package/scripts/migrate/transforms/picture.ts +95 -0
- package/scripts/migrate/transforms/tailwind-renames.test.ts +29 -0
- package/scripts/migrate/transforms/tailwind-renames.ts +34 -0
- package/scripts/migrate/transforms/timer-types.test.ts +30 -0
- package/scripts/migrate/transforms/timer-types.ts +48 -0
- package/scripts/migrate/transforms/use-script-handlers.test.ts +37 -0
- package/scripts/migrate/transforms/use-script-handlers.ts +36 -0
- package/scripts/migrate/types.ts +13 -1
- package/scripts/migrate-blocks-to-kv.ts +16 -2
- package/scripts/migrate-to-cf-observability.test.ts +5 -5
- package/scripts/migrate-to-cf-observability.ts +1 -1
- package/scripts/migrate.ts +244 -6
- package/scripts/reconcile.test.ts +162 -0
- package/scripts/reconcile.ts +303 -0
- package/scripts/sync-blocks-bot.test.ts +276 -0
- package/scripts/sync-blocks-bot.ts +567 -0
- package/scripts/sync-blocks-to-kv.ts +20 -2
- package/scripts/upgrade-6-to-7.ts +0 -0
- 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 {
|
|
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
|
-
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
-
*
|
|
24
|
+
* production canary cutover.
|
|
25
25
|
*
|
|
26
26
|
* Behavior:
|
|
27
27
|
* - dry-run by default — prints a unified diff against the existing
|
package/scripts/migrate.ts
CHANGED
|
@@ -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 (
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
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
|
-
|
|
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
|
+
});
|