From a475389b982bbfc485edc8ac5c01626467533285 Mon Sep 17 00:00:00 2001 From: Hernan Javier Ardila Sanchez Date: Mon, 15 Jun 2026 17:39:36 +0200 Subject: [PATCH] fix(opencode-plugin): drop CJS bundle to fix OpenCode plugin loader (#3883) Integrated into release/v3.8.26 (scoped to the ESM-only loader fix) --- @omniroute/opencode-plugin/package.json | 17 ++--- @omniroute/opencode-plugin/tsup.config.ts | 4 +- .../build/opencode-plugin-esm-only.test.ts | 65 +++++++++++++++++++ 3 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 tests/unit/build/opencode-plugin-esm-only.test.ts diff --git a/@omniroute/opencode-plugin/package.json b/@omniroute/opencode-plugin/package.json index ee6bc62363..e2f2bbde4c 100644 --- a/@omniroute/opencode-plugin/package.json +++ b/@omniroute/opencode-plugin/package.json @@ -3,19 +3,16 @@ "version": "0.1.0", "description": "OpenCode plugin for the OmniRoute AI Gateway. Drives dynamic model discovery, /connect auth flow, and multi-instance OmniRoute providers via the official @opencode-ai/plugin contract.", "type": "module", - "main": "./dist/index.cjs", - "module": "./dist/index.js", + "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./runtime": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" } }, "files": [ diff --git a/@omniroute/opencode-plugin/tsup.config.ts b/@omniroute/opencode-plugin/tsup.config.ts index 97d4437dd8..209450e7aa 100644 --- a/@omniroute/opencode-plugin/tsup.config.ts +++ b/@omniroute/opencode-plugin/tsup.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ entry: ["src/index.ts"], - format: ["esm", "cjs"], + format: ["esm"], dts: true, clean: true, sourcemap: false, @@ -11,7 +11,7 @@ export default defineConfig({ target: "node22", outDir: "dist", minify: false, - cjsInterop: true, + cjsInterop: false, // Bundle runtime deps so the .tgz / npm install is self-contained. // `zod` is required at runtime by the options schema and would otherwise // need a peer install when the plugin is loaded directly from a file path diff --git a/tests/unit/build/opencode-plugin-esm-only.test.ts b/tests/unit/build/opencode-plugin-esm-only.test.ts new file mode 100644 index 0000000000..030a7b5262 --- /dev/null +++ b/tests/unit/build/opencode-plugin-esm-only.test.ts @@ -0,0 +1,65 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +// Regression guard for #3883: the @omniroute/opencode-plugin must ship an +// ESM-only bundle. OpenCode's Bun-based plugin loader resolves the package +// `main`/`exports` and applies CJS-to-ESM interop on a dual CJS bundle, which +// turns `mod.default` into the whole exports namespace, fails V1 plugin +// detection, and makes the loader throw "Plugin export is not a function". +// Shipping ESM-only (with a `./runtime` subpath) keeps `mod.default` the V1 +// plugin object so the loader registers it. Do NOT re-introduce a CJS bundle. + +const PKG_URL = new URL("../../../@omniroute/opencode-plugin/package.json", import.meta.url); +const TSUP_URL = new URL("../../../@omniroute/opencode-plugin/tsup.config.ts", import.meta.url); + +function readJson(url: URL): Record { + return JSON.parse(readFileSync(fileURLToPath(url), "utf8")); +} + +test("opencode-plugin package.json ships ESM-only (no CJS bundle)", () => { + const pkg = readJson(PKG_URL); + + // main must point at the ESM build, never the .cjs bundle. + assert.equal(pkg.main, "./dist/index.js", "main must be the ESM build"); + assert.equal( + typeof pkg.main === "string" && pkg.main.endsWith(".cjs"), + false, + "main must not be a .cjs bundle" + ); + // The legacy dual-bundle `module` field should be gone. + assert.equal("module" in pkg, false, "the dual-bundle `module` field must be removed"); + + const exports = pkg.exports as Record | undefined; + assert.ok(exports && typeof exports === "object", "exports map must exist"); + + const root = exports["."] as Record; + assert.ok(root && typeof root === "object", 'exports["."] must exist'); + assert.equal(root.import, "./dist/index.js", 'exports["."].import must be the ESM build'); + // A `require` condition would re-introduce the CJS path the loader chokes on. + assert.equal("require" in root, false, 'exports["."] must not declare a `require` condition'); + + // The `./runtime` subpath used by the OpenCode loader must resolve to ESM. + const runtime = exports["./runtime"] as Record; + assert.ok(runtime && typeof runtime === "object", 'exports["./runtime"] must exist'); + assert.equal(runtime.import, "./dist/index.js", 'exports["./runtime"].import must be ESM'); +}); + +test("opencode-plugin tsup config builds ESM-only with cjsInterop disabled", () => { + const tsup = readFileSync(fileURLToPath(TSUP_URL), "utf8"); + + const formatMatch = tsup.match(/format:\s*\[([^\]]*)\]/); + assert.ok(formatMatch, "tsup config must declare a format array"); + const formats = formatMatch[1] + .split(",") + .map((s) => s.trim().replace(/['"]/g, "")) + .filter(Boolean); + assert.deepEqual(formats, ["esm"], "tsup must build esm only (no cjs)"); + + assert.equal( + /cjsInterop:\s*true/.test(tsup), + false, + "cjsInterop must not be true for an ESM-only build" + ); +});