@intelligo-dev/cli 1.0.0-beta.14 → 1.0.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +24 -0
  2. package/dist/args.d.ts +10 -0
  3. package/dist/args.d.ts.map +1 -0
  4. package/dist/args.js +20 -0
  5. package/dist/args.js.map +1 -0
  6. package/dist/bin.js +69 -25
  7. package/dist/bin.js.map +1 -1
  8. package/dist/commands/add.d.ts.map +1 -1
  9. package/dist/commands/add.js +19 -2
  10. package/dist/commands/add.js.map +1 -1
  11. package/dist/commands/create-flow.d.ts +4 -0
  12. package/dist/commands/create-flow.d.ts.map +1 -1
  13. package/dist/commands/create-flow.js +39 -4
  14. package/dist/commands/create-flow.js.map +1 -1
  15. package/dist/commands/create.d.ts +16 -0
  16. package/dist/commands/create.d.ts.map +1 -1
  17. package/dist/commands/create.js +42 -7
  18. package/dist/commands/create.js.map +1 -1
  19. package/dist/commands/doctor.d.ts +2 -0
  20. package/dist/commands/doctor.d.ts.map +1 -1
  21. package/dist/commands/doctor.js +102 -16
  22. package/dist/commands/doctor.js.map +1 -1
  23. package/dist/commands/sync.d.ts +6 -0
  24. package/dist/commands/sync.d.ts.map +1 -1
  25. package/dist/commands/sync.js +34 -3
  26. package/dist/commands/sync.js.map +1 -1
  27. package/dist/env-files.d.ts +11 -0
  28. package/dist/env-files.d.ts.map +1 -1
  29. package/dist/env-files.js +28 -9
  30. package/dist/env-files.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/args.ts +25 -0
  33. package/src/bin.ts +78 -28
  34. package/src/commands/add.ts +28 -12
  35. package/src/commands/create-flow.ts +38 -4
  36. package/src/commands/create.ts +65 -7
  37. package/src/commands/doctor.ts +129 -20
  38. package/src/commands/sync.ts +42 -2
  39. package/src/env-files.ts +28 -7
  40. package/templates/admin-page/admin-page.tsx.tpl +96 -24
  41. package/templates/app-scaffold/gitignore.tpl +25 -0
  42. package/templates/app-scaffold/next.config.mjs.tpl +31 -2
  43. package/templates/app-scaffold/package.json.tpl +2 -2
  44. package/templates/manifest.json +21 -3
  45. package/templates/pnpm-standalone/npmrc.tpl +6 -0
  46. package/templates/pnpm-standalone/pnpm-workspace.yaml.tpl +10 -0
  47. package/templates/registry/app-shell.json +2 -2
  48. package/templates/registry/billing-settings.json +8 -2
  49. package/templates/registry/chat.json +1 -1
  50. package/templates/registry/payment-poll.json +11 -5
  51. package/templates/registry/pricing.json +8 -2
  52. package/templates/registry/registry.json +18 -3
  53. package/templates/registry/trial-banner.json +2 -2
  54. package/templates/registry-items.json +3 -3
  55. package/templates/registry-requires.json +7 -5
package/src/bin.ts CHANGED
@@ -50,9 +50,11 @@ import {
50
50
  upgradeCheckExitCode,
51
51
  } from "./commands/upgrade-check.js";
52
52
 
53
+ import { itemNames, unknownFlags, wantsHelp } from "./args.js";
53
54
  import { loadAppEnv } from "./env-files.js";
55
+ import { workspaceRootVariable } from "./commands/create.js";
54
56
  import { resolveRegistryDir } from "./registry-bundle.js";
55
- import { readRegistryCatalogue } from "./registry-items.js";
57
+ import { findWorkspaceRoot, readRegistryCatalogue } from "./registry-items.js";
56
58
  import { MIGRATION_LOCATIONS, resolveMigrationsDir } from "./migrations-dir.js";
57
59
 
