@metaobjectsdev/cli 1.0.9-rc.4 → 1.0.9-rc.6
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.d.ts.map +1 -1
- package/dist/src/commands/gen.js +18 -5
- 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.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 +14 -4
- package/dist/src/lib/catalog-listing.d.ts.map +1 -1
- package/dist/src/lib/catalog-listing.js +56 -9
- 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/output.d.ts +1 -0
- package/dist/src/lib/output.d.ts.map +1 -1
- package/dist/src/lib/output.js +2 -0
- package/dist/src/lib/output.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/dist/src/lib/unknown-attr-warnings.d.ts +18 -0
- package/dist/src/lib/unknown-attr-warnings.d.ts.map +1 -0
- package/dist/src/lib/unknown-attr-warnings.js +43 -0
- package/dist/src/lib/unknown-attr-warnings.js.map +1 -0
- package/package.json +10 -10
- package/src/commands/eject.ts +103 -3
- package/src/commands/gen.ts +18 -5
- 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 +68 -7
- package/src/lib/install-set.ts +19 -2
- package/src/lib/output.ts +3 -0
- package/src/lib/runtime-eject.ts +263 -0
- package/src/lib/unknown-attr-warnings.ts +48 -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";
|
|
@@ -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(
|
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:
|
|
@@ -613,6 +617,10 @@ export async function run(argv: string[]): Promise<number> {
|
|
|
613
617
|
const { genCommand } = await import("./commands/gen.js");
|
|
614
618
|
return genCommand(rest, cwd, fmt);
|
|
615
619
|
}
|
|
620
|
+
case "generator": {
|
|
621
|
+
const { generatorCommand } = await import("./commands/generator.js");
|
|
622
|
+
return generatorCommand(rest, cwd);
|
|
623
|
+
}
|
|
616
624
|
case "eject": {
|
|
617
625
|
const { ejectCommand } = await import("./commands/eject.js");
|
|
618
626
|
return ejectCommand(rest, cwd, fmt);
|
package/src/lib/advisory.ts
CHANGED
|
@@ -108,6 +108,72 @@ export function ranSection<Row>(rows: Row[]): AdvisorySection<Row> {
|
|
|
108
108
|
return { status: "ran", total: rows.length, rows };
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
// The `rule` of each advisory row that is NOT an anti-pattern-scanner finding. A row whose
|
|
112
|
+
// rule is none of these came from the scanner: an authored site hand-rolling a construct.
|
|
113
|
+
export const RULE_MISSING_BASE_URL = "missing-base-url";
|
|
114
|
+
export const RULE_REMOVED_PROP_PREFIX = "removed-prop:";
|
|
115
|
+
export const RULE_LIBRARY_PREFIX = "library-prefix-unprovenanced";
|
|
116
|
+
export const RULE_UNINDEXED_FK = "unindexed-foreign-key";
|
|
117
|
+
export const RULE_REFERENTIAL_ACTION_CONFLICT = "overridden-referential-action";
|
|
118
|
+
|
|
119
|
+
/** The next-step line for `n` rows of one advisory kind, each naming what the rows are. */
|
|
120
|
+
const ADVISORY_HELP: ReadonlyArray<{ matches: (rule: string) => boolean; line: (n: number) => string }> = [
|
|
121
|
+
{
|
|
122
|
+
matches: (r) => r === RULE_UNINDEXED_FK,
|
|
123
|
+
line: (n) =>
|
|
124
|
+
`${n} foreign key(s) with no covering index — see antiPatterns.rows[] (rule "${RULE_UNINDEXED_FK}"); each names the index.lookup to declare`,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
matches: (r) => r === RULE_REFERENTIAL_ACTION_CONFLICT,
|
|
128
|
+
line: (n) =>
|
|
129
|
+
`${n} foreign key(s) whose two sides disagree on the referential action — see antiPatterns.rows[] (rule "${RULE_REFERENTIAL_ACTION_CONFLICT}")`,
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
matches: (r) => r === RULE_MISSING_BASE_URL,
|
|
133
|
+
line: (n) =>
|
|
134
|
+
`${n} entity-fetcher provider(s) mounted with no baseUrl while apiPrefix is set — see antiPatterns.rows[] (rule "${RULE_MISSING_BASE_URL}")`,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
matches: (r) => r.startsWith(RULE_REMOVED_PROP_PREFIX),
|
|
138
|
+
line: (n) =>
|
|
139
|
+
`${n} provider(s) still mounted with a prop 1.0 renamed away — see antiPatterns.rows[] (rule "${RULE_REMOVED_PROP_PREFIX}<prop>")`,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
matches: (r) => r === RULE_LIBRARY_PREFIX,
|
|
143
|
+
line: (n) =>
|
|
144
|
+
`${n} node(s) declared under "metaobjects::" with no ejection provenance — see antiPatterns.rows[] (rule "${RULE_LIBRARY_PREFIX}")`,
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* One next-step line per KIND of advisory row, in a structured payload's `help`.
|
|
150
|
+
*
|
|
151
|
+
* The advisory section carries several kinds of row keyed by `rule`, and the help line used
|
|
152
|
+
* to count them all as "authored site(s) hand-roll what MetaObjects can model" — so a project
|
|
153
|
+
* whose only advisories were unindexed foreign keys was told it hand-rolled three constructs.
|
|
154
|
+
* Scanner findings keep that line; every other kind is counted under its own label.
|
|
155
|
+
*/
|
|
156
|
+
export function advisoryHelpLines(rows: readonly AdvisoryFindingRow[]): string[] {
|
|
157
|
+
const counts = ADVISORY_HELP.map(() => 0);
|
|
158
|
+
let handRolled = 0;
|
|
159
|
+
for (const row of rows) {
|
|
160
|
+
const kind = ADVISORY_HELP.findIndex((k) => k.matches(row.rule));
|
|
161
|
+
if (kind === -1) handRolled++;
|
|
162
|
+
else counts[kind] = (counts[kind] ?? 0) + 1;
|
|
163
|
+
}
|
|
164
|
+
const lines: string[] = [];
|
|
165
|
+
if (handRolled > 0) {
|
|
166
|
+
lines.push(
|
|
167
|
+
`${handRolled} authored site(s) hand-roll what MetaObjects can model — see antiPatterns.rows[] and run \`meta types <construct>\``,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
ADVISORY_HELP.forEach((k, i) => {
|
|
171
|
+
const n = counts[i] ?? 0;
|
|
172
|
+
if (n > 0) lines.push(k.line(n));
|
|
173
|
+
});
|
|
174
|
+
return lines;
|
|
175
|
+
}
|
|
176
|
+
|
|
111
177
|
/** Project the scanner's findings into payload rows. `snippet` is deliberately
|
|
112
178
|
* dropped: it is a copy of the reader's own source line, and `file`+`line`
|
|
113
179
|
* already address it. */
|
|
@@ -129,7 +195,7 @@ export function missingBaseUrlRows(findings: readonly BaseUrlFinding[]): Advisor
|
|
|
129
195
|
return findings.map((f) => ({
|
|
130
196
|
file: f.file,
|
|
131
197
|
line: f.line,
|
|
132
|
-
rule:
|
|
198
|
+
rule: RULE_MISSING_BASE_URL,
|
|
133
199
|
construct: f.construct,
|
|
134
200
|
message: f.message,
|
|
135
201
|
}));
|
|
@@ -144,7 +210,7 @@ export function removedPropRows(findings: readonly RemovedPropFinding[]): Adviso
|
|
|
144
210
|
return findings.map((f) => ({
|
|
145
211
|
file: f.file,
|
|
146
212
|
line: f.line,
|
|
147
|
-
rule:
|
|
213
|
+
rule: `${RULE_REMOVED_PROP_PREFIX}${f.prop}`,
|
|
148
214
|
construct: f.construct,
|
|
149
215
|
message: f.message,
|
|
150
216
|
}));
|
|
@@ -157,7 +223,7 @@ export function libraryPrefixRows(
|
|
|
157
223
|
return findings.map((f) => ({
|
|
158
224
|
file: f.file,
|
|
159
225
|
line: 0,
|
|
160
|
-
rule:
|
|
226
|
+
rule: RULE_LIBRARY_PREFIX,
|
|
161
227
|
construct: f.fqn,
|
|
162
228
|
message: f.message,
|
|
163
229
|
}));
|
|
@@ -174,7 +240,7 @@ export function unindexedFkRows(
|
|
|
174
240
|
return findings.map((f) => ({
|
|
175
241
|
file: f.file,
|
|
176
242
|
line: 0,
|
|
177
|
-
rule:
|
|
243
|
+
rule: RULE_UNINDEXED_FK,
|
|
178
244
|
construct: f.construct,
|
|
179
245
|
message: f.message,
|
|
180
246
|
}));
|
|
@@ -190,7 +256,7 @@ export function referentialActionConflictRows(
|
|
|
190
256
|
return findings.map((f) => ({
|
|
191
257
|
file: f.file,
|
|
192
258
|
line: 0,
|
|
193
|
-
rule:
|
|
259
|
+
rule: RULE_REFERENTIAL_ACTION_CONFLICT,
|
|
194
260
|
construct: f.construct,
|
|
195
261
|
message: f.message,
|
|
196
262
|
}));
|