fix(plugins): route bundled pi-* subpath imports through the virtual registry

Compiled-binary extension validation rewrote @(scope)/pi-ai/oauth →
@oh-my-pi/pi-ai/oauth, but LEGACY_PI_PACKAGE_ROOT_OVERRIDES only
covered bare package roots. resolveCanonicalPiSpecifier therefore
fell through to Bun.resolveSync, which fails inside bunfs on Bun
1.3.14+, then the rewriteLegacyPiImports catch left the original
specifier alone. Bun's native resolver then failed because most
plugins (e.g. @charmland/pi-hyper-provider) declare @(scope)/pi-ai
as a peerDependency only and never materialize a real install.

A new scripts/generate-legacy-pi-bundled-registry.ts reads every
bundled pi-* package's non-wildcard exports field and emits both
the heavy legacy-pi-bundled-registry.ts (static imports + map) and
a light legacy-pi-bundled-keys.ts. legacy-pi-compat.ts statically
imports the keys file to seed the override map without paying the
legacy-pi-coding-agent-shim → ../index → export/html/... cascade,
so subpath imports now route to the same omp-legacy-pi-bundled:
virtual namespace that already serves the roots.
scripts/build-binary.ts runs the generator before bun build
--compile so new pi-* subpaths added under packages/*/package.json
ship without manual regeneration; --check verifies the committed
output stays in sync.

Fixes #3442
This commit is contained in:
roboomp
2026-06-25 05:52:38 +00:00
parent b2b818af6f
commit aa29b79642
7 changed files with 669 additions and 32 deletions
@@ -58,6 +58,15 @@ async function main(): Promise<void> {
: Bun.env,
);
await runCommand(["bun", "scripts/embed-mupdf-wasm.ts", "--generate"]);
// Regenerate the bundled-pi registry + key set before the compile so any
// new pi-* subpath export added under `packages/*/package.json` is served
// from the host's in-process copy. Without this, `bun build --compile`
// would freeze whatever the committed registry happened to enumerate at
// the time of the last manual `--generate`, and a new subpath added
// since then would crash extension validation with `Cannot find module`
// (issue #3442). The generator also normalizes formatting, so the diff
// against the committed copy stays clean.
await runCommand(["bun", "scripts/generate-legacy-pi-bundled-registry.ts", "--generate"]);
try {
const buildEnv = shouldAdhocSignDarwinBinary() ? { ...Bun.env, BUN_NO_CODESIGN_MACHO_BINARY: "1" } : Bun.env;
await runCommand(
@@ -0,0 +1,308 @@
#!/usr/bin/env bun
/**
* Generate the bundled-pi registry + key set served by the compiled binary's
* `omp-legacy-pi-bundled:` virtual namespace.
*
* Compiled-mode extension validation routes every `@(scope)/pi-*` import
* through this registry — bunfs filesystem APIs are unreachable on Bun 1.3.14+
* (issue #3423), so the binary serves bundled module surfaces from JS-heap
* references captured at build time. Bare package roots and every
* `non-wildcard` subpath export declared in each bundled pi-* package.json
* become a registry entry; wildcard subpath patterns are intentionally
* unbundled — those resolve from the extension's own peer deps as before.
*
* The generator emits two files:
* - `legacy-pi-bundled-registry.ts` (heavy): static imports of every
* subpath module + the `BUNDLED_PI_REGISTRY` map. Dynamically loaded by
* `legacy-pi-compat.ts` so dev/test runs never pay the cascade.
* - `legacy-pi-bundled-keys.ts` (light): just the canonical-key set.
* Statically imported by `legacy-pi-compat.ts` to seed
* `LEGACY_PI_PACKAGE_ROOT_OVERRIDES` without touching the heavy graph.
*
* Run via `bun scripts/generate-legacy-pi-bundled-registry.ts --generate`
* (also invoked from `scripts/build-binary.ts` before `bun build --compile`).
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
const packageDir = path.resolve(import.meta.dir, "..");
const repoRoot = path.resolve(packageDir, "..", "..");
const registryOutPath = path.join(packageDir, "src/extensibility/plugins/legacy-pi-bundled-registry.ts");
const keysOutPath = path.join(packageDir, "src/extensibility/plugins/legacy-pi-bundled-keys.ts");
const GENERATE_FLAG = "--generate";
const CHECK_FLAG = "--check";
interface BundledPackage {
readonly dir: string;
readonly name: string;
/** Identifier prefix for generated namespace imports (`PiAi`, `PiCodingAgent`, …). */
readonly identifier: string;
/** Root import — the shim path for surfaces that wrap the bundled namespace, `null` otherwise. */
readonly rootShim: string | null;
}
const PACKAGES: readonly BundledPackage[] = [
{ dir: "packages/agent", name: "@oh-my-pi/pi-agent-core", identifier: "PiAgentCore", rootShim: null },
{
dir: "packages/ai",
name: "@oh-my-pi/pi-ai",
identifier: "PiAi",
// pi-ai 15.1.0 dropped the runtime `Type` builder from the package root;
// the shim re-attaches it for extensions that still import `Type` from
// `@(scope)/pi-ai`. Subpaths bypass the shim — they're untouched by the
// schema-runtime split.
rootShim: "../legacy-pi-ai-shim",
},
{
dir: "packages/coding-agent",
name: "@oh-my-pi/pi-coding-agent",
identifier: "PiCodingAgent",
// pi-coding-agent root carries legacy helpers (`defineTool`,
// `createCodingTools`, …) the canonical entry never exposed; the shim
// re-exports the canonical surface plus those helpers.
rootShim: "../legacy-pi-coding-agent-shim",
},
{ dir: "packages/natives", name: "@oh-my-pi/pi-natives", identifier: "PiNatives", rootShim: null },
{ dir: "packages/tui", name: "@oh-my-pi/pi-tui", identifier: "PiTui", rootShim: null },
{ dir: "packages/utils", name: "@oh-my-pi/pi-utils", identifier: "PiUtils", rootShim: null },
];
// `typebox` is published under an upstream alias; legacy extensions import the
// bare name expecting the host-provided Zod-backed shim. Tracked alongside the
// pi-* surfaces so the override map and synthesizer cover it uniformly.
const TYPEBOX_REGISTRY_KEY = "typebox";
const TYPEBOX_SHIM_IMPORT = "../typebox";
interface RegistryEntry {
/** Canonical registry key, e.g. `@oh-my-pi/pi-ai/oauth`. */
readonly key: string;
/** Identifier bound in the generated module's static import. */
readonly binding: string;
/**
* ES module specifier the generated file imports. For bundled subpaths this
* is the canonical `@oh-my-pi/<pkg>/<subpath>` string Bun resolves via the
* package's exports field; for shimmed surfaces (root of pi-ai / pi-coding-agent
* / typebox) it's the relative path to the in-tree shim.
*/
readonly importSpecifier: string;
}
function bindingForSubpath(identifier: string, subpath: string): string {
const segments = subpath
.split("/")
.filter(Boolean)
.map(segment =>
segment
.split(/[-_]/)
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(""),
);
return `bundled${identifier}${segments.join("")}`;
}
async function collectEntries(): Promise<RegistryEntry[]> {
const entries: RegistryEntry[] = [];
for (const pkg of PACKAGES) {
const manifestPath = path.join(repoRoot, pkg.dir, "package.json");
const manifest = (await Bun.file(manifestPath).json()) as { name?: string; exports?: Record<string, unknown> };
if (manifest.name !== pkg.name) {
throw new Error(
`generate-legacy-pi-bundled-registry: package.json at ${manifestPath} declares "${manifest.name}", expected "${pkg.name}"`,
);
}
const exportsField = manifest.exports ?? {};
// Root: shim if one is declared, otherwise the canonical package.
entries.push({
key: pkg.name,
binding: `bundled${pkg.identifier}`,
importSpecifier: pkg.rootShim ?? pkg.name,
});
// Each non-wildcard subpath export becomes its own registry key.
for (const exportKey in exportsField) {
if (!exportKey.startsWith("./") || exportKey === "." || exportKey.includes("*")) continue;
const subpath = exportKey.slice(2);
entries.push({
key: `${pkg.name}/${subpath}`,
binding: bindingForSubpath(pkg.identifier, subpath),
importSpecifier: `${pkg.name}/${subpath}`,
});
}
}
entries.push({
key: TYPEBOX_REGISTRY_KEY,
binding: "bundledTypeBoxShim",
importSpecifier: TYPEBOX_SHIM_IMPORT,
});
const seen = new Set<string>();
for (const entry of entries) {
if (seen.has(entry.key)) {
throw new Error(`generate-legacy-pi-bundled-registry: duplicate registry key ${entry.key}`);
}
seen.add(entry.key);
if (seen.has(entry.binding)) {
throw new Error(`generate-legacy-pi-bundled-registry: duplicate binding ${entry.binding}`);
}
seen.add(entry.binding);
}
return entries;
}
function renderRegistry(entries: readonly RegistryEntry[]): string {
const importLines = entries.map(
entry => `import * as ${entry.binding} from ${JSON.stringify(entry.importSpecifier)};`,
);
const registryLines = entries.map(
entry => `\t${JSON.stringify(entry.key)}: ${entry.binding} as unknown as Readonly<Record<string, unknown>>,`,
);
return [
"// AUTO-GENERATED by scripts/generate-legacy-pi-bundled-registry.ts.",
"// Do not edit by hand — run `bun scripts/generate-legacy-pi-bundled-registry.ts --generate`.",
"/**",
" * Static handles on every bundled `@oh-my-pi/pi-*` surface — package",
" * roots plus every non-wildcard subpath export declared in each package's",
" * `exports` field. Loaded lazily by `legacy-pi-compat.ts` in compiled-binary",
" * mode (issue #3423) and re-exported through the `omp-legacy-pi-bundled:`",
" * virtual namespace — bunfs paths cannot be resolved at runtime on Bun",
" * 1.3.14+, so the only way to re-route extension imports onto the host's",
" * in-process copy is via live module references captured at compile time.",
" *",
" * This module is split out from `legacy-pi-compat.ts` so dev/test runs that",
" * touch the compat layer never trigger the cascade through",
" * `legacy-pi-coding-agent-shim.ts → ../index → export/html/...` (which",
" * requires generated artifacts that only exist after a `bun run build`).",
" *",
" * The bundler reaches every entry below via standard static-import analysis,",
" * so no `--compile` extras are required in `scripts/build-binary.ts`.",
" */",
...importLines,
"",
"/**",
" * Canonical specifier → live module namespace. Keys MUST match the right-hand",
" * side of `bundledRegistryVirtualSpecifier(...)` calls in",
" * `legacy-pi-compat.ts`; the synthesizer enumerates each namespace's own",
" * enumerable exports at extension load time. The companion",
" * `legacy-pi-bundled-keys.ts` mirrors `Object.keys(BUNDLED_PI_REGISTRY)` and",
" * is statically imported by `legacy-pi-compat.ts` to seed the override map",
" * without paying the cascade above.",
" */",
"export const BUNDLED_PI_REGISTRY: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {",
...registryLines,
"};",
"",
].join("\n");
}
function renderKeys(entries: readonly RegistryEntry[]): string {
const keyLines = entries.map(entry => `\t${JSON.stringify(entry.key)},`);
return [
"// AUTO-GENERATED by scripts/generate-legacy-pi-bundled-registry.ts.",
"// Do not edit by hand — run `bun scripts/generate-legacy-pi-bundled-registry.ts --generate`.",
"/**",
" * Canonical keys served by the `omp-legacy-pi-bundled:` virtual namespace.",
" * Mirrors `Object.keys(BUNDLED_PI_REGISTRY)` from",
" * `legacy-pi-bundled-keys.ts`'s sibling registry file. `legacy-pi-compat.ts`",
" * statically imports this set to seed `LEGACY_PI_PACKAGE_ROOT_OVERRIDES` in",
" * compiled-binary mode without dragging the heavy registry's transitive",
" * graph into dev/test runs (the registry itself stays behind a dynamic",
" * import — see `ensureBundledRegistryLoaded` in `legacy-pi-compat.ts`).",
" */",
"export const BUNDLED_PI_REGISTRY_KEYS: ReadonlySet<string> = new Set([",
...keyLines,
"]);",
"",
].join("\n");
}
async function formatInPlace(targets: readonly string[]): Promise<void> {
// `biome check --write` runs the formatter AND the assist's organizeImports
// pass; `biome format --write` alone leaves the import order untouched.
const proc = Bun.spawn(["bunx", "biome", "check", "--write", ...targets], {
cwd: packageDir,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
const exit = await proc.exited;
if (exit !== 0) {
const stderr = await new Response(proc.stderr).text();
throw new Error(`biome check --write failed (exit ${exit}): ${stderr}`);
}
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
const check = args.includes(CHECK_FLAG);
if (!check && !args.includes(GENERATE_FLAG)) {
console.log(
`Skipping bundled-pi registry generation; pass ${GENERATE_FLAG} to write the output files (or ${CHECK_FLAG} to verify the committed copy).`,
);
return;
}
const entries = await collectEntries();
const registrySource = renderRegistry(entries);
const keysSource = renderKeys(entries);
if (check) {
// biome ignores paths outside its `includes` glob (the `*.ts.candidate`
// suffix above would be rejected with "No files were processed"), so
// write the candidates into a sibling `.<name>.tmp/` directory whose
// basenames match the committed copies. The directory lives under the
// same package so biome's repo-relative `includes` still cover it.
const tmpDir = path.join(packageDir, "src/extensibility/plugins/.legacy-pi-bundled-candidates");
const tmpRegistry = path.join(tmpDir, path.basename(registryOutPath));
const tmpKeys = path.join(tmpDir, path.basename(keysOutPath));
try {
await Bun.write(tmpRegistry, registrySource);
await Bun.write(tmpKeys, keysSource);
await formatInPlace([tmpRegistry, tmpKeys]);
const drift: string[] = [];
const pairs: readonly (readonly [string, string])[] = [
[registryOutPath, tmpRegistry],
[keysOutPath, tmpKeys],
];
for (const [committedPath, candidatePath] of pairs) {
let committed: string;
try {
committed = await Bun.file(committedPath).text();
} catch {
committed = "";
}
const candidate = await Bun.file(candidatePath).text();
if (committed !== candidate) {
drift.push(path.relative(repoRoot, committedPath));
}
}
if (drift.length > 0) {
console.error(
`generate-legacy-pi-bundled-registry: stale output — rerun with ${GENERATE_FLAG}. Files out of sync:\n ${drift.join("\n ")}`,
);
process.exit(1);
}
console.log("generate-legacy-pi-bundled-registry: OK");
} finally {
// `fs.rm` recursively handles both files and the parent tmp dir; the
// individual file deletes the previous draft used `Bun.file().delete()`
// for would leave the empty directory behind.
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
}
return;
}
await Bun.write(registryOutPath, registrySource);
await Bun.write(keysOutPath, keysSource);
// Hand-emitted formatting can't perfectly match biome's organizeImports +
// long-line wrapping (lineWidth 120 + 60+ entries with long keys/bindings),
// so let biome rewrite the files in place. The committed output then matches
// what `bun check` enforces, and `--check` confirms zero drift on CI.
await formatInPlace([registryOutPath, keysOutPath]);
console.log(
`Generated ${path.relative(repoRoot, registryOutPath)} and ${path.relative(repoRoot, keysOutPath)} (${entries.length} entries)`,
);
}
await main();