Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
98 changes: 55 additions & 43 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,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<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 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 ||
Expand All @@ -84,7 +114,6 @@ export namespace BunProc {
process.env.https_proxy
)

// Build command arguments
const args = [
"add",
"--force",
Expand All @@ -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
}
}
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,
}
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
Loading