@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.
- package/package.json +5 -3
- package/scripts/analyze-traces.mjs +1 -0
- package/scripts/cdn-rules.test.ts +105 -0
- package/scripts/cdn-rules.ts +192 -0
- package/scripts/deco-migrate-cli.ts +6 -7
- package/scripts/fast-deploy-kv.test.ts +172 -0
- package/scripts/generate-blocks.test.ts +11 -0
- package/scripts/generate-blocks.ts +0 -10
- package/scripts/generate-invoke.test.ts +2 -2
- package/scripts/generate-invoke.ts +9 -21
- package/scripts/generate-loaders.test.ts +2 -2
- package/scripts/generate-loaders.ts +0 -10
- package/scripts/generate-schema-loader-aliases.test.ts +186 -0
- package/scripts/generate-schema-workspaces.test.ts +180 -0
- package/scripts/generate-schema.test.ts +68 -29
- package/scripts/generate-schema.ts +101 -89
- package/scripts/generate-sections.test.ts +104 -35
- package/scripts/generate-sections.ts +57 -11
- package/scripts/generate-workspaces.test.ts +179 -0
- package/scripts/generate.test.ts +1 -2
- package/scripts/generate.ts +43 -45
- package/scripts/lib/blocks-dedupe.test.ts +1 -1
- package/scripts/lib/cf-kv-rest.ts +49 -1
- package/scripts/lib/installedPackages.ts +42 -0
- package/scripts/lib/invokeSource.ts +18 -0
- package/scripts/lib/kv-snapshot.ts +44 -0
- package/scripts/lib/read-decofile.ts +13 -2
- package/scripts/lib/upgrade-6-to-7.ts +1 -1
- package/scripts/lib/wrangler-config.test.ts +49 -0
- package/scripts/lib/wrangler-config.ts +31 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +6 -6
- package/scripts/migrate/analyzers/tailwind-config.ts +95 -1
- package/scripts/migrate/config.ts +2 -2
- package/scripts/migrate/delete-sets.ts +75 -0
- package/scripts/migrate/fast-deploy-scaffold.test.ts +130 -0
- package/scripts/migrate/icon-casing.test.ts +63 -0
- package/scripts/migrate/phase-analyze.test.ts +66 -4
- package/scripts/migrate/phase-analyze.ts +79 -60
- package/scripts/migrate/phase-cleanup.test.ts +3 -1
- package/scripts/migrate/phase-cleanup.ts +1 -1
- package/scripts/migrate/phase-compile.test.ts +3 -1
- package/scripts/migrate/phase-report.test.ts +2 -0
- package/scripts/migrate/phase-report.ts +3 -4
- package/scripts/migrate/phase-scaffold.ts +150 -45
- package/scripts/migrate/phase-transform.ts +107 -2
- package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +13 -0
- package/scripts/migrate/phase-verify.test.ts +3 -1
- package/scripts/migrate/phase-verify.ts +87 -33
- package/scripts/migrate/post-cleanup/rules.ts +29 -11
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +1 -1
- package/scripts/migrate/post-cleanup/shim-classify.ts +2 -2
- package/scripts/migrate/source-layout.test.ts +43 -0
- package/scripts/migrate/source-layout.ts +32 -5
- package/scripts/migrate/templates/app-css.test.ts +24 -0
- package/scripts/migrate/templates/app-css.ts +27 -0
- package/scripts/migrate/templates/ci-workflows.test.ts +179 -0
- package/scripts/migrate/templates/ci-yml.ts +182 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +3 -3
- package/scripts/migrate/templates/hooks.test.ts +3 -1
- package/scripts/migrate/templates/lib-utils.ts +3 -3
- package/scripts/migrate/templates/main-push-guard-yml.ts +45 -0
- package/scripts/migrate/templates/package-json.ts +10 -0
- package/scripts/migrate/templates/parity-yml.ts +136 -0
- package/scripts/migrate/templates/perf-yml.ts +449 -0
- package/scripts/migrate/templates/playwright-yml.ts +127 -0
- package/scripts/migrate/templates/react-doctor-yml.ts +46 -0
- package/scripts/migrate/templates/routes.test.ts +31 -0
- package/scripts/migrate/templates/routes.ts +19 -9
- package/scripts/migrate/templates/section-loaders.ts +3 -3
- package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +19 -0
- package/scripts/migrate/templates/server-entry.ts +19 -14
- package/scripts/migrate/templates/setup.ts +11 -3
- package/scripts/migrate/templates/sync-blocks-bot-yml.ts +201 -0
- package/scripts/migrate/templates/ui-components.ts +3 -0
- package/scripts/migrate/transforms/ctx-compat.test.ts +37 -0
- package/scripts/migrate/transforms/ctx-compat.ts +93 -1
- package/scripts/migrate/transforms/htmx-on-events.test.ts +1 -1
- package/scripts/migrate/transforms/htmx-on-events.ts +2 -2
- package/scripts/migrate/transforms/jsx-class-type.test.ts +28 -0
- package/scripts/migrate/transforms/jsx.ts +10 -0
- package/scripts/migrate/transforms/picture.test.ts +56 -0
- package/scripts/migrate/transforms/picture.ts +95 -0
- package/scripts/migrate/transforms/tailwind-renames.test.ts +29 -0
- package/scripts/migrate/transforms/tailwind-renames.ts +34 -0
- package/scripts/migrate/transforms/timer-types.test.ts +30 -0
- package/scripts/migrate/transforms/timer-types.ts +48 -0
- package/scripts/migrate/transforms/use-script-handlers.test.ts +37 -0
- package/scripts/migrate/transforms/use-script-handlers.ts +36 -0
- package/scripts/migrate/types.ts +13 -1
- package/scripts/migrate-blocks-to-kv.ts +16 -2
- package/scripts/migrate-to-cf-observability.test.ts +5 -5
- package/scripts/migrate-to-cf-observability.ts +1 -1
- package/scripts/migrate.ts +244 -6
- package/scripts/reconcile.test.ts +162 -0
- package/scripts/reconcile.ts +303 -0
- package/scripts/sync-blocks-bot.test.ts +276 -0
- package/scripts/sync-blocks-bot.ts +567 -0
- package/scripts/sync-blocks-to-kv.ts +20 -2
- package/scripts/upgrade-6-to-7.ts +0 -0
- 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
|
+
});
|
package/scripts/generate.test.ts
CHANGED
|
@@ -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
|
|
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 {
|
package/scripts/generate.ts
CHANGED
|
@@ -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
|
|
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
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
:
|
|
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
|
-
//
|
|
662
|
-
// __SECTION_REF__, Resolvable) into meta.gen.json so
|
|
663
|
-
// SELF-CONTAINED for every consumer that reads it straight
|
|
664
|
-
// the FS-based Studio and the Eitri stack, but also anyone
|
|
665
|
-
// committed artifact against /live/_meta. The tanstack
|
|
666
|
-
// composeMeta on load, which is
|
|
667
|
-
// already-composed meta unchanged — see composeMeta's `framework`
|
|
668
|
-
// sentinel), so
|
|
669
|
-
|
|
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
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
* (
|
|
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
|
+
});
|