Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions packages/opencode/src/bun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any, any, any>) {
log.info("running", {
Expand Down Expand Up @@ -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 ||
Expand Down
44 changes: 44 additions & 0 deletions packages/opencode/src/bun/registry.ts
Original file line number Diff line number Diff line change
@@ -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<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
}
return semver.satisfies(cachedVersion, latestVersion)
}
}
46 changes: 37 additions & 9 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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, "{}")
Expand All @@ -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)
Expand Down
74 changes: 47 additions & 27 deletions packages/opencode/test/mcp/oauth-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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() {
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/test/provider/amazon-bedrock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/test/provider/gitlab-duo.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { PackageRegistry } from "@/bun/registry"
import { test, expect, mock } from "bun:test"
import path from "path"

Expand All @@ -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 = () => ({})
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => ({})
Expand Down