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:
@@ -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
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"files": [
|
||||
"src",
|
||||
"dist/cli.js",
|
||||
"dist/docs-index.generated.txt",
|
||||
"dist/CHANGELOG-*.md",
|
||||
"dist/*.node",
|
||||
"dist/template-*.css",
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user