Skip to content

fix(opencode-plugin): drop CJS bundle to fix OpenCode plugin loader - #3883

Merged
diegosouzapw merged 1 commit into
diegosouzapw:release/v3.8.26from
herjarsa:fix/opencode-plugin-cjs-interop
Jun 15, 2026
Merged

diegosouzapw merged 1 commit into
diegosouzapw:release/v3.8.26from
herjarsa:fix/opencode-plugin-cjs-interop

Conversation

@herjarsa

Copy link
Copy Markdown
Contributor

Summary

The @omniroute/opencode-plugin v0.1.0 ships a dual ESM+CJS bundle via tsup, but OpenCode's Bun-based plugin loader resolves the package's main field (which points to the CJS bundle). When Bun applies CJS-to-ESM interop, the resulting mod.default becomes the full exports namespace (not the V1 plugin object), so readV1Plugin in OpenCode's loader fails the V1 detection and the loader falls through to getLegacyPlugins, which iterates Object.values(mod) and chokes on the many named exports with:

TypeError: Plugin export is not a function

Net effect: the plugin fails to register in OpenCode v1.17.x and the OmniRoute provider never appears in the model picker.

Fix

Drop the CJS bundle; ship ESM only, with a ./runtime subpath export — matching the pattern used by the predecessor opencode-omniroute-auth package that worked correctly.

Changes

@omniroute/opencode-plugin/tsup.config.ts

-  format: ["esm", "cjs"],
+  format: ["esm"],

@omniroute/opencode-plugin/package.json

-  "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"
     }
   },

Why this happens (root cause)

OpenCode's plugin loader does import(entry) on the resolved file and uses Bun's CJS-interop on the dual-bundle CJS file. mod.default ends up being the entire exports namespace, not the V1 plugin object. readV1Plugin(mod, spec, 'server', 'detect') does the V1 detection but the namespace lacks the V1 id/server top-level keys, so it falls through to getLegacyPlugins, which iterates the namespace and throws on the first non-function export.

With the ESM-only fix, the loader imports ./dist/index.js directly. mod.default is { id, server: <function> } — V1 detection succeeds.

Note on related OpenCode bug

The same Plugin export is not a function error fires for superpowers@0.0.2 and any other plugin with named exports. The fix landed upstream in commit anomalyco/opencode@2e27403b26e9795d5ab60ec2b71585aa540abfd0 but is not in OpenCode 1.17.7. Tracking upstream: anomalyco/opencode#13543.

Verification

After the fix, with the rebuilt plugin installed:

  • Direct import test with Bun: mod.default is the V1 object with server: function
  • OpenCode loader test: no more failed to load plugin errors; service=omniroute log lines appear
  • Model picker: omniroute provider shows live catalog + combos
  • Existing tests pass: npm test green

cc @diegosouzapw

@herjarsa
herjarsa requested a review from diegosouzapw as a code owner June 15, 2026 09:38

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request bundles the @omniroute/opencode-plugin pre-built inside the omniroute npm package and introduces a new CLI command omniroute setup opencode to automate its installation and registration within OpenCode. It also drops the CommonJS (CJS) build format in favor of ESM-only, fixes baseURL resolution fallbacks for partner/tiered providers, and adds comprehensive unit tests for the new setup command. Review feedback identifies several critical issues, including a runtime crash in the setup command due to checking for the deleted CJS bundle, a platform-specific test sandbox issue on Windows that could overwrite real user configurations, a potential command injection vulnerability on Windows, and several unused variables that should be cleaned up.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +101 to +112
const esmEntry = join(BUNDLED_PLUGIN_DIR, "dist", "index.js");
const cjsEntry = join(BUNDLED_PLUGIN_DIR, "dist", "index.cjs");

if (!existsSync(esmEntry) || !existsSync(cjsEntry)) {
throw new Error(
`@omniroute/opencode-plugin dist/ not built (looked for ${esmEntry}).\n` +
`Run \`cd ${BUNDLED_PLUGIN_DIR} && npm install && npm run build\` and re-run this command.`
);
}

// Prefer ESM. OpenCode (≥1.15) loads ESM modules natively.
return { distEntry: esmEntry, cjsEntry, packageDir: BUNDLED_PLUGIN_DIR };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Since the CJS bundle has been dropped in this PR (tsup now only builds the ESM format), dist/index.cjs will no longer exist. The check !existsSync(esmEntry) || !existsSync(cjsEntry) will always fail, causing the setup command to throw an error and prevent installation. We should remove the check for cjsEntry entirely.

