@metaobjectsdev/cli 0.24.4 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metaobjectsdev/cli",
3
- "version": "0.24.4",
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.4",
53
- "@metaobjectsdev/codegen-ts-react": "0.24.4",
54
- "@metaobjectsdev/codegen-ts-tanstack": "0.24.4",
55
- "@metaobjectsdev/docs-site": "0.24.4",
56
- "@metaobjectsdev/metadata": "0.24.4",
57
- "@metaobjectsdev/migrate-ts": "0.24.4",
58
- "@metaobjectsdev/render": "0.24.4",
59
- "@metaobjectsdev/runtime-ts": "0.24.4",
60
- "@metaobjectsdev/sdk": "0.24.4",
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
+ }
@@ -11,13 +11,34 @@ import { resolveStack } from "../lib/detect-stack.js";
11
11
  import { parseInitArgs } from "../lib/args.js";
12
12
  import { log } from "../lib/log.js";
13
13
  import { cliVersion } from "../lib/version.js";
14
+ import { declaredDependencyNames, type PackageManifest } from "../lib/package-manifest.js";
14
15
  import { findWranglerConfig, parseWranglerConfig } from "@metaobjectsdev/migrate-ts";
15
- import { readReferenceTemplate, REFERENCE_GENERATOR_NAMES } from "@metaobjectsdev/codegen-ts";
16
+ import { readReferenceTemplate, type ReferenceGeneratorName } from "@metaobjectsdev/codegen-ts";
16
17
 
17
18
  // ADR-0034 scaffold-and-own — `meta init` copies the codegen reference templates into
18
19
  // the consumer's repo so they OWN them; metaobjects.config.ts imports them locally.
19
20
  const OWNED_GENERATORS_DIR = "codegen/generators";
20
21
 
