Skip to content

Commit ca4f9bd

Browse files
committed
fix: restore plugin bundling needed for compiled binaries
Reverts c95f776 which broke the desktop/web app. The bundling logic is required for compiled binaries (//) to dynamically import plugins from the filesystem. Without it, the binary cannot resolve modules in ~/.cache/opencode/node_modules/. The test preload fix (OPENCODE_DISABLE_DEFAULT_PLUGINS=true) remains to prevent CI failures from plugins that fail to bundle.
1 parent 3ada02e commit ca4f9bd

4 files changed

Lines changed: 313 additions & 8 deletions

File tree

packages/opencode/src/bun/index.ts

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,20 @@ export namespace BunProc {
6565
using _ = await Lock.write("bun-install")
6666

6767
const mod = path.join(Global.Path.cache, "node_modules", pkg)
68+
const bundledDir = path.join(Global.Path.cache, "bundled")
69+
const bundledFile = path.join(bundledDir, `${pkg.replace(/\//g, "-")}.js`)
6870
const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json"))
6971
const parsed = await pkgjson.json().catch(async () => {
70-
const result = { dependencies: {} }
72+
const result = { dependencies: {}, bundled: {} }
7173
await Bun.write(pkgjson.name!, JSON.stringify(result, null, 2))
7274
return result
7375
})
74-
if (parsed.dependencies[pkg] === version) return mod
76+
77+
// Check if already installed and bundled
78+
const bundledExists = await Bun.file(bundledFile).exists()
79+
if (parsed.dependencies[pkg] === version && bundledExists) {
80+
return bundledFile
81+
}
7582

7683
// Build command arguments
7784
const args = ["add", "--force", "--exact", "--cwd", Global.Path.cache, pkg + "@" + version]
@@ -107,8 +114,95 @@ export namespace BunProc {
107114
}
108115
}
109116

117+
// Bundle the plugin with all dependencies for compiled binary compatibility
118+
// This creates a single file that doesn't require subpath export resolution
119+
await Bun.file(bundledDir)
120+
.exists()
121+
.then(async (exists) => {
122+
if (!exists) await Bun.$`mkdir -p ${bundledDir}`
123+
})
124+
125+
// Find the entry point from package.json
126+
const installedPkgJson = Bun.file(path.join(mod, "package.json"))
127+
const installedPkg = await installedPkgJson.json().catch(() => ({}))
128+
const entryPoint = installedPkg.main || "index.js"
129+
const entryPath = path.join(mod, entryPoint)
130+
131+
log.info("bundling plugin for compiled binary compatibility", {
132+
pkg,
133+
entryPath,
134+
bundledFile,
135+
})
136+
137+
try {
138+
const result = await Bun.build({
139+
entrypoints: [entryPath],
140+
outdir: bundledDir,
141+
naming: `${pkg.replace(/\//g, "-")}.js`,
142+
target: "bun",
143+
format: "esm",
144+
// Bundle all dependencies to avoid subpath export resolution issues
145+
packages: "bundle",
146+
})
147+
148+
if (!result.success) {
149+
log.error("failed to bundle plugin", {
150+
pkg,
151+
logs: result.logs,
152+
})
153+
// Fall back to unbundled module
154+
return mod
155+
}
156+
157+
// Copy non-JS assets (HTML, CSS, etc.) that plugins may need at runtime
158+
// Some bundled code uses __dirname + ".." to find assets, so copy to both
159+
// the bundled dir and the parent cache dir for compatibility
160+
await copyPluginAssets(mod, bundledDir)
161+
await copyPluginAssets(mod, Global.Path.cache)
162+
} catch (e) {
163+
log.error("failed to bundle plugin", {
164+
pkg,
165+
error: (e as Error).message,
166+
})
167+
// Fall back to unbundled module
168+
return mod
169+
}
170+
110171
parsed.dependencies[pkg] = resolvedVersion
172+
if (!parsed.bundled) parsed.bundled = {}
173+
parsed.bundled[pkg] = bundledFile
111174
await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2))
112-
return mod
175+
return bundledFile
176+
}
177+
178+
async function copyPluginAssets(pluginDir: string, targetDir: string) {
179+
// Find and copy non-JS/TS assets that plugins might need at runtime
180+
const assetExtensions = [".html", ".css", ".json", ".txt", ".svg", ".png", ".jpg", ".gif"]
181+
182+
async function copyAssetsRecursive(srcDir: string, destDir: string) {
183+
const entries = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: srcDir, dot: false }))
184+
185+
for (const entry of entries) {
186+
const ext = path.extname(entry).toLowerCase()
187+
if (assetExtensions.includes(ext)) {
188+
const srcPath = path.join(srcDir, entry)
189+
const destPath = path.join(destDir, path.basename(entry))
190+
191+
try {
192+
const content = await Bun.file(srcPath).arrayBuffer()
193+
await Bun.write(destPath, content)
194+
log.info("copied plugin asset", { src: entry, dest: destPath })
195+
} catch (e) {
196+
log.error("failed to copy plugin asset", {
197+
src: srcPath,
198+
dest: destPath,
199+
error: (e as Error).message,
200+
})
201+
}
202+
}
203+
}
204+
}
205+
206+
await copyAssetsRecursive(pluginDir, targetDir)
113207
}
114208
}

