@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.
Files changed (100) hide show
  1. package/package.json +5 -3
  2. package/scripts/analyze-traces.mjs +1 -0
  3. package/scripts/cdn-rules.test.ts +105 -0
  4. package/scripts/cdn-rules.ts +192 -0
  5. package/scripts/deco-migrate-cli.ts +6 -7
  6. package/scripts/fast-deploy-kv.test.ts +172 -0
  7. package/scripts/generate-blocks.test.ts +11 -0
  8. package/scripts/generate-blocks.ts +0 -10
  9. package/scripts/generate-invoke.test.ts +2 -2
  10. package/scripts/generate-invoke.ts +9 -21
  11. package/scripts/generate-loaders.test.ts +2 -2
  12. package/scripts/generate-loaders.ts +0 -10
  13. package/scripts/generate-schema-loader-aliases.test.ts +186 -0
  14. package/scripts/generate-schema-workspaces.test.ts +180 -0
  15. package/scripts/generate-schema.test.ts +68 -29
  16. package/scripts/generate-schema.ts +101 -89
  17. package/scripts/generate-sections.test.ts +104 -35
  18. package/scripts/generate-sections.ts +57 -11
  19. package/scripts/generate-workspaces.test.ts +179 -0
  20. package/scripts/generate.test.ts +1 -2
  21. package/scripts/generate.ts +43 -45
  22. package/scripts/lib/blocks-dedupe.test.ts +1 -1
  23. package/scripts/lib/cf-kv-rest.ts +49 -1
  24. package/scripts/lib/installedPackages.ts +42 -0
  25. package/scripts/lib/invokeSource.ts +18 -0
  26. package/scripts/lib/kv-snapshot.ts +44 -0
  27. package/scripts/lib/read-decofile.ts +13 -2
  28. package/scripts/lib/upgrade-6-to-7.ts +1 -1
  29. package/scripts/lib/wrangler-config.test.ts +49 -0
  30. package/scripts/lib/wrangler-config.ts +31 -0
  31. package/scripts/migrate/analyzers/htmx-analyze.test.ts +6 -6
  32. package/scripts/migrate/analyzers/tailwind-config.ts +95 -1
  33. package/scripts/migrate/config.ts +2 -2
  34. package/scripts/migrate/delete-sets.ts +75 -0
  35. package/scripts/migrate/fast-deploy-scaffold.test.ts +130 -0
  36. package/scripts/migrate/icon-casing.test.ts +63 -0
  37. package/scripts/migrate/phase-analyze.test.ts +66 -4
  38. package/scripts/migrate/phase-analyze.ts +79 -60
  39. package/scripts/migrate/phase-cleanup.test.ts +3 -1
  40. package/scripts/migrate/phase-cleanup.ts +1 -1
  41. package/scripts/migrate/phase-compile.test.ts +3 -1
  42. package/scripts/migrate/phase-report.test.ts +2 -0
  43. package/scripts/migrate/phase-report.ts +3 -4
  44. package/scripts/migrate/phase-scaffold.ts +150 -45
  45. package/scripts/migrate/phase-transform.ts +107 -2
  46. package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +13 -0
  47. package/scripts/migrate/phase-verify.test.ts +3 -1
  48. package/scripts/migrate/phase-verify.ts +87 -33
  49. package/scripts/migrate/post-cleanup/rules.ts +29 -11
  50. package/scripts/migrate/post-cleanup/shim-classify.test.ts +1 -1
  51. package/scripts/migrate/post-cleanup/shim-classify.ts +2 -2
  52. package/scripts/migrate/source-layout.test.ts +43 -0
  53. package/scripts/migrate/source-layout.ts +32 -5
  54. package/scripts/migrate/templates/app-css.test.ts +24 -0
  55. package/scripts/migrate/templates/app-css.ts +27 -0
  56. package/scripts/migrate/templates/ci-workflows.test.ts +179 -0
  57. package/scripts/migrate/templates/ci-yml.ts +182 -0
  58. package/scripts/migrate/templates/cursor-rules.test.ts +3 -3
  59. package/scripts/migrate/templates/hooks.test.ts +3 -1
  60. package/scripts/migrate/templates/lib-utils.ts +3 -3
  61. package/scripts/migrate/templates/main-push-guard-yml.ts +45 -0
  62. package/scripts/migrate/templates/package-json.ts +10 -0
  63. package/scripts/migrate/templates/parity-yml.ts +136 -0
  64. package/scripts/migrate/templates/perf-yml.ts +449 -0
  65. package/scripts/migrate/templates/playwright-yml.ts +127 -0
  66. package/scripts/migrate/templates/react-doctor-yml.ts +46 -0
  67. package/scripts/migrate/templates/routes.test.ts +31 -0
  68. package/scripts/migrate/templates/routes.ts +19 -9
  69. package/scripts/migrate/templates/section-loaders.ts +3 -3
  70. package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +19 -0
  71. package/scripts/migrate/templates/server-entry.ts +19 -14
  72. package/scripts/migrate/templates/setup.ts +11 -3
  73. package/scripts/migrate/templates/sync-blocks-bot-yml.ts +201 -0
  74. package/scripts/migrate/templates/ui-components.ts +3 -0
  75. package/scripts/migrate/transforms/ctx-compat.test.ts +37 -0
  76. package/scripts/migrate/transforms/ctx-compat.ts +93 -1
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +1 -1
  78. package/scripts/migrate/transforms/htmx-on-events.ts +2 -2
  79. package/scripts/migrate/transforms/jsx-class-type.test.ts +28 -0
  80. package/scripts/migrate/transforms/jsx.ts +10 -0
  81. package/scripts/migrate/transforms/picture.test.ts +56 -0
  82. package/scripts/migrate/transforms/picture.ts +95 -0
  83. package/scripts/migrate/transforms/tailwind-renames.test.ts +29 -0
  84. package/scripts/migrate/transforms/tailwind-renames.ts +34 -0
  85. package/scripts/migrate/transforms/timer-types.test.ts +30 -0
  86. package/scripts/migrate/transforms/timer-types.ts +48 -0
  87. package/scripts/migrate/transforms/use-script-handlers.test.ts +37 -0
  88. package/scripts/migrate/transforms/use-script-handlers.ts +36 -0
  89. package/scripts/migrate/types.ts +13 -1
  90. package/scripts/migrate-blocks-to-kv.ts +16 -2
  91. package/scripts/migrate-to-cf-observability.test.ts +5 -5
  92. package/scripts/migrate-to-cf-observability.ts +1 -1
  93. package/scripts/migrate.ts +244 -6
  94. package/scripts/reconcile.test.ts +162 -0
  95. package/scripts/reconcile.ts +303 -0
  96. package/scripts/sync-blocks-bot.test.ts +276 -0
  97. package/scripts/sync-blocks-bot.ts +567 -0
  98. package/scripts/sync-blocks-to-kv.ts +20 -2
  99. package/scripts/upgrade-6-to-7.ts +0 -0
  100. package/scripts/lib/legacyArtifact.ts +0 -20
@@ -0,0 +1,179 @@
1
+ // @vitest-environment node
2
+ import { spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import { createRequire } from "node:module";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
9
+ import { buildPlan, parseCliOptions } from "./generate";
10
+
11
+ const tsx = createRequire(import.meta.url).resolve("tsx/cli");
12
+ const script = fileURLToPath(new URL("./generate.ts", import.meta.url));
13
+
14
+ describe("generation in a workspace with hoisted dependencies", () => {
15
+ let workspace: string;
16
+ let site: string;
17
+
18
+ function write(root: string, relative: string, content: string): string {
19
+ const file = path.join(root, relative);
20
+ fs.mkdirSync(path.dirname(file), { recursive: true });
21
+ fs.writeFileSync(file, content);
22
+ return file;
23
+ }
24
+
25
+ function install(root: string, name: string, version = "7.48.4"): string {
26
+ const dir = path.join(root, "node_modules", name);
27
+ // Published packages need not export their root or package.json.
28
+ write(
29
+ dir,
30
+ "package.json",
31
+ JSON.stringify({ name, version, exports: { "./mod": "./src/mod.ts" } }),
32
+ );
33
+ return dir;
34
+ }
35
+
36
+ function plans(args: string[] = []) {
37
+ return Object.fromEntries(buildPlan(site, parseCliOptions(args)).map((p) => [p.name, p]));
38
+ }
39
+
40
+ function run(args: string[]) {
41
+ // Exercise --root too: the caller's cwd is the workspace, not the site.
42
+ const result = spawnSync(process.execPath, [tsx, script, "--root", "apps/website", ...args], {
43
+ cwd: workspace,
44
+ encoding: "utf8",
45
+ timeout: 30_000,
46
+ });
47
+ expect(result.status, result.stdout + result.stderr).toBe(0);
48
+ return result.stdout;
49
+ }
50
+
51
+ beforeEach(() => {
52
+ workspace = fs.mkdtempSync(path.join(os.tmpdir(), "generate-workspaces-"));
53
+ site = path.join(workspace, "apps", "website");
54
+ write(workspace, "package.json", JSON.stringify({ private: true, workspaces: ["apps/*"] }));
55
+ write(site, "package.json", JSON.stringify({ name: "website", type: "module" }));
56
+ write(site, "tsconfig.json", JSON.stringify({ compilerOptions: { jsx: "react-jsx" } }));
57
+ write(site, ".deco/blocks/Home.json", JSON.stringify({ name: "Home", path: "/" }));
58
+ write(site, "src/sections/Hero.tsx", "export default function Hero() { return null; }\n");
59
+ install(workspace, "@decocms/blocks");
60
+ });
61
+
62
+ afterEach(() => {
63
+ fs.rmSync(workspace, { recursive: true, force: true });
64
+ });
65
+
66
+ it("detects hoisted Next.js and enables its manifest and section registry", () => {
67
+ install(workspace, "@decocms/nextjs");
68
+ const plan = plans();
69
+ expect(plan.manifest.enabled).toBe(true);
70
+ expect(plan.blocks.enabled).toBe(false);
71
+ expect(plan.sections.args).toContain("--registry");
72
+ });
73
+
74
+ it.each([
75
+ "nextjs",
76
+ "tanstack",
77
+ ])("uses the site's %s binding when siblings hoist both frameworks", (framework) => {
78
+ write(
79
+ site,
80
+ "package.json",
81
+ JSON.stringify({
82
+ name: "website",
83
+ dependencies: { [`@decocms/${framework}`]: "7.48.4" },
84
+ }),
85
+ );
86
+ install(workspace, "@decocms/nextjs");
87
+ install(workspace, "@decocms/tanstack");
88
+ install(workspace, "@tanstack/react-start");
89
+ const app = install(workspace, "@decocms/apps-vtex");
90
+ write(app, "src/invoke.ts", "export const invoke = {} as const;\n");
91
+ const plan = plans();
92
+ expect(plan.manifest.enabled).toBe(framework === "nextjs");
93
+ expect(plan.sections.args.includes("--registry")).toBe(framework === "nextjs");
94
+ expect(plan.blocks.enabled).toBe(framework === "tanstack");
95
+ expect(plan.invoke.enabled).toBe(framework === "tanstack");
96
+ });
97
+
98
+ it.each([
99
+ false,
100
+ true,
101
+ ])("generates invoke from hoisted packages (explicit apps directory: %s)", (explicit) => {
102
+ install(workspace, "@tanstack/react-start", "1.166.8");
103
+ const app = install(workspace, "@decocms/apps-vtex");
104
+ write(
105
+ app,
106
+ "src/invoke.ts",
107
+ `
108
+ import { createInvokeFn } from "@decocms/tanstack/sdk/createInvoke";
109
+ import { getOrCreateCart } from "./actions/checkout";
110
+ import type { OrderForm } from "./types";
111
+ export const invoke = {
112
+ vtex: { actions: {
113
+ getOrCreateCart: createInvokeFn(
114
+ (data: { orderFormId?: string }) => getOrCreateCart(data),
115
+ ) as unknown as (ctx: { data: { orderFormId?: string } }) => Promise<OrderForm>,
116
+ } },
117
+ } as const;
118
+ `,
119
+ );
120
+ expect(plans().invoke.enabled).toBe(true);
121
+ run([
122
+ "--only",
123
+ "invoke",
124
+ ...(explicit ? ["--apps-dir", "../../node_modules/@decocms/apps-vtex"] : []),
125
+ ]);
126
+ const output = fs.readFileSync(path.join(site, "src/server/invoke.gen.ts"), "utf8");
127
+ expect(output).toContain("getOrCreateCart");
128
+ expect(output).toContain("@decocms/apps-vtex/actions/checkout");
129
+ });
130
+
131
+ it("does not use a parent invoke contract when a local package shadows it", () => {
132
+ install(workspace, "@tanstack/react-start");
133
+ const hoisted = install(workspace, "@decocms/apps-vtex");
134
+ write(hoisted, "src/invoke.ts", "export const invoke = {};\n");
135
+ install(site, "@decocms/apps-vtex");
136
+ expect(plans().invoke.enabled).toBe(false);
137
+ });
138
+
139
+ it("fingerprints hoisted app sources and respects local package precedence and --skip-apps", () => {
140
+ const hoisted = install(workspace, "@decocms/apps-vtex");
141
+ const parentSource = write(hoisted, "src/loaders/product.ts", "export default () => [];\n");
142
+ const inputPaths = (args: string[] = []) =>
143
+ plans(args)
144
+ .schema.inputs()
145
+ .map(([rel]) => path.resolve(site, rel));
146
+ expect(inputPaths()).toContain(parentSource);
147
+ expect(inputPaths(["--skip-apps"])).not.toContain(parentSource);
148
+
149
+ const local = install(site, "@decocms/apps-vtex", "7.48.5");
150
+ const localSource = write(local, "src/loaders/local.ts", "export default () => [];\n");
151
+ expect(inputPaths()).toContain(localSource);
152
+ expect(inputPaths()).not.toContain(parentSource);
153
+ });
154
+
155
+ it("invalidates cached generation when a hoisted package version changes", () => {
156
+ run(["--only", "loaders"]);
157
+ const digests = JSON.parse(
158
+ fs.readFileSync(path.join(site, ".deco/generate.digests.json"), "utf8"),
159
+ );
160
+ expect(digests.generators.loaders.deco).toContain("@decocms/blocks@7.48.4");
161
+ expect(run(["--only", "loaders", "--dry-run"])).toContain("loaders: skip — cached");
162
+
163
+ install(workspace, "@decocms/blocks", "7.48.5");
164
+ expect(run(["--only", "loaders", "--dry-run"])).toContain("loaders: would run");
165
+ });
166
+
167
+ it("fingerprints the nearest package version, ignoring a shadowed parent's updates", () => {
168
+ install(site, "@decocms/blocks", "7.48.6");
169
+ run(["--only", "loaders"]);
170
+ const digests = JSON.parse(
171
+ fs.readFileSync(path.join(site, ".deco/generate.digests.json"), "utf8"),
172
+ );
173
+ expect(digests.generators.loaders.deco).toContain("@decocms/blocks@7.48.6");
174
+ expect(digests.generators.loaders.deco).not.toContain("@decocms/blocks@7.48.4");
175
+
176
+ install(workspace, "@decocms/blocks", "7.48.7");
177
+ expect(run(["--only", "loaders", "--dry-run"])).toContain("loaders: skip — cached");
178
+ });
179
+ });
@@ -242,8 +242,7 @@ describe("buildPlan --platform eitri", () => {
242
242
  expect(byName[name].disabledReason).toBe("not used by --platform eitri");
243
243
  }
244
244
 
245
- // schema is asked to bake composeMeta's framework block types in.
246
- expect(byName.schema.args).toContain("--compose");
245
+ // schema tags the composed meta with the eitri framework name.
247
246
  const fwIdx = byName.schema.args.indexOf("--framework");
248
247
  expect(byName.schema.args[fwIdx + 1]).toBe("eitri");
249
248
  } finally {
@@ -88,7 +88,7 @@
88
88
  * - args: the exact argv forwarded to that generator,
89
89
  * - cli: blocks-cli's own package version,
90
90
  * - deco: the resolved versions of every @decocms/* package in the
91
- * site's node_modules (a lockstep bump invalidates everything),
91
+ * site's module search path (a lockstep bump invalidates everything),
92
92
  * - inputs: sha256 over the sorted (relPath, contentSha256) pairs of the
93
93
  * generator's input set — CONTENT hashes, machine-independent.
94
94
  * Because the record is content-addressed, a FRESH CLONE with unchanged
@@ -127,6 +127,8 @@ import { createRequire } from "node:module";
127
127
  import path from "node:path";
128
128
  import { fileURLToPath } from "node:url";
129
129
  import { isExcludedCodegenFile } from "./lib/codegenExclusions";
130
+ import { listScopedPackages, resolvePackageDirectory } from "./lib/installedPackages";
131
+ import { resolveInvokeSource } from "./lib/invokeSource";
130
132
 
131
133
  // Bump when the digests file format or digest recipe changes — recorded in
132
134
  // every digest record so old files self-bust instead of mis-validating.
@@ -463,16 +465,9 @@ function readPkgVersion(pkgDir: string): string | null {
463
465
 
464
466
  function decoPackageVersions(cwd: string): Record<string, string> {
465
467
  const versions: Record<string, string> = {};
466
- const scopeDir = path.join(cwd, "node_modules", "@decocms");
467
- let names: string[];
468
- try {
469
- names = fs.readdirSync(scopeDir);
470
- } catch {
471
- return versions;
472
- }
473
- for (const name of names.sort()) {
474
- const v = readPkgVersion(path.join(scopeDir, name));
475
- if (v) versions[`@decocms/${name}`] = v;
468
+ for (const [name, dir] of listScopedPackages(cwd, "@decocms")) {
469
+ const v = readPkgVersion(dir);
470
+ if (v) versions[name] = v;
476
471
  }
477
472
  return versions;
478
473
  }
@@ -485,7 +480,25 @@ function ownVersion(): string {
485
480
  }
486
481
 
487
482
  function hasPackage(cwd: string, name: string): boolean {
488
- return fs.existsSync(path.join(cwd, "node_modules", ...name.split("/"), "package.json"));
483
+ const dir = resolvePackageDirectory(cwd, name);
484
+ return dir !== null && fs.existsSync(path.join(dir, "package.json"));
485
+ }
486
+
487
+ function isNextjsSite(cwd: string): boolean {
488
+ // Sibling sites can hoist both bindings. Prefer this site's declaration so
489
+ // a TanStack workspace does not adopt a sibling's Next.js generation mode.
490
+ try {
491
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
492
+ const dependencies = {
493
+ ...pkg.dependencies,
494
+ ...pkg.devDependencies,
495
+ ...pkg.peerDependencies,
496
+ ...pkg.optionalDependencies,
497
+ };
498
+ if ("@decocms/nextjs" in dependencies) return hasPackage(cwd, "@decocms/nextjs");
499
+ if ("@decocms/tanstack" in dependencies) return false;
500
+ } catch {}
501
+ return hasPackage(cwd, "@decocms/nextjs");
489
502
  }
490
503
 
491
504
  // ---------------------------------------------------------------------------
@@ -508,20 +521,6 @@ export interface GeneratorPlan {
508
521
  outputs: string[];
509
522
  }
510
523
 
511
- /** Resolve generate-invoke's apps dir exactly like the generator does
512
- * (minus the legacy ../apps-start/vtex fallback, which is dev-checkout-only). */
513
- function resolveInvokeSource(cwd: string, appsDir: string | null): string | null {
514
- const roots = appsDir
515
- ? [path.resolve(cwd, appsDir)]
516
- : [path.resolve(cwd, "node_modules/@decocms/apps-vtex")];
517
- for (const root of roots) {
518
- for (const c of [root, path.join(root, "src")]) {
519
- if (fs.existsSync(path.join(c, "invoke.ts"))) return path.join(c, "invoke.ts");
520
- }
521
- }
522
- return null;
523
- }
524
-
525
524
  export function buildPlan(cwd: string, opts: CliOptions): GeneratorPlan[] {
526
525
  const scriptsDir = path.dirname(fileURLToPath(import.meta.url));
527
526
  const blocksDirAbs = path.resolve(cwd, opts.blocksDir);
@@ -531,7 +530,7 @@ export function buildPlan(cwd: string, opts: CliOptions): GeneratorPlan[] {
531
530
 
532
531
  const blocksDirExists = fs.existsSync(blocksDirAbs);
533
532
  const sectionsDirExists = fs.existsSync(sectionsDirAbs);
534
- const isNext = hasPackage(cwd, "@decocms/nextjs");
533
+ const isNext = isNextjsSite(cwd);
535
534
  const hasTanstackStart = hasPackage(cwd, "@tanstack/react-start");
536
535
 
537
536
  const blocksGenJson = path.join(".deco", "blocks.gen.json");
@@ -635,10 +634,12 @@ export function buildPlan(cwd: string, opts: CliOptions): GeneratorPlan[] {
635
634
  args: opts.appsDir ? ["--apps-dir", opts.appsDir] : [],
636
635
  stage: 1,
637
636
  ...enabledIf(
638
- invokeSource !== null && hasTanstackStart,
637
+ invokeSource !== null && hasTanstackStart && !isNext,
639
638
  invokeSource === null
640
639
  ? "no apps invoke.ts found (@decocms/apps-vtex not installed and no --apps-dir)"
641
- : "@tanstack/react-start not installed (invoke.gen.ts targets TanStack Start)",
640
+ : isNext
641
+ ? "@decocms/nextjs site (invoke.gen.ts targets TanStack Start)"
642
+ : "@tanstack/react-start not installed (invoke.gen.ts targets TanStack Start)",
642
643
  ),
643
644
  // invoke.ts is the file the generator parses; the surrounding package's
644
645
  // action/type sources are fingerprinted via the @decocms/* version set
@@ -658,15 +659,15 @@ export function buildPlan(cwd: string, opts: CliOptions): GeneratorPlan[] {
658
659
  ...(opts.namespace ? ["--namespace", opts.namespace] : []),
659
660
  ...(opts.platform ? ["--platform", opts.platform] : []),
660
661
  ...(opts.skipApps ? ["--skip-apps"] : []),
661
- // Always bake composeMeta's framework block types (Page, matchers,
662
- // __SECTION_REF__, Resolvable) into meta.gen.json so the file is
663
- // SELF-CONTAINED for every consumer that reads it straight from disk —
664
- // the FS-based Studio and the Eitri stack, but also anyone diffing the
665
- // committed artifact against /live/_meta. The tanstack runtime re-runs
666
- // composeMeta on load, which is now idempotent (it returns an
667
- // already-composed meta unchanged — see composeMeta's `framework`
668
- // sentinel), so this is a no-op there rather than a double-compose.
669
- "--compose",
662
+ // generate-schema always bakes composeMeta's framework block types
663
+ // (Page, matchers, __SECTION_REF__, Resolvable) into meta.gen.json, so
664
+ // the file is SELF-CONTAINED for every consumer that reads it straight
665
+ // from disk — the FS-based Studio and the Eitri stack, but also anyone
666
+ // diffing the committed artifact against /live/_meta. The tanstack
667
+ // runtime re-runs composeMeta on load, which is idempotent (it returns
668
+ // an already-composed meta unchanged — see composeMeta's `framework`
669
+ // sentinel), so that's a no-op rather than a double-compose.
670
+ //
670
671
  // Non-React stacks (Eitri) tag the composed meta with their own
671
672
  // framework name; everyone else gets composeMeta's default
672
673
  // ("tanstack-start").
@@ -692,13 +693,10 @@ export function buildPlan(cwd: string, opts: CliOptions): GeneratorPlan[] {
692
693
  ),
693
694
  ];
694
695
  if (!opts.skipApps) {
695
- const scopeDir = path.join(cwd, "node_modules", "@decocms");
696
- let names: string[] = [];
697
- try {
698
- names = fs.readdirSync(scopeDir).filter((n) => n.startsWith("apps-"));
699
- } catch {}
700
- for (const name of names.sort()) {
701
- entries.push(...walkTree(cwd, path.join(scopeDir, name, "src"), [".ts", ".tsx"]));
696
+ for (const [name, dir] of listScopedPackages(cwd, "@decocms")) {
697
+ if (name.startsWith("@decocms/apps-")) {
698
+ entries.push(...walkTree(cwd, path.join(dir, "src"), [".ts", ".tsx"]));
699
+ }
702
700
  }
703
701
  }
704
702
  return sortEntries(entries);
@@ -83,7 +83,7 @@ describe("pickWinner", () => {
83
83
  });
84
84
 
85
85
  it("prefers higher decode-pass count when path-status matches", () => {
86
- // The lebiscuit reproduction case: a stale single-encoded leftover with a
86
+ // A production reproduction case: a stale single-encoded leftover with a
87
87
  // newer mtime and larger size loses to the bot's double-encoded fresh file.
88
88
  const stale = cand({
89
89
  file: "pages-Home%20-%20LB-618509.json",
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * CI has no Worker KV binding, so the fast-deploy sync/migrate scripts write to
5
5
  * KV over the REST API instead. Only the operations the scripts need — single-key
6
- * GET/PUT/DELETE plus prefix LIST (for per-deployment GC) — are implemented.
6
+ * GET/PUT/DELETE, prefix LIST (for per-deployment GC), and BULK put/delete (for
7
+ * the per-path redirect keys, which number in the tens of thousands) — are
8
+ * implemented.
7
9
  *
8
10
  * Auth/config via env (read by the scripts, passed to `createKvRestClient`):
9
11
  * - CF_ACCOUNT_ID Cloudflare account id (or CLOUDFLARE_ACCOUNT_ID)
@@ -37,6 +39,21 @@ export interface KvRestClient {
37
39
  delete(key: string): Promise<void>;
38
40
  /** List key names, optionally filtered by prefix. Follows pagination. */
39
41
  list(prefix?: string): Promise<string[]>;
42
+ /** Write many keys. Chunked to the API's per-request ceiling. A site can have
43
+ * tens of thousands of redirects; one PUT each would be as many round trips. */
44
+ putMany(entries: Array<{ key: string; value: string }>): Promise<void>;
45
+ /** Delete many keys. Same chunking. */
46
+ deleteMany(keys: string[]): Promise<void>;
47
+ }
48
+
49
+ /** Cloudflare's per-request ceiling for the bulk KV endpoints. */
50
+ const BULK_CHUNK = 10_000;
51
+
52
+ /** Split `items` into chunks of at most `size`. */
53
+ function chunk<T>(items: T[], size: number): T[][] {
54
+ const out: T[][] = [];
55
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
56
+ return out;
40
57
  }
41
58
 
42
59
  const DEFAULT_BASE = "https://api.cloudflare.com/client/v4";
@@ -108,6 +125,37 @@ export function createKvRestClient(config: KvRestConfig): KvRestClient {
108
125
  }
109
126
  },
110
127
 
128
+ async putMany(entries) {
129
+ for (const batch of chunk(entries, BULK_CHUNK)) {
130
+ const res = await fetchImpl(`${root}/bulk`, {
131
+ method: "PUT",
132
+ headers: { ...authHeaders, "Content-Type": "application/json" },
133
+ body: JSON.stringify(batch),
134
+ });
135
+ if (!res.ok) {
136
+ throw new Error(
137
+ `KV bulk PUT (${batch.length} keys) failed: ${res.status} ${await res.text()}`,
138
+ );
139
+ }
140
+ }
141
+ },
142
+
143
+ async deleteMany(keys) {
144
+ for (const batch of chunk(keys, BULK_CHUNK)) {
145
+ const res = await fetchImpl(`${root}/bulk`, {
146
+ method: "DELETE",
147
+ headers: { ...authHeaders, "Content-Type": "application/json" },
148
+ body: JSON.stringify(batch),
149
+ });
150
+ // 404 is fine — the keys are already gone (idempotent delete).
151
+ if (!res.ok && res.status !== 404) {
152
+ throw new Error(
153
+ `KV bulk DELETE (${batch.length} keys) failed: ${res.status} ${await res.text()}`,
154
+ );
155
+ }
156
+ }
157
+ },
158
+
111
159
  async list(prefix) {
112
160
  const names: string[] = [];
113
161
  let cursor: string | undefined;
@@ -0,0 +1,42 @@
1
+ import fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+
5
+ /** Search from the consuming site, including ancestor node_modules directories
6
+ * used by workspace hoisting. Directory lookup also works with source-only
7
+ * checkouts and packages whose exports expose neither the root nor package.json. */
8
+ function moduleDirectories(cwd: string, name: string): string[] {
9
+ return createRequire(path.resolve(cwd, "package.json")).resolve.paths(name) ?? [];
10
+ }
11
+
12
+ export function resolvePackageDirectory(cwd: string, name: string): string | null {
13
+ for (const modules of moduleDirectories(cwd, name)) {
14
+ const dir = path.join(modules, name);
15
+ if (fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory()) return dir;
16
+ }
17
+ return null;
18
+ }
19
+
20
+ /** Installed packages in a scope, keyed by full package name. The nearest
21
+ * copy wins, matching resolvePackageDirectory even with mixed local/hoisted
22
+ * installs; shadowed ancestor copies must not enter codegen fingerprints. */
23
+ export function listScopedPackages(cwd: string, scope: string): Map<string, string> {
24
+ const packages = new Map<string, string>();
25
+ for (const modules of moduleDirectories(cwd, `${scope}/_`)) {
26
+ const scopeDir = path.join(modules, scope);
27
+ let names: string[];
28
+ try {
29
+ names = fs.readdirSync(scopeDir);
30
+ } catch {
31
+ continue;
32
+ }
33
+ for (const name of names) {
34
+ const fullName = `${scope}/${name}`;
35
+ const dir = path.join(scopeDir, name);
36
+ if (!packages.has(fullName) && fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory()) {
37
+ packages.set(fullName, dir);
38
+ }
39
+ }
40
+ }
41
+ return new Map([...packages].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
42
+ }
@@ -0,0 +1,18 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { resolvePackageDirectory } from "./installedPackages";
4
+
5
+ /** Both the orchestrator and its child must parse the same invoke contract.
6
+ * Resolve the package first so a local copy without invoke.ts cannot silently
7
+ * pick up a different contract from a shadowed ancestor package. */
8
+ export function resolveInvokeSource(cwd: string, appsDir: string | null): string | null {
9
+ const root = appsDir
10
+ ? path.resolve(cwd, appsDir)
11
+ : resolvePackageDirectory(cwd, "@decocms/apps-vtex");
12
+ if (!root) return null;
13
+ for (const dir of [root, path.join(root, "src")]) {
14
+ const file = path.join(dir, "invoke.ts");
15
+ if (fs.existsSync(file)) return file;
16
+ }
17
+ return null;
18
+ }
@@ -12,9 +12,12 @@ import {
12
12
  computeRevision,
13
13
  DEPLOYMENTS_KEY,
14
14
  LIVE_KEY,
15
+ redirectKey,
16
+ redirectPrefix,
15
17
  revisionKey,
16
18
  snapshotKey,
17
19
  } from "@decocms/blocks/cms";
20
+ import type { ExactRedirect } from "@decocms/blocks/sdk/redirects";
18
21
  import type { KvRestClient } from "./cf-kv-rest";
19
22
 
20
23
  export interface Snapshot {
@@ -71,6 +74,42 @@ export async function verifySnapshotInKv(
71
74
  return { ok: true };
72
75
  }
73
76
 
77
+ /**
78
+ * Reconcile deployment `id`'s exact redirects in KV: write the ones that are
79
+ * new or changed, delete the ones that no longer exist.
80
+ *
81
+ * KV has no "replace everything under this prefix" operation, so the desired
82
+ * set is diffed against what is actually stored. The source of truth for
83
+ * "what's there now" is a prefix LIST rather than a manifest key — a manifest
84
+ * is one more thing that can drift out of sync with reality, and the list is
85
+ * free enough at build time.
86
+ *
87
+ * Updates are not detected by value: re-PUTting an unchanged key is one bulk
88
+ * write, while reading every current value back to compare would be one GET per
89
+ * key. So every desired rule is written, and only deletions need the diff.
90
+ */
91
+ export async function syncRedirectsToKv(
92
+ client: KvRestClient,
93
+ redirects: ExactRedirect[],
94
+ id: string,
95
+ ): Promise<{ written: number; deleted: number }> {
96
+ const desired = new Map(
97
+ redirects.map((r) => [redirectKey(id, r.path), JSON.stringify({ to: r.to, status: r.status })]),
98
+ );
99
+
100
+ const existing = await client.list(redirectPrefix(id));
101
+ const stale = existing.filter((key) => !desired.has(key));
102
+
103
+ if (desired.size > 0) {
104
+ await client.putMany([...desired].map(([key, value]) => ({ key, value })));
105
+ }
106
+ if (stale.length > 0) {
107
+ await client.deleteMany(stale);
108
+ }
109
+
110
+ return { written: desired.size, deleted: stale.length };
111
+ }
112
+
74
113
  /** Point `index:live` at deployment `id` (post-activation, from the deploy step). */
75
114
  export async function setLiveDeployment(client: KvRestClient, id: string): Promise<void> {
76
115
  await client.put(LIVE_KEY, id);
@@ -121,6 +160,11 @@ export async function recordAndGcDeployment(
121
160
  }
122
161
  await client.delete(snapshotKey(entry.id));
123
162
  await client.delete(revisionKey(entry.id));
163
+ // The per-path redirect keys are keyed by deployment too, so they are
164
+ // part of this snapshot and must go with it — otherwise every deploy
165
+ // leaks tens of thousands of orphans into the namespace forever.
166
+ const staleRedirects = await client.list(redirectPrefix(entry.id));
167
+ if (staleRedirects.length > 0) await client.deleteMany(staleRedirects);
124
168
  pruned.push(entry.id);
125
169
  }
126
170
  await client.put(DEPLOYMENTS_KEY, JSON.stringify([...keptOld, ...recent]));
@@ -6,7 +6,7 @@
6
6
  * the fast-deploy CI scripts (`migrate-blocks-to-kv.ts`, `sync-blocks-to-kv.ts`)
7
7
  * produce a decofile byte-identical to the bundled `blocks.gen` snapshot.
8
8
  *
9
- * Reuses `scripts/lib/blocks-dedupe.ts`.
9
+ * Reuses `scripts/lib/blocks-dedupe.ts` and `scripts/lib/csv-redirects.ts`.
10
10
  */
11
11
 
12
12
  import * as fs from "node:fs";
@@ -18,6 +18,7 @@ import {
18
18
  decodeBlockNameWithPasses,
19
19
  mergeCandidates,
20
20
  } from "./blocks-dedupe";
21
+ import { buildCsvRedirectBlocks } from "./csv-redirects";
21
22
 
22
23
  export interface ReadDecofileResult {
23
24
  /** Decoded block key → parsed block JSON. */
@@ -66,5 +67,15 @@ export function readDecofileFromDir(blocksDir: string, opts: { silent?: boolean
66
67
  for (const [key, candidate] of Object.entries(winners)) {
67
68
  blocks[key] = candidate.parsed;
68
69
  }
69
- return { blocks, collisions };
70
+
71
+ // Synthetic `__csv_redirects__*` blocks, exactly as `generate-blocks` builds
72
+ // them. Without this the KV snapshot is NOT byte-identical to `blocks.gen`:
73
+ // a site whose redirects come from a CSV would lose every one of them the
74
+ // moment the bundled snapshot stops being the fallback (the fastDeploy stub
75
+ // makes KV the only source), turning migration 301s into silent 404s.
76
+ // Same precedence as the generator — a curated CMS redirect wins over CSV.
77
+ const csvBlocks = buildCsvRedirectBlocks(blocks, { blocksDir, silent: opts.silent });
78
+ const merged = Object.keys(csvBlocks).length ? { ...csvBlocks, ...blocks } : blocks;
79
+
80
+ return { blocks: merged, collisions };
70
81
  }
@@ -7,7 +7,7 @@
7
7
  * only the package dependency that needs to move.
8
8
  *
9
9
  * Mapping derived by hand against a real production site
10
- * (montecarlo-tanstack) — see the issue for the full worked table. Symbol
10
+ * (a production VTEX storefront) — see the issue for the full worked table. Symbol
11
11
  * renames and relocations mean a pure specifier-string rewrite is not
12
12
  * enough for every case; those are called out below.
13
13
  */
@@ -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
+ });