Skip to content

Commit d116c22

Browse files
authored
fix(core): plugins are always reinstalled (#9675)
1 parent 3f07dff commit d116c22

File tree

4 files changed

+112
-37
lines changed

4 files changed

+112
-37
lines changed

packages/opencode/src/bun/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Filesystem } from "../util/filesystem"
66
import { NamedError } from "@opencode-ai/util/error"
77
import { readableStreamToText } from "bun"
88
import { Lock } from "../util/lock"
9+
import { PackageRegistry } from "./registry"
910

1011
export namespace BunProc {
1112
const log = Log.create({ service: "bun" })
@@ -73,7 +74,17 @@ export namespace BunProc {
7374
const dependencies = parsed.dependencies ?? {}
7475
if (!parsed.dependencies) parsed.dependencies = dependencies
7576
const modExists = await Filesystem.exists(mod)
76-
if (dependencies[pkg] === version && modExists) return mod
77+
const cachedVersion = dependencies[pkg]
78+
79+
if (!modExists || !cachedVersion) {
80+
// continue to install
81+
} else if (version !== "latest" && cachedVersion === version) {
82+
return mod
83+
} else if (version === "latest") {
84+
const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
85+
if (!isOutdated) return mod
86+
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
87+
}
7788

7889
const proxied = !!(
7990
process.env.HTTP_PROXY ||
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { readableStreamToText, semver } from "bun"
2+
import { Log } from "../util/log"
3+
4+
export namespace PackageRegistry {
5+
const log = Log.create({ service: "bun" })
6+
7+
function which() {
8+
return process.execPath
9+
}
10+
11+
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
12+
const result = Bun.spawn([which(), "info", pkg, field], {
13+
cwd,
14+
stdout: "pipe",
15+
stderr: "pipe",
16+
env: {
17+
...process.env,
18+
BUN_BE_BUN: "1",
19+
},
20+
})
21+
22+
const code = await result.exited
23+
const stdout = result.stdout ? await readableStreamToText(result.stdout) : ""
24+
const stderr = result.stderr ? await readableStreamToText(result.stderr) : ""
25+
26+
if (code !== 0) {
27+
log.warn("bun info failed", { pkg, field, code, stderr })
28+
return null
29+
}
30+
31+
const value = stdout.trim()
32+
if (!value) return null
33+
return value
34+
}
35+
36+
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
37+
const latestVersion = await info(pkg, "version", cwd)
38+
if (!latestVersion) {
39+
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
40+
return false
41+
}
42+
43+
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
44+
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
45+
46+
return semver.order(cachedVersion, latestVersion) === -1
47+
}
48+
}

packages/opencode/src/config/config.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { existsSync } from "fs"
2828
import { Bus } from "@/bus"
2929
import { GlobalBus } from "@/bus/global"
3030
import { Event } from "../server/event"
31+
import { PackageRegistry } from "@/bun/registry"
3132

3233
export namespace Config {
3334
const log = Log.create({ service: "config" })
@@ -154,9 +155,10 @@ export namespace Config {
154155
}
155156
}
156157

157-
const exists = existsSync(path.join(dir, "node_modules"))
158-
const installing = installDependencies(dir)
159-
if (!exists) await installing
158+
const shouldInstall = await needsInstall(dir)
159+
if (shouldInstall) {
160+
await installDependencies(dir)
161+
}
160162

161163
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
162164
result.agent = mergeDeep(result.agent, await loadAgent(dir))
@@ -235,6 +237,7 @@ export namespace Config {
235237

236238
export async function installDependencies(dir: string) {
237239
const pkg = path.join(dir, "package.json")
240+
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
238241

239242
if (!(await Bun.file(pkg).exists())) {
240243
await Bun.write(pkg, "{}")
@@ -244,18 +247,43 @@ export namespace Config {
244247
const hasGitIgnore = await Bun.file(gitignore).exists()
245248
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
246249

247-
await BunProc.run(
248-
["add", "@opencode-ai/plugin@" + (Installation.isLocal() ? "latest" : Installation.VERSION), "--exact"],
249-
{
250-
cwd: dir,
251-
},
252-
).catch(() => {})
250+
await BunProc.run(["add", `@opencode-ai/plugin@${targetVersion}`, "--exact"], {
251+
cwd: dir,
252+
}).catch(() => {})
253253

254254
// Install any additional dependencies defined in the package.json
255255
// This allows local plugins and custom tools to use external packages
256256
await BunProc.run(["install"], { cwd: dir }).catch(() => {})
257257
}
258258

259+
async function needsInstall(dir: string) {
260+
const nodeModules = path.join(dir, "node_modules")
261+
if (!existsSync(nodeModules)) return true
262+
263+
const pkg = path.join(dir, "package.json")
264+
const pkgFile = Bun.file(pkg)
265+
const pkgExists = await pkgFile.exists()
266+
if (!pkgExists) return true
267+
268+
const parsed = await pkgFile.json().catch(() => null)
269+
const dependencies = parsed?.dependencies ?? {}
270+
const depVersion = dependencies["@opencode-ai/plugin"]
271+
if (!depVersion) return true
272+
273+
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
274+
if (targetVersion === "latest") {
275+
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
276+
if (!isOutdated) return false
277+
log.info("Cached version is outdated, proceeding with install", {
278+
pkg: "@opencode-ai/plugin",
279+
cachedVersion: depVersion,
280+
})
281+
return true
282+
}
283+
if (depVersion === targetVersion) return false
284+
return true
285+
}
286+
259287
function rel(item: string, patterns: string[]) {
260288
for (const pattern of patterns) {
261289
const index = item.indexOf(pattern)

packages/opencode/test/mcp/oauth-browser.test.ts

Lines changed: 15 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ let openCalledWith: string | undefined
88
mock.module("open", () => ({
99
default: async (url: string) => {
1010
openCalledWith = url
11+
1112
// Return a mock subprocess that emits an error if openShouldFail is true
1213
const subprocess = new EventEmitter()
1314
if (openShouldFail) {
@@ -133,20 +134,17 @@ test("BrowserOpenFailed event is published when open() throws", async () => {
133134
})
134135

135136
// Run authenticate with a timeout to avoid waiting forever for the callback
136-
const authPromise = MCP.authenticate("test-oauth-server")
137+
// Attach a handler immediately so callback shutdown rejections
138+
// don't show up as unhandled between tests.
139+
const authPromise = MCP.authenticate("test-oauth-server").catch(() => undefined)
137140

138-
// Wait for the browser open attempt (error fires at 10ms, but we wait for event to be published)
139-
await new Promise((resolve) => setTimeout(resolve, 200))
141+
// Config.get() can be slow in tests, so give it plenty of time.
142+
await new Promise((resolve) => setTimeout(resolve, 2_000))
140143

141144
// Stop the callback server and cancel any pending auth
142145
await McpOAuthCallback.stop()
143146

144-
// Wait for authenticate to reject (due to server stopping)
145-
try {
146-
await authPromise
147-
} catch {
148-
// Expected to fail
149-
}
147+
await authPromise
150148

151149
unsubscribe()
152150

@@ -187,20 +185,15 @@ test("BrowserOpenFailed event is NOT published when open() succeeds", async () =
187185
})
188186

189187
// Run authenticate with a timeout to avoid waiting forever for the callback
190-
const authPromise = MCP.authenticate("test-oauth-server-2")
188+
const authPromise = MCP.authenticate("test-oauth-server-2").catch(() => undefined)
191189

192-
// Wait for the browser open attempt and the 500ms error detection timeout
193-
await new Promise((resolve) => setTimeout(resolve, 700))
190+
// Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
191+
await new Promise((resolve) => setTimeout(resolve, 2_000))
194192

195193
// Stop the callback server and cancel any pending auth
196194
await McpOAuthCallback.stop()
197195

198-
// Wait for authenticate to reject (due to server stopping)
199-
try {
200-
await authPromise
201-
} catch {
202-
// Expected to fail
203-
}
196+
await authPromise
204197

205198
unsubscribe()
206199

@@ -237,20 +230,15 @@ test("open() is called with the authorization URL", async () => {
237230
openCalledWith = undefined
238231

239232
// Run authenticate with a timeout to avoid waiting forever for the callback
240-
const authPromise = MCP.authenticate("test-oauth-server-3")
233+
const authPromise = MCP.authenticate("test-oauth-server-3").catch(() => undefined)
241234

242-
// Wait for the browser open attempt and the 500ms error detection timeout
243-
await new Promise((resolve) => setTimeout(resolve, 700))
235+
// Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
236+
await new Promise((resolve) => setTimeout(resolve, 2_000))
244237

245238
// Stop the callback server and cancel any pending auth
246239
await McpOAuthCallback.stop()
247240

248-
// Wait for authenticate to reject (due to server stopping)
249-
try {
250-
await authPromise
251-
} catch {
252-
// Expected to fail
253-
}
241+
await authPromise
254242

255243
// Verify open was called with a URL
256244
expect(openCalledWith).toBeDefined()

0 commit comments

Comments
 (0)