@decocms/blocks-cli 7.66.1 → 7.66.2
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 +2 -2
- package/scripts/generate-invoke.ts +9 -21
- package/scripts/generate-schema-workspaces.test.ts +180 -0
- package/scripts/generate-schema.ts +4 -2
- package/scripts/generate-workspaces.test.ts +179 -0
- package/scripts/generate.ts +34 -36
- package/scripts/lib/installedPackages.ts +42 -0
- package/scripts/lib/invokeSource.ts +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "7.66.
|
|
3
|
+
"version": "7.66.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
|
|
6
6
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"lint:unused": "knip"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@decocms/blocks": "7.66.
|
|
36
|
+
"@decocms/blocks": "7.66.2",
|
|
37
37
|
"ts-morph": "^27.0.0",
|
|
38
38
|
"tsx": "^4.22.5"
|
|
39
39
|
},
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
import fs from "node:fs";
|
|
47
47
|
import path from "node:path";
|
|
48
48
|
import { Project, type PropertyAssignment, SyntaxKind } from "ts-morph";
|
|
49
|
+
import { resolveInvokeSource } from "./lib/invokeSource";
|
|
49
50
|
|
|
50
51
|
const args = process.argv.slice(2);
|
|
51
52
|
function arg(name: string, fallback: string): string {
|
|
@@ -77,28 +78,15 @@ const PRIVILEGED_ACTIONS = new Set([
|
|
|
77
78
|
|
|
78
79
|
function resolveAppsDir(): string {
|
|
79
80
|
const explicit = arg("apps-dir", "");
|
|
81
|
+
const source = resolveInvokeSource(cwd, explicit || null);
|
|
82
|
+
if (source) return path.dirname(source);
|
|
83
|
+
// Preserve explicit-path errors and the legacy raw-checkout fallback used
|
|
84
|
+
// only by direct invocation. Published packages can be local or hoisted,
|
|
85
|
+
// with invoke.ts at the package root or under src/.
|
|
80
86
|
if (explicit) return path.resolve(cwd, explicit);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// for anyone still developing against the pre-split monorepo.
|
|
85
|
-
//
|
|
86
|
-
// For each root, invoke.ts may sit directly at the root (legacy dev
|
|
87
|
-
// checkouts) or under src/ — the published @decocms/apps-vtex tarball
|
|
88
|
-
// ships its sources under src/ (`"files": ["src"]`, `"main":
|
|
89
|
-
// "./src/index.ts"`), so on any site with npm-installed 7.x packages the
|
|
90
|
-
// file lives at node_modules/@decocms/apps-vtex/src/invoke.ts. The src/
|
|
91
|
-
// nesting doesn't affect the emitted imports: relative `./actions/*`
|
|
92
|
-
// specifiers are rewritten to `@decocms/apps-vtex/actions/*`, which the
|
|
93
|
-
// package's exports map points back into src/.
|
|
94
|
-
const roots = [
|
|
95
|
-
path.resolve(cwd, "node_modules/@decocms/apps-vtex"),
|
|
96
|
-
path.resolve(cwd, "../apps-start/vtex"),
|
|
97
|
-
];
|
|
98
|
-
for (const root of roots) {
|
|
99
|
-
for (const c of [root, path.join(root, "src")]) {
|
|
100
|
-
if (fs.existsSync(path.join(c, "invoke.ts"))) return c;
|
|
101
|
-
}
|
|
87
|
+
const legacy = resolveInvokeSource(cwd, "../apps-start/vtex");
|
|
88
|
+
if (legacy) {
|
|
89
|
+
return path.dirname(legacy);
|
|
102
90
|
}
|
|
103
91
|
throw new Error("Could not find @decocms/apps-vtex. Use --apps-dir to specify its location.");
|
|
104
92
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
interface Schema {
|
|
8
|
+
type?: string;
|
|
9
|
+
$ref?: string;
|
|
10
|
+
allOf?: Schema[];
|
|
11
|
+
properties?: Record<string, Schema>;
|
|
12
|
+
required?: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Meta {
|
|
16
|
+
manifest: { blocks: { loaders: Record<string, Schema>; apps: Record<string, Schema> } };
|
|
17
|
+
schema: { definitions: Record<string, Schema> };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("generate-schema workspace app packages", () => {
|
|
21
|
+
let workspace: string;
|
|
22
|
+
let site: string;
|
|
23
|
+
|
|
24
|
+
function write(relative: string, contents: string) {
|
|
25
|
+
const file = path.join(workspace, relative);
|
|
26
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
27
|
+
writeFileSync(file, contents);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function installApp(base: string, field: string) {
|
|
31
|
+
const directory = `${base}/node_modules/@decocms/apps-catalog`;
|
|
32
|
+
write(
|
|
33
|
+
`${directory}/package.json`,
|
|
34
|
+
JSON.stringify({
|
|
35
|
+
name: "@decocms/apps-catalog",
|
|
36
|
+
version: "1.0.0",
|
|
37
|
+
type: "module",
|
|
38
|
+
exports: { "./mod": "./src/mod.ts" },
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
write(
|
|
42
|
+
`${directory}/src/mod.ts`,
|
|
43
|
+
`export interface Props { account: string; }
|
|
44
|
+
export default function app(_props: Props) { return {}; }`,
|
|
45
|
+
);
|
|
46
|
+
write(
|
|
47
|
+
`${directory}/src/loaders/product.ts`,
|
|
48
|
+
`export interface Props {
|
|
49
|
+
${field}: string;
|
|
50
|
+
limit?: number;
|
|
51
|
+
}
|
|
52
|
+
export interface Product { sku: string; }
|
|
53
|
+
export default function loader(_props: Props): Product {
|
|
54
|
+
throw new Error("Fixture loaders must never execute during generation");
|
|
55
|
+
}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function runGenerator(script: string, args: string[] = []): string {
|
|
60
|
+
const result = spawnSync(
|
|
61
|
+
process.execPath,
|
|
62
|
+
["--import", import.meta.resolve("tsx"), path.resolve(__dirname, script), ...args],
|
|
63
|
+
{ cwd: site, encoding: "utf8", timeout: 30_000 },
|
|
64
|
+
);
|
|
65
|
+
expect(result.status, result.error?.message ?? result.stderr).toBe(0);
|
|
66
|
+
return result.stdout;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readMeta(): Meta {
|
|
70
|
+
return JSON.parse(readFileSync(path.join(site, ".deco/meta.gen.json"), "utf8"));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function generate(): Meta {
|
|
74
|
+
runGenerator("generate-schema.ts");
|
|
75
|
+
return readMeta();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function definition(meta: Meta, reference: Schema): Schema {
|
|
79
|
+
expect(reference?.$ref).toBeDefined();
|
|
80
|
+
return meta.schema.definitions[reference.$ref!.replace("#/definitions/", "")];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
workspace = mkdtempSync(path.join(tmpdir(), "schema-workspaces-"));
|
|
85
|
+
site = path.join(workspace, "apps/website");
|
|
86
|
+
write("package.json", JSON.stringify({ private: true, workspaces: ["apps/*"] }));
|
|
87
|
+
write(
|
|
88
|
+
"apps/website/package.json",
|
|
89
|
+
JSON.stringify({
|
|
90
|
+
name: "@example/website",
|
|
91
|
+
private: true,
|
|
92
|
+
dependencies: { "@decocms/apps-catalog": "1.0.0" },
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
write(
|
|
96
|
+
"apps/website/tsconfig.json",
|
|
97
|
+
JSON.stringify({
|
|
98
|
+
compilerOptions: {
|
|
99
|
+
target: "ES2022",
|
|
100
|
+
module: "ESNext",
|
|
101
|
+
moduleResolution: "Bundler",
|
|
102
|
+
strict: true,
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
write(
|
|
107
|
+
"apps/website/src/apps/catalog.ts",
|
|
108
|
+
'export { default } from "@decocms/apps-catalog/mod";',
|
|
109
|
+
);
|
|
110
|
+
mkdirSync(path.join(site, "src/sections"), { recursive: true });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterEach(() => {
|
|
114
|
+
if (workspace) rmSync(workspace, { recursive: true, force: true });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("emits loaders and their Props from an app hoisted above the website", () => {
|
|
118
|
+
installApp(".", "productId");
|
|
119
|
+
|
|
120
|
+
const meta = generate();
|
|
121
|
+
const loader = definition(meta, meta.manifest.blocks.loaders["catalog/loaders/product.ts"]);
|
|
122
|
+
expect(loader.properties).toMatchObject({
|
|
123
|
+
productId: { type: "string" },
|
|
124
|
+
limit: { type: "number" },
|
|
125
|
+
});
|
|
126
|
+
expect(loader.required).toEqual(["__resolveType", "productId"]);
|
|
127
|
+
|
|
128
|
+
const app = definition(meta, meta.manifest.blocks.apps["site/apps/catalog.ts"]);
|
|
129
|
+
const props = definition(meta, app.allOf!.find((part) => part.$ref)!);
|
|
130
|
+
expect(props.properties?.account).toMatchObject({ type: "string" });
|
|
131
|
+
expect(props.required).toEqual(["account"]);
|
|
132
|
+
}, 30_000);
|
|
133
|
+
|
|
134
|
+
it("uses the nearest app package without merging loaders from the hoisted copy", () => {
|
|
135
|
+
installApp(".", "parentProductId");
|
|
136
|
+
write(
|
|
137
|
+
"node_modules/@decocms/apps-catalog/src/loaders/parentOnly.ts",
|
|
138
|
+
"export default function loader(): string { return 'parent'; }",
|
|
139
|
+
);
|
|
140
|
+
installApp("apps/website", "localProductId");
|
|
141
|
+
|
|
142
|
+
const meta = generate();
|
|
143
|
+
const loader = definition(meta, meta.manifest.blocks.loaders["catalog/loaders/product.ts"]);
|
|
144
|
+
expect(loader.properties?.localProductId).toMatchObject({ type: "string" });
|
|
145
|
+
expect(loader.properties?.parentProductId).toBeUndefined();
|
|
146
|
+
expect(loader.required).toEqual(["__resolveType", "localProductId"]);
|
|
147
|
+
expect(meta.manifest.blocks.loaders["catalog/loaders/parentOnly.ts"]).toBeUndefined();
|
|
148
|
+
}, 30_000);
|
|
149
|
+
|
|
150
|
+
it("invalidates a warm schema cache when hoisted loader Props change at the same version", () => {
|
|
151
|
+
installApp(".", "productId");
|
|
152
|
+
const args = ["--only", "schema"];
|
|
153
|
+
const key = "catalog/loaders/product.ts";
|
|
154
|
+
const first = runGenerator("generate.ts", args);
|
|
155
|
+
expect(first).toMatch(/\[generate\] schema \d+ms \(fresh\)/);
|
|
156
|
+
const before = readMeta();
|
|
157
|
+
expect(
|
|
158
|
+
definition(before, before.manifest.blocks.loaders[key]).properties?.productId,
|
|
159
|
+
).toMatchObject({ type: "string" });
|
|
160
|
+
|
|
161
|
+
const warm = runGenerator("generate.ts", args);
|
|
162
|
+
expect(warm).toMatch(/\[generate\] schema \d+ms \(cached(?:, content-verified)?\)/);
|
|
163
|
+
|
|
164
|
+
// A linked/workspace app can change its source without a version bump.
|
|
165
|
+
// Only the hoisted loader changes; the site and package manifest stay put.
|
|
166
|
+
const relative = "node_modules/@decocms/apps-catalog/src/loaders/product.ts";
|
|
167
|
+
const source = readFileSync(path.join(workspace, relative), "utf8");
|
|
168
|
+
write(relative, source.replace("productId: string;", "productId: number; category?: string;"));
|
|
169
|
+
|
|
170
|
+
const updated = runGenerator("generate.ts", args);
|
|
171
|
+
expect(updated).toMatch(/\[generate\] schema \d+ms \(fresh\)/);
|
|
172
|
+
const after = readMeta();
|
|
173
|
+
const loader = definition(after, after.manifest.blocks.loaders[key]);
|
|
174
|
+
expect(loader.properties).toMatchObject({
|
|
175
|
+
productId: { type: "number" },
|
|
176
|
+
category: { type: "string" },
|
|
177
|
+
});
|
|
178
|
+
expect(loader.required).toEqual(["__resolveType", "productId"]);
|
|
179
|
+
}, 30_000);
|
|
180
|
+
});
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
type Type,
|
|
46
46
|
} from "ts-morph";
|
|
47
47
|
import { isExcludedCodegenFile } from "./lib/codegenExclusions";
|
|
48
|
+
import { resolvePackageDirectory } from "./lib/installedPackages";
|
|
48
49
|
|
|
49
50
|
// ---------------------------------------------------------------------------
|
|
50
51
|
// CLI arg parsing
|
|
@@ -1062,8 +1063,8 @@ function generateMeta(): MetaResponse {
|
|
|
1062
1063
|
// ---------------------------------------------------------------------------
|
|
1063
1064
|
|
|
1064
1065
|
/** Absolute path to the installed @decocms/apps-<namespace> package, if present. */
|
|
1065
|
-
function getAppPkgDir(namespace: string): string {
|
|
1066
|
-
return
|
|
1066
|
+
function getAppPkgDir(namespace: string): string | null {
|
|
1067
|
+
return resolvePackageDirectory(root, `@decocms/apps-${namespace}`);
|
|
1067
1068
|
}
|
|
1068
1069
|
|
|
1069
1070
|
/** Detect installed app namespaces from src/apps/ bridge files. */
|
|
@@ -1093,6 +1094,7 @@ function generateMeta(): MetaResponse {
|
|
|
1093
1094
|
|
|
1094
1095
|
for (const namespace of installed) {
|
|
1095
1096
|
const pkgDir = getAppPkgDir(namespace);
|
|
1097
|
+
if (!pkgDir) continue;
|
|
1096
1098
|
const loadersDir = path.join(pkgDir, "src", "loaders");
|
|
1097
1099
|
if (!fs.existsSync(loadersDir)) continue;
|
|
1098
1100
|
|
|
@@ -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.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
|
|
@@ -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);
|
|
@@ -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
|
+
}
|