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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable → Regular
+3
-93
@@ -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" };
|
||||
});
|
||||
|
||||
+4
@@ -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);
|
||||
|
||||
|
||||
+20
-18
@@ -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`.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user