Skip to content

fix(build): preserve ESM imports in published package#565

Open
pandaria75 wants to merge 1 commit into
NoeFabris:mainfrom
pandaria75:fix/esm-packaged-plugin-loading
Open

fix(build): preserve ESM imports in published package#565
pandaria75 wants to merge 1 commit into
NoeFabris:mainfrom
pandaria75:fix/esm-packaged-plugin-loading

Conversation

@pandaria75
Copy link
Copy Markdown

Summary

Fixes #564.

The published package could fail to load in OpenCode because emitted relative ESM specifiers in dist/**/*.js omitted explicit file extensions. When the plugin failed to load, OpenCode fell back to the default Google provider and surfaced a misleading GOOGLE_GENERATIVE_AI_API_KEY error instead of using the Antigravity OAuth path.

Changes

  • add a post-build script that rewrites emitted relative ESM specifiers in dist/**/*.js to explicit .js or /index.js targets
  • run that script from the build command after TypeScript compilation
  • import tool from @opencode-ai/plugin/tool instead of the package root to avoid a dependency root-entry resolution failure
  • update the related quota fallback test mock to use the same explicit subpath import

Validation

  • npm run build
  • npm run typecheck
  • npm test
  • verified direct import('./dist/index.js')
  • verified npm pack output by installing the tarball into a clean temporary project and importing opencode-antigravity-auth

Rewrite emitted relative ESM specifiers to explicit .js targets so packaged installs load correctly in OpenCode. Use the plugin tool subpath directly to avoid root-entry resolution failures and fix #564.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 5, 2026

Walkthrough

This PR addresses ESM import specifier issues in the published package. A post-build script (fix-esm-imports.mjs) is added that scans dist/ for relative import specifiers lacking explicit file extensions and rewrites them to include .js or /index.js suffixes, ensuring proper Node ESM resolution. The build pipeline is updated to execute this normalization after TypeScript compilation. Additionally, the source and test files are updated to import tool from @opencode-ai/plugin/tool instead of @opencode-ai/plugin to avoid dependency root-entry export problems.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and clearly summarizes the main change: adding a build-time fix to preserve ESM imports with explicit extensions in the published package output.
Description check ✅ Passed The description is well-detailed and directly related to the changeset, explaining the problem, changes made, and validation performed.
Linked Issues check ✅ Passed All coding objectives from issue #564 are met: post-build script rewrites relative ESM specifiers with .js/.js extensions, tool import uses explicit subpath, test mock updated, and validation steps completed.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the ESM import issue: build pipeline update, new fix-esm-imports script, import path change, and test adjustment.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 5, 2026

Greptile Summary

This PR fixes a packaging issue where emitted TypeScript output in dist/ lacked explicit .js extensions on relative ESM specifiers, causing the plugin to fail to load in OpenCode and fall back to a misleading Google provider error. It introduces a post-build rewrite script and switches the tool import to the @opencode-ai/plugin/tool subpath.

  • script/fix-esm-imports.mjs: New post-build script that walks dist/**/*.js, matches relative from/import()/side-effect import specifiers with three regex patterns, and rewrites each to an explicit .js or /index.js form by probing the filesystem.
  • src/plugin.ts + test: Import of tool moved from the package root to the @opencode-ai/plugin/tool subpath, with the corresponding vi.mock in the quota-fallback test updated to match.
  • package.json: build script extended to run the new script after tsc, and the package version bumped to 1.6.0.

Confidence Score: 4/5

Safe to merge — the fix correctly addresses the missing-extension problem and all three validation steps (build, typecheck, test) are reported as passing.

The rewrite script is straightforward and the regex patterns cover static imports, dynamic imports, re-exports, and side-effect imports. The one edge to watch is that an unresolvable specifier is returned unchanged without any diagnostic output, so a broken import could slip into the published dist silently. Everything else — the subpath import change, test mock update, and build wiring — looks correct and consistent.

script/fix-esm-imports.mjs — specifically the silent fallback in resolveSpecifier when neither .js nor index.js can be found on disk.

Important Files Changed

