summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/opencode/src/bun/index.ts13
-rw-r--r--packages/opencode/src/bun/registry.ts48
-rw-r--r--packages/opencode/src/config/config.ts46
-rw-r--r--packages/opencode/test/mcp/oauth-browser.test.ts42
4 files changed, 112 insertions, 37 deletions
diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts
index 8d061aed5..19edb6eec 100644
--- a/packages/opencode/src/bun/index.ts
+++ b/packages/opencode/src/bun/index.ts
@@ -6,6 +6,7 @@ import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error"
import { readableStreamToText } from "bun"
import { Lock } from "../util/lock"
+import { PackageRegistry } from "./registry"
export namespace BunProc {
const log = Log.create({ service: "bun" })
@@ -73,7 +74,17 @@ export namespace BunProc {
const dependencies = parsed.dependencies ?? {}
if (!parsed.dependencies) parsed.dependencies = dependencies
const modExists = await Filesystem.exists(mod)
- if (dependencies[pkg] === version && modExists) return mod
+ const cachedVersion = dependencies[pkg]
+
+ if (!modExists || !cachedVersion) {
+ // continue to install
+ } else if (version !== "latest" && cachedVersion === version) {
+ return mod
+ } else if (version === "latest") {
+ const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
+ if (!isOutdated) return mod
+ log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
+ }
const proxied = !!(
process.env.HTTP_PROXY ||
diff --git a/packages/opencode/src/bun/registry.ts b/packages/opencode/src/bun/registry.ts
new file mode 100644
index 000000000..c567668ac
--- /dev/null
+++ b/packages/opencode/src/bun/registry.ts
@@ -0,0 +1,48 @@
+import { readableStreamToText, semver } from "bun"
+import { Log } from "../util/log"
+
+export namespace PackageRegistry {
+ const log = Log.create({ service: "bun" })
+
+ function which() {
+ return process.execPath
+ }
+
+ export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
+ const result = Bun.spawn([which(), "info", pkg, field], {
+ cwd,
+ stdout: "pipe",
+ stderr: "pipe",
+ env: {
+ ...process.env,
+ BUN_BE_BUN: "1",
+ },
+ })
+
+ const code = await result.exited
+ const stdout = result.stdout ? await readableStreamToText(result.stdout) : ""
+ const stderr = result.stderr ? await readableStreamToText(result.stderr) : ""
+
+ if (code !== 0) {
+ log.warn("bun info failed", { pkg, field, code, stderr })
+ return null
+ }
+
+ const value = stdout.trim()
+ if (!value) return null
+ return value
+ }
+
+ export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
+ const latestVersion = await info(pkg, "version", cwd)
+ if (!latestVersion) {
+ log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
+ return false
+ }
+
+ const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
+ if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
+
+ return semver.order(cachedVersion, latestVersion) === -1
+ }
+}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 54ca94ae4..dae6db6f9 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -28,6 +28,7 @@ import { existsSync } from "fs"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
+import { PackageRegistry } from "@/bun/registry"
export namespace Config {
const log = Log.create({ service: "config" })
@@ -154,9 +155,10 @@ export namespace Config {
}
}
- const exists = existsSync(path.join(dir, "node_modules"))
- const installing = installDependencies(dir)
- if (!exists) await installing
+ const shouldInstall = await needsInstall(dir)
+ if (shouldInstall) {
+ await installDependencies(dir)
+ }
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
result.agent = mergeDeep(result.agent, await loadAgent(dir))
@@ -235,6 +237,7 @@ export namespace Config {
export async function installDependencies(dir: string) {
const pkg = path.join(dir, "package.json")
+ const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (!(await Bun.file(pkg).exists())) {
await Bun.write(pkg, "{}")
@@ -244,18 +247,43 @@ export namespace Config {
const hasGitIgnore = await Bun.file(gitignore).exists()
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
- await BunProc.run(
- ["add", "@opencode-ai/plugin@" + (Installation.isLocal() ? "latest" : Installation.VERSION), "--exact"],
- {
- cwd: dir,
- },
- ).catch(() => {})
+ await BunProc.run(["add", `@opencode-ai/plugin@${targetVersion}`, "--exact"], {
+ cwd: dir,
+ }).catch(() => {})
// Install any additional dependencies defined in the package.json
// This allows local plugins and custom tools to use external packages
await BunProc.run(["install"], { cwd: dir }).catch(() => {})
}
+ async function needsInstall(dir: string) {
+ const nodeModules = path.join(dir, "node_modules")
+ if (!existsSync(nodeModules)) return true
+
+ const pkg = path.join(dir, "package.json")
+ const pkgFile = Bun.file(pkg)
+ const pkgExists = await pkgFile.exists()
+ if (!pkgExists) return true
+
+ const parsed = await pkgFile.json().catch(() => null)
+ const dependencies = parsed?.dependencies ?? {}
+ const depVersion = dependencies["@opencode-ai/plugin"]
+ if (!depVersion) return true
+
+ const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
+ if (targetVersion === "latest") {
+ const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
+ if (!isOutdated) return false
+ log.info("Cached version is outdated, proceeding with install", {
+ pkg: "@opencode-ai/plugin",
+ cachedVersion: depVersion,
+ })
+ return true
+ }
+ if (depVersion === targetVersion) return false
+ return true
+ }
+
function rel(item: string, patterns: string[]) {
for (const pattern of patterns) {
const index = item.indexOf(pattern)
diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts
index 598a0315e..ee4429be7 100644
--- a/packages/opencode/test/mcp/oauth-browser.test.ts
+++ b/packages/opencode/test/mcp/oauth-browser.test.ts
@@ -8,6 +8,7 @@ let openCalledWith: string | undefined
mock.module("open", () => ({
default: async (url: string) => {
openCalledWith = url
+
// Return a mock subprocess that emits an error if openShouldFail is true
const subprocess = new EventEmitter()
if (openShouldFail) {
@@ -133,20 +134,17 @@ test("BrowserOpenFailed event is published when open() throws", async () => {
})
// Run authenticate with a timeout to avoid waiting forever for the callback
- const authPromise = MCP.authenticate("test-oauth-server")
+ // Attach a handler immediately so callback shutdown rejections
+ // don't show up as unhandled between tests.
+ const authPromise = MCP.authenticate("test-oauth-server").catch(() => undefined)
- // Wait for the browser open attempt (error fires at 10ms, but we wait for event to be published)
- await new Promise((resolve) => setTimeout(resolve, 200))
+ // Config.get() can be slow in tests, so give it plenty of time.
+ await new Promise((resolve) => setTimeout(resolve, 2_000))
// Stop the callback server and cancel any pending auth
await McpOAuthCallback.stop()
- // Wait for authenticate to reject (due to server stopping)
- try {
- await authPromise
- } catch {
- // Expected to fail
- }
+ await authPromise
unsubscribe()
@@ -187,20 +185,15 @@ test("BrowserOpenFailed event is NOT published when open() succeeds", async () =
})
// Run authenticate with a timeout to avoid waiting forever for the callback
- const authPromise = MCP.authenticate("test-oauth-server-2")
+ const authPromise = MCP.authenticate("test-oauth-server-2").catch(() => undefined)
- // Wait for the browser open attempt and the 500ms error detection timeout
- await new Promise((resolve) => setTimeout(resolve, 700))
+ // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
+ await new Promise((resolve) => setTimeout(resolve, 2_000))
// Stop the callback server and cancel any pending auth
await McpOAuthCallback.stop()
- // Wait for authenticate to reject (due to server stopping)
- try {
- await authPromise
- } catch {
- // Expected to fail
- }
+ await authPromise
unsubscribe()
@@ -237,20 +230,15 @@ test("open() is called with the authorization URL", async () => {
openCalledWith = undefined
// Run authenticate with a timeout to avoid waiting forever for the callback
- const authPromise = MCP.authenticate("test-oauth-server-3")
+ const authPromise = MCP.authenticate("test-oauth-server-3").catch(() => undefined)
- // Wait for the browser open attempt and the 500ms error detection timeout
- await new Promise((resolve) => setTimeout(resolve, 700))
+ // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
+ await new Promise((resolve) => setTimeout(resolve, 2_000))
// Stop the callback server and cancel any pending auth
await McpOAuthCallback.stop()
- // Wait for authenticate to reject (due to server stopping)
- try {
- await authPromise
- } catch {
- // Expected to fail
- }
+ await authPromise
// Verify open was called with a URL
expect(openCalledWith).toBeDefined()