fix(coding-agent): ship omp:// docs to npm consumers

exports resolve to TypeScript source, where the build-time PI_DOCS_EMBED
placeholder is empty and the dev-tree fallback resolved docs to an
unreachable node_modules/docs, so OmpProtocolHandler.complete()/.resolve()
threw ENOENT for every npm/SDK consumer.

- bundle-dist.ts writes the docs corpus to dist/docs-index.generated.txt
  (shipped via package.json files), reusing the same payload it inlines
  into dist/cli.js.
- docs-index.ts reads the shipped embed when the env embed is empty and
  the on-disk docs/ directory is absent, and degrades to an empty index
  (with a warning) instead of propagating ENOENT.
- Added a generator-to-runtime payload round-trip test.

Fixes #8134
This commit is contained in:
roboomp
2026-08-10 05:33:50 +00:00
parent 45e12e5bb7
commit 7ffe1eb877
5 changed files with 102 additions and 22 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed `omp://` throwing `ENOENT` for npm/SDK consumers: `@oh-my-pi/pi-coding-agent`'s `exports` resolve to TypeScript source where the build-time `PI_DOCS_EMBED` is empty, and the dev-tree fallback pointed at an unreachable `node_modules/docs`, so `OmpProtocolHandler.complete()`/`.resolve()` crashed for any consumer importing the package from npm. `gen:bundle` now also ships the docs corpus as `dist/docs-index.generated.txt`, the source path reads it when the env embed is empty and the on-disk `docs/` is absent, and a missing corpus degrades to an empty index (with a warning) instead of propagating `ENOENT` ([#8134](https://github.com/can1357/oh-my-pi/issues/8134)).
## [17.2.12] - 2026-08-08
### Fixed
+1
View File
@@ -89,6 +89,7 @@
"files": [
"src",
"dist/cli.js",
"dist/docs-index.generated.txt",
"dist/CHANGELOG-*.md",
"dist/*.node",
"dist/template-*.css",
+8 -1
View File
@@ -69,6 +69,7 @@ async function cleanBundleOutputs(): Promise<void> {
.filter(
entry =>
entry === "cli.js" ||
entry === "docs-index.generated.txt" ||
entry.endsWith(".node") ||
entry.endsWith(".js.map") ||
(entry.startsWith("CHANGELOG-") && entry.endsWith(".md")) ||
@@ -85,6 +86,11 @@ async function main(): Promise<void> {
// archive the same way compiled binaries do (scripts/build-binary.ts). Reset
// afterwards to keep the checked-in placeholder empty.
await runCommand(["bun", "--cwd=../stats", "run", "gen:stats"]);
// One payload for both consumers: inlined into dist/cli.js via `--define` for
// the bundled CLI entrypoint, and written to dist/docs-index.generated.txt so
// SDK consumers importing `@oh-my-pi/pi-coding-agent/*` (TypeScript source, no
// build-time embed) can still resolve omp:// docs (see src/internal-urls/docs-index.ts).
const docsPayload = await buildDocsIndexPayload();
try {
// Build in-process: the docs embed payload is far larger than Linux's
// 128KiB per-argv-string cap, so it can never be passed as a CLI
@@ -96,7 +102,7 @@ async function main(): Promise<void> {
external: [...ALWAYS_EXTERNAL, ...RUNTIME_EXTERNAL],
define: {
"process.env.PI_BUNDLED": JSON.stringify("true"),
"process.env.PI_DOCS_EMBED": JSON.stringify((await buildDocsIndexPayload()).payload),
"process.env.PI_DOCS_EMBED": JSON.stringify(docsPayload.payload),
},
minify: {
whitespace: true,
@@ -113,6 +119,7 @@ async function main(): Promise<void> {
await runCommand(["bun", "--cwd=../stats", "run", "gen:stats:reset"]);
}
await ensureShebang();
await Bun.write(path.join(outDir, "docs-index.generated.txt"), docsPayload.payload);
const stat = await fs.stat(cliPath);
const elapsedMs = (Bun.nanoseconds() - start) / 1_000_000;
process.stdout.write(
@@ -8,13 +8,16 @@
* Listing/completion (`getDocFilenames`) parses only the small first line and
* never inflates the blob; the bodies are gunzipped off the event loop (via the
* async `node:zlib` threadpool) lazily, once, on the first actual read. When the
* placeholder is empty (dev tree, source checkout), the index is read from the
* repo `docs/` directory on disk instead.
* placeholder is empty (running from TypeScript source), the index falls back to
* the repo `docs/` directory on disk (monorepo checkout), then the embed file
* shipped in the npm package (`dist/docs-index.generated.txt`, written by
* `gen:bundle`), so `@oh-my-pi/pi-coding-agent/*` SDK consumers resolve docs too.
*/
import { readFileSync } from "node:fs";
import * as path from "node:path";
import { promisify } from "node:util";
import { gunzip } from "node:zlib";
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
import { Glob } from "bun";
const docsEmbed = process.env.PI_DOCS_EMBED ?? "";
@@ -56,38 +59,85 @@ export function decodeDocsIndex(embed: string): DocsIndex | null {
};
}
/** Dev tree / source checkout: build the index from the repo `docs/` directory. */
function readDocsFromDisk(): DocsIndex {
/**
* Dev tree / source checkout: build the index from the repo `docs/` directory.
* Returns `null` when that directory is absent — for an npm-installed package,
* four levels up from `src/internal-urls/` is `node_modules/`, not a repo root,
* so `docs/` is structurally unreachable and the caller falls back to the
* shipped embed instead.
*/
function readDocsFromDisk(): DocsIndex | null {
const docsDir = path.resolve(import.meta.dir, "../../../../docs");
const filenames: string[] = [];
const bodies: Record<string, string> = {};
for (const relativePath of new Glob("**/*.md").scanSync(docsDir)) {
const normalized = relativePath.split(path.sep).join("/");
filenames.push(normalized);
bodies[normalized] = readFileSync(path.join(docsDir, relativePath), "utf8");
try {
for (const relativePath of new Glob("**/*.md").scanSync(docsDir)) {
const normalized = relativePath.split(path.sep).join("/");
filenames.push(normalized);
bodies[normalized] = readFileSync(path.join(docsDir, relativePath), "utf8");
}
} catch (err) {
if (isEnoent(err)) return null;
throw err;
}
filenames.sort();
return { filenames, getBody: relativePath => Promise.resolve(bodies[relativePath]) };
}
/**
* Prepacked npm package: the docs embed is written to `dist/docs-index.generated.txt`
* during `gen:bundle` (compiled binaries inline it via `PI_DOCS_EMBED` instead).
* SDK consumers importing `@oh-my-pi/pi-coding-agent/*` load TypeScript source, where
* the build-time placeholder is empty, so this shipped file is their only reachable
* corpus. Returns `null` when the file is absent (dev tree before a bundle build).
*/
function readShippedEmbed(): DocsIndex | null {
const embedPath = path.resolve(import.meta.dir, "../../dist/docs-index.generated.txt");
let raw: string;
try {
raw = readFileSync(embedPath, "utf8");
} catch (err) {
if (isEnoent(err)) return null;
throw err;
}
const decoded = decodeDocsIndex(raw);
if (decoded === null) {
throw new Error(
`Malformed shipped docs index at ${embedPath}: payload without a newline separator. Rebuild the bundle.`,
);
}
return decoded;
}
/** Empty index for when no docs corpus is reachable — degrades `omp://` instead of throwing ENOENT at callers. */
function emptyIndex(): DocsIndex {
logger.warn(
"omp:// docs corpus unavailable: no build-time embed, on-disk docs/ directory, or shipped dist embed found",
);
return { filenames: [], getBody: () => Promise.resolve(undefined) };
}
let index: DocsIndex | undefined;
function getIndex(): DocsIndex {
if (index !== undefined) return index;
// Empty placeholder → dev tree / source checkout: read docs from disk.
if (docsEmbed.length === 0) {
index = readDocsFromDisk();
// Populated embed in compiled binaries / npm bundle entrypoint. A non-empty
// payload with no newline is a broken build (truncated/corrupt embed).
if (docsEmbed.length > 0) {
const decoded = decodeDocsIndex(docsEmbed);
if (decoded === null) {
throw new Error(
"Malformed embedded docs index: non-empty payload without a newline separator. " +
"Rebuild the binary or bundle.",
);
}
index = decoded;
return index;
}
// Populated embed in compiled binaries / npm bundle. A non-empty payload with
// no newline is a broken build (truncated/corrupt embed), not a placeholder.
const decoded = decodeDocsIndex(docsEmbed);
if (decoded === null) {
throw new Error(
"Malformed embedded docs index: non-empty payload without a newline separator. " +
"Rebuild the binary or bundle.",
);
}
index = decoded;
// No build-time embed → running from TypeScript source. Prefer the on-disk
// docs corpus (monorepo checkout), then the embed file shipped in the npm
// package (dist/), and finally degrade to an empty index so a missing corpus
// never propagates ENOENT through omp:// callers.
index = readDocsFromDisk() ?? readShippedEmbed() ?? emptyIndex();
return index;
}
@@ -1,6 +1,7 @@
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 { buildDocsIndexPayload } 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")}`;
@@ -34,3 +35,20 @@ describe("decodeDocsIndex (embedded docs path)", () => {
expect(decodeDocsIndex("")).toBeNull();
});
});
describe("shipped docs embed (generator↔runtime contract)", () => {
// bundle-dist.ts writes buildDocsIndexPayload().payload to
// dist/docs-index.generated.txt, which docs-index.ts reads back via
// decodeDocsIndex for npm/SDK consumers. If the generator's encoding and the
// runtime decoder drift, the shipped embed silently fails to resolve, so
// assert the real generator output round-trips through the runtime decoder.
it("decodes the real generator payload with round-tripped filenames and bodies", async () => {
const payload = await buildDocsIndexPayload();
const index = decodeDocsIndex(payload.payload);
expect(index).not.toBeNull();
expect(index?.filenames).toEqual([...payload.files]);
const first = payload.files[0];
expect(first).toBeDefined();
expect(await index?.getBody(first)).toBe(payload.bodies[0]);
});
});