@intelligo-dev/cli 1.0.0-beta.13 → 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 (188) hide show
  1. package/README.md +65 -2
  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 +103 -22
  7. package/dist/bin.js.map +1 -1
  8. package/dist/commands/add.d.ts +4 -0
  9. package/dist/commands/add.d.ts.map +1 -1
  10. package/dist/commands/add.js +28 -2
  11. package/dist/commands/add.js.map +1 -1
  12. package/dist/commands/create-flow.d.ts +8 -1
  13. package/dist/commands/create-flow.d.ts.map +1 -1
  14. package/dist/commands/create-flow.js +101 -34
  15. package/dist/commands/create-flow.js.map +1 -1
  16. package/dist/commands/create.d.ts +24 -4
  17. package/dist/commands/create.d.ts.map +1 -1
  18. package/dist/commands/create.js +57 -14
  19. package/dist/commands/create.js.map +1 -1
  20. package/dist/commands/doctor.d.ts +2 -0
  21. package/dist/commands/doctor.d.ts.map +1 -1
  22. package/dist/commands/doctor.js +123 -35
  23. package/dist/commands/doctor.js.map +1 -1
  24. package/dist/commands/migrate-check.d.ts +20 -1
  25. package/dist/commands/migrate-check.d.ts.map +1 -1
  26. package/dist/commands/migrate-check.js +35 -6
  27. package/dist/commands/migrate-check.js.map +1 -1
  28. package/dist/commands/migrate.d.ts.map +1 -1
  29. package/dist/commands/migrate.js +2 -8
  30. package/dist/commands/migrate.js.map +1 -1
  31. package/dist/commands/sync-messages.d.ts +37 -0
  32. package/dist/commands/sync-messages.d.ts.map +1 -0
  33. package/dist/commands/sync-messages.js +88 -0
  34. package/dist/commands/sync-messages.js.map +1 -0
  35. package/dist/commands/sync-scaffold.d.ts +36 -0
  36. package/dist/commands/sync-scaffold.d.ts.map +1 -0
  37. package/dist/commands/sync-scaffold.js +53 -0
  38. package/dist/commands/sync-scaffold.js.map +1 -0
  39. package/dist/commands/sync.d.ts +109 -0
  40. package/dist/commands/sync.d.ts.map +1 -0
  41. package/dist/commands/sync.js +392 -0
  42. package/dist/commands/sync.js.map +1 -0
  43. package/dist/commands/upgrade-check.d.ts.map +1 -1
  44. package/dist/commands/upgrade-check.js +4 -1
  45. package/dist/commands/upgrade-check.js.map +1 -1
  46. package/dist/env-files.d.ts +11 -0
  47. package/dist/env-files.d.ts.map +1 -1
  48. package/dist/env-files.js +28 -9
  49. package/dist/env-files.js.map +1 -1
  50. package/dist/manifest.d.ts +27 -0
  51. package/dist/manifest.d.ts.map +1 -1
  52. package/dist/manifest.js +30 -3
  53. package/dist/manifest.js.map +1 -1
  54. package/dist/module-exports.d.ts +63 -0
  55. package/dist/module-exports.d.ts.map +1 -0
  56. package/dist/module-exports.js +231 -0
  57. package/dist/module-exports.js.map +1 -0
  58. package/dist/registry-bundle.d.ts +43 -0
  59. package/dist/registry-bundle.d.ts.map +1 -0
  60. package/dist/registry-bundle.js +74 -0
  61. package/dist/registry-bundle.js.map +1 -0
  62. package/dist/registry-items.d.ts +41 -13
  63. package/dist/registry-items.d.ts.map +1 -1
  64. package/dist/registry-items.js +117 -33
  65. package/dist/registry-items.js.map +1 -1
  66. package/package.json +1 -1
  67. package/src/args.ts +25 -0
  68. package/src/bin.ts +124 -22
  69. package/src/commands/add.ts +39 -12
  70. package/src/commands/create-flow.ts +119 -29
  71. package/src/commands/create.ts +82 -14
  72. package/src/commands/doctor.ts +156 -47
  73. package/src/commands/migrate-check.ts +51 -10
  74. package/src/commands/migrate.ts +2 -8
  75. package/src/commands/sync-messages.ts +114 -0
  76. package/src/commands/sync-scaffold.ts +83 -0
  77. package/src/commands/sync.ts +574 -0
  78. package/src/commands/upgrade-check.ts +4 -1
  79. package/src/env-files.ts +28 -7
  80. package/src/manifest.ts +57 -3
  81. package/src/module-exports.ts +266 -0
  82. package/src/registry-bundle.ts +103 -0
  83. package/src/registry-items.ts +141 -36
  84. package/templates/admin-page/admin-page.tsx.tpl +96 -24
  85. package/templates/app-scaffold/gitignore.tpl +25 -0
  86. package/templates/app-scaffold/intelligo.ts.tpl +6 -47
  87. package/templates/app-scaffold/next.config.mjs.tpl +31 -2
  88. package/templates/app-scaffold/package.json.tpl +2 -2
  89. package/templates/manifest.json +40 -3
  90. package/templates/pnpm-standalone/npmrc.tpl +6 -0
  91. package/templates/pnpm-standalone/pnpm-workspace.yaml.tpl +10 -0
  92. package/templates/registry/ai-agent-activity.json +23 -0
  93. package/templates/registry/ai-agent-progress.json +21 -0
  94. package/templates/registry/ai-approval-card.json +27 -0
  95. package/templates/registry/ai-artifact.json +22 -0
  96. package/templates/registry/ai-branch.json +21 -0
  97. package/templates/registry/ai-citations.json +24 -0
  98. package/templates/registry/ai-code-block.json +23 -0
  99. package/templates/registry/ai-composer-menu.json +19 -0
  100. package/templates/registry/ai-file-diff.json +25 -0
  101. package/templates/registry/ai-image-generation.json +23 -0
  102. package/templates/registry/ai-markdown.json +23 -0
  103. package/templates/registry/ai-message-bubble.json +23 -0
  104. package/templates/registry/ai-message-scroller.json +22 -0
  105. package/templates/registry/ai-message.json +21 -0
  106. package/templates/registry/ai-motion.json +19 -0
  107. package/templates/registry/ai-prompt-input.json +28 -0
  108. package/templates/registry/ai-reasoning-text.json +22 -0
  109. package/templates/registry/ai-reasoning.json +22 -0
  110. package/templates/registry/ai-shimmer-text.json +19 -0
  111. package/templates/registry/ai-sidebar.json +25 -0
  112. package/templates/registry/ai-speech-input.json +21 -0
  113. package/templates/registry/ai-streaming-response.json +24 -0
  114. package/templates/registry/ai-suggestion.json +19 -0
  115. package/templates/registry/ai-todo-list.json +22 -0
  116. package/templates/registry/ai-tool-approval.json +26 -0
  117. package/templates/registry/ai-tool-result.json +25 -0
  118. package/templates/registry/alert-dialog.json +23 -0
  119. package/templates/registry/animated-list.json +21 -0
  120. package/templates/registry/app-shell.json +93 -0
  121. package/templates/registry/artifacts.json +82 -0
  122. package/templates/registry/attachment.json +22 -0
  123. package/templates/registry/auth-email-verification.json +39 -0
  124. package/templates/registry/auth-login.json +72 -0
  125. package/templates/registry/auth-password-reset.json +53 -0
  126. package/templates/registry/auth-signup.json +41 -0
  127. package/templates/registry/billing-settings.json +66 -0
  128. package/templates/registry/button.json +22 -0
  129. package/templates/registry/chat-eve.json +19 -0
  130. package/templates/registry/chat-panel.json +30 -0
  131. package/templates/registry/chat-share.json +42 -0
  132. package/templates/registry/chat-widget.json +34 -0
  133. package/templates/registry/chat.json +326 -0
  134. package/templates/registry/checkbox.json +22 -0
  135. package/templates/registry/checkout.json +40 -0
  136. package/templates/registry/collapsible.json +19 -0
  137. package/templates/registry/command.json +23 -0
  138. package/templates/registry/copy-button.json +21 -0
  139. package/templates/registry/dashboard.json +69 -0
  140. package/templates/registry/dialog.json +24 -0
  141. package/templates/registry/document-viewer.json +22 -0
  142. package/templates/registry/dropdown-menu.json +23 -0
  143. package/templates/registry/expandable-tabs.json +22 -0
  144. package/templates/registry/feature-gating.json +73 -0
  145. package/templates/registry/file-upload.json +24 -0
  146. package/templates/registry/hold-action-button.json +22 -0
  147. package/templates/registry/input-group.json +23 -0
  148. package/templates/registry/input.json +19 -0
  149. package/templates/registry/intelligo.json +187 -0
  150. package/templates/registry/invitation-accept.json +64 -0
  151. package/templates/registry/language-switcher.json +29 -0
  152. package/templates/registry/morphing-modal.json +24 -0
  153. package/templates/registry/notification-stack.json +24 -0
  154. package/templates/registry/notifications.json +87 -0
  155. package/templates/registry/onboarding.json +83 -0
  156. package/templates/registry/otp-input.json +22 -0
  157. package/templates/registry/page-header.json +15 -0
  158. package/templates/registry/payment-poll.json +58 -0
  159. package/templates/registry/popover-morph.json +22 -0
  160. package/templates/registry/popover.json +22 -0
  161. package/templates/registry/pricing.json +117 -0
  162. package/templates/registry/privacy-settings.json +65 -0
  163. package/templates/registry/profile-settings.json +68 -0
  164. package/templates/registry/progress.json +19 -0
  165. package/templates/registry/radio-group.json +22 -0
  166. package/templates/registry/registry.json +2960 -0
  167. package/templates/registry/route-error.json +65 -0
  168. package/templates/registry/select-morph.json +23 -0
  169. package/templates/registry/select.json +23 -0
  170. package/templates/registry/settings-shell.json +47 -0
  171. package/templates/registry/sheet.json +24 -0
  172. package/templates/registry/sidebar.json +28 -0
  173. package/templates/registry/smoke.json +34 -0
  174. package/templates/registry/spinner.json +16 -0
  175. package/templates/registry/stat-card.json +18 -0
  176. package/templates/registry/status-badge.json +18 -0
  177. package/templates/registry/switch.json +20 -0
  178. package/templates/registry/tabs.json +23 -0
  179. package/templates/registry/team-settings.json +97 -0
  180. package/templates/registry/textarea.json +16 -0
  181. package/templates/registry/tooltip.json +22 -0
  182. package/templates/registry/trial-banner.json +43 -0
  183. package/templates/registry/usage.json +84 -0
  184. package/templates/registry/workspace-settings.json +71 -0
  185. package/templates/registry-items.json +3 -3
  186. package/templates/registry-requires.json +30 -4
  187. package/templates/vitest/server-only.ts.tpl +7 -0
  188. package/templates/vitest/vitest.config.ts.tpl +37 -0
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * The conversation around `createApp`: ask what the flags did not say,
3
3
  * scaffold, and install the chosen registry items once the developer
