From 7c5182ecaf0c1eb2ea997c731af3aab960b129bc Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 11 Aug 2026 16:56:15 -0400 Subject: [PATCH] cli: inline text imports as content in the node bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite's built-in asset plugin claims known asset types (.txt) before normal-priority plugins load, rewriting the import to an asset URL string — the models.dev snapshot shipped as "/assets/snapshot-*.txt" and cold-cache catalog access died decoding it. Pre-existing instances of the same class: the node bundle's prompt and tool-description .txt imports. enforce: "pre" intercepts first; a build assertion now fails the build if text imports surface as asset URLs, since the bundle still builds and runs --help convincingly without their content. Also: drop the redundant text.d.ts (bun-types already declares *.txt), give the snapshot refresh script a provider-count floor and Windows-safe path printing. --- packages/cli/script/build-node.ts | 22 ++++++++++++++++++- packages/cli/vite.node.config.ts | 5 +++++ .../core/script/update-models-snapshot.ts | 9 +++++--- packages/core/src/text.d.ts | 4 ---- 4 files changed, 32 insertions(+), 8 deletions(-) delete mode 100644 packages/core/src/text.d.ts diff --git a/packages/cli/script/build-node.ts b/packages/cli/script/build-node.ts index 5949b82d491..9f6f6c2466f 100644 --- a/packages/cli/script/build-node.ts +++ b/packages/cli/script/build-node.ts @@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process" import { createHash } from "node:crypto" -import { chmod, copyFile, mkdir, mkdtemp, realpath, rename, rm, stat, writeFile } from "node:fs/promises" +import { chmod, copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" import { build } from "vite" @@ -55,6 +55,25 @@ const builder = ? await resolveHostNode() : undefined +// Vite silently rewrites text imports of known asset types (.txt) to asset +// URL strings when the raw-text plugin doesn't intercept them first — the +// bundle still builds and `--help` still runs, so only content assertions +// catch it. Guards the models.dev snapshot and the prompt/tool description +// text that ships inside the bundle. +async function assertTextImportsInlined(bundlePath: string) { + const bundle = await readFile(bundlePath, "utf8") + const markers = [ + { marker: '"zhipuai"', source: "models-dev snapshot" }, + { marker: "/assets/snapshot", source: "models-dev snapshot inlined as asset URL", forbidden: true }, + { marker: '="/assets/', source: "text import inlined as asset URL", forbidden: true }, + ] + for (const { marker, source, forbidden } of markers) { + const present = bundle.includes(marker) + if (forbidden ? present : !present) + throw new Error(`${bundlePath}: ${source} — text imports are not inlined as content (marker ${marker})`) + } +} + for (const target of targets) { console.log(`building cli-node-${targetName(target)}`) const assets = await collectNodeAssets(target) @@ -63,6 +82,7 @@ for (const target of targets) { const input = { version: Script.version, channel: Script.channel, assetHash, target } await copyNodeAssets(assets) await build(mainConfig(input)) + await assertTextImportsInlined("dist-node/opencode.mjs") const host = target.platform === process.platform && target.arch === process.arch if (host) { diff --git a/packages/cli/vite.node.config.ts b/packages/cli/vite.node.config.ts index 8adf03a5280..6af5466d70a 100644 --- a/packages/cli/vite.node.config.ts +++ b/packages/cli/vite.node.config.ts @@ -10,6 +10,11 @@ const dir = import.meta.dirname function rawTextPlugin(): Plugin { return { name: "opencode:raw-text", + // "pre" is load-bearing for .txt: Vite's built-in asset plugin claims + // known asset types (.txt among them) ahead of normal-priority plugins, + // replacing the import with an asset URL string instead of the content. + // .md only ever worked without it because .md is not a known asset type. + enforce: "pre", async load(id) { if (!id.endsWith(".md") && !id.endsWith(".txt")) return return `export default ${JSON.stringify(await readFile(id, "utf8"))}` diff --git a/packages/core/script/update-models-snapshot.ts b/packages/core/script/update-models-snapshot.ts index 147073152dd..e65f9a2ccdc 100644 --- a/packages/core/script/update-models-snapshot.ts +++ b/packages/core/script/update-models-snapshot.ts @@ -12,10 +12,13 @@ if (!response.ok) { } const text = await response.text() const parsed: unknown = JSON.parse(text) -if (typeof parsed !== "object" || parsed === null || Object.keys(parsed).length === 0) { - console.error("Fetched catalog is empty; refusing to write snapshot") +// A floor, not equality: guards against committing an error page or a +// truncated body that still parses as a small object. +const MINIMUM_PROVIDERS = 100 +if (typeof parsed !== "object" || parsed === null || Object.keys(parsed).length < MINIMUM_PROVIDERS) { + console.error(`Fetched catalog has fewer than ${MINIMUM_PROVIDERS} providers; refusing to write snapshot`) process.exit(1) } const target = new URL("../src/models-dev/snapshot.txt", import.meta.url) await Bun.write(target, text) -console.log(`Wrote ${Object.keys(parsed).length} providers (${text.length} bytes) to ${target.pathname}`) +console.log(`Wrote ${Object.keys(parsed).length} providers (${text.length} bytes) to ${Bun.fileURLToPath(target)}`) diff --git a/packages/core/src/text.d.ts b/packages/core/src/text.d.ts deleted file mode 100644 index f2b13a934c4..00000000000 --- a/packages/core/src/text.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module "*.txt" { - const content: string - export default content -}