refactor(coding-agent): restructured plugin system and build logic

- Migrated legacy static bundled-module registry to a dynamic Bun-based in-memory plugin system.
- Removed over 4,000 lines of manually maintained registry files to reduce maintenance overhead.
- Implemented CJS absolute path rewriting and native-addon load hooks to improve FFI dependency resolution.
- Standardized binary build processes by centralizing compilation logic into shared utilities.
This commit is contained in:
can1357
2026-07-11 07:24:47 +02:00
parent ea632a518b
commit 33c161d9dd
20 changed files with 761 additions and 5332 deletions
-2
View File
@@ -181,8 +181,6 @@
"gen:models": "bun --cwd=packages/catalog run gen:models",
"gen:stats": "bun --cwd=packages/stats run gen:stats",
"gen:stats:reset": "bun --cwd=packages/stats run gen:stats:reset",
"gen:docs": "bun --cwd=packages/coding-agent run gen:docs",
"gen:docs:reset": "bun --cwd=packages/coding-agent run gen:docs:reset",
"gen:changelog": "bun scripts/rewrite-changelog.ts",
"gen:tool-views": "bun --cwd=packages/collab-web run gen:tool-views",
"gen:bundle": "bun --cwd=packages/coding-agent run gen:bundle",
+3
View File
@@ -20,12 +20,15 @@
- Clarified that prerequisite work for subagent tasks should be handled inline by the main agent
- HTML search requests now use a randomized, internally consistent desktop Chrome profile per request; Google, Ecosia, and Mojeek try fetch first and escalate blocked or failed production responses to the shared stealth browser.
- Reordered credential-free web search engines from live quality measurements: Startpage (Google-backed, fastest and most reliable in testing) now leads the auto chain, Ecosia precedes the flakier browser-backed Google, and Mojeek stays last; the Public Web fan-out tiebreak now favors Google-index engines (Startpage, Google) so their ranking wins equal-consensus ties.
- Replaced the generated legacy Pi bundled-module registry and duplicate key list with an in-memory Bun build plugin that derives static compile edges directly from current package exports; binary builds no longer generate, format, or reset thousands of lines of compatibility source.
- Replaced the generated `docs-index.generated.txt` file and its surrounding build-time reset orchestration with an in-memory compiler define (`process.env.PI_DOCS_EMBED`); compiled executables and prepacked npm bundles are inlined directly from the source `docs/` Markdown corpus without writing to the working tree.
### Removed
- Removed the bundled `plan` subagent from available task agents
### Fixed
- Fixed compiled-binary extensions failing to load napi-rs/FFI dependencies whose CommonJS loader requires a platform package with a native `.node` main (for example `@yuuang/ffi-rs-darwin-arm64`). The extension loader now resolves those platform packages against the extension's own `node_modules`, pins each require to the addon's absolute path, and serves the rewritten CommonJS loader synchronously so Bun can `require()` it.
- Fixed the `omp search` / `omp q` CLI command hanging instead of exiting cleanly after search execution by ensuring the internally discovered `AuthStorage` connection is properly closed.
- Fixed `write` blocking for the full 3-second LSP diagnostics poll in main-agent sessions by wiring it into the deferred late-diagnostics channel; slow diagnostics now return after the short inline window and arrive as an aside.
- Fixed `tab.fill`/`tab.click` (and every puppeteer Locator action) timing out after 15s on all pages: the stealth patch routes default `Frame.evaluate`/`waitForFunction` through the isolated world, but `waitForSelector`/Locator results were still transferred to the main world, so Locator's enabled-precondition (`handle.frame.waitForFunction(pred, opts, handle)`) and `page.evaluate(fn, handle)` threw a cross-context handle error that Locators retried silently until timeout. `QueryHandler.waitFor` now returns its result in the isolated world, matching the patched default realm; explicit `//!world=main` evaluation still adopts handles via ElementHandle
+2 -6
View File
@@ -32,24 +32,20 @@
},
"scripts": {
"build": "bun scripts/build-binary.ts",
"check": "biome check . && bun run check:docs && bun run check:types",
"check:docs": "bun scripts/generate-docs-index.ts --check",
"check": "biome check . && bun run check:types",
"check:types": "tsgo -p tsconfig.json --noEmit",
"lint": "biome lint .",
"test": "bun ../../scripts/ci-test-ts.ts coding-agent-heavy --full",
"fix": "biome check --write --unsafe . && bun run format-prompts",
"fmt": "biome format --write . && bun run format-prompts",
"format-prompts": "bun scripts/format-prompts.ts",
"gen:docs": "bun scripts/generate-docs-index.ts --generate",
"gen:docs:reset": "bun scripts/generate-docs-index.ts --reset",
"gen:tool-views": "bun --cwd=../collab-web run gen:tool-views",
"gen:bundle": "bun scripts/bundle-dist.ts",
"gen:mupdf": "bun scripts/embed-mupdf-wasm.ts --generate",
"gen:mupdf:reset": "bun scripts/embed-mupdf-wasm.ts --reset",
"gen:native": "bun --cwd=../natives run gen:native",
"gen:native:reset": "bun --cwd=../natives run gen:native:reset",
"prepack": "bun run gen:docs && bun run gen:tool-views && bun run gen:bundle || ( bun run gen:docs:reset; exit 1 )",
"postpack": "bun run gen:docs:reset",
"prepack": "bun run gen:tool-views && bun run gen:bundle",
"bench:guard": "bun scripts/bench-guard.ts"
},
"dependencies": {
+56 -73
View File
@@ -2,17 +2,41 @@
import { createRequire } from "node:module";
import * as path from "node:path";
import { compileCodingAgent } from "./compile-binary";
const packageDir = path.join(import.meta.dir, "..");
const repoRoot = path.join(packageDir, "..", "..");
// Optional cross-compile target, e.g. CROSS_TARGET=linux-arm64 → bun build
// --target=bun-linux-arm64, embeds the matching native, outputs dist/omp-<target>.
const crossTarget = Bun.env.CROSS_TARGET || null;
const [crossPlatform, crossArch] = crossTarget ? crossTarget.split("-") : [null, null];
// x64 uses the baseline bun runtime so it runs under Rosetta / pre-AVX2 CPUs
// (the modern bun-linux-x64 target SIGILLs under Apple-Silicon Rosetta).
const bunTarget = crossTarget ? (crossTarget === "linux-x64" ? "bun-linux-x64-baseline" : `bun-${crossTarget}`) : null;
const outName = crossTarget ? `omp-${crossTarget}` : "omp";
interface CrossBuild {
readonly id: string;
readonly platform: string;
readonly arch: string;
readonly target: Bun.Build.CompileTarget;
}
function resolveCrossBuild(value: string | undefined): CrossBuild | null {
switch (value) {
case undefined:
case "":
return null;
case "darwin-arm64":
return { id: value, platform: "darwin", arch: "arm64", target: "bun-darwin-arm64" };
case "darwin-x64":
return { id: value, platform: "darwin", arch: "x64", target: "bun-darwin-x64" };
case "linux-arm64":
return { id: value, platform: "linux", arch: "arm64", target: "bun-linux-arm64" };
case "linux-x64":
return { id: value, platform: "linux", arch: "x64", target: "bun-linux-x64-baseline" };
case "win32-x64":
case "windows-x64":
return { id: value, platform: "win32", arch: "x64", target: "bun-windows-x64-modern" };
default:
throw new Error(`Unsupported CROSS_TARGET: ${value}`);
}
}
const crossBuild = resolveCrossBuild(Bun.env.CROSS_TARGET);
const outName = crossBuild ? `omp-${crossBuild.id}` : "omp";
const outputPath = path.join(packageDir, "dist", outName);
// Transformers.js is an optional, native-heavy dependency that is never bundled
@@ -20,12 +44,19 @@ const outputPath = path.join(packageDir, "dist", outName);
// on first use. The `catalog:` spec cannot be resolved from inside the compiled
// bunfs (issue #1763), so embed the concrete installed version here for the
// worker to pin its runtime install against.
const transformersVersion = (
createRequire(import.meta.url)("@huggingface/transformers/package.json") as { version: string }
).version;
const transformersManifest: unknown = createRequire(import.meta.url)("@huggingface/transformers/package.json");
if (
typeof transformersManifest !== "object" ||
transformersManifest === null ||
!("version" in transformersManifest) ||
typeof transformersManifest.version !== "string"
) {
throw new Error("@huggingface/transformers package manifest has no string version");
}
const transformersVersion = transformersManifest.version;
function shouldAdhocSignDarwinBinary(): boolean {
return process.platform === "darwin" && !crossTarget;
return process.platform === "darwin" && !crossBuild;
}
async function runCommand(
@@ -50,73 +81,26 @@ async function main(): Promise<void> {
// placeholders (stats client archive, docs index) even on failure.
try {
await runCommand(["bun", "--cwd=../stats", "run", "gen:stats"]);
await runCommand(["bun", "run", "gen:docs"]);
// `legacy-pi-bundled-registry.ts` static-imports
// `@oh-my-pi/pi-coding-agent/export/html` (one of pi-coding-agent's
// named subpath exports, see scripts/generate-legacy-pi-bundled-registry.ts),
// whose source pulls in `tool-views.generated.js`. The root
// `package.json` "prepare" lifecycle hook builds that file on
// `bun install`, but a clean binary build that skips install hooks
// would `bun build --compile` against the registry entry and fail
// resolving the missing generated bundle. Rebuilding the tool views
// here makes the compile self-contained and matches what `prepack`
// does for the npm bundle.
// The in-memory legacy Pi virtual module reaches the coding-agent
// `export/html` subpath, whose source imports `tool-views.generated.js`.
// Rebuild it before compilation so clean checkouts that skipped install
// hooks still contain that generated bundle.
await runCommand(["bun", "--cwd=../collab-web", "run", "gen:tool-views"]);
await runCommand(
["bun", "--cwd=../natives", "run", "gen:native"],
crossTarget
? { ...Bun.env, TARGET_PLATFORM: crossPlatform as string, TARGET_ARCH: crossArch as string }
: Bun.env,
crossBuild ? { ...Bun.env, TARGET_PLATFORM: crossBuild.platform, TARGET_ARCH: crossBuild.arch } : Bun.env,
);
await runCommand(["bun", "run", "gen:mupdf"]);
// 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(
[
"bun",
"build",
"--compile",
...(bunTarget ? ["--target", bunTarget] : []),
"--no-compile-autoload-bunfig",
"--no-compile-autoload-dotenv",
"--no-compile-autoload-tsconfig",
"--no-compile-autoload-package-json",
"--keep-names",
"--define",
'process.env.PI_COMPILED="true"',
"--define",
`process.env.PI_TINY_TRANSFORMERS_VERSION=${JSON.stringify(transformersVersion)}`,
"--external",
"fastembed",
"--external",
"onnxruntime-node",
"--root",
".",
"./packages/coding-agent/src/cli.ts",
// Legacy pi-* extension compat surfaces (host packages + shims)
// were previously listed as explicit `--compile` entries so the
// rewrite path could emit `/$bunfs/root/...` URLs against them.
// Bun 1.3.14 made bunfs files unreachable at runtime (issue
// #3423), so `legacy-pi-compat.ts` now serves them through a
// virtual namespace backed by `legacy-pi-bundled-registry.ts`,
// which static-imports each surface — the bundler already
// includes them via the main module graph, so no `--compile`
// extras are required.
"--outfile",
`packages/coding-agent/dist/${outName}`,
],
buildEnv,
await compileCodingAgent({
repoRoot,
);
entrypoint: path.join(packageDir, "src", "cli.ts"),
outfile: outputPath,
transformersVersion,
target: crossBuild?.target,
external: ["fastembed", "onnxruntime-node"],
skipBuiltinCodesign: shouldAdhocSignDarwinBinary(),
});
// Bun 1.3.12 emits a truncated Mach-O signature on darwin builds.
if (shouldAdhocSignDarwinBinary()) {
@@ -128,7 +112,6 @@ async function main(): Promise<void> {
}
} finally {
await runCommand(["bun", "--cwd=../stats", "run", "gen:stats:reset"]);
await runCommand(["bun", "run", "gen:docs:reset"]);
}
}
+15 -27
View File
@@ -3,17 +3,24 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { assertDocsIndexFresh, buildDocsIndexPayload } from "./generate-docs-index";
import { buildDocsIndexPayload } from "./generate-docs-index";
const packageDir = path.join(import.meta.dir, "..");
const outDir = path.join(packageDir, "dist");
const cliPath = path.join(outDir, "cli.js");
const shebang = "#!/usr/bin/env bun\n";
// Native / optional / platform-specific deps that are never bundled — installed on
// demand (transformers/fastembed/onnxruntime) or shipped as their own artifact
// (native addon, mupdf).
const ALWAYS_EXTERNAL = ["mupdf", "@oh-my-pi/pi-natives", "@huggingface/transformers", "fastembed", "onnxruntime-node"];
// Native / optional / platform-specific deps are loaded from installed files.
// `omp-legacy-pi-modules` exists only in compiled binaries via the build plugin;
// the npm bundle never executes that `isCompiledBinary()` branch.
const ALWAYS_EXTERNAL = [
"mupdf",
"@oh-my-pi/pi-natives",
"@huggingface/transformers",
"fastembed",
"onnxruntime-node",
"omp-legacy-pi-modules",
];
// Heavy, lazily-used third-party leaf deps. Each is a declared `dependency`, so the
// published package resolves it from node_modules at runtime; bundling only embeds a
@@ -73,31 +80,12 @@ async function cleanBundleOutputs(): Promise<void> {
);
}
async function assertDocsEmbedPopulated(): Promise<void> {
// bundle-dist runs from prepack (which calls `gen:docs` first) or directly.
// Direct invocations must fail — the tarball ships src/, and an empty embed
// would make src/internal-urls/docs-index.ts fall through to the missing
// repo `docs/` tree at runtime in published packages (codex review, PR #3941).
const embedPath = path.join(packageDir, "src/internal-urls/docs-index.generated.txt");
const embed = await Bun.file(embedPath).text();
if (embed.length === 0) {
throw new Error(
"docs-index embed is empty. Run `bun run gen:docs` before `bun run gen:bundle`, or use `bun pm pack` which runs the prepack chain.",
);
}
const expected = await buildDocsIndexPayload();
assertDocsIndexFresh(embed, expected);
}
async function main(): Promise<void> {
const start = Bun.nanoseconds();
await cleanBundleOutputs();
await assertDocsEmbedPopulated();
// The npm bundle ships no stats dashboard sources, so embed the dashboard
// archive the same way compiled binaries do (scripts/build-binary.ts). Reset
// afterwards to keep the checked-in placeholder empty. The docs embed stays
// populated on disk — postpack owns its reset so `bun pm pack` can pack a
// tarball whose src copy is still valid for subpath imports.
// afterwards to keep the checked-in placeholder empty.
await runCommand(["bun", "--cwd=../stats", "run", "gen:stats"]);
try {
await runCommand([
@@ -106,13 +94,13 @@ async function main(): Promise<void> {
"--target=bun",
"--outdir",
"dist",
// Full minify (whitespace + syntax + identifiers); --keep-names retains
// fn/class .name where code depends on it.
"--minify",
"--keep-names",
...[...ALWAYS_EXTERNAL, ...RUNTIME_EXTERNAL].flatMap(dep => ["--external", dep]),
"--define",
'process.env.PI_BUNDLED="true"',
"--define",
`process.env.PI_DOCS_EMBED=${JSON.stringify((await buildDocsIndexPayload()).payload)}`,
"./src/cli.ts",
]);
} finally {
@@ -0,0 +1,68 @@
import { createLegacyPiVirtualModulePlugin } from "./legacy-pi-virtual-module";
import { buildDocsIndexPayload } from "./generate-docs-index";
/** Inputs shared by local and release coding-agent binary builds. */
export interface CodingAgentCompileOptions {
/** Absolute repository root used for package resolution. */
readonly repoRoot: string;
/** Absolute CLI entrypoint. */
readonly entrypoint: string;
/** Absolute standalone executable output path. */
readonly outfile: string;
/** Concrete Transformers.js version baked into the tiny-model worker. */
readonly transformersVersion: string;
/** Optional cross-compilation runtime target. */
readonly target?: Bun.Build.CompileTarget;
/** Dependencies intentionally resolved from the runtime filesystem. */
readonly external?: readonly string[];
/** Match release builds that minify identifiers while retaining names. */
readonly minifyIdentifiers?: boolean;
/** Disable Bun's built-in Darwin signing before the caller re-signs. */
readonly skipBuiltinCodesign?: boolean;
}
/**
* Compile the coding-agent executable with its legacy Pi compatibility module
* graph supplied by an in-memory build plugin rather than generated files.
*/
export async function compileCodingAgent(options: CodingAgentCompileOptions): Promise<void> {
const previousCodesignSetting = Bun.env.BUN_NO_CODESIGN_MACHO_BINARY;
if (options.skipBuiltinCodesign) {
Bun.env.BUN_NO_CODESIGN_MACHO_BINARY = "1";
}
try {
const output = await Bun.build({
entrypoints: [options.entrypoint],
root: options.repoRoot,
external: options.external ? [...options.external] : undefined,
define: {
"process.env.PI_COMPILED": JSON.stringify("true"),
"process.env.PI_TINY_TRANSFORMERS_VERSION": JSON.stringify(options.transformersVersion),
"process.env.PI_DOCS_EMBED": JSON.stringify((await buildDocsIndexPayload()).payload),
},
minify: {
identifiers: options.minifyIdentifiers ?? false,
keepNames: true,
},
plugins: [await createLegacyPiVirtualModulePlugin()],
compile: {
...(options.target ? { target: options.target } : {}),
outfile: options.outfile,
autoloadBunfig: false,
autoloadDotenv: false,
autoloadTsconfig: false,
autoloadPackageJson: false,
},
throw: false,
});
if (!output.success) {
throw new Error(`Coding-agent binary bundle failed:\n${output.logs.map(log => log.message).join("\n")}`);
}
} finally {
if (previousCodesignSetting === undefined) {
delete Bun.env.BUN_NO_CODESIGN_MACHO_BINARY;
} else {
Bun.env.BUN_NO_CODESIGN_MACHO_BINARY = previousCodesignSetting;
}
}
}
+3 -93
View File
@@ -1,38 +1,18 @@
#!/usr/bin/env bun
/**
* Populate, check, or reset the embedded harness documentation index for `omp://`.
*
* `--generate` writes `src/internal-urls/docs-index.generated.txt` as two lines:
* a plain JSON array of the sorted `docs/**\/*.md` file names, then a base64
* gzip blob of the index-aligned doc bodies (`string[]`). `--check` rebuilds
* that payload from the real docs corpus and compares it to the embed when
* present; the checked-in empty placeholder is accepted after verifying that a
* fresh generated payload round-trips. `--reset` restores the placeholder so the
* dev tree reads `docs/` from disk. Mirrors the stats / model-catalog embeds.
*/
import * as path from "node:path";
import { gunzipSync, gzipSync } from "node:zlib";
import { Glob } from "bun";
const docsDir = path.resolve(import.meta.dir, "../../../docs");
const outputPath = path.resolve(import.meta.dir, "../src/internal-urls/docs-index.generated.txt");
const GENERATE_FLAG = "--generate";
const RESET_FLAG = "--reset";
const CHECK_FLAG = "--check";
const packageDir = path.resolve(import.meta.dir, "..");
const docsDir = path.resolve(packageDir, "../../docs");
export interface DocsIndexPayload {
/** Sorted `docs/**\/*.md` file names plus index-aligned bodies and embed text. */
readonly files: readonly string[];
readonly bodies: readonly string[];
readonly payload: string;
}
export interface DecodedDocsIndexPayload {
/** Sorted `docs/**\/*.md` file names decoded from an embed payload. */
readonly files: readonly string[];
/** Index-aligned Markdown bodies decoded from an embed payload. */
readonly bodies: readonly string[];
}
@@ -40,7 +20,7 @@ function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every(item => typeof item === "string");
}
/** Build the exact two-line `omp://` docs embed from the source `docs/**\/*.md` corpus. */
/** Build the exact two-line `omp://` docs embed from the source `docs` Markdown corpus. */
export async function buildDocsIndexPayload(): Promise<DocsIndexPayload> {
const glob = new Glob("**/*.md");
const files: string[] = [];
@@ -76,73 +56,3 @@ export function decodeDocsIndexPayload(embed: string): DecodedDocsIndexPayload |
return { files: filenames, bodies };
}
/**
* Assert that an embed payload is fresh against the current source docs payload.
* An empty placeholder is accepted by round-tripping the expected payload (the
* dev tree and post-build reset state both checked-in placeholders).
*/
export function assertDocsIndexFresh(embed: string, expected: DecodedDocsIndexPayload): void {
const source =
embed.length > 0
? embed
: `${JSON.stringify(expected.files)}\n${Buffer.from(gzipSync(Buffer.from(JSON.stringify(expected.bodies)), { level: 9 })).toString("base64")}`;
const decoded = decodeDocsIndexPayload(source);
if (decoded === null) {
throw new Error("Embedded docs index is malformed: missing newline separator.");
}
if (decoded.files.length !== expected.files.length) {
throw new Error(
`Embedded docs index has ${decoded.files.length} docs; source corpus has ${expected.files.length}.`,
);
}
if (decoded.bodies.length !== expected.bodies.length) {
throw new Error(
`Embedded docs index has ${decoded.bodies.length} bodies; source corpus has ${expected.bodies.length}.`,
);
}
for (let i = 0; i < expected.files.length; i++) {
if (decoded.files[i] !== expected.files[i]) {
throw new Error(
`Embedded docs index filename mismatch at ${i}: ${decoded.files[i] ?? "<missing>"} !== ${expected.files[i]}.`,
);
}
if (decoded.bodies[i] !== expected.bodies[i]) {
throw new Error(`Embedded docs index body mismatch for ${expected.files[i]}. Run \`bun run gen:docs\`.`);
}
}
}
async function main(): Promise<void> {
const rel = path.relative(process.cwd(), outputPath);
if (process.argv.includes(RESET_FLAG)) {
await Bun.write(outputPath, "");
process.stdout.write(`Reset ${rel}\n`);
return;
}
if (process.argv.includes(CHECK_FLAG)) {
const current = await buildDocsIndexPayload();
const embed = await Bun.file(outputPath).text();
assertDocsIndexFresh(embed, current);
process.stdout.write(`Docs index fresh for ${current.files.length} docs (${rel})\n`);
return;
}
if (!process.argv.includes(GENERATE_FLAG)) {
process.stdout.write(
`Skipping ${rel}; pass ${GENERATE_FLAG} to embed docs (the dev tree reads docs/ from disk)\n`,
);
return;
}
const current = await buildDocsIndexPayload();
assertDocsIndexFresh(current.payload, current);
await Bun.write(outputPath, current.payload);
process.stdout.write(`Generated ${rel} (${current.files.length} docs, ${current.payload.length} bytes)\n`);
}
if (import.meta.main) {
await main();
}
@@ -1,420 +0,0 @@
#!/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("")}`;
}
// Skip files whose presence on disk is meaningful to the build pipeline rather
// than something a plugin would import: editor backups (`_*`, `.*`), tests,
// declaration files, and conventional `index` files (already covered by the
// non-wildcard root of the same directory when one is declared).
const SKIPPED_WILDCARD_BASENAMES = new Set(["index"]);
function isSafeWildcardBasename(basename: string): boolean {
if (!basename || basename.startsWith(".") || basename.startsWith("_")) return false;
if (SKIPPED_WILDCARD_BASENAMES.has(basename)) return false;
if (/\.(test|spec|d|generated|bench)$/.test(basename)) return false;
return true;
}
// Worker entry modules intentionally throw when imported outside a Worker. The
// bundled registry loads on the main thread during legacy extension validation,
// so these exported subpaths must stay out of the static registry.
const MAIN_THREAD_UNSAFE_WILDCARD_BASENAMES = new Set(["worker-entry"]);
function isMainThreadSafeWildcardBasename(basename: string): boolean {
return !MAIN_THREAD_UNSAFE_WILDCARD_BASENAMES.has(basename);
}
interface WildcardPattern {
readonly exportPrefix: string;
readonly exportSuffix: string;
readonly sourcePrefix: string;
readonly sourceSuffix: string;
}
/**
* Parse a single-asterisk Node exports wildcard into its prefix/suffix halves.
* Returns `null` for patterns with more than one asterisk or non-relative
* sources — neither shows up in our packages today and the generator stays
* conservative rather than guessing.
*/
function parseWildcardPattern(exportKey: string, sourcePattern: string): WildcardPattern | null {
const exportStar = exportKey.indexOf("*");
const sourceStar = sourcePattern.indexOf("*");
if (exportStar === -1 || sourceStar === -1) return null;
if (exportKey.indexOf("*", exportStar + 1) !== -1) return null;
if (sourcePattern.indexOf("*", sourceStar + 1) !== -1) return null;
if (!sourcePattern.startsWith("./")) return null;
return {
exportPrefix: exportKey.slice(2, exportStar),
exportSuffix: exportKey.slice(exportStar + 1),
sourcePrefix: sourcePattern.slice(2, sourceStar),
sourceSuffix: sourcePattern.slice(sourceStar + 1),
};
}
function exportImportTarget(value: unknown): string | null {
if (typeof value === "string") return value;
if (value && typeof value === "object" && "import" in value) {
const target = (value as { import?: unknown }).import;
return typeof target === "string" ? target : null;
}
return null;
}
async function collectEntries(): Promise<RegistryEntry[]> {
const entries: RegistryEntry[] = [];
const seenKeys = new Set<string>();
function pushEntry(key: string, binding: string, importSpecifier: string): void {
if (seenKeys.has(key)) return;
seenKeys.add(key);
entries.push({ key, binding, importSpecifier });
}
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.
pushEntry(pkg.name, `bundled${pkg.identifier}`, pkg.rootShim ?? pkg.name);
// Pass 1: every 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);
pushEntry(`${pkg.name}/${subpath}`, bindingForSubpath(pkg.identifier, subpath), `${pkg.name}/${subpath}`);
}
// Pass 2: expand wildcard exports against the source tree so plugins can
// import concrete subpath targets — e.g. `@(scope)/pi-ai/oauth/anthropic`
// remaps to `@oh-my-pi/pi-ai/oauth/anthropic`, covered by pi-ai's
// `./oauth/*` export pattern, which Node only resolves at runtime against
// a real `node_modules`. Compiled bunfs can't resolve at runtime, so we
// statically enumerate the concrete files now (issue #3442 follow-up).
// Root catch-all patterns (`./*`, `./*.js`) are skipped intentionally:
// the pi-coding-agent root is the binary entry's source tree, so static-
// importing every top-level file would drag `cli.ts`/`main.ts` through a
// second graph and explode the bundle for no plugin-facing benefit.
for (const exportKey in exportsField) {
if (!exportKey.startsWith("./") || exportKey === "." || !exportKey.includes("*")) continue;
const sourcePattern = exportImportTarget(exportsField[exportKey]);
if (!sourcePattern) continue;
const pattern = parseWildcardPattern(exportKey, sourcePattern);
if (!pattern) continue;
// Limit to JS-loadable source modules. `./prompts/*` mapping to
// `*.md` would emit a `import * as foo from "@(pkg)/prompts/<name>"`
// that Bun can't load as a JS module.
if (!/\.(ts|tsx|mts|cts|js|mjs|cjs|jsx)$/.test(pattern.sourceSuffix)) continue;
// Skip root catch-alls (prefix is empty before the wildcard). See
// the explanatory block comment above for the bundle-explosion
// reasoning. Named wildcards like `./oauth/*` keep `oauth/` here.
if (pattern.exportPrefix === "" || pattern.exportPrefix === "/") continue;
const sourceDir = path.join(repoRoot, pkg.dir, pattern.sourcePrefix);
try {
const glob = new Bun.Glob(`*${pattern.sourceSuffix}`);
const matches: string[] = [];
for await (const match of glob.scan({ cwd: sourceDir, onlyFiles: true })) {
matches.push(match);
}
matches.sort();
for (const match of matches) {
if (!match.endsWith(pattern.sourceSuffix)) continue;
const basename = match.slice(0, match.length - pattern.sourceSuffix.length);
if (!isSafeWildcardBasename(basename)) continue;
if (!isMainThreadSafeWildcardBasename(basename)) continue;
if (basename.includes("/")) continue;
const subpath = `${pattern.exportPrefix}${basename}${pattern.exportSuffix}`;
const key = `${pkg.name}/${subpath}`;
pushEntry(key, bindingForSubpath(pkg.identifier, subpath), key);
}
} catch (err) {
// Missing source dir means the wildcard is declared in
// package.json but the implementation tree hasn't shipped that
// folder yet. Leave it to runtime resolution.
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
}
}
}
entries.push({
key: TYPEBOX_REGISTRY_KEY,
binding: "bundledTypeBoxShim",
importSpecifier: TYPEBOX_SHIM_IMPORT,
});
const seenBindings = new Set<string>();
for (const entry of entries) {
if (seenBindings.has(entry.binding)) {
throw new Error(
`generate-legacy-pi-bundled-registry: duplicate binding ${entry.binding} for key ${entry.key}`,
);
}
seenBindings.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",
});
// Drain both pipes concurrently with proc.exited to avoid a pipe-buffer
// deadlock — biome check can emit thousands of lines when it rewrites the
// generated registry, easily exceeding the ~64 KiB OS pipe buffer.
const [exit, , stderr] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
if (exit !== 0) {
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();
@@ -0,0 +1,192 @@
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
/** Build-time specifier resolved to bundled legacy Pi module namespaces. */
export const LEGACY_PI_MODULES_SPECIFIER = "omp-legacy-pi-modules";
const VIRTUAL_NAMESPACE = "omp-legacy-pi-modules-build";
const packageDir = path.resolve(import.meta.dir, "..");
const repoRoot = path.resolve(packageDir, "..", "..");
interface BundledPackage {
readonly dir: string;
readonly identifier: string;
readonly rootShim: string | null;
}
const BUNDLED_PACKAGES: readonly BundledPackage[] = [
{ dir: "agent", identifier: "PiAgentCore", rootShim: null },
{ dir: "ai", identifier: "PiAi", rootShim: "legacy-pi-ai-shim.ts" },
{ dir: "coding-agent", identifier: "PiCodingAgent", rootShim: "legacy-pi-coding-agent-shim.ts" },
{ dir: "natives", identifier: "PiNatives", rootShim: null },
{ dir: "tui", identifier: "PiTui", rootShim: null },
{ dir: "utils", identifier: "PiUtils", rootShim: null },
];
const TYPEBOX_MODULE_KEY = "typebox";
const TYPEBOX_SHIM = "typebox.ts";
const SKIPPED_WILDCARD_BASENAMES = new Set(["index"]);
const MAIN_THREAD_UNSAFE_WILDCARD_BASENAMES = new Set(["worker-entry"]);
/** One namespace module the binary must retain for legacy extension imports. */
export interface BundledPiEntry {
/** Canonical import key exposed to extensions. */
readonly key: string;
/** Unique identifier used by the virtual module's generated import. */
readonly binding: string;
/** Package or absolute source specifier compiled into the binary. */
readonly importSpecifier: string;
}
interface WildcardPattern {
readonly exportPrefix: string;
readonly exportSuffix: string;
readonly sourcePrefix: string;
readonly sourceSuffix: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
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("")}`;
}
function isSafeWildcardBasename(basename: string): boolean {
if (!basename || basename.startsWith(".") || basename.startsWith("_")) return false;
if (SKIPPED_WILDCARD_BASENAMES.has(basename)) return false;
if (MAIN_THREAD_UNSAFE_WILDCARD_BASENAMES.has(basename)) return false;
return !/\.(test|spec|d|generated|bench)$/.test(basename);
}
function parseWildcardPattern(exportKey: string, sourcePattern: string): WildcardPattern | null {
const exportStar = exportKey.indexOf("*");
const sourceStar = sourcePattern.indexOf("*");
if (exportStar === -1 || sourceStar === -1) return null;
if (exportKey.indexOf("*", exportStar + 1) !== -1) return null;
if (sourcePattern.indexOf("*", sourceStar + 1) !== -1) return null;
if (!sourcePattern.startsWith("./")) return null;
return {
exportPrefix: exportKey.slice(2, exportStar),
exportSuffix: exportKey.slice(exportStar + 1),
sourcePrefix: sourcePattern.slice(2, sourceStar),
sourceSuffix: sourcePattern.slice(sourceStar + 1),
};
}
function exportImportTarget(value: unknown): string | null {
if (typeof value === "string") return value;
if (isRecord(value) && typeof value.import === "string") return value.import;
return null;
}
function shimSpecifier(file: string): string {
return path.join(packageDir, "src", "extensibility", file);
}
/**
* Derive the bundled legacy Pi module surface from current package exports.
* Named wildcard exports are expanded from source; root catch-alls stay out to
* avoid importing CLI entrypoints and other non-extension surfaces.
*/
export async function collectBundledPiEntries(): Promise<BundledPiEntry[]> {
const entries: BundledPiEntry[] = [];
const seenKeys = new Set<string>();
const seenBindings = new Set<string>();
function addEntry(key: string, binding: string, importSpecifier: string): void {
if (seenKeys.has(key)) return;
if (seenBindings.has(binding)) {
throw new Error(`Duplicate bundled Pi binding ${binding} for ${key}`);
}
seenKeys.add(key);
seenBindings.add(binding);
entries.push({ key, binding, importSpecifier });
}
for (const pkg of BUNDLED_PACKAGES) {
const packageRoot = path.join(repoRoot, "packages", pkg.dir);
const manifestPath = path.join(packageRoot, "package.json");
const manifest: unknown = await Bun.file(manifestPath).json();
if (!isRecord(manifest) || typeof manifest.name !== "string") {
throw new Error(`Bundled Pi package manifest has no name: ${manifestPath}`);
}
const exportsField = isRecord(manifest.exports) ? manifest.exports : {};
const rootSpecifier = pkg.rootShim ? shimSpecifier(pkg.rootShim) : manifest.name;
addEntry(manifest.name, `bundled${pkg.identifier}`, rootSpecifier);
for (const exportKey in exportsField) {
if (!exportKey.startsWith("./") || exportKey === "." || exportKey.includes("*")) continue;
const subpath = exportKey.slice(2);
const key = `${manifest.name}/${subpath}`;
addEntry(key, bindingForSubpath(pkg.identifier, subpath), key);
}
for (const exportKey in exportsField) {
if (!exportKey.startsWith("./") || exportKey === "." || !exportKey.includes("*")) continue;
const sourcePattern = exportImportTarget(exportsField[exportKey]);
if (!sourcePattern) continue;
const pattern = parseWildcardPattern(exportKey, sourcePattern);
if (!pattern || !/\.(ts|tsx|mts|cts|js|mjs|cjs|jsx)$/.test(pattern.sourceSuffix)) continue;
if (pattern.exportPrefix === "" || pattern.exportPrefix === "/") continue;
const sourceDir = path.join(packageRoot, pattern.sourcePrefix);
try {
const glob = new Bun.Glob(`*${pattern.sourceSuffix}`);
const matches: string[] = [];
for await (const match of glob.scan({ cwd: sourceDir, onlyFiles: true })) {
matches.push(match);
}
matches.sort();
for (const match of matches) {
if (!match.endsWith(pattern.sourceSuffix)) continue;
const basename = match.slice(0, match.length - pattern.sourceSuffix.length);
if (!isSafeWildcardBasename(basename) || basename.includes("/")) continue;
const subpath = `${pattern.exportPrefix}${basename}${pattern.exportSuffix}`;
const key = `${manifest.name}/${subpath}`;
addEntry(key, bindingForSubpath(pkg.identifier, subpath), key);
}
} catch (error) {
if (!isEnoent(error)) throw error;
}
}
}
addEntry(TYPEBOX_MODULE_KEY, "bundledTypeBoxShim", shimSpecifier(TYPEBOX_SHIM));
return entries;
}
function renderVirtualModule(entries: readonly BundledPiEntry[]): string {
const imports = entries.map(entry => `import * as ${entry.binding} from ${JSON.stringify(entry.importSpecifier)};`);
const modules = entries.map(entry => `\t${JSON.stringify(entry.key)}: ${entry.binding},`);
return [...imports, "", "export const BUNDLED_PI_MODULES = {", ...modules, "};", ""].join("\n");
}
/**
* Build plugin that materializes the legacy Pi module graph entirely in
* memory. Bun still needs static import edges at compile time, but no generated
* source or key-list file is written to the repository.
*/
export async function createLegacyPiVirtualModulePlugin(): Promise<Bun.BunPlugin> {
const source = renderVirtualModule(await collectBundledPiEntries());
return {
name: "omp:legacy-pi-modules",
setup(build) {
build.onResolve({ filter: /^omp-legacy-pi-modules$/ }, () => ({
path: LEGACY_PI_MODULES_SPECIFIER,
namespace: VIRTUAL_NAMESPACE,
}));
build.onLoad({ filter: /.*/, namespace: VIRTUAL_NAMESPACE }, () => ({ contents: source, loader: "ts" }));
},
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -4,79 +4,58 @@ import * as path from "node:path";
import * as url from "node:url";
import { isCompiledBinary, stripWindowsExtendedLengthPathPrefix } from "@oh-my-pi/pi-utils";
import { registerPluginCacheInvalidator } from "../../discovery/helpers";
import { BUNDLED_PI_REGISTRY_KEYS } from "./legacy-pi-bundled-keys";
const IS_COMPILED_BINARY = isCompiledBinary();
// === Bundled host-package registry (issue #3423) ===
// === Bundled host modules (issue #3423) ===
//
// Bun 1.3.14 stopped exposing `--compile` extras through any filesystem-style
// API: `fs.existsSync`, `Bun.file().exists()`, `Bun.resolveSync`, and even
// `import("/$bunfs/...")` / `import("file:///$bunfs/...")` all fail for the
// embedded entries — only the main binary itself answers from
// `/$bunfs/root/<binary-name>`. The previous strategy of rewriting
// `@(scope)/pi-*` imports to a `file:///$bunfs/...` URL therefore breaks
// every legacy extension in compiled mode (issue #3423; see also issues
// #3329, #2168). Bun.plugin `onResolve` for bare specifiers also no longer
// fires for transitive imports inside runtime-loaded extensions, so the
// fallback hook in `installLegacyPiSpecifierShim()` cannot rescue them.
// embedded entries. Bun.plugin `onResolve` also no longer fires for transitive
// imports inside runtime-loaded extensions.
//
// Instead we keep a JS-heap reference to every bundled pi-* surface (the
// canonical host packages and the legacy shims) and re-export them through a
// Bun.plugin `onLoad` against a custom namespace. Extension source rewrites
// emit `omp-legacy-pi-bundled:<key>` specifiers that the synthetic loader
// resolves against the registry — no bunfs path ever leaves this module in
// compiled mode. Dev / source-link / installed-package modes keep the
// historical `file://` rewrite (the source files exist on disk and load fine
// through Bun's standard URL loader).
//
// The registry lives in a sibling file (`legacy-pi-bundled-registry.ts`)
// loaded via a conditional dynamic import: its transitive deps include the
// coding-agent root which pulls in generated artifacts (e.g.
// `export/html/tool-views.generated.js`) that only exist after a build, so a
// static import would crash every dev/test run that touches
// `legacy-pi-compat.ts`. This is the documented "conditional platform code"
// exception to the static-import rule.
// Compiled builds therefore keep live JS-heap references to the host packages
// and serve requested surfaces through `omp-legacy-pi-bundled:<key>` synthetic
// modules. `scripts/legacy-pi-virtual-module.ts` derives the static import edges
// from current package exports inside a Bun build plugin: no generated source
// or duplicate key list exists on disk. Runtime extension loading stays lazy —
// the virtual module is evaluated only when an extension requests a host
// package — but the compiler still sees every possible edge at build time.
const BUNDLED_VIRTUAL_SCHEME = "omp-legacy-pi-bundled:";
const BUNDLED_VIRTUAL_NAMESPACE = "omp-legacy-pi-bundled";
const BUNDLED_REGISTRY_GLOBAL = "__ompLegacyPiBundledRegistry";
const TYPEBOX_BUNDLED_REGISTRY_KEY = "typebox";
const BUNDLED_MODULES_GLOBAL = "__ompLegacyPiBundledModules";
const TYPEBOX_BUNDLED_MODULE_KEY = "typebox";
type BundledRegistry = Readonly<Record<string, Readonly<Record<string, unknown>>>>;
type BundledModules = Readonly<Record<string, Readonly<Record<string, unknown>>>>;
let bundledRegistryPromise: Promise<BundledRegistry> | null = null;
let bundledModulesPromise: Promise<BundledModules> | null = null;
/**
* Lazy-load the bundled host-package registry and stash it on `globalThis`
* for the synthetic loader emitted by `synthesizeBundledModuleSource`.
* Lazy-load the build-supplied host modules and stash them on `globalThis` for
* the synthetic module source emitted by `synthesizeBundledModuleSource`.
*
* `globalThis` is the bridge, not laziness: each synthesized
* `omp-legacy-pi-bundled:<key>` module is a *separate* ES module Bun compiles
* from a source string, so it cannot close over the registry in this file's
* lexical scope and the live (non-serializable) function/object exports cannot
* be inlined — the only runtime channel back to the host objects is a global.
*
* The dynamic import is gated by `IS_COMPILED_BINARY` so dev/test runs (where
* the registry's transitive deps include build-time-generated artifacts)
* never trigger the cascade.
* `globalThis` is the bridge: each `omp-legacy-pi-bundled:<key>` source string
* becomes a separate ES module and cannot close over this file's lexical scope.
* The dynamic import is intentional conditional build code. Dev/test runs
* never execute it; binary builds resolve the literal through the in-memory
* plugin in `scripts/legacy-pi-virtual-module.ts`.
*/
function ensureBundledRegistryLoaded(): Promise<BundledRegistry> {
function ensureBundledModulesLoaded(): Promise<BundledModules> {
if (!IS_COMPILED_BINARY) {
return Promise.reject(
new Error("omp:legacy-pi-shim: bundled registry is only available in compiled-binary mode"),
);
return Promise.reject(new Error("omp:legacy-pi-shim: bundled modules are only available in compiled mode"));
}
if (!bundledRegistryPromise) {
bundledRegistryPromise = import("./legacy-pi-bundled-registry").then(m => {
(globalThis as Record<string, unknown>)[BUNDLED_REGISTRY_GLOBAL] = m.BUNDLED_PI_REGISTRY;
return m.BUNDLED_PI_REGISTRY;
if (!bundledModulesPromise) {
bundledModulesPromise = import("omp-legacy-pi-modules").then(module => {
Reflect.set(globalThis, BUNDLED_MODULES_GLOBAL, module.BUNDLED_PI_MODULES);
return module.BUNDLED_PI_MODULES;
});
}
return bundledRegistryPromise;
return bundledModulesPromise;
}
function bundledRegistryVirtualSpecifier(registryKey: string): string {
return `${BUNDLED_VIRTUAL_SCHEME}${registryKey}`;
function bundledModuleVirtualSpecifier(moduleKey: string): string {
return `${BUNDLED_VIRTUAL_SCHEME}${moduleKey}`;
}
function isBundledVirtualSpecifier(value: string): boolean {
@@ -84,19 +63,16 @@ function isBundledVirtualSpecifier(value: string): boolean {
}
/**
* Build the synthetic ES module source for a `omp-legacy-pi-bundled:<key>`
* import against an explicit registry. Pure: takes the live module namespace
* and emits a string of ES exports rooted in `globalThis[BUNDLED_REGISTRY_GLOBAL]`.
* `synthesizeBundledModuleSource` wraps this with the lazy registry load —
* tests use this sync helper directly to assert export-shape preservation.
* Build a synthetic ES module for one live bundled namespace. Every export
* reads through the global bridge; no bunfs path or copied package is involved.
*/
function synthesizeBundledModuleSourceFromRegistry(registryKey: string, registry: BundledRegistry): string {
const mod = registry[registryKey];
function synthesizeBundledModuleSourceFromModules(moduleKey: string, modules: BundledModules): string {
const mod = modules[moduleKey];
if (!mod) {
throw new Error(`omp:legacy-pi-shim: no bundled module registered for ${registryKey}`);
throw new Error(`omp:legacy-pi-shim: no bundled module registered for ${moduleKey}`);
}
const lines: string[] = [
`const __omp_bundled = globalThis[${JSON.stringify(BUNDLED_REGISTRY_GLOBAL)}][${JSON.stringify(registryKey)}];`,
`const __omp_bundled = globalThis[${JSON.stringify(BUNDLED_MODULES_GLOBAL)}][${JSON.stringify(moduleKey)}];`,
];
let hasDefault = false;
for (const exportName in mod) {
@@ -114,36 +90,25 @@ function synthesizeBundledModuleSourceFromRegistry(registryKey: string, registry
}
/**
* Build the synthetic ES module source served for an
* `omp-legacy-pi-bundled:<key>` import. Enumerates the live module namespace
* so legacy extensions see the same named/default exports they would have
* gotten from a real `file://` load — without touching the inaccessible bunfs
* filesystem.
* Build the synthetic source served for one
* `omp-legacy-pi-bundled:<key>` import.
*/
async function synthesizeBundledModuleSource(registryKey: string): Promise<string> {
const registry = await ensureBundledRegistryLoaded();
return synthesizeBundledModuleSourceFromRegistry(registryKey, registry);
async function synthesizeBundledModuleSource(moduleKey: string): Promise<string> {
const modules = await ensureBundledModulesLoaded();
return synthesizeBundledModuleSourceFromModules(moduleKey, modules);
}
/**
* Test seam: builds the synthetic ES module source for a virtual specifier
* against an explicit registry. Pure (no globalThis read); the emitted source
* still routes runtime lookups through `globalThis[BUNDLED_REGISTRY_GLOBAL]`.
*/
export function __synthesizeLegacyPiBundledSourceWithRegistry(
registryKey: string,
registry: Readonly<Record<string, Readonly<Record<string, unknown>>>>,
/** Test seam for the virtual module's named/default export forwarding. */
export function __synthesizeLegacyPiBundledSourceWithModules(
moduleKey: string,
modules: Readonly<Record<string, Readonly<Record<string, unknown>>>>,
): string {
return synthesizeBundledModuleSourceFromRegistry(registryKey, registry);
return synthesizeBundledModuleSourceFromModules(moduleKey, modules);
}
/**
* Test seam: returns the globalThis key the synthetic loader reads from. Tests
* assert that the emitted source addresses the exact stash key the install
* function writes to, so a rename can't break extension loads silently.
*/
export function __getLegacyPiBundledRegistryGlobal(): string {
return BUNDLED_REGISTRY_GLOBAL;
/** Test seam for the global bridge key shared with synthetic module source. */
export function __getLegacyPiBundledModulesGlobal(): string {
return BUNDLED_MODULES_GLOBAL;
}
// Canonical scope for in-process pi packages. Plugins published against any of
@@ -207,6 +172,9 @@ const nodePackageRootCache = new Map<string, Promise<string | null>>();
const packageManifestCache = new Map<string, Promise<Record<string, unknown> | null>>();
const bareDependencyResolutionCache = new Map<string, Promise<string | null>>();
const realpathCache = new Map<string, Promise<string>>();
const nativeAddonResolutionCache = new Map<string, Promise<string | null>>();
const nativeAddonRequireScanCache = new Map<string, Promise<boolean>>();
const nativeAddonLoaderModulePaths = new Set<string>();
function clearLegacyPiResolutionCaches(): void {
resolvedSpecifierFallbacks.clear();
@@ -215,6 +183,9 @@ function clearLegacyPiResolutionCaches(): void {
nodePackageRootCache.clear();
packageManifestCache.clear();
bareDependencyResolutionCache.clear();
nativeAddonResolutionCache.clear();
nativeAddonRequireScanCache.clear();
nativeAddonLoaderModulePaths.clear();
realpathCache.clear();
}
@@ -232,7 +203,7 @@ const TYPEBOX_SPECIFIER_FILTER = /^(?:@sinclair\/typebox|typebox)$/;
// Compat-shim path resolution. In compiled-binary mode every bundled surface
// is served through the `omp-legacy-pi-bundled:` virtual namespace (see the
// registry block above) — bunfs paths are unreachable on Bun 1.3.14+, so the
// bundled-module block above) — bunfs paths are unreachable on Bun 1.3.14+, so the
// pre-#3423 helpers that derived `/$bunfs/root/...` paths from
// `import.meta.dir` are gone. Dev / source-link / installed-package modes
// still need a real filesystem path for the source shims, which
@@ -298,7 +269,7 @@ export function __resolveTypeBoxShimPath(
pathExistsSync: (p: string) => boolean = fs.existsSync,
): string | null {
if (isCompiled) {
return bundledRegistryVirtualSpecifier(TYPEBOX_BUNDLED_REGISTRY_KEY);
return bundledModuleVirtualSpecifier(TYPEBOX_BUNDLED_MODULE_KEY);
}
return pathExistsSync(sourcePath) ? sourcePath : null;
}
@@ -314,18 +285,18 @@ const TYPEBOX_SHIM_PATH = __resolveTypeBoxShimPath(IS_COMPILED_BINARY, sourceShi
// imports such as `@oh-my-pi/pi-ai/oauth` continue to resolve directly
// against the bundled pi-ai package.
const LEGACY_PI_AI_SHIM_PATH = IS_COMPILED_BINARY
? bundledRegistryVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-ai`)
? bundledModuleVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-ai`)
: sourceShimPath("legacy-pi-ai-shim.ts");
// The coding-agent's own `./src/index.ts` cannot be listed as an extra
// `bun --compile` entrypoint alongside the CLI entry without breaking binary
// startup (issue #1474 follow-up). In compiled-binary mode the legacy
// `@(scope)/pi-coding-agent` root therefore resolves through the bundled
// registry shim; in dev / source-link / installed-package mode it points at
// the sibling source shim whose distinct file path avoids the #1474 collision
// module shim; in dev / source-link / installed-package mode it points at the
// sibling source shim whose distinct file path avoids the #1474 collision
// while still re-exporting the canonical package surface.
const LEGACY_PI_CODING_AGENT_SHIM_PATH = IS_COMPILED_BINARY
? bundledRegistryVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-coding-agent`)
? bundledModuleVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-coding-agent`)
: sourceShimPath("legacy-pi-coding-agent-shim.ts");
// Package-root overrides. Shim entries (`pi-ai`, `pi-coding-agent`) always
@@ -346,8 +317,8 @@ const LEGACY_PI_CODING_AGENT_SHIM_PATH = IS_COMPILED_BINARY
/**
* Drop overrides whose filesystem targets are missing so they can fall
* through to the canonical-resolution path. Virtual `omp-legacy-pi-bundled:`
* entries always pass — the bundled registry is the source of truth in
* compiled-binary mode where bunfs paths are unreachable (issue #3423).
* entries always pass — live bundled module references are the source of truth
* in compiled mode where bunfs paths are unreachable (issue #3423).
*
* `pathExistsSync` defaults to `fs.existsSync`; tests inject a stub to
* simulate the missing-entrypoint failure mode without touching the real FS.
@@ -356,41 +327,59 @@ export function __validateLegacyPiPackageRootOverrides(
candidates: Record<string, string>,
pathExistsSync: (p: string) => boolean = fs.existsSync,
): Record<string, string> {
return Object.fromEntries(
Object.entries(candidates).filter(
([, candidate]) => isBundledVirtualSpecifier(candidate) || pathExistsSync(candidate),
),
);
const valid: Record<string, string> = {};
for (const key in candidates) {
const candidate = candidates[key];
if (candidate && (isBundledVirtualSpecifier(candidate) || pathExistsSync(candidate))) {
valid[key] = candidate;
}
}
return valid;
}
/**
* Compute the override map keyed by every canonical specifier the host serves
* directly: the pi-ai / pi-coding-agent roots (compat shims that re-attach
* legacy helpers) plus, in compiled-binary mode, every other canonical pi-*
* package root AND every non-wildcard subpath registered in the bundled
* registry (see `legacy-pi-bundled-keys.ts`). Subpath coverage is what stops
* `@(scope)/pi-ai/oauth` and friends from falling through to the extension's
* own — possibly absent — peer install when bunfs filesystem walks fail
* (issue #3442 follow-up to #3423). Exported as a test seam so the
* compiled-binary branch is verifiable from dev tests.
* legacy helpers) plus, in compiled mode, every build-supplied module key.
* Subpath coverage stops `@(scope)/pi-ai/oauth` and friends from falling
* through to the extension's absent peer install when bunfs walks fail.
*/
export function __buildLegacyPiPackageRootOverrides(isCompiled: boolean): Record<string, string> {
export function __buildLegacyPiPackageRootOverrides(
isCompiled: boolean,
bundledModuleKeys: Iterable<string> = [],
): Record<string, string> {
const candidates: Record<string, string> = {
[`${CANONICAL_PI_SCOPE}/pi-ai`]: LEGACY_PI_AI_SHIM_PATH,
[`${CANONICAL_PI_SCOPE}/pi-coding-agent`]: LEGACY_PI_CODING_AGENT_SHIM_PATH,
};
if (isCompiled) {
for (const key of BUNDLED_PI_REGISTRY_KEYS) {
// Shim-bearing roots above already mapped to their compat surface;
// the bundled typebox shim has a dedicated TYPEBOX_SHIM_PATH route.
if (key in candidates || key === TYPEBOX_BUNDLED_REGISTRY_KEY) continue;
candidates[key] = bundledRegistryVirtualSpecifier(key);
for (const key of bundledModuleKeys) {
// Shim-bearing roots already map to their compat surfaces; TypeBox
// has a dedicated TYPEBOX_SHIM_PATH route.
if (key in candidates || key === TYPEBOX_BUNDLED_MODULE_KEY) continue;
candidates[key] = bundledModuleVirtualSpecifier(key);
}
}
return __validateLegacyPiPackageRootOverrides(candidates);
}
const LEGACY_PI_PACKAGE_ROOT_OVERRIDES = __buildLegacyPiPackageRootOverrides(IS_COMPILED_BINARY);
// Seeded with compat roots at module init; first compiled extension load adds
// every key supplied by the in-memory build module.
let legacyPiPackageRootOverrides = __buildLegacyPiPackageRootOverrides(IS_COMPILED_BINARY);
let legacyPiOverridesReadyPromise: Promise<void> | null = null;
/** Complete compiled-mode overrides once from the lazily evaluated host modules. */
function ensureLegacyPiOverridesReady(): Promise<void> {
if (!IS_COMPILED_BINARY) {
return Promise.resolve();
}
if (!legacyPiOverridesReadyPromise) {
legacyPiOverridesReadyPromise = ensureBundledModulesLoaded().then(modules => {
legacyPiPackageRootOverrides = __buildLegacyPiPackageRootOverrides(true, Object.keys(modules));
});
}
return legacyPiOverridesReadyPromise;
}
let isLegacyPiSpecifierShimInstalled = false;
@@ -428,7 +417,7 @@ function getResolvedSpecifier(specifier: string): string {
* specifiers.
*/
function resolveCanonicalPiSpecifier(remappedSpecifier: string): string {
const override = LEGACY_PI_PACKAGE_ROOT_OVERRIDES[remappedSpecifier];
const override = legacyPiPackageRootOverrides[remappedSpecifier];
if (override) {
return override;
}
@@ -438,7 +427,7 @@ function resolveCanonicalPiSpecifier(remappedSpecifier: string): string {
function toImportSpecifier(resolvedPath: string): string {
// Virtual `omp-legacy-pi-bundled:` specifiers are served by the synthetic
// onLoad in `installLegacyPiSpecifierShim()`; wrapping them as `file://`
// would corrupt the scheme and bypass the bundled registry.
// would corrupt the scheme.
if (isBundledVirtualSpecifier(resolvedPath)) {
return resolvedPath;
}
@@ -492,6 +481,9 @@ async function rewriteLegacyExtensionSource(
importerPath: string,
mtimeTag: string | null = null,
): Promise<string> {
// Compiled mode completes the override map from the build-supplied module
// keys on first use; every rewrite path must see the full map.
await ensureLegacyPiOverridesReady();
const withPi = rewriteLegacyPiImports(source);
// When the TypeBox shim is missing (release build dropped the entrypoint —
// issue #3414), leave bare specifiers untouched so Bun resolves a real
@@ -506,10 +498,11 @@ async function rewriteLegacyExtensionSource(
: withPi;
const withPkg = await rewriteExtensionPackageImports(withTypeBox, importerPath, mtimeTag);
const withBare = await rewriteExtensionBareImports(withPkg, importerPath, mtimeTag);
const withNativeAddons = await rewriteExtensionNativeAddonRequires(withBare, importerPath);
if (!mtimeTag) {
return withBare;
return withNativeAddons;
}
return withBare.replace(
return withNativeAddons.replace(
RELATIVE_GRAPH_IMPORT_SPECIFIER_REGEX,
(_match, prefix: string, specifier: string, suffix: string) => `${prefix}${specifier}?mtime=${mtimeTag}${suffix}`,
);
@@ -950,6 +943,118 @@ async function resolveExtensionBareDependencyUncached(specifier: string, importe
return resolveNodePackageDependency(specifier, importerPath);
}
const NATIVE_ADDON_EXTENSION = ".node";
// Match CommonJS require calls so bare native-addon specifiers can be pinned
// to absolute paths. Only requires whose resolution lands on a `.node` addon
// are rewritten; everything else stays on Bun's native resolver.
const NATIVE_ADDON_REQUIRE_SPECIFIER_REGEX = /(\brequire\s*\(\s*["'])([^"'()\s]+)(["']\s*\))/g;
/**
* Resolve a bare specifier whose target is a native `.node` addon — either a
* package subpath ending in `.node`, or a package whose `main` points at an
* addon (the napi-rs per-platform package convention, e.g.
* `@yuuang/ffi-rs-darwin-arm64` → `ffi-rs.darwin-arm64.node`). Returns the
* addon's absolute realpath, or null when the specifier is not a native addon.
*/
async function resolveExtensionNativeAddon(specifier: string, importerPath: string): Promise<string | null> {
if (!isBareExtensionDependencySpecifier(specifier)) {
return null;
}
const cacheKey = `${specifier}\0${path.resolve(path.dirname(importerPath))}`;
const cached = nativeAddonResolutionCache.get(cacheKey);
if (cached) return cached;
const promise = resolveExtensionNativeAddonUncached(specifier, importerPath);
nativeAddonResolutionCache.set(cacheKey, promise);
return promise;
}
async function resolveExtensionNativeAddonUncached(specifier: string, importerPath: string): Promise<string | null> {
const parsed = splitBarePackageSpecifier(specifier);
if (!parsed) return null;
const packageRoot = await findNodePackageRoot(parsed.name, importerPath);
if (!packageRoot) return null;
let target: string | null = null;
if (parsed.subpath !== null) {
target = parsed.subpath.endsWith(NATIVE_ADDON_EXTENSION) ? path.join(packageRoot, parsed.subpath) : null;
} else {
const manifest = await readPackageManifest(packageRoot);
const main = manifest?.main;
target =
typeof main === "string" && main.endsWith(NATIVE_ADDON_EXTENSION) ? path.resolve(packageRoot, main) : null;
}
if (!target || !(await pathExists(target))) {
return null;
}
return realpathOrSelf(target);
}
/**
* Rewrite bare `require()` specifiers that resolve to native `.node` addons
* into absolute-path requires. In `bun build --compile` binaries, Bun's bare
* resolution fails for packages whose `main` is a `.node` addon ("Cannot find
* module '@scope/pkg-<platform>'") even when the package sits in the
* extension's own node_modules; requiring the addon by absolute path works.
*/
async function rewriteExtensionNativeAddonRequires(source: string, importerPath: string): Promise<string> {
let rewritten = "";
let lastIndex = 0;
for (const match of source.matchAll(NATIVE_ADDON_REQUIRE_SPECIFIER_REGEX)) {
const matchIndex = match.index;
if (matchIndex === undefined) continue;
const [fullMatch, prefix, specifier, suffix] = match;
if (!prefix || !specifier || !suffix) continue;
const resolved = await resolveExtensionNativeAddon(specifier, importerPath);
if (!resolved) continue;
rewritten += source.slice(lastIndex, matchIndex);
// Forward slashes keep Windows paths valid inside single- or double-quoted literals.
rewritten += `${prefix}${stripWindowsExtendedLengthPathPrefix(resolved).replaceAll("\\", "/")}${suffix}`;
lastIndex = matchIndex + fullMatch.length;
}
if (lastIndex === 0) {
return source;
}
return `${rewritten}${source.slice(lastIndex)}`;
}
/**
* Whether a module's source contains a bare require that resolves to a native
* `.node` addon — i.e. a napi-rs style loader that must be hooked into the
* extension graph so {@link rewriteExtensionNativeAddonRequires} can pin its
* platform-package requires to absolute paths.
*/
async function moduleRequiresNativeAddon(modulePath: string): Promise<boolean> {
const cached = nativeAddonRequireScanCache.get(modulePath);
if (cached) return cached;
const promise = moduleRequiresNativeAddonUncached(modulePath);
nativeAddonRequireScanCache.set(modulePath, promise);
return promise;
}
async function moduleRequiresNativeAddonUncached(modulePath: string): Promise<boolean> {
let source: string;
try {
source = await Bun.file(modulePath).text();
} catch {
return false;
}
for (const match of source.matchAll(NATIVE_ADDON_REQUIRE_SPECIFIER_REGEX)) {
const specifier = match[2];
if (specifier && (await resolveExtensionNativeAddon(specifier, modulePath))) {
return true;
}
}
return false;
}
async function rewriteExtensionBareImports(
source: string,
importerPath: string,
@@ -986,7 +1091,9 @@ function escapeRegExp(value: string): string {
// `imports` aliases such as `#src/*`, and extension-local bare dependency
// entries. Bare imports inside node_modules dependencies remain native Bun
// resolutions; once the dependency entry is hooked, its relative children are
// still collected and rewritten with the reload mtime tag.
// still collected and rewritten with the reload mtime tag. `require()` calls
// are scanned too so CJS entries and napi-rs loaders reached without an
// import statement still join the graph.
const EXTENSION_GRAPH_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])([^"'()\s]+)(["'])/g;
// Extension source realpaths already covered by an installed load-time hook for
@@ -1027,6 +1134,10 @@ async function realpathOrSelfUncached(p: string): Promise<string> {
* Extension-local bare dependency entries are also included so their relative
* children receive the reload mtime tag; bare imports inside those dependencies
* remain native Bun resolutions to avoid taking over full third-party graphs.
* CommonJS dependency entries stay native too, with one exception: napi-rs
* style loaders whose bare requires resolve to `.node` addons are hooked so
* their requires can be pinned to absolute paths (unresolvable by bare
* specifier inside `bun build --compile` binaries).
*/
async function collectExtensionModules(entryRealPath: string): Promise<Map<string, string>> {
const modules = new Map<string, string>();
@@ -1047,14 +1158,24 @@ async function collectExtensionModules(entryRealPath: string): Promise<Map<strin
let source: string;
try {
source = await Bun.file(file).text();
if (nativeAddonLoaderModulePaths.has(file)) {
// CJS requires cannot await an async onLoad hook. Resolve and
// rewrite native-addon paths before installing its sync hook.
source = await rewriteExtensionNativeAddonRequires(source, file);
}
} catch {
continue;
}
modules.set(file, source);
const dir = path.dirname(file);
const specifiers = new Set<string>();
for (const match of source.matchAll(EXTENSION_GRAPH_SPECIFIER_REGEX)) {
const specifier = match[2];
if (!specifier) continue;
if (match[2]) specifiers.add(match[2]);
}
for (const match of source.matchAll(NATIVE_ADDON_REQUIRE_SPECIFIER_REGEX)) {
if (match[2]) specifiers.add(match[2]);
}
for (const specifier of specifiers) {
try {
let resolved: string | null = null;
let nextFollowsBareDependencies = followBareDependencies;
@@ -1079,10 +1200,17 @@ async function collectExtensionModules(entryRealPath: string): Promise<Map<strin
dependencyExtension === ".cjs" ||
dependencyExtension === ".cts" ||
((dependencyExtension === ".js" || dependencyExtension === ".jsx") && manifest?.type !== "module");
resolved =
dependencyEntry && hasSourceModuleExtension(dependencyEntry) && !isCommonJsEntry
? await realpathOrSelf(dependencyEntry)
: null;
const isHookableEntry = Boolean(dependencyEntry && hasSourceModuleExtension(dependencyEntry));
const hookCommonJsEntry =
isHookableEntry && isCommonJsEntry && dependencyEntry
? await moduleRequiresNativeAddon(dependencyEntry)
: false;
if (isHookableEntry && dependencyEntry && (!isCommonJsEntry || hookCommonJsEntry)) {
resolved = await realpathOrSelf(dependencyEntry);
if (hookCommonJsEntry) {
nativeAddonLoaderModulePaths.add(resolved);
}
}
nextFollowsBareDependencies = false;
}
if (resolved && !modules.has(resolved)) {
@@ -1100,39 +1228,67 @@ async function collectExtensionModules(entryRealPath: string): Promise<Map<strin
}
/**
* Install a `Bun.plugin()` `onLoad` hook scoped to a set of extension-owned
* source modules. Runtime `onLoad` cannot fall through (Bun requires a result
* object), so every hook uses an exact-path alternation for modules known to be
* part of this entry's graph; reloads add supplemental hooks for newly
* discovered modules instead of widening an existing filter to unrelated files.
* Install exact-path load hooks for the current extension graph. ESM/TS source
* retains the async rewrite path. Native-addon CJS loaders use a synchronous
* hook with source pre-rewritten during graph collection; Bun rejects a CJS
* `require()` whose onLoad callback returns a promise.
*/
function installExtensionGraphHook(entryRealPath: string, modules: Map<string, string>): void {
const alternation = [...modules.keys()].map(escapeRegExp).join("|");
const filter = new RegExp(`^(?:${alternation})(?:\\?mtime=\\d+)?$`);
const hookId = Bun.hash(`${entryRealPath}\0${[...modules.keys()].join("\0")}`).toString(36);
Bun.plugin({
name: `omp:legacy-pi-ext:${hookId}`,
setup(build) {
build.onLoad({ filter, namespace: "file" }, async args => {
const queryIndex = args.path.indexOf("?mtime=");
const sourcePath = queryIndex >= 0 ? args.path.slice(0, queryIndex) : args.path;
const mtimeTag = queryIndex >= 0 ? args.path.slice(queryIndex + "?mtime=".length) : null;
const cached = modules.get(sourcePath);
let raw: string;
if (cached !== undefined) {
// consume-once: preserves ?mtime edit-pickup for the re-imported entry
modules.delete(sourcePath);
raw = cached;
} else {
raw = await Bun.file(sourcePath).text();
}
return {
contents: await rewriteLegacyExtensionSource(raw, sourcePath, mtimeTag),
loader: getLoader(sourcePath),
};
});
},
});
const asyncModules = new Map<string, string>();
const syncCommonJsModules = new Map<string, string>();
for (const [modulePath, source] of modules) {
const destination = nativeAddonLoaderModulePaths.has(modulePath) ? syncCommonJsModules : asyncModules;
destination.set(modulePath, source);
}
if (asyncModules.size > 0) {
const alternation = [...asyncModules.keys()].map(escapeRegExp).join("|");
const filter = new RegExp(`^(?:${alternation})(?:\\?mtime=\\d+)?$`);
const hookId = Bun.hash(`${entryRealPath}\0async\0${[...asyncModules.keys()].join("\0")}`).toString(36);
Bun.plugin({
name: `omp:legacy-pi-ext:${hookId}`,
setup(build) {
build.onLoad({ filter, namespace: "file" }, async args => {
const queryIndex = args.path.indexOf("?mtime=");
const sourcePath = queryIndex >= 0 ? args.path.slice(0, queryIndex) : args.path;
const mtimeTag = queryIndex >= 0 ? args.path.slice(queryIndex + "?mtime=".length) : null;
const cached = asyncModules.get(sourcePath);
let raw: string;
if (cached !== undefined) {
// consume-once: preserves ?mtime edit-pickup for re-imports
asyncModules.delete(sourcePath);
raw = cached;
} else {
raw = await Bun.file(sourcePath).text();
}
return {
contents: await rewriteLegacyExtensionSource(raw, sourcePath, mtimeTag),
loader: getLoader(sourcePath),
};
});
},
});
}
if (syncCommonJsModules.size > 0) {
const alternation = [...syncCommonJsModules.keys()].map(escapeRegExp).join("|");
const filter = new RegExp(`^(?:${alternation})(?:\\?mtime=\\d+)?$`);
const hookId = Bun.hash(`${entryRealPath}\0sync-cjs\0${[...syncCommonJsModules.keys()].join("\0")}`).toString(36);
Bun.plugin({
name: `omp:legacy-pi-ext:${hookId}`,
setup(build) {
build.onLoad({ filter, namespace: "file" }, args => {
const queryIndex = args.path.indexOf("?mtime=");
const sourcePath = queryIndex >= 0 ? args.path.slice(0, queryIndex) : args.path;
const source = syncCommonJsModules.get(sourcePath);
if (source === undefined) {
throw new Error(`Missing pre-rewritten CommonJS extension source: ${sourcePath}`);
}
return { contents: source, loader: getLoader(sourcePath) };
});
},
});
}
}
/**
@@ -1184,6 +1340,7 @@ export async function loadLegacyPiModule(resolvedPath: string): Promise<unknown>
// `bun link`/pnpm installs) so the rewrite filter matches the path Bun
// actually hands the hook.
const entryRealPath = await realpathOrSelf(path.resolve(resolvedPath));
await ensureLegacyPiOverridesReady();
const pendingSources = await ensureExtensionGraphHook(entryRealPath);
try {
// Dynamic import is required: legacy extension entry paths are user/plugin supplied at runtime.
@@ -1261,9 +1418,8 @@ export function installLegacyPiSpecifierShim(): void {
setup(build) {
build.onResolve({ filter: LEGACY_PI_SPECIFIER_FILTER, namespace: "file" }, resolveLegacyPiSpecifier);
build.onResolve({ filter: TYPEBOX_SPECIFIER_FILTER, namespace: "file" }, resolveTypeBoxSpecifier);
// Compiled-binary mode: serve `omp-legacy-pi-bundled:<key>` imports
// from the JS-heap registry. The rewrite path emits these specifiers
// in place of unreachable `file:///$bunfs/...` URLs (issue #3423).
// Compiled mode serves `omp-legacy-pi-bundled:<key>` imports from
// live host module references. No bunfs path leaves this loader.
build.onLoad({ filter: /.*/, namespace: BUNDLED_VIRTUAL_NAMESPACE }, async args => {
return { contents: await synthesizeBundledModuleSource(args.path), loader: "js" };
});
@@ -0,0 +1,4 @@
declare module "omp-legacy-pi-modules" {
/** Host package namespaces retained by the compiled binary for legacy extensions. */
export const BUNDLED_PI_MODULES: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
}
@@ -16,7 +16,7 @@ import * as path from "node:path";
import { promisify } from "node:util";
import { gunzip } from "node:zlib";
import { Glob } from "bun";
import docsEmbed from "./docs-index.generated.txt";
const docsEmbed = process.env.PI_DOCS_EMBED ?? "";
const gunzipAsync = promisify(gunzip);
@@ -1,19 +1,21 @@
import { describe, expect, it } from "bun:test";
import { BUNDLED_PI_REGISTRY_KEYS } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/legacy-pi-bundled-keys";
import { __buildLegacyPiPackageRootOverrides } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/legacy-pi-compat";
import { collectBundledPiEntries } from "../../scripts/legacy-pi-virtual-module";
const bundledModuleKeys = new Set((await collectBundledPiEntries()).map(entry => entry.key));
// Regression for issue #3442: extension validation in compiled-binary mode
// failed to resolve `@earendil-works/pi-ai/oauth` because the override map
// only covered bare package roots — every non-wildcard subpath fell through
// to `Bun.resolveSync`, which bunfs can't satisfy on Bun 1.3.14+, then the
// `rewriteLegacyPiImports` catch left the original specifier in place and
// Bun's native resolver couldn't find a peer install. The fix seeds the
// override map with every key in `BUNDLED_PI_REGISTRY_KEYS` so subpath
// imports route to the same `omp-legacy-pi-bundled:` virtual namespace
// that already serves the roots.
// Bun's native resolver couldn't find a peer install. The build plugin now
// derives every module key from current package exports, so subpaths route to
// the same `omp-legacy-pi-bundled:` virtual namespace as package roots without
// a generated registry or duplicate key list.
describe("legacy pi compat compiled-mode subpath overrides (issue #3442)", () => {
it("serves @oh-my-pi/pi-ai/oauth through the bundled virtual namespace in compiled mode", () => {
const overrides = __buildLegacyPiPackageRootOverrides(true);
const overrides = __buildLegacyPiPackageRootOverrides(true, bundledModuleKeys);
expect(overrides["@oh-my-pi/pi-ai/oauth"]).toBe("omp-legacy-pi-bundled:@oh-my-pi/pi-ai/oauth");
});
@@ -24,18 +26,18 @@ describe("legacy pi compat compiled-mode subpath overrides (issue #3442)", () =>
// from `@mariozechner/pi-ai/utils/oauth/anthropic`) still hit the bunfs
// fall-through. The generator now globs each wildcard's source pattern
// and registers every concrete `.ts` match against the virtual namespace.
const overrides = __buildLegacyPiPackageRootOverrides(true);
const overrides = __buildLegacyPiPackageRootOverrides(true, bundledModuleKeys);
expect(overrides["@oh-my-pi/pi-ai/oauth/anthropic"]).toBe(
"omp-legacy-pi-bundled:@oh-my-pi/pi-ai/oauth/anthropic",
);
// Sanity: the wildcard expansion also reaches deeper subroots so plugins
// pinned to e.g. `@oh-my-pi/pi-ai/providers/openai` keep resolving.
expect(BUNDLED_PI_REGISTRY_KEYS.has("@oh-my-pi/pi-ai/oauth/anthropic")).toBe(true);
expect(BUNDLED_PI_REGISTRY_KEYS.has("@oh-my-pi/pi-ai/oauth/openai-codex")).toBe(true);
expect(bundledModuleKeys.has("@oh-my-pi/pi-ai/oauth/anthropic")).toBe(true);
expect(bundledModuleKeys.has("@oh-my-pi/pi-ai/oauth/openai-codex")).toBe(true);
});
it("expands web search provider wildcard exports for compiled plugin imports", () => {
const overrides = __buildLegacyPiPackageRootOverrides(true);
const overrides = __buildLegacyPiPackageRootOverrides(true, bundledModuleKeys);
const providerKeys = [
"@oh-my-pi/pi-coding-agent/web/search/providers/xai",
"@oh-my-pi/pi-coding-agent/web/search/providers/tinyfish",
@@ -44,7 +46,7 @@ describe("legacy pi compat compiled-mode subpath overrides (issue #3442)", () =>
] as const;
for (const key of providerKeys) {
expect(BUNDLED_PI_REGISTRY_KEYS.has(key)).toBe(true);
expect(bundledModuleKeys.has(key)).toBe(true);
expect(overrides[key]).toBe(`omp-legacy-pi-bundled:${key}`);
}
});
@@ -55,8 +57,8 @@ describe("legacy pi compat compiled-mode subpath overrides (issue #3442)", () =>
// binary entry's transitive graph. Plugins almost never import top-level
// pi-* files directly, so we keep those routed via `Bun.resolveSync`.
// Concrete check: `@oh-my-pi/pi-coding-agent/cli` is NOT bundled.
expect(BUNDLED_PI_REGISTRY_KEYS.has("@oh-my-pi/pi-coding-agent/cli")).toBe(false);
expect(BUNDLED_PI_REGISTRY_KEYS.has("@oh-my-pi/pi-coding-agent/main")).toBe(false);
expect(bundledModuleKeys.has("@oh-my-pi/pi-coding-agent/cli")).toBe(false);
expect(bundledModuleKeys.has("@oh-my-pi/pi-coding-agent/main")).toBe(false);
});
it("does not bundle main-thread-unsafe worker entrypoints", () => {
@@ -64,13 +66,13 @@ describe("legacy pi compat compiled-mode subpath overrides (issue #3442)", () =>
// The compiled legacy registry is imported on the main thread while
// validating plugin extensions, so enumerating these files recreates the
// `js worker-entry: missing parentPort` failure from #3508.
expect(BUNDLED_PI_REGISTRY_KEYS.has("@oh-my-pi/pi-coding-agent/eval/js/worker-entry")).toBe(false);
expect(bundledModuleKeys.has("@oh-my-pi/pi-coding-agent/eval/js/worker-entry")).toBe(false);
});
it("maps every bundled key (minus shimmed roots + typebox) to its virtual specifier in compiled mode", () => {
const overrides = __buildLegacyPiPackageRootOverrides(true);
const overrides = __buildLegacyPiPackageRootOverrides(true, bundledModuleKeys);
const missing: string[] = [];
for (const key of BUNDLED_PI_REGISTRY_KEYS) {
for (const key of bundledModuleKeys) {
// pi-ai/pi-coding-agent roots intentionally use the legacy compat shims
// (they re-attach `Type`, `defineTool`, etc. dropped from the canonical
// package surface); typebox is served via TYPEBOX_SHIM_PATH.
@@ -88,7 +90,7 @@ describe("legacy pi compat compiled-mode subpath overrides (issue #3442)", () =>
// keys); the test asserts only that the roots stay distinct from the
// canonical pi-* surface — extensions still see the `Type` /
// `defineTool` helpers the canonical entrypoints dropped.
const overrides = __buildLegacyPiPackageRootOverrides(true);
const overrides = __buildLegacyPiPackageRootOverrides(true, bundledModuleKeys);
expect(overrides["@oh-my-pi/pi-ai"]).toBeDefined();
expect(overrides["@oh-my-pi/pi-ai"]).not.toBe("omp-legacy-pi-bundled:@oh-my-pi/pi-ai/oauth");
expect(overrides["@oh-my-pi/pi-coding-agent"]).toBeDefined();
@@ -107,7 +109,7 @@ describe("legacy pi compat compiled-mode subpath overrides (issue #3442)", () =>
// typebox is routed through `TYPEBOX_SHIM_PATH` + a dedicated onResolve
// hook; mirroring it in the override map would double-register and the
// virtual loader would race the dedicated shim path.
const overrides = __buildLegacyPiPackageRootOverrides(true);
const overrides = __buildLegacyPiPackageRootOverrides(true, bundledModuleKeys);
expect(overrides).not.toHaveProperty("typebox");
});
});
@@ -1,19 +1,15 @@
import { describe, expect, it } from "bun:test";
import {
__getLegacyPiBundledRegistryGlobal,
__synthesizeLegacyPiBundledSourceWithRegistry,
__getLegacyPiBundledModulesGlobal,
__synthesizeLegacyPiBundledSourceWithModules,
} from "@oh-my-pi/pi-coding-agent/extensibility/plugins/legacy-pi-compat";
// Regression for issue #3423: Bun 1.3.14 made `--compile` extras unreachable
// via every filesystem-style API, so `legacy-pi-compat.ts` now routes
// canonical `@oh-my-pi/pi-*` imports through a virtual specifier whose body
// re-exports a live registry entry from `globalThis`. The synthesizer must
// preserve every named export (and a default if present) so legacy
// extensions see the same surface they would have through a real `file://`
// load — otherwise `import { foo } from "@oh-my-pi/pi-coding-agent"` raises
// `Export named 'foo' not found in module ...`.
// via every filesystem-style API. The compat layer now routes canonical
// `@oh-my-pi/pi-*` imports through virtual modules backed by live host module
// references. The synthesizer must preserve every named/default export.
describe("legacy-pi bundled virtual module synthesizer (issue #3423)", () => {
const registry = {
const modules = {
"@oh-my-pi/pi-coding-agent": {
VERSION: "16.1.17",
defineTool: () => undefined,
@@ -28,22 +24,22 @@ describe("legacy-pi bundled virtual module synthesizer (issue #3423)", () => {
Type: { Object: () => undefined },
},
};
const globalKey = __getLegacyPiBundledRegistryGlobal();
const globalKey = __getLegacyPiBundledModulesGlobal();
it("emits one ES named export per enumerable namespace key", () => {
const src = __synthesizeLegacyPiBundledSourceWithRegistry("@oh-my-pi/pi-coding-agent", registry);
const src = __synthesizeLegacyPiBundledSourceWithModules("@oh-my-pi/pi-coding-agent", modules);
expect(src).toContain(
`const __omp_bundled = globalThis[${JSON.stringify(globalKey)}]["@oh-my-pi/pi-coding-agent"];`,
);
expect(src).toContain('export const VERSION = __omp_bundled["VERSION"];');
expect(src).toContain('export const defineTool = __omp_bundled["defineTool"];');
expect(src).toContain('export const Type = __omp_bundled["Type"];');
// Every named export emerges from a live registry lookup — never the FS.
// Every named export emerges from a live module lookup — never the FS.
expect(src).not.toMatch(/\$bunfs|file:\/\//);
});
it("forwards `default` through `export default` so default imports survive", () => {
const src = __synthesizeLegacyPiBundledSourceWithRegistry("@oh-my-pi/pi-utils", registry);
const src = __synthesizeLegacyPiBundledSourceWithModules("@oh-my-pi/pi-utils", modules);
expect(src).toContain("export default __omp_bundled.default;");
// Default and named exports coexist on the same module.
expect(src).toContain('export const VERSION = __omp_bundled["VERSION"];');
@@ -51,12 +47,12 @@ describe("legacy-pi bundled virtual module synthesizer (issue #3423)", () => {
});
it("omits `default` line when the registered namespace has no default export", () => {
const src = __synthesizeLegacyPiBundledSourceWithRegistry("@oh-my-pi/pi-coding-agent", registry);
const src = __synthesizeLegacyPiBundledSourceWithModules("@oh-my-pi/pi-coding-agent", modules);
expect(src).not.toContain("export default");
});
it("throws when asked to synthesize a key the registry does not cover", () => {
expect(() => __synthesizeLegacyPiBundledSourceWithRegistry("@oh-my-pi/pi-not-bundled", registry)).toThrow(
it("throws when asked to synthesize a key the bundled modules do not cover", () => {
expect(() => __synthesizeLegacyPiBundledSourceWithModules("@oh-my-pi/pi-not-bundled", modules)).toThrow(
/no bundled module registered for @oh-my-pi\/pi-not-bundled/,
);
});
@@ -65,7 +61,7 @@ describe("legacy-pi bundled virtual module synthesizer (issue #3423)", () => {
// The emitted source MUST read from the exact key the install function
// writes to — a rename of either side breaks every legacy extension
// load with a `Cannot read properties of undefined` at first import.
const src = __synthesizeLegacyPiBundledSourceWithRegistry("typebox", registry);
const src = __synthesizeLegacyPiBundledSourceWithModules("typebox", modules);
expect(src.startsWith(`const __omp_bundled = globalThis[${JSON.stringify(globalKey)}]["typebox"];`)).toBe(true);
});
@@ -75,9 +71,9 @@ describe("legacy-pi bundled virtual module synthesizer (issue #3423)", () => {
// the inner globalThis lookup + property-getter pattern in isolation —
// it would `throw` if the emitted code addressed the wrong stash key
// or skipped an enumerable export.
(globalThis as Record<string, unknown>)[globalKey] = registry;
Reflect.set(globalThis, globalKey, modules);
try {
const src = __synthesizeLegacyPiBundledSourceWithRegistry("@oh-my-pi/pi-coding-agent", registry);
const src = __synthesizeLegacyPiBundledSourceWithModules("@oh-my-pi/pi-coding-agent", modules);
// Strip the ES export prefix and run the body as a plain script so
// we can read `__omp_bundled` from the returned closure.
const body = src
@@ -85,12 +81,15 @@ describe("legacy-pi bundled virtual module synthesizer (issue #3423)", () => {
.filter(line => line.startsWith("const __omp_bundled"))
.join("\n");
const fn = new Function(`${body}; return __omp_bundled;`);
const live = fn() as Record<string, unknown>;
expect(live.VERSION).toBe("16.1.17");
expect(typeof live.defineTool).toBe("function");
expect(typeof live.Type).toBe("object");
const live: unknown = fn();
if (typeof live !== "object" || live === null) {
throw new Error("synthetic module did not resolve an object namespace");
}
expect("VERSION" in live ? live.VERSION : undefined).toBe("16.1.17");
expect(typeof ("defineTool" in live ? live.defineTool : undefined)).toBe("function");
expect(typeof ("Type" in live ? live.Type : undefined)).toBe("object");
} finally {
delete (globalThis as Record<string, unknown>)[globalKey];
Reflect.deleteProperty(globalThis, globalKey);
}
});
});
@@ -520,6 +520,40 @@ describe("legacy-pi in-place module loading (issue #1674)", () => {
expect(rewritten).toContain('from "node:path"');
});
it("pins native-addon package requires to absolute extension paths", async () => {
const dir = await writePackage({
"package.json": JSON.stringify({ name: "native-require-ext", version: "1.0.0" }),
"node_modules/@fixture/native-platform/package.json": JSON.stringify({
name: "@fixture/native-platform",
version: "1.0.0",
main: "binding.node",
}),
"node_modules/@fixture/native-platform/binding.node": "native fixture",
"node_modules/plain-dep/package.json": JSON.stringify({
name: "plain-dep",
version: "1.0.0",
main: "index.js",
}),
"node_modules/plain-dep/index.js": "module.exports = {};",
"index.ts": "",
});
const importer = path.join(dir, "index.ts");
const rewritten = await __rewriteLegacyExtensionSourceForTests(
[
'const binding = require("@fixture/native-platform");',
'const plain = require("plain-dep");',
'const local = require("./local.node");',
"export { binding, plain, local };",
].join("\n"),
importer,
);
const addon = await fs.realpath(path.join(dir, "node_modules/@fixture/native-platform/binding.node"));
expect(rewritten).toContain(`require("${addon.replaceAll("\\", "/")}")`);
expect(rewritten).toContain('require("plain-dep")');
expect(rewritten).toContain('require("./local.node")');
});
it("remaps legacy pi-ai utils/oauth subpaths to registry OAuth exports", async () => {
const dir = await writePackage({
"package.json": JSON.stringify({ name: "legacy-oauth-ext", version: "1.0.0" }),
@@ -1,7 +1,6 @@
import { describe, expect, it } from "bun:test";
import { gzipSync } from "node:zlib";
import { decodeDocsIndex } from "@oh-my-pi/pi-coding-agent/internal-urls/docs-index";
import { assertDocsIndexFresh } from "../../scripts/generate-docs-index";
function embed(files: readonly string[], bodies: readonly string[]): string {
return `${JSON.stringify(files)}\n${Buffer.from(gzipSync(Buffer.from(JSON.stringify(bodies)))).toString("base64")}`;
@@ -36,16 +35,3 @@ describe("decodeDocsIndex (embedded docs path)", () => {
});
});
describe("docs index freshness guard", () => {
it("rejects stale filename lists before bundling", () => {
expect(() => assertDocsIndexFresh(embed(["agent.md"], ["agent body"]), { files, bodies })).toThrow(
"Embedded docs index has 1 docs; source corpus has 2.",
);
});
it("rejects stale bodies with matching filenames", () => {
expect(() => assertDocsIndexFresh(embed(files, ["old agent body", "read body"]), { files, bodies })).toThrow(
"Embedded docs index body mismatch for agent.md. Run `bun run gen:docs`.",
);
});
});
+27 -56
View File
@@ -3,29 +3,32 @@
import * as fs from "node:fs/promises";
import { createRequire } from "node:module";
import * as path from "node:path";
import { compileCodingAgent } from "../packages/coding-agent/scripts/compile-binary";
interface BinaryTarget {
id: string;
platform: string;
arch: string;
target: string;
target: Bun.Build.CompileTarget;
outfile: string;
}
interface PackageManifest {
version: string;
}
const repoRoot = path.join(import.meta.dir, "..");
const binariesDir = path.join(repoRoot, "packages", "coding-agent", "binaries");
const entrypoint = "./packages/coding-agent/src/cli.ts";
const transformersManifest: PackageManifest = createRequire(import.meta.url)("@huggingface/transformers/package.json");
const entrypoint = path.join(repoRoot, "packages", "coding-agent", "src", "cli.ts");
const transformersManifest: unknown = createRequire(import.meta.url)("@huggingface/transformers/package.json");
if (
typeof transformersManifest !== "object" ||
transformersManifest === null ||
!("version" in transformersManifest) ||
typeof transformersManifest.version !== "string"
) {
throw new Error("@huggingface/transformers package manifest has no string version");
}
const transformersVersion = transformersManifest.version;
// Worker threads spawn `new Worker(Bun.main, { argv })` — they re-enter the
// binary's own entry module — so no separate worker modules are compiled.
// Legacy pi-* extension compat surfaces are served through an in-process
// virtual namespace (`legacy-pi-compat.ts`), reached via the main module
// graph, so no extra `--compile` entrypoints are required (issue #3423).
// Worker threads re-enter the binary's CLI entry module. Legacy Pi host
// modules are supplied by the in-memory compile plugin, so neither subsystem
// needs extra `--compile` entrypoints.
const isDryRun = process.argv.includes("--dry-run");
const targets: BinaryTarget[] = [
{
@@ -114,42 +117,23 @@ async function embedNative(target: BinaryTarget): Promise<void> {
});
}
function buildCompileCommand(target: BinaryTarget): string[] {
return [
"bun",
"build",
"--compile",
"--no-compile-autoload-bunfig",
"--no-compile-autoload-dotenv",
"--no-compile-autoload-tsconfig",
"--no-compile-autoload-package-json",
"--minify-identifiers",
"--keep-names",
"--define",
'process.env.PI_COMPILED="true"',
"--define",
`process.env.PI_TINY_TRANSFORMERS_VERSION=${JSON.stringify(transformersVersion)}`,
"--root",
".",
"--target",
target.target,
entrypoint,
"--outfile",
target.outfile,
];
}
async function buildBinary(target: BinaryTarget): Promise<void> {
console.log(`Building ${target.outfile}...`);
await embedNative(target);
if (isDryRun) {
console.log(`DRY RUN ${buildCompileCommand(target).join(" ")}`);
console.log(`DRY RUN Bun.build target=${target.target} outfile=${target.outfile}`);
return;
}
const buildEnv = shouldAdhocSignDarwinBinary(target) ? { ...Bun.env, BUN_NO_CODESIGN_MACHO_BINARY: "1" } : Bun.env;
await runCommand(buildCompileCommand(target), repoRoot, buildEnv);
await compileCodingAgent({
repoRoot,
entrypoint,
outfile: path.join(repoRoot, target.outfile),
transformersVersion,
target: target.target,
minifyIdentifiers: true,
skipBuiltinCodesign: shouldAdhocSignDarwinBinary(target),
});
// Bun 1.3.12 emits a truncated Mach-O signature on darwin builds.
if (shouldAdhocSignDarwinBinary(target)) {
await runCommand(["codesign", "--force", "--sign", "-", path.join(repoRoot, target.outfile)], repoRoot);
@@ -159,12 +143,12 @@ async function buildBinary(target: BinaryTarget): Promise<void> {
async function generateBundle(): Promise<void> {
if (isDryRun) {
console.log("DRY RUN bun run gen:stats");
console.log("DRY RUN bun run gen:docs");
console.log("DRY RUN bun --cwd=packages/collab-web run gen:tool-views");
console.log("DRY RUN bun run gen:mupdf");
return;
}
await runCommand(["bun", "run", "gen:stats"], repoRoot);
await runCommand(["bun", "run", "gen:docs"], repoRoot);
await runCommand(["bun", "--cwd=packages/collab-web", "run", "gen:tool-views"], repoRoot);
await runCommand(["bun", "run", "gen:mupdf"], repoRoot);
}
@@ -172,26 +156,13 @@ async function resetArtifacts(): Promise<void> {
if (isDryRun) {
console.log("DRY RUN bun run gen:native:reset");
console.log("DRY RUN bun run gen:stats:reset");
console.log("DRY RUN bun run gen:docs:reset");
console.log("DRY RUN bun run gen:mupdf:reset");
return;
}
await runCommand(["bun", "run", "gen:native:reset"], repoRoot);
await runCommand(["bun", "run", "gen:stats:reset"], repoRoot);
await runCommand(["bun", "run", "gen:docs:reset"], repoRoot);
await runCommand(["bun", "run", "gen:mupdf:reset"], repoRoot);
}
async function main(): Promise<void> {
const requestedTargets = parseRequestedTargets();
const selectedTargets = requestedTargets ? targets.filter(target => requestedTargets.has(target.id)) : targets;
if (requestedTargets) {
const unknownTargets = [...requestedTargets].filter(
requestedTarget => !targets.some(target => target.id === requestedTarget),
);
if (unknownTargets.length > 0) {
throw new Error(`Unknown release target(s): ${unknownTargets.join(", ")}`);
}
}