diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index a18bbd14d8a..572e1df6e1b 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -5,12 +5,11 @@ import path from "path" import { Filesystem } from "../util/filesystem" import { NamedError } from "@opencode-ai/util/error" import { readableStreamToText } from "bun" -import { createRequire } from "module" import { Lock } from "../util/lock" +import { PackageRegistry } from "./registry" export namespace BunProc { const log = Log.create({ service: "bun" }) - const req = createRequire(import.meta.url) export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject) { log.info("running", { @@ -75,7 +74,22 @@ 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) { + if (version !== "latest") { + if (cachedVersion === version) return mod + } + + 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 00000000000..a93754be290 --- /dev/null +++ b/packages/opencode/src/bun/registry.ts @@ -0,0 +1,44 @@ +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 { + 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 { + const latestVersion = await info(pkg, "version", cwd) + if (!latestVersion) { + log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion }) + return false + } + return semver.satisfies(cachedVersion, latestVersion) + } +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 020e626cba8..01ca380ef01 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" }) @@ -138,9 +139,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)) @@ -203,6 +205,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, "{}") @@ -212,18 +215,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 598a0315ef8..a1bcdc1380a 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -20,6 +20,30 @@ mock.module("open", () => ({ }, })) + +// Prevent Config.get() from performing networked `bun info` calls during tests. +// MCP.startAuth() calls Config.get(), and Config may check registry freshness. +mock.module("@/bun/registry", () => ({ + PackageRegistry: { + info: async () => null, + isOutdated: async () => false, + }, +})) + +async function waitFor(check: () => boolean, timeoutMs = 2000) { + const start = Date.now() + async function loop(): Promise { + if (check()) return + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for condition after ${timeoutMs}ms`) + } + await new Promise((resolve) => setTimeout(resolve, 10)) + return loop() + } + await loop() +} + + // Mock UnauthorizedError class MockUnauthorizedError extends Error { constructor() { @@ -133,20 +157,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)) + // Wait until we see the failure event (Config.get() can be slow in tests) + await waitFor(() => events.length === 1, 3000) // 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 +208,19 @@ 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 until open() is called (Config.get() can be slow in tests) + await waitFor(() => openCalledWith !== undefined, 3000) - // Wait for the browser open attempt and the 500ms error detection timeout - await new Promise((resolve) => setTimeout(resolve, 700)) + // See note in the previous test: give authenticate() time to move past + // the 500ms open() error-detection window. + await new Promise((resolve) => setTimeout(resolve, 600)) // 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 +257,20 @@ 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 until open() is called (Config.get() can be slow in tests) + await waitFor(() => openCalledWith !== undefined, 3000) - // Wait for the browser open attempt and the 500ms error detection timeout - await new Promise((resolve) => setTimeout(resolve, 700)) + // authenticate() waits ~500ms to detect async open() failures before it + // starts awaiting the OAuth callback promise. If we stop the callback + // server before that, the rejection can be reported as unhandled. + await new Promise((resolve) => setTimeout(resolve, 600)) // 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() diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 6b5cf681cda..fdbaf12171f 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -21,6 +21,10 @@ mock.module("../../src/bun/index", () => ({ which: () => process.execPath, InstallFailedError: class extends Error {}, }, + PackageRegistry: { + info: async () => null, + isOutdated: async () => false, + }, })) mock.module("@aws-sdk/credential-providers", () => ({ diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts index 4d5aa9c7461..b65e311dd9c 100644 --- a/packages/opencode/test/provider/gitlab-duo.test.ts +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -1,3 +1,4 @@ +import type { PackageRegistry } from "@/bun/registry" import { test, expect, mock } from "bun:test" import path from "path" @@ -17,6 +18,10 @@ mock.module("../../src/bun/index", () => ({ which: () => process.execPath, InstallFailedError: class extends Error {}, }, + PackageRegistry: { + info: async () => null, + isOutdated: async () => false, + }, })) const mockPlugin = () => ({}) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 8a2009646e0..49ec44c539c 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -15,6 +15,10 @@ mock.module("../../src/bun/index", () => ({ which: () => process.execPath, InstallFailedError: class extends Error {}, }, + PackageRegistry: { + info: async () => null, + isOutdated: async () => false, + }, })) const mockPlugin = () => ({})