4
- * has approved the commands that do it.
4
+ * has approved the commands that do it. The items go in through
5
+ * `intelligo sync` — one `shadcn add` per item from the registry this
6
+ * CLI carries, recorded in the manifest — exactly as a later sync
7
+ * would put them back.
5
8
  *
6
9
  * Nothing runs on the developer's machine without their say-so. In a
7
10
  * terminal the commands are shown and confirmed; without one (CI, a
@@ -21,15 +24,18 @@ import {
21
24
  formatNextSteps,
22
25
  isOccupied,
23
26
  } from "./create.js";
27
+ import { recordItems, syncApply, type SyncContext } from "./sync.js";
28
+ import { resolveRegistryDir } from "../registry-bundle.js";
24
29
  import {
25
30
  detectPackageManager,
31
+ findWorkspaceRoot,
26
32
  formatCommand,
27
- hasLocalShadcn,
28
33
  installPlan,
29
34
  readRegistryCatalogue,
30
35
  shortDescription,
31
36
  withDependencies,
32
37
  type Command,
38
+ type InstallPlan,
33
39
  } from "../registry-items.js";
34
40
 
35
41
  export type CreateFlags = {
@@ -48,11 +54,25 @@ export type CreateFlags = {
48
54
  * range, because that is what works everywhere else.
49
55
  */
50
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[];
51
61
  };
52
62
 
63
+ const CREATE_FLAGS = [
64
+ "--all",
65
+ "--yes",
66
+ "-y",
67
+ "--no-install",
68
+ "--link-workspace",
69
+ ];
70
+
53
71
  export function parseCreateFlags(args: readonly string[]): CreateFlags {
54
72
  let target: string | undefined;
55
73
  let items: string[] | undefined;
74
+ let name: string | undefined;
75
+ const unknown: string[] = [];
56
76
  for (let i = 0; i < args.length; i++) {
57
77
  const arg = args[i]!;
58
78
  if (arg === "--items" || arg.startsWith("--items=")) {
@@ -61,13 +81,23 @@ export function parseCreateFlags(args: readonly string[]): CreateFlags {
61
81
  .split(",")
62
82
  .map((s) => s.trim())
63
83
  .filter(Boolean);
64
- } 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) {
65
91
  target = arg;
92
+ } else {
93
+ unknown.push(arg);
66
94
  }
67
95
  }
68
96
  return {
69
97
  target,
70
98
  items,
99
+ name,
100
+ unknown,
71
101
  all: args.includes("--all"),
72
102
  yes: args.includes("--yes") || args.includes("-y"),
73
103
  install: !args.includes("--no-install"),
@@ -97,6 +127,47 @@ function run(c: Command, cwd: string): boolean {
97
127
  return result.status === 0;
98
128
  }
99
129
 
130
+ /**
131
+ * Run the plan: the dependency install, then the sync. Returns the
132
+ * commands still to run by hand — none when both succeeded.
133
+ */
134
+ async function installPages(
135
+ plan: InstallPlan,
136
+ options: {
137
+ appRoot: string;
138
+ interactive: boolean;
139
+ show: (c: Command) => string;
140
+ syncContext: SyncContext;
141
+ }
142
+ ): Promise<Command[]> {
143
+ const { appRoot, interactive, show } = options;
144
+ const say = (line: string) =>
145
+ interactive ? p.log.step(line) : console.log(line);
146
+ const fail = (message: string) =>
147
+ interactive ? p.log.error(message) : console.error(message);
148
+ if (plan.install) {
149
+ say(`› ${show(plan.install)}`);
150
+ if (!run(plan.install, plan.install.cwd ?? appRoot)) {
151
+ fail(
152
+ `\`${show(plan.install)}\` failed — the scaffold is in place; run the rest by hand.`
153
+ );
154
+ return [plan.install, plan.sync];
155
+ }
156
+ }
157
+ say(`› ${show(plan.sync)}`);
158
+ const code = await syncApply(plan.items, options.syncContext, {
159
+ force: true,
160
+ log: say,
161
+ });
162
+ if (code !== 0) {
163
+ fail(
164
+ "The pages did not all install — the scaffold is in place; re-run the sync by hand."
165
+ );
166
+ return [plan.sync];
167
+ }
168
+ return [];
169
+ }
170
+
100
171
  export async function runCreate(
101
172
  flags: CreateFlags,
102
173
  context: {
@@ -117,7 +188,7 @@ export async function runCreate(
117
188
  if (!target) {
118
189
  if (!interactive) {
119
190
  console.error(
120
- "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>]"
121
192
  );
122
193
  return 1;
123
194
  }
@@ -171,21 +242,41 @@ export async function runCreate(
171
242
  templatesDir: context.templatesDir,
172
243
  frameworkVersion: context.frameworkVersion,
173
244
  linkWorkspace: flags.linkWorkspace,
245
+ name: flags.name,
246
+ packageManager,
174
247
  });
175
248
  const appRoot = path.resolve(target);
176
249
  if (interactive) {
177
250
  p.log.success(`Scaffolded ${result.written.length} files in ${target}`);
178
251
  }
179
252
 
180
- // 4. The pages — only with approval.
181
- const plan = installPlan(items, { appRoot, packageManager });
182
- let approved = plan.length > 0 && flags.install && flags.yes;
183
- if (plan.length > 0 && flags.install && !flags.yes && interactive) {
184
- const needsInstall = !hasLocalShadcn(appRoot);
185
- p.note(plan.map(formatCommand).join("\n"), "Commands to run in " + target);
253
+ // 4. The pages — only with approval, but recorded now, so a bare
254
+ // `intelligo sync` installs them if this install does not.
255
+ const workspaceRoot = findWorkspaceRoot(appRoot);
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
+ }
264
+ const commands: Command[] = plan
265
+ ? [...(plan.install ? [plan.install] : []), plan.sync]
266
+ : [];
267
+ const show = (c: Command) => formatCommand(c, appRoot);
268
+ const registryDir = resolveRegistryDir(context.templatesDir);
269
+ if (plan && flags.install && !registryDir) {
270
+ console.error(
271
+ "This CLI has no bundled registry to install pages from — in the framework repository, run `pnpm registry:build` first."
272
+ );
273
+ }
274
+ let approved = plan !== null && flags.install && flags.yes && !!registryDir;
275
+ if (plan && flags.install && registryDir && !flags.yes && interactive) {
276
+ p.note(commands.map(show).join("\n"), "Commands to run in " + target);
186
277
  const answer = await p.confirm({
187
- message: needsInstall
188
- ? `shadcn is not installed yet — install the dependencies (shadcn included) with ${packageManager} and add ${items.length} page(s)?`
278
+ message: plan.install
279
+ ? `shadcn is not installed yet — install the dependencies (shadcn included) with ${plan.install.command}${workspaceRoot ? " from the workspace root" : ""} and add ${items.length} page(s)?`
189
280
  : `Add ${items.length} page(s) with shadcn?`,
190
281
  });
191
282
  if (bail(answer, `The scaffold is in ${target}; nothing was installed.`))
@@ -193,27 +284,26 @@ export async function runCreate(
193
284
  approved = answer;
194
285
  }
195
286
 
196
- let pending: Command[] = [];
197
- if (approved) {
198
- for (const [i, command] of plan.entries()) {
199
- if (interactive) p.log.step(formatCommand(command));
200
- else console.log(`› ${formatCommand(command)}`);
201
- if (!run(command, appRoot)) {
202
- pending = plan.slice(i);
203
- const message = `\`${formatCommand(command)}\` failed — the scaffold is in place; run the rest by hand.`;
204
- if (interactive) p.log.error(message);
205
- else console.error(message);
206
- break;
207
- }
208
- }
209
- } else {
210
- pending = plan;
211
- }
287
+ const pending =
288
+ approved && plan && registryDir
289
+ ? await installPages(plan, {
290
+ appRoot,
291
+ interactive,
292
+ show,
293
+ syncContext: {
294
+ appRoot,
295
+ registryDir,
296
+ requires: catalogue.requires,
297
+ frameworkVersion: context.frameworkVersion,
298
+ },
299
+ })
300
+ : commands;
212
301
 
213
302
  const next = {
214
- packageManager,
303
+ packageManager: workspaceRoot ? ("pnpm" as const) : packageManager,
215
304
  installed: approved && pending.length === 0,
216
305
  pending,
306
+ workspaceRoot,
217
307
  };
218
308
  if (interactive) {
219
309
  p.note(formatNextSteps(target, next), "Next");
@@ -9,17 +9,25 @@
9
9
  *
10
10
  * In a terminal it asks for the project name when none is given, then
11
11
  * which registry pages to install (`--items a,b` or `--all` answer that
12
- * without asking). The pages are installed by the shadcn CLI the scaffold
13
- * declares, after the dependencies — and only once you approve the exact
14
- * commands, or pass `--yes`. `--no-install` stops after the scaffold and
15
- * prints them instead.
12
+ * without asking). The pages are installed by `intelligo sync` — one
13
+ * `shadcn add` per item, from the registry this CLI carries — after the
14
+ * dependencies (from the root of a parent pnpm workspace when the app is
15
+ * a member of one), and only once you approve the exact commands, or
16
+ * pass `--yes`. `--no-install` stops after the scaffold and prints them
17
+ * instead.
16
18
  */
17
19
 
18
20
  import { existsSync, mkdirSync, readdirSync } from "node:fs";
19
21
  import path from "node:path";
20
22
 
21
- import { addFeature, formatAddResult, type AddResult } from "./add.js";
22
23
  import {
24
+ addFeature,
25
+ formatAddResult,
26
+ readCatalogue,
27
+ type AddResult,
28
+ } from "./add.js";
29
+ import {
30
+ findWorkspaceRoot,
23
31
  formatCommand,
24
32
  type Command,
25
33
  type PackageManager,
@@ -37,6 +45,15 @@ export type CreateOptions = {
37
45
  * protocol used outside a workspace".
38
46
  */
39
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;
40
57
  };
41
58
 
42
59
  /** A package name and product slug derived from the directory name. */
@@ -55,17 +72,23 @@ export function deriveNames(target: string): {
55
72
  return { appName: slug, appSlug: slug };
56
73
  }
57
74
 
75
+ /** A fresh repository — `git init`, then `intelligo create .` — is still empty. */
76
+ const IGNORED_ENTRIES = new Set([".git"]);
77
+
58
78
  export function isOccupied(target: string): boolean {
59
79
  const dir = path.resolve(target);
60
- return existsSync(dir) && readdirSync(dir).length > 0;
80
+ return (
81
+ existsSync(dir) &&
82
+ readdirSync(dir).some((entry) => !IGNORED_ENTRIES.has(entry))
83
+ );
61
84
  }
62
85
 
63
86
  /** Scaffolding into an occupied directory is how people lose work. */
64
87
  export function assertNotOccupied(target: string): void {
65
88
  if (isOccupied(target)) {
66
89
  throw new Error(
67
- `${path.resolve(target)} is not empty. Create the app in a new directory, or use ` +
68
- `\`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)."
69
92
  );
70
93
  }
71
94
  }
@@ -74,11 +97,13 @@ export function createApp(options: CreateOptions): AddResult {
74
97
  const target = path.resolve(options.target);
75
98
 
76
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);
77
103
  mkdirSync(target, { recursive: true });
104
+ const workspaceRoot = findWorkspaceRoot(target);
78
105
 
79
- const { appName, appSlug } = deriveNames(target);
80
-
81
- return addFeature("app-scaffold", {
106
+ const scaffold = addFeature("app-scaffold", {
82
107
  appRoot: target,
83
108
  templatesDir: options.templatesDir,
84
109
  frameworkVersion: options.frameworkVersion,
@@ -88,8 +113,43 @@ export function createApp(options: CreateOptions): AddResult {
88
113
  __INTELLIGO_DEP__: options.linkWorkspace
89
114
  ? "workspace:*"
90
115
  : `^${options.frameworkVersion}`,
116
+ ...workspaceRootVariable(target),
91
117
  },
92
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
+ };
93
153
  }
94
154
 
95
155
  export type NextSteps = {
@@ -98,6 +158,8 @@ export type NextSteps = {
98
158
  installed: boolean;
99
159
  /** Install commands the developer declined, or that did not get to run. */
100
160
  pending?: Command[];
161
+ /** The parent pnpm workspace the app is a member of, installed from its root. */
162
+ workspaceRoot?: string | null;
101
163
  };
102
164
 
103
165
  /** A path the reader can paste into a shell, spaces and quotes included. */
@@ -109,14 +171,20 @@ function shellQuote(value: string): string {
109
171
 
110
172
  export function formatNextSteps(target: string, next: NextSteps): string {
111
173
  const pm = next.packageManager;
174
+ const appRoot = path.resolve(target);
175
+ const install: Command = {
176
+ command: pm,
177
+ args: ["install"],
178
+ cwd: next.workspaceRoot ?? appRoot,
179
+ };
112
180
  return [
113
- `cd ${shellQuote(path.relative(process.cwd(), path.resolve(target)) || ".")}`,
181
+ `cd ${shellQuote(path.relative(process.cwd(), appRoot) || ".")}`,
114
182
  "cp .env.example .env.local # then fill it in",
115
183
  ...(next.pending?.length
116
- ? next.pending.map(formatCommand)
184
+ ? next.pending.map((c) => formatCommand(c, appRoot))
117
185
  : next.installed
118
186
  ? []
119
- : [`${pm} install`]),
187
+ : [formatCommand(install, appRoot)]),
120
188
  `${pm === "npm" ? "npm run" : pm} dev`,
121
189
  ].join("\n");
122
190
  }
@@ -10,6 +10,12 @@ import { parseEnv } from "node:util";
10
10
 
11
11
  import { inspectMigrationChain, readMigrationChain } from "../migrations.js";
12
12
  import { readManifest } from "../manifest.js";
13
+ import {
14
+ checkExports,
15
+ hasObjectKey,
16
+ reexportedSource,
17
+ stripComments,
18
+ } from "../module-exports.js";
13
19
  import {
14
20
  MODEL_CATALOGUE_LOCATIONS,
15
21
  readCatalogueModelIds,
@@ -35,20 +41,11 @@ type ItemRequires = {
35
41
 
36
42
  export type RegistryRequires = {
37
43
  scaffold: string[];
44
+ /** Files an item ships once and the deployment then owns. */
45
+ seams?: Record<string, string>;
38
46
  items: Record<string, ItemRequires>;
39
47
  };
40
48
 
41
- /**
42
- * Block and line comments removed, so a check cannot be satisfied — or
43
- * defeated — by prose. The composition root's own doc comment names
44
- * `registerModels` in several of the templates.
45
- */
46
- function stripComments(text: string): string {
47
- return text
48
- .replace(/\/\*[\s\S]*?\*\//g, "")
49
- .replace(/(^|[^:])\/\/.*$/gm, "$1");
50
- }
51
-
52
49
  /**
53
50
  * templates/registry-requires.json is a build-time copy of
54
51
  * packages/registry/requires.json (scripts/sync-registry-requires.mjs),
@@ -261,6 +258,103 @@ function checkModelIds(root: string, source: string): CheckResult[] {
261
258
  const OPAQUE_REGISTRATION =
262
259
  /\bregisterModels?\s*\(\s*(?![\s[{]|DEFAULT_MODELS\s*[,)])/;
263
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
+
264
358
  export function runChecks(options: DoctorOptions = {}): CheckResult[] {
265
359
  const root = options.root ?? process.cwd();
266
360
  const env = options.env ?? process.env;
@@ -299,22 +393,15 @@ export function runChecks(options: DoctorOptions = {}): CheckResult[] {
299
393
 
300
394
  // 3. Billing product. The engine has no built-in default catalogue;
301
395
  // an unset product means every plan lookup returns nothing and
302
- // quotas silently read as zero.
303
- results.push(
304
- env.INTELLIGO_BILLING_PRODUCT
305
- ? {
306
- name: "billing",
307
- status: "ok",
308
- detail: `Product: ${env.INTELLIGO_BILLING_PRODUCT}`,
309
- }
310
- : {
311
- name: "billing",
312
- status: "warn",
313
- detail:
314
- "INTELLIGO_BILLING_PRODUCT unset — the composition root must call " +
315
- "setDefaultProductSlug(), or plan lookups resolve to nothing",
316
- }
317
- );
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));
318
405
 
319
406
  // 4. Installed registry items against registry/requires.json: the
320
407
  // sibling items they import from, the scaffold files they import,
@@ -328,13 +415,17 @@ export function runChecks(options: DoctorOptions = {}): CheckResult[] {
328
415
  [".ts", ".tsx", ".js", ".jsx", ".json", ""].some((ext) =>
329
416
  existsSync(path.join(root, rel + ext))
330
417
  );
331
- const plansSource = existsSync(path.join(root, "lib/plans.ts"))
332
- ? readFileSync(path.join(root, "lib/plans.ts"), "utf8")
333
- : null;
418
+ // lib/plans.ts and whatever it re-exports — a catalogue defined in
419
+ // a workspace package counts — comments stripped.
420
+ const plansFile = ["lib/plans.ts", "lib/plans.tsx"]
421
+ .map((rel) => path.join(root, rel))
422
+ .find((file) => existsSync(file));
423
+ const plans = plansFile ? reexportedSource(plansFile, root) : null;
334
424
 
335
425
  for (const [name, item] of Object.entries(requires.items)) {
336
426
  if (!existsSync(path.join(root, item.marker))) continue;
337
427
  const problems: string[] = [];
428
+ const notes: string[] = [];
338
429
 
339
430
  for (const dep of item.items ?? []) {
340
431
  const marker = requires.items[dep]?.marker;
@@ -350,23 +441,22 @@ export function runChecks(options: DoctorOptions = {}): CheckResult[] {
350
441
  .map((ext) => path.join(root, file + ext))
351
442
  .find((p) => existsSync(p));
352
443
  if (!abs) continue; // reported above as a missing file
353
- const source = readFileSync(abs, "utf8");
354
- const missing = names.filter(
355
- (n) =>
356
- !new RegExp(
357
- `export\\s+(?:default\\s+)?(?:async\\s+)?(?:const|function\\*?|let|var|class)\\s+${n}\\b`
358
- ).test(source) &&
359
- !new RegExp(`export\\s*\\{[^}]*\\b${n}\\b`).test(source)
360
- );
444
+ const { missing, unresolved } = checkExports(abs, names, root);
361
445
  if (missing.length) {
362
446
  problems.push(`${file} must export ${missing.join(", ")}`);
447
+ } else if (unresolved.length) {
448
+ notes.push(
449
+ `${file}: could not read ${unresolved.map((u) => `\`export * from "${u}"\``).join(", ")} — assumed to provide ${names.join(", ")}`
450
+ );
363
451
  }
364
452
  }
365
453
  for (const feature of item.features ?? []) {
366
- if (
367
- plansSource !== null &&
368
- !new RegExp(`\\b${feature}\\b\\s*:`).test(plansSource)
369
- ) {
454
+ if (!plans || hasObjectKey(plans.text, feature)) continue;
455
+ if (plans.unresolved.length) {
456
+ notes.push(
457
+ `the "${feature}" feature key is not in lib/plans.ts, and ${plans.unresolved.join(", ")} could not be read to confirm it`
458
+ );
459
+ } else {
370
460
  problems.push(
371
461
  `register the "${feature}" feature key in lib/plans.ts — an unregistered key is denied (403)`
372
462
  );
@@ -375,7 +465,9 @@ export function runChecks(options: DoctorOptions = {}): CheckResult[] {
375
465
 
376
466
  results.push(
377
467
  problems.length === 0
378
- ? { name: `item:${name}`, status: "ok", detail: "requirements met" }
468
+ ? notes.length === 0
469
+ ? { name: `item:${name}`, status: "ok", detail: "requirements met" }
470
+ : { name: `item:${name}`, status: "warn", detail: notes.join("; ") }
379
471
  : {
380
472
  name: `item:${name}`,
381
473
  status: "error",
@@ -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);