From 7ffe1eb8777e2646f02636249dbd2410470fc12a Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 10 Aug 2026 05:33:50 +0000 Subject: [PATCH] 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 --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/package.json | 1 + packages/coding-agent/scripts/bundle-dist.ts | 9 +- .../src/internal-urls/docs-index.ts | 92 ++++++++++++++----- .../test/internal-urls/docs-index.test.ts | 18 ++++ 5 files changed, 102 insertions(+), 22 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a4d66e8d2..c2731f12e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 7e73deaa7..22fb13662 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -89,6 +89,7 @@ "files": [ "src", "dist/cli.js", + "dist/docs-index.generated.txt", "dist/CHANGELOG-*.md", "dist/*.node", "dist/template-*.css", diff --git a/packages/coding-agent/scripts/bundle-dist.ts b/packages/coding-agent/scripts/bundle-dist.ts index 711dd0d64..04b577dad 100755 --- a/packages/coding-agent/scripts/bundle-dist.ts +++ b/packages/coding-agent/scripts/bundle-dist.ts @@ -69,6 +69,7 @@ async function cleanBundleOutputs(): Promise { .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 { // 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 { 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 { 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( diff --git a/packages/coding-agent/src/internal-urls/docs-index.ts b/packages/coding-agent/src/internal-urls/docs-index.ts index 6fcca6fc0..a480c053b 100644 --- a/packages/coding-agent/src/internal-urls/docs-index.ts +++ b/packages/coding-agent/src/internal-urls/docs-index.ts @@ -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 = {}; - 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; } diff --git a/packages/coding-agent/test/internal-urls/docs-index.test.ts b/packages/coding-agent/test/internal-urls/docs-index.test.ts index 81c4325d8..7edcd5e49 100644 --- a/packages/coding-agent/test/internal-urls/docs-index.test.ts +++ b/packages/coding-agent/test/internal-urls/docs-index.test.ts @@ -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]); + }); +});