Filename Overview
script/fix-esm-imports.mjs New post-build script that rewrites extension-less relative ESM specifiers in dist/ to explicit .js / /index.js forms. Logic is correct; minor concern around silent fallback when resolution fails.
package.json Build script extended to run fix-esm-imports.mjs after tsc; script/ directory correctly excluded from published files.
src/plugin.ts Import of tool changed from package root to explicit /tool subpath to avoid root-entry resolution failure.
src/plugin/quota-fallback.test.ts Mock path updated from @opencode-ai/plugin to @opencode-ai/plugin/tool to match the source change in plugin.ts; no logic changes.
package-lock.json Lockfile updated to reflect version bump from 1.3.3-beta.2 to 1.6.0; no dependency tree changes.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["npm run build"] --> B["tsc -p tsconfig.build.json\n(emits dist/**/*.js)"]
    B --> C["node script/fix-esm-imports.mjs"]
    C --> D{"dist/ exists?"}
    D -- No --> E["exit silently"]
    D -- Yes --> F["listJsFiles(dist/)"]
    F --> G["for each .js file → fixFile()"]
    G --> H["apply 3 regex patterns\n(from / import() / side-effect)"]
    H --> I{"specifier\nhas extension?"}
    I -- Yes --> J["keep as-is"]
    I -- No --> K{"targetPath.js\nexists?"}
    K -- Yes --> L["append .js"]
    K -- No --> M{"targetPath/index.js\nexists?"}
    M -- Yes --> N["append /index.js"]
    M -- No --> O["⚠ return unchanged\n(silent fallback)"]
    L --> P["writeFile if changed"]
    N --> P
    J --> P
    O --> P
    P --> Q["console.log: Fixed N dist file(s)"]
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
script/fix-esm-imports.mjs:23-25
Silent fallback on unresolved specifier — when neither `${specifier}.js` nor `${specifier}/index.js` exists on disk, the function returns the original extension-less specifier unchanged. The output file will silently contain an unresolvable import, which only fails at runtime in the consuming project rather than failing the build step where it would be easy to catch.

```suggestion
  if (existsSync(`${targetPath}.js`)) return `${specifier}.js`
  if (existsSync(path.join(targetPath, "index.js"))) return `${specifier}/index.js`
  console.warn(`[fix-esm-imports] could not resolve specifier: ${specifier} (from ${filePath})`)
  return specifier
```

### Issue 2 of 2
script/fix-esm-imports.mjs:49-53
**Stateful `/g` regex applied per-file in a loop**`importPatterns` is a module-level array of regex literals all carrying the `/g` flag. `String.prototype.replace()` resets `lastIndex` to 0 before iterating, so this is safe in practice — but it is an easy trap if someone later changes the loop to use `.exec()` or `.test()`. Moving the regex construction inside `fixFile` (or inside `main` per-run) would eliminate the statefulness entirely and make the intent clearer.

Reviews (1): Last reviewed commit: "fix(build): preserve ESM imports in publ..." | Re-trigger Greptile

Comment on lines +23 to +25
if (existsSync(`${targetPath}.js`)) return `${specifier}.js`
if (existsSync(path.join(targetPath, "index.js"))) return `${specifier}/index.js`
return specifier
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.

P2 Silent fallback on unresolved specifier — when neither ${specifier}.js nor ${specifier}/index.js exists on disk, the function returns the original extension-less specifier unchanged. The output file will silently contain an unresolvable import, which only fails at runtime in the consuming project rather than failing the build step where it would be easy to catch.

Suggested change
if (existsSync(`${targetPath}.js`)) return `${specifier}.js`
if (existsSync(path.join(targetPath, "index.js"))) return `${specifier}/index.js`
return specifier
if (existsSync(`${targetPath}.js`)) return `${specifier}.js`
if (existsSync(path.join(targetPath, "index.js"))) return `${specifier}/index.js`
console.warn(`[fix-esm-imports] could not resolve specifier: ${specifier} (from ${filePath})`)
return specifier
Prompt To Fix With AI
This is a comment left during a code review.
Path: script/fix-esm-imports.mjs
Line: 23-25

