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:
@@ -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();
|
||||
Reference in New Issue
Block a user