@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.
- package/README.md +15 -0
- package/dist/src/commands/eject.d.ts +17 -0
- package/dist/src/commands/eject.d.ts.map +1 -1
- package/dist/src/commands/eject.js +87 -2
- package/dist/src/commands/eject.js.map +1 -1
- package/dist/src/commands/generator-help.d.ts +21 -0
- package/dist/src/commands/generator-help.d.ts.map +1 -0
- package/dist/src/commands/generator-help.js +43 -0
- package/dist/src/commands/generator-help.js.map +1 -0
- package/dist/src/commands/generator.d.ts +36 -0
- package/dist/src/commands/generator.d.ts.map +1 -0
- package/dist/src/commands/generator.js +333 -0
- package/dist/src/commands/generator.js.map +1 -0
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/init.js +10 -1
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/verify.d.ts.map +1 -1
- package/dist/src/commands/verify.js +5 -3
- package/dist/src/commands/verify.js.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/advisory.d.ts +14 -0
- package/dist/src/lib/advisory.d.ts.map +1 -1
- package/dist/src/lib/advisory.js +64 -5
- package/dist/src/lib/advisory.js.map +1 -1
- package/dist/src/lib/catalog-listing.d.ts +8 -0
- package/dist/src/lib/catalog-listing.d.ts.map +1 -1
- package/dist/src/lib/catalog-listing.js +35 -1
- package/dist/src/lib/catalog-listing.js.map +1 -1
- package/dist/src/lib/install-set.d.ts +12 -2
- package/dist/src/lib/install-set.d.ts.map +1 -1
- package/dist/src/lib/install-set.js +14 -2
- package/dist/src/lib/install-set.js.map +1 -1
- package/dist/src/lib/runtime-eject.d.ts +80 -0
- package/dist/src/lib/runtime-eject.d.ts.map +1 -0
- package/dist/src/lib/runtime-eject.js +212 -0
- package/dist/src/lib/runtime-eject.js.map +1 -0
- package/package.json +10 -10
- package/src/commands/eject.ts +103 -3
- package/src/commands/generator-help.ts +47 -0
- package/src/commands/generator.ts +390 -0
- package/src/commands/init.ts +10 -1
- package/src/commands/verify.ts +5 -4
- package/src/index.ts +8 -0
- package/src/lib/advisory.ts +71 -5
- package/src/lib/catalog-listing.ts +48 -1
- package/src/lib/install-set.ts +19 -2
- package/src/lib/runtime-eject.ts +263 -0
package/src/commands/eject.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
`;
|
package/src/commands/verify.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|