@decocms/blocks-cli 7.66.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.66.0",
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.0",
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
- // Try common locations: the installed @decocms/apps-vtex package first,
83
- // then a raw apps-start checkout's vtex/ subdirectory as a legacy fallback
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 path.resolve(root, `node_modules/@decocms/apps-${namespace}`);
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
+ });
@@ -88,7 +88,7 @@
88
88
  * - args: the exact argv forwarded to that generator,
89
89
  * - cli: blocks-cli's own package version,
90
90
  * - deco: the resolved versions of every @decocms/* package in the
91
- * site's node_modules (a lockstep bump invalidates everything),
91
+ * site's module search path (a lockstep bump invalidates everything),
92
92
  * - inputs: sha256 over the sorted (relPath, contentSha256) pairs of the
93
93
  * generator's input set — CONTENT hashes, machine-independent.
94
94
  * Because the record is content-addressed, a FRESH CLONE with unchanged
@@ -127,6 +127,8 @@ import { createRequire } from "node:module";
127
127
  import path from "node:path";
128
128
  import { fileURLToPath } from "node:url";
129
129
  import { isExcludedCodegenFile } from "./lib/codegenExclusions";
130
+ import { listScopedPackages, resolvePackageDirectory } from "./lib/installedPackages";
131
+ import { resolveInvokeSource } from "./lib/invokeSource";
130
132
 
131
133
  // Bump when the digests file format or digest recipe changes — recorded in
132
134
  // every digest record so old files self-bust instead of mis-validating.
@@ -463,16 +465,9 @@ function readPkgVersion(pkgDir: string): string | null {
463
465
 
464
466
  function decoPackageVersions(cwd: string): Record<string, string> {
465
467
  const versions: Record<string, string> = {};
466
- const scopeDir = path.join(cwd, "node_modules", "@decocms");
467
- let names: string[];
468
- try {
469
- names = fs.readdirSync(scopeDir);
470
- } catch {
471
- return versions;
472
- }
473
- for (const name of names.sort()) {
474
- const v = readPkgVersion(path.join(scopeDir, name));
475
- if (v) versions[`@decocms/${name}`] = v;
468
+ for (const [name, dir] of listScopedPackages(cwd, "@decocms")) {
469
+ const v = readPkgVersion(dir);
470
+ if (v) versions[name] = v;
476
471
  }
477
472
  return versions;
478
473
  }
@@ -485,7 +480,25 @@ function ownVersion(): string {
485
480
  }
486
481
 
487
482
  function hasPackage(cwd: string, name: string): boolean {
488
- return fs.existsSync(path.join(cwd, "node_modules", ...name.split("/"), "package.json"));
483
+ const dir = resolvePackageDirectory(cwd, name);
484
+ return dir !== null && fs.existsSync(path.join(dir, "package.json"));
485
+ }
486
+
487
+ function isNextjsSite(cwd: string): boolean {
488
+ // Sibling sites can hoist both bindings. Prefer this site's declaration so
489
+ // a TanStack workspace does not adopt a sibling's Next.js generation mode.
490
+ try {
491
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
492
+ const dependencies = {
493
+ ...pkg.dependencies,
494
+ ...pkg.devDependencies,
495
+ ...pkg.peerDependencies,
496
+ ...pkg.optionalDependencies,
497
+ };
498
+ if ("@decocms/nextjs" in dependencies) return hasPackage(cwd, "@decocms/nextjs");
499
+ if ("@decocms/tanstack" in dependencies) return false;
500
+ } catch {}
501
+ return hasPackage(cwd, "@decocms/nextjs");
489
502
  }
490
503
 
491
504
  // ---------------------------------------------------------------------------
@@ -508,20 +521,6 @@ export interface GeneratorPlan {
508
521
  outputs: string[];
509
522
  }
510
523
 
511
- /** Resolve generate-invoke's apps dir exactly like the generator does
512
- * (minus the legacy ../apps-start/vtex fallback, which is dev-checkout-only). */
513
- function resolveInvokeSource(cwd: string, appsDir: string | null): string | null {
514
- const roots = appsDir
515
- ? [path.resolve(cwd, appsDir)]
516
- : [path.resolve(cwd, "node_modules/@decocms/apps-vtex")];
517
- for (const root of roots) {
518
- for (const c of [root, path.join(root, "src")]) {
519
- if (fs.existsSync(path.join(c, "invoke.ts"))) return path.join(c, "invoke.ts");
520
- }
521
- }
522
- return null;
523
- }
524
-
525
524
  export function buildPlan(cwd: string, opts: CliOptions): GeneratorPlan[] {
526
525
  const scriptsDir = path.dirname(fileURLToPath(import.meta.url));
527
526
  const blocksDirAbs = path.resolve(cwd, opts.blocksDir);
@@ -531,7 +530,7 @@ export function buildPlan(cwd: string, opts: CliOptions): GeneratorPlan[] {
531
530
 
532
531
  const blocksDirExists = fs.existsSync(blocksDirAbs);
533
532
  const sectionsDirExists = fs.existsSync(sectionsDirAbs);
534
- const isNext = hasPackage(cwd, "@decocms/nextjs");
533
+ const isNext = isNextjsSite(cwd);
535
534
  const hasTanstackStart = hasPackage(cwd, "@tanstack/react-start");
536
535
 
537
536
  const blocksGenJson = path.join(".deco", "blocks.gen.json");
@@ -635,10 +634,12 @@ export function buildPlan(cwd: string, opts: CliOptions): GeneratorPlan[] {
635
634
  args: opts.appsDir ? ["--apps-dir", opts.appsDir] : [],
636
635
  stage: 1,
637
636
  ...enabledIf(
638
- invokeSource !== null && hasTanstackStart,
637
+ invokeSource !== null && hasTanstackStart && !isNext,
639
638
  invokeSource === null
640
639
  ? "no apps invoke.ts found (@decocms/apps-vtex not installed and no --apps-dir)"
641
- : "@tanstack/react-start not installed (invoke.gen.ts targets TanStack Start)",
640
+ : isNext
641
+ ? "@decocms/nextjs site (invoke.gen.ts targets TanStack Start)"
642
+ : "@tanstack/react-start not installed (invoke.gen.ts targets TanStack Start)",
642
643
  ),
643
644
  // invoke.ts is the file the generator parses; the surrounding package's
644
645
  // action/type sources are fingerprinted via the @decocms/* version set
@@ -692,13 +693,10 @@ export function buildPlan(cwd: string, opts: CliOptions): GeneratorPlan[] {
692
693
  ),
693
694
  ];
694
695
  if (!opts.skipApps) {
695
- const scopeDir = path.join(cwd, "node_modules", "@decocms");
696
- let names: string[] = [];
697
- try {
698
- names = fs.readdirSync(scopeDir).filter((n) => n.startsWith("apps-"));
699
- } catch {}
700
- for (const name of names.sort()) {
701
- entries.push(...walkTree(cwd, path.join(scopeDir, name, "src"), [".ts", ".tsx"]));
696
+ for (const [name, dir] of listScopedPackages(cwd, "@decocms")) {
697
+ if (name.startsWith("@decocms/apps-")) {
698
+ entries.push(...walkTree(cwd, path.join(dir, "src"), [".ts", ".tsx"]));
699
+ }
702
700
  }
703
701
  }
704
702
  return sortEntries(entries);
@@ -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
+ }