58
60
  /**
@@ -85,7 +87,7 @@ function usage(): string {
85
87
  "intelligo <command>",
86
88
  "",
87
89
  " create [dir] Scaffold an app, then install the registry pages you pick",
88
- " (--items a,b | --all, --yes, --no-install)",
90
+ " (--items a,b | --all, --yes, --no-install, --name <name>)",
89
91
  " doctor Report configuration and migration-chain problems",
90
92
  " migrate Apply the framework's migration chain to DATABASE_URL",
91
93
  " migrate --check Compare the framework's migrations to a database",
@@ -93,7 +95,8 @@ function usage(): string {
93
95
  " fresh | ahead | unmanaged | legacy)",
94
96
  " add <feature> Generate consumer-owned source (--force to overwrite)",
95
97
  " upgrade --check Show what a template upgrade would change",
96
- " sync [items…] Install registry pages from this release's registry,",
98
+ " sync [items…] Install registry pages from this release's registry",
99
+ " (names space- or comma-separated),",
97
100
  " keeping seams and merging messages (--force replaces",
98
101
  " hand-edited files; --check only reports, exit 1 on drift)",
99
102
  "",
@@ -178,17 +181,72 @@ async function runMigrate(
178
181
  }
179
182
  }
180
183
 
184
+ function flagsOk(
185
+ command: string,
186
+ args: readonly string[],
187
+ allowed: readonly string[]
188
+ ): boolean {
189
+ const unknown = unknownFlags(args, allowed);
190
+ if (unknown.length === 0) return true;
191
+ console.error(
192
+ `intelligo ${command}: unknown argument ${unknown.join(" ")}` +
193
+ (allowed.length ? ` (it takes ${allowed.join(", ")}).` : ".")
194
+ );
195
+ return false;
196
+ }
197
+
198
+ function runAdd(rest: readonly string[]): number {
199
+ if (!flagsOk("add", rest, ["--force"])) return 2;
200
+ const feature = rest.find((a) => !a.startsWith("--"));
201
+ if (!feature) {
202
+ const catalogue = readCatalogue(TEMPLATES_DIR);
203
+ console.error("Usage: intelligo add <feature>\n");
204
+ for (const [name, spec] of Object.entries(catalogue)) {
205
+ console.error(` ${name.padEnd(16)} ${spec.description}`);
206
+ }
207
+ return 1;
208
+ }
209
+ if (feature === "pnpm-standalone" && findWorkspaceRoot(process.cwd())) {
210
+ console.error(
211
+ "intelligo add pnpm-standalone: this app is a member of a pnpm workspace, and a " +
212
+ "workspace file of its own would take it out; the workspace root's settings apply."
213
+ );
214
+ return 1;
215
+ }
216
+ const result = addFeature(feature, {
217
+ appRoot: process.cwd(),
218
+ templatesDir: TEMPLATES_DIR,
219
+ frameworkVersion: FRAMEWORK_VERSION,
220
+ force: rest.includes("--force"),
221
+ // Computed, not recorded: an app made before the variable existed
222
+ // has no value for it.
223
+ variables:
224
+ feature === "app-scaffold"
225
+ ? workspaceRootVariable(process.cwd())
226
+ : undefined,
227
+ });
228
+ console.log(formatAddResult(result));
229
+ return addExitCode(result);
230
+ }
231
+
181
232
  async function main(): Promise<number> {
182
233
  const [, , command = "help", ...rest] = process.argv;
183
234
 
235
+ if (wantsHelp(rest)) {
236
+ console.log(usage());
237
+ return 0;
238
+ }
239
+
184
240
  // The commands that read the app's configuration see what the app
185
- // itself would: its .env.local and .env, under anything the shell set.
241
+ // itself would: its .env.local and .env, then the pnpm workspace
242
+ // root's, under anything the shell set.
186
243
  if (command === "doctor" || command === "migrate" || command === "upgrade") {
187
244
  loadAppEnv(process.cwd());
188
245
  }
189
246
 
190
247
  switch (command) {
191
248
  case "doctor": {
249
+ if (!flagsOk("doctor", rest, [])) return 2;
192
250
  const results = runChecks();
193
251
  console.log(formatResults(results));
194
252
  return exitCodeFor(results);
@@ -217,32 +275,24 @@ async function main(): Promise<number> {
217
275
  // command that converses.
218
276
  const { parseCreateFlags, runCreate } =
219
277
  await import("./commands/create-flow.js");
220
- return runCreate(parseCreateFlags(rest), {
221
- templatesDir: TEMPLATES_DIR,
222
- frameworkVersion: FRAMEWORK_VERSION,
223
- interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
224
- });
225
- }
226
- case "add": {
227
- const feature = rest.find((a) => !a.startsWith("--"));
228
- if (!feature) {
229
- const catalogue = readCatalogue(TEMPLATES_DIR);
230
- console.error("Usage: intelligo add <feature>\n");
231
- for (const [name, spec] of Object.entries(catalogue)) {
232
- console.error(` ${name.padEnd(16)} ${spec.description}`);
233
- }
234
- return 1;
278
+ const flags = parseCreateFlags(rest);
279
+ if (flags.unknown.length > 0) {
280
+ console.error(
281
+ `intelligo create: unknown argument ${flags.unknown.join(" ")}.\n`
282
+ );
283
+ console.error(usage());
284
+ return 2;
235
285
  }
236
- const result = addFeature(feature, {
237
- appRoot: process.cwd(),
286
+ return runCreate(flags, {
238
287
  templatesDir: TEMPLATES_DIR,
239
288
  frameworkVersion: FRAMEWORK_VERSION,
240
- force: rest.includes("--force"),
289
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
241
290
  });
242
- console.log(formatAddResult(result));
243
- return addExitCode(result);
244
291
  }
292
+ case "add":
293
+ return runAdd(rest);
245
294
  case "upgrade": {
295
+ if (!flagsOk("upgrade", rest, ["--check"])) return 2;
246
296
  if (!rest.includes("--check")) {
247
297
  console.error("Only `upgrade --check` is implemented.");
248
298
  console.error(
@@ -260,6 +310,7 @@ async function main(): Promise<number> {
260
310
  return upgradeCheckExitCode(report);
261
311
  }
262
312
  case "sync": {
313
+ if (!flagsOk("sync", rest, ["--check", "--force"])) return 2;
263
314
  const registryDir = resolveRegistryDir(TEMPLATES_DIR);
264
315
  if (!registryDir) {
265
316
  console.error(
@@ -280,10 +331,7 @@ async function main(): Promise<number> {
280
331
  "run the CLI of the same version (`pnpm exec intelligo`), or pages and packages will disagree."
281
332
  );
282
333
  }
283
- const selection = selectItems(
284
- rest.filter((a) => !a.startsWith("-")),
285
- context
286
- );
334
+ const selection = selectItems(itemNames(rest), context);
287
335
  if (!selection.ok) {
288
336
  console.error(selection.message);
289
337
  return 1;
@@ -298,6 +346,8 @@ async function main(): Promise<number> {
298
346
  });
299
347
  }
300
348
  case "help":
349
+ case "--help":
350
+ case "-h":
301
351
  console.log(usage());
302
352
  return 0;
303
353
  default:
@@ -85,6 +85,9 @@ export function substitute(
85
85
  );
86
86
  }
87
87
 
88
+ /** A `__NAME__` placeholder the variables did not fill. */
89
+ const PLACEHOLDER = /__[A-Z][A-Z0-9_]*__/;
90
+
88
91
  const VERCEL_CONFIG = "vercel.json";
