#10275 · @jerome-benoit · opened Jan 23, 2026 at 4:45 PM UTC · last updated Mar 21, 2026 at 11:31 AM UTC

feat(bun): track provider packages for automatic cleanup

appfeat
51
+4851065 files

Score breakdown

Impact

7.0

Clarity

8.0

Urgency

4.0

Ease Of Review

4.0

Guidelines

8.0

Readiness

5.0

Size

1.0

Trust

10.0

Traction

2.0

Summary

This PR implements a new system to track provider-to-package mappings in package.json to enable automatic cleanup of unused SDK packages. It aims to prevent package accumulation by only removing packages when no provider still uses them.

Open in GitHub

Description

Fixes #10276

What changed

Track provider→package mappings in package.json under opencode.providers. Reference counting ensures packages are only removed when no provider uses them.

Key changes:

  • Add opencode.providers section to track which provider uses which package
  • Cleanup old packages only after successful install (not before)
  • Check if other providers still use a package before removing it
  • Handle version=latest with PackageRegistry.isOutdated() for proper staleness detection
  • Refactor helpers: readPackageJson, writePackageJson, track, cleanup, resolveVersion, finalize
  • Refactor BunProc.run() to use Process.run() pattern (matches git.ts conventions)
  • Harmonize registry.ts to use BunProc.which() instead of duplicate implementation

How to verify

cd packages/opencode
bun test ./test/bun.test.ts
bun test --coverage ./test/bun.test.ts

25 tests cover the full decision tree for BunProc.install().

Linked Issues

#10276 Provider SDK packages accumulate in cache when switching

View issue

Comments

PR comments

jerome-benoit

@rekram1-node: hello, that PR is covering all packages cache use cases in tests, it will avoid as much as possible any regression in the future.

Changed Files

packages/opencode/src/bun/index.ts

+8961
@@ -12,6 +12,13 @@ import { Process } from "../util/process"
export namespace BunProc {
const log = Log.create({ service: "bun" })
interface PackageJson {
dependencies?: Record<string, string>
opencode?: {
providers?: Record<string, string>
}
}
export async function run(cmd: string[], options?: Process.RunOptions) {
const full = [which(), ...cmd]
log.info("running", {
@@ -50,78 +57,99 @@ export namespace BunProc {
}),
)
export async function install(pkg: string, version = "latest") {
// Use lock to ensure only one install at a time
async function readPackageJson(): Promise<PackageJson> {
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
return Filesystem.readJson<PackageJson>(pkgjsonPath).catch(() => ({}))
}
async function writePackageJson(parsed: PackageJson) {
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
await Filesystem.writeJson(pkgjsonPath, parsed)
}
async function track(provider: string, pkg: string) {
const parsed = await readPackageJson()
if (!parsed.opencode) parsed.opencode = {}
if (!parsed.opencode.providers) parsed.o

packages/opencode/src/bun/registry.ts

+56
@@ -1,16 +1,13 @@
import semver from "semver"
import { Log } from "../util/log"
import { Process } from "../util/process"
import { BunProc } from "."
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 { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], {
const { code, stdout, stderr } = await Process.run([BunProc.which(), "info", pkg, field], {
cwd,
env: {
...process.env,
@@ -29,7 +26,9 @@ export namespace PackageRegistry {
return value
}
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
export async function isOutdated(pkg: string, cachedVersion: string | undefined, cwd?: string): Promise<boolean> {
if (!cachedVersion) return true
const latestVersion = await info(pkg, "version", cwd)
if (!latestVersion) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })

packages/opencode/src/global/index.ts

+41
@@ -20,7 +20,10 @@ export namespace Global {
data,
bin: path.join(cache, "bin"),
log: path.join(data, "log"),
cache,
// Allow override via OPENCODE_TEST_CACHE for test isolation
get cache() {
return process.env.OPENCODE_TEST_CACHE || cache
},
config,
state,
}

packages/opencode/src/provider/provider.ts

+11
@@ -1296,7 +1296,7 @@ export namespace Provider {
let installedPath: string
if (!model.api.npm.startsWith("file://")) {
installedPath = await BunProc.install(model.api.npm, "latest")
installedPath = await BunProc.install(model.api.npm, "latest", model.providerID)
} else {
log.info("loading local provider", { pkg: model.api.npm })
installedPath = model.api.npm

packages/opencode/test/bun.test.ts

+38637
@@ -1,53 +1,402 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import * as fs from "fs/promises"
import path from "path"
import { tmpdir } from "./fixture/fixture"
describe("BunProc registry configuration", () => {
test("should not contain hardcoded registry parameters", async () => {
// Read the bun/index.ts file
const bunIndexPath = path.join(__dirname, "../src/bun/index.ts")
const content = await fs.readFile(bunIndexPath, "utf-8")
const SEMVER_REGEX =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
// Verify that no hardcoded registry is present
describe("BunProc.install - command structure", () => {
test("uses correct bun add flags", async () => {
const content = await Bun.file(path.join(__dirname, "../src/bun/index.ts")).text()
const match = content.match(/export async function install[\s\S]*?^ }/m)
expect(match).toBeTruthy()
const fn = match![0]
expect(fn).toContain('"add"')
expect(fn).toContain('"--force"')
expect(fn).toContain('"-