@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "8.0.0",
3
+ "version": "8.1.0-next.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": {
@@ -17,7 +17,9 @@
17
17
  "deco-audit-observability": "./scripts/audit-observability-config.ts",
18
18
  "deco-migrate-blocks-to-kv": "./scripts/migrate-blocks-to-kv.ts",
19
19
  "deco-sync-blocks-to-kv": "./scripts/sync-blocks-to-kv.ts",
20
- "deco-upgrade-6-to-7": "./scripts/upgrade-6-to-7.ts"
20
+ "deco-sync-blocks-bot": "./scripts/sync-blocks-bot.ts",
21
+ "deco-upgrade-6-to-7": "./scripts/upgrade-6-to-7.ts",
22
+ "deco-reconcile": "./scripts/reconcile.ts"
21
23
  },
22
24
  "//exports": "Deliberately narrow. ./generate is the ONE public module entry (unified orchestrator); ./generate-blocks stays only because @decocms/tanstack's vite plugin tsImports it (readBlockDelta + programmatic generateBlocks) — each surviving entry must name its consumer here. The other scripts remain shipped FILES (the orchestrator spawns them; sites' existing `tsx node_modules/@decocms/blocks-cli/scripts/generate-*.ts` invocations keep working) but are internal implementation details of ./generate, not module subpaths. CLIs are exposed via bin, not exports.",
23
25
  "exports": {
@@ -31,7 +33,7 @@
31
33
  "lint:unused": "knip"
32
34
  },
