@metaobjectsdev/cli 1.0.3 → 1.0.4-rc.1

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 (79) hide show
  1. package/README.md +13 -5
  2. package/dist/src/commands/eject.d.ts +2 -1
  3. package/dist/src/commands/eject.d.ts.map +1 -1
  4. package/dist/src/commands/eject.js +235 -75
  5. package/dist/src/commands/eject.js.map +1 -1
  6. package/dist/src/commands/gen.d.ts.map +1 -1
  7. package/dist/src/commands/gen.js +119 -29
  8. package/dist/src/commands/gen.js.map +1 -1
  9. package/dist/src/commands/init.d.ts +5 -9
  10. package/dist/src/commands/init.d.ts.map +1 -1
  11. package/dist/src/commands/init.js +80 -384
  12. package/dist/src/commands/init.js.map +1 -1
  13. package/dist/src/commands/verify.d.ts.map +1 -1
  14. package/dist/src/commands/verify.js +40 -14
  15. package/dist/src/commands/verify.js.map +1 -1
  16. package/dist/src/index.d.ts.map +1 -1
  17. package/dist/src/index.js +36 -14
  18. package/dist/src/index.js.map +1 -1
  19. package/dist/src/lib/advisory.d.ts +6 -0
  20. package/dist/src/lib/advisory.d.ts.map +1 -1
  21. package/dist/src/lib/advisory.js +10 -0
  22. package/dist/src/lib/advisory.js.map +1 -1
  23. package/dist/src/lib/args.d.ts +18 -2
  24. package/dist/src/lib/args.d.ts.map +1 -1
  25. package/dist/src/lib/args.js +15 -3
  26. package/dist/src/lib/args.js.map +1 -1
  27. package/dist/src/lib/catalog-listing.d.ts +92 -0
  28. package/dist/src/lib/catalog-listing.d.ts.map +1 -0
  29. package/dist/src/lib/catalog-listing.js +268 -0
  30. package/dist/src/lib/catalog-listing.js.map +1 -0
  31. package/dist/src/lib/catalog.d.ts +41 -0
  32. package/dist/src/lib/catalog.d.ts.map +1 -0
  33. package/dist/src/lib/catalog.js +89 -0
  34. package/dist/src/lib/catalog.js.map +1 -0
  35. package/dist/src/lib/collection-load-options.d.ts +1 -1
  36. package/dist/src/lib/collection-load-options.d.ts.map +1 -1
  37. package/dist/src/lib/collection-load-options.js +6 -0
  38. package/dist/src/lib/collection-load-options.js.map +1 -1
  39. package/dist/src/lib/install-set.d.ts +31 -0
  40. package/dist/src/lib/install-set.d.ts.map +1 -0
  41. package/dist/src/lib/install-set.js +103 -0
  42. package/dist/src/lib/install-set.js.map +1 -0
  43. package/dist/src/lib/library-eject.d.ts +79 -0
  44. package/dist/src/lib/library-eject.d.ts.map +1 -0
  45. package/dist/src/lib/library-eject.js +241 -0
  46. package/dist/src/lib/library-eject.js.map +1 -0
  47. package/dist/src/lib/library-listing.d.ts +63 -0
  48. package/dist/src/lib/library-listing.d.ts.map +1 -0
  49. package/dist/src/lib/library-listing.js +178 -0
  50. package/dist/src/lib/library-listing.js.map +1 -0
  51. package/dist/src/lib/library-prefix-advisory.d.ts +17 -0
  52. package/dist/src/lib/library-prefix-advisory.d.ts.map +1 -0
  53. package/dist/src/lib/library-prefix-advisory.js +67 -0
  54. package/dist/src/lib/library-prefix-advisory.js.map +1 -0
  55. package/dist/src/lib/load-metaobjects-config.d.ts +1 -2
  56. package/dist/src/lib/load-metaobjects-config.d.ts.map +1 -1
  57. package/dist/src/lib/load-metaobjects-config.js +11 -17
  58. package/dist/src/lib/load-metaobjects-config.js.map +1 -1
  59. package/dist/src/lib/requirement-check.d.ts +17 -2
  60. package/dist/src/lib/requirement-check.d.ts.map +1 -1
  61. package/dist/src/lib/requirement-check.js +51 -9
  62. package/dist/src/lib/requirement-check.js.map +1 -1
  63. package/package.json +10 -10
  64. package/src/commands/eject.ts +304 -90
  65. package/src/commands/gen.ts +128 -28
  66. package/src/commands/init.ts +82 -390
  67. package/src/commands/verify.ts +47 -15
  68. package/src/index.ts +36 -14
  69. package/src/lib/advisory.ts +13 -0
  70. package/src/lib/args.ts +29 -5
  71. package/src/lib/catalog-listing.ts +369 -0
  72. package/src/lib/catalog.ts +107 -0
  73. package/src/lib/collection-load-options.ts +7 -1
  74. package/src/lib/install-set.ts +116 -0
  75. package/src/lib/library-eject.ts +298 -0
  76. package/src/lib/library-listing.ts +258 -0
  77. package/src/lib/library-prefix-advisory.ts +78 -0
  78. package/src/lib/load-metaobjects-config.ts +15 -21
  79. package/src/lib/requirement-check.ts +71 -11
