@metaobjectsdev/cli 1.0.9-rc.4 → 1.0.9-rc.5

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 (49) hide show
  1. package/README.md +15 -0
  2. package/dist/src/commands/eject.d.ts +17 -0
  3. package/dist/src/commands/eject.d.ts.map +1 -1
  4. package/dist/src/commands/eject.js +87 -2
  5. package/dist/src/commands/eject.js.map +1 -1
  6. package/dist/src/commands/generator-help.d.ts +21 -0
  7. package/dist/src/commands/generator-help.d.ts.map +1 -0
  8. package/dist/src/commands/generator-help.js +43 -0
  9. package/dist/src/commands/generator-help.js.map +1 -0
  10. package/dist/src/commands/generator.d.ts +36 -0
  11. package/dist/src/commands/generator.d.ts.map +1 -0
  12. package/dist/src/commands/generator.js +333 -0
  13. package/dist/src/commands/generator.js.map +1 -0
  14. package/dist/src/commands/init.d.ts.map +1 -1
  15. package/dist/src/commands/init.js +10 -1
  16. package/dist/src/commands/init.js.map +1 -1
  17. package/dist/src/commands/verify.d.ts.map +1 -1
  18. package/dist/src/commands/verify.js +5 -3
  19. package/dist/src/commands/verify.js.map +1 -1
  20. package/dist/src/index.d.ts.map +1 -1
  21. package/dist/src/index.js +8 -0
  22. package/dist/src/index.js.map +1 -1
  23. package/dist/src/lib/advisory.d.ts +14 -0
  24. package/dist/src/lib/advisory.d.ts.map +1 -1
  25. package/dist/src/lib/advisory.js +64 -5
  26. package/dist/src/lib/advisory.js.map +1 -1
  27. package/dist/src/lib/catalog-listing.d.ts +8 -0
  28. package/dist/src/lib/catalog-listing.d.ts.map +1 -1
  29. package/dist/src/lib/catalog-listing.js +35 -1
  30. package/dist/src/lib/catalog-listing.js.map +1 -1
  31. package/dist/src/lib/install-set.d.ts +12 -2
  32. package/dist/src/lib/install-set.d.ts.map +1 -1
  33. package/dist/src/lib/install-set.js +14 -2
  34. package/dist/src/lib/install-set.js.map +1 -1
  35. package/dist/src/lib/runtime-eject.d.ts +80 -0
  36. package/dist/src/lib/runtime-eject.d.ts.map +1 -0
  37. package/dist/src/lib/runtime-eject.js +212 -0
  38. package/dist/src/lib/runtime-eject.js.map +1 -0
  39. package/package.json +10 -10
  40. package/src/commands/eject.ts +103 -3
  41. package/src/commands/generator-help.ts +47 -0
  42. package/src/commands/generator.ts +390 -0
  43. package/src/commands/init.ts +10 -1
  44. package/src/commands/verify.ts +5 -4
  45. package/src/index.ts +8 -0
  46. package/src/lib/advisory.ts +71 -5
  47. package/src/lib/catalog-listing.ts +48 -1
  48. package/src/lib/install-set.ts +19 -2
  49. package/src/lib/runtime-eject.ts +263 -0
@@ -25,7 +25,12 @@ import { log } from "../lib/log.js";
25
25
  import { declaredDependencyNames, readPackageManifest } from "../lib/package-manifest.js";
26
26
  import { compareOwnedCopy, type OwnedComparison } from "../lib/owned-copy.js";
27
27
  import { composeCatalog, packageOf } from "../lib/catalog.js";
28
- import { installSetFor, type InstallSet } from "../lib/install-set.js";
28
+ import { installSetFor, type InstallSet, type OwnedRuntimeInstall } from "../lib/install-set.js";
29
+ import {
30
+ ejectRuntime, ejectsRuntime, runtimeCopyStatus, runtimeDiffCommand, runtimeInstall,
31
+ type RuntimeEjectResult, type RuntimeFileResult,
32
+ } from "../lib/runtime-eject.js";
33
+ import { HTTP_RUNTIME_PACKAGE, OWNED_RUNTIME_DIR } from "@metaobjectsdev/codegen-ts";
29
34
  import { emitStructured, type OutputFormat } from "../lib/format.js";