33
35
  "dependencies": {
34
- "@decocms/blocks": "8.0.0",
36
+ "@decocms/blocks": "8.1.0-next.0",
35
37
  "ts-morph": "^27.0.0",
36
38
  "tsx": "^4.22.5"
37
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,105 @@
1
+ import { BOT_UA_SUBSTRINGS, isBot } from "@decocms/blocks/cms";
2
+ import { DECO_MATCHERS_OVERRIDE_PARAM } from "@decocms/blocks/matchers/override";
3
+ import { SEGMENT_COOKIE } from "@decocms/blocks/sdk/flags";
4
+ import { describe, expect, it } from "vitest";
5
+ import { AUTH_COOKIE_PREFIXES, bypassExpression, cacheRuleset } from "./cdn-rules";
6
+
7
+ /**
8
+ * These are drift tests, not behaviour tests. The CDN rules and the Worker's
9
+ * cache key are two descriptions of the same segmentation; when they disagree,
10
+ * one visitor gets another's response. Each assertion below pins one dimension
11
+ * to the constant the Worker actually uses.
12
+ */
13
+ describe("CDN bypass expression tracks the worker cache key", () => {
14
+ const expr = bypassExpression();
15
+
16
+ it("bypasses authenticated visitors", () => {
17
+ // Without this, a logged-in request is answered from the anonymous CDN
18
+ // entry and the Worker never runs — no header can save it.
19
+ for (const name of AUTH_COOKIE_PREFIXES) {
20
+ expect(expr).toContain(`http.cookie contains "${name}"`);
21
+ }
22
+ });
23
+
24
+ it("bypasses the A/B cohort cookie by its real name (__abf)", () => {
25
+ expect(expr).toContain(`${SEGMENT_COOKIE}=`);
26
+ });
27
+
28
+ it("bypasses every bot UA the framework renders eagerly (__bot)", () => {
29
+ for (const ua of BOT_UA_SUBSTRINGS) {
30
+ expect(expr).toContain(ua);
31
+ // The list is only meaningful if it really is what isBot() matches.
32
+ expect(isBot(`Mozilla/5.0 (compatible; ${ua}/1.0)`)).toBe(true);
33
+ }
34
+ });
35
+
36
+ it("bypasses programmatic fetches (__fetch)", () => {
37
+ expect(expr).toContain("sec-fetch-dest");
38
+ });
39
+
40
+ it("bypasses matcher overrides by their real param name", () => {
41
+ expect(expr).toContain(DECO_MATCHERS_OVERRIDE_PARAM);
42
+ });
43
+
44
+ it("bypasses draft preview", () => {
45
+ expect(expr).toContain("__draft=");
46
+ expect(expr).toContain("__deco_draft");
47
+ });
48
+
49
+ it("is scoped to hostnames that opted in", () => {
50
+ // A shared zone: an unscoped rule would enable every site at once.
51
+ expect(expr).toContain("cf.hostname.metadata");
52
+ for (const rule of cacheRuleset().rules) {
53
+ expect(rule.expression).toContain("cf.hostname.metadata");
54
+ }
55
+ });
56
+ });
57
+
58
+ describe("cache ruleset", () => {
59
+ const [bypass, cache] = cacheRuleset().rules;
60
+
61
+ it("does not rely on rule order — the two rules are mutually exclusive", () => {
62
+ // Cloudflare's cache phase is LAST-match-wins for non-terminating actions,
63
+ // so a catch-all `cache: true` listed after `cache: false` silently
64
+ // overrides it and the whole bypass list becomes inert. An earlier version
65
+ // of this file had exactly that bug, and an earlier version of THIS test
66
+ // asserted "bypass is evaluated first", certifying it. Correctness must
67
+ // come from the expressions, not the array order.
68
+ expect(bypass.action_parameters.cache).toBe(false);
69
+ expect(cache.action_parameters.cache).toBe(true);
70
+
71
+ const clauses = bypass.expression.slice(bypass.expression.indexOf(") and (") + 7, -1);
72
+ expect(cache.expression).toContain(`and not (${clauses})`);
73
+ });
74
+
75
+ it("does not bypass /_serverFn on sec-fetch-dest, which is what it exists to cache", () => {
76
+ // Every /_serverFn call is an XHR and sends `sec-fetch-dest: empty`. A bare
77
+ // clause would bypass exactly the traffic `serverfn-segment` caches,
78
+ // reducing the feature to a no-op. `buildCacheKey` excludes server-fn paths
79
+ // from `__fetch` for the same reason.
80
+ expect(bypass.expression).toContain('not starts_with(http.request.uri.path, "/_serverFn/")');
81
+ expect(bypass.expression).toContain('not starts_with(http.request.uri.path, "/_server/")');
82
+ });
83
+
84
+ it("keys by device, since deviceSpecificKeys defaults to true", () => {
85
+ expect(cache.action_parameters.cache_key?.cache_by_device_type).toBe(true);
86
+ });
87
+
88
+ it("takes the TTL from the origin, not a copy of the profile table", () => {
89
+ expect(cache.action_parameters.edge_ttl?.mode).toBe("respect_origin");
90
+ });
91
+ });
92
+
93
+ describe("geo in the cache key", () => {
94
+ it("is off by default — a site without regional content must not pay for it", () => {
95
+ const [, cache] = cacheRuleset().rules;
96
+ expect(cache.action_parameters.cache_key?.custom_key).toBeUndefined();
97
+ });
98
+
99
+ it("is opt-in, for sites whose content actually varies by region", () => {
100
+ const [, cache] = cacheRuleset({ geo: true }).rules;
101
+ expect(cache.action_parameters.cache_key?.custom_key?.user?.geo).toBe(true);
102
+ // device stays keyed either way
103
+ expect(cache.action_parameters.cache_key?.cache_by_device_type).toBe(true);
104
+ });
105
+ });
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Generate (and optionally apply) the Cloudflare Cache Rules that let the CDN
4
+ * serve deco storefronts without invoking the Worker.
5
+ *
6
+ * ## Why this is generated and not hand-written
7
+ *
8
+ * The Worker keys its edge cache on a SYNTHETIC Request carrying
9
+ * `__seg`/`__cf_device`/`__cf_geo`/`__bot`/`__fetch`/`__abf` (`buildCacheKey`
10
+ * in `@decocms/tanstack`). Cloudflare's CDN keys on the raw URL and ignores
11
+ * `Vary` beyond `Accept-Encoding`. Every dimension the CDN cannot reproduce has
12
+ * to become a bypass here — and each one corresponds to a constant on the
13
+ * Worker side. Two hand-maintained copies of that list drift, and the failure
14
+ * mode is not a slow page, it is one visitor being served another's response.
15
+ * So the expressions below are DERIVED from the same constants the Worker uses.
16
+ *
17
+ * ## Division of labour
18
+ *
19
+ * The Worker stays the source of truth for *whether* a response may be cached:
20
+ * private routes, logged-in requests, drafts and set-cookie responses already
21
+ * go out with `CDN-Cache-Control: no-store`, and Cloudflare honours that. So
22
+ * these rules deliberately do NOT enumerate private paths — a site adding
23
+ * `registerPrivatePaths([...])` propagates to the CDN on its own.
24
+ *
25
+ * What the rules must cover is the narrower case the header cannot reach: when
26
+ * the CDN answers from cache WITHOUT consulting the Worker, and would hand one
27
+ * visitor an entry that belongs to another segment.
28
+ *
29
+ * ## Scoping in a shared zone
30
+ *
31
+ * Sites are custom hostnames under one deco zone (Cloudflare for SaaS), so a
32
+ * rule applies to every site at once. Enablement therefore rides on per-hostname
33
+ * custom metadata rather than a rule per site:
34
+ *
35
+ * curl "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/custom_hostnames/$ID" \
36
+ * --request PATCH --header "Authorization: Bearer $TOKEN" \
37
+ * --json '{"custom_metadata": {"deco_cdn_html": "on"}}'
38
+ *
39
+ * Rolling out to a site is that PATCH; rolling back is setting it to "off".
40
+ * Neither touches the ruleset.
41
+ *
42
+ * ## Why zone rules, and not Workers Cache
43
+ *
44
+ * `/_serverFn` is already handled without touching the zone: the client carries
45
+ * a segment marker in the URL (`sdk/cdnSegment`), which makes the URL a
46
+ * complete key. HTML cannot do that — the initial navigation is a browser
47
+ * request with no hook to attach a marker to.
48
+ *
49
+ * Workers Cache does not close the gap either. Its documented mechanisms are
50
+ * `Vary` and per-entrypoint disable, and neither expresses "not for a logged-in
51
+ * visitor": `Vary: Cookie` compares verbatim, so every visitor's analytics
52
+ * cookies produce a distinct entry and the cache is dead on arrival. There is
53
+ * no per-request opt-out before the Worker runs — and on a cache HIT the Worker
54
+ * does not run, so the `no-store` it would have emitted never exists.
55
+ *
56
+ * Zone rules are the only layer that can decline BEFORE the Worker, which is
57
+ * exactly what a logged-in visitor needs.
58
+ *
59
+ * Usage:
60
+ * tsx scripts/cdn-rules.ts # print the ruleset as JSON
61
+ * tsx scripts/cdn-rules.ts --expression # print just the bypass expression
62
+ * tsx scripts/cdn-rules.ts --geo # include geo in the cache key
63
+ */
64
+
65
+ import { BOT_UA_SUBSTRINGS } from "@decocms/blocks/cms";
66
+ import { DECO_MATCHERS_OVERRIDE_PARAM } from "@decocms/blocks/matchers/override";
67
+ import { SEGMENT_COOKIE } from "@decocms/blocks/sdk/flags";
68
+
69
+ /** Metadata key read off the custom hostname to enable CDN caching per site. */
70
+ export const CDN_ENABLED_METADATA_KEY = "deco_cdn_html";
71
+
72
+ /**
73
+ * Auth cookie names that mean "this visitor must never be served a shared CDN
74
+ * entry". VTEX sets both the bare name and an account-suffixed variant
75
+ * (`VtexIdclientAutCookie_<account>`); matching on the prefix covers both.
76
+ */
77
+ export const AUTH_COOKIE_PREFIXES = ["VtexIdclientAutCookie"];
78
+
79
+ const enabled = `lookup_json_string(cf.hostname.metadata, "${CDN_ENABLED_METADATA_KEY}") eq "on"`;
80
+
81
+ /** Path prefix for TanStack server-function requests. */
82
+ const SERVER_FN_PREFIXES = ["/_serverFn/", "/_server/"];
83
+
84
+ /**
85
+ * The individual bypass clauses, each annotated with the `buildCacheKey`
86
+ * dimension it mirrors. If you add a dimension to the cache key, it belongs
87
+ * here too.
88
+ */
89
+ function bypassClauses(): string[] {
90
+ const notServerFn = SERVER_FN_PREFIXES.map(
91
+ (p) => `not starts_with(http.request.uri.path, "${p}")`,
92
+ ).join(" and ");
93
+
94
+ return [
95
+ // Worker key: no equivalent — an authenticated visitor would otherwise be
96
+ // served the cached anonymous entry without the Worker ever running. This
97
+ // is the single most important clause in the file.
98
+ ...AUTH_COOKIE_PREFIXES.map((name) => `http.cookie contains "${name}"`),
99
+
100
+ // Worker key: `__abf` — the sticky A/B cohort cookie.
101
+ `http.cookie contains "${SEGMENT_COOKIE}="`,
102
+
103
+ // Worker key: `__bot` — bots render every section eagerly (~10x payload).
104
+ `lower(http.user_agent) matches "${BOT_UA_SUBSTRINGS.join("|")}"`,
105
+
106
+ // Worker key: `__fetch` — programmatic fetches also render eagerly.
107
+ //
108
+ // Carved out for `/_serverFn`, which ALWAYS sends `sec-fetch-dest: empty`
109
+ // (it is an XHR). Without the carve-out this single clause would bypass
110
+ // exactly the traffic `cdnCacheControl: "serverfn-segment"` exists to
111
+ // cache, silently reducing the whole feature to a no-op. `buildCacheKey`
112
+ // excludes server-fn paths from `__fetch` for the same reason, so this
113
+ // mirrors it rather than diverging.
114
+ `(http.request.headers["sec-fetch-dest"][0] eq "empty" and ${notServerFn})`,
115
+
116
+ // Worker: `isCacheable()` bypasses these outright. Duplicated here so a
117
+ // draft never depends on the response header alone.
118
+ 'http.request.uri.query contains "__draft="',
119
+ 'http.request.uri.query contains "__deco_preview"',
120
+ 'http.request.uri.query contains "pathTemplate"',
121
+ `http.request.uri.query contains "${DECO_MATCHERS_OVERRIDE_PARAM}"`,
122
+ `any(http.request.headers.names[*] eq "${DECO_MATCHERS_OVERRIDE_PARAM}")`,
123
+ 'http.cookie contains "__deco_draft"',
124
+ ];
125
+ }
126
+
127
+ /** Requests that must never be served from a shared CDN entry. */
128
+ export function bypassExpression(): string {
129
+ return `(${enabled}) and (${bypassClauses().join(" or ")})`;
130
+ }
131
+
132
+ /**
133
+ * The ruleset.
134
+ *
135
+ * The two rules are MUTUALLY EXCLUSIVE by expression, not by ordering. That is
136
+ * deliberate and load-bearing: Cloudflare's cache phase is **last-match-wins**
137
+ * for non-terminating actions like `set_cache_settings`, so a catch-all
138
+ * `cache: true` rule listed after a `cache: false` one silently overrides it.
139
+ * Scoping rule 2 with `and not (<bypass clauses>)` means correctness no longer
140
+ * depends on rule order at all — reorder them freely, or apply them via an API
141
+ * that does not preserve order, and the behaviour is unchanged.
142
+ *
143
+ * https://developers.cloudflare.com/cache/how-to/cache-rules/order/
144
+ *
145
+ * Note what is NOT set here: the edge TTL. `respect_origin` keeps the TTL
146
+ * coming from the `CDN-Cache-Control` the Worker already derives per cache
147
+ * profile, so the profile table stays in one place.
148
+ */
149
+ /**
150
+ * @param opts.geo Include country/region in the cache key. Required for a site
151
+ * whose content varies by region (a `website/matchers/location.ts` in the
152
+ * decofile). Leave it off otherwise: it multiplies the number of entries that
153
+ * have to warm, for content that is identical across them.
154
+ *
155
+ * NOTE: this keys on country+region. A site whose matchers discriminate by
156
+ * CITY or coordinates needs finer granularity than this, and enabling CDN
157
+ * caching there serves the wrong variant — worse than today, since the Worker
158
+ * at least evaluates the matcher on every request.
159
+ */
160
+ export function cacheRuleset(opts: { geo?: boolean } = {}) {
161
+ const clauses = bypassClauses().join(" or ");
162
+
163
+ return {
164
+ rules: [
165
+ {
166
+ description: "deco: bypass CDN for segment-sensitive requests",
167
+ expression: `(${enabled}) and (${clauses})`,
168
+ action: "set_cache_settings",
169
+ action_parameters: { cache: false },
170
+ },
171
+ {
172
+ description: "deco: cache by device type, TTL from origin",
173
+ expression: `(${enabled}) and not (${clauses})`,
174
+ action: "set_cache_settings",
175
+ action_parameters: {
176
+ cache: true,
177
+ cache_key: {
178
+ cache_by_device_type: true,
179
+ ...(opts.geo ? { custom_key: { user: { geo: true } } } : {}),
180
+ },
181
+ edge_ttl: { mode: "respect_origin" },
182
+ },
183
+ },
184
+ ],
185
+ };
186
+ }
187
+
188
+ if (import.meta.url === `file://${process.argv[1]}`) {
189
+ const expressionOnly = process.argv.includes("--expression");
190
+ const geo = process.argv.includes("--geo");
191
+ console.log(expressionOnly ? bypassExpression() : JSON.stringify(cacheRuleset({ geo }), null, 2));
192
+ }
@@ -123,15 +123,15 @@ function showHelp() {
123
123
  npx tsx scripts/deco-migrate-cli.ts https://github.com/org/my-site
124
124
 
125
125
  ${dim("# Migrate local dir, compare against golden reference:")}
126
- npx tsx scripts/deco-migrate-cli.ts ./casaevideo \\
127
- --ref ./casaevideo-storefront
126
+ npx tsx scripts/deco-migrate-cli.ts ./acme \\
127
+ --ref ./acme-storefront
128
128
 
129
129
  ${dim("# Quick re-run (wipe previous output first):")}
130
- npx tsx scripts/deco-migrate-cli.ts ./casaevideo \\
131
- -o ./casaevideo-migrated --clean
130
+ npx tsx scripts/deco-migrate-cli.ts ./acme \\
131
+ -o ./acme-migrated --clean
132
132
 
133
133
  ${dim("# Dry run to preview what would change:")}
134
- npx tsx scripts/deco-migrate-cli.ts ./casaevideo --dry-run -v
134
+ npx tsx scripts/deco-migrate-cli.ts ./acme --dry-run -v
135
135
  `);
136
136
  }
137
137
 
@@ -429,8 +429,7 @@ async function main() {
429
429
  console.log(`\n ${bold("Next steps:")}`);
430
430
  console.log(` cd ${outputDir}`);
431
431
  console.log(` npm install`);
432
- console.log(` npm run generate:blocks`);
433
- console.log(` npm run generate:schema`);
432
+ console.log(` npm run generate`);
434
433
  console.log(` npx tsr generate`);
435
434
  console.log(` npm run dev`);
436
435
  }
@@ -9,12 +9,15 @@ import {
9
9
  revisionKey,
10
10
  snapshotKey,
11
11
  } from "@decocms/blocks/cms";
12
+ import { splitExactRedirects } from "@decocms/blocks/sdk/redirects";
12
13
  import { createKvRestClient, type KvRestClient, kvConfigFromEnv } from "./lib/cf-kv-rest";
14
+ import { readDecofileFromDir } from "./lib/read-decofile";
13
15
  import { kvNamespaceIdFromToml, kvNamespaceIdFromWrangler } from "./lib/wrangler-config";
14
16
  import {
15
17
  buildSnapshot,
16
18
  recordAndGcDeployment,
17
19
  setLiveDeployment,
20
+ syncRedirectsToKv,
18
21
  verifySnapshotInKv,
19
22
  writeSnapshotToKv,
20
23
  } from "./lib/kv-snapshot";
@@ -45,6 +48,14 @@ function makeClient(initial: Record<string, string> = {}) {
45
48
  Promise.resolve(
46
49
  [...store.keys()].filter((k) => !prefix || k.startsWith(prefix)),
47
50
  ),
51
+ putMany: (entries) => {
52
+ for (const e of entries) store.set(e.key, e.value);
53
+ return Promise.resolve();
54
+ },
55
+ deleteMany: (keys) => {
56
+ for (const k of keys) store.delete(k);
57
+ return Promise.resolve();
58
+ },
48
59
  };
49
60
  return { client, store, putOrder };
50
61
  }
@@ -341,3 +352,164 @@ describe("sync-helpers", () => {
341
352
  expect(paths).not.toContain(undefined);
342
353
  });
343
354
  });
355
+
356
+ describe("readDecofileFromDir — CSV redirects", () => {
357
+ /** A site tree: `.deco/blocks/*.json` + `public/*.csv`. Returns the blocks dir. */
358
+ function makeSite(blocks: Record<string, unknown>, csv: Record<string, string>): string {
359
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "decofile-csv-"));
360
+ const blocksDir = path.join(root, ".deco", "blocks");
361
+ fs.mkdirSync(blocksDir, { recursive: true });
362
+ fs.mkdirSync(path.join(root, "public"), { recursive: true });
363
+ for (const [name, value] of Object.entries(blocks)) {
364
+ fs.writeFileSync(path.join(blocksDir, name), JSON.stringify(value));
365
+ }
366
+ for (const [name, value] of Object.entries(csv)) {
367
+ fs.writeFileSync(path.join(root, "public", name), value);
368
+ }
369
+ return blocksDir;
370
+ }
371
+
372
+ it("materializes a CSV loader nested under site.routes[] into a top-level block", () => {
373
+ // The shape that broke production: the CSV loader is nested inside
374
+ // `site.json -> routes[]`, where `loadRedirects` (top-level only) can't see
375
+ // it, and the CSV itself was only ever read by the unported Fresh loader.
376
+ const blocksDir = makeSite(
377
+ {
378
+ "site.json": {
379
+ __resolveType: "site/apps/site.ts",
380
+ routes: [{ __resolveType: "website/loaders/redirectsFromCsv.ts", from: "static/r.csv" }],
381
+ },
382
+ },
383
+ { "r.csv": "from,to,type\n/old,/new,permanent\n/tmp,/other\n" },
384
+ );
385
+
386
+ const { blocks } = readDecofileFromDir(blocksDir, { silent: true });
387
+
388
+ expect(blocks["__csv_redirects__r.csv"]).toEqual({
389
+ __resolveType: "website/loaders/redirects.ts",
390
+ redirects: [
391
+ { from: "/old", to: "/new", type: "permanent" },
392
+ { from: "/tmp", to: "/other", type: "temporary" },
393
+ ],
394
+ });
395
+ // The real blocks are untouched.
396
+ expect(blocks.site).toBeDefined();
397
+ });
398
+
399
+ it("splits CSV-derived exact redirects out of the snapshot", () => {
400
+ const blocksDir = makeSite(
401
+ {
402
+ "site.json": {
403
+ __resolveType: "site/apps/site.ts",
404
+ routes: [{ __resolveType: "website/loaders/redirectsFromCsv.ts", from: "static/r.csv" }],
405
+ },
406
+ },
407
+ { "r.csv": "from,to,type\n/old,/new,permanent\n/g/*,/h/*\n" },
408
+ );
409
+ const { blocks } = readDecofileFromDir(blocksDir, { silent: true });
410
+ const split = splitExactRedirects(blocks);
411
+
412
+ expect(split.exact).toEqual([{ path: "/old", to: "/new", status: 301 }]);
413
+ expect(JSON.stringify(split.blocks)).not.toContain('"/old"');
414
+ expect(split.blocks["__csv_redirects__r.csv"]).toMatchObject({ redirects: [{ from: "/g/*" }] });
415
+ });
416
+
417
+ it("lets a real block win over the synthetic one on a key collision", () => {
418
+ const blocksDir = makeSite(
419
+ {
420
+ "site.json": {
421
+ routes: [{ __resolveType: "website/loaders/redirectsFromCsv.ts", from: "r.csv" }],
422
+ },
423
+ // Decodes to exactly the synthetic key the CSV materializer produces.
424
+ "__csv_redirects__r.csv.json": { curated: true },
425
+ },
426
+ { "r.csv": "/old,/new\n" },
427
+ );
428
+
429
+ const { blocks } = readDecofileFromDir(blocksDir, { silent: true });
430
+
431
+ // `{ ...csv, ...blocks }` — the curated block wins, never the synthetic one.
432
+ expect(blocks["__csv_redirects__r.csv"]).toEqual({ curated: true });
433
+ });
434
+
435
+ it("is a no-op when no CSV loader is referenced", () => {
436
+ const blocksDir = makeSite({ "site.json": { __resolveType: "site/apps/site.ts" } }, {});
437
+ const { blocks } = readDecofileFromDir(blocksDir, { silent: true });
438
+ expect(Object.keys(blocks)).toEqual(["site"]);
439
+ });
440
+ });
441
+
442
+ describe("syncRedirectsToKv", () => {
443
+ /** In-memory client with the bulk ops the redirect sync uses. */
444
+ function makeBulkClient(initial: Record<string, string> = {}) {
445
+ const store = new Map(Object.entries(initial));
446
+ const client = {
447
+ get: (k: string) => Promise.resolve(store.get(k) ?? null),
448
+ put: (k: string, v: string) => {
449
+ store.set(k, v);
450
+ return Promise.resolve();
451
+ },
452
+ delete: (k: string) => {
453
+ store.delete(k);
454
+ return Promise.resolve();
455
+ },
456
+ list: (prefix?: string) =>
457
+ Promise.resolve([...store.keys()].filter((k) => !prefix || k.startsWith(prefix))),
458
+ putMany: (entries: Array<{ key: string; value: string }>) => {
459
+ for (const e of entries) store.set(e.key, e.value);
460
+ return Promise.resolve();
461
+ },
462
+ deleteMany: (keys: string[]) => {
463
+ for (const k of keys) store.delete(k);
464
+ return Promise.resolve();
465
+ },
466
+ };
467
+ return { client: client as unknown as KvRestClient, store };
468
+ }
469
+
470
+ const rule = (path: string, to: string, status: 301 | 302 = 301) => ({ path, to, status });
471
+
472
+ it("adds new rules under redirect:<id>:<path>", async () => {
473
+ const { client, store } = makeBulkClient();
474
+ const out = await syncRedirectsToKv(client, [rule("/old", "/new")], ID);
475
+
476
+ expect(out).toEqual({ written: 1, deleted: 0 });
477
+ expect(store.get(`redirect:${ID}:/old`)).toBe('{"to":"/new","status":301}');
478
+ });
479
+
480
+ it("updates a changed target in place", async () => {
481
+ const { client, store } = makeBulkClient({
482
+ [`redirect:${ID}:/old`]: '{"to":"/stale","status":302}',
483
+ });
484
+ await syncRedirectsToKv(client, [rule("/old", "/fresh")], ID);
485
+ expect(store.get(`redirect:${ID}:/old`)).toBe('{"to":"/fresh","status":301}');
486
+ });
487
+
488
+ it("removes rules that no longer exist", async () => {
489
+ // The reason this diffs against a prefix LIST: KV has no "replace
490
+ // everything under this prefix", so a deleted rule would redirect forever.
491
+ const { client, store } = makeBulkClient({
492
+ [`redirect:${ID}:/keep`]: '{"to":"/a","status":301}',
493
+ [`redirect:${ID}:/gone`]: '{"to":"/b","status":301}',
494
+ });
495
+ const out = await syncRedirectsToKv(client, [rule("/keep", "/a")], ID);
496
+
497
+ expect(out).toEqual({ written: 1, deleted: 1 });
498
+ expect(store.has(`redirect:${ID}:/gone`)).toBe(false);
499
+ expect(store.has(`redirect:${ID}:/keep`)).toBe(true);
500
+ });
501
+
502
+ it("never touches another deployment's keys", async () => {
503
+ const { client, store } = makeBulkClient({
504
+ "redirect:other-sha:/gone": '{"to":"/x","status":301}',
505
+ });
506
+ await syncRedirectsToKv(client, [], ID);
507
+ expect(store.has("redirect:other-sha:/gone")).toBe(true);
508
+ });
509
+
510
+ it("does not disturb the decofile keys", async () => {
511
+ const { client, store } = makeBulkClient({ [snapshotKey(ID)]: "{}" });
512
+ await syncRedirectsToKv(client, [rule("/a", "/b")], ID);
513
+ expect(store.has(snapshotKey(ID))).toBe(true);
514
+ });
515
+ });
@@ -1,9 +1,20 @@
1
+ import * as cp from "node:child_process";
1
2
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { tmpdir } from "node:os";
3
4
  import path from "node:path";
