@decocms/blocks-cli 7.70.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.70.0",
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.70.0",
36
+ "@decocms/blocks": "7.71.0",
37
37
  "ts-morph": "^27.0.0",
38
38
  "tsx": "^4.22.5"
39
39
  },
@@ -361,6 +361,7 @@ function imageStats(events, ns) {
361
361
  const imgDomains = [
362
362
  "vtexassets",
363
363
  "decoims",
364
+ "assets.decocms.com",
364
365
  "decoazn",
365
366
  "decocache",
366
367
  "deco-sites-assets",
@@ -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
+ }
@@ -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://decoims.com", crossOrigin: "anonymous" as const },`);
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
  }
@@ -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 {