89
92
 
90
93
  /**
@@ -145,16 +148,35 @@ export function addFeature(feature: string, options: AddOptions): AddResult {
145
148
  const recorded: GeneratedFile[] = [];
146
149
  const handedOver = new Set(previous?.handedOver ?? []);
147
150
 
151
+ // Re-generating a feature substitutes what it was generated with, so
152
+ // `add app-scaffold` in an app `create` made takes the app's own name;
153
+ // values passed now fill in or replace them.
154
+ const variables =
155
+ previous?.variables || options.variables
156
+ ? { ...previous?.variables, ...options.variables }
157
+ : undefined;
158
+
159
+ // Checked before anything is written, so a refusal leaves no half-made
160
+ // feature behind.
161
+ for (const file of spec.files) {
162
+ const unfilled = substitute(
163
+ readFileSync(path.join(options.templatesDir, file.template), "utf8"),
164
+ variables
165
+ ).match(PLACEHOLDER);
166
+ if (unfilled) {
167
+ throw new Error(
168
+ `${feature} needs a value for ${unfilled[0]} that only \`intelligo create\` supplies — ` +
169
+ "run `intelligo create <directory>`, or `intelligo create .` in an empty one."
170
+ );
171
+ }
172
+ }
173
+
148
174
  for (const file of spec.files) {
149
175
  // A registry item replaced it; `intelligo sync` keeps it now.
150
176
  if (handedOver.has(file.target)) continue;
151
177
  const source = path.join(options.templatesDir, file.template);
152
178
  const target = path.join(options.appRoot, file.target);
153
- const contents = substitute(
154
- readFileSync(source, "utf8"),
155
- options.variables
156
- );
157
-
179
+ const contents = substitute(readFileSync(source, "utf8"), variables);
158
180
  if (existsSync(target) && !options.force) {
159
181
  const current = hashContents(readFileSync(target, "utf8"));
160
182
  const known = previousByPath.get(file.target);
@@ -190,13 +212,7 @@ export function addFeature(feature: string, options: AddOptions): AddResult {
190
212
 
191
213
  writeManifest(
192
214
  options.appRoot,
193
- recordFeature(
194
- manifest,
195
- feature,
196
- spec.templateVersion,
197
- recorded,
198
- options.variables
199
- )
215
+ recordFeature(manifest, feature, spec.templateVersion, recorded, variables)
200
216
  );
201
217
 
202
218
  return result;
@@ -24,7 +24,7 @@ import {
24
24
  formatNextSteps,
25
25
  isOccupied,
26
26
  } from "./create.js";
27
- import { syncApply, type SyncContext } from "./sync.js";
27
+ import { recordItems, syncApply, type SyncContext } from "./sync.js";
28
28
  import { resolveRegistryDir } from "../registry-bundle.js";
29
29
  import {
30
30
  detectPackageManager,
@@ -54,11 +54,25 @@ export type CreateFlags = {
54
54
  * range, because that is what works everywhere else.
55
55
  */
