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