@decocms/blocks-cli 7.0.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 (93) hide show
  1. package/package.json +44 -0
  2. package/scripts/analyze-traces.mjs +1117 -0
  3. package/scripts/audit-observability-config.test.ts +446 -0
  4. package/scripts/audit-observability-config.ts +511 -0
  5. package/scripts/deco-migrate-cli.ts +444 -0
  6. package/scripts/fast-deploy-kv.test.ts +131 -0
  7. package/scripts/generate-blocks.test.ts +94 -0
  8. package/scripts/generate-blocks.ts +274 -0
  9. package/scripts/generate-invoke.test.ts +195 -0
  10. package/scripts/generate-invoke.ts +469 -0
  11. package/scripts/generate-loaders.ts +217 -0
  12. package/scripts/generate-schema.ts +1287 -0
  13. package/scripts/generate-sections.ts +237 -0
  14. package/scripts/htmx-analyze.ts +226 -0
  15. package/scripts/lib/blocks-dedupe.test.ts +179 -0
  16. package/scripts/lib/blocks-dedupe.ts +142 -0
  17. package/scripts/lib/cf-kv-rest.ts +78 -0
  18. package/scripts/lib/jsonc.ts +122 -0
  19. package/scripts/lib/kv-snapshot.ts +51 -0
  20. package/scripts/lib/read-decofile.ts +70 -0
  21. package/scripts/lib/sync-helpers.ts +44 -0
  22. package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
  23. package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
  24. package/scripts/migrate/analyzers/island-classifier.ts +96 -0
  25. package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
  26. package/scripts/migrate/analyzers/section-metadata.ts +147 -0
  27. package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
  28. package/scripts/migrate/colors.ts +46 -0
  29. package/scripts/migrate/config.test.ts +202 -0
  30. package/scripts/migrate/config.ts +186 -0
  31. package/scripts/migrate/phase-analyze.test.ts +63 -0
  32. package/scripts/migrate/phase-analyze.ts +782 -0
  33. package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
  34. package/scripts/migrate/phase-cleanup-audit.ts +105 -0
  35. package/scripts/migrate/phase-cleanup.test.ts +141 -0
  36. package/scripts/migrate/phase-cleanup.ts +1588 -0
  37. package/scripts/migrate/phase-compile.test.ts +193 -0
  38. package/scripts/migrate/phase-compile.ts +177 -0
  39. package/scripts/migrate/phase-report.ts +243 -0
  40. package/scripts/migrate/phase-scaffold.ts +593 -0
  41. package/scripts/migrate/phase-transform.ts +310 -0
  42. package/scripts/migrate/phase-verify.test.ts +127 -0
  43. package/scripts/migrate/phase-verify.ts +572 -0
  44. package/scripts/migrate/post-cleanup/rules.ts +1708 -0
  45. package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
  46. package/scripts/migrate/post-cleanup/runner.ts +137 -0
  47. package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
  48. package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
  49. package/scripts/migrate/post-cleanup/types.ts +106 -0
  50. package/scripts/migrate/source-layout.test.ts +111 -0
  51. package/scripts/migrate/source-layout.ts +103 -0
  52. package/scripts/migrate/templates/app-css.ts +366 -0
  53. package/scripts/migrate/templates/cache-config.ts +26 -0
  54. package/scripts/migrate/templates/commerce-loaders.ts +230 -0
  55. package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
  56. package/scripts/migrate/templates/cursor-rules.ts +70 -0
  57. package/scripts/migrate/templates/hooks.test.ts +141 -0
  58. package/scripts/migrate/templates/hooks.ts +152 -0
  59. package/scripts/migrate/templates/knip-config.ts +27 -0
  60. package/scripts/migrate/templates/lib-utils.test.ts +139 -0
  61. package/scripts/migrate/templates/lib-utils.ts +326 -0
  62. package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
  63. package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
  64. package/scripts/migrate/templates/package-json.ts +177 -0
  65. package/scripts/migrate/templates/routes.ts +237 -0
  66. package/scripts/migrate/templates/sdk-gen.ts +59 -0
  67. package/scripts/migrate/templates/section-loaders.ts +456 -0
  68. package/scripts/migrate/templates/server-entry.ts +561 -0
  69. package/scripts/migrate/templates/setup.ts +148 -0
  70. package/scripts/migrate/templates/tsconfig.ts +21 -0
  71. package/scripts/migrate/templates/types-gen.ts +174 -0
  72. package/scripts/migrate/templates/ui-components.ts +144 -0
  73. package/scripts/migrate/templates/vite-config.ts +101 -0
  74. package/scripts/migrate/transforms/dead-code.ts +455 -0
  75. package/scripts/migrate/transforms/deno-isms.ts +85 -0
  76. package/scripts/migrate/transforms/fresh-apis.ts +223 -0
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
  78. package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
  79. package/scripts/migrate/transforms/imports.ts +385 -0
  80. package/scripts/migrate/transforms/jsx.ts +317 -0
  81. package/scripts/migrate/transforms/section-conventions.ts +210 -0
  82. package/scripts/migrate/transforms/tailwind.ts +739 -0
  83. package/scripts/migrate/types.ts +244 -0
  84. package/scripts/migrate-blocks-to-kv.ts +104 -0
  85. package/scripts/migrate-post-cleanup.ts +191 -0
  86. package/scripts/migrate-to-cf-observability.test.ts +215 -0
  87. package/scripts/migrate-to-cf-observability.ts +699 -0
  88. package/scripts/migrate.ts +282 -0
  89. package/scripts/smoke-otlp-errorlog.ts +46 -0
  90. package/scripts/smoke-otlp-meter.ts +48 -0
  91. package/scripts/sync-blocks-to-kv.ts +153 -0
  92. package/scripts/tailwind-lint.ts +518 -0
  93. package/tsconfig.json +7 -0
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env tsx
2
+
3
+ /**
4
+ * Migration Script: Fresh/Deno/Preact → TanStack Start/React/Cloudflare Workers
5
+ *
6
+ * Converts a Deco storefront from the old Fresh/Deno stack to the new TanStack Start stack.
7
+ * Part of the @decocms/start framework — run from a site's root directory.
8
+ *
9
+ * Usage (from your Fresh site directory):
10
+ * npx -p @decocms/start deco-migrate [options]
11
+ *
12
+ * Options:
13
+ * --source <dir> Source directory (default: current directory)
14
+ * --dry-run Preview changes without writing files
15
+ * --verbose Show detailed output
16
+ * --help Show this help message
17
+ *
18
+ * Phases:
19
+ * 1. Analyze — Scan source site, categorize files, detect patterns
20
+ * 2. Scaffold — Create target structure (configs, routes, infra files)
21
+ * 3. Transform — Convert source files (imports, JSX, Fresh APIs, Deno-isms, Tailwind)
22
+ * 4. Cleanup — Delete old artifacts, move static → public
23
+ * 5. Report — Generate MIGRATION_REPORT.md with findings
24
+ * 6. Verify — Smoke test the migrated output
25
+ */
26
+
27
+ import { execSync } from "node:child_process";
28
+ import * as path from "node:path";
29
+ import { banner, green, red, stat, yellow } from "./migrate/colors";
30
+ import { loadConfig, validateConfig } from "./migrate/config";
31
+ import { analyze } from "./migrate/phase-analyze";
32
+ import { cleanup } from "./migrate/phase-cleanup";
33
+ import { cleanupAudit } from "./migrate/phase-cleanup-audit";
34
+ import { compile } from "./migrate/phase-compile";
35
+ import { report } from "./migrate/phase-report";
36
+ import { scaffold } from "./migrate/phase-scaffold";
37
+ import { transform } from "./migrate/phase-transform";
38
+ import { verify } from "./migrate/phase-verify";
39
+ import { detectSourceLayout, explainNonClassicLayout } from "./migrate/source-layout";
40
+ import { createContext, logPhase } from "./migrate/types";
41
+
42
+ function parseArgs(args: string[]): {
43
+ source: string;
44
+ dryRun: boolean;
45
+ verbose: boolean;
46
+ help: boolean;
47
+ strict: boolean;
48
+ withBuild: boolean;
49
+ noCompile: boolean;
50
+ noCleanupAudit: boolean;
51
+ } {
52
+ let source = ".";
53
+ let dryRun = false;
54
+ let verbose = false;
55
+ let help = false;
56
+ let strict = false;
57
+ let withBuild = false;
58
+ let noCompile = false;
59
+ let noCleanupAudit = false;
60
+
61
+ for (let i = 0; i < args.length; i++) {
62
+ switch (args[i]) {
63
+ case "--source":
64
+ source = args[++i];
65
+ break;
66
+ case "--dry-run":
67
+ dryRun = true;
68
+ break;
69
+ case "--verbose":
70
+ verbose = true;
71
+ break;
72
+ case "--strict":
73
+ strict = true;
74
+ break;
75
+ case "--with-build":
76
+ withBuild = true;
77
+ break;
78
+ case "--no-compile":
79
+ noCompile = true;
80
+ break;
81
+ case "--no-cleanup-audit":
82
+ noCleanupAudit = true;
83
+ break;
84
+ case "--help":
85
+ case "-h":
86
+ help = true;
87
+ break;
88
+ }
89
+ }
90
+
91
+ return {
92
+ source,
93
+ dryRun,
94
+ verbose,
95
+ help,
96
+ strict,
97
+ withBuild,
98
+ noCompile,
99
+ noCleanupAudit,
100
+ };
101
+ }
102
+
103
+ function showHelp() {
104
+ console.log(`
105
+ @decocms/start — Migration Script: Fresh/Deno → TanStack Start
106
+
107
+ Usage:
108
+ npx -p @decocms/start deco-migrate [options]
109
+
110
+ Options:
111
+ --source <dir> Source directory (default: .)
112
+ --dry-run Preview changes without writing files
113
+ --verbose Show detailed output for every file
114
+ --strict Fail (exit 2) when typecheck/build report errors
115
+ --with-build Also run \`vite build\` after typecheck (slower)
116
+ --no-compile Skip the post-bootstrap compile phase entirely
117
+ --no-cleanup-audit Skip the post-migration cleanup audit (run separately
118
+ via \`deco-post-cleanup\` if needed)
119
+ --help, -h Show this help message
120
+
121
+ Examples:
122
+ npx -p @decocms/start deco-migrate --dry-run --verbose
123
+ npx -p @decocms/start deco-migrate --source ./my-site
124
+ npx -p @decocms/start deco-migrate --strict --with-build
125
+ npx -p @decocms/start deco-migrate
126
+ `);
127
+ }
128
+
129
+ async function main() {
130
+ const opts = parseArgs(process.argv.slice(2));
131
+
132
+ if (opts.help) {
133
+ showHelp();
134
+ process.exit(0);
135
+ }
136
+
137
+ const sourceDir = path.resolve(opts.source);
138
+
139
+ banner("@decocms/start — Migrate: Fresh/Deno → TanStack Start");
140
+ stat("Source", sourceDir);
141
+ stat("Mode", opts.dryRun ? yellow("DRY RUN") : green("EXECUTE"));
142
+ stat("Verbose", opts.verbose ? "yes" : "no");
143
+
144
+ // Load optional per-site config from `.deco-migrate.config.json`. Drives
145
+ // section-conventions hardcoded lists today; future fields will tune
146
+ // import rewrites, scaffolding, etc.
147
+ const siteConfig = loadConfig(sourceDir);
148
+ if (siteConfig) {
149
+ validateConfig(siteConfig);
150
+ stat("Config", green(".deco-migrate.config.json (loaded)"));
151
+ }
152
+
153
+ const ctx = createContext(sourceDir, {
154
+ dryRun: opts.dryRun,
155
+ verbose: opts.verbose,
156
+ config: siteConfig,
157
+ });
158
+
159
+ // Phase 0: Source-layout detection (early-abort for unsupported layouts).
160
+ // The analyzer assumes a classic root layout (sections/, islands/, ...);
161
+ // running it on a modern src/ layout silently yields a near-empty
162
+ // migration. Detect-and-abort here so the user gets an actionable error
163
+ // before we touch any files.
164
+ const layout = detectSourceLayout(sourceDir);
165
+ if (layout !== "classic") {
166
+ console.error(red(`Error: ${layout} source layout`));
167
+ console.error("");
168
+ console.error(explainNonClassicLayout(layout, sourceDir));
169
+ process.exit(2);
170
+ }
171
+
172
+ try {
173
+ // Phase 1: Analyze source
174
+ analyze(ctx);
175
+
176
+ // Phase 2: Scaffold target structure
177
+ scaffold(ctx);
178
+
179
+ // Phase 3: Transform source files
180
+ transform(ctx);
181
+
182
+ // Phase 4: Cleanup old artifacts
183
+ cleanup(ctx);
184
+
185
+ // Phase 5: Generate report
186
+ report(ctx);
187
+
188
+ // Phase 6: Verify (smoke test)
189
+ const ok = verify(ctx);
190
+ if (!ok) {
191
+ process.exit(2);
192
+ }
193
+
194
+ // Phase 7: Bootstrap (install + generate)
195
+ if (!ctx.dryRun) {
196
+ bootstrap(ctx);
197
+ }
198
+
199
+ // Phase 8: Compile (typecheck + optional build)
200
+ // Skipped in dry-run, when --no-compile is passed, or when bootstrap
201
+ // didn't install dependencies (handled inside `compile`).
202
+ if (!opts.noCompile) {
203
+ const compileResult = compile(ctx, {
204
+ strict: opts.strict,
205
+ withBuild: opts.withBuild,
206
+ });
207
+ if (compileResult.shouldFail) {
208
+ process.exit(2);
209
+ }
210
+ }
211
+
212
+ // Phase 9: Post-migration cleanup audit
213
+ // Read-only scan that catches residual debt the migration script
214
+ // can't (or won't) fix. Always informational unless --strict is on,
215
+ // in which case warning-severity findings exit 2.
216
+ if (!opts.noCleanupAudit) {
217
+ const auditFailed = cleanupAudit(ctx, { strict: opts.strict });
218
+ if (auditFailed) {
219
+ process.exit(2);
220
+ }
221
+ }
222
+ } catch (error) {
223
+ console.error(`\n ${red("Migration failed:")}`, error);
224
+ process.exit(1);
225
+ }
226
+ }
227
+
228
+ function bootstrap(ctx: { sourceDir: string }) {
229
+ logPhase("Bootstrap (install + generate)");
230
+
231
+ let failures = 0;
232
+ const run = (cmd: string, label: string, critical = false) => {
233
+ console.log(` Running: ${label}...`);
234
+ try {
235
+ execSync(cmd, { cwd: ctx.sourceDir, stdio: "pipe" });
236
+ console.log(` ${green("✓")} ${label}`);
237
+ } catch (e: any) {
238
+ failures++;
239
+ const icon = critical ? red("✗") : yellow("⚠");
240
+ console.log(` ${icon} ${label} failed: ${e.message?.split("\n")[0]}`);
241
+ if (critical) {
242
+ console.log(`\n ${red("Bootstrap aborted.")} Fix the error above and run manually.\n`);
243
+ return false;
244
+ }
245
+ }
246
+ return true;
247
+ };
248
+
249
+ // bun is the fleet-wide canonical package manager for decocms storefronts.
250
+ // We hardcode it here (instead of sniffing process.env.npm_execpath) so a
251
+ // freshly-migrated site always commits a bun.lock and never accidentally
252
+ // ships a package-lock.json that drifts vs bun.lock under CF Workers Builds.
253
+ // See MIGRATION_TOOLING_PLAN.md and the package-json template for the
254
+ // matching `packageManager` field that pins the version.
255
+ const pm = "bun";
256
+ if (!run(`${pm} install`, "Install dependencies", true)) return;
257
+ run("bunx tsx node_modules/@decocms/start/scripts/generate-blocks.ts", "Generate CMS blocks");
258
+ // generate-invoke emits src/server/invoke.gen.ts with top-level
259
+ // createServerFn declarations + the forwardResponseCookies bridge that
260
+ // propagates VTEX Set-Cookie headers (orderFormId, segment, sc…) to the
261
+ // browser. Without this file, the site falls back to the proxy
262
+ // `~/runtime.ts` route which hits /deco/invoke and used to drop cookies,
263
+ // making the cart appear empty at /checkout after addItemToCart. The
264
+ // upstream invoke handler now also forwards cookies correctly, but
265
+ // running the generator gives every freshly-migrated site the canonical
266
+ // RPC path so VTEX hooks (useCart, useUser, useWishlist) work end-to-end.
267
+ run(
268
+ "bunx tsx node_modules/@decocms/start/scripts/generate-invoke.ts",
269
+ "Generate VTEX invoke server functions",
270
+ );
271
+ run("bunx tsr generate", "Generate TanStack routes");
272
+
273
+ if (failures > 0) {
274
+ console.log(
275
+ `\n ${yellow("Bootstrap completed with warnings.")} Check errors above before running dev.\n`,
276
+ );
277
+ } else {
278
+ console.log(`\n ${green("Ready!")} Run \`${pm} run dev\` to start the dev server.\n`);
279
+ }
280
+ }
281
+
282
+ main();
@@ -0,0 +1,46 @@
1
+ /**
2
+ * One-shot smoke: emit an error log record through
3
+ * `createOtlpHttpLogAdapter` and POST it to the deployed
4
+ * `deco-otel-ingest` `/v1/logs`. Verifies the wire format end-to-end.
5
+ *
6
+ * Run with: `npx tsx scripts/smoke-otlp-errorlog.ts`
7
+ * Expected output: `{"inserted":1}` echoed by the ingestor.
8
+ */
9
+
10
+ import { createOtlpHttpLogAdapter } from "@decocms/blocks/sdk/otelHttpLog";
11
+
12
+ async function main() {
13
+ const sink = createOtlpHttpLogAdapter({
14
+ endpoint: "https://deco-otel-ingest.deco-cx.workers.dev/v1/logs",
15
+ resourceAttributes: {
16
+ "service.name": "smoke-otlp-errorlog",
17
+ "service.version": "5.1.1-dev",
18
+ "deco.runtime.version": "5.1.1-dev",
19
+ "deployment.environment": "smoke",
20
+ },
21
+ scopeName: "@decocms/start",
22
+ scopeVersion: "5.1.1-dev",
23
+ minFlushIntervalMs: 0,
24
+ onError: (kind, err) => {
25
+ console.error(`[smoke] onError(${kind}):`, err);
26
+ process.exitCode = 1;
27
+ },
28
+ });
29
+
30
+ sink.adapter.log("error", "smoke error from otelHttpLog exporter", {
31
+ stage: "smoke",
32
+ reason: "wire-format-validation",
33
+ durationMs: 27,
34
+ ok: false,
35
+ });
36
+
37
+ console.log("pending records:", sink.pendingRecordCount());
38
+
39
+ await sink.flush();
40
+ console.log("flush complete");
41
+ }
42
+
43
+ main().catch((err) => {
44
+ console.error(err);
45
+ process.exit(1);
46
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * One-shot smoke: serialize a real OTLP/HTTP metrics payload through the
3
+ * `createOtlpHttpMeterAdapter` exporter and POST it to the deployed
4
+ * `deco-otel-ingest` `/v1/metrics`. Verifies the wire format end-to-end
5
+ * (exporter → ingestor → ClickHouse) without standing up a Worker.
6
+ *
7
+ * Not part of the standard test suite — committed for repeatability.
8
+ * Run with:
9
+ *
10
+ * npx tsx scripts/smoke-otlp-meter.ts
11
+ *
12
+ * Expected output: `{"inserted":{"sum":1,"gauge":1,"histogram":1}}`.
13
+ */
14
+
15
+ import { createOtlpHttpMeterAdapter } from "@decocms/blocks/sdk/otelHttpMeter";
16
+
17
+ async function main() {
18
+ const meter = createOtlpHttpMeterAdapter({
19
+ endpoint: "https://deco-otel-ingest.deco-cx.workers.dev/v1/metrics",
20
+ resourceAttributes: {
21
+ "service.name": "smoke-otlp-meter",
22
+ "service.version": "5.1.1-dev",
23
+ "deco.runtime.version": "5.1.1-dev",
24
+ "deployment.environment": "smoke",
25
+ },
26
+ scopeName: "@decocms/start",
27
+ scopeVersion: "5.1.1-dev",
28
+ minFlushIntervalMs: 0,
29
+ onError: (kind, err) => {
30
+ console.error(`[smoke] onError(${kind}):`, err);
31
+ process.exitCode = 1;
32
+ },
33
+ });
34
+
35
+ meter.counterInc("deco.http.requests", 1, { method: "GET", status_class: "2xx" });
36
+ meter.gaugeSet("deco.metrics.flush.buffer_size", 1, { kind: "smoke" });
37
+ meter.histogramRecord("outbound_request_duration_ms", 27, { provider: "smoke", status_class: "2xx" });
38
+
39
+ console.log("pending datapoints:", meter.pendingDatapointCount());
40
+
41
+ await meter.flush();
42
+ console.log("flush complete");
43
+ }
44
+
45
+ main().catch((err) => {
46
+ console.error(err);
47
+ process.exit(1);
48
+ });
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * CI fast-deploy content sync: push the site's current decofile to KV when
4
+ * content changed, WITHOUT a worker redeploy.
5
+ *
6
+ * Because the runtime swaps whole snapshots (not per-block), this always writes
7
+ * the FULL current decofile (`decofile:current`) and bumps `index:revision`.
8
+ * The default mode first checks whether any `.deco/blocks/*.json` changed since
9
+ * a base ref — if nothing changed, it exits 0 without writing (the "content
10
+ * unchanged → nothing to do" path). `--all` skips that check and always syncs
11
+ * (use post-deploy for bootstrap/paranoia).
12
+ *
13
+ * After writing, it optionally purges the edge cache for the changed pages.
14
+ *
15
+ * Usage (in CI, on push to main):
16
+ * CF_ACCOUNT_ID=... CF_KV_NAMESPACE_ID=... CF_API_TOKEN=... \
17
+ * npx -p @decocms/start deco-sync-blocks-to-kv --write \
18
+ * --since "$GITHUB_BEFORE" --purge-url https://site.example --purge-token "$PURGE_TOKEN"
19
+ * # bootstrap (always write the whole snapshot):
20
+ * ... deco-sync-blocks-to-kv --write --all
21
+ *
22
+ * Options:
23
+ * --all Always sync (skip the git-diff content check)
24
+ * --since <ref> Base git ref for the diff (default: HEAD~1)
25
+ * --blocks-dir <dir> Input blocks dir (default: .deco/blocks)
26
+ * --purge-url <origin> Site origin to POST /_cache/purge after sync
27
+ * --purge-token <tok> Purge bearer token (or PURGE_TOKEN env)
28
+ * --write Perform writes (otherwise dry-run, exit 0)
29
+ * --help, -h Show this help
30
+ *
31
+ * Env: CF_ACCOUNT_ID, CF_KV_NAMESPACE_ID, CF_API_TOKEN (required with --write)
32
+ *
33
+ * Exit codes: 0 ok / no-op / dry-run; 2 error (bad dir, missing env, verify failed)
34
+ */
35
+
36
+ import { execSync } from "node:child_process";
37
+ import * as path from "node:path";
38
+ import { createKvRestClient, kvConfigFromEnv } from "./lib/cf-kv-rest";
39
+ import { buildSnapshot, verifySnapshotInKv, writeSnapshotToKv } from "./lib/kv-snapshot";
40
+ import { readDecofileFromDir } from "./lib/read-decofile";
41
+ import { changedBlockFiles, changedBlockKeys, purgePathsForChangedKeys } from "./lib/sync-helpers";
42
+
43
+ function parseArgs(argv: string[]) {
44
+ const has = (f: string) => argv.includes(f);
45
+ const val = (f: string, d: string) => {
46
+ const i = argv.indexOf(f);
47
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : d;
48
+ };
49
+ return {
50
+ help: has("--help") || has("-h"),
51
+ all: has("--all"),
52
+ write: has("--write"),
53
+ since: val("--since", "HEAD~1"),
54
+ blocksDir: val("--blocks-dir", ".deco/blocks"),
55
+ purgeUrl: val("--purge-url", ""),
56
+ purgeToken: val("--purge-token", process.env.PURGE_TOKEN ?? ""),
57
+ };
58
+ }
59
+
60
+ function gitChangedFiles(since: string, blocksDir: string): string[] {
61
+ const out = execSync(`git diff --name-only ${since} HEAD`, { encoding: "utf-8" });
62
+ return changedBlockFiles(out, blocksDir);
63
+ }
64
+
65
+ async function purgeCache(origin: string, token: string, paths: string[]): Promise<void> {
66
+ const res = await fetch(new URL("/_cache/purge", origin).toString(), {
67
+ method: "POST",
68
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
69
+ body: JSON.stringify({ paths }),
70
+ });
71
+ if (!res.ok) {
72
+ console.warn(`warning: purge failed: ${res.status} ${await res.text()}`);
73
+ } else {
74
+ console.log(`purged ${paths.length} path(s): ${paths.join(", ")}`);
75
+ }
76
+ }
77
+
78
+ async function main() {
79
+ const opts = parseArgs(process.argv.slice(2));
80
+ if (opts.help) {
81
+ console.log("Usage: deco-sync-blocks-to-kv [--all] [--since <ref>] [--write] [--purge-url <origin>]");
82
+ process.exit(0);
83
+ }
84
+
85
+ const blocksDir = path.resolve(process.cwd(), opts.blocksDir);
86
+ const blocksDirRel = opts.blocksDir;
87
+
88
+ // Decide whether there's anything to sync.
89
+ let changedKeys: string[] = [];
90
+ if (!opts.all) {
91
+ let changed: string[];
92
+ try {
93
+ changed = gitChangedFiles(opts.since, blocksDirRel);
94
+ } catch (e) {
95
+ console.error(`error: git diff failed: ${e instanceof Error ? e.message : String(e)}`);
96
+ process.exit(2);
97
+ }
98
+ if (changed.length === 0) {
99
+ console.log(`no ${blocksDirRel} changes since ${opts.since} — nothing to sync.`);
100
+ process.exit(0);
101
+ }
102
+ changedKeys = changedBlockKeys(changed);
103
+ console.log(`${changed.length} changed block file(s) since ${opts.since}.`);
104
+ }
105
+
106
+ let blocks: Record<string, unknown>;
107
+ try {
108
+ blocks = readDecofileFromDir(blocksDir).blocks;
109
+ } catch (e) {
110
+ console.error(`error: ${e instanceof Error ? e.message : String(e)}`);
111
+ process.exit(2);
112
+ }
113
+
114
+ const snap = buildSnapshot(blocks);
115
+ const purgePaths = opts.all
116
+ ? ["/"]
117
+ : purgePathsForChangedKeys(blocks, changedKeys);
118
+ console.log(`decofile: ${snap.count} blocks, revision ${snap.revision}`);
119
+
120
+ if (!opts.write) {
121
+ console.log(`\nDry-run only. Would write snapshot + revision and purge: ${purgePaths.join(", ")}`);
122
+ process.exit(0);
123
+ }
124
+
125
+ let client: ReturnType<typeof createKvRestClient>;
126
+ try {
127
+ client = createKvRestClient(kvConfigFromEnv());
128
+ } catch (e) {
129
+ console.error(`error: ${e instanceof Error ? e.message : String(e)}`);
130
+ process.exit(2);
131
+ }
132
+
133
+ try {
134
+ await writeSnapshotToKv(client, snap);
135
+ const verify = await verifySnapshotInKv(client, snap.revision);
136
+ if (!verify.ok) {
137
+ console.error(`error: KV verify failed — ${verify.reason}`);
138
+ process.exit(2);
139
+ }
140
+ } catch (e) {
141
+ console.error(`error: ${e instanceof Error ? e.message : String(e)}`);
142
+ process.exit(2);
143
+ }
144
+ console.log(`synced decofile:current (rev ${snap.revision}) → KV.`);
145
+
146
+ if (opts.purgeUrl && opts.purgeToken) {
147
+ await purgeCache(opts.purgeUrl, opts.purgeToken, purgePaths);
148
+ } else if (opts.purgeUrl) {
149
+ console.warn("warning: --purge-url given without a token (PURGE_TOKEN/--purge-token) — skipping purge.");
150
+ }
151
+ }
152
+
153
+ main();