summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorDax <[email protected]>2026-04-01 17:01:37 -0400
committerGitHub <[email protected]>2026-04-01 21:01:37 +0000
commitc9326fc199447025af13ce26192e3ce21db16980 (patch)
treeeb880c521991c8684fc9b5f261dd31d493bd376a /packages
parentd7481f459363efc11f206d3244b804e6a512c43a (diff)
downloadopencode-c9326fc199447025af13ce26192e3ce21db16980.tar.gz
opencode-c9326fc199447025af13ce26192e3ce21db16980.zip
refactor: replace BunProc with Npm module using @npmcli/arborist (#18308)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Brendan Allan <[email protected]> Co-authored-by: Aiden Cline <[email protected]>
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/package.json2
-rw-r--r--packages/opencode/src/bun/index.ts129
-rw-r--r--packages/opencode/src/bun/registry.ts50
-rw-r--r--packages/opencode/src/config/config.ts94
-rw-r--r--packages/opencode/src/format/formatter.ts7
-rw-r--r--packages/opencode/src/lsp/server.ts233
-rw-r--r--packages/opencode/src/npm/index.ts178
-rw-r--r--packages/opencode/src/plugin/shared.ts11
-rw-r--r--packages/opencode/src/provider/provider.ts4
-rw-r--r--packages/opencode/test/bun.test.ts137
-rw-r--r--packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts14
-rw-r--r--packages/opencode/test/config/config.test.ts40
-rw-r--r--packages/opencode/test/plugin/loader-shared.test.ts34
13 files changed, 283 insertions, 650 deletions
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index d97046ca9..5893dcaa5 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -53,6 +53,7 @@
"@types/bun": "catalog:",
"@types/cross-spawn": "6.0.6",
"@types/mime-types": "3.0.1",
+ "@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
@@ -94,6 +95,7 @@
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1",
+ "@npmcli/arborist": "9.4.0",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts
deleted file mode 100644
index 589414a02..000000000
--- a/packages/opencode/src/bun/index.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import z from "zod"
-import { Global } from "../global"
-import { Log } from "../util/log"
-import path from "path"
-import { Filesystem } from "../util/filesystem"
-import { NamedError } from "@opencode-ai/util/error"
-import { Lock } from "../util/lock"
-import { PackageRegistry } from "./registry"
-import { online, proxied } from "@/util/network"
-import { Process } from "../util/process"
-
-export namespace BunProc {
- const log = Log.create({ service: "bun" })
-
- export async function run(cmd: string[], options?: Process.RunOptions) {
- const full = [which(), ...cmd]
- log.info("running", {
- cmd: full,
- ...options,
- })
- const result = await Process.run(full, {
- cwd: options?.cwd,
- abort: options?.abort,
- kill: options?.kill,
- timeout: options?.timeout,
- nothrow: options?.nothrow,
- env: {
- ...process.env,
- ...options?.env,
- BUN_BE_BUN: "1",
- },
- })
- log.info("done", {
- code: result.code,
- stdout: result.stdout.toString(),
- stderr: result.stderr.toString(),
- })
- return result
- }
-
- export function which() {
- return process.execPath
- }
-
- export const InstallFailedError = NamedError.create(
- "BunInstallFailedError",
- z.object({
- pkg: z.string(),
- version: z.string(),
- }),
- )
-
- export async function install(pkg: string, version = "latest", opts?: { ignoreScripts?: boolean }) {
- // Use lock to ensure only one install at a time
- using _ = await Lock.write("bun-install")
-
- const mod = path.join(Global.Path.cache, "node_modules", pkg)
- const pkgjsonPath = path.join(Global.Path.cache, "package.json")
- const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
- const result = { dependencies: {} as Record<string, string> }
- await Filesystem.writeJson(pkgjsonPath, result)
- return result
- })
- if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
- const dependencies = parsed.dependencies
- const modExists = await Filesystem.exists(mod)
- const cachedVersion = dependencies[pkg]
-
- if (!modExists || !cachedVersion) {
- // continue to install
- } else if (version === "latest") {
- if (!online()) return mod
- const stale = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
- if (!stale) return mod
- log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
- } else if (cachedVersion === version) {
- return mod
- }
-
- // Build command arguments
- const args = [
- "add",
- "--force",
- "--exact",
- ...(opts?.ignoreScripts ? ["--ignore-scripts"] : []),
- // TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
- ...(proxied() || process.env.CI ? ["--no-cache"] : []),
- "--cwd",
- Global.Path.cache,
- 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,
- })
-
- 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 installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
- () => null,
- )
- if (installedPkg?.version) {
- resolvedVersion = installedPkg.version
- }
- }
-
- parsed.dependencies[pkg] = resolvedVersion
- await Filesystem.writeJson(pkgjsonPath, parsed)
- return mod
- }
-}
diff --git a/packages/opencode/src/bun/registry.ts b/packages/opencode/src/bun/registry.ts
deleted file mode 100644
index dead5e74d..000000000
--- a/packages/opencode/src/bun/registry.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import semver from "semver"
-import { Log } from "../util/log"
-import { Process } from "../util/process"
-import { online } from "@/util/network"
-
-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> {
- if (!online()) {
- log.debug("offline, skipping bun info", { pkg, field })
- return null
- }
-
- const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], {
- cwd,
- env: {
- ...process.env,
- BUN_BE_BUN: "1",
- },
- nothrow: true,
- })
-
- if (code !== 0) {
- log.warn("bun info failed", { pkg, field, code, stderr: stderr.toString() })
- return null
- }
-
- const value = stdout.toString().trim()
- if (!value) return null
- return value
- }
-
- export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
- const latestVersion = await info(pkg, "version", cwd)
- if (!latestVersion) {
- log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
- return false
- }
-
- const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
- if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
-
- return semver.lt(cachedVersion, latestVersion)
- }
-}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index f86d8d32a..27618a3c3 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -20,7 +20,6 @@ import {
} from "jsonc-parser"
import { Instance, type InstanceContext } from "../project/instance"
import { LSPServer } from "../lsp/server"
-import { BunProc } from "@/bun"
import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import { constants, existsSync } from "fs"
@@ -28,20 +27,18 @@ import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Glob } from "../util/glob"
-import { PackageRegistry } from "@/bun/registry"
-import { online, proxied } from "@/util/network"
import { iife } from "@/util/iife"
import { Account } from "@/account"
import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
-import { Process } from "@/util/process"
import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Duration, Effect, Layer, Option, ServiceMap } from "effect"
import { Flock } from "@/util/flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
+import { Npm } from "@/npm"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -90,8 +87,7 @@ export namespace Config {
}
export async function installDependencies(dir: string, input?: InstallInput) {
- if (!(await needsInstall(dir))) return
-
+ if (!(await isWritable(dir))) return
await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, {
signal: input?.signal,
onWait: (tick) =>
@@ -102,13 +98,10 @@ export namespace Config {
waited: tick.waited,
}),
})
-
input?.signal?.throwIfAborted()
- if (!(await needsInstall(dir))) return
const pkg = path.join(dir, "package.json")
const target = Installation.isLocal() ? "*" : Installation.VERSION
-
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
dependencies: {},
}))
@@ -126,49 +119,7 @@ export namespace Config {
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
-
- // Bun can race cache writes on Windows when installs run in parallel across dirs.
- // Serialize installs globally on win32, but keep parallel installs on other platforms.
- await using __ =
- process.platform === "win32"
- ? await Flock.acquire("config-install:bun", {
- signal: input?.signal,
- })
- : undefined
-
- await BunProc.run(
- [
- "install",
- // TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
- ...(proxied() || process.env.CI ? ["--no-cache"] : []),
- ],
- {
- cwd: dir,
- abort: input?.signal,
- },
- ).catch((err) => {
- if (err instanceof Process.RunFailedError) {
- const detail = {
- dir,
- cmd: err.cmd,
- code: err.code,
- stdout: err.stdout.toString(),
- stderr: err.stderr.toString(),
- }
- if (Flag.OPENCODE_STRICT_CONFIG_DEPS) {
- log.error("failed to install dependencies", detail)
- throw err
- }
- log.warn("failed to install dependencies", detail)
- return
- }
-
- if (Flag.OPENCODE_STRICT_CONFIG_DEPS) {
- log.error("failed to install dependencies", { dir, error: err })
- throw err
- }
- log.warn("failed to install dependencies", { dir, error: err })
- })
+ await Npm.install(dir)
}
async function isWritable(dir: string) {
@@ -180,42 +131,6 @@ export namespace Config {
}
}
- export async function needsInstall(dir: string) {
- // Some config dirs may be read-only.
- // Installing deps there will fail; skip installation in that case.
- const writable = await isWritable(dir)
- if (!writable) {
- log.debug("config dir is not writable, skipping dependency install", { dir })
- return false
- }
-
- const mod = path.join(dir, "node_modules", "@opencode-ai", "plugin")
- if (!existsSync(mod)) return true
-
- const pkg = path.join(dir, "package.json")
- const pkgExists = await Filesystem.exists(pkg)
- if (!pkgExists) return true
-
- const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
- const dependencies = parsed?.dependencies ?? {}
- const depVersion = dependencies["@opencode-ai/plugin"]
- if (!depVersion) return true
-
- const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
- if (targetVersion === "latest") {
- if (!online()) return false
- const stale = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
- if (!stale) return false
- log.info("Cached version is outdated, proceeding with install", {
- pkg: "@opencode-ai/plugin",
- cachedVersion: depVersion,
- })
- return true
- }
- if (depVersion === targetVersion) return false
- return true
- }
-
function rel(item: string, patterns: string[]) {
const normalizedItem = item.replaceAll("\\", "/")
for (const pattern of patterns) {
@@ -1355,8 +1270,7 @@ export namespace Config {
}
const dep = iife(async () => {
- const stale = await needsInstall(dir)
- if (stale) await installDependencies(dir)
+ await installDependencies(dir)
})
void dep.catch((err) => {
log.warn("background dependency install failed", { dir, error: err })
diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts
index 9e96b2305..9051cecde 100644
--- a/packages/opencode/src/format/formatter.ts
+++ b/packages/opencode/src/format/formatter.ts
@@ -1,5 +1,4 @@
import { text } from "node:stream/consumers"
-import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
@@ -34,7 +33,7 @@ export const mix: Info = {
export const prettier: Info = {
name: "prettier",
- command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
+ command: ["bun", "x", "prettier", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
@@ -82,7 +81,7 @@ export const prettier: Info = {
export const oxfmt: Info = {
name: "oxfmt",
- command: [BunProc.which(), "x", "oxfmt", "$FILE"],
+ command: ["bun", "x", "oxfmt", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
@@ -104,7 +103,7 @@ export const oxfmt: Info = {
export const biome: Info = {
name: "biome",
- command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
+ command: ["bun", "x", "@biomejs/biome", "check", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts
index 123e8aea8..aa9bc884a 100644
--- a/packages/opencode/src/lsp/server.ts
+++ b/packages/opencode/src/lsp/server.ts
@@ -3,7 +3,6 @@ import path from "path"
import os from "os"
import { Global } from "../global"
import { Log } from "../util/log"
-import { BunProc } from "../bun"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
@@ -14,6 +13,7 @@ import { Process } from "../util/process"
import { which } from "../util/which"
import { Module } from "@opencode-ai/util/module"
import { spawn } from "./launch"
+import { Npm } from "@/npm"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -103,11 +103,12 @@ export namespace LSPServer {
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
log.info("typescript server", { tsserver })
if (!tsserver) return
- const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
+ const bin = await Npm.which("typescript-language-server")
+ if (!bin) return
+ const proc = spawn(bin, ["--stdio"], {
cwd: root,
env: {
...process.env,
- BUN_BE_BUN: "1",
},
})
return {
@@ -129,36 +130,16 @@ export namespace LSPServer {
let binary = which("vue-language-server")
const args: string[] = []
if (!binary) {
- const js = path.join(
- Global.Path.bin,
- "node_modules",
- "@vue",
- "language-server",
- "bin",
- "vue-language-server.js",
- )
- if (!(await Filesystem.exists(js))) {
- if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
- await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
- cwd: Global.Path.bin,
- env: {
- ...process.env,
- BUN_BE_BUN: "1",
- },
- stdout: "pipe",
- stderr: "pipe",
- stdin: "pipe",
- }).exited
- }
- binary = BunProc.which()
- args.push("run", js)
+ if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+ const resolved = await Npm.which("@vue/language-server")
+ if (!resolved) return
+ binary = resolved
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
- BUN_BE_BUN: "1",
},
})
return {
@@ -214,11 +195,10 @@ export namespace LSPServer {
log.info("installed VS Code ESLint server", { serverPath })
}
- const proc = spawn(BunProc.which(), [serverPath, "--stdio"], {
+ const proc = spawn("node", [serverPath, "--stdio"], {
cwd: root,
env: {
...process.env,
- BUN_BE_BUN: "1",
},
})
@@ -345,15 +325,15 @@ export namespace LSPServer {
if (!bin) {
const resolved = Module.resolve("biome", root)
if (!resolved) return
- bin = BunProc.which()
- args = ["x", "biome", "lsp-proxy", "--stdio"]
+ bin = await Npm.which("biome")
+ if (!bin) return
+ args = ["lsp-proxy", "--stdio"]
}
const proc = spawn(bin, args, {
cwd: root,
env: {
...process.env,
- BUN_BE_BUN: "1",
},
})
@@ -372,9 +352,7 @@ export namespace LSPServer {
},
extensions: [".go"],
async spawn(root) {
- let bin = which("gopls", {
- PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
- })
+ let bin = which("gopls")
if (!bin) {
if (!which("go")) return
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -409,9 +387,7 @@ export namespace LSPServer {
root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) {
- let bin = which("rubocop", {
- PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
- })
+ let bin = which("rubocop")
if (!bin) {
const ruby = which("ruby")
const gem = which("gem")
@@ -516,19 +492,10 @@ export namespace LSPServer {
let binary = which("pyright-langserver")
const args = []
if (!binary) {
- const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
- if (!(await Filesystem.exists(js))) {
- if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
- await Process.spawn([BunProc.which(), "install", "pyright"], {
- cwd: Global.Path.bin,
- env: {
- ...process.env,
- BUN_BE_BUN: "1",
- },
- }).exited
- }
- binary = BunProc.which()
- args.push(...["run", js])
+ if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+ const resolved = await Npm.which("pyright")
+ if (!resolved) return
+ binary = resolved
}
args.push("--stdio")
@@ -552,7 +519,6 @@ export namespace LSPServer {
cwd: root,
env: {
...process.env,
- BUN_BE_BUN: "1",
},
})
return {
@@ -630,9 +596,7 @@ export namespace LSPServer {
extensions: [".zig", ".zon"],
root: NearestRoot(["build.zig"]),
async spawn(root) {
- let bin = which("zls", {
- PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
- })
+ let bin = which("zls")
if (!bin) {
const zig = which("zig")
@@ -742,9 +706,7 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"],
async spawn(root) {
- let bin = which("csharp-ls", {
- PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
- })
+ let bin = which("csharp-ls")
if (!bin) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install csharp-ls")
@@ -781,9 +743,7 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
async spawn(root) {
- let bin = which("fsautocomplete", {
- PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
- })
+ let bin = which("fsautocomplete")
if (!bin) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install fsautocomplete")
@@ -1049,29 +1009,16 @@ export namespace LSPServer {
let binary = which("svelteserver")
const args: string[] = []
if (!binary) {
- const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
- if (!(await Filesystem.exists(js))) {
- if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
- await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
- cwd: Global.Path.bin,
- env: {
- ...process.env,
- BUN_BE_BUN: "1",
- },
- stdout: "pipe",
- stderr: "pipe",
- stdin: "pipe",
- }).exited
- }
- binary = BunProc.which()
- args.push("run", js)
+ if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+ const resolved = await Npm.which("svelte-language-server")
+ if (!resolved) return
+ binary = resolved
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
- BUN_BE_BUN: "1",
},
})
return {
@@ -1096,29 +1043,16 @@ export namespace LSPServer {
let binary = which("astro-ls")
const args: string[] = []
if (!binary) {
- const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
- if (!(await Filesystem.exists(js))) {
- if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
- await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
- cwd: Global.Path.bin,
- env: {
- ...process.env,
- BUN_BE_BUN: "1",
- },
- stdout: "pipe",
- stderr: "pipe",
- stdin: "pipe",
- }).exited
- }
- binary = BunProc.which()
- args.push("run", js)
+ if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+ const resolved = await Npm.which("@astrojs/language-server")
+ if (!resolved) return
+ binary = resolved
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
- BUN_BE_BUN: "1",
},
})
return {
@@ -1360,38 +1294,16 @@ export namespace LSPServer {
let binary = which("yaml-language-server")
const args: string[] = []
if (!binary) {
- const js = path.join(
- Global.Path.bin,
- "node_modules",
- "yaml-language-server",
- "out",
- "server",
- "src",
- "server.js",
- )
- const exists = await Filesystem.exists(js)
- if (!exists) {
- if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
- await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
- cwd: Global.Path.bin,
- env: {
- ...process.env,
- BUN_BE_BUN: "1",
- },
- stdout: "pipe",
- stderr: "pipe",
- stdin: "pipe",
- }).exited
- }
- binary = BunProc.which()
- args.push("run", js)
+ if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+ const resolved = await Npm.which("yaml-language-server")
+ if (!resolved) return
+ binary = resolved
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
- BUN_BE_BUN: "1",
},
})
return {
@@ -1413,9 +1325,7 @@ export namespace LSPServer {
]),
extensions: [".lua"],
async spawn(root) {
- let bin = which("lua-language-server", {
- PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
- })
+ let bin = which("lua-language-server")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1551,29 +1461,16 @@ export namespace LSPServer {
let binary = which("intelephense")
const args: string[] = []
if (!binary) {
- const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
- if (!(await Filesystem.exists(js))) {
- if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
- await Process.spawn([BunProc.which(), "install", "intelephense"], {
- cwd: Global.Path.bin,
- env: {
- ...process.env,
- BUN_BE_BUN: "1",
- },
- stdout: "pipe",
- stderr: "pipe",
- stdin: "pipe",
- }).exited
- }
- binary = BunProc.which()
- args.push("run", js)
+ if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+ const resolved = await Npm.which("intelephense")
+ if (!resolved) return
+ binary = resolved
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
- BUN_BE_BUN: "1",
},
})
return {
@@ -1648,29 +1545,16 @@ export namespace LSPServer {
let binary = which("bash-language-server")
const args: string[] = []
if (!binary) {
- const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
- if (!(await Filesystem.exists(js))) {
- if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
- await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
- cwd: Global.Path.bin,
- env: {
- ...process.env,
- BUN_BE_BUN: "1",
- },
- stdout: "pipe",
- stderr: "pipe",
- stdin: "pipe",
- }).exited
- }
- binary = BunProc.which()
- args.push("run", js)
+ if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+ const resolved = await Npm.which("bash-language-server")
+ if (!resolved) return
+ binary = resolved
}
args.push("start")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
- BUN_BE_BUN: "1",
},
})
return {
@@ -1684,9 +1568,7 @@ export namespace LSPServer {
extensions: [".tf", ".tfvars"],
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
async spawn(root) {
- let bin = which("terraform-ls", {
- PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
- })
+ let bin = which("terraform-ls")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1767,9 +1649,7 @@ export namespace LSPServer {
extensions: [".tex", ".bib"],
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
async spawn(root) {
- let bin = which("texlab", {
- PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
- })
+ let bin = which("texlab")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1860,29 +1740,16 @@ export namespace LSPServer {
let binary = which("docker-langserver")
const args: string[] = []
if (!binary) {
- const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
- if (!(await Filesystem.exists(js))) {
- if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
- await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
- cwd: Global.Path.bin,
- env: {
- ...process.env,
- BUN_BE_BUN: "1",
- },
- stdout: "pipe",
- stderr: "pipe",
- stdin: "pipe",
- }).exited
- }
- binary = BunProc.which()
- args.push("run", js)
+ if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+ const resolved = await Npm.which("dockerfile-language-server-nodejs")
+ if (!resolved) return
+ binary = resolved
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
- BUN_BE_BUN: "1",
},
})
return {
@@ -1966,9 +1833,7 @@ export namespace LSPServer {
extensions: [".typ", ".typc"],
root: NearestRoot(["typst.toml"]),
async spawn(root) {
- let bin = which("tinymist", {
- PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
- })
+ let bin = which("tinymist")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts
new file mode 100644
index 000000000..194c4b621
--- /dev/null
+++ b/packages/opencode/src/npm/index.ts
@@ -0,0 +1,178 @@
+import semver from "semver"
+import z from "zod"
+import { NamedError } from "@opencode-ai/util/error"
+import { Global } from "../global"
+import { Lock } from "../util/lock"
+import { Log } from "../util/log"
+import path from "path"
+import { readdir, rm } from "fs/promises"
+import { Filesystem } from "@/util/filesystem"
+import { Flock } from "@/util/flock"
+import { Arborist } from "@npmcli/arborist"
+
+export namespace Npm {
+ const log = Log.create({ service: "npm" })
+
+ export const InstallFailedError = NamedError.create(
+ "NpmInstallFailedError",
+ z.object({
+ pkg: z.string(),
+ }),
+ )
+
+ function directory(pkg: string) {
+ return path.join(Global.Path.cache, "packages", pkg)
+ }
+
+ function resolveEntryPoint(name: string, dir: string) {
+ const entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
+ const result = {
+ directory: dir,
+ entrypoint,
+ }
+ return result
+ }
+
+ export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
+ const response = await fetch(`https://registry.npmjs.org/${pkg}`)
+ if (!response.ok) {
+ log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
+ return false
+ }
+
+ const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
+ const latestVersion = data?.["dist-tags"]?.latest
+ if (!latestVersion) {
+ log.warn("No latest version found, using cached", { pkg, cachedVersion })
+ return false
+ }
+
+ const range = /[\s^~*xX<>|=]/.test(cachedVersion)
+ if (range) return !semver.satisfies(latestVersion, cachedVersion)
+
+ return semver.lt(cachedVersion, latestVersion)
+ }
+
+ export async function add(pkg: string) {
+ using _ = await Lock.write(`npm-install:${pkg}`)
+ log.info("installing package", {
+ pkg,
+ })
+ const dir = directory(pkg)
+
+ const arborist = new Arborist({
+ path: dir,
+ binLinks: true,
+ progress: false,
+ savePrefix: "",
+ })
+ const tree = await arborist.loadVirtual().catch(() => {})
+ if (tree) {
+ const first = tree.edgesOut.values().next().value?.to
+ if (first) {
+ return resolveEntryPoint(first.name, first.path)
+ }
+ }
+
+ const result = await arborist
+ .reify({
+ add: [pkg],
+ save: true,
+ saveType: "prod",
+ })
+ .catch((cause) => {
+ throw new InstallFailedError(
+ { pkg },
+ {
+ cause,
+ },
+ )
+ })
+
+ const first = result.edgesOut.values().next().value?.to
+ if (!first) throw new InstallFailedError({ pkg })
+ return resolveEntryPoint(first.name, first.path)
+ }
+
+ export async function install(dir: string) {
+ await using _ = await Flock.acquire(`npm-install:${dir}`)
+ log.info("checking dependencies", { dir })
+
+ const reify = async () => {
+ const arb = new Arborist({
+ path: dir,
+ binLinks: true,
+ progress: false,
+ savePrefix: "",
+ })
+ await arb.reify().catch(() => {})
+ }
+
+ if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
+ log.info("node_modules missing, reifying")
+ await reify()
+ return
+ }
+
+ const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
+ const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
+
+ const declared = new Set([
+ ...Object.keys(pkg.dependencies || {}),
+ ...Object.keys(pkg.devDependencies || {}),
+ ...Object.keys(pkg.peerDependencies || {}),
+ ...Object.keys(pkg.optionalDependencies || {}),
+ ])
+
+ const root = lock.packages?.[""] || {}
+ const locked = new Set([
+ ...Object.keys(root.dependencies || {}),
+ ...Object.keys(root.devDependencies || {}),
+ ...Object.keys(root.peerDependencies || {}),
+ ...Object.keys(root.optionalDependencies || {}),
+ ])
+
+ for (const name of declared) {
+ if (!locked.has(name)) {
+ log.info("dependency not in lock file, reifying", { name })
+ await reify()
+ return
+ }
+ }
+
+ log.info("dependencies in sync")
+ }
+
+ export async function which(pkg: string) {
+ const dir = directory(pkg)
+ const binDir = path.join(dir, "node_modules", ".bin")
+
+ const pick = async () => {
+ const files = await readdir(binDir).catch(() => [])
+ if (files.length === 0) return undefined
+ if (files.length === 1) return files[0]
+ // Multiple binaries — resolve from package.json bin field like npx does
+ const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
+ path.join(dir, "node_modules", pkg, "package.json"),
+ ).catch(() => undefined)
+ if (pkgJson?.bin) {
+ const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
+ const bin = pkgJson.bin
+ if (typeof bin === "string") return unscoped
+ const keys = Object.keys(bin)
+ if (keys.length === 1) return keys[0]
+ return bin[unscoped] ? unscoped : keys[0]
+ }
+ return files[0]
+ }
+
+ const bin = await pick()
+ if (bin) return path.join(binDir, bin)
+
+ await rm(path.join(dir, "package-lock.json"), { force: true })
+ await add(pkg)
+ const resolved = await pick()
+ if (!resolved) return
+ return path.join(binDir, resolved)
+ }
+}
diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts
index 3ccb1f65d..e8cbd3ae9 100644
--- a/packages/opencode/src/plugin/shared.ts
+++ b/packages/opencode/src/plugin/shared.ts
@@ -1,7 +1,7 @@
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import semver from "semver"
-import { BunProc } from "@/bun"
+import { Npm } from "@/npm"
import { Filesystem } from "@/util/filesystem"
import { isRecord } from "@/util/record"
@@ -106,7 +106,7 @@ async function resolveDirectoryIndex(dir: string) {
async function resolveTargetDirectory(target: string) {
const file = targetPath(target)
if (!file) return
- const stat = await Filesystem.stat(file)
+ const stat = Filesystem.stat(file)
if (!stat?.isDirectory()) return
return file
}
@@ -153,7 +153,7 @@ export function isPathPluginSpec(spec: string) {
export async function resolvePathPluginTarget(spec: string) {
const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec
const file = path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) ? raw : path.resolve(raw)
- const stat = await Filesystem.stat(file)
+ const stat = Filesystem.stat(file)
if (!stat?.isDirectory()) {
if (spec.startsWith("file://")) return spec
return pathToFileURL(file).href
@@ -184,12 +184,13 @@ export async function checkPluginCompatibility(target: string, opencodeVersion:
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
- return BunProc.install(parsed.pkg, parsed.version, { ignoreScripts: true })
+ const result = await Npm.add(parsed.pkg + "@" + parsed.version)
+ return result.directory
}
export async function readPluginPackage(target: string): Promise<PluginPackage> {
const file = target.startsWith("file://") ? fileURLToPath(target) : target
- const stat = await Filesystem.stat(file)
+ const stat = Filesystem.stat(file)
const dir = stat?.isDirectory() ? file : path.dirname(file)
const pkg = path.join(dir, "package.json")
const json = await Filesystem.readJson<Record<string, unknown>>(pkg)
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 7d9972f2a..3803984d2 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -5,7 +5,7 @@ import { Config } from "../config/config"
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai"
import { Log } from "../util/log"
-import { BunProc } from "../bun"
+import { Npm } from "../npm"
import { Hash } from "../util/hash"
import { Plugin } from "../plugin"
import { NamedError } from "@opencode-ai/util/error"
@@ -1365,7 +1365,7 @@ export namespace Provider {
let installedPath: string
if (!model.api.npm.startsWith("file://")) {
- installedPath = await BunProc.install(model.api.npm, "latest")
+ installedPath = await Npm.add(model.api.npm).then((item) => item.entrypoint)
} 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
deleted file mode 100644
index db3fa2a28..000000000
--- a/packages/opencode/test/bun.test.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { describe, expect, spyOn, test } from "bun:test"
-import fs from "fs/promises"
-import path from "path"
-import { BunProc } from "../src/bun"
-import { PackageRegistry } from "../src/bun/registry"
-import { Global } from "../src/global"
-import { Process } from "../src/util/process"
-
-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
- 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 pinning", () => {
- test("uses pinned cache without touching registry", async () => {
- const pkg = `pin-test-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
- const ver = "1.2.3"
- const mod = path.join(Global.Path.cache, "node_modules", pkg)
- const data = path.join(Global.Path.cache, "package.json")
-
- await fs.mkdir(mod, { recursive: true })
- await Bun.write(path.join(mod, "package.json"), JSON.stringify({ name: pkg, version: ver }, null, 2))
-
- const src = await fs.readFile(data, "utf8").catch(() => "")
- const json = src ? ((JSON.parse(src) as { dependencies?: Record<string, string> }) ?? {}) : {}
- const deps = json.dependencies ?? {}
- deps[pkg] = ver
- await Bun.write(data, JSON.stringify({ ...json, dependencies: deps }, null, 2))
-
- const stale = spyOn(PackageRegistry, "isOutdated").mockImplementation(async () => {
- throw new Error("unexpected registry check")
- })
- const run = spyOn(Process, "run").mockImplementation(async () => {
- throw new Error("unexpected process.run")
- })
-
- try {
- const out = await BunProc.install(pkg, ver)
- expect(out).toBe(mod)
- expect(stale).not.toHaveBeenCalled()
- expect(run).not.toHaveBeenCalled()
- } finally {
- stale.mockRestore()
- run.mockRestore()
-
- await fs.rm(mod, { recursive: true, force: true })
- const end = await fs
- .readFile(data, "utf8")
- .then((item) => JSON.parse(item) as { dependencies?: Record<string, string> })
- .catch(() => undefined)
- if (end?.dependencies) {
- delete end.dependencies[pkg]
- await Bun.write(data, JSON.stringify(end, null, 2))
- }
- }
- })
-
- test("passes --ignore-scripts when requested", async () => {
- const pkg = `ignore-test-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
- const ver = "4.5.6"
- const mod = path.join(Global.Path.cache, "node_modules", pkg)
- const data = path.join(Global.Path.cache, "package.json")
-
- const run = spyOn(Process, "run").mockImplementation(async () => ({
- code: 0,
- stdout: Buffer.alloc(0),
- stderr: Buffer.alloc(0),
- }))
-
- try {
- await fs.rm(mod, { recursive: true, force: true })
- await BunProc.install(pkg, ver, { ignoreScripts: true })
-
- expect(run).toHaveBeenCalled()
- const call = run.mock.calls[0]?.[0]
- expect(call).toContain("--ignore-scripts")
- expect(call).toContain(`${pkg}@${ver}`)
- } finally {
- run.mockRestore()
- await fs.rm(mod, { recursive: true, force: true })
-
- const end = await fs
- .readFile(data, "utf8")
- .then((item) => JSON.parse(item) as { dependencies?: Record<string, string> })
- .catch(() => undefined)
- if (end?.dependencies) {
- delete end.dependencies[pkg]
- await Bun.write(data, JSON.stringify(end, null, 2))
- }
- }
- })
-})
diff --git a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
index 5473a28a4..1e6da5913 100644
--- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
@@ -5,7 +5,7 @@ import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { TuiConfig } from "../../../src/config/tui"
-import { BunProc } from "../../../src/bun"
+import { Npm } from "../../../src/npm"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -56,7 +56,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
await TuiPluginRuntime.init(createTuiPluginApi())
@@ -118,7 +118,7 @@ test("does not use npm package exports dot for tui entry", async () => {
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
await TuiPluginRuntime.init(createTuiPluginApi())
@@ -181,7 +181,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
await TuiPluginRuntime.init(createTuiPluginApi())
@@ -244,7 +244,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
await TuiPluginRuntime.init(createTuiPluginApi())
@@ -303,7 +303,7 @@ test("does not use npm package main for tui entry", async () => {
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
const warn = spyOn(console, "warn").mockImplementation(() => {})
const error = spyOn(console, "error").mockImplementation(() => {})
@@ -475,7 +475,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
})
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
await TuiPluginRuntime.init(createTuiPluginApi())
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index ef71ca8cf..6369ab5ce 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -21,7 +21,7 @@ import { Global } from "../../src/global"
import { ProjectID } from "../../src/project/schema"
import { Filesystem } from "../../src/util/filesystem"
import * as Network from "../../src/util/network"
-import { BunProc } from "../../src/bun"
+import { Npm } from "../../src/npm"
const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()),
@@ -767,18 +767,13 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
const prev = process.env.OPENCODE_CONFIG_DIR
process.env.OPENCODE_CONFIG_DIR = tmp.extra
const online = spyOn(Network, "online").mockReturnValue(false)
- const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
- const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
+ const install = spyOn(Npm, "install").mockImplementation(async (dir: string) => {
+ const mod = path.join(dir, "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
path.join(mod, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
)
- return {
- code: 0,
- stdout: Buffer.alloc(0),
- stderr: Buffer.alloc(0),
- }
})
try {
@@ -795,7 +790,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
} finally {
online.mockRestore()
- run.mockRestore()
+ install.mockRestore()
if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR
else process.env.OPENCODE_CONFIG_DIR = prev
}
@@ -821,23 +816,23 @@ test("dedupes concurrent config dependency installs for the same dir", async ()
blocked = resolve
})
const online = spyOn(Network, "online").mockReturnValue(false)
- const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
- const hit = path.normalize(opts?.cwd ?? "") === path.normalize(dir)
+ const targetDir = dir
+ const run = spyOn(Npm, "install").mockImplementation(async (d: string) => {
+ const hit = path.normalize(d) === path.normalize(targetDir)
if (hit) {
calls += 1
start()
await gate
}
- const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
+ const mod = path.join(d, "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
path.join(mod, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "1.0.0" }),
)
- return {
- code: 0,
- stdout: Buffer.alloc(0),
- stderr: Buffer.alloc(0),
+ if (hit) {
+ start()
+ await gate
}
})
@@ -859,7 +854,7 @@ test("dedupes concurrent config dependency installs for the same dir", async ()
run.mockRestore()
}
- expect(calls).toBe(1)
+ expect(calls).toBe(2)
expect(ticks.length).toBeGreaterThan(0)
expect(await Filesystem.exists(path.join(dir, "package.json"))).toBe(true)
})
@@ -886,8 +881,8 @@ test("serializes config dependency installs across dirs", async () => {
})
const online = spyOn(Network, "online").mockReturnValue(false)
- const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => {
- const cwd = path.normalize(opts?.cwd ?? "")
+ const run = spyOn(Npm, "install").mockImplementation(async (dir: string) => {
+ const cwd = path.normalize(dir)
const hit = cwd === path.normalize(a) || cwd === path.normalize(b)
if (hit) {
calls += 1
@@ -898,7 +893,7 @@ test("serializes config dependency installs across dirs", async () => {
await gate
}
}
- const mod = path.join(opts?.cwd ?? "", "node_modules", "@opencode-ai", "plugin")
+ const mod = path.join(cwd, "node_modules", "@opencode-ai", "plugin")
await fs.mkdir(mod, { recursive: true })
await Filesystem.write(
path.join(mod, "package.json"),
@@ -907,11 +902,6 @@ test("serializes config dependency installs across dirs", async () => {
if (hit) {
open -= 1
}
- return {
- code: 0,
- stdout: Buffer.alloc(0),
- stderr: Buffer.alloc(0),
- }
})
try {
diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts
index 704c2e8e1..7830ac0da 100644
--- a/packages/opencode/test/plugin/loader-shared.test.ts
+++ b/packages/opencode/test/plugin/loader-shared.test.ts
@@ -10,7 +10,7 @@ process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
const { Plugin } = await import("../../src/plugin/index")
const { Instance } = await import("../../src/project/instance")
-const { BunProc } = await import("../../src/bun")
+const { Npm } = await import("../../src/npm")
const { Bus } = await import("../../src/bus")
const { Session } = await import("../../src/session")
@@ -258,18 +258,18 @@ describe("plugin.loader.shared", () => {
},
})
- const install = spyOn(BunProc, "install").mockImplementation(async (pkg) => {
- if (pkg === "acme-plugin") return tmp.extra.acme
- return tmp.extra.scope
+ const add = spyOn(Npm, "add").mockImplementation(async (pkg) => {
+ if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: tmp.extra.acme }
+ return { directory: tmp.extra.scope, entrypoint: tmp.extra.scope }
})
try {
await load(tmp.path)
- expect(install.mock.calls).toContainEqual(["acme-plugin", "latest", { ignoreScripts: true }])
- expect(install.mock.calls).toContainEqual(["scope-plugin", "2.3.4", { ignoreScripts: true }])
+ expect(add.mock.calls).toContainEqual(["acme-plugin@latest"])
+ expect(add.mock.calls).toContainEqual(["[email protected]"])
} finally {
- install.mockRestore()
+ add.mockRestore()
}
})
@@ -321,7 +321,7 @@ describe("plugin.loader.shared", () => {
},
})
- const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
await load(tmp.path)
@@ -378,7 +378,7 @@ describe("plugin.loader.shared", () => {
},
})
- const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
const errors = await errs(tmp.path)
@@ -431,7 +431,7 @@ describe("plugin.loader.shared", () => {
},
})
- const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
const errors = await errs(tmp.path)
@@ -477,7 +477,7 @@ describe("plugin.loader.shared", () => {
},
})
- const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
const errors = await errs(tmp.path)
@@ -541,7 +541,7 @@ describe("plugin.loader.shared", () => {
},
})
- const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
const errors = await errs(tmp.path)
@@ -572,15 +572,15 @@ describe("plugin.loader.shared", () => {
},
})
- const install = spyOn(BunProc, "install").mockResolvedValue("")
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: "" })
try {
await load(tmp.path)
const pkgs = install.mock.calls.map((call) => call[0])
- expect(pkgs).toContain("regular-plugin")
- expect(pkgs).not.toContain("opencode-openai-codex-auth")
- expect(pkgs).not.toContain("opencode-copilot-auth")
+ expect(pkgs).toContain("[email protected]")
+ expect(pkgs).not.toContain("[email protected]")
+ expect(pkgs).not.toContain("[email protected]")
} finally {
install.mockRestore()
}
@@ -593,7 +593,7 @@ describe("plugin.loader.shared", () => {
},
})
- const install = spyOn(BunProc, "install").mockRejectedValue(new Error("boom"))
+ const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
try {
const errors = await errs(tmp.path)