From beb523ae3d755c61f5af21d8776dbe9df19a801a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 23 Jan 2026 17:44:58 +0100 Subject: [PATCH 1/8] feat(bun): track provider packages for automatic cleanup When a provider switches SDK packages, the old package is now automatically removed to avoid accumulating unused dependencies in the cache. - Add provider tracking in package.json under opencode.providers section - Modify BunProc.install() to accept optional providerID parameter - Remove old package when provider switches to a different SDK - Add comprehensive integration tests for provider tracking --- packages/opencode/src/bun/index.ts | 93 ++++++------ packages/opencode/src/global/index.ts | 5 +- packages/opencode/src/provider/provider.ts | 2 +- packages/opencode/test/bun.test.ts | 162 ++++++++++++++++++--- 4 files changed, 192 insertions(+), 70 deletions(-) diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index a18bbd14d8a..57feeddc9c1 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -5,12 +5,17 @@ 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" export namespace BunProc { const log = Log.create({ service: "bun" }) - const req = createRequire(import.meta.url) + + interface PackageJson { + dependencies?: Record + opencode?: { + providers?: Record + } + } export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject) { log.info("running", { @@ -61,21 +66,43 @@ 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 { + const file = Bun.file(path.join(Global.Path.cache, "package.json")) + return file.json().catch(() => ({})) + } + + async function writePackageJson(parsed: PackageJson) { + const file = Bun.file(path.join(Global.Path.cache, "package.json")) + await Bun.write(file.name!, JSON.stringify(parsed, null, 2)) + } + + async function track(provider: string, pkg: string) { + const parsed = await readPackageJson() + if (!parsed.opencode) parsed.opencode = {} + if (!parsed.opencode.providers) parsed.opencode.providers = {} + parsed.opencode.providers[provider] = pkg + await writePackageJson(parsed) + } + + export async function install(pkg: string, version = "latest", provider?: string) { using _ = await Lock.write("bun-install") const mod = path.join(Global.Path.cache, "node_modules", pkg) - const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json")) - const parsed = await pkgjson.json().catch(async () => { - const result = { dependencies: {} } - await Bun.write(pkgjson.name!, JSON.stringify(result, null, 2)) - return result - }) - const dependencies = parsed.dependencies ?? {} - if (!parsed.dependencies) parsed.dependencies = dependencies - const modExists = await Filesystem.exists(mod) - if (dependencies[pkg] === version && modExists) return mod + const parsed = await readPackageJson() + const oldPkg = provider ? parsed.opencode?.providers?.[provider] : undefined + const pkgSwitched = oldPkg && oldPkg !== pkg + + if (pkgSwitched) { + log.info("provider package changed", { provider, old: oldPkg, new: pkg }) + await BunProc.run(["remove", "--cwd", Global.Path.cache, oldPkg]).catch(() => {}) + } + + // Re-read after potential remove, check if already installed + const current = pkgSwitched ? await readPackageJson() : parsed + if (current.dependencies?.[pkg] === version && (await Filesystem.exists(mod))) { + if (provider) await track(provider, pkg) + return mod + } const proxied = !!( process.env.HTTP_PROXY || @@ -84,7 +111,6 @@ export namespace BunProc { process.env.https_proxy ) - // Build command arguments const args = [ "add", "--force", @@ -96,39 +122,16 @@ export namespace BunProc { pkg + "@" + version, ] - // Let Bun handle registry resolution: - // - If .npmrc files exist, Bun will use them automatically - // - If no .npmrc files exist, Bun will default to https://registry.npmjs.org - // - No need to pass --registry flag - log.info("installing package using Bun's default registry resolution", { - pkg, - version, - }) + log.info("installing package", { pkg, version }) - await BunProc.run(args, { - cwd: Global.Path.cache, - }).catch((e) => { - throw new InstallFailedError( - { pkg, version }, - { - cause: e, - }, - ) - }) - - // Resolve actual version from installed package when using "latest" - // This ensures subsequent starts use the cached version until explicitly updated - let resolvedVersion = version - if (version === "latest") { - const installedPkgJson = Bun.file(path.join(mod, "package.json")) - const installedPkg = await installedPkgJson.json().catch(() => null) - if (installedPkg?.version) { - resolvedVersion = installedPkg.version + await BunProc.run(args, { cwd: Global.Path.cache }).catch((e) => { + if (pkgSwitched && provider) { + log.info("install failed, keeping old provider package tracking", { provider, old: oldPkg }) } - } + throw new InstallFailedError({ pkg, version }, { cause: e }) + }) - parsed.dependencies[pkg] = resolvedVersion - await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2)) + if (provider) await track(provider, pkg) return mod } } diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 25595abcddc..625c31dca6e 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -19,7 +19,10 @@ export namespace Global { data, bin: path.join(data, "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, // Allow overriding models.dev URL for offline deployments diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index fdd4ccdfb61..49f523f08f7 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1039,7 +1039,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 diff --git a/packages/opencode/test/bun.test.ts b/packages/opencode/test/bun.test.ts index d607ae47820..1bcdd160d01 100644 --- a/packages/opencode/test/bun.test.ts +++ b/packages/opencode/test/bun.test.ts @@ -1,53 +1,169 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, test, beforeEach, afterEach } from "bun:test" import fs from "fs/promises" import path from "path" +import os from "os" 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") - // Verify that no hardcoded registry is present expect(content).not.toContain("--registry=") expect(content).not.toContain("hasNpmRcConfig") expect(content).not.toContain("NpmRc") }) - test("should use Bun's default registry resolution", async () => { - // Read the bun/index.ts file + test("should have correct bun add command structure", async () => { const bunIndexPath = path.join(__dirname, "../src/bun/index.ts") const content = await fs.readFile(bunIndexPath, "utf-8") - // Verify that it uses Bun's default resolution - expect(content).toContain("Bun's default registry resolution") - expect(content).toContain("Bun will use them automatically") - expect(content).toContain("No need to pass --registry flag") - }) - - test("should have correct command structure without registry", async () => { - // Read the bun/index.ts file - const bunIndexPath = path.join(__dirname, "../src/bun/index.ts") - const content = await fs.readFile(bunIndexPath, "utf-8") - - // Extract the install function const installFunctionMatch = content.match(/export async function install[\s\S]*?^ }/m) expect(installFunctionMatch).toBeTruthy() if (installFunctionMatch) { const installFunction = installFunctionMatch[0] - // Verify expected arguments are present expect(installFunction).toContain('"add"') expect(installFunction).toContain('"--force"') expect(installFunction).toContain('"--exact"') expect(installFunction).toContain('"--cwd"') - expect(installFunction).toContain("Global.Path.cache") - expect(installFunction).toContain('pkg + "@" + version') - - // Verify no registry argument is added expect(installFunction).not.toContain('"--registry"') - expect(installFunction).not.toContain('args.push("--registry') } }) }) + +describe("BunProc.install provider tracking", () => { + let tempDir: string + let originalCache: string | undefined + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), "opencode-bun-test-" + Math.random().toString(36).slice(2)) + await fs.mkdir(tempDir, { recursive: true }) + originalCache = process.env.OPENCODE_TEST_CACHE + process.env.OPENCODE_TEST_CACHE = tempDir + }) + + afterEach(async () => { + if (originalCache === undefined) { + delete process.env.OPENCODE_TEST_CACHE + } else { + process.env.OPENCODE_TEST_CACHE = originalCache + } + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}) + }) + + async function readPkgJson() { + return JSON.parse(await fs.readFile(path.join(tempDir, "package.json"), "utf-8")) + } + + async function pkgExists(pkg: string) { + return fs + .stat(path.join(tempDir, "node_modules", pkg)) + .then(() => true) + .catch(() => false) + } + + test("should track provider in opencode.providers section", async () => { + const { BunProc } = await import("../src/bun") + + await BunProc.install("zod", "latest", "anthropic") + + const pkgJson = await readPkgJson() + expect(pkgJson.opencode?.providers?.anthropic).toBe("zod") + }) + + test("should install package in node_modules", async () => { + const { BunProc } = await import("../src/bun") + + await BunProc.install("zod", "latest", "anthropic") + + expect(await pkgExists("zod")).toBe(true) + }) + + test("should update tracking when provider switches packages", async () => { + const { BunProc } = await import("../src/bun") + + await BunProc.install("zod", "latest", "anthropic") + let pkgJson = await readPkgJson() + expect(pkgJson.opencode?.providers?.anthropic).toBe("zod") + + await BunProc.install("superstruct", "latest", "anthropic") + pkgJson = await readPkgJson() + expect(pkgJson.opencode?.providers?.anthropic).toBe("superstruct") + }) + + test("should remove old package when provider switches", async () => { + const { BunProc } = await import("../src/bun") + + await BunProc.install("zod", "latest", "anthropic") + expect(await pkgExists("zod")).toBe(true) + + await BunProc.install("superstruct", "latest", "anthropic") + + // Old package should be removed + expect(await pkgExists("zod")).toBe(false) + // New package should be installed + expect(await pkgExists("superstruct")).toBe(true) + }) + + test("should remove old package even when new package is already cached", async () => { + const { BunProc } = await import("../src/bun") + + // Install zod for provider-a + await BunProc.install("zod", "latest", "provider-a") + expect(await pkgExists("zod")).toBe(true) + + // Install superstruct for provider-b (now superstruct is cached) + await BunProc.install("superstruct", "latest", "provider-b") + expect(await pkgExists("superstruct")).toBe(true) + + // Switch provider-a from zod to superstruct (superstruct already cached) + await BunProc.install("superstruct", "latest", "provider-a") + + // zod should be removed since provider-a no longer uses it + expect(await pkgExists("zod")).toBe(false) + + // Tracking should be updated + const pkgJson = await readPkgJson() + expect(pkgJson.opencode?.providers?.["provider-a"]).toBe("superstruct") + expect(pkgJson.opencode?.providers?.["provider-b"]).toBe("superstruct") + }) + + test("should not remove package if provider is not switching", async () => { + const { BunProc } = await import("../src/bun") + + // Install zod for anthropic + await BunProc.install("zod", "latest", "anthropic") + + // Install same package again (e.g., version check) + await BunProc.install("zod", "latest", "anthropic") + + // Package should still exist + expect(await pkgExists("zod")).toBe(true) + }) + + test("should work without providerID (backward compatible)", async () => { + const { BunProc } = await import("../src/bun") + + await BunProc.install("zod", "latest") + + expect(await pkgExists("zod")).toBe(true) + const pkgJson = await readPkgJson() + // No provider tracking when providerID not provided + expect(pkgJson.opencode?.providers).toBeUndefined() + }) + + test("should track multiple providers independently", async () => { + const { BunProc } = await import("../src/bun") + + await BunProc.install("zod", "latest", "anthropic") + await BunProc.install("superstruct", "latest", "openai") + + const pkgJson = await readPkgJson() + expect(pkgJson.opencode?.providers?.anthropic).toBe("zod") + expect(pkgJson.opencode?.providers?.openai).toBe("superstruct") + + expect(await pkgExists("zod")).toBe(true) + expect(await pkgExists("superstruct")).toBe(true) + }) +}) From 2a89e1b2b097cd2dce8fc8aefedfb4378a7a3851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 23 Jan 2026 18:01:24 +0100 Subject: [PATCH 2/8] fix(bun): address review feedback for provider tracking - Don't remove package if other providers still use it - Fix version comparison to handle "latest" correctly - Add test for shared package scenario --- packages/opencode/src/bun/index.ts | 13 ++++++++++--- packages/opencode/test/bun.test.ts | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index 57feeddc9c1..bbc6cd4be59 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -93,13 +93,20 @@ export namespace BunProc { const pkgSwitched = oldPkg && oldPkg !== pkg if (pkgSwitched) { - log.info("provider package changed", { provider, old: oldPkg, new: pkg }) - await BunProc.run(["remove", "--cwd", Global.Path.cache, oldPkg]).catch(() => {}) + const providers = parsed.opencode?.providers ?? {} + const used = Object.entries(providers).some(([p, name]) => p !== provider && name === oldPkg) + if (used) { + log.info("provider package changed but still used", { provider, old: oldPkg, new: pkg }) + } else { + log.info("provider package changed", { provider, old: oldPkg, new: pkg }) + await BunProc.run(["remove", "--cwd", Global.Path.cache, oldPkg]).catch(() => {}) + } } // Re-read after potential remove, check if already installed const current = pkgSwitched ? await readPackageJson() : parsed - if (current.dependencies?.[pkg] === version && (await Filesystem.exists(mod))) { + const installed = current.dependencies?.[pkg] + if (installed && (version === "latest" || installed === version) && (await Filesystem.exists(mod))) { if (provider) await track(provider, pkg) return mod } diff --git a/packages/opencode/test/bun.test.ts b/packages/opencode/test/bun.test.ts index 1bcdd160d01..137a8d10fb9 100644 --- a/packages/opencode/test/bun.test.ts +++ b/packages/opencode/test/bun.test.ts @@ -166,4 +166,23 @@ describe("BunProc.install provider tracking", () => { expect(await pkgExists("zod")).toBe(true) expect(await pkgExists("superstruct")).toBe(true) }) + + test("should not remove package if another provider still uses it", async () => { + const { BunProc } = await import("../src/bun") + + // Both providers use zod + await BunProc.install("zod", "latest", "anthropic") + await BunProc.install("zod", "latest", "openai") + + // anthropic switches to superstruct + await BunProc.install("superstruct", "latest", "anthropic") + + // zod should still exist because openai uses it + expect(await pkgExists("zod")).toBe(true) + expect(await pkgExists("superstruct")).toBe(true) + + const pkgJson = await readPkgJson() + expect(pkgJson.opencode?.providers?.anthropic).toBe("superstruct") + expect(pkgJson.opencode?.providers?.openai).toBe("zod") + }) }) From 43fc90e98476dae4dc432e3b4f1c749afae7608c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 23 Jan 2026 18:11:42 +0100 Subject: [PATCH 3/8] fix: install new package before removing old to prevent broken state --- packages/opencode/src/bun/index.ts | 40 ++++++++++++++++-------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index bbc6cd4be59..968add39ee5 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -90,24 +90,21 @@ export namespace BunProc { const mod = path.join(Global.Path.cache, "node_modules", pkg) const parsed = await readPackageJson() const oldPkg = provider ? parsed.opencode?.providers?.[provider] : undefined - const pkgSwitched = oldPkg && oldPkg !== pkg + const switched = oldPkg && oldPkg !== pkg - if (pkgSwitched) { - const providers = parsed.opencode?.providers ?? {} - const used = Object.entries(providers).some(([p, name]) => p !== provider && name === oldPkg) - if (used) { - log.info("provider package changed but still used", { provider, old: oldPkg, new: pkg }) - } else { - log.info("provider package changed", { provider, old: oldPkg, new: pkg }) - await BunProc.run(["remove", "--cwd", Global.Path.cache, oldPkg]).catch(() => {}) - } - } - - // Re-read after potential remove, check if already installed - const current = pkgSwitched ? await readPackageJson() : parsed - const installed = current.dependencies?.[pkg] + // Check if already installed + const installed = parsed.dependencies?.[pkg] if (installed && (version === "latest" || installed === version) && (await Filesystem.exists(mod))) { if (provider) await track(provider, pkg) + // Remove old package after tracking update, only if not used by others + if (switched) { + const providers = parsed.opencode?.providers ?? {} + const used = Object.entries(providers).some(([p, name]) => p !== provider && name === oldPkg) + if (!used) { + log.info("removing unused package", { pkg: oldPkg }) + await BunProc.run(["remove", "--cwd", Global.Path.cache, oldPkg]).catch(() => {}) + } + } return mod } @@ -132,13 +129,20 @@ export namespace BunProc { log.info("installing package", { pkg, version }) await BunProc.run(args, { cwd: Global.Path.cache }).catch((e) => { - if (pkgSwitched && provider) { - log.info("install failed, keeping old provider package tracking", { provider, old: oldPkg }) - } throw new InstallFailedError({ pkg, version }, { cause: e }) }) + // Install succeeded - update tracking and remove old package if (provider) await track(provider, pkg) + if (switched) { + const current = await readPackageJson() + const providers = current.opencode?.providers ?? {} + const used = Object.entries(providers).some(([p, name]) => p !== provider && name === oldPkg) + if (!used) { + log.info("removing unused package", { pkg: oldPkg }) + await BunProc.run(["remove", "--cwd", Global.Path.cache, oldPkg!]).catch(() => {}) + } + } return mod } } From 25a764cde79cb7759d47051a2a1046c7f5b76560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 23 Jan 2026 18:34:27 +0100 Subject: [PATCH 4/8] fix: always reinstall when version is latest --- packages/opencode/src/bun/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index 968add39ee5..0719b5acd51 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -92,9 +92,9 @@ export namespace BunProc { const oldPkg = provider ? parsed.opencode?.providers?.[provider] : undefined const switched = oldPkg && oldPkg !== pkg - // Check if already installed + // Check if already installed with exact version const installed = parsed.dependencies?.[pkg] - if (installed && (version === "latest" || installed === version) && (await Filesystem.exists(mod))) { + if (installed && version !== "latest" && installed === version && (await Filesystem.exists(mod))) { if (provider) await track(provider, pkg) // Remove old package after tracking update, only if not used by others if (switched) { From b4229987c27e991c803c4238126d9e168cdacdc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 23 Jan 2026 18:45:55 +0100 Subject: [PATCH 5/8] test: add edge cases for package.json states --- packages/opencode/test/bun.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/opencode/test/bun.test.ts b/packages/opencode/test/bun.test.ts index 137a8d10fb9..b2827652d3a 100644 --- a/packages/opencode/test/bun.test.ts +++ b/packages/opencode/test/bun.test.ts @@ -185,4 +185,30 @@ describe("BunProc.install provider tracking", () => { expect(pkgJson.opencode?.providers?.anthropic).toBe("superstruct") expect(pkgJson.opencode?.providers?.openai).toBe("zod") }) + + test("should work when package.json exists without opencode section", async () => { + const { BunProc } = await import("../src/bun") + + // Create package.json without opencode section + await fs.writeFile(path.join(tempDir, "package.json"), JSON.stringify({ dependencies: {} }, null, 2)) + + await BunProc.install("zod", "latest", "anthropic") + + const pkgJson = await readPkgJson() + expect(pkgJson.opencode?.providers?.anthropic).toBe("zod") + expect(await pkgExists("zod")).toBe(true) + }) + + test("should work when opencode section exists without providers", async () => { + const { BunProc } = await import("../src/bun") + + // Create package.json with opencode but no providers + await fs.writeFile(path.join(tempDir, "package.json"), JSON.stringify({ dependencies: {}, opencode: {} }, null, 2)) + + await BunProc.install("zod", "latest", "anthropic") + + const pkgJson = await readPkgJson() + expect(pkgJson.opencode?.providers?.anthropic).toBe("zod") + expect(await pkgExists("zod")).toBe(true) + }) }) From 3891358d278d55fb6df2b08241d8aacafd2bd89d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 23 Jan 2026 18:51:46 +0100 Subject: [PATCH 6/8] chore: clean up comments --- packages/opencode/src/bun/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index 0719b5acd51..8702a43f5c3 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -92,11 +92,10 @@ export namespace BunProc { const oldPkg = provider ? parsed.opencode?.providers?.[provider] : undefined const switched = oldPkg && oldPkg !== pkg - // Check if already installed with exact version + // Skip install if exact version already cached (always reinstall with "latest") const installed = parsed.dependencies?.[pkg] if (installed && version !== "latest" && installed === version && (await Filesystem.exists(mod))) { if (provider) await track(provider, pkg) - // Remove old package after tracking update, only if not used by others if (switched) { const providers = parsed.opencode?.providers ?? {} const used = Object.entries(providers).some(([p, name]) => p !== provider && name === oldPkg) @@ -132,7 +131,6 @@ export namespace BunProc { throw new InstallFailedError({ pkg, version }, { cause: e }) }) - // Install succeeded - update tracking and remove old package if (provider) await track(provider, pkg) if (switched) { const current = await readPackageJson() From 060731127f8d5bcb09ec43be579d300864b6597a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 23 Jan 2026 20:06:00 +0100 Subject: [PATCH 7/8] test(bun): verify exact semver version after install --- packages/opencode/test/bun.test.ts | 53 +++++++++++++++++------------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/opencode/test/bun.test.ts b/packages/opencode/test/bun.test.ts index b2827652d3a..4de235475d0 100644 --- a/packages/opencode/test/bun.test.ts +++ b/packages/opencode/test/bun.test.ts @@ -63,6 +63,13 @@ describe("BunProc.install provider tracking", () => { .catch(() => false) } + 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-]+)*))?$/ + + function isExactVersion(v: string) { + return typeof v === "string" && SEMVER_REGEX.test(v) + } + test("should track provider in opencode.providers section", async () => { const { BunProc } = await import("../src/bun") @@ -70,6 +77,7 @@ describe("BunProc.install provider tracking", () => { const pkgJson = await readPkgJson() expect(pkgJson.opencode?.providers?.anthropic).toBe("zod") + expect(isExactVersion(pkgJson.dependencies?.zod)).toBe(true) }) test("should install package in node_modules", async () => { @@ -78,18 +86,22 @@ describe("BunProc.install provider tracking", () => { await BunProc.install("zod", "latest", "anthropic") expect(await pkgExists("zod")).toBe(true) + const pkgJson = await readPkgJson() + expect(isExactVersion(pkgJson.dependencies?.zod)).toBe(true) }) test("should update tracking when provider switches packages", async () => { const { BunProc } = await import("../src/bun") await BunProc.install("zod", "latest", "anthropic") - let pkgJson = await readPkgJson() - expect(pkgJson.opencode?.providers?.anthropic).toBe("zod") + const pkgJson1 = await readPkgJson() + expect(pkgJson1.opencode?.providers?.anthropic).toBe("zod") + expect(isExactVersion(pkgJson1.dependencies?.zod)).toBe(true) await BunProc.install("superstruct", "latest", "anthropic") - pkgJson = await readPkgJson() - expect(pkgJson.opencode?.providers?.anthropic).toBe("superstruct") + const pkgJson2 = await readPkgJson() + expect(pkgJson2.opencode?.providers?.anthropic).toBe("superstruct") + expect(isExactVersion(pkgJson2.dependencies?.superstruct)).toBe(true) }) test("should remove old package when provider switches", async () => { @@ -100,30 +112,23 @@ describe("BunProc.install provider tracking", () => { await BunProc.install("superstruct", "latest", "anthropic") - // Old package should be removed expect(await pkgExists("zod")).toBe(false) - // New package should be installed expect(await pkgExists("superstruct")).toBe(true) }) test("should remove old package even when new package is already cached", async () => { const { BunProc } = await import("../src/bun") - // Install zod for provider-a await BunProc.install("zod", "latest", "provider-a") expect(await pkgExists("zod")).toBe(true) - // Install superstruct for provider-b (now superstruct is cached) await BunProc.install("superstruct", "latest", "provider-b") expect(await pkgExists("superstruct")).toBe(true) - // Switch provider-a from zod to superstruct (superstruct already cached) await BunProc.install("superstruct", "latest", "provider-a") - // zod should be removed since provider-a no longer uses it expect(await pkgExists("zod")).toBe(false) - // Tracking should be updated const pkgJson = await readPkgJson() expect(pkgJson.opencode?.providers?.["provider-a"]).toBe("superstruct") expect(pkgJson.opencode?.providers?.["provider-b"]).toBe("superstruct") @@ -132,13 +137,9 @@ describe("BunProc.install provider tracking", () => { test("should not remove package if provider is not switching", async () => { const { BunProc } = await import("../src/bun") - // Install zod for anthropic await BunProc.install("zod", "latest", "anthropic") - - // Install same package again (e.g., version check) await BunProc.install("zod", "latest", "anthropic") - // Package should still exist expect(await pkgExists("zod")).toBe(true) }) @@ -149,8 +150,8 @@ describe("BunProc.install provider tracking", () => { expect(await pkgExists("zod")).toBe(true) const pkgJson = await readPkgJson() - // No provider tracking when providerID not provided expect(pkgJson.opencode?.providers).toBeUndefined() + expect(isExactVersion(pkgJson.dependencies?.zod)).toBe(true) }) test("should track multiple providers independently", async () => { @@ -165,19 +166,17 @@ describe("BunProc.install provider tracking", () => { expect(await pkgExists("zod")).toBe(true) expect(await pkgExists("superstruct")).toBe(true) + expect(isExactVersion(pkgJson.dependencies?.zod)).toBe(true) + expect(isExactVersion(pkgJson.dependencies?.superstruct)).toBe(true) }) test("should not remove package if another provider still uses it", async () => { const { BunProc } = await import("../src/bun") - // Both providers use zod await BunProc.install("zod", "latest", "anthropic") await BunProc.install("zod", "latest", "openai") - - // anthropic switches to superstruct await BunProc.install("superstruct", "latest", "anthropic") - // zod should still exist because openai uses it expect(await pkgExists("zod")).toBe(true) expect(await pkgExists("superstruct")).toBe(true) @@ -189,7 +188,6 @@ describe("BunProc.install provider tracking", () => { test("should work when package.json exists without opencode section", async () => { const { BunProc } = await import("../src/bun") - // Create package.json without opencode section await fs.writeFile(path.join(tempDir, "package.json"), JSON.stringify({ dependencies: {} }, null, 2)) await BunProc.install("zod", "latest", "anthropic") @@ -197,12 +195,12 @@ describe("BunProc.install provider tracking", () => { const pkgJson = await readPkgJson() expect(pkgJson.opencode?.providers?.anthropic).toBe("zod") expect(await pkgExists("zod")).toBe(true) + expect(isExactVersion(pkgJson.dependencies?.zod)).toBe(true) }) test("should work when opencode section exists without providers", async () => { const { BunProc } = await import("../src/bun") - // Create package.json with opencode but no providers await fs.writeFile(path.join(tempDir, "package.json"), JSON.stringify({ dependencies: {}, opencode: {} }, null, 2)) await BunProc.install("zod", "latest", "anthropic") @@ -210,5 +208,16 @@ describe("BunProc.install provider tracking", () => { const pkgJson = await readPkgJson() expect(pkgJson.opencode?.providers?.anthropic).toBe("zod") expect(await pkgExists("zod")).toBe(true) + expect(isExactVersion(pkgJson.dependencies?.zod)).toBe(true) + }) + + test("should install exact version when specific version provided", async () => { + const { BunProc } = await import("../src/bun") + + await BunProc.install("zod", "3.23.0", "anthropic") + + const pkgJson = await readPkgJson() + expect(pkgJson.dependencies?.zod).toBe("3.23.0") + expect(await pkgExists("zod")).toBe(true) }) }) From 7d198b45fabe0ed11e9e4c480a7c014a947fd095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 23 Jan 2026 20:33:32 +0100 Subject: [PATCH 8/8] test(bun): harmonize version checks across all tests --- packages/opencode/test/bun.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/bun.test.ts b/packages/opencode/test/bun.test.ts index 4de235475d0..116636403b6 100644 --- a/packages/opencode/test/bun.test.ts +++ b/packages/opencode/test/bun.test.ts @@ -80,11 +80,12 @@ describe("BunProc.install provider tracking", () => { expect(isExactVersion(pkgJson.dependencies?.zod)).toBe(true) }) - test("should install package in node_modules", async () => { + test("should install package and return module path", async () => { const { BunProc } = await import("../src/bun") - await BunProc.install("zod", "latest", "anthropic") + const mod = await BunProc.install("zod", "latest", "anthropic") + expect(mod).toContain("node_modules/zod") expect(await pkgExists("zod")).toBe(true) const pkgJson = await readPkgJson() expect(isExactVersion(pkgJson.dependencies?.zod)).toBe(true) @@ -114,6 +115,10 @@ describe("BunProc.install provider tracking", () => { expect(await pkgExists("zod")).toBe(false) expect(await pkgExists("superstruct")).toBe(true) + + const pkgJson = await readPkgJson() + expect(pkgJson.dependencies?.zod).toBeUndefined() + expect(isExactVersion(pkgJson.dependencies?.superstruct)).toBe(true) }) test("should remove old package even when new package is already cached", async () => { @@ -132,6 +137,8 @@ describe("BunProc.install provider tracking", () => { const pkgJson = await readPkgJson() expect(pkgJson.opencode?.providers?.["provider-a"]).toBe("superstruct") expect(pkgJson.opencode?.providers?.["provider-b"]).toBe("superstruct") + expect(pkgJson.dependencies?.zod).toBeUndefined() + expect(isExactVersion(pkgJson.dependencies?.superstruct)).toBe(true) }) test("should not remove package if provider is not switching", async () => { @@ -183,6 +190,8 @@ describe("BunProc.install provider tracking", () => { const pkgJson = await readPkgJson() expect(pkgJson.opencode?.providers?.anthropic).toBe("superstruct") expect(pkgJson.opencode?.providers?.openai).toBe("zod") + expect(isExactVersion(pkgJson.dependencies?.zod)).toBe(true) + expect(isExactVersion(pkgJson.dependencies?.superstruct)).toBe(true) }) test("should work when package.json exists without opencode section", async () => {