30
35
  import {
31
36
  ejectLibrary, ejectableLibraryNames, isLibraryName, libraryStaleness,
@@ -159,6 +164,12 @@ export interface EjectResult {
159
164
  * cheapest thing that makes that condition observable.
160
165
  */
161
166
  comparison?: OwnedComparison | undefined;
167
+ /**
168
+ * ADR-0034 Amendment 3 (2026-09-24): the HTTP-adapter source this generator's OUTPUT
169
+ * imports, copied into `codegen/runtime/` so the adopter owns that too. Absent for a
170
+ * generator whose output imports no adapter.
171
+ */
172
+ runtime?: RuntimeEjectResult | undefined;
162
173
  }
163
174
 
164
175
  /** The `@metaobjectsdev/*` packages an ejected template imports, read from the file
@@ -241,8 +252,15 @@ export async function ejectGenerator(opts: EjectOptions): Promise<EjectResult> {
241
252
  const comparison =
242
253
  existing === undefined ? undefined : await compareOwnedCopy(existing, templateSource);
243
254
 
255
+ // The adapter source the OUTPUT imports is part of what is handed over — copied whether
256
+ // or not the generator file itself was already here, and under the same never-clobber
257
+ // rule, so re-running eject fills in a missing copy without touching a kept one.
258
+ const runtime = ejectsRuntime(opts.name)
259
+ ? await ejectRuntime(opts.cwd, [opts.name], opts.force === true)
260
+ : undefined;
261
+
244
262
  if (!opts.force && existing !== undefined) {
245
- return { ...common, status: "preserved", comparison };
263
+ return { ...common, status: "preserved", comparison, runtime };
246
264
  }
247
265
 
248
266
  await mkdir(join(opts.cwd, OWNED_GENERATORS_DIR), { recursive: true });
@@ -251,9 +269,45 @@ export async function ejectGenerator(opts: EjectOptions): Promise<EjectResult> {
251
269
  ...common,
252
270
  status: existing === undefined ? "created" : "replaced",
253
271
  comparison,
272
+ runtime,
254
273
  };
255
274
  }
256
275
 
276
+ /**
277
+ * The owned HTTP-adapter copy under `codegen/runtime/`, file by file, against the installed
278
+ * `@metaobjectsdev/runtime-ts` it was copied from. Shared by `meta eject --list` and
279
+ * `meta gen --list`. Empty when the project owns no adapter copy.
280
+ *
281
+ * `identical` says an upgrade can be taken by re-copying (`meta eject <name> --force`);
282
+ * `DIFFERS` says the copy is yours in a way the package is not — your fix, or upstream's —
283
+ * and names the diff that tells which.
284
+ */
285
+ export async function runtimeCopyLines(cwd: string): Promise<string[]> {
286
+ const rows = await runtimeCopyStatus(cwd);
287
+ if (rows.length === 0) return [];
288
+ const lines: string[] = [
289
+ `HTTP adapter source this project owns (${OWNED_RUNTIME_DIR}/), against the installed ${HTTP_RUNTIME_PACKAGE}:`,
290
+ ];
291
+ const differing: string[] = [];
292
+ for (const r of rows) {
293
+ if (r.verdict === "identical") lines.push(` ${r.path} [identical to the package]`);
294
+ else if (r.verdict === "reformatted") lines.push(` ${r.path} [same content as the package, your formatting]`);
295
+ else if (r.verdict === "not-in-package") lines.push(` ${r.path} [not in the package — yours, or removed upstream]`);
296
+ else {
297
+ differing.push(r.path);
298
+ lines.push(
299
+ ` ${r.path} [DIFFERS: ${r.referenceOnly} line(s) only in the package, ${r.localOnly} line(s) only in yours]`,
300
+ );
301
+ }
302
+ }
303
+ if (differing.length > 0) {
304
+ lines.push(" See the difference (upstream fix, or your own?) before taking an upgrade:");
305
+ for (const p of differing) lines.push(` ${runtimeDiffCommand(p)}`);
306
+ }
307
+ lines.push("");
308
+ return lines;
309
+ }
310
+
257
311
  /**
258
312
  * `--list` doubles as the staleness report for what this project ALREADY owns.
259
313
  *
@@ -315,6 +369,7 @@ async function listOutput(cwd: string): Promise<string> {
315
369
  );
316
370
  lines.push("");
317
371
  }
372
+ lines.push(...(await runtimeCopyLines(cwd)));
318
373
  // FR-043 — the shipped libraries, and how far this project's copies have drifted.
319
374
  lines.push("Shipped libraries (copy one into your own sources and own it):");
320
375
  for (const name of ejectableLibraryNames()) lines.push(` ${name}`);
@@ -369,6 +424,8 @@ interface EjectedRow {
369
424
  /** The two edits an adopter makes in `metaobjects.config.ts`. */
370
425
  wire: { import: string; entry: string };
371
426
  requires: readonly string[];
427
+ /** The adapter source copied beside it (ADR-0034 Amendment 3). Absent when none. */
428
+ runtime?: RuntimeFileResult[];
372
429
  }
373
430
 