22
+ // The FOUR reference generators `meta init` copies EAGERLY — deliberately an explicit
23
+ // literal, not derived from @metaobjectsdev/codegen-ts's REFERENCE_GENERATOR_NAMES (the
24
+ // full list of everything `meta eject` can copy). Looping over that array unconditionally
25
+ // used to mean init scaffolded whatever it contained: when a later task registered
26
+ // "routes-hono" there, init started writing an unwired Hono generator nothing in the
27
+ // scaffolded config imports, on every fresh project, silently. This constant is the
28
+ // scaffolded metaobjects.config.ts's import list (buildMetaobjectsConfigBody below) made
29
+ // explicit and checkable — anything else is eject-on-demand via `meta eject <name>`, which
30
+ // exists exactly so eager copying isn't the only way to take ownership of a template.
31
+ const SCAFFOLDED_GENERATOR_NAMES: readonly ReferenceGeneratorName[] = ["entity", "queries", "routes", "barrel"];
32
+
33
+ // The scaffolded config's outDir + dbImport, as named constants so the throwing-stub
34
+ // path below is DERIVED from the same values the config template embeds rather than
35
+ // duplicated as a second literal that could drift from it.
36
+ const SCAFFOLD_OUT_DIR = "src/generated";
37
+ const SCAFFOLD_DB_IMPORT = "../db";
38
+ // "src/generated" + "../db" -> "src/db" -> "src/db.ts" (dbImport resolves relative
39
+ // to outDir, same as the module specifier a generated route file emits).
40
+ const DB_STUB_REL_PATH = `${join(SCAFFOLD_OUT_DIR, SCAFFOLD_DB_IMPORT)}.ts`;
41
+
21
42
  const META_COMMON_JSON = JSON.stringify(
22
43
  {
23
44
  metadata: {
@@ -87,9 +108,18 @@ import { routesFile } from "./codegen/generators/routes.js";
87
108
  import { barrel } from "./codegen/generators/barrel.js";
88
109
 
89
110
  export default defineConfig({
90
- outDir: "src/generated",
91
- extStyle: "js", // ".js"-extensioned relative imports — safe under Node ESM / tsc nodenext AND bundlers
92
- dbImport: "../db",
111
+ outDir: "${SCAFFOLD_OUT_DIR}",
112
+ extStyle: "js", // ".js"-extensioned relative imports — correct for Node ESM and \`tsc\` with
113
+ // nodenext, which is what a fresh project has. BUNDLERS DISAGREE: this fails
114
+ // outright under Turbopack (even between two generated files, so the whole
115
+ // generated tree goes unresolvable), while Vite and esbuild accept it and
116
+ // webpack needs \`resolve.extensionAlias\`. If a generated import fails to
117
+ // resolve, set "none" and retest — do not assume this line covers your bundler.
118
+ dbImport: "${SCAFFOLD_DB_IMPORT}", // routesFile() below emits \`import { db } from …\` — meta init
119
+ // scaffolded ${DB_STUB_REL_PATH} as a THROWING STUB (types clean, no
120
+ // driver chosen) so meta gen and tsc pass; replace it with your real
121
+ // Drizzle connection before running the app.
122
+ // (queriesFile() takes db as a parameter and never reads this.)
93
123
  dialect: "${dialect}",
94
124
  apiPrefix: "", // set to "/api" if your routes mount under /api
95
125
  generators: [
@@ -107,10 +137,76 @@ export default defineConfig({
107
137
  `;
108
138
  }
109
139
 
110
- const NEXT_STEPS = `
140
+ // The throwing-stub scaffolded at `dbImport`'s resolved path (DB_STUB_REL_PATH,
141
+ // "src/db.ts" by default). It exists so `meta gen` and a fresh project's FIRST
142
+ // `tsc` both succeed with no driver chosen and no dependency added — deliberately
143
+ // NOT a real connection. Every generated route only ever passes `db` straight
144
+ // through to `mountCrudRoutes(...)`; nothing reads a property off it at import
145
+ // time, so a value typed `unknown` (not `any`) satisfies every call site while
146
+ // making a genuine runtime use (mountCrudRoutes calling `db.select()` etc.) throw
147
+ // immediately with an actionable message instead of failing to resolve at all.
148
+ // Built as an array of plain single-quoted lines (not a template literal) so the
149
+ // backticks and quotes inside the comment/message need no escaping.
150
+ const DB_STUB_BODY = [
151
+ "// `meta init` scaffolded this file because the generated Fastify routes",
152
+ '// `import { db } from "../db.js"` (see `dbImport` in metaobjects.config.ts) —',
153
+ "// a module that has to exist for `meta gen` and `tsc` to succeed. MetaObjects",
154
+ "// cannot fill it in for real without choosing a database driver on your",
155
+ "// behalf (better-sqlite3 vs @libsql/client vs pg vs postgres.js) and adding a",
156
+ "// dependency you may not want, so this is a STUB, not a connection.",
157
+ "//",
158
+ "// It type-checks and satisfies every generated import, but throws the first",
159
+ "// time anything actually touches `db` at runtime. Replace the export below",
160
+ "// with your real Drizzle connection, e.g.:",
161
+ "//",
162
+ '// import { drizzle } from "drizzle-orm/better-sqlite3";',
163
+ '// import Database from "better-sqlite3";',
164
+ '// export const db = drizzle(new Database("dev.sqlite"));',
165
+ "//",
166
+ "// (swap the driver import for your dialect — see",
167
+ "// https://github.com/metaobjectsdev/metaobjects/blob/main/docs/recipes/wiring-generated-queries.md",
168
+ "// for SQLite/libsql, Cloudflare D1, Postgres and multi-tenant setups.)",
169
+ "",
170
+ "const UNWIRED_MESSAGE =",
171
+ ' "src/db.ts is still the scaffolded stub meta init wrote — it cannot choose " +',
172
+ " \"a database driver for you. Replace 'export const db = ...' below with \" +",
173
+ ' "your real Drizzle connection, e.g.:\\n\\n" +',
174
+ " \" import { drizzle } from 'drizzle-orm/better-sqlite3';\\n\" +",
175
+ " \" import Database from 'better-sqlite3';\\n\" +",
176
+ " \" export const db = drizzle(new Database('dev.sqlite'));\\n\";",
177
+ "",
178
+ "function unwired(): never {",
179
+ " throw new Error(UNWIRED_MESSAGE);",
180
+ "}",
181
+ "",
182
+ "/**",
183
+ " * Stand-in for your real Drizzle database connection. Generated code only",
184
+ " * ever passes `db` straight through to `mountCrudRoutes(...)` — it never",
185
+ " * reads a property off it at import time — so this typechecks everywhere",
186
+ " * `db` is used, and throws the message above the first time anything really",
187
+ " * touches it.",
188
+ " */",
189
+ "export const db: unknown = new Proxy({}, { get: unwired });",
190
+ "",
191
+ ].join("\n");
192
+
193
+ // Printed only when the stub was ACTUALLY written this run. It is gated on having just
194
+ // written the scaffolded config (see the db-stub block in `init`), so a re-run in a
195
+ // project that keeps its own config writes nothing — and a block claiming otherwise is
196
+ // the same "asserting things about its own scaffold that aren't true" defect the rest of
197
+ // this file was corrected for.
198
+ const DB_STUB_NOTE = `Also scaffolded ${DB_STUB_REL_PATH}: a THROWING STUB standing in for your database
199
+ connection, so the generated routes' \`db\` import resolves and the first tsc is clean.
200
+ Replace it with a real connection before running the app — until you do, the first
201
+ request that touches \`db\` throws with instructions.
202
+ `;
203
+
204
+ const SCAFFOLD_SUMMARY = `
111
205
  Initialized metaobjects/ + .metaobjects/ + metaobjects.config.ts
112
206
  Codegen generators copied to codegen/generators/ — they're YOURS to edit (ADR-0034 scaffold-and-own).
207
+ `;
113
208
 
209
+ const NEXT_STEPS = `
114
210
  Next steps:
115
211
  0. Everything here is ESM — package.json needs "type": "module" (init sets it
116
212
  unless the project has CommonJS sources; without it the first tsc fails).
@@ -329,11 +425,16 @@ async function wireRootMemory(cwd: string, result: InitResult, dryRun = false):
329
425
  * `codegen/generators/<name>.ts` so they own them. Each file is written only if absent,
330
426
  * so a re-run with --force never clobbers a hand-edited generator. The scaffolded
331
427
  * metaobjects.config.ts imports these local copies (not the package `/generators` export).
428
+ *
429
+ * Copies SCAFFOLDED_GENERATOR_NAMES only — the four the scaffolded config actually
430
+ * wires — not every name @metaobjectsdev/codegen-ts happens to register. Anything else
431
+ * (routes-hono, and any UI-tier template from codegen-ts-react/-tanstack) is reached with
432
+ * `meta eject <name>`, not by eager copying.
332
433
  */
333
434
  async function writeOwnedGenerators(opts: InitOptions, result: InitResult): Promise<void> {
334
435
  const dir = join(opts.cwd, OWNED_GENERATORS_DIR);
335
436
  await mkdir(dir, { recursive: true });
336
- for (const name of REFERENCE_GENERATOR_NAMES) {
437
+ for (const name of SCAFFOLDED_GENERATOR_NAMES) {
337
438
  const rel = `${OWNED_GENERATORS_DIR}/${name}.ts`;
338
439
  const abs = join(dir, `${name}.ts`);
339
440
  if (await fileExists(abs)) {
@@ -475,8 +576,8 @@ export async function init(opts: InitOptions): Promise<InitResult> {
475
576
  `.metaobjects/${PACKAGE_MANIFEST_FILE}`,
476
577
  );
477
578
  result.created.push(".metaobjects/AGENTS.md", ".metaobjects/CLAUDE.md", ".claude/skills/metaobjects-*", AGENT_CONTEXT_MANIFEST_PATH);
478
- for (const name of REFERENCE_GENERATOR_NAMES) result.created.push(`${OWNED_GENERATORS_DIR}/${name}.ts`);
479
- result.created.push("metaobjects.config.ts", ".gitignore");
579
+ for (const name of SCAFFOLDED_GENERATOR_NAMES) result.created.push(`${OWNED_GENERATORS_DIR}/${name}.ts`);
580
+ result.created.push("metaobjects.config.ts", DB_STUB_REL_PATH, ".gitignore");
480
581
  return result;
481
582
  }
482
583
 
@@ -524,11 +625,54 @@ export async function init(opts: InitOptions): Promise<InitResult> {
524
625
 
525
626
  // Scaffold metaobjects.config.ts at the project root. Never overwrite if it exists.
526
627
  const forgeConfigPath = join(opts.cwd, "metaobjects.config.ts");
527
- if (!(await fileExists(forgeConfigPath))) {
628
+ const wroteScaffoldedConfig = !(await fileExists(forgeConfigPath));
629
+ if (wroteScaffoldedConfig) {
528
630
  await writeFile(forgeConfigPath, buildMetaobjectsConfigBody(opts.d1 ? "d1" : "sqlite"), "utf8");
529
631
  result.created.push("metaobjects.config.ts");
530
632
  }
531
633
 
634
+ // Scaffold the `dbImport` throwing stub at DB_STUB_REL_PATH ("src/db.ts" by
635
+ // default) — ONLY if absent, so a re-run never clobbers a user's real db module
636
+ // (same "write once" precedent as writeOwnedGenerators above). Without this, the
637
+ // scaffolded config declares `dbImport: "../db"` pointing at a module `meta init`
638
+ // never creates, and a fresh project's FIRST `tsc` fails to resolve it.
639
+ //
640
+ // Gated on having just WRITTEN that config, not merely on the stub being absent.
641
+ // DB_STUB_REL_PATH is derived from SCAFFOLD_OUT_DIR + SCAFFOLD_DB_IMPORT — the
642
+ // scaffold's own constants — so it describes where the SCAFFOLDED config points and
643
+ // nowhere else. Re-running `meta init` in a project that already has a config with
644
+ // its own `outDir`/`dbImport` preserves that config (above) and would otherwise still
645
+ // drop a src/db.ts that nothing in the project references: a stray file, in the
646
+ // adopter's application source, answering a question they had already answered.
647
+ const dbStubPath = join(opts.cwd, DB_STUB_REL_PATH);
648
+ const dbStubExists = await fileExists(dbStubPath);
649
+ if (wroteScaffoldedConfig && !dbStubExists) {
650
+ await mkdir(dirname(dbStubPath), { recursive: true });
651
+ await writeFile(dbStubPath, DB_STUB_BODY, "utf8");
652
+ result.created.push(DB_STUB_REL_PATH);
653
+ } else if (dbStubExists) {
654
+ // "preserved" means a file we would have written was left alone. Skipping because
655
+ // this project keeps its own config is not preservation — there is nothing there.
656
+ result.preserved.push(DB_STUB_REL_PATH);
657
+ } else {
658
+ // Neither written nor preserved: this project keeps its own config AND has no stub.
659
+ // Usually correct — its `dbImport` points at a real module somewhere else. But it is
660
+ // ALSO what a scaffolded project looks like after someone deletes or moves src/db.ts,
661
+ // and `meta init --force` cannot tell those apart: `wroteScaffoldedConfig` is only
662
+ // "no config existed", so it is false for the config init itself wrote. Silently
663
+ // doing nothing there leaves the scaffolded `dbImport: "../db"` and the generated
664
+ // routes' `import { db } from "../db.js"` pointing at nothing, and the adopter meets
665
+ // it as a TS2307 from `tsc` with no word from the command that could have said so.
666
+ // Say it here rather than writing: dropping a file into a project that owns its
667
+ // config is what the branch above deliberately refuses.
668
+ result.warnings.push(
669
+ `${DB_STUB_REL_PATH} was not scaffolded — this project has its own ` +
670
+ "metaobjects.config.ts, so init leaves the database module to it. If that config's " +
671
+ `\`dbImport\` resolves to ${DB_STUB_REL_PATH}, create it or the generated routes will ` +
672
+ "not resolve.",
673
+ );
674
+ }
675
+
532
676
  // Scaffold a minimal root .gitignore ONLY when the project has none — never
533
677
  // clobber a user's existing one (they may have their own rules).
534
678
  const rootGitignorePath = join(opts.cwd, ".gitignore");
@@ -598,6 +742,7 @@ async function ensureEsmPackageType(cwd: string, result: InitResult): Promise<vo
598
742
  return;
599
743
  }
600
744
 
745
+ const declaredType = pkg.type; // read BEFORE the mutation below overwrites it
601
746
  pkg.type = "module";
602
747
  const added = addScaffoldDevDependencies(pkg);
603
748
  // Preserve the file's existing indentation rather than reformatting someone's manifest.
@@ -606,9 +751,16 @@ async function ensureEsmPackageType(cwd: string, result: InitResult): Promise<vo
606
751
  // Past tense, deliberately: this reports an edit already made. The imperative
607
752
  // ("set `\"type\": \"module\"`") read as a TODO on the one line a newcomer sees
608
753
  // last, so a scaffold that had just done the right thing looked like it had failed.
754
+ //
755
+ // And it must not claim the manifest was SILENT on the point: `npm init -y` writes
756
+ // `"type": "commonjs"` explicitly (npm 11.x), which is the dominant first-touch path,
757
+ // so "declared no module system" was false exactly where it is read most. Report what
758
+ // was actually there.
759
+ const previous = typeof declaredType === "string" ? declaredType : undefined;
609
760
  result.warnings.push(
610
- 'package.json declared no module system set `"type": "module"` for you, because ' +
611
- "MetaObjects scaffolds and generates ESM, which a CommonJS project cannot compile.",
761
+ `package.json ${previous === undefined ? "declared no module system" : `declared "type": "${previous}"`} ` +
762
+ 'set `"type": "module"` for you, because MetaObjects scaffolds and generates ESM, ' +
763
+ "which a CommonJS project cannot compile.",
612
764
  );
613
765
  if (added.length > 0) {
614
766
  result.warnings.push(
@@ -643,10 +795,17 @@ function addScaffoldDevDependencies(pkg: Record<string, unknown>): string[] {
643
795
  "@metaobjectsdev/metadata": `^${version}`,
644
796
  };
645
797
  const dev = (pkg.devDependencies ?? {}) as Record<string, string>;
646
- const deps = (pkg.dependencies ?? {}) as Record<string, string>;
798
+ // "Already declared" spans all four dependency fields — the shared rule, so this
799
+ // cannot drift from what `meta eject` means by the same words. It used to ask only
800
+ // dependencies + devDependencies, which meant a project declaring codegen-ts as a
801
+ // PEER dependency (correct for a library whose consumer supplies the version) got it
802
+ // added to devDependencies as well: the same package pinned twice in one manifest,
803
+ // and a second physical copy is the class-identity split this repo has been bitten
804
+ // by twice.
805
+ const declared = declaredDependencyNames(pkg as PackageManifest);
647
806
  const added: string[] = [];
648
807
  for (const [name, range] of Object.entries(wanted)) {
649
- if (dev[name] !== undefined || deps[name] !== undefined) continue;
808
+ if (declared.has(name)) continue;
650
809
  dev[name] = range;
651
810
  added.push(name);
652
811
  }
@@ -697,8 +856,18 @@ function buildD1MigrateBlock(cwd: string): Record<string, unknown> {
697
856
  return block;
698
857
  }
699
858
 
700
- export function nextStepsBlock(): string {
701
- return NEXT_STEPS;
859
+ /**
860
+ * The post-init message.
861
+ *
862
+ * @param dbStubWritten whether THIS run wrote {@link DB_STUB_REL_PATH}. Required rather
863
+ * than defaulted, because a default would silently restore the bug this parameter
864
+ * exists to close: the note used to be part of one static string and so claimed the
865
+ * stub on every run, including the `meta init --force` in a project keeping its own
866
+ * `metaobjects.config.ts`, where the stub is deliberately not written. Callers pass
867
+ * `result.created.includes(DB_STUB_REL_PATH)` — the same list the message describes.
868
+ */
869
+ export function nextStepsBlock(dbStubWritten: boolean): string {
870
+ return SCAFFOLD_SUMMARY + (dbStubWritten ? DB_STUB_NOTE : "") + NEXT_STEPS;
702
871
  }
703
872
 
704
873
  async function dirExists(p: string): Promise<boolean> {
@@ -763,7 +932,7 @@ export async function initCommand(args: string[], cwd: string): Promise<number>
763
932
  }
764
933
  for (const w of result.warnings) log.warn(w);
765
934
  } else {
766
- log.info(nextStepsBlock());
935
+ log.info(nextStepsBlock(result.created.includes(DB_STUB_REL_PATH)));
767
936
  // Surface any scaffold warnings (e.g. the #77 monorepo-subdir agent-context
768
937
  // discovery warning) — these are otherwise dropped on the normal init path.
769
938
  for (const w of result.warnings) log.warn(w);
@@ -949,9 +949,16 @@ export async function verifyCommand(
949
949
  if (result.changes.length === 0) return [];
950
950
 
951
951
  return [
952
+ // `meta migrate --from-db` is NOT the repair: it writes a snapshot only when it has
953
+ // changes to EMIT, so on a database that already matches the metadata it reports
954
+ // "no schema changes / nothing to do", writes nothing, and leaves the stale snapshot
955
+ // exactly as it was — so this gate fails again, identically, with the user having
956
+ // been told everything is in sync. `baseline --from-db` rewrites it unconditionally,
957
+ // which is the whole point of the subcommand.
952
958
  `the committed schema snapshot disagrees with ${displayUrl} ` +
953
959
  `(${result.changes.length} difference(s)) — the next 'meta migrate' would emit DDL from it ` +
954
- `and fail at apply. Re-derive it with 'meta migrate --from-db --db <url> --dialect ${dialect}'.`,
960
+ `and fail at apply. Re-derive it with ` +
961
+ `'meta migrate baseline --from-db --db <url> --dialect ${dialect}'.`,
955
962
  ...summarizeDrift(result.changes),
956
963
  ];
957
964
  }