Comment:
Silent fallback on unresolved specifier — when neither `${specifier}.js` nor `${specifier}/index.js` exists on disk, the function returns the original extension-less specifier unchanged. The output file will silently contain an unresolvable import, which only fails at runtime in the consuming project rather than failing the build step where it would be easy to catch.

```suggestion
  if (existsSync(`${targetPath}.js`)) return `${specifier}.js`
  if (existsSync(path.join(targetPath, "index.js"))) return `${specifier}/index.js`
  console.warn(`[fix-esm-imports] could not resolve specifier: ${specifier} (from ${filePath})`)
  return specifier
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +49 to +53
for (const pattern of importPatterns) {
output = output.replace(pattern, (match, prefix, specifier, suffix) => {
return `${prefix}${resolveSpecifier(filePath, specifier)}${suffix}`
})
}
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.

P2 Stateful /g regex applied per-file in a loopimportPatterns is a module-level array of regex literals all carrying the /g flag. String.prototype.replace() resets lastIndex to 0 before iterating, so this is safe in practice — but it is an easy trap if someone later changes the loop to use .exec() or .test(). Moving the regex construction inside fixFile (or inside main per-run) would eliminate the statefulness entirely and make the intent clearer.

Prompt To Fix With AI
This is a comment left during a code review.
Path: script/fix-esm-imports.mjs
Line: 49-53

Comment:
**Stateful `/g` regex applied per-file in a loop**`importPatterns` is a module-level array of regex literals all carrying the `/g` flag. `String.prototype.replace()` resets `lastIndex` to 0 before iterating, so this is safe in practice — but it is an easy trap if someone later changes the loop to use `.exec()` or `.test()`. Moving the regex construction inside `fixFile` (or inside `main` per-run) would eliminate the statefulness entirely and make the intent clearer.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/plugin/quota-fallback.test.ts (1)

31-46: 💤 Low value

Consider moving vi.mock to the top level.

Placing vi.mock inside beforeAll works for this dynamic-import pattern (the mock is registered before await import("../plugin") in the same callback), but it deviates from Vitest's idiomatic top-level placement and can surprise future maintainers who expect hoisting semantics.

The standard pattern for this use case is:

♻️ Proposed refactor
 import { beforeAll, describe, expect, it, vi } from "vitest";
 import type { HeaderStyle, ModelFamily } from "./accounts";
+
+vi.mock("@opencode-ai/plugin/tool", () => ({
+  tool: vi.fn(),
+}));

 // ... type declarations ...

 beforeAll(async () => {
-  vi.mock("@opencode-ai/plugin/tool", () => ({
-    tool: vi.fn(),
-  }));
-
   const { __testExports } = await import("../plugin");
   // ...
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/plugin/quota-fallback.test.ts` around lines 31 - 46, Move the vi.mock
call out of beforeAll to the test file's top-level so the mock is registered
before any imports; keep the same mock factory for "@opencode-ai/plugin/tool"
and then in beforeAll only dynamically import("../plugin") and extract
resolveQuotaFallbackHeaderStyle, getHeaderStyleFromUrl, and
resolveHeaderRoutingDecision from __testExports — remove the vi.mock from inside
beforeAll to follow Vitest hoisting/idiomatic placement while preserving the
existing dynamic-import pattern.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@script/fix-esm-imports.mjs`:
- Around line 19-26: resolveSpecifier currently returns the original specifier
silently when neither `${targetPath}.js` nor `${targetPath}/index.js` exists,
which can leave broken ESM imports; modify resolveSpecifier to detect this
unresolved case and emit a clear warning (e.g., console.warn) that includes the
filePath, specifier, and attempted resolution targets so the build author is
alerted; keep existing behavior for specifiers with extensions (hasExtension)
and successful resolutions, and reference resolveSpecifier, hasExtension, path,
and existsSync when locating the change.

---

Nitpick comments:
In `@src/plugin/quota-fallback.test.ts`:
- Around line 31-46: Move the vi.mock call out of beforeAll to the test file's
top-level so the mock is registered before any imports; keep the same mock
factory for "@opencode-ai/plugin/tool" and then in beforeAll only dynamically
import("../plugin") and extract resolveQuotaFallbackHeaderStyle,
getHeaderStyleFromUrl, and resolveHeaderRoutingDecision from __testExports —
remove the vi.mock from inside beforeAll to follow Vitest hoisting/idiomatic
placement while preserving the existing dynamic-import pattern.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: e92a85a5-f30c-4f66-8d19-fedead0c39de

📥 Commits

Reviewing files that changed from the base of the PR and between 740e315 and 33c2388.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (4)
  • package.json
  • script/fix-esm-imports.mjs
  • src/plugin.ts
  • src/plugin/quota-fallback.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🔇 Additional comments (2)
package.json (1)

37-37: LGTM.

Using && ensures fix-esm-imports.mjs only runs on a successful TypeScript build, and prepublishOnly already chains through npm run build, so the post-processing is automatically included in npm publish.

src/plugin.ts (1)

2-2: ⚡ Quick win

The @opencode-ai/plugin/tool subpath export is available in version 0.15.30. The import statement is valid and will not produce ERR_PACKAGE_PATH_NOT_EXPORTED at runtime. No action required.

			> Likely an incorrect or invalid review comment.

Comment on lines +19 to +26
function resolveSpecifier(filePath, specifier) {
if (hasExtension(specifier)) return specifier

const targetPath = path.resolve(path.dirname(filePath), specifier)
if (existsSync(`${targetPath}.js`)) return `${specifier}.js`
if (existsSync(path.join(targetPath, "index.js"))) return `${specifier}/index.js`
return specifier
}
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Silent passthrough leaves broken specifiers unfixed without any signal.

When neither ${specifier}.js nor ${specifier}/index.js resolves on disk, resolveSpecifier returns the original extension-less specifier unchanged. This means a build where TypeScript emits an import for a path that wasn't correctly compiled (e.g., missing output file, typo in source) will silently produce a broken ESM specifier in dist/ — with no build-time indication.

Adding a console.warn for unresolved specifiers makes the failure observable:

🛡️ Proposed fix
 function resolveSpecifier(filePath, specifier) {
   if (hasExtension(specifier)) return specifier
 
   const targetPath = path.resolve(path.dirname(filePath), specifier)
   if (existsSync(`${targetPath}.js`)) return `${specifier}.js`
   if (existsSync(path.join(targetPath, "index.js"))) return `${specifier}/index.js`
+  console.warn(`[fix-esm-imports] Could not resolve specifier "${specifier}" in ${filePath} — leaving unchanged`)
   return specifier
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function resolveSpecifier(filePath, specifier) {
if (hasExtension(specifier)) return specifier
const targetPath = path.resolve(path.dirname(filePath), specifier)
if (existsSync(`${targetPath}.js`)) return `${specifier}.js`
if (existsSync(path.join(targetPath, "index.js"))) return `${specifier}/index.js`
return specifier
}
function resolveSpecifier(filePath, specifier) {
if (hasExtension(specifier)) return specifier
const targetPath = path.resolve(path.dirname(filePath), specifier)
if (existsSync(`${targetPath}.js`)) return `${specifier}.js`
if (existsSync(path.join(targetPath, "index.js"))) return `${specifier}/index.js`
console.warn(`[fix-esm-imports] Could not resolve specifier "${specifier}" in ${filePath} — leaving unchanged`)
return specifier
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@script/fix-esm-imports.mjs` around lines 19 - 26, resolveSpecifier currently
returns the original specifier silently when neither `${targetPath}.js` nor
`${targetPath}/index.js` exists, which can leave broken ESM imports; modify
resolveSpecifier to detect this unresolved case and emit a clear warning (e.g.,
console.warn) that includes the filePath, specifier, and attempted resolution
targets so the build author is alerted; keep existing behavior for specifiers
with extensions (hasExtension) and successful resolutions, and reference
resolveSpecifier, hasExtension, path, and existsSync when locating the change.

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.

Published package fails to load in OpenCode due to broken ESM import specifiers

1 participant