@intelligo-dev/cli 1.0.0-beta.1 → 1.0.0-beta.13

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 (103) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +63 -0
  3. package/dist/bin.js +85 -37
  4. package/dist/bin.js.map +1 -1
  5. package/dist/commands/add.d.ts +22 -1
  6. package/dist/commands/add.d.ts.map +1 -1
  7. package/dist/commands/add.js +61 -4
  8. package/dist/commands/add.js.map +1 -1
  9. package/dist/commands/create-flow.d.ts +34 -0
  10. package/dist/commands/create-flow.d.ts.map +1 -0
  11. package/dist/commands/create-flow.js +180 -0
  12. package/dist/commands/create-flow.js.map +1 -0
  13. package/dist/commands/create.d.ts +23 -6
  14. package/dist/commands/create.d.ts.map +1 -1
  15. package/dist/commands/create.js +45 -13
  16. package/dist/commands/create.js.map +1 -1
  17. package/dist/commands/doctor.d.ts +14 -3
  18. package/dist/commands/doctor.d.ts.map +1 -1
  19. package/dist/commands/doctor.js +293 -37
  20. package/dist/commands/doctor.js.map +1 -1
  21. package/dist/commands/migrate-check.d.ts +54 -1
  22. package/dist/commands/migrate-check.d.ts.map +1 -1
  23. package/dist/commands/migrate-check.js +101 -5
  24. package/dist/commands/migrate-check.js.map +1 -1
  25. package/dist/commands/migrate.d.ts +97 -0
  26. package/dist/commands/migrate.d.ts.map +1 -0
  27. package/dist/commands/migrate.js +147 -0
  28. package/dist/commands/migrate.js.map +1 -0
  29. package/dist/commands/upgrade-check.d.ts +5 -5
  30. package/dist/commands/upgrade-check.d.ts.map +1 -1
  31. package/dist/commands/upgrade-check.js +19 -6
  32. package/dist/commands/upgrade-check.js.map +1 -1
  33. package/dist/env-files.d.ts +12 -0
  34. package/dist/env-files.d.ts.map +1 -0
  35. package/dist/env-files.js +29 -0
  36. package/dist/env-files.js.map +1 -0
  37. package/dist/index.d.ts +9 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +9 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/manifest.d.ts +9 -15
  42. package/dist/manifest.d.ts.map +1 -1
  43. package/dist/manifest.js +5 -16
  44. package/dist/manifest.js.map +1 -1
  45. package/dist/migrations.d.ts +3 -6
  46. package/dist/migrations.d.ts.map +1 -1
  47. package/dist/migrations.js +3 -6
  48. package/dist/migrations.js.map +1 -1
  49. package/dist/model-catalogue.d.ts +14 -0
  50. package/dist/model-catalogue.d.ts.map +1 -0
  51. package/dist/model-catalogue.js +34 -0
  52. package/dist/model-catalogue.js.map +1 -0
  53. package/dist/registry-items.d.ts +56 -0
  54. package/dist/registry-items.d.ts.map +1 -0
  55. package/dist/registry-items.js +103 -0
  56. package/dist/registry-items.js.map +1 -0
  57. package/package.json +36 -10
  58. package/src/bin.ts +264 -0
  59. package/src/commands/add.ts +244 -0
  60. package/src/commands/create-flow.ts +228 -0
  61. package/src/commands/create.ts +139 -0
  62. package/src/commands/doctor.ts +487 -0
  63. package/src/commands/migrate-check.ts +257 -0
  64. package/src/commands/migrate.ts +234 -0
  65. package/src/commands/upgrade-check.ts +157 -0
  66. package/src/env-files.ts +31 -0
  67. package/src/index.ts +39 -0
  68. package/src/manifest.ts +93 -0
  69. package/src/migrations-dir.ts +25 -0
  70. package/src/migrations.ts +133 -0
  71. package/src/model-catalogue.ts +35 -0
  72. package/src/registry-items.ts +132 -0
  73. package/templates/admin-page/admin-page.tsx.tpl +2 -3
  74. package/templates/app-scaffold/assistant-route.ts.tpl +2 -2
  75. package/templates/app-scaffold/auth-route.ts.tpl +10 -0
  76. package/templates/app-scaffold/components.json.tpl +6 -2
  77. package/templates/app-scaffold/db-schema.ts.tpl +29 -0
  78. package/templates/app-scaffold/drizzle-journal.json.tpl +5 -0
  79. package/templates/app-scaffold/drizzle.config.ts.tpl +45 -0
  80. package/templates/app-scaffold/env.example.tpl +65 -1
  81. package/templates/app-scaffold/globals.css.tpl +172 -281
  82. package/templates/app-scaffold/i18n-request.ts.tpl +25 -0
  83. package/templates/app-scaffold/instrumentation.ts.tpl +21 -0
  84. package/templates/app-scaffold/intelligo.ts.tpl +104 -9
  85. package/templates/app-scaffold/layout.tsx.tpl +18 -1
  86. package/templates/app-scaffold/lib-utils.ts.tpl +1 -6
  87. package/templates/app-scaffold/next.config.mjs.tpl +2 -0
  88. package/templates/app-scaffold/package.json.tpl +31 -17
  89. package/templates/app-scaffold/page.tsx.tpl +16 -0
  90. package/templates/app-scaffold/plans.ts.tpl +32 -13
  91. package/templates/app-scaffold/{middleware.ts.tpl → proxy.ts.tpl} +6 -0
  92. package/templates/app-scaffold/sonner.tsx.tpl +42 -20
  93. package/templates/app-scaffold/stripe-webhook-route.ts.tpl +24 -0
  94. package/templates/app-scaffold/theme-provider.tsx.tpl +6 -4
  95. package/templates/app-scaffold/tsconfig.json.tpl +20 -4
  96. package/templates/app-scaffold/use-mobile.ts.tpl +19 -0
  97. package/templates/app-scaffold/workspace-bootstrap.ts.tpl +30 -0
  98. package/templates/maintenance/maintenance-route.ts.tpl +119 -0
  99. package/templates/manifest.json +57 -18
  100. package/templates/registry-items.json +116 -0
  101. package/templates/registry-requires.json +156 -0
  102. package/templates/usage-page/usage-page.tsx.tpl +23 -8
  103. package/templates/billing-page/billing-page.tsx.tpl +0 -72