packages/opencode/src/plugin/index.ts

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Hooks, PluginInput, Plugin as PluginInstance } from "@opencode-ai/plugin"
2+
import { pathToFileURL } from "node:url"
23
import { Config } from "../config/config"
34
import { Bus } from "../bus"
45
import { Log } from "../util/log"
@@ -7,10 +8,74 @@ import { Server } from "../server/server"
78
import { BunProc } from "../bun"
89
import { Instance } from "../project/instance"
910
import { Flag } from "../flag/flag"
11+
import { Global } from "../global"
12+
import * as path from "node:path"
13+
import * as crypto from "node:crypto"
1014

1115
export namespace Plugin {
1216
const log = Log.create({ service: "plugin" })
1317

18+
/**
19+
* Bundle a local plugin file with its dependencies.
20+
* This ensures that local plugins can use npm dependencies that are installed
21+
* in their parent directory's node_modules.
22+
*/
23+
async function bundleLocalPlugin(filePath: string): Promise<string> {
24+
const bundledDir = path.join(Global.Path.cache, "bundled-local")
25+
await Bun.file(bundledDir)
26+
.exists()
27+
.then(async (exists) => {
28+
if (!exists) await Bun.$`mkdir -p ${bundledDir}`
29+
})
30+
31+
// Create a hash of the file path and its modification time for cache invalidation
32+
const stat = await Bun.file(filePath)
33+
.stat()
34+
.catch(() => null)
35+
const mtime = stat?.mtimeMs ?? 0
36+
const hash = crypto.createHash("md5").update(`${filePath}:${mtime}`).digest("hex").slice(0, 12)
37+
const baseName = path.basename(filePath, path.extname(filePath))
38+
const bundledFile = path.join(bundledDir, `${baseName}-${hash}.js`)
39+
40+
// Check if already bundled
41+
if (await Bun.file(bundledFile).exists()) {
42+
log.info("using cached bundled local plugin", { path: filePath, bundled: bundledFile })
43+
return bundledFile
44+
}
45+
46+
log.info("bundling local plugin with dependencies", { path: filePath, bundled: bundledFile })
47+
48+
try {
49+
const result = await Bun.build({
50+
entrypoints: [filePath],
51+
outdir: bundledDir,
52+
naming: `${baseName}-${hash}.js`,
53+
target: "bun",
54+
format: "esm",
55+
// Bundle all dependencies to resolve imports like 'jsonc-parser'
56+
packages: "bundle",
57+
})
58+
59+
if (!result.success) {
60+
log.error("failed to bundle local plugin", {
61+
path: filePath,
62+
logs: result.logs,
63+
})
64+
// Fall back to direct import (will fail if deps are missing)
65+
return filePath
66+
}
67+
68+
return bundledFile
69+
} catch (e) {
70+
log.error("failed to bundle local plugin", {
71+
path: filePath,
72+
error: (e as Error).message,
73+
})
74+
// Fall back to direct import
75+
return filePath
76+
}
77+
}
78+
1479
const state = Instance.state(async () => {
1580
const client = createOpencodeClient({
1681
baseUrl: "http://localhost:4096",
@@ -33,16 +98,46 @@ export namespace Plugin {
3398
}
3499
for (let plugin of plugins) {
35100
log.info("loading plugin", { path: plugin })
101+
let pluginUrl: string
36102
if (!plugin.startsWith("file://")) {
37103
const lastAtIndex = plugin.lastIndexOf("@")
38104
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
39105
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
40-
plugin = await BunProc.install(pkg, version)
106+
// BunProc.install now returns the bundled file path directly
107+
const pluginPath = await BunProc.install(pkg, version)
108+
pluginUrl = pathToFileURL(pluginPath).href
109+
} else {
110+
// Resolve relative file:// paths against the working directory
111+
const filePath = plugin.substring("file://".length)
112+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(Instance.directory, filePath)
113+
// Bundle local plugins with their dependencies for compiled binary compatibility
114+
const bundledPath = await bundleLocalPlugin(absolutePath)
115+
pluginUrl = pathToFileURL(bundledPath).href
41116
}
42-
const mod = await import(plugin)
43-
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
44-
const init = await fn(input)
45-
hooks.push(init)
117+
try {
118+
// Use dynamic import() with absolute file:// URLs for ES module compatibility
119+
// pathToFileURL ensures proper URL encoding regardless of import.meta.url context
120+
const mod = await import(pluginUrl)
121+
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
122+
const init = await fn(input)
123+
hooks.push(init)
124+
}
125+
} catch (e) {
126+
const err = e as Error
127+
// Check for module resolution issues
128+
if (err.message?.includes("Cannot find module") || err.message?.includes("Cannot find package")) {
129+
log.error("failed to load plugin", {
130+
plugin,
131+
error: err.message,
132+
hint: "Make sure all plugin dependencies are installed. Run 'bun install' in the plugin directory.",
133+
})
134+
} else {
135+
log.error("failed to load plugin", {
136+
plugin,
137+
error: err.message,
138+
})
139+
}
140+
throw e
46141
}
47142
}
48143

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { describe, test, expect, beforeAll } from "bun:test"
2+
import { tmpdir } from "../fixture/fixture"
3+
import * as fs from "fs/promises"
4+
import * as path from "path"
5+
import { Instance } from "@/project/instance"
6+
import { Config } from "@/config/config"
7+
import { Global } from "@/global"
8+
9+
describe("Plugin", () => {
10+
beforeAll(() => {
11+
// Disable default plugins to isolate our test
12+
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "true"
13+
})
14+
15+
test("bundles local plugin with npm dependencies", async () => {
16+
await using tmp = await tmpdir({
17+
git: true,
18+
init: async (dir) => {
19+
// Create the .opencode/plugin directory structure
20+
const pluginDir = path.join(dir, ".opencode", "plugin")
21+
await fs.mkdir(pluginDir, { recursive: true })
22+
23+
// Create a package.json with jsonc-parser as a dependency
24+
await Bun.write(
25+
path.join(dir, ".opencode", "package.json"),
26+
JSON.stringify(
27+
{
28+
name: "test-plugin",
29+
version: "1.0.0",
30+
type: "module",
31+
dependencies: {
32+
"jsonc-parser": "^3.2.0",
33+
"@opencode-ai/plugin": "latest",
34+
},
35+
},
36+
null,
37+
2,
38+
),
39+
)
40+
41+
// Install dependencies
42+
await Bun.$`bun install`.cwd(path.join(dir, ".opencode")).quiet()
43+
44+
// Create a plugin that uses jsonc-parser
45+
await Bun.write(
46+
path.join(pluginDir, "test-plugin.ts"),
47+
`
48+
import { parse } from "jsonc-parser"
49+
import type { Plugin } from "@opencode-ai/plugin"
50+
51+
export const testPlugin: Plugin = async (input) => {
52+
// Use jsonc-parser to prove it was bundled
53+
const result = parse('{"test": true}')
54+
console.log("Plugin loaded with jsonc-parser:", result)
55+
return {}
56+
}
57+
`,
58+
)
59+
},
60+
})
61+
62+
await Instance.provide({
63+
directory: tmp.path,
64+
fn: async () => {
65+
const config = await Config.get()
66+
const plugins = config.plugin ?? []
67+
68+
// Should have found our local plugin
69+
const localPlugin = plugins.find((p) => p.includes("test-plugin.ts"))
70+
expect(localPlugin).toBeDefined()
71+
72+
// Verify the bundled-local directory will be used
73+
// The bundled plugins go to ~/.cache/opencode/bundled-local/
74+
expect(Global.Path.cache).toBeDefined()
75+
expect(plugins.length).toBeGreaterThan(0)
76+
expect(localPlugin).toContain("file://")
77+
},
78+
})
79+
})
80+
81+
test("caches bundled local plugins based on file modification time", async () => {
82+
await using tmp = await tmpdir({
83+
git: true,
84+
init: async (dir) => {
85+
const pluginDir = path.join(dir, ".opencode", "plugin")
86+
await fs.mkdir(pluginDir, { recursive: true })
87+
88+
await Bun.write(
89+
path.join(dir, ".opencode", "package.json"),
90+
JSON.stringify({ name: "test-cache", version: "1.0.0", type: "module" }, null, 2),
91+
)
92+
93+
await Bun.write(
94+
path.join(pluginDir, "cache-test.ts"),
95+
`
96+
import type { Plugin } from "@opencode-ai/plugin"
97+
export const cacheTestPlugin: Plugin = async () => ({})
98+
`,
99+
)
100+
},
101+
})
102+
103+
await Instance.provide({
104+
directory: tmp.path,
105+
fn: async () => {
106+
const config = await Config.get()
107+
const plugins = config.plugin ?? []
108+
expect(plugins.some((p) => p.includes("cache-test.ts"))).toBe(true)
109+
},
110+
})
111+
})
112+
})

packages/opencode/test/preload.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ process.env["XDG_CACHE_HOME"] = path.join(dir, "cache")
1616
process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
1717
process.env["XDG_STATE_HOME"] = path.join(dir, "state")
1818

19+
// Disable default plugins (opencode-anthropic-auth, opencode-copilot-auth) during tests
20+
// These plugins have dependencies that can fail to bundle in CI environments
21+
process.env["OPENCODE_DISABLE_DEFAULT_PLUGINS"] = "true"
22+
1923
// Pre-fetch models.json so tests don't need the macro fallback
2024
// Also write the cache version file to prevent global/index.ts from clearing the cache
2125
const cacheDir = path.join(dir, "cache", "opencode")

0 commit comments

Comments
 (0)