4
5
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
6
  import { readBlockDelta } from "./generate-blocks";
6
7
 
8
+ const SCRIPT = path.resolve(__dirname, "generate-blocks.ts");
9
+
10
+ function runGenerator(
11
+ args: string[],
12
+ opts: { cwd?: string } = {},
13
+ ): { stdout: string; stderr: string; code: number } {
14
+ const r = cp.spawnSync("npx", ["tsx", SCRIPT, ...args], { encoding: "utf8", cwd: opts.cwd });
15
+ return { stdout: r.stdout || "", stderr: r.stderr || "", code: r.status ?? 0 };
16
+ }
17
+
7
18
  describe("readBlockDelta", () => {
8
19
  let dir: string;
9
20
 
@@ -27,10 +27,6 @@
27
27
  * --out-file by swapping its extension, so passing --out-file
28
28
  * moves both artifacts together)
29
29
  *
30
- * If no `--out-file` is passed and the OLD default (src/server/cms/blocks.gen.ts)
31
- * still exists on disk, a one-line legacy warning is printed to stderr and the
32
- * NEW default is written anyway — see lib/legacyArtifact.ts.
33
- *
34
30
  * Programmatic:
35
31
  * import { generateBlocks } from "@decocms/blocks-cli/generate-blocks";
36
32
  * await generateBlocks({ blocksDir, outFile });
@@ -49,7 +45,6 @@ import {
49
45
  singleDecodeBlockName,
50
46
  } from "./lib/blocks-dedupe";
51
47
  import { buildCsvRedirectBlocks } from "./lib/csv-redirects";
52
- import { warnLegacyArtifact } from "./lib/legacyArtifact";
53
48
 
54
49
  const TS_STUB = [
55
50
  "// Auto-generated — thin wrapper around blocks.gen.json.",
@@ -321,13 +316,8 @@ if (isMainModule()) {
321
316
  };
322
317
 
323
318
  const blocksDir = path.resolve(process.cwd(), arg("blocks-dir", ".deco/blocks"));
324
- const OUT_FILE_EXPLICIT = args.includes("--out-file");
325
319
  const NEW_DEFAULT_OUT_FILE = ".deco/blocks.gen.ts";
326
- const OLD_DEFAULT_OUT_FILE = "src/server/cms/blocks.gen.ts";
327
320
  const outFile = path.resolve(process.cwd(), arg("out-file", NEW_DEFAULT_OUT_FILE));
328
- if (!OUT_FILE_EXPLICIT && fs.existsSync(path.resolve(process.cwd(), OLD_DEFAULT_OUT_FILE))) {
329
- warnLegacyArtifact(OLD_DEFAULT_OUT_FILE, NEW_DEFAULT_OUT_FILE);
330
- }
331
321
 
332
322
  generateBlocks({ blocksDir, outFile }).catch((err) => {
333
323
  console.error(err);
@@ -197,8 +197,8 @@ describe("generate-invoke.ts — default --apps-dir resolution", () => {
197
197
  // at node_modules/@decocms/apps-vtex/src/invoke.ts — NOT at the package
198
198
  // root. The old default only probed the root, never resolved on a site
199
199
  // with npm-installed packages, and forced sites to pass
200
- // `--apps-dir node_modules/@decocms/apps-vtex/src` by hand (granadobr's
201
- // migration workaround). The default must probe <pkg>/invoke.ts first,
200
+ // `--apps-dir node_modules/@decocms/apps-vtex/src` by hand (a production
201
+ // site's migration workaround). The default must probe <pkg>/invoke.ts first,
202
202
  // then <pkg>/src/invoke.ts.
203
203
 
204
204
  function makeSite(layout: "root" | "src"): { siteDir: string; cleanup: () => void } {