@@ -0,0 +1,487 @@
1
+ /**
2
+ * `intelligo doctor` — report the problems that are invisible until
3
+ * they are an incident.
4
+ */
5
+
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { parseEnv } from "node:util";
10
+
11
+ import { inspectMigrationChain, readMigrationChain } from "../migrations.js";
12
+ import { readManifest } from "../manifest.js";
13
+ import {
14
+ MODEL_CATALOGUE_LOCATIONS,
15
+ readCatalogueModelIds,
16
+ } from "../model-catalogue.js";
17
+ import {
18
+ MIGRATION_LOCATIONS,
19
+ resolveMigrationsDir,
20
+ } from "../migrations-dir.js";
21
+
22
+ export type CheckResult = {
23
+ name: string;
24
+ status: "ok" | "warn" | "error";
25
+ detail: string;
26
+ };
27
+
28
+ type ItemRequires = {
29
+ marker: string;
30
+ items?: string[];
31
+ files?: string[];
32
+ exports?: Record<string, string[]>;
33
+ features?: string[];
34
+ };
35
+
36
+ export type RegistryRequires = {
37
+ scaffold: string[];
38
+ items: Record<string, ItemRequires>;
39
+ };
40
+
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
+ /**
53
+ * templates/registry-requires.json is a build-time copy of
54
+ * packages/registry/requires.json (scripts/sync-registry-requires.mjs),
55
+ * committed so that running from source works too, and shipped so
56
+ * doctor can check an installed app without a registry checkout.
57
+ * `../../templates` resolves from src/ and from dist/commands/ alike.
58
+ */
59
+ function bundledRequires(): RegistryRequires | null {
60
+ const file = path.resolve(
61
+ // fileURLToPath, not `.pathname` — see the note in bin.ts.
62
+ path.dirname(fileURLToPath(import.meta.url)),
63
+ "..",
64
+ "..",
65
+ "templates",
66
+ "registry-requires.json"
67
+ );
68
+ if (!existsSync(file)) return null;
69
+ return JSON.parse(readFileSync(file, "utf8")) as RegistryRequires;
70
+ }
71
+
72
+ export type DoctorOptions = {
73
+ /** Workspace root; defaults to the current working directory. */
74
+ root?: string;
75
+ /** Registry requirements; defaults to the copy bundled with the CLI. */
76
+ requires?: RegistryRequires | null;
77
+ /** Environment to validate; defaults to process.env. */
78
+ env?: NodeJS.ProcessEnv;
79
+ };
80
+
81
+ /** Variables the app cannot boot without. */
82
+ const REQUIRED_ENV = [
83
+ "DATABASE_URL",
84
+ "BETTER_AUTH_SECRET",
85
+ "NEXT_PUBLIC_APP_URL",
86
+ ];
87
+
88
+ /** The app's `assertEnv` refuses a shorter BETTER_AUTH_SECRET. */
89
+ const MIN_AUTH_SECRET_LENGTH = 32;
90
+
91
+ /** Env files Next.js reads only for a production build or server. */
92
+ const PRODUCTION_ENV_FILES = [".env.production.local", ".env.production"];
93
+
94
+ const LOCAL_URL = /^https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/;
95
+
96
+ /**
97
+ * Where a production deployment would get a NEXT_PUBLIC_APP_URL that
98
+ * points at this machine: the environment itself when it says
99
+ * production, or a production env file in the app.
100
+ */
101
+ function localAppUrlInProduction(
102
+ root: string,
103
+ env: NodeJS.ProcessEnv
104
+ ): string | null {
105
+ if (
106
+ env.NODE_ENV === "production" &&
107
+ LOCAL_URL.test(env.NEXT_PUBLIC_APP_URL ?? "")
108
+ ) {
109
+ return "NODE_ENV=production";
110
+ }
111
+ for (const name of PRODUCTION_ENV_FILES) {
112
+ const file = path.join(root, name);
113
+ if (!existsSync(file)) continue;
114
+ const url = parseEnv(readFileSync(file, "utf8")).NEXT_PUBLIC_APP_URL;
115
+ if (url && LOCAL_URL.test(url)) return name;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ /** Files of the chat items that name the models a deployment runs on. */
121
+ const MODEL_ID_FILES = [
122
+ "lib/chat-model.ts",
123
+ "lib/chat-models.ts",
124
+ "lib/chat-server-config.ts",
125
+ ];
126
+
127
+ /** A provider-prefixed model id literal, in any of the three quote styles. */
128
+ const MODEL_ID =
129
+ /["'`]((?:openai|anthropic|google|xai|mistral|meta)\/[a-z0-9._-]+)["'`]/g;
130
+
131
+ /** `id: "provider/model"` — a model registered by a literal. */
132
+ const REGISTERED_ID = /\bid:\s*["'`]([a-z0-9-]+\/[a-z0-9._-]+)["'`]/g;
133
+
134
+ /**
135
+ * Required environment: present, and strong enough that the app's own
136
+ * startup validation will accept it.
137
+ */
138
+ function checkEnvironment(
139
+ root: string,
140
+ env: NodeJS.ProcessEnv | Record<string, string | undefined>
141
+ ): CheckResult[] {
142
+ const results: CheckResult[] = [];
143
+ const missing = REQUIRED_ENV.filter((k) => !env[k]);
144
+ const authSecret = env.BETTER_AUTH_SECRET ?? "";
145
+ const weakSecret =
146
+ authSecret.length > 0 && authSecret.length < MIN_AUTH_SECRET_LENGTH;
147
+ if (missing.length > 0) {
148
+ results.push({
149
+ name: "env",
150
+ status: "error",
151
+ detail: `Missing: ${missing.join(", ")}`,
152
+ });
153
+ }
154
+ if (weakSecret) {
155
+ results.push({
156
+ name: "env",
157
+ status: "error",
158
+ detail:
159
+ `BETTER_AUTH_SECRET is ${authSecret.length} chars — the app refuses to ` +
160
+ `boot with fewer than ${MIN_AUTH_SECRET_LENGTH}. Generate one with: ` +
161
+ "openssl rand -base64 32",
162
+ });
163
+ }
164
+ if (missing.length === 0 && !weakSecret) {
165
+ results.push({
166
+ name: "env",
167
+ status: "ok",
168
+ detail: `${REQUIRED_ENV.length} required variables present`,
169
+ });
170
+ }
171
+ const localUrlSource = localAppUrlInProduction(root, env);
172
+ if (localUrlSource) {
173
+ results.push({
174
+ name: "env",
175
+ status: "warn",
176
+ detail:
177
+ `NEXT_PUBLIC_APP_URL points at localhost (${localUrlSource}) — auth ` +
178
+ "callbacks and email links are built from it; production needs the " +
179
+ "public https URL",
180
+ });
181
+ }
182
+ return results;
183
+ }
184
+
185
+ /**
186
+ * Model ids. A registered catalogue still refuses an id it does not
187
+ * hold, so a typo in the chat seam is a refused first turn. Every
188
+ * provider-prefixed literal there must be in DEFAULT_MODELS (when the
189
+ * root registers it) or registered by a literal `id` in the root.
190
+ */
191
+ function checkModelIds(root: string, source: string): CheckResult[] {
192
+ const used = new Map<string, string>();
193
+ for (const rel of MODEL_ID_FILES) {
194
+ const file = path.join(root, rel);
195
+ if (!existsSync(file)) continue;
196
+ const text = stripComments(readFileSync(file, "utf8"));
197
+ for (const match of text.matchAll(MODEL_ID)) {
198
+ if (!used.has(match[1]!)) used.set(match[1]!, rel);
199
+ }
200
+ }
201
+
202
+ if (used.size === 0) return [];
203
+
204
+ const code = stripComments(source);
205
+ const known = new Set([...code.matchAll(REGISTERED_ID)].map((m) => m[1]!));
206
+ const usesCatalogue = /\bDEFAULT_MODELS\b/.test(code);
207
+ const catalogue = usesCatalogue ? readCatalogueModelIds(root) : null;
208
+ for (const id of catalogue ?? []) known.add(id);
209
+
210
+ const unknown = [...used].filter(([id]) => !known.has(id));
211
+ const listed = unknown.map(([id, rel]) => `${id} (${rel})`).join(", ");
212
+
213
+ if (unknown.length === 0) {
214
+ return [
215
+ {
216
+ name: "model-ids",
217
+ status: "ok",
218
+ detail: `${used.size} model id(s) in the chat seam, all registered`,
219
+ },
220
+ ];
221
+ } else if (usesCatalogue && !catalogue) {
222
+ return [
223
+ {
224
+ name: "model-ids",
225
+ status: "warn",
226
+ detail:
227
+ `cannot read DEFAULT_MODELS (looked in ${MODEL_CATALOGUE_LOCATIONS.join(", ")}) ` +
228
+ `to check ${listed} — is @intelligo-dev/executions installed?`,
229
+ },
230
+ ];
231
+ } else if (OPAQUE_REGISTRATION.test(code)) {
232
+ return [
233
+ {
234
+ name: "model-ids",
235
+ status: "warn",
236
+ detail:
237
+ `${listed} not found in DEFAULT_MODELS or a literal registerModel({ id }) — ` +
238
+ "the composition root registers a catalogue defined elsewhere, " +
239
+ "so confirm it holds them",
240
+ },
241
+ ];
242
+ } else {
243
+ return [
244
+ {
245
+ name: "model-ids",
246
+ status: "error",
247
+ detail:
248
+ `${listed} not registered — admission refuses an unpriced model ` +
249
+ "with `unknown_model`. Fix the id, or add " +
250
+ "`registerModel({ id, … })` to the composition root",
251
+ },
252
+ ];
253
+ }
254
+ }
255
+
256
+ /**
257
+ * A `registerModel(s)` argument that is neither a literal nor
258
+ * `DEFAULT_MODELS`: a catalogue defined in another module, whose ids
259
+ * cannot be read from the composition root.
260
+ */
261
+ const OPAQUE_REGISTRATION =
262
+ /\bregisterModels?\s*\(\s*(?![\s[{]|DEFAULT_MODELS\s*[,)])/;
263
+
264
+ export function runChecks(options: DoctorOptions = {}): CheckResult[] {
265
+ const root = options.root ?? process.cwd();
266
+ const env = options.env ?? process.env;
267
+ const results: CheckResult[] = [];
268
+
269
+ // 1. Migration chain: drift between the journal and the .sql files.
270
+ const migrationsDir = resolveMigrationsDir(root);
271
+ if (!migrationsDir) {
272
+ results.push({
273
+ name: "migrations",
274
+ status: "warn",
275
+ detail: `No migrations directory (looked in ${MIGRATION_LOCATIONS.join(", ")}) — run from the workspace root, with @intelligo-dev/core installed?`,
276
+ });
277
+ } else {
278
+ const chain = readMigrationChain(migrationsDir);
279
+ const problems = inspectMigrationChain(chain);
280
+ if (problems.length === 0) {
281
+ results.push({
282
+ name: "migrations",
283
+ status: "ok",
284
+ detail: `${chain.files.length} migrations, all registered in the journal`,
285
+ });
286
+ } else {
287
+ for (const p of problems) {
288
+ results.push({
289
+ name: "migrations",
290
+ status: p.level === "error" ? "error" : "warn",
291
+ detail: p.message,
292
+ });
293
+ }
294
+ }
295
+ }
296
+
297
+ // 2. Required environment.
298
+ results.push(...checkEnvironment(root, env));
299
+
300
+ // 3. Billing product. The engine has no built-in default catalogue;
301
+ // 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
+ );
318
+
319
+ // 4. Installed registry items against registry/requires.json: the
320
+ // sibling items they import from, the scaffold files they import,
321
+ // the exports the composition root must provide, and the feature
322
+ // keys they gate on — each a `next build` or a 403 that only shows
323
+ // up later. An item counts as installed when its marker file is.
324
+ const requires =
325
+ options.requires === undefined ? bundledRequires() : options.requires;
326
+ if (requires) {
327
+ const exists = (rel: string) =>
328
+ [".ts", ".tsx", ".js", ".jsx", ".json", ""].some((ext) =>
329
+ existsSync(path.join(root, rel + ext))
330
+ );
331
+ const plansSource = existsSync(path.join(root, "lib/plans.ts"))
332
+ ? readFileSync(path.join(root, "lib/plans.ts"), "utf8")
333
+ : null;
334
+
335
+ for (const [name, item] of Object.entries(requires.items)) {
336
+ if (!existsSync(path.join(root, item.marker))) continue;
337
+ const problems: string[] = [];
338
+
339
+ for (const dep of item.items ?? []) {
340
+ const marker = requires.items[dep]?.marker;
341
+ if (marker && !existsSync(path.join(root, marker))) {
342
+ problems.push(`install the ${dep} item first`);
343
+ }
344
+ }
345
+ for (const file of item.files ?? []) {
346
+ if (!exists(file)) problems.push(`create ${file} (scaffold-provided)`);
347
+ }
348
+ for (const [file, names] of Object.entries(item.exports ?? {})) {
349
+ const abs = [".ts", ".tsx"]
350
+ .map((ext) => path.join(root, file + ext))
351
+ .find((p) => existsSync(p));
352
+ 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
+ );
361
+ if (missing.length) {
362
+ problems.push(`${file} must export ${missing.join(", ")}`);
363
+ }
364
+ }
365
+ for (const feature of item.features ?? []) {
366
+ if (
367
+ plansSource !== null &&
368
+ !new RegExp(`\\b${feature}\\b\\s*:`).test(plansSource)
369
+ ) {
370
+ problems.push(
371
+ `register the "${feature}" feature key in lib/plans.ts — an unregistered key is denied (403)`
372
+ );
373
+ }
374
+ }
375
+
376
+ results.push(
377
+ problems.length === 0
378
+ ? { name: `item:${name}`, status: "ok", detail: "requirements met" }
379
+ : {
380
+ name: `item:${name}`,
381
+ status: "error",
382
+ detail: problems.join("; "),
383
+ }
384
+ );
385
+ }
386
+ }
387
+
388
+ // 4b. Maintenance route. It refuses to serve without a strong
389
+ // CRON_SECRET, and a scheduler hitting a 403 every five minutes
390
+ // is easy to miss.
391
+ if (existsSync(path.join(root, "app/api/cron/maintenance/route.ts"))) {
392
+ const cronSecret = env.CRON_SECRET ?? "";
393
+ results.push(
394
+ cronSecret.length >= 32
395
+ ? { name: "maintenance", status: "ok", detail: "CRON_SECRET set" }
396
+ : {
397
+ name: "maintenance",
398
+ status: "error",
399
+ detail:
400
+ "app/api/cron/maintenance/route.ts exists but CRON_SECRET is " +
401
+ (cronSecret ? "shorter than 32 chars" : "unset") +
402
+ " — the route answers 403 until it is",
403
+ }
404
+ );
405
+ }
406
+
407
+ // 4c. Better-Auth's HTTP mount. Every auth item's form posts to
408
+ // `/api/auth/*` on this origin; without the catch-all route
409
+ // those requests 404 and the pages look broken for no visible
410
+ // reason.
411
+ if (existsSync(path.join(root, "app/[locale]/(auth)/layout.tsx"))) {
412
+ results.push(
413
+ existsSync(path.join(root, "app/api/auth/[...all]/route.ts"))
414
+ ? { name: "auth-mount", status: "ok", detail: "/api/auth mounted" }
415
+ : {
416
+ name: "auth-mount",
417
+ status: "error",
418
+ detail:
419
+ "auth pages are installed but app/api/auth/[...all]/route.ts " +
420
+ "is missing — add `export { GET, POST } from " +
421
+ '"@intelligo-dev/next/auth";` there or every sign-in answers 404',
422
+ }
423
+ );
424
+ }
425
+
426
+ // 4d. Model prices. The registry is open and nothing self-registers,
427
+ // so a composition root that never calls `registerModels` leaves
428
+ // admission with no price to estimate against: every request is
429
+ // refused with `unknown_model`, at runtime, on a deployment whose
430
+ // 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
+ if (compositionRoot) {
436
+ const source = readFileSync(compositionRoot, "utf8");
437
+ const registers = /\bregisterModels?\s*\(/.test(stripComments(source));
438
+ results.push(
439
+ registers
440
+ ? {
441
+ name: "models",
442
+ status: "ok",
443
+ detail: "the composition root registers model prices",
444
+ }
445
+ : {
446
+ name: "models",
447
+ status: "error",
448
+ detail:
449
+ "no registerModels() in the composition root — admission cannot " +
450
+ "price any model and refuses every request with `unknown_model`. " +
451
+ "Add `registerModels(DEFAULT_MODELS)` from @intelligo-dev/executions, " +
452
+ "or your own catalogue",
453
+ }
454
+ );
455
+
456
+ if (registers) results.push(...checkModelIds(root, source));
457
+ }
458
+
459
+ // 5. Generated source. A conflict — template and consumer both
460
+ // moved — is the one state an upgrade cannot resolve on its own.
461
+ const manifest = readManifest(root);
462
+ if (manifest) {
463
+ const features = Object.keys(manifest.features);
464
+ results.push({
465
+ name: "generated",
466
+ status: "ok",
467
+ detail:
468
+ features.length === 0
469
+ ? "manifest present, no features generated"
470
+ : `${features.length} feature(s): ${features.join(", ")} — run \`intelligo upgrade --check\` for detail`,
471
+ });
472
+ }
473
+
474
+ return results;
475
+ }
476
+
477
+ export function formatResults(results: CheckResult[]): string {
478
+ const icon = { ok: "✓", warn: "!", error: "✗" } as const;
479
+ return results
480
+ .map((r) => `${icon[r.status]} ${r.name.padEnd(12)} ${r.detail}`)
481
+ .join("\n");
482
+ }
483
+
484
+ /** Process exit code: non-zero when any check errored. */
485
+ export function exitCodeFor(results: CheckResult[]): number {
486
+ return results.some((r) => r.status === "error") ? 1 : 0;
487
+ }