@metaobjectsdev/cli 1.0.9-rc.4 → 1.0.9-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +15 -0
  2. package/dist/src/commands/eject.d.ts +17 -0
  3. package/dist/src/commands/eject.d.ts.map +1 -1
  4. package/dist/src/commands/eject.js +87 -2
  5. package/dist/src/commands/eject.js.map +1 -1
  6. package/dist/src/commands/generator-help.d.ts +21 -0
  7. package/dist/src/commands/generator-help.d.ts.map +1 -0
  8. package/dist/src/commands/generator-help.js +43 -0
  9. package/dist/src/commands/generator-help.js.map +1 -0
  10. package/dist/src/commands/generator.d.ts +36 -0
  11. package/dist/src/commands/generator.d.ts.map +1 -0
  12. package/dist/src/commands/generator.js +333 -0
  13. package/dist/src/commands/generator.js.map +1 -0
  14. package/dist/src/commands/init.d.ts.map +1 -1
  15. package/dist/src/commands/init.js +10 -1
  16. package/dist/src/commands/init.js.map +1 -1
  17. package/dist/src/commands/verify.d.ts.map +1 -1
  18. package/dist/src/commands/verify.js +5 -3
  19. package/dist/src/commands/verify.js.map +1 -1
  20. package/dist/src/index.d.ts.map +1 -1
  21. package/dist/src/index.js +8 -0
  22. package/dist/src/index.js.map +1 -1
  23. package/dist/src/lib/advisory.d.ts +14 -0
  24. package/dist/src/lib/advisory.d.ts.map +1 -1
  25. package/dist/src/lib/advisory.js +64 -5
  26. package/dist/src/lib/advisory.js.map +1 -1
  27. package/dist/src/lib/catalog-listing.d.ts +8 -0
  28. package/dist/src/lib/catalog-listing.d.ts.map +1 -1
  29. package/dist/src/lib/catalog-listing.js +35 -1
  30. package/dist/src/lib/catalog-listing.js.map +1 -1
  31. package/dist/src/lib/install-set.d.ts +12 -2
  32. package/dist/src/lib/install-set.d.ts.map +1 -1
  33. package/dist/src/lib/install-set.js +14 -2
  34. package/dist/src/lib/install-set.js.map +1 -1
  35. package/dist/src/lib/runtime-eject.d.ts +80 -0
  36. package/dist/src/lib/runtime-eject.d.ts.map +1 -0
  37. package/dist/src/lib/runtime-eject.js +212 -0
  38. package/dist/src/lib/runtime-eject.js.map +1 -0
  39. package/package.json +10 -10
  40. package/src/commands/eject.ts +103 -3
  41. package/src/commands/generator-help.ts +47 -0
  42. package/src/commands/generator.ts +390 -0
  43. package/src/commands/init.ts +10 -1
  44. package/src/commands/verify.ts +5 -4
  45. package/src/index.ts +8 -0
  46. package/src/lib/advisory.ts +71 -5
  47. package/src/lib/catalog-listing.ts +48 -1
  48. package/src/lib/install-set.ts +19 -2
  49. package/src/lib/runtime-eject.ts +263 -0
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);
@@ -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: "missing-base-url",
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: `removed-prop:${f.prop}`,
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: "library-prefix-unprovenanced",
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: "unindexed-foreign-key",
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: "overridden-referential-action",
259
+ rule: RULE_REFERENTIAL_ACTION_CONFLICT,
194
260
  construct: f.construct,
195
261
  message: f.message,
196
262
  }));
@@ -34,6 +34,11 @@ import {
34
34
  type LibraryCatalogRow, type LibraryProjectContext,
35
35
  } from "./library-listing.js";
36
36
  import { installSetFor } from "./install-set.js";
37
+ import {
38
+ RUNTIME_ENTRIES, resolveRuntimePackage, runtimeClosure, runtimeCopyStatus, runtimeInstall,
39
+ type RuntimeCopyRow,
40
+ } from "./runtime-eject.js";
41
+ import { OWNED_RUNTIME_DIR } from "@metaobjectsdev/codegen-ts";
37
42
  import { readPackageManifest, declaredDependencyNames } from "./package-manifest.js";
38
43
 
39
44
  /**
@@ -67,6 +72,13 @@ export interface GeneratorCatalogRow {
67
72
  ejectable: boolean;
68
73
  /** Whether this project already owns a copy. `null` with no project. */