56
56
  linkWorkspace: boolean;
57
+ /** `--name <name>` — the project name, when it is not the directory's. */
58
+ name?: string;
59
+ /** Flags `create` does not know. */
60
+ unknown: string[];
57
61
  };
58
62
 
63
+ const CREATE_FLAGS = [
64
+ "--all",
65
+ "--yes",
66
+ "-y",
67
+ "--no-install",
68
+ "--link-workspace",
69
+ ];
70
+
59
71
  export function parseCreateFlags(args: readonly string[]): CreateFlags {
60
72
  let target: string | undefined;
61
73
  let items: string[] | undefined;
74
+ let name: string | undefined;
75
+ const unknown: string[] = [];
62
76
  for (let i = 0; i < args.length; i++) {
63
77
  const arg = args[i]!;
64
78
  if (arg === "--items" || arg.startsWith("--items=")) {
@@ -67,13 +81,23 @@ export function parseCreateFlags(args: readonly string[]): CreateFlags {
67
81
  .split(",")
68
82
  .map((s) => s.trim())
69
83
  .filter(Boolean);
70
- } else if (!arg.startsWith("-") && target === undefined) {
84
+ } else if (arg === "--name" || arg.startsWith("--name=")) {
85
+ const value = arg === "--name" ? args[++i] : arg.slice(7);
86
+ if (!value?.trim() || value.startsWith("-")) unknown.push(arg);
87
+ else name = value;
88
+ } else if (arg.startsWith("-")) {
89
+ if (!CREATE_FLAGS.includes(arg)) unknown.push(arg);
90
+ } else if (target === undefined) {
71
91
  target = arg;
92
+ } else {
93
+ unknown.push(arg);
72
94
  }
73
95
  }
74
96
  return {
75
97
  target,
76
98
  items,
99
+ name,
100
+ unknown,
77
101
  all: args.includes("--all"),
78
102
  yes: args.includes("--yes") || args.includes("-y"),
79
103
  install: !args.includes("--no-install"),
@@ -164,7 +188,7 @@ export async function runCreate(
164
188
  if (!target) {
165
189
  if (!interactive) {
166
190
  console.error(
167
- "Usage: intelligo create <directory> [--items a,b | --all] [--yes] [--no-install]"
191
+ "Usage: intelligo create <directory> [--items a,b | --all] [--yes] [--no-install] [--name <name>]"
168
192
  );
169
193
  return 1;
170
194
  }
@@ -218,15 +242,25 @@ export async function runCreate(
218
242
  templatesDir: context.templatesDir,
219
243
  frameworkVersion: context.frameworkVersion,
220
244
  linkWorkspace: flags.linkWorkspace,
245
+ name: flags.name,
246
+ packageManager,
221
247
  });
222
248
  const appRoot = path.resolve(target);
223
249
  if (interactive) {
224
250
  p.log.success(`Scaffolded ${result.written.length} files in ${target}`);
225
251
  }
226
252
 
227
- // 4. The pages — only with approval.
253
+ // 4. The pages — only with approval, but recorded now, so a bare
254
+ // `intelligo sync` installs them if this install does not.
228
255
  const workspaceRoot = findWorkspaceRoot(appRoot);
229
256
  const plan = installPlan(items, { appRoot, packageManager, workspaceRoot });
257
+ if (plan) {
258
+ recordItems(plan.items, {
259
+ appRoot,
260
+ requires: catalogue.requires,
261
+ frameworkVersion: context.frameworkVersion,
262
+ });
263
+ }
230
264
  const commands: Command[] = plan
231
265
  ? [...(plan.install ? [plan.install] : []), plan.sync]
232
266
  : [];
@@ -20,8 +20,14 @@
20
20
  import { existsSync, mkdirSync, readdirSync } from "node:fs";
21
21
  import path from "node:path";
22
22
 
23
- import { addFeature, formatAddResult, type AddResult } from "./add.js";
24
23
  import {
24
+ addFeature,
25
+ formatAddResult,
26
+ readCatalogue,
27
+ type AddResult,
28
+ } from "./add.js";
29
+ import {
30
+ findWorkspaceRoot,
25
31
  formatCommand,
26
32
  type Command,
27
33
  type PackageManager,
@@ -39,6 +45,15 @@ export type CreateOptions = {
39
45
  * protocol used outside a workspace".
40
46
  */
41
47
  linkWorkspace?: boolean;
48
+ /** The project name, when it is not the directory's. */
49
+ name?: string;
50
+ /**
51
+ * The package manager that will install the app. Under pnpm, an app
52
+ * outside any workspace also gets `pnpm-standalone`: its own
53
+ * workspace file declining the dependency build scripts pnpm 10+
54
+ * would otherwise stop the install over.
55
+ */
56
+ packageManager?: PackageManager;
42
57
  };
43
58
 
44
59
  /** A package name and product slug derived from the directory name. */
@@ -57,17 +72,23 @@ export function deriveNames(target: string): {
57
72
  return { appName: slug, appSlug: slug };
58
73
  }
59
74
 
75
+ /** A fresh repository — `git init`, then `intelligo create .` — is still empty. */
76
+ const IGNORED_ENTRIES = new Set([".git"]);
77
+
60
78
  export function isOccupied(target: string): boolean {
61
79
  const dir = path.resolve(target);
62
- return existsSync(dir) && readdirSync(dir).length > 0;
80
+ return (
81
+ existsSync(dir) &&
82
+ readdirSync(dir).some((entry) => !IGNORED_ENTRIES.has(entry))
83
+ );
63
84
  }
64
85
 
65
86
  /** Scaffolding into an occupied directory is how people lose work. */
66
87
  export function assertNotOccupied(target: string): void {
67
88
  if (isOccupied(target)) {
68
89
  throw new Error(
69
- `${path.resolve(target)} is not empty. Create the app in a new directory, or use ` +
70
- `\`intelligo add app-scaffold\` inside an existing one.`
90
+ `${path.resolve(target)} is not empty. Create the app in a new directory, ` +
91
+ "or in an empty one (a .git directory may already be there)."
71
92
  );
72
93
  }
73
94
  }
@@ -76,11 +97,13 @@ export function createApp(options: CreateOptions): AddResult {
76
97
  const target = path.resolve(options.target);
77
98
 
78
99
  assertNotOccupied(target);
100
+ // Before the directory exists, so a name that slugifies to nothing
101
+ // leaves nothing behind.
102
+ const { appName, appSlug } = deriveNames(options.name || target);
79
103
  mkdirSync(target, { recursive: true });
104
+ const workspaceRoot = findWorkspaceRoot(target);
80
105
 
81
- const { appName, appSlug } = deriveNames(target);
82
-
83
- return addFeature("app-scaffold", {
106
+ const scaffold = addFeature("app-scaffold", {
84
107
  appRoot: target,
85
108
  templatesDir: options.templatesDir,
86
109
  frameworkVersion: options.frameworkVersion,
@@ -90,8 +113,43 @@ export function createApp(options: CreateOptions): AddResult {
90
113
  __INTELLIGO_DEP__: options.linkWorkspace
91
114
  ? "workspace:*"
92
115
  : `^${options.frameworkVersion}`,
116
+ ...workspaceRootVariable(target),
93
117
  },
94
118
  });
119
+
120
+ // A member of a parent workspace installs from that root; a workspace
121
+ // file of its own would take it out of it.
122
+ const standalone =
123
+ options.packageManager === "pnpm" &&
124
+ workspaceRoot === null &&
125
+ STANDALONE in readCatalogue(options.templatesDir);
126
+ if (!standalone) return scaffold;
127
+
128
+ const pnpm = addFeature(STANDALONE, {
129
+ appRoot: target,
130
+ templatesDir: options.templatesDir,
131
+ frameworkVersion: options.frameworkVersion,
132
+ });
133
+ return { ...scaffold, written: [...scaffold.written, ...pnpm.written] };
134
+ }
135
+
136
+ const STANDALONE = "pnpm-standalone";
137
+
138
+ /**
139
+ * `__WORKSPACE_ROOT__` for the scaffold's next.config: a JavaScript
140
+ * literal naming the enclosing pnpm workspace's root relative to the
141
+ * app, or null when the app is not a member of one — the rule `doctor`
142
+ * and `migrate` load env files by.
143
+ */
144
+ export function workspaceRootVariable(target: string): Record<string, string> {
145
+ const root = findWorkspaceRoot(path.resolve(target));
146
+ return {
147
+ __WORKSPACE_ROOT__: root
148
+ ? JSON.stringify(
149
+ path.relative(path.resolve(target), root).split(path.sep).join("/")
150
+ )
151
+ : "null",
152
+ };
95
153
  }
96
154
 
97
155
  export type NextSteps = {
@@ -41,6 +41,8 @@ type ItemRequires = {
41
41
 
42
42
  export type RegistryRequires = {
43
43
  scaffold: string[];
44
+ /** Files an item ships once and the deployment then owns. */
45
+ seams?: Record<string, string>;
44
46
  items: Record<string, ItemRequires>;
45
47
  };
46
48
 
@@ -256,6 +258,103 @@ function checkModelIds(root: string, source: string): CheckResult[] {
256
258
  const OPAQUE_REGISTRATION =
257
259
  /\bregisterModels?\s*\(\s*(?![\s[{]|DEFAULT_MODELS\s*[,)])/;
258
260
 
261
+ /**
262
+ * A seam's feature keys are the deployment's own choice, so they are
263
+ * not an item requirement; a literal key the app's copy still names but
264
+ * lib/plans.ts does not register is worth a warning.
265
+ */
266
+ function checkSeamFeatures(
267
+ root: string,
268
+ seams: Record<string, string> | undefined,
269
+ plans: ReturnType<typeof reexportedSource> | null
270
+ ): CheckResult[] {
271
+ if (!plans || plans.unresolved.length) return [];
272
+ const results: CheckResult[] = [];
273
+ for (const seam of Object.keys(seams ?? {})) {
274
+ const abs = path.join(root, seam);
275
+ if (!existsSync(abs)) continue;
276
+ const keys = new Set(
277
+ [
278
+ ...readFileSync(abs, "utf8").matchAll(
279
+ /featureKey:\s*["']([^"']+)["']/g
280
+ ),
281
+ ].map((m) => m[1]!)
282
+ );
283
+ const unregistered = [...keys].filter(
284
+ (key) => !hasObjectKey(plans.text, key)
285
+ );
286
+ if (unregistered.length === 0) continue;
287
+ results.push({
288
+ name: `seam:${seam}`,
289
+ status: "warn",
290
+ detail: `${unregistered.map((key) => `"${key}"`).join(", ")} ${unregistered.length === 1 ? "is" : "are"} not registered in lib/plans.ts — a gate on an unregistered key is denied (403)`,
291
+ });
292
+ }
293
+ return results;
294
+ }
295
+
296
+ const VITEST_CONFIGS = [
297
+ "vitest.config.ts",
298
+ "vitest.config.mts",
299
+ "vitest.config.cts",
300
+ "vitest.config.js",
301
+ "vitest.config.mjs",
302
+ "vitest.config.cjs",
303
+ ];
304
+
305
+ /** `PRODUCT_SLUG = "acme"` or `setDefaultProductSlug("acme")` in the composition root. */
306
+ const PRODUCT_SLUG_LITERAL =
307
+ /(?:\bPRODUCT_SLUG\s*=|\bsetDefaultProductSlug\s*\()\s*["'`]([^"'`]+)["'`]/;
308
+
309
+ function checkBillingProduct(
310
+ fromEnv: string | undefined,
311
+ rootSource: string | null
312
+ ): CheckResult {
313
+ const setsDefault =
314
+ rootSource !== null && /\bsetDefaultProductSlug\s*\(/.test(rootSource);
315
+ const inCode = rootSource?.match(PRODUCT_SLUG_LITERAL)?.[1];
316
+ if (fromEnv && setsDefault && inCode && fromEnv !== inCode) {
317
+ return {
318
+ name: "billing",
319
+ status: "warn",
320
+ detail:
321
+ `INTELLIGO_BILLING_PRODUCT is "${fromEnv}" but the composition root sets "${inCode}". ` +
322
+ "At runtime the composition root wins; the variable only misleads tooling that reads it — make them agree",
323
+ };
324
+ }
325
+ if (fromEnv) {
326
+ return { name: "billing", status: "ok", detail: `Product: ${fromEnv}` };
327
+ }
328
+ if (setsDefault) {
329
+ return {
330
+ name: "billing",
331
+ status: "ok",
332
+ detail: `Product: ${inCode ?? "set"} by setDefaultProductSlug() in the composition root`,
333
+ };
334
+ }
335
+ return {
336
+ name: "billing",
337
+ status: "warn",
338
+ detail:
339
+ "no product — set INTELLIGO_BILLING_PRODUCT, or call setDefaultProductSlug() " +
340
+ "in the composition root, or plan lookups resolve to nothing",
341
+ };
342
+ }
343
+
344
+ /**
345
+ * Whether `name` is installed where the app resolves packages from: its
346
+ * own node_modules or any directory above it, a workspace root's
347
+ * included.
348
+ */
349
+ function resolvesFrom(root: string, name: string): boolean {
350
+ for (let dir = path.resolve(root); ; dir = path.dirname(dir)) {
351
+ if (existsSync(path.join(dir, "node_modules", name, "package.json"))) {
352
+ return true;
353
+ }
354
+ if (path.dirname(dir) === dir) return false;
355
+ }
356
+ }
357
+
259
358
  export function runChecks(options: DoctorOptions = {}): CheckResult[] {
260
359
  const root = options.root ?? process.cwd();
261
360
  const env = options.env ?? process.env;
@@ -294,22 +393,15 @@ export function runChecks(options: DoctorOptions = {}): CheckResult[] {
294
393
 
295
394
  // 3. Billing product. The engine has no built-in default catalogue;
296
395
  // an unset product means every plan lookup returns nothing and
297
- // quotas silently read as zero.
298
- results.push(
299
- env.INTELLIGO_BILLING_PRODUCT
300
- ? {
301
- name: "billing",
302
- status: "ok",
303
- detail: `Product: ${env.INTELLIGO_BILLING_PRODUCT}`,
304
- }
305
- : {
306
- name: "billing",
307
- status: "warn",
308
- detail:
309
- "INTELLIGO_BILLING_PRODUCT unset — the composition root must call " +
310
- "setDefaultProductSlug(), or plan lookups resolve to nothing",
311
- }
312
- );
396
+ // quotas silently read as zero. The composition root sets it at
397
+ // boot; the variable lets tooling see it without booting.
398
+ const compositionRoot = ["lib/intelligo.ts", "lib/intelligo.tsx"]
399
+ .map((rel) => path.join(root, rel))
400
+ .find((file) => existsSync(file));
401
+ const rootSource = compositionRoot
402
+ ? stripComments(readFileSync(compositionRoot, "utf8"))
403
+ : null;
404
+ results.push(checkBillingProduct(env.INTELLIGO_BILLING_PRODUCT, rootSource));
313
405
 
314
406
  // 4. Installed registry items against registry/requires.json: the
315
407
  // sibling items they import from, the scaffold files they import,
@@ -383,6 +475,8 @@ export function runChecks(options: DoctorOptions = {}): CheckResult[] {
383
475
  }
384
476
  );
385
477
  }
478
+
479
+ results.push(...checkSeamFeatures(root, requires.seams, plans));
386
480
  }
387
481
 
388
482
  // 4b. Maintenance route. It refuses to serve without a strong
@@ -428,10 +522,6 @@ export function runChecks(options: DoctorOptions = {}): CheckResult[] {
428
522
  // admission with no price to estimate against: every request is
429
523
  // refused with `unknown_model`, at runtime, on a deployment whose
430
524
  // only mistake was omitting one line.
431
- const compositionRoot = ["lib/intelligo.ts", "lib/intelligo.tsx"]
432
- .map((rel) => path.join(root, rel))
433
- .find((file) => existsSync(file));
434
-
435
525
  if (compositionRoot) {
436
526
  const source = readFileSync(compositionRoot, "utf8");
437
527
  const registers = /\bregisterModels?\s*\(/.test(stripComments(source));
@@ -456,6 +546,25 @@ export function runChecks(options: DoctorOptions = {}): CheckResult[] {
456
546
  if (registers) results.push(...checkModelIds(root, source));
457
547
  }
458
548
 
549
+ // 4e. A test config whose runner is not installed fails at the first
550
+ // `vitest run`, with a module error that does not say why.
551
+ const vitestConfig = VITEST_CONFIGS.find((file) =>
552
+ existsSync(path.join(root, file))
553
+ );
554
+ if (vitestConfig) {
555
+ results.push(
556
+ resolvesFrom(root, "vitest")
557
+ ? { name: "tests", status: "ok", detail: "vitest is installed" }
558
+ : {
559
+ name: "tests",
560
+ status: "warn",
561
+ detail:
562
+ `${vitestConfig} is here but vitest does not resolve from the app — ` +
563
+ "`pnpm add -D vitest`",
564
+ }
565
+ );
566
+ }
567
+
459
568
  // 5. Generated source. A conflict — template and consumer both
460
569
  // moved — is the one state an upgrade cannot resolve on its own.
461
570
  const manifest = readManifest(root);