@metaobjectsdev/cli 0.24.3 → 0.24.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.
@@ -0,0 +1,52 @@
1
+ // One answer to "what does this project declare as a dependency".
2
+ //
3
+ // Three commands ask it — `meta init` (is this package already declared, or must the
4
+ // scaffold add it?), `meta eject` (will the ejected file's imports resolve, or does the
5
+ // adopter's tsc report TS2307?), and stack detection (which frameworks is this project
6
+ // on?). They had three hand-rolled JSON-parse-and-union blocks covering DIFFERENT field
7
+ // sets, and the asymmetry was the cost: `eject` reported a peer-declared package as
8
+ // missing and told the adopter to install what they already had — advice that, followed,
9
+ // adds a second physical copy of a package whose class identity is load-bearing (the
10
+ // ts-poet split in 0.21.6 and the metadata node-guard split are both that bug).
11
+ //
12
+ // A fix to one had no way to find the other two: nothing referenced anything, and no
13
+ // shared name connected them to a grep.
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ /**
17
+ * Every package name the manifest declares, across ALL FOUR dependency fields.
18
+ *
19
+ * All four, because the question every caller is really asking is "will this resolve,
20
+ * and will their typecheck be happy" — not "is it in one particular field". A library
21
+ * consuming MetaObjects through `peerDependencies` (the correct declaration for a
22
+ * package whose consumer supplies the version) has it declared, and so does one using
23
+ * `optionalDependencies`.
24
+ */
25
+ export function declaredDependencyNames(pkg) {
26
+ const out = new Set();
27
+ for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
28
+ for (const name of Object.keys(pkg[field] ?? {}))
29
+ out.add(name);
30
+ }
31
+ return out;
32
+ }
33
+ /**
34
+ * Read and parse `<cwd>/package.json`.
35
+ *
36
+ * `undefined` distinguishes "no readable manifest" from "a manifest declaring nothing",
37
+ * which callers report differently: with no manifest there is nothing to compare
38
+ * against, so the honest message names what a file needs rather than claiming it is
39
+ * missing from a list that was never read.
40
+ */
41
+ export function readPackageManifest(cwd) {
42
+ const path = join(cwd, "package.json");
43
+ if (!existsSync(path))
44
+ return undefined;
45
+ try {
46
+ return JSON.parse(readFileSync(path, "utf8"));
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ //# sourceMappingURL=package-manifest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"package-manifest.js","sourceRoot":"","sources":["../../../src/lib/package-manifest.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,qFAAqF;AACrF,wFAAwF;AACxF,uFAAuF;AACvF,wFAAwF;AACxF,oFAAoF;AACpF,yFAAyF;AACzF,qFAAqF;AACrF,gFAAgF;AAChF,EAAE;AACF,qFAAqF;AACrF,wCAAwC;AACxC,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAUjC;;;;;;;;GAQG;AACH,MAAM,UAAU,uBAAuB,CAAC,GAAoB;IAC1D,MAAM,GAAG,GAAG,IAAI,GAAG,EAAU,CAAC;IAC9B,KAAK,MAAM,KAAK,IAAI,CAAC,cAAc,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,sBAAsB,CAAU,EAAE,CAAC;QAC7G,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;YAAE,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAClE,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,mBAAmB,CAAC,GAAW;IAC7C,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,EAAE,cAAc,CAAC,CAAC;IACvC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,SAAS,CAAC;IACxC,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAoB,CAAC;IACnE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metaobjectsdev/cli",
3
- "version": "0.24.3",
3
+ "version": "0.24.5",
4
4
  "description": "CLI for MetaObjects: scaffold, codegen, migrate, and drift-detection commands.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -49,15 +49,15 @@
49
49
  ],
50
50
  "dependencies": {
51
51
  "@libsql/kysely-libsql": "^0.4.0",
52
- "@metaobjectsdev/codegen-ts": "0.24.3",
53
- "@metaobjectsdev/codegen-ts-react": "0.24.3",
54
- "@metaobjectsdev/codegen-ts-tanstack": "0.24.3",
55
- "@metaobjectsdev/docs-site": "0.24.3",
56
- "@metaobjectsdev/metadata": "0.24.3",
57
- "@metaobjectsdev/migrate-ts": "0.24.3",
58
- "@metaobjectsdev/render": "0.24.3",
59
- "@metaobjectsdev/runtime-ts": "0.24.3",
60
- "@metaobjectsdev/sdk": "0.24.3",
52
+ "@metaobjectsdev/codegen-ts": "0.24.5",
53
+ "@metaobjectsdev/codegen-ts-react": "0.24.5",
54
+ "@metaobjectsdev/codegen-ts-tanstack": "0.24.5",
55
+ "@metaobjectsdev/docs-site": "0.24.5",
56
+ "@metaobjectsdev/metadata": "0.24.5",
57
+ "@metaobjectsdev/migrate-ts": "0.24.5",
58
+ "@metaobjectsdev/render": "0.24.5",
59
+ "@metaobjectsdev/runtime-ts": "0.24.5",
60
+ "@metaobjectsdev/sdk": "0.24.5",
61
61
  "@toon-format/toon": "^2.3.0",
62
62
  "jiti": "^2.4.0"
63
63
  },
@@ -76,7 +76,7 @@
76
76
  "@types/pg": "^8.0.0",
77
77
  "bun-types": "latest",
78
78
  "drizzle-orm": "^0.45.1",
79
- "fastify": "^4.28.1",
79
+ "fastify": "^5.6.2",
80
80
  "kysely": "^0.27.0",
81
81
  "pg": "^8.0.0",
82
82
  "pg-mem": "^3.0.4",
@@ -0,0 +1,282 @@
1
+ // FR-040 §4.2(a) — `meta eject <generator>` takes ownership of any reference-template
2
+ // generator, in any package, at any time after `meta init`. ADR-0034 scaffold-and-own
3
+ // has `init` copy four of them eagerly (entity, queries, routes, barrel); this is the
4
+ // SAME copy operation, generalised to every ejectable name and callable on demand — for
5
+ // a generator you skipped at init time, or one a package gained since.
6
+ import { mkdir, writeFile, stat, readFile } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { cliVersion } from "../lib/version.js";
9
+ import * as coreTpl from "@metaobjectsdev/codegen-ts";
10
+ import * as reactTpl from "@metaobjectsdev/codegen-ts-react";
11
+ import * as tanstackTpl from "@metaobjectsdev/codegen-ts-tanstack";
12
+ import { parseEjectArgs } from "../lib/args.js";
13
+ import { log } from "../lib/log.js";
14
+ import { declaredDependencyNames, readPackageManifest } from "../lib/package-manifest.js";
15
+
16
+ // Mirrors `OWNED_GENERATORS_DIR` in init.ts's `writeOwnedGenerators` — same directory,
17
+ // same never-clobber-without-consent contract. Kept as its own local constant rather
18
+ // than shared: eject is a standalone operation on ANY name, not a byproduct of init,
19
+ // and the two call sites have no other state in common worth coupling over one string.
20
+ const OWNED_GENERATORS_DIR = "codegen/generators";
21
+
22
+ interface TemplateSource {
23
+ packageName: string;
24
+ names: readonly string[];
25
+ /** That package's own `src/reference/` directory. */
26
+ root: () => string;
27
+ }
28
+
29
+ // One registry, three packages — a package that gains templates later registers itself
30
+ // here and `meta eject` picks it up with no other change.
31
+ //
32
+ // Each entry exposes its reference ROOT rather than a read function. The packages' own
33
+ // `readReferenceTemplate` narrows its parameter to a literal union, so calling it with
34
+ // a CLI-supplied `string` used to need a generic `asserts name is N` helper — ~20 lines
35
+ // to re-establish, for the compiler, a fact `resolveSource` has ALREADY established at
36
+ // runtime by selecting this entry via `names.includes(name)`. Reading from the root
37
+ // deletes that machinery without weakening anything: membership is still checked, once,
38
+ // where the untrusted value enters.
39
+ const SOURCES: TemplateSource[] = [
40
+ {
41
+ packageName: "@metaobjectsdev/codegen-ts",
42
+ names: coreTpl.REFERENCE_GENERATOR_NAMES,
43
+ root: coreTpl.resolveReferenceRoot,
44
+ },
45
+ {
46
+ packageName: "@metaobjectsdev/codegen-ts-react",
47
+ names: reactTpl.REFERENCE_GENERATOR_NAMES,
48
+ root: reactTpl.resolveReferenceRoot,
49
+ },
50
+ {
51
+ packageName: "@metaobjectsdev/codegen-ts-tanstack",
52
+ names: tanstackTpl.REFERENCE_GENERATOR_NAMES,
53
+ root: tanstackTpl.resolveReferenceRoot,
54
+ },
55
+ ];
56
+
57
+ function resolveSource(name: string): TemplateSource | undefined {
58
+ return SOURCES.find((s) => s.names.includes(name));
59
+ }
60
+
61
+ /** Every ejectable name, in registry order (stable — matches `meta eject --list`). */
62
+ export function ejectableNames(): string[] {
63
+ return SOURCES.flatMap((s) => s.names);
64
+ }
65
+
66
+ // Every reference template's header documents its own paste-ready import line, e.g.
67
+ // codegen-ts/src/reference/entity.ts:
68
+ // // Then import it LOCALLY in metaobjects.config.ts:
69
+ // // import { entityFile } from "./codegen/generators/entity.js";
70
+ // Extracting it here — rather than re-deriving an export symbol from the file name —
71
+ // means eject can never drift from what the template itself already tells a human to
72
+ // paste, and needs no per-name export-symbol map: a generator's exported symbol does
73
+ // NOT follow its file name (`hooks.ts` exports `tanstackQuery`, `routes-hono.ts`
74
+ // exports `routesFileHono`, `grid.ts` exports `tanstackGrid`).
75
+ const HEADER_IMPORT_RE = /^\/\/\s+(import \{ \w+ \} from "\.\/codegen\/generators\/[\w.-]+\.js";)\s*$/m;
76
+
77
+ /** The bound symbol out of the already-validated import line — same single source of
78
+ * truth as the line itself, so the "replace this binding" message can never name a
79
+ * symbol the template does not actually export. */
80
+ function extractExportName(importLine: string, name: string): string {
81
+ const match = /^import \{ (\w+) \}/.exec(importLine);
82
+ if (!match?.[1]) {
83
+ throw new Error(`reference template "${name}" has an unparseable import line: ${importLine}`);
84
+ }
85
+ return match[1];
86
+ }
87
+
88
+ function extractImportLine(templateSource: string, name: string): string {
89
+ const match = HEADER_IMPORT_RE.exec(templateSource);
90
+ if (!match?.[1]) {
91
+ throw new Error(
92
+ `reference template "${name}" has no documented "// import { ... }" header line — ` +
93
+ "cannot report the import line to paste.",
94
+ );
95
+ }
96
+ return match[1];
97
+ }
98
+
99
+ async function fileExists(p: string): Promise<boolean> {
100
+ try {
101
+ return (await stat(p)).isFile();
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ export interface EjectOptions {
108
+ cwd: string;
109
+ name: string;
110
+ /** Overwrite an already-ejected file. Without it, eject NEVER clobbers — matching
111
+ * `writeOwnedGenerators`'s unconditional preserve-if-present rule in init.ts. */
112
+ force?: boolean;
113
+ }
114
+
115
+ export interface EjectResult {
116
+ path: string;
117
+ importLine: string;
118
+ /** The symbol the template exports — the binding to REPLACE in the config. */
119
+ exportName: string;
120
+ /** The package the generator currently comes from, i.e. the import to remove. */
121
+ packageName: string;
122
+ /** Advisory lines about packages the ejected file imports but the project lacks. */
123
+ dependencyNotes: string[];
124
+ status: "created" | "preserved";
125
+ }
126
+
127
+ /** The `@metaobjectsdev/*` packages an ejected template imports, read from the file
128
+ * itself rather than from a per-name table — the template is the only thing that
129
+ * knows, and a table would drift from it the moment a template gains an import. */
130
+ function requiredPackages(templateSource: string): string[] {
131
+ const found = new Set<string>();
132
+ // The optional trailing group matches a SUBPATH and is deliberately not captured: what
133
+ // has to be installed is the package, and `@metaobjectsdev/metadata/constants` is a
134
+ // real, documented subpath this codebase already uses (the browser-safe pure-constants
135
+ // entry). Requiring the closing quote straight after the package name — as this did —
136
+ // made any such import match nothing at all, so a template gaining one would get no
137
+ // note. That is the drift this function exists to prevent, reappearing inside the
138
+ // function itself.
139
+ for (const m of templateSource.matchAll(/from\s+"(@metaobjectsdev\/[\w-]+)(?:\/[\w./-]+)?"/g)) {
140
+ if (m[1] !== undefined) found.add(m[1]);
141
+ }
142
+ return [...found].sort();
143
+ }
144
+
145
+ /**
146
+ * An ejected file is ordinary source in the adopter's repo, so its imports must be
147
+ * declared dependencies or their `tsc` reports TS2307 on the file we just told them
148
+ * they own — and under a strict (pnpm/npm) node_modules layout `meta gen` cannot
149
+ * resolve it either. `meta init` already calls this out by ADDING the two packages its
150
+ * four scaffolded generators need; the on-demand templates import two more
151
+ * (codegen-ts-react, codegen-ts-tanstack) that nothing declares.
152
+ *
153
+ * This REPORTS rather than edits: init is a scaffolder writing a whole project and has
154
+ * a manifest in hand, while eject copies one file into a repo whose dependency policy
155
+ * (workspace protocol, catalog, pinned ranges) is the adopter's. Naming the exact
156
+ * missing package and the version to match is the useful half; silently rewriting
157
+ * someone's manifest is not.
158
+ */
159
+ export async function dependencyNotesForTemplate(cwd: string, templateSource: string): Promise<string[]> {
160
+ const required = requiredPackages(templateSource);
161
+ if (required.length === 0) return [];
162
+
163
+ const pkg = readPackageManifest(cwd);
164
+ if (pkg === undefined) {
165
+ // No readable manifest — say what the file needs and let the adopter place it.
166
+ return [`This file imports: ${required.join(", ")}. Make sure each is installed.`];
167
+ }
168
+ const declared = declaredDependencyNames(pkg);
169
+
170
+ const missing = required.filter((p) => !declared.has(p));
171
+ if (missing.length === 0) return [];
172
+ return [
173
+ `The ejected file imports ${missing.join(", ")}, which your package.json does not ` +
174
+ "declare — your typecheck will report TS2307 until it does. Install with:",
175
+ ` npm i -D ${missing.map((p) => `${p}@^${cliVersion()}`).join(" ")}`,
176
+ ];
177
+ }
178
+
179
+ export async function ejectGenerator(opts: EjectOptions): Promise<EjectResult> {
180
+ const source = resolveSource(opts.name);
181
+ if (source === undefined) {
182
+ throw new Error(
183
+ `unknown generator "${opts.name}". Ejectable generators: ${ejectableNames().join(", ")}. ` +
184
+ "Run `meta eject --list` to see them grouped by package.",
185
+ );
186
+ }
187
+
188
+ const templateSource = await readFile(join(source.root(), `${opts.name}.ts`), "utf8");
189
+ const importLine = extractImportLine(templateSource, opts.name);
190
+ const exportName = extractExportName(importLine, opts.name);
191
+ const rel = `${OWNED_GENERATORS_DIR}/${opts.name}.ts`;
192
+ const abs = join(opts.cwd, rel);
193
+ const notes = await dependencyNotesForTemplate(opts.cwd, templateSource);
194
+ const common = {
195
+ path: rel,
196
+ importLine,
197
+ exportName,
198
+ packageName: source.packageName,
199
+ dependencyNotes: notes,
200
+ };
201
+
202
+ if (!opts.force && (await fileExists(abs))) {
203
+ return { ...common, status: "preserved" };
204
+ }
205
+
206
+ await mkdir(join(opts.cwd, OWNED_GENERATORS_DIR), { recursive: true });
207
+ await writeFile(abs, templateSource, "utf8");
208
+ return { ...common, status: "created" };
209
+ }
210
+
211
+ function listOutput(): string {
212
+ const lines: string[] = [];
213
+ lines.push("Ejectable generators (copy any of these into codegen/generators/ and own it):");
214
+ lines.push("");
215
+ for (const source of SOURCES) {
216
+ lines.push(`${source.packageName}:`);
217
+ lines.push(` ${source.names.join(", ")}`);
218
+ lines.push("");
219
+ }
220
+ lines.push("Run: meta eject <name>");
221
+ return lines.join("\n");
222
+ }
223
+
224
+ export async function ejectCommand(args: string[], cwd: string): Promise<number> {
225
+ let flags;
226
+ try {
227
+ flags = parseEjectArgs(args);
228
+ } catch (err) {
229
+ log.error((err as Error).message);
230
+ return 2;
231
+ }
232
+
233
+ if (flags.list) {
234
+ log.info(listOutput());
235
+ return 0;
236
+ }
237
+
238
+ if (flags.name === undefined) {
239
+ log.error("meta eject requires a generator name, or --list to see what's ejectable.");
240
+ return 2;
241
+ }
242
+
243
+ try {
244
+ const result = await ejectGenerator({ cwd, name: flags.name, force: flags.force });
245
+ if (result.status === "preserved") {
246
+ log.info(`${result.path} already exists — left untouched (pass --force to overwrite).`);
247
+ } else {
248
+ log.info(`Ejected "${flags.name}" -> ${result.path}. You own it now (ADR-0034 scaffold-and-own).`);
249
+ }
250
+ // REPLACE, never "paste". A generator reaches `generators: [...]` under ONE binding,
251
+ // so a reader told to "paste" gets a duplicate identifier at best — and at worst
252
+ // deletes nothing, keeps `formFile()` in the array bound to the PACKAGE import, and
253
+ // silently runs the packaged generator while editing the ejected file. That failure
254
+ // is invisible and is the exact one ejecting exists to prevent.
255
+ //
256
+ // But eject reads no config, so it cannot know WHICH of the three states this project
257
+ // is in, and stating one of them as fact is wrong in the other two — including for the
258
+ // four `meta init` scaffolds, whose config already imports from ./codegen/generators/,
259
+ // which is precisely the `meta eject <name> --force` re-sync case. Name the goal, then
260
+ // the three branches; the reader knows which one they are looking at.
261
+ log.info(`In metaobjects.config.ts, "${result.exportName}" must resolve to this file:`);
262
+ log.info(` ${result.importLine}`);
263
+ log.info(
264
+ ` - If it is imported from "${result.packageName}", REPLACE that import with the ` +
265
+ "line above. Adding a second one leaves `generators` bound to the PACKAGED " +
266
+ "generator, and your edits to this file do nothing.",
267
+ );
268
+ log.info(
269
+ " - If it is already imported from ./codegen/generators/ (what `meta init` " +
270
+ "scaffolds), it points here already — nothing to change.",
271
+ );
272
+ log.info(
273
+ ` - If ${result.exportName}() is not in \`generators\` yet, add the import above ` +
274
+ "AND the entry.",
275
+ );
276
+ for (const line of result.dependencyNotes) log.info(line);
277
+ return 0;
278
+ } catch (err) {
279
+ log.error((err as Error).message);
280
+ return 1;
281
+ }
282
+ }