374
431
  interface EjectPayload {
@@ -518,6 +575,33 @@ function reportOne(result: EjectResult, name: string): void {
518
575
  "AND the entry.",
519
576
  );
520
577
  for (const line of result.dependencyNotes) log.info(line);
578
+ if (result.runtime !== undefined && result.runtime.files.length > 0) reportRuntime(result.runtime.files);
579
+ }
580
+
581
+ /** What the adapter copy handed over, and how the emitted code reaches it. */
582
+ function reportRuntime(files: readonly RuntimeFileResult[]): void {
583
+ const written = files.filter((f) => f.status !== "preserved");
584
+ // A kept file that already matches the package is not news (ejecting `entity routes`
585
+ // copies the shared allowlist file once, then finds it) — only a kept file that differs is.
586
+ const kept = files.filter((f) => f.status === "preserved" && f.comparison?.verdict !== "identical");
587
+ log.info(
588
+ `The code this generator emits imports its HTTP adapter from ${OWNED_RUNTIME_DIR}/, not from ` +
589
+ `${HTTP_RUNTIME_PACKAGE}: the adapter, filter parser, error envelopes and pagination are yours too.`,
590
+ );
591
+ if (written.length > 0) {
592
+ log.info(` Copied ${written.length} file(s) into ${OWNED_RUNTIME_DIR}/ (verbatim from the installed package).`);
593
+ }
594
+ const keptDiffering = kept.filter((f) => f.comparison?.verdict === "differs");
595
+ if (kept.length > 0) {
596
+ log.info(
597
+ ` ${kept.length} file(s) were already there and were left untouched` +
598
+ (keptDiffering.length > 0 ? `, ${keptDiffering.length} of them differing from the package` : "") +
599
+ " (--force replaces them). `meta eject --list` compares each one.",
600
+ );
601
+ }
602
+ log.info(
603
+ ` To import the package instead, pass \`runtimeImport: "${HTTP_RUNTIME_PACKAGE}"\` to the generator.`,
604
+ );
521
605
  }
522
606
 
