diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index a18bbd14d8a..8702a43f5c3 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,46 @@ 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 switched = oldPkg && oldPkg !== pkg + + // 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) + 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 + } const proxied = !!( process.env.HTTP_PROXY || @@ -84,7 +114,6 @@ export namespace BunProc { process.env.https_proxy ) - // Build command arguments const args = [ "add", "--force", @@ -96,39 +125,22 @@ 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, - }, - ) + 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 + 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(() => {}) } } - - parsed.dependencies[pkg] = resolvedVersion - await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2)) return mod } } diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index ade3e5d5295..a34c4a6a2e8 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, } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index f898d3be430..8408f783d7a 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1038,7 +1038,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..116636403b6 100644 --- a/packages/opencode/test/bun.test.ts +++ b/packages/opencode/test/bun.test.ts @@ -1,53 +1,232 @@ -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) + } + + 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") + + await BunProc.install("zod", "latest", "anthropic") + + const pkgJson = await readPkgJson() + expect(pkgJson.opencode?.providers?.anthropic).toBe("zod") + expect(isExactVersion(pkgJson.dependencies?.zod)).toBe(true) + }) + + test("should install package and return module path", async () => { + const { BunProc } = await import("../src/bun") + + 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) + }) + + test("should update tracking when provider switches packages", async () => { + const { BunProc } = await import("../src/bun") + + await BunProc.install("zod", "latest", "anthropic") + const pkgJson1 = await readPkgJson() + expect(pkgJson1.opencode?.providers?.anthropic).toBe("zod") + expect(isExactVersion(pkgJson1.dependencies?.zod)).toBe(true) + + await BunProc.install("superstruct", "latest", "anthropic") + 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 () => { + const { BunProc } = await import("../src/bun") + + await BunProc.install("zod", "latest", "anthropic") + expect(await pkgExists("zod")).toBe(true) + + await BunProc.install("superstruct", "latest", "anthropic") + + 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 () => { + const { BunProc } = await import("../src/bun") + + await BunProc.install("zod", "latest", "provider-a") + expect(await pkgExists("zod")).toBe(true) + + await BunProc.install("superstruct", "latest", "provider-b") + expect(await pkgExists("superstruct")).toBe(true) + + await BunProc.install("superstruct", "latest", "provider-a") + + expect(await pkgExists("zod")).toBe(false) + + 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 () => { + const { BunProc } = await import("../src/bun") + + await BunProc.install("zod", "latest", "anthropic") + await BunProc.install("zod", "latest", "anthropic") + + 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() + expect(pkgJson.opencode?.providers).toBeUndefined() + expect(isExactVersion(pkgJson.dependencies?.zod)).toBe(true) + }) + + 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) + 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") + + await BunProc.install("zod", "latest", "anthropic") + await BunProc.install("zod", "latest", "openai") + await BunProc.install("superstruct", "latest", "anthropic") + + 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") + 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 () => { + const { BunProc } = await import("../src/bun") + + 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) + expect(isExactVersion(pkgJson.dependencies?.zod)).toBe(true) + }) + + test("should work when opencode section exists without providers", async () => { + const { BunProc } = await import("../src/bun") + + 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) + 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) + }) +})