69
74
  owned: boolean | null;
75
+ /**
76
+ * ADR-0034 Amendment 3 (2026-09-24): the HTTP-adapter source this generator's output
77
+ * imports, as far as the project owns a copy of it under `codegen/runtime/` — each
78
+ * file compared to the installed runtime package. Absent when the generator's output
79
+ * imports no adapter, or the project owns none of it.
80
+ */
81
+ runtimeCopy?: RuntimeCopyRow[];
70
82
  };
71
83
  project?: {
72
84
  /** Wired in this project's `generators: [...]`. */
@@ -237,12 +249,37 @@ const PROBE_DB_IMPORT_PLACEHOLDER = "./db";
237
249
  // the listing
238
250
  // ---------------------------------------------------------------------------
239
251
 
252
+ /** Per generator that hands over adapter source, the project's copies of the files it reaches. */
253
+ async function ownedRuntimeByGenerator(
254
+ projectRoot: string,
255
+ ): Promise<Map<string, { files: RuntimeCopyRow[]; packages: string[] }>> {
256
+ const out = new Map<string, { files: RuntimeCopyRow[]; packages: string[] }>();
257
+ const status = await runtimeCopyStatus(projectRoot);
258
+ if (status.length === 0) return out;
259
+ const { srcRoot } = resolveRuntimePackage();
260
+ for (const [name, modules] of Object.entries(RUNTIME_ENTRIES)) {
261
+ const closure = runtimeClosure(srcRoot, modules);
262
+ const reached = new Set(closure.files.map((f) => `${OWNED_RUNTIME_DIR}/${f}`));
263
+ const files = status.filter((r) => reached.has(r.path));
264
+ if (files.length > 0) out.set(name, { files, packages: closure.packages });
265
+ }
266
+ return out;
267
+ }
268
+
240
269
  export async function buildCatalogListing(opts: CatalogListingOpts = {}): Promise<CatalogRow[]> {
241
270
  const rows: CatalogRow[] = [];
271
+ const runtimeCopies = opts.project === undefined
272
+ ? new Map<string, { files: RuntimeCopyRow[]; packages: string[] }>()
273
+ : await ownedRuntimeByGenerator(opts.project.projectRoot);
242
274
 
243
275
  for (const entry of listCatalog()) {
244
276
  const template = entry.ejectable ? readTemplate(entry.name) : undefined;
245
- const install = installSetFor([entry]);
277
+ // A project that owns this generator's adapter copy installs what the COPY imports,
278
+ // not the runtime package — the same trade `meta eject` prints.
279
+ const owned = runtimeCopies.get(entry.name);
280
+ const install = owned === undefined
281
+ ? installSetFor([entry])
282
+ : installSetFor([entry], [], { generators: new Set([entry.name]), ...runtimeInstall(owned.packages) });
246
283
  const pkg = packageOf(entry.name) ?? "";
247
284
 
248
285
  const row: GeneratorCatalogRow = {
@@ -266,6 +303,7 @@ export async function buildCatalogListing(opts: CatalogListingOpts = {}): Promis
266
303
  kind: entry.ejectable ? "reference-template" : "package-only",
267
304
  ejectable: entry.ejectable,
268
305
  owned: opts.project === undefined ? null : opts.project.ownedNames.has(entry.name),
306
+ ...(owned !== undefined ? { runtimeCopy: owned.files } : {}),
269
307
  },
270
308
  };
271
309
 
@@ -408,6 +446,15 @@ export function renderCatalogText(rows: CatalogRow[], probed: boolean): string {
408
446
  if (!r.source.ejectable) marks.push("package-only");
409
447
  if (r.project?.wired) marks.push("WIRED");
410
448
  if (r.source.owned) marks.push("owned");
449
+ if (r.source.runtimeCopy !== undefined) {
450
+ // The adapter source this generator's output imports, compared to the package.
451
+ const differing = r.source.runtimeCopy.filter((c) => c.verdict === "differs").length;
452
+ marks.push(
453
+ differing === 0
454
+ ? "owned runtime: identical to the package"
455
+ : `owned runtime: ${differing} file(s) DIFFER from the package — meta eject --list`,
456
+ );
457
+ }
411
458
  if (probed && r.project?.wouldEmit !== null && r.project?.wouldEmit !== undefined) {
412
459
  const needs = r.project.needsConfig;
413
460
  marks.push(
@@ -20,7 +20,7 @@
20
20
  import { createRequire } from "node:module";
21
21
  import { existsSync, readFileSync } from "node:fs";
22
22
  import { dirname, join } from "node:path";
23
- import type { GeneratorRegistryEntry } from "@metaobjectsdev/codegen-ts";
23
+ import { HTTP_RUNTIME_PACKAGE, type GeneratorRegistryEntry } from "@metaobjectsdev/codegen-ts";
24
24
  import { cliVersion } from "./version.js";
25
25
  import { packageOf } from "./catalog.js";
26
26
  import type { PackageManifest } from "./package-manifest.js";
@@ -63,6 +63,13 @@ export function peerRangesOf(packageName: string): Record<string, string> {
63
63
  }
64
64
  }
65
65
 
66
+ /** What an owned adapter copy changes about the install set — see `installSetFor`. */
67
+ export interface OwnedRuntimeInstall {
68
+ generators: ReadonlySet<string>;
69
+ runtime: ReadonlyMap<string, string | undefined>;
70
+ dev: ReadonlyMap<string, string | undefined>;
71
+ }
72
+
66
73
  export interface InstallSet {
67
74
  /** Build-time packages, `name@range`. */
68
75
  dev: string[];
@@ -114,6 +121,10 @@ export function installSetFor(
114
121
  * catalog does not know them). Build-time, so dev — unless the runtime set already
115
122
  * installs the package, which satisfies the build too. */
116
123
  templatePackages: readonly string[] = [],
124
+ /** ADR-0034 Amendment 3 (2026-09-24): generators whose adapter SOURCE was copied into the
125
+ * project, and what that copy imports. Their output no longer imports the runtime package,
126
+ * so it is not installed on their account; the copy's own imports are, instead. */
127
+ ownedRuntime?: OwnedRuntimeInstall,
117
128
  ): InstallSet {
118
129
  const version = cliVersion();
119
130
  const dev = new Map<string, string | undefined>();
@@ -123,7 +134,10 @@ export function installSetFor(
123
134
  const pkg = packageOf(entry.name);
124
135
  if (pkg !== undefined) addPackage(dev, pkg, `^${version}`);
125
136
 
126
- for (const rt of entry.runtimePackages ?? []) addPackage(runtime, rt, `^${version}`);
137
+ for (const rt of entry.runtimePackages ?? []) {
138
+ if (ownedRuntime?.generators.has(entry.name) && rt === HTTP_RUNTIME_PACKAGE) continue;
139
+ addPackage(runtime, rt, `^${version}`);
140
+ }
127
141
 
128
142
  if (entry.runtimePeers !== undefined && entry.runtimePeers.length > 0) {
129
143
  // Ranges come from whichever runtime package declares these as peers — the union
@@ -137,6 +151,9 @@ export function installSetFor(
137
151
  }
138
152
  }
139
153
 
154
+ for (const [name, range] of ownedRuntime?.runtime ?? []) addPackage(runtime, name, range);
155
+ for (const [name, range] of ownedRuntime?.dev ?? []) addPackage(dev, name, range);
156
+
140
157
  for (const p of templatePackages) {
141
158
  if (!runtime.has(p)) addPackage(dev, p, `^${version}`);
142
159
  }
@@ -0,0 +1,263 @@
1
+ // ADR-0034 Amendment 3 (2026-09-24 ruling) — ejecting a helper hands over ALL of its code.
2
+ //
3
+ // `meta eject routes` used to copy a generator whose OUTPUT still imported the HTTP adapter
4
+ // from `@metaobjectsdev/runtime-ts` — the mount helpers, filter parser, error envelopes and
5
+ // pagination, which is where most route defects have lived. Owning the generator while the
6
+ // code it emits calls into a package is half of owning it: a defect in the adapter still
7
+ // waits on an upstream release.
8
+ //
9
+ // So eject also copies the adapter SOURCE the output depends on — the transitive closure of
10
+ // relative imports from the entry module each generator's output names — into
11
+ // `codegen/runtime/`, laid out exactly as the package's own `src/` so every relative import
12
+ // in it resolves unchanged. Package imports are left as they are: what the closure reaches
13
+ // outside the package is `@metaobjectsdev/metadata` (core: the filter-op vocabulary and the
14
+ // canonical serializer) and third-party libraries (drizzle-orm, fastify, hono, zod, qs).
15
+ //
16
+ // The copies are VERBATIM. That is what makes "is my copy still the package's?" a byte
17
+ // comparison and "what did upstream change?" a plain `diff -r` against the installed
18
+ // package's `src/`, with no header to strip first.
19
+
20
+ import { createRequire } from "node:module";
21
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
22
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
23
+ import { dirname, join, relative, sep } from "node:path";
24
+ import {
25
+ HTTP_RUNTIME_MODULES,
26
+ HTTP_RUNTIME_PACKAGE,
27
+ OWNED_RUNTIME_DIR,
28
+ type HttpRuntimeModule,
29
+ } from "@metaobjectsdev/codegen-ts";
30
+ import { compareOwnedCopy, type OwnedComparison } from "./owned-copy.js";
31
+ import { cliVersion } from "./version.js";
32
+
33
+ /**
34
+ * The ejectable generators whose OUTPUT imports the adapter tier, and the adapter modules
35
+ * it imports. `entity` is here for its allowlist TYPES only, which is one file.
36
+ */
37
+ export const RUNTIME_ENTRIES: Readonly<Record<string, readonly HttpRuntimeModule[]>> = {
38
+ routes: ["drizzle-fastify"],
39
+ "routes-hono": ["hono"],
40
+ entity: ["allowlists"],
41
+ };
42
+
43
+ /** True iff ejecting `name` also copies adapter source. */
44
+ export function ejectsRuntime(name: string): boolean {
45
+ return Object.hasOwn(RUNTIME_ENTRIES, name);
46
+ }
47
+
48
+ interface RuntimePackage {
49
+ /** `<root>/src` — the source the copies are taken from and compared against. */
50
+ srcRoot: string;
51
+ manifest: {
52
+ dependencies?: Record<string, string>;
53
+ peerDependencies?: Record<string, string>;
54
+ devDependencies?: Record<string, string>;
55
+ };
56
+ }
57
+
58
+ /**
59
+ * The installed `@metaobjectsdev/runtime-ts`: its `src/` and its manifest.
60
+ *
61
+ * Resolves the package ENTRY and walks up to the manifest that names it, never
62
+ * `"<pkg>/package.json"`: the exports map does not export that subpath, and under real Node
63
+ * asking for it throws ERR_PACKAGE_PATH_NOT_EXPORTED (see `install-set.ts`). `src/` ships
64
+ * in the published package (`files: ["dist", "src", ...]`).
65
+ */
66
+ export function resolveRuntimePackage(): RuntimePackage {
67
+ const req = createRequire(import.meta.url);
68
+ let dir = dirname(req.resolve(HTTP_RUNTIME_PACKAGE));
69
+ for (let hops = 0; hops < 8; hops++) {
70
+ const candidate = join(dir, "package.json");
71
+ if (existsSync(candidate)) {
72
+ const manifest = JSON.parse(readFileSync(candidate, "utf8")) as RuntimePackage["manifest"] & { name?: string };
73
+ if (manifest.name === HTTP_RUNTIME_PACKAGE) {
74
+ const srcRoot = join(dir, "src");
75
+ if (!existsSync(srcRoot)) {
76
+ throw new Error(`${HTTP_RUNTIME_PACKAGE} at ${dir} ships no src/ — cannot copy the adapter source.`);
77
+ }
78
+ return { srcRoot, manifest };
79
+ }
80
+ }
81
+ const up = dirname(dir);
82
+ if (up === dir) break;
83
+ dir = up;
84
+ }
85
+ throw new Error(`could not locate the installed ${HTTP_RUNTIME_PACKAGE} package.`);
86
+ }
87
+
88
+ /** Relative (`./`, `../`) and bare module specifiers a source file imports or re-exports. */
89
+ function specifiersOf(source: string): string[] {
90
+ const out: string[] = [];
91
+ // `import … from "x"` and the re-export forms (`export { … } from`, `export * from`) only —
92
+ // a bare `export function f() { return Array.from("…") }` must not read as an import.
93
+ const STATEMENT =
94
+ /(?:^|\n)\s*(?:import\s+[^;"']*?\s+from|export\s+(?:type\s+)?(?:\*(?:\s+as\s+\w+)?|\{[^}]*\})\s*from)\s*["']([^"']+)["']/g;
95
+ for (const m of source.matchAll(STATEMENT)) {
96
+ if (m[1] !== undefined) out.push(m[1]);
97
+ }
98
+ for (const m of source.matchAll(/(?:^|\n)\s*import\s*["']([^"']+)["']/g)) {
99
+ if (m[1] !== undefined) out.push(m[1]);
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** `@scope/name/sub` → `@scope/name`; `name/sub` → `name`. */
105
+ function packageNameOf(spec: string): string {
106
+ const parts = spec.split("/");
107
+ return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]!;
108
+ }
109
+
110
+ function toPosix(p: string): string {
111
+ return p.split(sep).join("/");
112
+ }
113
+
114
+ export interface RuntimeClosure {
115
+ /** Source files, POSIX paths relative to the package's `src/`, sorted. */
116
+ files: string[];
117
+ /** Packages the closure imports (never `node:` builtins), sorted. */
118
+ packages: string[];
119
+ }
120
+
121
+ /** Every file the given adapter modules reach through relative imports, and what else they import. */
122
+ export function runtimeClosure(srcRoot: string, modules: readonly HttpRuntimeModule[]): RuntimeClosure {
123
+ const files = new Set<string>();
124
+ const packages = new Set<string>();
125
+ const queue = modules.map((m) => `${HTTP_RUNTIME_MODULES[m].file}.ts`);
126
+ while (queue.length > 0) {
127
+ const rel = queue.shift()!;
128
+ if (files.has(rel)) continue;
129
+ const abs = join(srcRoot, rel);
130
+ if (!existsSync(abs)) {
131
+ throw new Error(`${HTTP_RUNTIME_PACKAGE}: ${rel} is imported but not shipped in src/.`);
132
+ }
133
+ files.add(rel);
134
+ for (const spec of specifiersOf(readFileSync(abs, "utf8"))) {
135
+ if (spec.startsWith("./") || spec.startsWith("../")) {
136
+ const target = toPosix(join(dirname(rel), spec)).replace(/\.js$/, ".ts");
137
+ queue.push(target.endsWith(".ts") ? target : `${target}.ts`);
138
+ } else if (!spec.startsWith("node:")) {
139
+ packages.add(packageNameOf(spec));
140
+ }
141
+ }
142
+ }
143
+ return { files: [...files].sort(), packages: [...packages].sort() };
144
+ }
145
+
146
+ export type RuntimeFileStatus = "created" | "preserved" | "replaced";
147
+
148
+ export interface RuntimeFileResult {
149
+ /** Project-relative path of the copy. */
150
+ path: string;
151
+ status: RuntimeFileStatus;
152
+ /** How the copy ALREADY on disk compared to the package's source. Absent when there was none. */
153
+ comparison?: OwnedComparison | undefined;
154
+ }
155
+
156
+ export interface RuntimeEjectResult {
157
+ files: RuntimeFileResult[];
158
+ /** Packages the copied source imports — what the adopter now installs directly. */
159
+ packages: string[];
160
+ }
161
+
162
+ /**
163
+ * Copy the adapter source `names` emit imports of into `<cwd>/codegen/runtime/`.
164
+ * Never overwrites a file without `force`, the same rule the generator copy keeps.
165
+ */
166
+ export async function ejectRuntime(cwd: string, names: readonly string[], force: boolean): Promise<RuntimeEjectResult> {
167
+ const modules = [...new Set(names.flatMap((n) => RUNTIME_ENTRIES[n] ?? []))];
168
+ if (modules.length === 0) return { files: [], packages: [] };
169
+ const { srcRoot } = resolveRuntimePackage();
170
+ const closure = runtimeClosure(srcRoot, modules);
171
+ const files: RuntimeFileResult[] = [];
172
+ for (const rel of closure.files) {
173
+ const source = await readFile(join(srcRoot, rel), "utf8");
174
+ const path = `${OWNED_RUNTIME_DIR}/${rel}`;
175
+ const abs = join(cwd, path);
176
+ const existing = existsSync(abs) ? await readFile(abs, "utf8") : undefined;
177
+ const comparison = existing === undefined ? undefined : await compareOwnedCopy(existing, source);
178
+ if (existing !== undefined && !force) {
179
+ files.push({ path, status: "preserved", comparison });
180
+ continue;
181
+ }
182
+ await mkdir(dirname(abs), { recursive: true });
183
+ await writeFile(abs, source, "utf8");
184
+ files.push({ path, status: existing === undefined ? "created" : "replaced", comparison });
185
+ }
186
+ return { files, packages: closure.packages };
187
+ }
188
+
189
+ /**
190
+ * The install lines the copied source needs, with ranges read from the runtime package's
191
+ * own manifest (never restated here). A `@metaobjectsdev/*` package moves with the CLI.
192
+ * A package that ships no types of its own gets its `@types/*` sibling as a dev dependency
193
+ * when the runtime package itself develops against one.
194
+ */
195
+ export function runtimeInstall(packages: readonly string[]): {
196
+ runtime: Map<string, string | undefined>;
197
+ dev: Map<string, string | undefined>;
198
+ } {
199
+ const { manifest } = resolveRuntimePackage();
200
+ const known = { ...manifest.peerDependencies, ...manifest.dependencies };
201
+ const runtime = new Map<string, string | undefined>();
202
+ const dev = new Map<string, string | undefined>();
203
+ for (const p of packages) {
204
+ runtime.set(p, p.startsWith("@metaobjectsdev/") ? `^${cliVersion()}` : known[p]);
205
+ const typesPkg = `@types/${p.replace(/^@/, "").replace("/", "__")}`;
206
+ const typesRange = manifest.devDependencies?.[typesPkg];
207
+ if (typesRange !== undefined) dev.set(typesPkg, typesRange);
208
+ }
209
+ return { runtime, dev };
210
+ }
211
+
212
+ /** One owned adapter file, compared to the package it came from. */
213
+ export interface RuntimeCopyRow {
214
+ path: string;
215
+ verdict: OwnedComparison["verdict"] | "not-in-package";
216
+ localOnly: number;
217
+ referenceOnly: number;
218
+ }
219
+
220
+ function listTs(dir: string): string[] {
221
+ const out: string[] = [];
222
+ for (const entry of readdirSync(dir)) {
223
+ const abs = join(dir, entry);
224
+ if (statSync(abs).isDirectory()) out.push(...listTs(abs));
225
+ else if (entry.endsWith(".ts")) out.push(abs);
226
+ }
227
+ return out;
228
+ }
229
+
230
+ /**
231
+ * Every file under `<cwd>/codegen/runtime/`, compared to the installed package's source.
232
+ * Empty when the project owns no adapter copy. `not-in-package` is a file the package does
233
+ * not ship — your own addition, or one upstream deleted or renamed.
234
+ */
235
+ export async function runtimeCopyStatus(cwd: string): Promise<RuntimeCopyRow[]> {
236
+ const root = join(cwd, OWNED_RUNTIME_DIR);
237
+ if (!existsSync(root)) return [];
238
+ let srcRoot: string;
239
+ try {
240
+ srcRoot = resolveRuntimePackage().srcRoot;
241
+ } catch {
242
+ return [];
243
+ }
244
+ const rows: RuntimeCopyRow[] = [];
245
+ for (const abs of listTs(root).sort()) {
246
+ const rel = toPosix(relative(root, abs));
247
+ const path = `${OWNED_RUNTIME_DIR}/${rel}`;
248
+ const upstream = join(srcRoot, rel);
249
+ if (!existsSync(upstream)) {
250
+ rows.push({ path, verdict: "not-in-package", localOnly: 0, referenceOnly: 0 });
251
+ continue;
252
+ }
253
+ const cmp = await compareOwnedCopy(readFileSync(abs, "utf8"), readFileSync(upstream, "utf8"));
254
+ rows.push({ path, verdict: cmp.verdict, localOnly: cmp.localOnly, referenceOnly: cmp.referenceOnly });
255
+ }
256
+ return rows;
257
+ }
258
+
259
+ /** The command that shows exactly how one owned file differs from the installed package's. */
260
+ export function runtimeDiffCommand(path: string): string {
261
+ const rel = path.startsWith(`${OWNED_RUNTIME_DIR}/`) ? path.slice(OWNED_RUNTIME_DIR.length + 1) : path;
262
+ return `diff -u node_modules/${HTTP_RUNTIME_PACKAGE}/src/${rel} ${OWNED_RUNTIME_DIR}/${rel}`;
263
+ }