523
607
  export async function ejectCommand(
@@ -575,6 +659,9 @@ export async function ejectCommand(
575
659
  const rows: EjectedRow[] = [];
576
660
  const libraries: LibraryEjectResult[] = [];
577
661
  const templatePackages = new Set<string>();
662
+ // ADR-0034 Amendment 3: the generators whose adapter source was copied, and what it imports.
663
+ const runtimeGenerators = new Set<string>();
664
+ const runtimePackages = new Set<string>();
578
665
  try {
579
666
  for (const name of flags.names) {
580
667
  if (isLibraryName(name)) {
@@ -585,12 +672,17 @@ export async function ejectCommand(
585
672
  }
586
673
  const result = await ejectGenerator({ cwd, name, force: flags.force });
587
674
  for (const p of result.templatePackages) templatePackages.add(p);
675
+ if (result.runtime !== undefined) {
676
+ runtimeGenerators.add(name);
677
+ for (const p of result.runtime.packages) runtimePackages.add(p);
678
+ }
588
679
  rows.push({
589
680
  name,
590
681
  path: result.path,
591
682
  status: result.status,
592
683
  wire: { import: result.importLine, entry: `${result.exportName}()` },
593
684
  requires: catalog[name]?.requires ?? [],
685
+ ...(result.runtime !== undefined ? { runtime: result.runtime.files } : {}),
594
686
  });
595
687
  if (fmt === "text") reportOne(result, name);
596
688
  }
@@ -610,7 +702,15 @@ export async function ejectCommand(
610
702
  .filter((n) => !isLibraryName(n))
611
703
  .map((n) => catalog[n])
612
704
  .filter((e) => e !== undefined);
613
- const install = installSetFor(entries, [...templatePackages]);
705
+ // The copied adapter source imports its own dependencies (qs, the framework, the core
706
+ // `@metaobjectsdev/metadata`), and the generated code no longer imports the runtime
707
+ // package — so the install line trades one for the other.
708
+ let ownedRuntime: OwnedRuntimeInstall | undefined;
709
+ if (runtimeGenerators.size > 0) {
710
+ const { runtime, dev } = runtimeInstall([...runtimePackages]);
711
+ ownedRuntime = { generators: runtimeGenerators, runtime, dev };
712
+ }
713
+ const install = installSetFor(entries, [...templatePackages], ownedRuntime);
614
714
  const configKeys = [...new Set(entries.flatMap((e) => e.configKeys ?? []))].sort();
615
715
 
616
716
  if (fmt === "text") {
@@ -0,0 +1,47 @@
1
+ // The dependency-free half of `meta generator` — its flags and help text — so the CLI entry
2
+ // point can print `meta generator --help` without loading the codegen packages the command
3
+ // itself needs.
4
+
5
+ /** Where owned generators live — the directory `meta init` scaffolds and `meta eject` fills. */
6
+ export const OWNED_GENERATORS_DIR = "codegen/generators";
7
+ export const CONFIG_FILE = "metaobjects.config.ts";
8
+
9
+ export const GENERATOR_SCOPES = ["entity", "package", "model"] as const;
10
+ export type GeneratorScope = (typeof GENERATOR_SCOPES)[number];
11
+
12
+ export const GENERATOR_OPTIONS = {
13
+ "scope": { type: "string", default: "entity" },
14
+ "force": { type: "boolean", default: false },
15
+ "no-wire": { type: "boolean", default: false },
16
+ } as const;
17
+
18
+ export const GENERATOR_HELP = `meta generator — write a generator of your own
19
+
20
+ USAGE:
21
+ meta generator new <name> [--scope entity|package|model] [--force] [--no-wire]
22
+
23
+ Writes ${OWNED_GENERATORS_DIR}/<name>.ts — a small, commented, WORKING generator — and
24
+ adds its import and entry to ${CONFIG_FILE}, so \`meta gen\` runs it and
25
+ \`meta verify --codegen\` gates its output straight away. Then edit the emit to produce
26
+ what you need: OpenAPI, JSON Schema, a client, DTOs, docs — anything the model describes.
27
+
28
+ This is the primary way to get an output MetaObjects does not ship. Reach for
29
+ \`meta eject <name>\` instead only when a reference generator already emits something
30
+ close to what you want (\`meta gen --list\` is the catalog).
31
+
32
+ <name> is kebab-case (json-schema, openapi, service-layer). It names the file, the
33
+ generator's \`name\` in diagnostics, and the exported factory (jsonSchemaGenerator).
34
+
35
+ FLAGS:
36
+ --scope <scope> What the generator walks, one file per unit (default: entity):
37
+ entity one file per object in the model
38
+ package one file per metadata package
39
+ model one file for the whole model
40
+ --force Overwrite an existing ${OWNED_GENERATORS_DIR}/<name>.ts
41
+ --no-wire Write the file only; print the import and entry to add yourself
42
+ --help, -h Print this help
43
+
44
+ Every port's shape, and JSON Schema + OpenAPI 3.1 examples to copy: the guide
45
+ docs/recipes/write-your-own-generator.md in the MetaObjects repository
46
+ (github.com/metaobjectsdev), and the metaobjects-codegen skill \`meta init\` installs.
47
+ `;
@@ -0,0 +1,390 @@
1
+ // `meta generator new <name>` — scaffold a generator of YOUR OWN (ADR-0034 Amendment 4).
2
+ //
3
+ // MetaObjects' product is the core — the model, loader, `verify`, `migrate`, render and
4
+ // extract. Every output an application needs beyond the reference generators (OpenAPI, JSON
5
+ // Schema, a client, a DTO layer, docs) is a generator the adopter writes. This command makes
6
+ // the first minute of that cheap: it writes a working, commented generator into
7
+ // codegen/generators/ and wires it into metaobjects.config.ts, so `meta gen` runs it and
8
+ // `meta verify --codegen` gates it before the author has changed a line.
9
+ //
10
+ // It is deliberately NOT eject: eject copies a reference generator; this writes a new one.
11
+ // The two share only the dependency report.
12
+
13
+ import { existsSync } from "node:fs";
14
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+ import { parseArgs } from "node:util";
17
+ import { catalogEntry } from "../lib/catalog.js";
18
+ import { log } from "../lib/log.js";
19
+ import { dependencyNotesForTemplate } from "./eject.js";
20
+ import {
21
+ CONFIG_FILE,
22
+ GENERATOR_OPTIONS,
23
+ GENERATOR_SCOPES,
24
+ OWNED_GENERATORS_DIR,
25
+ type GeneratorScope,
26
+ } from "./generator-help.js";
27
+
28
+ export { GENERATOR_HELP, GENERATOR_OPTIONS, GENERATOR_SCOPES, OWNED_GENERATORS_DIR } from "./generator-help.js";
29
+ export type { GeneratorScope } from "./generator-help.js";
30
+
31
+ const NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
32
+
33
+ /** kebab-case → the exported factory's name: `json-schema` → `jsonSchemaGenerator`. */
34
+ export function factoryNameFor(name: string): string {
35
+ const camel = name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
36
+ return `${camel}Generator`;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // The skeleton
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const HEADER = (name: string, scope: GeneratorScope) => `// ${name} — a generator of your own. It is yours: edit it freely; no MetaObjects release
44
+ // changes it. Scaffolded by \`meta generator new ${name} --scope ${scope}\`.
45
+ //
46
+ // HOW IT RUNS. metaobjects.config.ts lists it in \`generators: [...]\`. \`meta gen\` calls
47
+ // generate() with the loaded model and writes each returned { path, content } under outDir
48
+ // (paths are relative to it). \`meta verify --codegen\` re-runs it and fails when committed
49
+ // output is stale — nothing to register for that. Typecheck this file with
50
+ // npx tsc -p tsconfig.codegen.json
51
+ // because \`meta gen\` loads it WITHOUT typechecking.
52
+ //
53
+ // READING THE MODEL (it is read-only — never mutate it):
54
+ // ctx.entities EVERY object: entities, value objects, projections AND abstract
55
+ // bases. The \`filter\` below decides which ones you get.
56
+ // obj.name / effectivePackage(obj) the object's name and its package (or undefined)
57
+ // obj.fields() all fields, INCLUDING those inherited through \`extends\`
58
+ // field.subType "string" | "long" | "enum" | "object" | ... (\`meta types field\`)
59
+ // field.isRequired, field.maxLength, field.precision, field.scale
60
+ // field.resolvedIsArray() array-ness, inherited too
61
+ // field.attr("description") any attribute's effective value (own, else inherited)
62
+ // enumValues(field) a field.enum's members
63
+ // objectRefTarget(field) the object a field.object points at (package-aware)
64
+ // obj.primaryIdentity()?.fields the primary-key field names
65
+ //
66
+ // THE ONE RULE (ADR-0039): always read through the accessors above. The own*() forms
67
+ // (ownFields, ownAttr, ownChildren) and the raw \`isArray\` flag skip everything an object or
68
+ // field inherits through \`extends\` — the output compiles and is silently wrong.
69
+ //
70
+ // NAMING: toCamelCase / toPascalCase / toSnakeCase / pluralize, and servedPath(obj, prefix)
71
+ // for the REST address the reference routes serve, all from "@metaobjectsdev/codegen-ts".
72
+ // The config's apiPrefix reaches you as ctx.renderContext?.apiPrefix.
73
+ `;
74
+
75
+ /** Exactly the names the scope's skeleton uses — an unused import fails a strict tsconfig. */
76
+ function importsFor(scope: GeneratorScope): string {
77
+ const names = ["effectivePackage", "enumValues", "isAbstract", "objectRefTarget", HELPER_BY_SCOPE[scope]];
78
+ if (scope !== "model") names.push("packageToPath");
79
+ const list = names.sort().map((n) => ` ${n},\n`).join("");
80
+ return `import {\n${list} type Generator,\n} from "@metaobjectsdev/codegen-ts";\n` +
81
+ `import type { MetaField, MetaObject } from "@metaobjectsdev/metadata";\n`;
82
+ }
83
+
84
+ const DESCRIBE_FIELD = `
85
+ /** One field, described. Replace this with whatever your output needs per field. */
86
+ function describeField(field: MetaField): Record<string, unknown> {
87
+ const out: Record<string, unknown> = {
88
+ name: field.name,
89
+ type: field.subType,
90
+ required: field.isRequired,
91
+ array: field.resolvedIsArray(),
92
+ };
93
+ if (field.maxLength !== undefined) out.maxLength = field.maxLength;
94
+ const members = enumValues(field);
95
+ if (members !== undefined) out.values = members;
96
+ const target = objectRefTarget(field);
97
+ if (target !== undefined) out.ref = target.resolutionKey();
98
+ return out;
99
+ }
100
+
101
+ const describeObject = (obj: MetaObject) => ({
102
+ name: obj.name,
103
+ package: effectivePackage(obj) ?? null,
104
+ fields: obj.fields().map(describeField),
105
+ });
106
+ `;
107
+
108
+ const FACTORY_HEAD = (name: string, factory: string) => `
109
+ export interface ${factory.charAt(0).toUpperCase()}${factory.slice(1)}Opts {
110
+ /** Narrow which objects this generator sees (ANDed with the default filter below). */
111
+ filter?: (obj: MetaObject) => boolean;
112
+ /** Named output target from the config's \`targets\`; omit for the top-level outDir. */
113
+ target?: string;
114
+ }
115
+
116
+ export function ${factory}(opts: ${factory.charAt(0).toUpperCase()}${factory.slice(1)}Opts = {}): Generator {
117
+ const generator: Generator = {
118
+ name: "${name}",
119
+ // Which objects: here, every concrete one. Abstract bases only contribute fields to
120
+ // what \`extends\` them. Other predicates to compose: hasAnyRdbSource (persisted),
121
+ // servesReadApi / servesWriteApi (has reference routes), isProjection.
122
+ filter: (obj) => !isAbstract(obj) && (opts.filter?.(obj) ?? true),
123
+ `;
124
+
125
+ const FACTORY_TAIL = ` };
126
+ if (opts.target !== undefined) generator.target = opts.target;
127
+ return generator;
128
+ }
129
+ `;
130
+
131
+ const GENERATE_BY_SCOPE: Record<GeneratorScope, (name: string) => string> = {
132
+ entity: (name) => ` // perEntity: generate() runs once per matched object; return one file (or an array).
133
+ generate: perEntity((obj) => {
134
+ const pkg = effectivePackage(obj);
135
+ const dir = pkg === undefined ? "${name}" : \`${name}/\${packageToPath(pkg)}\`;
136
+ return {
137
+ path: \`\${dir}/\${obj.name}.json\`,
138
+ content: \`\${JSON.stringify(describeObject(obj), null, 2)}\\n\`,
139
+ };
140
+ }),
141
+ `,
142
+ package: (name) => ` // perPackage: generate() runs once per metadata package with that package's objects.
143
+ generate: perPackage((pkg, objects) => ({
144
+ path: \`${name}/\${pkg === "" ? "_root" : packageToPath(pkg)}.json\`,
145
+ content: \`\${JSON.stringify({ package: pkg, objects: objects.map(describeObject) }, null, 2)}\\n\`,
146
+ })),
147
+ `,
148
+ model: (name) => ` // perModel: generate() runs once with every matched object; return one file (or more).
149
+ generate: perModel((objects) => ({
150
+ path: "${name}.json",
151
+ content: \`\${JSON.stringify({ objects: objects.map(describeObject) }, null, 2)}\\n\`,
152
+ })),
153
+ `,
154
+ };
155
+
156
+ const HELPER_BY_SCOPE: Record<GeneratorScope, string> = {
157
+ entity: "perEntity",
158
+ package: "perPackage",
159
+ model: "perModel",
160
+ };
161
+
162
+ /** The generator source `meta generator new` writes. Exported for the gate that runs it. */
163
+ export function renderGeneratorSkeleton(name: string, scope: GeneratorScope): string {
164
+ const factory = factoryNameFor(name);
165
+ return [
166
+ HEADER(name, scope),
167
+ importsFor(scope),
168
+ DESCRIBE_FIELD,
169
+ FACTORY_HEAD(name, factory),
170
+ GENERATE_BY_SCOPE[scope](name),
171
+ FACTORY_TAIL,
172
+ ].join("");
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Wiring the config
177
+ // ---------------------------------------------------------------------------
178
+
179
+ export interface WireResult {
180
+ status: "wired" | "already-wired" | "manual";
181
+ source: string;
182
+ }
183
+
184
+ /**
185
+ * Index of the `]` closing the `[` at `open`, skipping string literals, template literals
186
+ * and comments so a bracket inside `"a]b"` does not end the array. -1 if unbalanced.
187
+ */
188
+ function matchingBracket(src: string, open: number): number {
189
+ let depth = 0;
190
+ for (let i = open; i < src.length; i++) {
191
+ const c = src[i]!;
192
+ if (c === '"' || c === "'" || c === "`") {
193
+ for (i++; i < src.length && src[i] !== c; i++) if (src[i] === "\\") i++;
194
+ continue;
195
+ }
196
+ if (c === "/" && src[i + 1] === "/") {
197
+ while (i < src.length && src[i] !== "\n") i++;
198
+ continue;
199
+ }
200
+ if (c === "/" && src[i + 1] === "*") {
201
+ i = src.indexOf("*/", i + 2);
202
+ if (i === -1) return -1;
203
+ i++;
204
+ continue;
205
+ }
206
+ if (c === "[" || c === "(" || c === "{") depth++;
207
+ else if (c === "]" || c === ")" || c === "}") {
208
+ depth--;
209
+ if (depth === 0) return c === "]" ? i : -1;
210
+ }
211
+ }
212
+ return -1;
213
+ }
214
+
215
+ /**
216
+ * Add `importLine` after the config's last top-level import and `entry` to the end of its
217
+ * `generators: [...]` array. Edits only a config it can read unambiguously — exactly one
218
+ * literal `generators: [` array — and otherwise returns `manual` with the source untouched,
219
+ * so the caller prints the two lines instead of guessing.
220
+ */
221
+ export function wireGeneratorIntoConfig(source: string, importLine: string, entry: string): WireResult {
222
+ if (source.includes(importLine)) return { status: "already-wired", source };
223
+
224
+ const arrays = [...source.matchAll(/\bgenerators\s*:\s*\[/g)];
225
+ if (arrays.length !== 1) return { status: "manual", source };
226
+ const open = arrays[0]!.index! + arrays[0]![0].length - 1;
227
+ const close = matchingBracket(source, open);
228
+ if (close === -1) return { status: "manual", source };
229
+
230
+ const inner = source.slice(open + 1, close);
231
+ let newInner: string;
232
+ if (inner.trim() === "") {
233
+ newInner = entry;
234
+ } else if (inner.includes("\n")) {
235
+ // Multi-line: a new line at the indent of the last element, keeping the closing line.
236
+ const trailing = inner.slice(inner.trimEnd().length); // "\n " before `]`
237
+ const body = inner.trimEnd();
238
+ const lastLine = body.slice(body.lastIndexOf("\n") + 1);
239
+ const indent = lastLine.slice(0, lastLine.length - lastLine.trimStart().length);
240
+ // A trailing line comment means the element's comma sits before the comment.
241
+ const hasComma = /,\s*(\/\/[^\n]*)?$/.test(body);
242
+ newInner = `${body}${hasComma ? "" : ","}\n${indent}${entry},${trailing}`;
243
+ } else {
244
+ const body = inner.trimEnd();
245
+ newInner = body.endsWith(",") ? `${body} ${entry}` : `${body}, ${entry}`;
246
+ }
247
+ let out = source.slice(0, open + 1) + newInner + source.slice(close);
248
+
249
+ // The import: after the last line that starts an import statement (and its `from` line).
250
+ const importRe = /^import[\s\S]*?from\s+["'][^"']+["'];?[^\n]*$/gm;
251
+ let lastEnd = -1;
252
+ for (const m of out.matchAll(importRe)) lastEnd = m.index! + m[0].length;
253
+ out = lastEnd === -1
254
+ ? `${importLine}\n${out}`
255
+ : `${out.slice(0, lastEnd)}\n${importLine}${out.slice(lastEnd)}`;
256
+ return { status: "wired", source: out };
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // The command
261
+ // ---------------------------------------------------------------------------
262
+
263
+ export interface NewGeneratorOptions {
264
+ cwd: string;
265
+ name: string;
266
+ scope?: GeneratorScope;
267
+ force?: boolean;
268
+ wire?: boolean;
269
+ }
270
+
271
+ export interface NewGeneratorResult {
272
+ file: string;
273
+ fileStatus: "created" | "overwritten";
274
+ wireStatus: WireResult["status"] | "skipped";
275
+ importLine: string;
276
+ entry: string;
277
+ dependencyNotes: string[];
278
+ }
279
+
280
+ export async function newGenerator(opts: NewGeneratorOptions): Promise<NewGeneratorResult> {
281
+ const { cwd, name } = opts;
282
+ const scope = opts.scope ?? "entity";
283
+ if (!NAME_PATTERN.test(name)) {
284
+ throw new Error(`generator name "${name}" must be kebab-case (e.g. json-schema, openapi).`);
285
+ }
286
+ if (catalogEntry(name) !== undefined) {
287
+ throw new Error(
288
+ `"${name}" is a reference generator's name. Pick another name for your own generator, ` +
289
+ `or take a copy of that one with \`meta eject ${name}\`.`,
290
+ );
291
+ }
292
+ const configPath = join(cwd, CONFIG_FILE);
293
+ const wire = opts.wire ?? true;
294
+ if (wire && !existsSync(configPath)) {
295
+ throw new Error(`no ${CONFIG_FILE} here — run \`meta init\` first, or pass --no-wire.`);
296
+ }
297
+
298
+ const rel = `${OWNED_GENERATORS_DIR}/${name}.ts`;
299
+ const abs = join(cwd, rel);
300
+ const existed = existsSync(abs);
301
+ if (existed && opts.force !== true) {
302
+ throw new Error(`${rel} already exists — it is yours; edit it, or pass --force to replace it.`);
303
+ }
304
+ const source = renderGeneratorSkeleton(name, scope);
305
+ await mkdir(join(cwd, OWNED_GENERATORS_DIR), { recursive: true });
306
+ await writeFile(abs, source, "utf8");
307
+
308
+ const factory = factoryNameFor(name);
309
+ const importLine = `import { ${factory} } from "./${OWNED_GENERATORS_DIR}/${name}.js";`;
310
+ const entry = `${factory}()`;
311
+
312
+ let wireStatus: NewGeneratorResult["wireStatus"] = "skipped";
313
+ if (wire) {
314
+ const result = wireGeneratorIntoConfig(await readFile(configPath, "utf8"), importLine, entry);
315
+ if (result.status === "wired") await writeFile(configPath, result.source, "utf8");
316
+ wireStatus = result.status;
317
+ }
318
+
319
+ return {
320
+ file: rel,
321
+ fileStatus: existed ? "overwritten" : "created",
322
+ wireStatus,
323
+ importLine,
324
+ entry,
325
+ // The same report eject gives, worded for a file nobody ejected.
326
+ dependencyNotes: (await dependencyNotesForTemplate(cwd, source)).map((n) =>
327
+ n.replace(/^The ejected file imports/, "The new generator imports")),
328
+ };
329
+ }
330
+
331
+ export async function generatorCommand(args: string[], cwd: string): Promise<number> {
332
+ let values: { scope?: string; force?: boolean; "no-wire"?: boolean };
333
+ let positionals: string[];
334
+ try {
335
+ ({ values, positionals } = parseArgs({ args, options: GENERATOR_OPTIONS, strict: true, allowPositionals: true }));
336
+ } catch (err) {
337
+ log.error((err as Error).message);
338
+ return 2;
339
+ }
340
+ const [sub, name, ...extra] = positionals;
341
+ if (sub !== "new" || name === undefined || extra.length > 0) {
342
+ log.error("usage: meta generator new <name> [--scope entity|package|model]. See `meta generator --help`.");
343
+ return 2;
344
+ }
345
+ const scope = values.scope ?? "entity";
346
+ if (!(GENERATOR_SCOPES as readonly string[]).includes(scope)) {
347
+ log.error(`--scope must be one of ${GENERATOR_SCOPES.join(", ")} (got "${scope}").`);
348
+ return 2;
349
+ }
350
+
351
+ let r: NewGeneratorResult;
352
+ try {
353
+ r = await newGenerator({
354
+ cwd,
355
+ name,
356
+ scope: scope as GeneratorScope,
357
+ force: values.force === true,
358
+ wire: values["no-wire"] !== true,
359
+ });
360
+ } catch (err) {
361
+ log.error((err as Error).message);
362
+ return 2;
363
+ }
364
+
365
+ const lines = [`${r.fileStatus === "created" ? "Created" : "Overwrote"} ${r.file} — yours to edit.`];
366
+ if (r.wireStatus === "wired") {
367
+ lines.push(`Wired it into ${CONFIG_FILE}: ${r.entry}`);
368
+ } else if (r.wireStatus === "already-wired") {
369
+ lines.push(`${CONFIG_FILE} already imports it.`);
370
+ } else {
371
+ lines.push(
372
+ r.wireStatus === "manual"
373
+ ? `Could not find one literal \`generators: [...]\` in ${CONFIG_FILE} to edit safely. Add:`
374
+ : `Add to ${CONFIG_FILE}:`,
375
+ ` ${r.importLine}`,
376
+ ` generators: [..., ${r.entry}]`,
377
+ );
378
+ }
379
+ lines.push(
380
+ "",
381
+ "Next:",
382
+ " meta gen # runs it; output lands under outDir",
383
+ " meta verify --codegen # drift-gates it (no registration needed)",
384
+ " npx tsc -p tsconfig.codegen.json # typecheck it — meta gen does not",
385
+ `Then edit ${r.file}: change what it emits, keep reading the model through fields() / attr().`,
386
+ );
387
+ if (r.dependencyNotes.length > 0) lines.push("", ...r.dependencyNotes);
388
+ log.info(lines.join("\n"));
389
+ return 0;
390
+ }
@@ -114,6 +114,13 @@ export default defineConfig({
114
114
  // \`meta gen\` runs — and prints the import line to add here, the entry to add below,
115
115
  // what to install, and any config keys those generators read (\`dbImport\`,
116
116
  // \`apiPrefix\`, \`extStyle\`, …). Add them here as you go.
117
+ //
118
+ // Need an output nothing in that catalog emits — OpenAPI, JSON Schema, a client, a
119
+ // service layer, docs? Write your own generator; that is the normal path:
120
+ //
121
+ // meta generator new openapi --scope model
122
+ //
123
+ // writes a working, commented generator into ./codegen/generators/ and adds it here.
117
124
  generators: [],
118
125
 
119
126
  docs: {
@@ -145,7 +152,9 @@ Next steps:
145
152
  3. meta eject <name>... # take the ones you want — copies them into
146
153
  # codegen/generators/ (yours to edit) and prints the
147
154
  # import, the entry to wire, and what to install
148
- 4. meta gen # generate from exactly what you wired
155
+ meta generator new <name> # an output no reference emits? write your own — this
156
+ # scaffolds a working one and wires it
157
+ 4. meta gen # generate from exactly what you wired
149
158
  5. meta docs # neutral model + API docs (on by default)
150
159
  6. Create your tables: meta migrate --from-db --db file:dev.sqlite --dialect sqlite --slug init --apply
151
160
  `;
@@ -17,6 +17,7 @@ import {
17
17
  libraryPrefixRows,
18
18
  unindexedFkRows,
19
19
  referentialActionConflictRows,
20
+ advisoryHelpLines,
20
21
  } from "../lib/advisory.js";
21
22
  import { scanForUnprovenancedLibraryPrefix } from "../lib/library-prefix-advisory.js";
22
23
  import { scanForUnindexedForeignKeys, type UnindexedFkFinding } from "../lib/fk-index-advisory.js";
@@ -1815,7 +1816,9 @@ function buildVerifyPayload(input: {
1815
1816
  : `${failed.length} of ${ran.length} gate(s) failed (${failed.map((g) => g.gate).join(", ")})`,
1816
1817
  ];
1817
1818
  if (input.antiPatterns.status === "ran" && input.antiPatterns.total > 0) {
1818
- parts.push(`${input.antiPatterns.total} advisory anti-pattern finding(s)`);
1819
+ // "advisory", not "anti-pattern": the section also carries unindexed foreign keys, a
1820
+ // missing baseUrl and the other non-scanner rows — `help` breaks the count down by kind.
1821
+ parts.push(`${input.antiPatterns.total} advisory finding(s)`);
1819
1822
  }
1820
1823
  if (input.requirements.total > 0) {
1821
1824
  parts.push(`${input.requirements.total} requirement diagnostic(s)`);
@@ -1831,9 +1834,7 @@ function buildVerifyPayload(input: {
1831
1834
  );
1832
1835
  }
1833
1836
  if (input.antiPatterns.total > 0) {
1834
- help.push(
1835
- `${input.antiPatterns.total} authored site(s) hand-roll what MetaObjects can model — see antiPatterns.rows[] and run \`meta types <construct>\``,
1836
- );
1837
+ help.push(...advisoryHelpLines(input.antiPatterns.rows));
1837
1838
  }
1838
1839
  if (input.overlays.total > 0) {
1839
1840
  help.push(