@decocms/blocks-cli 7.69.1 → 7.71.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 +2 -2
- package/scripts/analyze-traces.mjs +1 -0
- package/scripts/lib/wrangler-config.test.ts +49 -0
- package/scripts/lib/wrangler-config.ts +31 -0
- package/scripts/migrate/fast-deploy-scaffold.test.ts +130 -0
- package/scripts/migrate/phase-scaffold.ts +42 -15
- package/scripts/migrate/phase-verify.ts +40 -0
- package/scripts/migrate/templates/routes.ts +1 -1
- package/scripts/migrate/templates/setup.ts +8 -1
- package/scripts/migrate.ts +177 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.71.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
|
|
6
6
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"lint:unused": "knip"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@decocms/blocks": "7.
|
|
36
|
+
"@decocms/blocks": "7.71.0",
|
|
37
37
|
"ts-morph": "^27.0.0",
|
|
38
38
|
"tsx": "^4.22.5"
|
|
39
39
|
},
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { setKvNamespaceIdInJsonc } from "./wrangler-config";
|
|
3
|
+
|
|
4
|
+
describe("setKvNamespaceIdInJsonc", () => {
|
|
5
|
+
// The scaffolded wrangler.jsonc carries the comments explaining why each
|
|
6
|
+
// binding exists; a parse-and-restringify round trip would drop every one.
|
|
7
|
+
const WRANGLER = `{
|
|
8
|
+
"name": "acme",
|
|
9
|
+
"kv_namespaces": [
|
|
10
|
+
// Fast Deploy content store.
|
|
11
|
+
{ "binding": "DECO_KV", "id": "" },
|
|
12
|
+
// A/B testing assignments.
|
|
13
|
+
{ "binding": "SITES_KV", "id": "" }
|
|
14
|
+
],
|
|
15
|
+
"vars": { "DECO_FAST_DEPLOY": "1" }
|
|
16
|
+
}`;
|
|
17
|
+
|
|
18
|
+
it("writes the id into the DECO_KV entry and leaves comments intact", () => {
|
|
19
|
+
const out = setKvNamespaceIdInJsonc(WRANGLER, "ns-123");
|
|
20
|
+
expect(out).toContain('{ "binding": "DECO_KV", "id": "ns-123" }');
|
|
21
|
+
expect(out).toContain("// Fast Deploy content store.");
|
|
22
|
+
expect(out).toContain("// A/B testing assignments.");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("does not touch a different binding's id", () => {
|
|
26
|
+
const out = setKvNamespaceIdInJsonc(WRANGLER, "ns-123");
|
|
27
|
+
expect(out).toContain('{ "binding": "SITES_KV", "id": "" }');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("targets the requested binding", () => {
|
|
31
|
+
const out = setKvNamespaceIdInJsonc(WRANGLER, "ab-9", "SITES_KV");
|
|
32
|
+
expect(out).toContain('{ "binding": "SITES_KV", "id": "ab-9" }');
|
|
33
|
+
expect(out).toContain('{ "binding": "DECO_KV", "id": "" }');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("handles reversed field order and an entry with no id yet", () => {
|
|
37
|
+
const src = '{ "kv_namespaces": [{ "id": "old", "binding": "DECO_KV" }] }';
|
|
38
|
+
expect(setKvNamespaceIdInJsonc(src, "new")).toContain('"id": "new"');
|
|
39
|
+
const noId = '{ "kv_namespaces": [{ "binding": "DECO_KV" }] }';
|
|
40
|
+
expect(setKvNamespaceIdInJsonc(noId, "new")).toContain('"id": "new"');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns the source unchanged when the binding is absent", () => {
|
|
44
|
+
// Not an error: the builder re-forces the id from CF_KV_NAMESPACE_ID on
|
|
45
|
+
// every build, so a repo without the binding is just nothing to personalize.
|
|
46
|
+
const src = '{ "kv_namespaces": [{ "binding": "SITES_KV", "id": "x" }] }';
|
|
47
|
+
expect(setKvNamespaceIdInJsonc(src, "ns-123")).toBe(src);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -72,3 +72,34 @@ function matchTomlString(body: string, key: string): string | null {
|
|
|
72
72
|
const m = body.match(new RegExp(`(?:^|\\n)\\s*${key}\\s*=\\s*["']([^"']+)["']`));
|
|
73
73
|
return m ? m[1] : null;
|
|
74
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Set the `id` of a `kv_namespaces` entry in a `wrangler.jsonc` SOURCE string,
|
|
78
|
+
* preserving comments and formatting.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately text-level rather than parse-and-restringify: the scaffolded
|
|
81
|
+
* `wrangler.jsonc` carries the comments explaining why each binding exists, and
|
|
82
|
+
* a round-trip through `JSON.parse`/`stringify` would drop every one of them.
|
|
83
|
+
*
|
|
84
|
+
* Returns the original string unchanged when the binding is absent — callers
|
|
85
|
+
* treat that as "nothing to personalize", never as an error, because the
|
|
86
|
+
* builder re-forces the id from `CF_KV_NAMESPACE_ID` on every build anyway.
|
|
87
|
+
*/
|
|
88
|
+
export function setKvNamespaceIdInJsonc(
|
|
89
|
+
src: string,
|
|
90
|
+
id: string,
|
|
91
|
+
binding = DEFAULT_BINDING,
|
|
92
|
+
): string {
|
|
93
|
+
// Match the object literal holding this binding, in either field order, and
|
|
94
|
+
// rewrite only its `id` value. Bounded to a single `{...}` so it cannot reach
|
|
95
|
+
// past the entry into the next one.
|
|
96
|
+
const entry = new RegExp(`\\{[^{}]*?"binding"\\s*:\\s*"${binding}"[^{}]*?\\}`, "s");
|
|
97
|
+
const m = src.match(entry);
|
|
98
|
+
if (!m) return src;
|
|
99
|
+
|
|
100
|
+
const patched = m[0].includes('"id"')
|
|
101
|
+
? m[0].replace(/"id"\s*:\s*"[^"]*"/, `"id": ${JSON.stringify(id)}`)
|
|
102
|
+
: m[0].replace(/\}$/, `, "id": ${JSON.stringify(id)} }`);
|
|
103
|
+
|
|
104
|
+
return src.slice(0, m.index) + patched + src.slice(m.index! + m[0].length);
|
|
105
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { parseJsonc } from "../lib/jsonc";
|
|
6
|
+
import { generateWranglerConfig } from "./phase-scaffold";
|
|
7
|
+
import { checks } from "./phase-verify";
|
|
8
|
+
import { generateSetup } from "./templates/setup";
|
|
9
|
+
import type { MigrationContext } from "./types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fast Deploy is inert unless THREE things line up: the `DECO_KV` binding,
|
|
13
|
+
* `DECO_FAST_DEPLOY=1`, and `setup.ts` handing the KV resolver to
|
|
14
|
+
* `@decocms/blocks-admin` (which cannot import `@decocms/tanstack` itself).
|
|
15
|
+
*
|
|
16
|
+
* Two of three is the worst state: it looks configured, a Studio publish
|
|
17
|
+
* reports success, and nothing ever reaches KV. These tests pin the scaffold to
|
|
18
|
+
* emit all three and pin the verify check to actually catch a partial config.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const FIXTURE: MigrationContext = {
|
|
22
|
+
sourceDir: "",
|
|
23
|
+
siteName: "test-site",
|
|
24
|
+
platform: "custom",
|
|
25
|
+
vtexAccount: null,
|
|
26
|
+
gtmId: null,
|
|
27
|
+
importMap: {},
|
|
28
|
+
discoveredNpmDeps: {},
|
|
29
|
+
themeColors: {},
|
|
30
|
+
tailwindConfig: {
|
|
31
|
+
colors: {},
|
|
32
|
+
fontFamily: {},
|
|
33
|
+
screens: {},
|
|
34
|
+
safelist: [],
|
|
35
|
+
safelistPatterns: [],
|
|
36
|
+
plugins: [],
|
|
37
|
+
reviewItems: [],
|
|
38
|
+
animations: {},
|
|
39
|
+
keyframes: {},
|
|
40
|
+
},
|
|
41
|
+
fontFamily: null,
|
|
42
|
+
googleFonts: [],
|
|
43
|
+
layout: null,
|
|
44
|
+
files: [],
|
|
45
|
+
sectionMetas: [],
|
|
46
|
+
islandClassifications: [],
|
|
47
|
+
loaderInventory: [],
|
|
48
|
+
patterns: {},
|
|
49
|
+
scaffoldedFiles: [],
|
|
50
|
+
transformedFiles: [],
|
|
51
|
+
deletedFiles: [],
|
|
52
|
+
movedFiles: [],
|
|
53
|
+
manualReview: [],
|
|
54
|
+
dryRun: false,
|
|
55
|
+
verbose: false,
|
|
56
|
+
} as unknown as MigrationContext;
|
|
57
|
+
|
|
58
|
+
const fastDeployCheck = checks.find((c) => c.name === "Fast Deploy wired end to end");
|
|
59
|
+
|
|
60
|
+
/** Write a site tree with the given wrangler + setup contents. */
|
|
61
|
+
function makeSite(wrangler: string, setup: string): MigrationContext {
|
|
62
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fd-scaffold-"));
|
|
63
|
+
fs.writeFileSync(path.join(dir, "wrangler.jsonc"), wrangler);
|
|
64
|
+
fs.mkdirSync(path.join(dir, "src"), { recursive: true });
|
|
65
|
+
fs.writeFileSync(path.join(dir, "src", "setup.ts"), setup);
|
|
66
|
+
return { ...FIXTURE, sourceDir: dir };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("scaffolded wrangler.jsonc", () => {
|
|
70
|
+
// Parse it rather than grep it: the template is hand-written JSONC, and a
|
|
71
|
+
// stray comma or comment that breaks parsing silently disables fast-deploy
|
|
72
|
+
// everywhere at once (the builder, the control-plane and the verify check
|
|
73
|
+
// below all read this file through a JSONC parser).
|
|
74
|
+
const cfg = parseJsonc(generateWranglerConfig(FIXTURE)) as {
|
|
75
|
+
kv_namespaces: Array<{ binding: string }>;
|
|
76
|
+
vars: Record<string, string>;
|
|
77
|
+
tail_consumers?: unknown[];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
it("declares the DECO_KV binding and DECO_FAST_DEPLOY together", () => {
|
|
81
|
+
expect(cfg.kv_namespaces.map((n) => n.binding)).toContain("DECO_KV");
|
|
82
|
+
expect(cfg.vars.DECO_FAST_DEPLOY).toBe("1");
|
|
83
|
+
expect(cfg.vars.DECO_SITE_NAME).toBe("test-site");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("declares a tail consumer, without which exceededMemory is unobservable", () => {
|
|
87
|
+
// Cloudflare kills the isolate over the 128MB cap before any in-worker code
|
|
88
|
+
// could report it — the tail worker is the only channel that sees it.
|
|
89
|
+
expect(cfg.tail_consumers).toBeTruthy();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("scaffolded setup.ts", () => {
|
|
94
|
+
it("calls setupTanstackFastDeploy, or the Studio publish write-through no-ops", () => {
|
|
95
|
+
const setup = generateSetup(FIXTURE);
|
|
96
|
+
expect(setup).toContain("setupTanstackFastDeploy()");
|
|
97
|
+
expect(setup).toMatch(/import \{[^}]*setupTanstackFastDeploy[^}]*\} from "@decocms\/tanstack"/);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('verify check "Fast Deploy wired end to end"', () => {
|
|
102
|
+
const FULL_WRANGLER = `{
|
|
103
|
+
// comment + trailing comma: real wrangler.jsonc, not JSON
|
|
104
|
+
"kv_namespaces": [{ "binding": "DECO_KV", "id": "" }],
|
|
105
|
+
"vars": { "DECO_FAST_DEPLOY": "1" },
|
|
106
|
+
}`;
|
|
107
|
+
const FULL_SETUP = "setupTanstackFastDeploy();\n";
|
|
108
|
+
|
|
109
|
+
it("passes on a fully wired site", () => {
|
|
110
|
+
expect(fastDeployCheck?.fn(makeSite(FULL_WRANGLER, FULL_SETUP))).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("fails when the DECO_KV binding is missing", () => {
|
|
114
|
+
const w = '{ "kv_namespaces": [], "vars": { "DECO_FAST_DEPLOY": "1" } }';
|
|
115
|
+
expect(fastDeployCheck?.fn(makeSite(w, FULL_SETUP))).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("fails when DECO_FAST_DEPLOY is not set", () => {
|
|
119
|
+
const w = '{ "kv_namespaces": [{ "binding": "DECO_KV", "id": "" }] }';
|
|
120
|
+
expect(fastDeployCheck?.fn(makeSite(w, FULL_SETUP))).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("fails when setup.ts never hands over the KV resolver", () => {
|
|
124
|
+
expect(fastDeployCheck?.fn(makeSite(FULL_WRANGLER, "// nothing\n"))).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("is an error, not a warning — a silent half-config is the failure mode", () => {
|
|
128
|
+
expect(fastDeployCheck?.severity).toBe("error");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -277,21 +277,48 @@ export { type Font as SiteThemeFont };
|
|
|
277
277
|
`;
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
function generateWranglerConfig(ctx: MigrationContext): string {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
280
|
+
export function generateWranglerConfig(ctx: MigrationContext): string {
|
|
281
|
+
// Emitted as real JSONC (not JSON.stringify) so the comments survive — every
|
|
282
|
+
// field below is load-bearing and the next person to edit this file needs to
|
|
283
|
+
// know why. The ids are placeholders: the control-plane owns them (it
|
|
284
|
+
// provisions the site's OWN namespaces and overrides these at build time),
|
|
285
|
+
// so a wrong id here can never reach production.
|
|
286
|
+
return `{
|
|
287
|
+
"name": "${ctx.siteName}",
|
|
288
|
+
"main": "src/worker-entry.ts",
|
|
289
|
+
"compatibility_date": "2025-05-01",
|
|
290
|
+
// no_handle_cross_request_promise_resolution: the framework's SWR/dedup
|
|
291
|
+
// caches hold in-flight promises across requests; without this flag the
|
|
292
|
+
// worker hangs.
|
|
293
|
+
"compatibility_flags": ["nodejs_compat", "no_handle_cross_request_promise_resolution"],
|
|
294
|
+
// Workers Cache — tiered cache IN FRONT of the worker. On a hit the worker
|
|
295
|
+
// never runs. Per-response cacheability is still driven by Cache-Control.
|
|
296
|
+
"cache": { "enabled": true },
|
|
297
|
+
// Surfaces the build sha as service.version on every span and log line.
|
|
298
|
+
"version_metadata": { "binding": "CF_VERSION_METADATA" },
|
|
299
|
+
// Tail worker. The ONLY channel that can see exceededMemory / exceededCpu:
|
|
300
|
+
// Cloudflare kills the isolate before any in-worker code could report them.
|
|
301
|
+
// Without this a memory incident is invisible — see docs/runbooks/.
|
|
302
|
+
"tail_consumers": [{ "service": "deco-otel-tail" }],
|
|
303
|
+
"kv_namespaces": [
|
|
304
|
+
// Fast Deploy content store. Holds decofile:<deployment-id> + the revision
|
|
305
|
+
// index, so a CMS publish goes live without a code deploy. The id is
|
|
306
|
+
// written by the control-plane at site creation and re-forced from
|
|
307
|
+
// CF_KV_NAMESPACE_ID on every build (per-site KV isolation).
|
|
308
|
+
{ "binding": "DECO_KV", "id": "" }
|
|
309
|
+
// No SITES_KV: withABTesting (server-entry) passes through when it is
|
|
310
|
+
// unbound, and nothing provisions it — an empty id would fail deploy.
|
|
311
|
+
// Add it with the shared namespace's real id when starting an A/B test.
|
|
312
|
+
],
|
|
313
|
+
"vars": {
|
|
314
|
+
"DECO_SITE_NAME": "${ctx.siteName}",
|
|
315
|
+
"DECO_ENV_NAME": "production",
|
|
316
|
+
// Fast Deploy opt-in. Requires BOTH this flag AND the DECO_KV binding
|
|
317
|
+
// above; inert if either is missing. Set to "0" to disable.
|
|
318
|
+
"DECO_FAST_DEPLOY": "1"
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
`;
|
|
295
322
|
}
|
|
296
323
|
|
|
297
324
|
function generateGitignore(): string {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { parseJsonc } from "../lib/jsonc";
|
|
3
4
|
import type { MigrationContext } from "./types";
|
|
4
5
|
import { logPhase } from "./types";
|
|
5
6
|
|
|
@@ -84,6 +85,45 @@ export const checks: Check[] = [
|
|
|
84
85
|
return true;
|
|
85
86
|
},
|
|
86
87
|
},
|
|
88
|
+
{
|
|
89
|
+
// Fast Deploy needs THREE things and is inert unless all three are present:
|
|
90
|
+
// the DECO_KV binding, DECO_FAST_DEPLOY=1, and setup.ts handing the KV
|
|
91
|
+
// resolver to blocks-admin. Two of three is the worst state — it looks
|
|
92
|
+
// configured, the Studio publish reports success, and nothing reaches KV.
|
|
93
|
+
name: "Fast Deploy wired end to end",
|
|
94
|
+
severity: "error",
|
|
95
|
+
fn: (ctx) => {
|
|
96
|
+
const missing: string[] = [];
|
|
97
|
+
const wranglerPath = path.join(ctx.sourceDir, "wrangler.jsonc");
|
|
98
|
+
try {
|
|
99
|
+
const cfg = parseJsonc(fs.readFileSync(wranglerPath, "utf-8")) as {
|
|
100
|
+
kv_namespaces?: Array<{ binding?: string }>;
|
|
101
|
+
vars?: Record<string, unknown>;
|
|
102
|
+
};
|
|
103
|
+
const flag = cfg.vars?.DECO_FAST_DEPLOY;
|
|
104
|
+
if (!(cfg.kv_namespaces ?? []).some((n) => n?.binding === "DECO_KV")) {
|
|
105
|
+
missing.push("wrangler.jsonc kv_namespaces is missing the DECO_KV binding");
|
|
106
|
+
}
|
|
107
|
+
if (flag !== "1" && flag !== "true") {
|
|
108
|
+
missing.push('wrangler.jsonc vars.DECO_FAST_DEPLOY is not "1"');
|
|
109
|
+
}
|
|
110
|
+
} catch (e) {
|
|
111
|
+
missing.push(`wrangler.jsonc could not be parsed: ${(e as Error).message}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const setupPath = path.join(ctx.sourceDir, "src", "setup.ts");
|
|
115
|
+
const setup = fs.existsSync(setupPath) ? fs.readFileSync(setupPath, "utf-8") : "";
|
|
116
|
+
if (!setup.includes("setupTanstackFastDeploy()")) {
|
|
117
|
+
missing.push("src/setup.ts does not call setupTanstackFastDeploy()");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (missing.length > 0) {
|
|
121
|
+
for (const m of missing) console.log(` ${m}`);
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
},
|
|
126
|
+
},
|
|
87
127
|
{
|
|
88
128
|
name: "Old artifacts removed",
|
|
89
129
|
severity: "error",
|
|
@@ -46,7 +46,7 @@ function generateRoot(ctx: MigrationContext, siteTitle: string, vtexAccount: str
|
|
|
46
46
|
|
|
47
47
|
// Build preconnect list based on platform
|
|
48
48
|
const preconnects: string[] = [];
|
|
49
|
-
preconnects.push(` { rel: "preconnect", href: "https://
|
|
49
|
+
preconnects.push(` { rel: "preconnect", href: "https://assets.decocms.com", crossOrigin: "anonymous" as const },`);
|
|
50
50
|
if (isVtex) {
|
|
51
51
|
preconnects.push(` { rel: "preconnect", href: "https://${vtexAccount}.vtexassets.com", crossOrigin: "anonymous" as const },`);
|
|
52
52
|
}
|
|
@@ -104,7 +104,7 @@ import { initVtexFromBlocks, setVtexFetch } from "@decocms/apps-vtex";` : ""}${h
|
|
|
104
104
|
import { registerLocationMatcher } from "./matchers/location";` : ""}
|
|
105
105
|
import { blocks as generatedBlocks } from "../.deco/blocks.gen";
|
|
106
106
|
import { sectionMeta, syncComponents, loadingFallbacks, renderJsons } from "../.deco/sections.gen";
|
|
107
|
-
import { PreviewProviders } from "@decocms/tanstack";
|
|
107
|
+
import { PreviewProviders, setupTanstackFastDeploy } from "@decocms/tanstack";
|
|
108
108
|
// @ts-ignore Vite ?url import
|
|
109
109
|
import appCss from "./styles/app.css?url";
|
|
110
110
|
|
|
@@ -131,6 +131,13 @@ createSiteSetup({
|
|
|
131
131
|
return null;
|
|
132
132
|
},
|
|
133
133
|
});
|
|
134
|
+
|
|
135
|
+
// -- Fast Deploy --
|
|
136
|
+
// Hands the KV binding resolver to @decocms/blocks-admin, which cannot import
|
|
137
|
+
// @decocms/tanstack itself (wrong direction in the package graph). Without this
|
|
138
|
+
// call a Studio publish silently no-ops instead of writing through to KV.
|
|
139
|
+
// Inert unless the worker has DECO_FAST_DEPLOY=1 + a DECO_KV binding.
|
|
140
|
+
setupTanstackFastDeploy();
|
|
134
141
|
${isVtex ? `
|
|
135
142
|
// -- VTEX wiring --
|
|
136
143
|
setVtexFetch(createInstrumentedFetch("vtex"));
|
package/scripts/migrate.ts
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { execSync } from "node:child_process";
|
|
28
28
|
import * as fs from "node:fs";
|
|
29
29
|
import * as path from "node:path";
|
|
30
|
+
import { setKvNamespaceIdInJsonc } from "./lib/wrangler-config";
|
|
30
31
|
import { banner, green, red, stat, yellow } from "./migrate/colors";
|
|
31
32
|
import { loadConfig, validateConfig } from "./migrate/config";
|
|
32
33
|
import { analyze } from "./migrate/phase-analyze";
|
|
@@ -119,6 +120,15 @@ function showHelp() {
|
|
|
119
120
|
via \`deco-post-cleanup\` if needed)
|
|
120
121
|
--help, -h Show this help message
|
|
121
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
|
+
|
|
122
132
|
Examples:
|
|
123
133
|
npx -p @decocms/blocks-cli deco-migrate --dry-run --verbose
|
|
124
134
|
npx -p @decocms/blocks-cli deco-migrate --source ./my-site
|
|
@@ -198,6 +208,7 @@ async function main() {
|
|
|
198
208
|
// Phase 7: Bootstrap (install + generate)
|
|
199
209
|
if (!ctx.dryRun) {
|
|
200
210
|
bootstrap(ctx);
|
|
211
|
+
await provisionSite(ctx.sourceDir);
|
|
201
212
|
await provisionAnalytics(ctx.sourceDir);
|
|
202
213
|
}
|
|
203
214
|
|
|
@@ -284,6 +295,162 @@ function bootstrap(ctx: { sourceDir: string }) {
|
|
|
284
295
|
}
|
|
285
296
|
}
|
|
286
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
|
+
|
|
287
454
|
// Supabase project ref for decocms — the central platform DB that tracks all sites.
|
|
288
455
|
const DECOCMS_SUPABASE_REF = "ozksgdmyrqcxcwhnbepg";
|
|
289
456
|
|
|
@@ -306,6 +473,16 @@ async function provisionAnalytics(sourceDir: string): Promise<void> {
|
|
|
306
473
|
return;
|
|
307
474
|
}
|
|
308
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
|
+
|
|
309
486
|
const sql = `UPDATE public.sites SET metadata = metadata || '{"analytics": "onedollarstats"}'::jsonb WHERE name = '${siteName}'`;
|
|
310
487
|
|
|
311
488
|
try {
|