Browse Source

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]>
Dax 2 tuần trước cách đây
mục cha
commit
c9326fc199

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 250 - 158
bun.lock


+ 2 - 0
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:",

+ 0 - 129
packages/opencode/src/bun/index.ts

@@ -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
-  }
-}

+ 0 - 50
packages/opencode/src/bun/registry.ts

@@ -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)
-  }
-}

+ 4 - 90
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 })

+ 3 - 4
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",
   },

+ 49 - 184
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

+ 178 - 0
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)
+  }
+}

+ 6 - 5
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)

+ 2 - 2
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

+ 0 - 137
packages/opencode/test/bun.test.ts

@@ -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))
-      }
-    }
-  })
-})

+ 7 - 7
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())

+ 15 - 25
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 {

+ 17 - 17
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("regular-plugin@1.0.0")
+      expect(pkgs).not.toContain("opencode-openai-codex-auth@1.0.0")
+      expect(pkgs).not.toContain("opencode-copilot-auth@1.0.0")
     } 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)

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác