Skip to content

Commit eb96780

Browse files
dmealingclaude
andcommitted
fix(cli): the peer-range read threw on a real install, and the fallback hid it
`604eb348a` had `meta init` declare what `meta gen`'s output imports, reading the version ranges from `@metaobjectsdev/runtime-ts`'s own peerDependencies. Against the published rc.4 it declared ONE of the four packages and reported success. The cold quickstart still ended in six TS2307s. Two defects, and the second is the one worth remembering. **The resolve was wrong.** `require.resolve("@metaobjectsdev/runtime-ts/package.json")` is gated by that package's `exports` map, which is `.` / `./drivers` / `./fastify` / `./drizzle-fastify` / `./hono` — no `./package.json`. Under real Node it throws ERR_PACKAGE_PATH_NOT_EXPORTED. It resolves the ENTRY now and walks up to the nearest manifest bearing the right `name`: `.` is the one subpath every package exports, so that form works under npm, pnpm, bun and a linked checkout alike. Verified against the published rc.4 tree — it reads all five declared peer ranges. **The fallback was silent, which is why the workspace could not see it.** `catch { return {} }` turned a hard failure into "declare fewer packages", and the run looked successful. `meta init` now says outright which packages it could NOT declare and why. A fallback nobody can observe is indistinguishable from a bug — this one WAS the bug, for one release. Why every existing test passed: the workspace runs the CLI under bun against a symlinked source tree, where the subpath resolves. Only a real external install has the exports map in force. That is the same blindness as the peer-range gate's ("a workspace cannot see a peer range") and it is why `scaffold-output-imports-declared` — which asserts exactly the right thing — was green while the published CLI was broken. The new gate needs neither an install nor a runtime, so it does not share that blindness: for every `resolve("<pkg>/package.json")` in the CLI's source it reads that package's own manifest and requires it to have no exports map, or to export `./package.json`. Proven by restoring the old call — it fails and names the defect. Its second test asserts runtime-ts really does withhold `./package.json`, so the rule cannot quietly become vacuous. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3v6M6uqECZ2Sb5eUv6YY
1 parent 7706b63 commit eb96780

2 files changed

Lines changed: 126 additions & 4 deletions

File tree

‎server/typescript/packages/cli/src/commands/init.ts‎

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,22 @@ async function ensureEsmPackageType(cwd: string, result: InitResult): Promise<vo
807807
"installed. Run your package manager's install before `meta gen`.",
808808
);
809809
}
810+
// Never degrade in silence. The first version of this returned `{}` on any failure to
811+
// read runtime-ts's peer ranges and skipped the three packages that come from them —
812+
// which is exactly what happened against the published package, and the run looked
813+
// successful while leaving the project with the six TS2307s this code exists to
814+
// prevent. A fallback that cannot be observed is indistinguishable from a bug.
815+
const undeclarable = SCAFFOLD_OUTPUT_PEERS.filter(
816+
(name) => !declaredDependencyNames(pkg as PackageManifest).has(name),
817+
);
818+
if (undeclarable.length > 0) {
819+
result.warnings.push(
820+
`could NOT declare ${undeclarable.join(" + ")} — the version ranges are read from ` +
821+
"@metaobjectsdev/runtime-ts's own peerDependencies and that manifest could not be " +
822+
"read. Generated code imports them, so add them to dependencies by hand or " +
823+
"`npx tsc` will report TS2307 on files `meta gen` writes.",
824+
);
825+
}
810826
}
811827

812828
/**
@@ -904,13 +920,39 @@ function scaffoldRuntimeDependencies(): Record<string, string> {
904920
return wanted;
905921
}
906922

907-
/** `@metaobjectsdev/runtime-ts`'s declared peer ranges, or `{}` if it cannot be read. */
923+
/**
924+
* `@metaobjectsdev/runtime-ts`'s declared peer ranges, or `{}` if they cannot be read.
925+
*
926+
* Resolves the package's ENTRY and walks up to the nearest package.json, rather than
927+
* asking for `"@metaobjectsdev/runtime-ts/package.json"` directly. That direct form is
928+
* the obvious one and it is WRONG: a package's `exports` map gates every subpath, and
929+
* runtime-ts's exports are `.` / `./drivers` / `./fastify` / `./drizzle-fastify` /
930+
* `./hono` — no `./package.json`. Under real Node it throws
931+
* ERR_PACKAGE_PATH_NOT_EXPORTED. It appeared to work here only because the workspace
932+
* runs this under bun against a symlinked source tree; against the published package it
933+
* failed on the first try, silently, and `meta init` declared one package instead of
934+
* four. Resolving the entry is never gated — `.` is the one subpath every package
935+
* exports — so this form works under npm, pnpm, bun and a linked checkout alike.
936+
*/
908937
function runtimeTsPeerRanges(): Record<string, string> {
909938
try {
910939
const req = createRequire(import.meta.url);
911-
const manifestPath = req.resolve("@metaobjectsdev/runtime-ts/package.json");
912-
const manifest = JSON.parse(readFileSyncWrap(manifestPath, "utf8")) as PackageManifest;
913-
return (manifest.peerDependencies ?? {}) as Record<string, string>;
940+
let dir = dirname(req.resolve("@metaobjectsdev/runtime-ts"));
941+
// The entry sits under dist/; the manifest is at the package root above it.
942+
for (let hops = 0; hops < 8; hops++) {
943+
const candidate = join(dir, "package.json");
944+
if (existsSyncWrap(candidate)) {
945+
const manifest = JSON.parse(readFileSyncWrap(candidate, "utf8")) as PackageManifest & { name?: string };
946+
// Guard against stopping at a nested manifest that is not the package itself.
947+
if (manifest.name === "@metaobjectsdev/runtime-ts") {
948+
return (manifest.peerDependencies ?? {}) as Record<string, string>;
949+
}
950+
}
951+
const up = dirname(dir);
952+
if (up === dir) break;
953+
dir = up;
954+
}
955+
return {};
914956
} catch {
915957
return {};
916958
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// A `require.resolve("<pkg>/package.json")` only works if that package EXPORTS the
2+
// subpath. Most of ours do not.
3+
//
4+
// `meta init` reads `@metaobjectsdev/runtime-ts`'s peerDependencies to learn the version
5+
// ranges it declares for the adopter, and asked for them with
6+
// `resolve("@metaobjectsdev/runtime-ts/package.json")`. runtime-ts's exports map is
7+
// `.` / `./drivers` / `./fastify` / `./drizzle-fastify` / `./hono` — no `./package.json` —
8+
// so under real Node that throws ERR_PACKAGE_PATH_NOT_EXPORTED. The call site caught the
9+
// throw and returned `{}`, so `meta init` declared ONE package instead of four and said
10+
// nothing, and the project it had just scaffolded failed `npx tsc` with six TS2307s.
11+
//
12+
// It passed every test in this package. The workspace runs the CLI under bun against a
13+
// symlinked source tree, where the subpath resolves; only a real external install has the
14+
// exports map in force. The release smoke test caught it — after publishing rc.4.
15+
//
16+
// This check needs no install and no runtime: for each `resolve("X/package.json")` in the
17+
// CLI's source, read X's own manifest and require that it either has no `exports` map
18+
// (all subpaths open) or explicitly exports `./package.json`. Resolving a package's ENTRY
19+
// is always allowed, so walking up from there is the portable form.
20+
import { describe, test, expect } from "bun:test";
21+
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
22+
import { join } from "node:path";
23+
24+
const SRC = join(import.meta.dirname, "..", "src");
25+
26+
function tsFiles(dir: string): string[] {
27+
return readdirSync(dir).flatMap((e) => {
28+
const full = join(dir, e);
29+
if (statSync(full).isDirectory()) return tsFiles(full);
30+
return full.endsWith(".ts") ? [full] : [];
31+
});
32+
}
33+
34+
/** The package this repo publishes under `name`, or undefined if it is not ours. */
35+
function workspaceManifest(name: string): Record<string, unknown> | undefined {
36+
const short = name.replace("@metaobjectsdev/", "");
37+
for (const root of ["server/typescript/packages", "client/web/packages"]) {
38+
const p = join(import.meta.dirname, "..", "..", "..", "..", "..", root, short, "package.json");
39+
if (existsSync(p)) return JSON.parse(readFileSync(p, "utf8")) as Record<string, unknown>;
40+
}
41+
return undefined;
42+
}
43+
44+
describe("the CLI never resolves a package.json subpath a package does not export", () => {
45+
test("every resolve(\"<pkg>/package.json\") names a package that exports it", () => {
46+
const offenders: string[] = [];
47+
for (const file of tsFiles(SRC)) {
48+
const source = readFileSync(file, "utf8");
49+
for (const m of source.matchAll(/resolve\(\s*["'`]([^"'`]+)\/package\.json["'`]/g)) {
50+
const pkg = m[1];
51+
if (pkg === undefined || pkg.startsWith(".")) continue; // a path, not a specifier
52+
const manifest = workspaceManifest(pkg);
53+
// Not one of ours: we cannot read its exports map here, and asking for a
54+
// third party's package.json is the same gamble. Flag it either way.
55+
if (manifest === undefined) {
56+
offenders.push(`${file.replace(SRC, "src")}: ${pkg} (not a workspace package — exports unknown)`);
57+
continue;
58+
}
59+
const exportsMap = manifest.exports as Record<string, unknown> | undefined;
60+
if (exportsMap !== undefined && !("./package.json" in exportsMap)) {
61+
offenders.push(
62+
`${file.replace(SRC, "src")}: ${pkg} has an exports map without "./package.json" — ` +
63+
"this throws ERR_PACKAGE_PATH_NOT_EXPORTED on a real install",
64+
);
65+
}
66+
}
67+
}
68+
expect(offenders).toEqual([]);
69+
});
70+
71+
test("runtime-ts really does withhold ./package.json, so the rule above is not vacuous", () => {
72+
// If runtime-ts ever exports it, this test says so rather than quietly becoming a
73+
// check with nothing left to check.
74+
const manifest = workspaceManifest("@metaobjectsdev/runtime-ts");
75+
expect(manifest).toBeDefined();
76+
const exportsMap = manifest?.exports as Record<string, unknown> | undefined;
77+
expect(exportsMap).toBeDefined();
78+
expect(Object.keys(exportsMap ?? {})).not.toContain("./package.json");
79+
});
80+
});

0 commit comments

Comments
 (0)