@@ -0,0 +1,369 @@
1
+ // `meta gen --list` — the catalog as one machine-readable document.
2
+ //
3
+ // The governing rule (opt-in-codegen design §1): stop hard-coding selection decisions
4
+ // into the CLI. The tool's job is to describe what it can do, accurately, and to make
5
+ // each choice cheap to act on. Deciding WHICH generators an application needs belongs
6
+ // to whoever is building it — increasingly an LLM working in the repo, which is well
7
+ // able to make that call given a truthful catalog and is badly served by a default that
8
+ // pre-empts it.
9
+ //
10
+ // Two questions this answers, kept apart on purpose (§D5):
11
+ //
12
+ // APPLICABILITY — "would this emit anything for MY model?" — is `--probe`, which
13
+ // cannot drift, because it does not describe the generators, it RUNS them.
14
+ // COMPATIBILITY — "what does this need in order to work?" — is declared
15
+ // (`requires`, `runtimePeers`) and therefore has to be gated separately.
16
+ //
17
+ // Conflating the two is how a catalog goes quietly wrong as frameworks are added.
18
+
19
+ import { readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import type { MetaData } from "@metaobjectsdev/metadata";
22
+ import {
23
+ runGen,
24
+ type GeneratorRegistryEntry,
25
+ type Layer,
26
+ type MetaobjectsGenConfig,
27
+ } from "@metaobjectsdev/codegen-ts";
28
+ import * as coreTpl from "@metaobjectsdev/codegen-ts";
29
+ import * as reactTpl from "@metaobjectsdev/codegen-ts-react";
30
+ import * as tanstackTpl from "@metaobjectsdev/codegen-ts-tanstack";
31
+ import { listCatalog, packageOf } from "./catalog.js";
32
+ import {
33
+ buildLibraryRows, renderLibraryText,
34
+ type LibraryCatalogRow, type LibraryProjectContext,
35
+ } from "./library-listing.js";
36
+ import { installSetFor } from "./install-set.js";
37
+ import { readPackageManifest, declaredDependencyNames } from "./package-manifest.js";
38
+
39
+ /**
40
+ * One `--list` row. The cross-port subset is name / layer / tier / description.
41
+ *
42
+ * `kind` is the discriminator: FR-043 adds `kind: "library"` rows to THIS table rather
43
+ * than a parallel one, because an agent about to model a capability should meet the
44
+ * library that already declares it in the list it was already reading.
45
+ */
46
+ export type CatalogRow = GeneratorCatalogRow | LibraryCatalogRow;
47
+
48
+ export type { LibraryCatalogRow };
49
+
50
+ export interface GeneratorCatalogRow {
51
+ name: string;
52
+ kind: "generator";
53
+ layer: Layer;
54
+ framework?: string;
55
+ tier: "native" | "neutral";
56
+ package: string;
57
+ description: string;
58
+ /** From the reference template's `use-when:` header. Absent for a non-ejectable entry. */
59
+ useWhen?: string;
60
+ /** From the reference template's `emits:` header. Absent for a non-ejectable entry. */
61
+ emits?: string;
62
+ requires?: readonly string[];
63
+ configKeys?: readonly string[];
64
+ install: { dev: string[]; runtime: string[] };
65
+ source: {
66
+ kind: "reference-template" | "package-only";
67
+ ejectable: boolean;
68
+ /** Whether this project already owns a copy. `null` with no project. */
69
+ owned: boolean | null;
70
+ };
71
+ project?: {
72
+ /** Wired in this project's `generators: [...]`. */
73
+ wired: boolean;
74
+ /** This generator's framework is a declared dependency. `null` when neutral or unknown. */
75
+ frameworkDetected: boolean | null;
76
+ /** Files this generator WOULD emit for this model. `null` unless --probe. */
77
+ wouldEmit: number | null;
78
+ /** Why `wouldEmit` is null despite --probe. Absent when it is a number. */
79
+ probeError?: string;
80
+ };
81
+ }
82
+
83
+ /** Where an ejected copy lands — mirrors `eject.ts`'s constant of the same name. */
84
+ const OWNED_GENERATORS_DIR = "codegen/generators";
85
+
86
+ /** The npm package a framework token names, for `frameworkDetected`. */
87
+ const FRAMEWORK_PACKAGE: Record<string, string> = {
88
+ fastify: "fastify",
89
+ hono: "hono",
90
+ react: "react",
91
+ tanstack: "@tanstack/react-query",
92
+ };
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // reference-template headers
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /** The three packages that ship reference templates, and their roots. */
99
+ const TEMPLATE_ROOTS: ReadonlyArray<readonly [readonly string[], () => string]> = [
100
+ [coreTpl.REFERENCE_GENERATOR_NAMES, coreTpl.resolveReferenceRoot],
101
+ [reactTpl.REFERENCE_GENERATOR_NAMES, reactTpl.resolveReferenceRoot],
102
+ [tanstackTpl.REFERENCE_GENERATOR_NAMES, tanstackTpl.resolveReferenceRoot],
103
+ ];
104
+
105
+ /**
106
+ * A `// <facet>: <text>` header line and its indented continuations, unwrapped.
107
+ *
108
+ * Read from the template rather than restated in the registry entry, for the same
109
+ * reason `eject` reads its import line from the header: the template is the thing an
110
+ * adopter opens, so a second copy of its own summary in the registry is a second thing
111
+ * to keep in step. A test asserts every ejectable entry's row actually carries these.
112
+ */
113
+ function headerFacet(source: string, facet: string): string | undefined {
114
+ const lines = source.split("\n");
115
+ const start = lines.findIndex((l) => l.startsWith(`// ${facet}:`));
116
+ if (start === -1) return undefined;
117
+ const parts = [lines[start]!.slice(`// ${facet}:`.length).trim()];
118
+ for (let i = start + 1; i < lines.length; i++) {
119
+ const line = lines[i]!;
120
+ // A continuation is a comment line indented past the facet column and NOT itself a
121
+ // new `facet:` line. Anything else ends the block.
122
+ if (!/^\/\/ {3,}\S/.test(line) || /^\/\/ {3,}[a-z-]+: /.test(line)) break;
123
+ parts.push(line.replace(/^\/\/\s+/, "").trim());
124
+ }
125
+ return parts.join(" ").trim() || undefined;
126
+ }
127
+
128
+ function readTemplate(name: string): string | undefined {
129
+ for (const [names, root] of TEMPLATE_ROOTS) {
130
+ if (!names.includes(name)) continue;
131
+ try {
132
+ return readFileSync(join(root(), `${name}.ts`), "utf8");
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ }
137
+ return undefined;
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // the probe
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /**
145
+ * The project facts a `--list` row can carry. All optional: `--list` with no project at
146
+ * all is the documented shape (`meta types` behaves the same way), because "what CAN
147
+ * this engine do" is a question about the installed engine, not about a repo.
148
+ */
149
+ export interface CatalogProject {
150
+ projectRoot: string;
151
+ config: MetaobjectsGenConfig;
152
+ /** Stable names of the wired generators, as far as they can be identified. */
153
+ wiredNames: ReadonlySet<string>;
154
+ /** Owned copies present under `codegen/generators/`. */
155
+ ownedNames: ReadonlySet<string>;
156
+ /** Declared dependency names from package.json, for `frameworkDetected`. */
157
+ declaredDeps: ReadonlySet<string> | undefined;
158
+ /** FR-043 — the project's `libraries` selection, for the `kind: "library"` rows.
159
+ * Defaults to none, so a caller that predates libraries reports "not opted in"
160
+ * rather than crashing. */
161
+ libraries?: readonly string[];
162
+ }
163
+
164
+ export interface CatalogListingOpts {
165
+ project?: CatalogProject;
166
+ /** Run every generator in memory and report a real file count. Needs `project` + `metadata`. */
167
+ probe?: { metadata: MetaData; scope?: (fqn: string) => boolean };
168
+ }
169
+
170
+ /**
171
+ * Dry-run ONE generator and count what it would emit.
172
+ *
173
+ * One `runGen` per generator rather than one run over all of them, for isolation: a
174
+ * generator that throws (or one whose real use requires options `--list` cannot supply,
175
+ * like `template` and `shared-model`) must report itself and leave every other row
176
+ * intact. `dryRun` touches nothing on disk.
177
+ */
178
+ async function probeOne(
179
+ entry: GeneratorRegistryEntry,
180
+ project: CatalogProject,
181
+ metadata: MetaData,
182
+ scope: ((fqn: string) => boolean) | undefined,
183
+ ): Promise<{ count: number } | { error: string }> {
184
+ try {
185
+ const result = await runGen({
186
+ config: { ...project.config, generators: [entry.factory()] },
187
+ metadata,
188
+ projectRoot: project.projectRoot,
189
+ dryRun: true,
190
+ ...(scope !== undefined ? { scope } : {}),
191
+ });
192
+ return { count: result.files.length };
193
+ } catch (err) {
194
+ return { error: (err as Error).message };
195
+ }
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // the listing
200
+ // ---------------------------------------------------------------------------
201
+
202
+ export async function buildCatalogListing(opts: CatalogListingOpts = {}): Promise<CatalogRow[]> {
203
+ const rows: CatalogRow[] = [];
204
+
205
+ for (const entry of listCatalog()) {
206
+ const template = entry.ejectable ? readTemplate(entry.name) : undefined;
207
+ const install = installSetFor([entry]);
208
+ const pkg = packageOf(entry.name) ?? "";
209
+
210
+ const row: GeneratorCatalogRow = {
211
+ name: entry.name,
212
+ kind: entry.kind,
213
+ layer: entry.layer,
214
+ ...(entry.framework !== undefined ? { framework: entry.framework } : {}),
215
+ tier: entry.tier,
216
+ package: pkg,
217
+ description: entry.description,
218
+ ...(template !== undefined && headerFacet(template, "use-when") !== undefined
219
+ ? { useWhen: headerFacet(template, "use-when")! }
220
+ : {}),
221
+ ...(template !== undefined && headerFacet(template, "emits") !== undefined
222
+ ? { emits: headerFacet(template, "emits")! }
223
+ : {}),
224
+ ...(entry.requires !== undefined ? { requires: entry.requires } : {}),
225
+ ...(entry.configKeys !== undefined ? { configKeys: entry.configKeys } : {}),
226
+ install: { dev: install.dev, runtime: install.runtime },
227
+ source: {
228
+ kind: entry.ejectable ? "reference-template" : "package-only",
229
+ ejectable: entry.ejectable,
230
+ owned: opts.project === undefined ? null : opts.project.ownedNames.has(entry.name),
231
+ },
232
+ };
233
+
234
+ if (opts.project !== undefined) {
235
+ const project = opts.project;
236
+ const fw = entry.framework;
237
+ const frameworkDetected =
238
+ fw === undefined || project.declaredDeps === undefined
239
+ ? null
240
+ : project.declaredDeps.has(FRAMEWORK_PACKAGE[fw] ?? fw);
241
+
242
+ let wouldEmit: number | null = null;
243
+ let probeError: string | undefined;
244
+ if (opts.probe !== undefined) {
245
+ const outcome = await probeOne(entry, project, opts.probe.metadata, opts.probe.scope);
246
+ if ("count" in outcome) wouldEmit = outcome.count;
247
+ else probeError = outcome.error;
248
+ }
249
+
250
+ row.project = {
251
+ wired: project.wiredNames.has(entry.name),
252
+ frameworkDetected,
253
+ wouldEmit,
254
+ ...(probeError !== undefined ? { probeError } : {}),
255
+ };
256
+ }
257
+
258
+ rows.push(row);
259
+ }
260
+
261
+ // FR-043 — the library rows, after the generators. Same table, same `--probe`.
262
+ const libraryCtx: LibraryProjectContext | undefined =
263
+ opts.project === undefined
264
+ ? undefined
265
+ : {
266
+ libraries: opts.project.libraries ?? [],
267
+ wiredNames: opts.project.wiredNames,
268
+ ...(opts.probe !== undefined ? { metadata: opts.probe.metadata } : {}),
269
+ };
270
+ rows.push(...(await buildLibraryRows(libraryCtx)));
271
+
272
+ return rows;
273
+ }
274
+
275
+ /**
276
+ * The stable names wired in a config.
277
+ *
278
+ * `generators: [...]` holds constructed Generator objects (and, since ADR-0021 #1,
279
+ * bare stable-name strings). A constructed generator carries its own kebab-case `name`,
280
+ * which IS the stable name for every catalog entry — the registry's factory is what
281
+ * produced it. A generator whose name is not a catalog key is an owned or third-party
282
+ * one and simply contributes nothing here.
283
+ */
284
+ export function wiredGeneratorNames(config: MetaobjectsGenConfig): Set<string> {
285
+ const names = new Set<string>();
286
+ for (const spec of config.generators ?? []) {
287
+ if (typeof spec === "string") names.add(spec);
288
+ else if (typeof spec?.name === "string") names.add(spec.name);
289
+ }
290
+ return names;
291
+ }
292
+
293
+ /** Owned copies present under `codegen/generators/` in this project. */
294
+ export function ownedGeneratorNames(projectRoot: string): Set<string> {
295
+ const owned = new Set<string>();
296
+ for (const entry of listCatalog()) {
297
+ if (!entry.ejectable) continue;
298
+ try {
299
+ readFileSync(join(projectRoot, OWNED_GENERATORS_DIR, `${entry.name}.ts`), "utf8");
300
+ owned.add(entry.name);
301
+ } catch {
302
+ // absent — not owned
303
+ }
304
+ }
305
+ return owned;
306
+ }
307
+
308
+ /** Declared dependency names, or undefined when there is no readable manifest. */
309
+ export function declaredDepsOf(projectRoot: string): Set<string> | undefined {
310
+ const pkg = readPackageManifest(projectRoot);
311
+ return pkg === undefined ? undefined : declaredDependencyNames(pkg);
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // text rendering
316
+ // ---------------------------------------------------------------------------
317
+
318
+ const LAYER_BLURB: Record<Layer, string> = {
319
+ model: "the entity modules and the constants beside them",
320
+ persistence: "how rows are read and written",
321
+ api: "the HTTP surface — pick ONE framework",
322
+ client: "the browser tier — form + hooks + grid compose, they do not conflict",
323
+ docs: "on by default; the canonical door is `meta docs`",
324
+ capability: "chosen by your MODEL, not by browsing — run --probe",
325
+ };
326
+
327
+ /** The human rendering: grouped by layer, because layer is the axis you select by. */
328
+ export function renderCatalogText(rows: CatalogRow[], probed: boolean): string {
329
+ const lines: string[] = [];
330
+ lines.push("Generator catalog — nothing runs until you wire it in `generators: [...]`.");
331
+ lines.push("");
332
+
333
+ const generators = rows.filter((r): r is GeneratorCatalogRow => r.kind === "generator");
334
+ const width = Math.max(...generators.map((r) => r.name.length));
335
+ let layer: string | undefined;
336
+ for (const r of generators) {
337
+ if (r.layer !== layer) {
338
+ layer = r.layer;
339
+ lines.push(`${layer} — ${LAYER_BLURB[r.layer]}`);
340
+ }
341
+ const marks: string[] = [];
342
+ if (r.framework !== undefined) marks.push(r.framework);
343
+ if (r.tier === "neutral") marks.push("neutral");
344
+ if (r.project?.wired) marks.push("WIRED");
345
+ if (r.source.owned) marks.push("owned");
346
+ if (probed && r.project?.wouldEmit !== null && r.project?.wouldEmit !== undefined) {
347
+ marks.push(`would emit ${r.project.wouldEmit}`);
348
+ }
349
+ const suffix = marks.length > 0 ? ` [${marks.join(", ")}]` : "";
350
+ lines.push(` ${r.name.padEnd(width)} — ${r.description}${suffix}`);
351
+ if (r.requires !== undefined && r.requires.length > 0) {
352
+ lines.push(` ${" ".repeat(width)} requires: ${r.requires.join(", ")}`);
353
+ }
354
+ }
355
+
356
+ // The library section sits between the generators and the footer: the footer's
357
+ // `meta eject` line applies to both kinds, and putting libraries after it buries
358
+ // the one row an agent modelling a capability most needs to see.
359
+ lines.push(...renderLibraryText(rows.filter((r): r is LibraryCatalogRow => r.kind === "library")));
360
+
361
+ lines.push("");
362
+ lines.push("`meta eject <name...>` copies a generator into codegen/generators/ and prints");
363
+ lines.push("the import to add, the entry to wire, and what to install.");
364
+ if (!probed) {
365
+ lines.push("Add --probe to see how many files each would emit for YOUR model.");
366
+ }
367
+ lines.push("`meta gen --list --format json` is the same catalog, machine-readable.");
368
+ return lines.join("\n");
369
+ }
@@ -0,0 +1,107 @@
1
+ // The TypeScript port's generator CATALOG — the one place all three registry slices
2
+ // are visible at once (ADR-0021 D3; opt-in-codegen design §D2a).
3
+ //
4
+ // `codegen-ts` owns the entry type and its own entries, but cannot import
5
+ // `codegen-ts-react` / `codegen-ts-tanstack` (dependency direction), so each of those
6
+ // exports its own slice and the CLI unions them here — exactly how `eject.ts` already
7
+ // composes its `SOURCES` table over the same three packages.
8
+ //
9
+ // Before this existed, `meta gen --list` read the codegen-ts registry and
10
+ // `meta eject --list` read the reference-template lists, so an agent asking "what can I
11
+ // turn on" got two different answers. After it, they are one table with two views.
12
+ //
13
+ // (The Angular generators stay out until ADR-0048's promotion bar is met — a
14
+ // source-only package is not a catalog entry.)
15
+
16
+ import {
17
+ generatorRegistry,
18
+ GENERATOR_LAYERS,
19
+ type GeneratorRegistryEntry,
20
+ } from "@metaobjectsdev/codegen-ts";
21
+ import { reactGeneratorRegistry } from "@metaobjectsdev/codegen-ts-react";
22
+ import { tanstackGeneratorRegistry } from "@metaobjectsdev/codegen-ts-tanstack";
23
+
24
+ /** The slices, in install-boundary order: the engine, then its framework packages. */
25
+ const SLICES: ReadonlyArray<readonly [string, Record<string, GeneratorRegistryEntry>]> = [
26
+ ["@metaobjectsdev/codegen-ts", generatorRegistry],
27
+ ["@metaobjectsdev/codegen-ts-react", reactGeneratorRegistry],
28
+ ["@metaobjectsdev/codegen-ts-tanstack", tanstackGeneratorRegistry],
29
+ ];
30
+
31
+ /**
32
+ * Which npm package a catalog entry comes from.
33
+ *
34
+ * This is the fact composition ADDS: a slice cannot know its own package name without
35
+ * hard-coding it in every entry, and the package is precisely what `meta eject` has to
36
+ * name in an install command. Undefined for a name no slice registers.
37
+ */
38
+ export function packageOf(name: string): string | undefined {
39
+ return SLICES.find(([, slice]) => name in slice)?.[0];
40
+ }
41
+
42
+ /** Every `@metaobjectsdev` package that contributes catalog entries. */
43
+ export function catalogPackages(): string[] {
44
+ return SLICES.map(([pkg]) => pkg);
45
+ }
46
+
47
+ /**
48
+ * The composed catalog, keyed by stable name.
49
+ *
50
+ * Throws on a name registered by two packages. A stable name identifies ONE generator
51
+ * across the whole catalog — that is the entire content of ADR-0021 D3 — so a collision
52
+ * is a build-time defect, not something to resolve by precedence. Silently letting the
53
+ * last slice win would make `meta gen --list` describe one generator and `meta gen` run
54
+ * another.
55
+ */
56
+ export function composeCatalog(): Record<string, GeneratorRegistryEntry> {
57
+ return composeSlices(SLICES);
58
+ }
59
+
60
+ /**
61
+ * The composition itself, over any slice list.
62
+ *
63
+ * Separate from {@link composeCatalog} so the REFUSAL is reachable from a test. With the
64
+ * live `SLICES` closed over, the only thing a test could assert is that today's three
65
+ * packages happen not to collide — which stays true if the check above is deleted, so
66
+ * the guard that makes a collision a build failure was never covered by anything.
67
+ */
68
+ export function composeSlices(
69
+ slices: ReadonlyArray<readonly [string, Record<string, GeneratorRegistryEntry>]>,
70
+ ): Record<string, GeneratorRegistryEntry> {
71
+ const out: Record<string, GeneratorRegistryEntry> = {};
72
+ const from: Record<string, string> = {};
73
+ for (const [pkg, slice] of slices) {
74
+ for (const [name, entry] of Object.entries(slice)) {
75
+ if (name in out) {
76
+ throw new Error(
77
+ `generator "${name}" is registered by both ${from[name]} and ${pkg} — a stable ` +
78
+ "name identifies ONE generator across the whole catalog (ADR-0021 D3). Rename " +
79
+ "one of them, or delete the duplicate registration.",
80
+ );
81
+ }
82
+ out[name] = entry;
83
+ from[name] = pkg;
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /**
90
+ * Every catalog entry, grouped by layer in {@link GENERATOR_LAYERS} order and
91
+ * alphabetical within a layer.
92
+ *
93
+ * Layer order, not tier order: `layer` is the axis an adopter selects by, so it is the
94
+ * axis the listing is organized along. `tier` still travels on each row.
95
+ */
96
+ export function listCatalog(): GeneratorRegistryEntry[] {
97
+ return Object.values(composeCatalog()).sort(
98
+ (a, b) =>
99
+ GENERATOR_LAYERS.indexOf(a.layer) - GENERATOR_LAYERS.indexOf(b.layer) ||
100
+ a.name.localeCompare(b.name),
101
+ );
102
+ }
103
+
104
+ /** Resolve one catalog entry by stable name, or undefined. */
105
+ export function catalogEntry(name: string): GeneratorRegistryEntry | undefined {
106
+ return composeCatalog()[name];
107
+ }
@@ -26,11 +26,17 @@ import type { Collection, LoadMemoryOptions } from "@metaobjectsdev/sdk";
26
26
  */
27
27
  export function collectionLoadOptions(
28
28
  collection: Collection,
29
- ): Required<Pick<LoadMemoryOptions, "files" | "fileIds" | "importedPackages" | "importedNodes">> {
29
+ ): Required<Pick<LoadMemoryOptions, "files" | "fileIds" | "importedPackages" | "importedNodes" | "libraries">> {
30
30
  return {
31
31
  files: collection.files,
32
32
  fileIds: collection.fileIds,
33
33
  importedPackages: collection.importedPackages,
34
34
  importedNodes: collection.importedNodes,
35
+ // FR-043 — the shipped-library selection moved here from metaobjects.config.ts, so
36
+ // it now rides the same helper as everything else the collection contributes. That
37
+ // is the point of the move as much as the neutrality is: `libraries` used to be
38
+ // threaded by `loadMemoryOptionsFrom`, a SECOND helper, and #333 is on record as the
39
+ // bug where one of the two reached every command and the other reached none.
40
+ libraries: collection.libraries,
35
41
  };
36
42
  }
@@ -0,0 +1,116 @@
1
+ // What a catalog entry costs to install — the one derivation behind both
2
+ // `meta gen --list --format json` and `meta eject --format json`.
3
+ //
4
+ // Two kinds of package, resolved two different ways:
5
+ //
6
+ // dev the `@metaobjectsdev/codegen-*` package the generator lives in. Its range
7
+ // is the CLI's own version: the engine and the CLI ship in lockstep, so a
8
+ // generator from a different minor is a version mismatch, not a choice.
9
+ // runtime the `@metaobjectsdev` runtime the EMITTED code imports, plus the
10
+ // third-party packages it imports. The runtime's range is the CLI's version
11
+ // for the same reason; the third-party RANGES are read from that runtime
12
+ // package's own `peerDependencies` and are never written here.
13
+ //
14
+ // That last rule is the point of this module. A second copy of a peer range in the CLI
15
+ // is a second thing to keep in step, and the failure mode of drift is an adopter
16
+ // installing a major nothing was tested against — the ERESOLVE trap FR-040 §4.4
17
+ // documented, closed by construction rather than by a warning. If a range cannot be
18
+ // read, the package is reported WITHOUT one rather than guessed at.
19
+
20
+ import { createRequire } from "node:module";
21
+ import { existsSync, readFileSync } from "node:fs";
22
+ import { dirname, join } from "node:path";
23
+ import type { GeneratorRegistryEntry } from "@metaobjectsdev/codegen-ts";
24
+ import { cliVersion } from "./version.js";
25
+ import { packageOf } from "./catalog.js";
26
+ import type { PackageManifest } from "./package-manifest.js";
27
+
28
+ /**
29
+ * A package's declared peer ranges, or `{}` if they cannot be read.
30
+ *
31
+ * Resolves the package's ENTRY and walks up to the nearest manifest, rather than asking
32
+ * for `"<pkg>/package.json"` directly. The direct form is the obvious one and it is
33
+ * WRONG: a package's `exports` map gates every subpath, and `@metaobjectsdev/runtime-ts`
34
+ * exports `.` / `./drivers` / `./fastify` / `./drizzle-fastify` / `./hono` and no
35
+ * `./package.json`, so under real Node it throws ERR_PACKAGE_PATH_NOT_EXPORTED. It
36
+ * appeared to work in-repo only because bun resolves a symlinked source tree; against
37
+ * the published package it failed silently on the first try. Resolving the entry is
38
+ * never gated — `.` is the one subpath every package exports.
39
+ */
40
+ export function peerRangesOf(packageName: string): Record<string, string> {
41
+ try {
42
+ const req = createRequire(import.meta.url);
43
+ let dir = dirname(req.resolve(packageName));
44
+ // The entry sits under dist/; the manifest is at the package root above it.
45
+ for (let hops = 0; hops < 8; hops++) {
46
+ const candidate = join(dir, "package.json");
47
+ if (existsSync(candidate)) {
48
+ const manifest = JSON.parse(readFileSync(candidate, "utf8")) as PackageManifest & {
49
+ name?: string;
50
+ };
51
+ // Guard against stopping at a nested manifest that is not the package itself.
52
+ if (manifest.name === packageName) {
53
+ return (manifest.peerDependencies ?? {}) as Record<string, string>;
54
+ }
55
+ }
56
+ const up = dirname(dir);
57
+ if (up === dir) break;
58
+ dir = up;
59
+ }
60
+ return {};
61
+ } catch {
62
+ return {};
63
+ }
64
+ }
65
+
66
+ export interface InstallSet {
67
+ /** Build-time packages, `name@range`. */
68
+ dev: string[];
69
+ /** Application-runtime packages, `name@range` (or bare `name` if no range is known). */
70
+ runtime: string[];
71
+ /** A paste-ready shell line, or "" when there is nothing to install. */
72
+ command: string;
73
+ }
74
+
75
+ function spec(name: string, range: string | undefined): string {
76
+ return range === undefined ? name : `${name}@${range}`;
77
+ }
78
+
79
+ /**
80
+ * The consolidated install set for a group of catalog entries.
81
+ *
82
+ * Consolidated, not per-entry: ejecting `hooks` and `grid` needs
83
+ * `@metaobjectsdev/codegen-ts-tanstack` ONCE, and an adopter handed the same package
84
+ * twice reasonably wonders which one to run.
85
+ */
86
+ export function installSetFor(entries: readonly GeneratorRegistryEntry[]): InstallSet {
87
+ const version = cliVersion();
88
+ const dev = new Set<string>();
89
+ const runtime = new Set<string>();
90
+
91
+ for (const entry of entries) {
92
+ const pkg = packageOf(entry.name);
93
+ if (pkg !== undefined) dev.add(spec(pkg, `^${version}`));
94
+
95
+ for (const rt of entry.runtimePackages ?? []) runtime.add(spec(rt, `^${version}`));
96
+
97
+ if (entry.runtimePeers !== undefined && entry.runtimePeers.length > 0) {
98
+ // Ranges come from whichever runtime package declares these as peers — the union
99
+ // over this generator's runtimes, since a generator emitting against two of them
100
+ // may take a peer from either. A generator with third-party peers but no runtime
101
+ // package of its own has none to read, so its peers are named UNPINNED: honest,
102
+ // and better than a made-up bound.
103
+ const ranges: Record<string, string> = {};
104
+ for (const rt of entry.runtimePackages ?? []) Object.assign(ranges, peerRangesOf(rt));
105
+ for (const peer of entry.runtimePeers) runtime.add(spec(peer, ranges[peer]));
106
+ }
107
+ }
108
+
109
+ const devList = [...dev].sort();
110
+ const runtimeList = [...runtime].sort();
111
+ const parts: string[] = [];
112
+ if (devList.length > 0) parts.push(`npm i -D ${devList.join(" ")}`);
113
+ if (runtimeList.length > 0) parts.push(`npm i ${runtimeList.join(" ")}`);
114
+
115
+ return { dev: devList, runtime: runtimeList, command: parts.join(" && ") };
116
+ }