Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
93 changes: 48 additions & 45 deletions packages/opencode/src/bun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
opencode?: {
providers?: Record<string, string>
}
}

export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
log.info("running", {
Expand Down Expand Up @@ -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<PackageJson> {
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 ||
Expand All @@ -84,7 +111,6 @@ export namespace BunProc {
process.env.https_proxy
)

// Build command arguments
const args = [
"add",
"--force",
Expand All @@ -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
}
}
5 changes: 4 additions & 1 deletion packages/opencode/src/global/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
162 changes: 139 additions & 23 deletions packages/opencode/test/bun.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})