@metaobjectsdev/cli 1.0.9-rc.3 → 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/gen.js +3 -1
- package/dist/src/commands/gen.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 +13 -1
- package/dist/src/commands/verify.d.ts.map +1 -1
- package/dist/src/commands/verify.js +69 -14
- package/dist/src/commands/verify.js.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +12 -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/args.d.ts +12 -0
- package/dist/src/lib/args.d.ts.map +1 -1
- package/dist/src/lib/args.js +6 -0
- package/dist/src/lib/args.js.map +1 -1
- package/dist/src/lib/catalog-listing.d.ts +15 -0
- package/dist/src/lib/catalog-listing.d.ts.map +1 -1
- package/dist/src/lib/catalog-listing.js +38 -3
- package/dist/src/lib/catalog-listing.js.map +1 -1
- package/dist/src/lib/codegen-drift.d.ts +10 -0
- package/dist/src/lib/codegen-drift.d.ts.map +1 -1
- package/dist/src/lib/codegen-drift.js +8 -0
- package/dist/src/lib/codegen-drift.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/gen.ts +3 -1
- 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 +71 -14
- package/src/index.ts +12 -0
- package/src/lib/advisory.ts +71 -5
- package/src/lib/args.ts +16 -0
- package/src/lib/catalog-listing.ts +68 -3
- package/src/lib/codegen-drift.ts +17 -0
- package/src/lib/install-set.ts +19 -2
- package/src/lib/runtime-eject.ts +263 -0
|
@@ -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";
|
|
@@ -416,12 +417,12 @@ export async function verifyCommand(
|
|
|
416
417
|
|
|
417
418
|
// Naming a gate runs ONLY the gates named (ADR-0021 D2), so `verify --codegen --db <url>`
|
|
418
419
|
// exits 0 without ever looking at the prompt templates. A green partial run must not read
|
|
419
|
-
// as a full one: say which gates did not run and how to select each.
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
420
|
+
// as a full one: say which gates did not run and how to select each. The BARE run says so
|
|
421
|
+
// too, at the END where the verdict is read: its opening note scrolls past, and on a model
|
|
422
|
+
// with no templates the only other line was "nothing to check" over a green exit — easy to
|
|
423
|
+
// wire into CI believing it guards everything. Never changes the exit code.
|
|
424
|
+
const note = notRunNote(gates, { bare: !flags.anyExplicit });
|
|
425
|
+
if (note !== undefined) say(note);
|
|
425
426
|
|
|
426
427
|
if (structured) {
|
|
427
428
|
emitStructured(
|
|
@@ -937,7 +938,9 @@ export async function verifyCommand(
|
|
|
937
938
|
// ADR-0039: effective children — resolve rather than rely on root being unextended.
|
|
938
939
|
const templates = root.children().filter((c) => c.type === TYPE_TEMPLATE);
|
|
939
940
|
if (templates.length === 0) {
|
|
940
|
-
|
|
941
|
+
// Names the GATE that had nothing to check — "nothing to check" alone read as a
|
|
942
|
+
// verdict on the whole project.
|
|
943
|
+
say("meta verify — no template.* nodes found; the template gate had nothing to check.");
|
|
941
944
|
return 0;
|
|
942
945
|
}
|
|
943
946
|
|
|
@@ -1450,13 +1453,19 @@ export async function verifyCommand(
|
|
|
1450
1453
|
return 2;
|
|
1451
1454
|
}
|
|
1452
1455
|
|
|
1456
|
+
// Hand edits are not drift, but they must never be INVISIBLE: a validator weakened
|
|
1457
|
+
// inside a generated file used to pass this gate with "every file's generated
|
|
1458
|
+
// contribution is current" and nothing else. Reported on every run; fails the build
|
|
1459
|
+
// only under --forbid-hand-edits.
|
|
1460
|
+
const handEditExit = reportHandEdits(result.handEdited);
|
|
1461
|
+
|
|
1453
1462
|
if (result.clean) {
|
|
1454
1463
|
// Says what it CHECKED, not more. Since 0.24.3 this gate asks whether each file's
|
|
1455
1464
|
// GENERATED contribution is current — a hand edit `meta gen` preserves is exempt —
|
|
1456
1465
|
// so "output is in sync with the metadata" claimed a stronger property than it had
|
|
1457
1466
|
// verified, and did so over a file whose committed content contradicted the model.
|
|
1458
1467
|
say("meta verify — every file's generated contribution is current (no codegen drift).");
|
|
1459
|
-
return
|
|
1468
|
+
return handEditExit;
|
|
1460
1469
|
}
|
|
1461
1470
|
|
|
1462
1471
|
log.error(
|
|
@@ -1481,6 +1490,23 @@ export async function verifyCommand(
|
|
|
1481
1490
|
return 1;
|
|
1482
1491
|
}
|
|
1483
1492
|
|
|
1493
|
+
/** Print the hand-edit notice (or failure, under --forbid-hand-edits); return its exit code. */
|
|
1494
|
+
function reportHandEdits(handEdited: readonly string[]): number {
|
|
1495
|
+
if (handEdited.length === 0) return 0;
|
|
1496
|
+
const [header, ...rest] = handEditNotice(handEdited, flags.forbidHandEdits);
|
|
1497
|
+
const paths = rest.slice(0, handEdited.length);
|
|
1498
|
+
const footer = rest.slice(handEdited.length);
|
|
1499
|
+
if (flags.forbidHandEdits) {
|
|
1500
|
+
// A failure lists every path: truncating the list a build failed on hides the cause.
|
|
1501
|
+
for (const line of [header!, ...paths, ...footer]) log.error(line);
|
|
1502
|
+
return 1;
|
|
1503
|
+
}
|
|
1504
|
+
log.warn(header!);
|
|
1505
|
+
warnCapped(paths, flags.limit, { structured });
|
|
1506
|
+
for (const line of footer) log.warn(line);
|
|
1507
|
+
return 0;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1484
1510
|
// -- docs drift -------------------------------------------------------------
|
|
1485
1511
|
// Gated on --docs. Runs `meta docs` into a temp dir and diffs the committed docs
|
|
1486
1512
|
// tree. See lib/docs-drift.ts for the deliberate differences from --codegen: a byte
|
|
@@ -1683,6 +1709,30 @@ interface VerifyGateRow {
|
|
|
1683
1709
|
ok: boolean;
|
|
1684
1710
|
}
|
|
1685
1711
|
|
|
1712
|
+
/**
|
|
1713
|
+
* The hand-edit report for `verify --codegen`: a header with the count, one indented line
|
|
1714
|
+
* per path, then how to see an edit. Exported for its test.
|
|
1715
|
+
*
|
|
1716
|
+
* The recipe regenerates rather than diffing against `.gen-state`, because the snapshot
|
|
1717
|
+
* BODIES are gitignored — on a fresh clone or a CI runner only the hashes exist, which
|
|
1718
|
+
* prove a file was edited but cannot show the edit. Deleting a generated file and
|
|
1719
|
+
* re-running `meta gen` re-emits its pristine generation; git then shows the difference.
|
|
1720
|
+
*/
|
|
1721
|
+
export function handEditNotice(paths: readonly string[], forbidden: boolean): string[] {
|
|
1722
|
+
const header = forbidden
|
|
1723
|
+
? `meta verify — ${paths.length} generated file(s) carry hand edits, and --forbid-hand-edits is set:`
|
|
1724
|
+
: `meta verify — ${paths.length} generated file(s) carry hand edits ` +
|
|
1725
|
+
`(notice — 'meta gen' preserves them by design; does not fail the build — ` +
|
|
1726
|
+
`pass --forbid-hand-edits to make it):`;
|
|
1727
|
+
return [
|
|
1728
|
+
header,
|
|
1729
|
+
...paths.map((p) => ` ${p}`),
|
|
1730
|
+
"To see an edit: delete the file and run 'meta gen' (it re-emits the pristine generation), " +
|
|
1731
|
+
"then 'git diff -R -- <file>' shows the edit and 'git checkout -- <file>' restores it" +
|
|
1732
|
+
(forbidden ? "; to drop the edit, commit the regenerated file instead." : "."),
|
|
1733
|
+
];
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1686
1736
|
/** The flag that selects each gate, for the "not run" note. `requirements` always runs. */
|
|
1687
1737
|
const GATE_SELECTOR: Record<string, string> = {
|
|
1688
1738
|
templates: "--templates",
|
|
@@ -1697,14 +1747,21 @@ const GATE_SELECTOR: Record<string, string> = {
|
|
|
1697
1747
|
* One line naming every gate this run did NOT run, each with the flag that selects it —
|
|
1698
1748
|
* undefined when every gate ran. Exported for its test.
|
|
1699
1749
|
*/
|
|
1700
|
-
export function notRunNote(
|
|
1750
|
+
export function notRunNote(
|
|
1751
|
+
gates: readonly VerifyGateRow[],
|
|
1752
|
+
opts: { bare?: boolean } = {},
|
|
1753
|
+
): string | undefined {
|
|
1701
1754
|
const skipped = gates.filter((g) => !g.ran);
|
|
1702
1755
|
if (skipped.length === 0) return undefined;
|
|
1703
1756
|
const named = skipped.map((g) => {
|
|
1704
1757
|
const flag = GATE_SELECTOR[g.gate];
|
|
1705
1758
|
return flag !== undefined ? `${g.gate} (${flag})` : g.gate;
|
|
1706
1759
|
});
|
|
1707
|
-
|
|
1760
|
+
const tail = opts.bare === true
|
|
1761
|
+
? " — a bare 'meta verify' runs only the template gate (plus the requirement ledger); " +
|
|
1762
|
+
"name each gate to run it"
|
|
1763
|
+
: "";
|
|
1764
|
+
return `meta verify — not run: ${named.join(", ")}${tail}`;
|
|
1708
1765
|
}
|
|
1709
1766
|
|
|
1710
1767
|
/**
|
|
@@ -1759,7 +1816,9 @@ function buildVerifyPayload(input: {
|
|
|
1759
1816
|
: `${failed.length} of ${ran.length} gate(s) failed (${failed.map((g) => g.gate).join(", ")})`,
|
|
1760
1817
|
];
|
|
1761
1818
|
if (input.antiPatterns.status === "ran" && input.antiPatterns.total > 0) {
|
|
1762
|
-
|
|
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)`);
|
|
1763
1822
|
}
|
|
1764
1823
|
if (input.requirements.total > 0) {
|
|
1765
1824
|
parts.push(`${input.requirements.total} requirement diagnostic(s)`);
|
|
@@ -1775,9 +1834,7 @@ function buildVerifyPayload(input: {
|
|
|
1775
1834
|
);
|
|
1776
1835
|
}
|
|
1777
1836
|
if (input.antiPatterns.total > 0) {
|
|
1778
|
-
help.push(
|
|
1779
|
-
`${input.antiPatterns.total} authored site(s) hand-roll what MetaObjects can model — see antiPatterns.rows[] and run \`meta types <construct>\``,
|
|
1780
|
-
);
|
|
1837
|
+
help.push(...advisoryHelpLines(input.antiPatterns.rows));
|
|
1781
1838
|
}
|
|
1782
1839
|
if (input.overlays.total > 0) {
|
|
1783
1840
|
help.push(
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { log } from "./lib/log.js";
|
|
|
3
3
|
import { cliVersion } from "./lib/version.js";
|
|
4
4
|
import { resolveFormat, isValidFormat, VALID_FORMATS, type OutputFormat } from "./lib/format.js";
|
|
5
5
|
import { resolveCollection } from "@metaobjectsdev/sdk";
|
|
6
|
+
import { GENERATOR_HELP } from "./commands/generator-help.js";
|
|
6
7
|
export { defineConfig } from "@metaobjectsdev/codegen-ts";
|
|
7
8
|
export type { MetaobjectsGenConfig } from "@metaobjectsdev/codegen-ts";
|
|
8
9
|
|
|
@@ -30,6 +31,8 @@ COMMANDS:
|
|
|
30
31
|
init --config-only Write only .metaobjects/config.json — for a Maven- or pip-rooted project
|
|
31
32
|
agent-docs Scaffold only the agent-context (.metaobjects/ + .claude/skills/) — canonical redirect target for all language ports
|
|
32
33
|
gen [<entity>...] Codegen TS targets from your declared metadata
|
|
34
|
+
generator new <name> Write a generator of your own into codegen/generators/ and wire it — the
|
|
35
|
+
way to get any output MetaObjects does not ship (OpenAPI, JSON Schema, …)
|
|
33
36
|
eject <generator> Copy a reference generator into codegen/generators/ to own it (any time after init)
|
|
34
37
|
eject --list List every ejectable generator name, grouped by package
|
|
35
38
|
deps sync [<name>...] Resolve declared dependencies (path transport): sync the committed
|
|
@@ -158,6 +161,7 @@ See https://metaobjects.com for docs.
|
|
|
158
161
|
|
|
159
162
|
/** Focused per-subcommand usage slices shown by `<cmd> --help`. */
|
|
160
163
|
export const COMMAND_HELP: Record<string, string> = {
|
|
164
|
+
generator: GENERATOR_HELP,
|
|
161
165
|
gen: `meta gen — codegen TS targets from your declared metadata
|
|
162
166
|
|
|
163
167
|
USAGE:
|
|
@@ -270,6 +274,10 @@ FLAGS:
|
|
|
270
274
|
An <Entity>.names.ts artifact is the exception: it must match a
|
|
271
275
|
fresh regen exactly — a hand edit in it is itself the defect.
|
|
272
276
|
Needs metaobjects.config.ts; exit 2 if absent.
|
|
277
|
+
Every generated file that carries a hand edit is still LISTED
|
|
278
|
+
(a notice, exit code unchanged), so an edit is never invisible.
|
|
279
|
+
--forbid-hand-edits With --codegen: fail (exit 1) on ANY hand edit inside generated
|
|
280
|
+
code, for teams that want generated files untouchable.
|
|
273
281
|
--docs Docs drift — run 'meta docs' to a temp dir and diff docs.outDir
|
|
274
282
|
Needs metaobjects.config.ts; exit 2 if absent. A page the
|
|
275
283
|
project GIT-IGNORES is exempt (docs.outDir is a directory, not
|
|
@@ -609,6 +617,10 @@ export async function run(argv: string[]): Promise<number> {
|
|
|
609
617
|
const { genCommand } = await import("./commands/gen.js");
|
|
610
618
|
return genCommand(rest, cwd, fmt);
|
|
611
619
|
}
|
|
620
|
+
case "generator": {
|
|
621
|
+
const { generatorCommand } = await import("./commands/generator.js");
|
|
622
|
+
return generatorCommand(rest, cwd);
|
|
623
|
+
}
|
|
612
624
|
case "eject": {
|
|
613
625
|
const { ejectCommand } = await import("./commands/eject.js");
|
|
614
626
|
return ejectCommand(rest, cwd, fmt);
|