Suggested change
const esmEntry = join(BUNDLED_PLUGIN_DIR, "dist", "index.js");
const cjsEntry = join(BUNDLED_PLUGIN_DIR, "dist", "index.cjs");
if (!existsSync(esmEntry) || !existsSync(cjsEntry)) {
throw new Error(
`@omniroute/opencode-plugin dist/ not built (looked for ${esmEntry}).\n` +
`Run \`cd ${BUNDLED_PLUGIN_DIR} && npm install && npm run build\` and re-run this command.`
);
}
// Prefer ESM. OpenCode (≥1.15) loads ESM modules natively.
return { distEntry: esmEntry, cjsEntry, packageDir: BUNDLED_PLUGIN_DIR };
const esmEntry = join(BUNDLED_PLUGIN_DIR, "dist", "index.js");
if (!existsSync(esmEntry)) {
throw new Error(
`@omniroute/opencode-plugin dist/ not built (looked for ${esmEntry}).\n` +
`Run \`cd ${BUNDLED_PLUGIN_DIR} && npm install && npm run build\` and re-run this command.`
);
}
// Prefer ESM. OpenCode (≥1.15) loads ESM modules natively.
return { distEntry: esmEntry, packageDir: BUNDLED_PLUGIN_DIR };

Comment on lines +19 to +40
async function withSandbox(fn: (sandbox: string, homeDir: string) => Promise<void>) {
const sandbox = createTempDir();
// Create a fake HOME so opencode.json goes to sandbox/.config/opencode/
const fakeHome = path.join(sandbox, "fake-home");
fs.mkdirSync(fakeHome, { recursive: true });
const oldHome = process.env.HOME;
process.env.HOME = fakeHome;
// Ensure XDG_CONFIG_HOME is not set so resolveOpenCodeDirs uses HOME
delete process.env.XDG_CONFIG_HOME;

try {
await fn(sandbox, fakeHome);
} finally {
process.env.HOME = oldHome;
if (ORIGINAL_XDG_CONFIG_HOME === undefined) {
delete process.env.XDG_CONFIG_HOME;
} else {
process.env.XDG_CONFIG_HOME = ORIGINAL_XDG_CONFIG_HOME;
}
fs.rmSync(sandbox, { recursive: true, force: true });
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

On Windows, os.homedir() resolves using the USERPROFILE environment variable rather than HOME. Overriding only process.env.HOME in the test sandbox means that on Windows, the test will read and write to the developer's actual home directory, potentially corrupting or overwriting their real opencode.json configuration. We must override and restore process.env.USERPROFILE as well.

async function withSandbox(fn: (sandbox: string, homeDir: string) => Promise<void>) {
  const sandbox = createTempDir();
  // Create a fake HOME so opencode.json goes to sandbox/.config/opencode/
  const fakeHome = path.join(sandbox, "fake-home");
  fs.mkdirSync(fakeHome, { recursive: true });
  const oldHome = process.env.HOME;
  const oldUserProfile = process.env.USERPROFILE;
  process.env.HOME = fakeHome;
  process.env.USERPROFILE = fakeHome;
  // Ensure XDG_CONFIG_HOME is not set so resolveOpenCodeDirs uses HOME
  delete process.env.XDG_CONFIG_HOME;

  try {
    await fn(sandbox, fakeHome);
  } finally {
    process.env.HOME = oldHome;
    if (oldUserProfile === undefined) {
      delete process.env.USERPROFILE;
    } else {
      process.env.USERPROFILE = oldUserProfile;
    }
    if (ORIGINAL_XDG_CONFIG_HOME === undefined) {
      delete process.env.XDG_CONFIG_HOME;
    } else {
      process.env.XDG_CONFIG_HOME = ORIGINAL_XDG_CONFIG_HOME;
    }
    fs.rmSync(sandbox, { recursive: true, force: true });
  }
}

Comment on lines +262 to +267
export async function runSetupOpenCodeCommand(opts = {}) {
const providerId = opts.providerId || "omniroute";
const baseURL = opts.baseURL || "http://localhost:20128";
const displayName = opts.displayName || null;
const wantsAuth = Boolean(opts.auth);
const nonInteractive = Boolean(opts.nonInteractive);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

To prevent potential command injection on Windows (where spawnSync is run with shell: true) and directory traversal issues, we should validate that providerId only contains safe alphanumeric characters, dashes, or underscores. Additionally, validating that baseURL is a well-formed URL prevents writing invalid configurations to opencode.json.

export async function runSetupOpenCodeCommand(opts = {}) {
  const providerId = opts.providerId || "omniroute";
  const baseURL = opts.baseURL || "http://localhost:20128";
  const displayName = opts.displayName || null;
  const wantsAuth = Boolean(opts.auth);
  const nonInteractive = Boolean(opts.nonInteractive);

  if (!/^[a-zA-Z0-9-_]+$/.test(providerId)) {
    printError("Invalid provider ID. Only alphanumeric characters, dashes, and underscores are allowed.");
    return { exitCode: 1 };
  }

  try {
    new URL(baseURL);
  } catch {
    printError("Invalid base URL format.");
    return { exitCode: 1 };
  }

Comment on lines +148 to +154
function registerPluginInOpenCodeConfig({
opencodeConfigDir,
pluginTargetDir,
providerId,
baseURL,
displayName,
}) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The parameter pluginTargetDir is defined in registerPluginInOpenCodeConfig but is never used within the function body. We should remove it to keep the code clean.

function registerPluginInOpenCodeConfig({
  opencodeConfigDir,
  providerId,
  baseURL,
  displayName,
}) {

Comment on lines +306 to +312
const reg = registerPluginInOpenCodeConfig({
opencodeConfigDir,
pluginTargetDir,
providerId,
baseURL,
displayName,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Remove the unused pluginTargetDir argument from the call to registerPluginInOpenCodeConfig.

    const reg = registerPluginInOpenCodeConfig({
      opencodeConfigDir,
      providerId,
      baseURL,
      displayName,
    });

Comment on lines +7 to +8
const ORIGINAL_HOME = process.env.HOME;
const ORIGINAL_XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The global constant ORIGINAL_HOME is declared but never used. We should remove it.

Suggested change
const ORIGINAL_HOME = process.env.HOME;
const ORIGINAL_XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
const ORIGINAL_XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME;

@diegosouzapw
diegosouzapw changed the base branch from main to release/v3.8.26 June 15, 2026 15:35
OpenCode v1.17.x's Bun-based plugin loader resolves the package main/exports
and applies CJS-to-ESM interop on the dual CJS bundle, so mod.default becomes
the whole exports namespace instead of the V1 plugin object. V1 detection then
fails and the legacy path iterates the namespace and throws
'Plugin export is not a function' — the OmniRoute provider never registers.

Ship ESM-only (format: [esm], cjsInterop: false) with main -> ./dist/index.js
and a ./runtime subpath export, matching the predecessor opencode-omniroute-auth
package that loaded correctly. Adds a regression test asserting the ESM-only
package.json/tsup shape so a CJS bundle can't be re-introduced.

Scoped down to just the loader fix; the CLI setup-opencode + baseURL changes
from the original PR are deferred to a separate follow-up.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
@diegosouzapw
diegosouzapw force-pushed the fix/opencode-plugin-cjs-interop branch from 5ac782b to 7c6ce30 Compare June 15, 2026 15:39
@diegosouzapw

Copy link
Copy Markdown
Owner

Thanks, @herjarsa — and great root-cause writeup. 🙏 The diagnosis is spot-on: OpenCode v1.17.x's Bun loader applies CJS-to-ESM interop on the dual bundle, so mod.default becomes the exports namespace, V1 detection fails, and the legacy path throws Plugin export is not a function. Shipping ESM-only with the ./runtime subpath is exactly the right fix.

To land this quickly and safely, I scoped the PR down to just the loader fix (package.json ESM-only + ./runtime, tsup.config.ts format: [esm] / cjsInterop: false) and added a regression test (tests/unit/build/opencode-plugin-esm-only.test.ts) that fails if a CJS bundle is ever re-introduced. Co-authored on your branch and merging into release/v3.8.26.

A couple of the other changes I deliberately left out of this merge, so they can be handled separately:

Really appreciate the fix — this unblocks the OmniRoute provider in OpenCode v1.17.x. 🚀

@diegosouzapw
diegosouzapw merged commit a475389 into diegosouzapw:release/v3.8.26 Jun 15, 2026
2 checks passed
@herjarsa

Copy link
Copy Markdown
Contributor Author

Added 3 follow-up commits addressing the bot review comments + an additional fix:

  1. 512224cae fix(setup): address bot review comments on setup-open-code

    • Removes the broken CJS bundle check (dist/index.cjs no longer exists)
    • Adds Windows USERPROFILE override in test sandbox (hermetic tests)
    • Adds providerId/baseURL input validation (security: prevent command injection on Windows)
    • Removes unused pluginTargetDir parameter and ORIGINAL_HOME constant
  2. 5ac782b5b fix(autoCombo): advertise MIN context window across candidates

    • computeAdvertisedLimits() was using Math.max (largest window) but should be Math.min (bottleneck)
    • For nested combos (combo A containing combo B), clients were sending oversized prompts that the smallest-window member could not handle
    • Switches to Math.min so the advertised context reflects the real bottleneck
  3. 9b4694e48 fix(opencode-plugin): disable CJS interop and namespace combo api.id

    • tsup cjsInterop: true was emitting __esModule / default shims that confused OpenCode's plugin loader
    • api.id for combos now bakes in the combo/<id> namespace so the AI SDK forwards the correct model name to OmniRoute's /v1/chat/completions
    • Picker key stays as the bare slug so OpenCode can resolve providerID + id

All 6 existing tests pass; typecheck clean. The branch was force-pushed to herjarsa/OmniRoute @ 512224c.

@herjarsa

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review and the scoped merge \ud83d\ude4f \ud83d\ude80

You're right on both counts:

  1. Context window (MAX vs MIN): I missed fix(combo): stop premature context compaction — real auto-combo windows + per-target compression limit #3680 entirely. The MAX strategy is correct \u2014 advertising the largest window keeps OpenCode's auto-compaction calibrated and avoids the "advertise 0 \u2192 agent forgets" regression. My MAX\u2192MIN flip would re-introduce that. I'll revert that commit locally.

    For my concrete case (nested combo with 200k outer + 32k inner), a better solution might be to surface a minContextWindow advisory field alongside contextLength \u2014 let clients that care about the bottleneck opt-in, without changing the default advertised window. I'll explore that on a separate branch and open a follow-up PR if it pans out.

  2. Setup opencode CLI: A setup-open-code command already exists on release/v3.8.26, so my added baseURL resolution fallbacks and bot-review fixes need to be reconciled against the current one. Will rebase against release/v3.8.26 and open a focused follow-up PR for the deltas.

Closing this discussion here \u2014 thanks for the quick turnaround on the loader fix!

diegosouzapw added a commit that referenced this pull request Jun 16, 2026
The plugin became ESM-only when the CJS bundle was dropped to fix the OpenCode loader
(#3883), so tests/scaffold.test.ts's 'CJS default export resolves via require()' test
fails at publish time with 'Cannot find module ../dist/index.cjs' (it only runs in the
npm-publish opencode-plugin job, so the cycle never caught it). Replaced with an ESM
import of the built dist/index.js asserting the same v1 { id, server } shape; dropped the
now-unused createRequire import. omniroute@3.8.26 itself already published fine.
@diegosouzapw diegosouzapw mentioned this pull request Jun 17, 2026
HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
The plugin became ESM-only when the CJS bundle was dropped to fix the OpenCode loader
(diegosouzapw#3883), so tests/scaffold.test.ts's 'CJS default export resolves via require()' test
fails at publish time with 'Cannot find module ../dist/index.cjs' (it only runs in the
npm-publish opencode-plugin job, so the cycle never caught it). Replaced with an ESM
import of the built dist/index.js asserting the same v1 { id, server } shape; dropped the
now-unused createRequire import. omniroute@3.8.26 itself already published fine.
Poid-ZA pushed a commit to Poid-ZA/OmniRoute that referenced this pull request Aug 5, 2026
The plugin became ESM-only when the CJS bundle was dropped to fix the OpenCode loader
(diegosouzapw#3883), so tests/scaffold.test.ts's 'CJS default export resolves via require()' test
fails at publish time with 'Cannot find module ../dist/index.cjs' (it only runs in the
npm-publish opencode-plugin job, so the cycle never caught it). Replaced with an ESM
import of the built dist/index.js asserting the same v1 { id, server } shape; dropped the
now-unused createRequire import. omniroute@3.8.26 itself already published fine.
tkgo11 pushed a commit to tkgo11/OmniRoute that referenced this pull request Sep 23, 2026
…iegosouzapw#3883)

Integrated into release/v3.8.26 (scoped to the ESM-only loader fix)
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
The plugin became ESM-only when the CJS bundle was dropped to fix the OpenCode loader
(diegosouzapw#3883), so tests/scaffold.test.ts's 'CJS default export resolves via require()' test
fails at publish time with 'Cannot find module ../dist/index.cjs' (it only runs in the
npm-publish opencode-plugin job, so the cycle never caught it). Replaced with an ESM
import of the built dist/index.js asserting the same v1 { id, server } shape; dropped the
now-unused createRequire import. omniroute@3.8.26 itself already published fine.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants