Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
beff163
fix: slow plugins
neriousy Jan 20, 2026
9a84dba
Merge branch 'dev' into fix/slow-plugins
neriousy Jan 20, 2026
be79d7c
feat(core): add Npm module for version management and update BunProc …
neriousy Jan 20, 2026
71a274c
revert(desktop): undo slow server guard
neriousy Jan 20, 2026
1d954eb
refactor: streamline dependency installation logic and improve versio…
neriousy Jan 20, 2026
1038fc1
feat: handle custom registries
neriousy Jan 20, 2026
4d4c02c
feat(core): add semver dependency and enhance version retrieval npm m…
neriousy Jan 20, 2026
11a5976
fix: semver version
neriousy Jan 20, 2026
0f282dc
refactor: npm -> package-registry
neriousy Jan 20, 2026
d04f4d8
fix: types semver ver
neriousy Jan 20, 2026
9ce1240
wip
neriousy Jan 20, 2026
b9a2ff4
refactor
neriousy Jan 20, 2026
9d0a325
clean up
neriousy Jan 20, 2026
c37becb
Merge branch 'dev' into fix/slow-plugins
neriousy Jan 20, 2026
e6030de
mock test
neriousy Jan 21, 2026
caeaa38
fix: last one
neriousy Jan 21, 2026
0b783cd
new lock file
neriousy Jan 21, 2026
19c2aa4
remove semver dependency
neriousy Jan 21, 2026
87e0c43
Merge branch 'dev' into fix/slow-plugins
neriousy Jan 21, 2026
3170262
Merge branch 'dev' into fix/slow-plugins
neriousy Jan 21, 2026
7bcf7ef
Merge branch 'dev' into fix/slow-plugins
neriousy Jan 21, 2026
0609a92
restore lock file from dev
neriousy Jan 21, 2026
b57788b
remove mocks
neriousy Jan 26, 2026
3169695
revert changes
neriousy Jan 26, 2026
3fb7de1
fix: plugin package reinstall
neriousy Jan 26, 2026
ef0767e
Merge branch 'dev' into fix/slow-plugins
neriousy Jan 26, 2026
ac56535
remove dead codE
neriousy Jan 26, 2026
83f833c
review: refactor
neriousy Jan 26, 2026
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
15 changes: 12 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,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 ||
Expand Down
48 changes: 48 additions & 0 deletions packages/opencode/src/bun/registry.ts
Original file line number Diff line number Diff line change
@@ -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: {
Comment on lines +12 to +16
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't we use BunProc here instead? it already bakes in the which() logic

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah ig circular dep

...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
}
}
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
63 changes: 36 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,19 @@ mock.module("open", () => ({
},
}))

async function waitFor(check: () => boolean, timeoutMs = 8_000) {
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 +146,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)

// 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 +197,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 for the browser open attempt and the 500ms error detection timeout
await new Promise((resolve) => setTimeout(resolve, 700))
// Wait until open() is called (Config.get() can be slow in tests)
await waitFor(() => openCalledWith !== undefined)

// 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 +246,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 for the browser open attempt and the 500ms error detection timeout
await new Promise((resolve) => setTimeout(resolve, 700))
// Wait until open() is called (Config.get() can be slow in tests)
await waitFor(() => openCalledWith !== undefined)

// 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