From ca2099e69dbbc24d91a25ab07bb57c92d78747f4 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 19 Mar 2026 19:40:40 -0400 Subject: [PATCH] refactor: replace BunProc with Npm module using @npmcli/arborist --- packages/opencode/package.json | 2 + packages/opencode/src/bun/index.ts | 127 ------------ packages/opencode/src/bun/registry.ts | 44 ---- packages/opencode/src/config/config.ts | 90 ++------ packages/opencode/src/format/formatter.ts | 148 ++++++++------ packages/opencode/src/format/index.ts | 21 +- packages/opencode/src/lsp/server.ts | 203 +++---------------- packages/opencode/src/npm/index.ts | 180 ++++++++++++++++ packages/opencode/src/plugin/index.ts | 16 +- packages/opencode/src/provider/provider.ts | 4 +- packages/opencode/test/bun.test.ts | 53 ----- packages/opencode/test/config/config.test.ts | 36 +--- 12 files changed, 334 insertions(+), 590 deletions(-) delete mode 100644 packages/opencode/src/bun/index.ts delete mode 100644 packages/opencode/src/bun/registry.ts create mode 100644 packages/opencode/src/npm/index.ts delete mode 100644 packages/opencode/test/bun.test.ts diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 049573e3e5..5e37e2749a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -45,6 +45,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", @@ -87,6 +88,7 @@ "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", + "@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 d6c4538259..0000000000 --- a/packages/opencode/src/bun/index.ts +++ /dev/null @@ -1,127 +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 { proxied } from "@/util/proxied" -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") { - // 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 }>(pkgjsonPath).catch(async () => { - const result = { dependencies: {} as Record } - await Filesystem.writeJson(pkgjsonPath, result) - return result - }) - if (!parsed.dependencies) parsed.dependencies = {} as Record - const dependencies = parsed.dependencies - const modExists = await Filesystem.exists(mod) - const cachedVersion = dependencies[pkg] - - if (!modExists || !cachedVersion) { - // continue to install - } else if (version !== "latest" && cachedVersion === version) { - return mod - } else if (version === "latest") { - const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache) - if (!isOutdated) return mod - log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion }) - } - - // Build command arguments - const args = [ - "add", - "--force", - "--exact", - // 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 e43e20e6c5..0000000000 --- a/packages/opencode/src/bun/registry.ts +++ /dev/null @@ -1,44 +0,0 @@ -import semver from "semver" -import { Log } from "../util/log" -import { Process } from "../util/process" - -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 { - 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 { - 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 47afdfd7d0..594ebc17ae 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1,6 +1,6 @@ import { Log } from "../util/log" import path from "path" -import { pathToFileURL, fileURLToPath } from "url" +import { pathToFileURL } from "url" import { createRequire } from "module" import os from "os" import z from "zod" @@ -22,7 +22,6 @@ import { } from "jsonc-parser" import { Instance } 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" @@ -30,14 +29,11 @@ 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 { proxied } from "@/util/proxied" import { iife } from "@/util/iife" import { Account } from "@/account" import { ConfigPaths } from "./paths" import { Filesystem } from "@/util/filesystem" -import { Process } from "@/util/process" -import { Lock } from "@/util/lock" +import { Npm } from "@/npm" export namespace Config { const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) @@ -154,8 +150,7 @@ export namespace Config { deps.push( iife(async () => { - const shouldInstall = await needsInstall(dir) - if (shouldInstall) await installDependencies(dir) + await installDependencies(dir) }), ) @@ -271,6 +266,10 @@ export namespace Config { } export async function installDependencies(dir: string) { + if (!(await isWritable(dir))) { + log.info("config dir is not writable, skipping dependency install", { dir }) + return + } const pkg = path.join(dir, "package.json") const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION @@ -284,43 +283,15 @@ export namespace Config { await Filesystem.writeJson(pkg, json) const gitignore = path.join(dir, ".gitignore") - const hasGitIgnore = await Filesystem.exists(gitignore) - if (!hasGitIgnore) - await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n")) + if (!(await Filesystem.exists(gitignore))) + await Filesystem.write( + gitignore, + ["node_modules", "plans", "package.json", "bun.lock", ".gitignore", "package-lock.json"].join("\n"), + ) // Install any additional dependencies defined in the package.json // This allows local plugins and custom tools to use external packages - using _ = await Lock.write("bun-install") - 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 }, - ).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) { @@ -332,41 +303,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 nodeModules = path.join(dir, "node_modules") - if (!existsSync(nodeModules)) 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 }>(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") { - const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir) - if (!isOutdated) 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) { diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 9e96b2305c..5424520cab 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -1,40 +1,40 @@ 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" import { which } from "../util/which" import { Flag } from "@/flag/flag" +import { Npm } from "@/npm" export interface Info { name: string - command: string[] environment?: Record extensions: string[] - enabled(): Promise + enabled(): Promise } export const gofmt: Info = { name: "gofmt", - command: ["gofmt", "-w", "$FILE"], extensions: [".go"], async enabled() { - return which("gofmt") !== null + const p = which("gofmt") + if (p === null) return false + return [p, "-w", "$FILE"] }, } export const mix: Info = { name: "mix", - command: ["mix", "format", "$FILE"], extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"], async enabled() { - return which("mix") !== null + const p = which("mix") + if (p === null) return false + return [p, "format", "$FILE"] }, } export const prettier: Info = { name: "prettier", - command: [BunProc.which(), "x", "prettier", "--write", "$FILE"], environment: { BUN_BE_BUN: "1", }, @@ -73,8 +73,9 @@ export const prettier: Info = { dependencies?: Record devDependencies?: Record }>(item) - if (json.dependencies?.prettier) return true - if (json.devDependencies?.prettier) return true + if (json.dependencies?.prettier || json.devDependencies?.prettier) { + return [await Npm.which("prettier"), "--write", "$FILE"] + } } return false }, @@ -82,7 +83,6 @@ export const prettier: Info = { export const oxfmt: Info = { name: "oxfmt", - command: [BunProc.which(), "x", "oxfmt", "$FILE"], environment: { BUN_BE_BUN: "1", }, @@ -95,8 +95,9 @@ export const oxfmt: Info = { dependencies?: Record devDependencies?: Record }>(item) - if (json.dependencies?.oxfmt) return true - if (json.devDependencies?.oxfmt) return true + if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) { + return [await Npm.which("oxfmt"), "$FILE"] + } } return false }, @@ -104,7 +105,6 @@ export const oxfmt: Info = { export const biome: Info = { name: "biome", - command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"], environment: { BUN_BE_BUN: "1", }, @@ -141,7 +141,7 @@ export const biome: Info = { for (const config of configs) { const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree) if (found.length > 0) { - return true + return [await Npm.which("@biomejs/biome"), "check", "--write", "$FILE"] } } return false @@ -150,47 +150,49 @@ export const biome: Info = { export const zig: Info = { name: "zig", - command: ["zig", "fmt", "$FILE"], extensions: [".zig", ".zon"], async enabled() { - return which("zig") !== null + const p = which("zig") + if (p === null) return false + return [p, "fmt", "$FILE"] }, } export const clang: Info = { name: "clang-format", - command: ["clang-format", "-i", "$FILE"], extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"], async enabled() { const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree) - return items.length > 0 + if (items.length === 0) return false + return ["clang-format", "-i", "$FILE"] }, } export const ktlint: Info = { name: "ktlint", - command: ["ktlint", "-F", "$FILE"], extensions: [".kt", ".kts"], async enabled() { - return which("ktlint") !== null + const p = which("ktlint") + if (p === null) return false + return [p, "-F", "$FILE"] }, } export const ruff: Info = { name: "ruff", - command: ["ruff", "format", "$FILE"], extensions: [".py", ".pyi"], async enabled() { - if (!which("ruff")) return false + const p = which("ruff") + if (p === null) return false const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"] for (const config of configs) { const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree) if (found.length > 0) { if (config === "pyproject.toml") { const content = await Filesystem.readText(found[0]) - if (content.includes("[tool.ruff]")) return true + if (content.includes("[tool.ruff]")) return [p, "format", "$FILE"] } else { - return true + return [p, "format", "$FILE"] } } } @@ -199,7 +201,7 @@ export const ruff: Info = { const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree) if (found.length > 0) { const content = await Filesystem.readText(found[0]) - if (content.includes("ruff")) return true + if (content.includes("ruff")) return [p, "format", "$FILE"] } } return false @@ -208,14 +210,13 @@ export const ruff: Info = { export const rlang: Info = { name: "air", - command: ["air", "format", "$FILE"], extensions: [".R"], async enabled() { const airPath = which("air") if (airPath == null) return false try { - const proc = Process.spawn(["air", "--help"], { + const proc = Process.spawn([airPath, "--help"], { stdout: "pipe", stderr: "pipe", }) @@ -227,7 +228,10 @@ export const rlang: Info = { const firstLine = output.split("\n")[0] const hasR = firstLine.includes("R language") const hasFormatter = firstLine.includes("formatter") - return hasR && hasFormatter + if (hasR && hasFormatter) { + return [airPath, "format", "$FILE"] + } + return false } catch (error) { return false } @@ -236,14 +240,14 @@ export const rlang: Info = { export const uvformat: Info = { name: "uv", - command: ["uv", "format", "--", "$FILE"], extensions: [".py", ".pyi"], async enabled() { if (await ruff.enabled()) return false - if (which("uv") !== null) { - const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" }) + const uvPath = which("uv") + if (uvPath !== null) { + const proc = Process.spawn([uvPath, "format", "--help"], { stderr: "pipe", stdout: "pipe" }) const code = await proc.exited - return code === 0 + if (code === 0) return [uvPath, "format", "--", "$FILE"] } return false }, @@ -251,108 +255,118 @@ export const uvformat: Info = { export const rubocop: Info = { name: "rubocop", - command: ["rubocop", "--autocorrect", "$FILE"], extensions: [".rb", ".rake", ".gemspec", ".ru"], async enabled() { - return which("rubocop") !== null + const path = which("rubocop") + if (path === null) return false + return [path, "--autocorrect", "$FILE"] }, } export const standardrb: Info = { name: "standardrb", - command: ["standardrb", "--fix", "$FILE"], extensions: [".rb", ".rake", ".gemspec", ".ru"], async enabled() { - return which("standardrb") !== null + const path = which("standardrb") + if (path === null) return false + return [path, "--fix", "$FILE"] }, } export const htmlbeautifier: Info = { name: "htmlbeautifier", - command: ["htmlbeautifier", "$FILE"], extensions: [".erb", ".html.erb"], async enabled() { - return which("htmlbeautifier") !== null + const path = which("htmlbeautifier") + if (path === null) return false + return [path, "$FILE"] }, } export const dart: Info = { name: "dart", - command: ["dart", "format", "$FILE"], extensions: [".dart"], async enabled() { - return which("dart") !== null + const path = which("dart") + if (path === null) return false + return [path, "format", "$FILE"] }, } export const ocamlformat: Info = { name: "ocamlformat", - command: ["ocamlformat", "-i", "$FILE"], extensions: [".ml", ".mli"], async enabled() { - if (!which("ocamlformat")) return false + const path = which("ocamlformat") + if (!path) return false const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree) - return items.length > 0 + if (items.length === 0) return false + return [path, "-i", "$FILE"] }, } export const terraform: Info = { name: "terraform", - command: ["terraform", "fmt", "$FILE"], extensions: [".tf", ".tfvars"], async enabled() { - return which("terraform") !== null + const path = which("terraform") + if (path === null) return false + return [path, "fmt", "$FILE"] }, } export const latexindent: Info = { name: "latexindent", - command: ["latexindent", "-w", "-s", "$FILE"], extensions: [".tex"], async enabled() { - return which("latexindent") !== null + const path = which("latexindent") + if (path === null) return false + return [path, "-w", "-s", "$FILE"] }, } export const gleam: Info = { name: "gleam", - command: ["gleam", "format", "$FILE"], extensions: [".gleam"], async enabled() { - return which("gleam") !== null + const path = which("gleam") + if (path === null) return false + return [path, "format", "$FILE"] }, } export const shfmt: Info = { name: "shfmt", - command: ["shfmt", "-w", "$FILE"], extensions: [".sh", ".bash"], async enabled() { - return which("shfmt") !== null + const path = which("shfmt") + if (path === null) return false + return [path, "-w", "$FILE"] }, } export const nixfmt: Info = { name: "nixfmt", - command: ["nixfmt", "$FILE"], extensions: [".nix"], async enabled() { - return which("nixfmt") !== null + const path = which("nixfmt") + if (path === null) return false + return [path, "$FILE"] }, } export const rustfmt: Info = { name: "rustfmt", - command: ["rustfmt", "$FILE"], extensions: [".rs"], async enabled() { - return which("rustfmt") !== null + const path = which("rustfmt") + if (path === null) return false + return [path, "$FILE"] }, } export const pint: Info = { name: "pint", - command: ["./vendor/bin/pint", "$FILE"], extensions: [".php"], async enabled() { const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree) @@ -361,8 +375,9 @@ export const pint: Info = { require?: Record "require-dev"?: Record }>(item) - if (json.require?.["laravel/pint"]) return true - if (json["require-dev"]?.["laravel/pint"]) return true + if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) { + return ["./vendor/bin/pint", "$FILE"] + } } return false }, @@ -370,27 +385,30 @@ export const pint: Info = { export const ormolu: Info = { name: "ormolu", - command: ["ormolu", "-i", "$FILE"], extensions: [".hs"], async enabled() { - return which("ormolu") !== null + const path = which("ormolu") + if (path === null) return false + return [path, "-i", "$FILE"] }, } export const cljfmt: Info = { name: "cljfmt", - command: ["cljfmt", "fix", "--quiet", "$FILE"], extensions: [".clj", ".cljs", ".cljc", ".edn"], async enabled() { - return which("cljfmt") !== null + const path = which("cljfmt") + if (path === null) return false + return [path, "fix", "--quiet", "$FILE"] }, } export const dfmt: Info = { name: "dfmt", - command: ["dfmt", "-i", "$FILE"], extensions: [".d"], async enabled() { - return which("dfmt") !== null + const path = which("dfmt") + if (path === null) return false + return [path, "-i", "$FILE"] }, } diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 6da8caa08c..8a7ee0af5c 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -37,7 +37,7 @@ export namespace Format { Effect.gen(function* () { const instance = yield* InstanceContext - const enabled: Record = {} + const enabled: Record = {} const formatters: Record = {} const cfg = yield* Effect.promise(() => Config.get()) @@ -62,7 +62,7 @@ export namespace Format { formatters[name] = { ...info, name, - enabled: async () => true, + enabled: async () => info.command, } } } else { @@ -79,13 +79,22 @@ export namespace Format { } async function getFormatter(ext: string) { - const result = [] + const result: Array<{ + name: string + command: string[] + environment?: Record + }> = [] for (const item of Object.values(formatters)) { log.info("checking", { name: item.name, ext }) if (!item.extensions.includes(ext)) continue - if (!(await isEnabled(item))) continue + const cmd = await isEnabled(item) + if (!cmd) continue log.info("enabled", { name: item.name, ext }) - result.push(item) + result.push({ + name: item.name, + command: cmd, + environment: item.environment, + }) } return result } @@ -141,7 +150,7 @@ export namespace Format { result.push({ name: formatter.name, extensions: formatter.extensions, - enabled: isOn, + enabled: !!isOn, }) } return result diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 123e8aea86..23ff17ceeb 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,7 +103,7 @@ 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 proc = spawn(await Npm.which("typescript-language-server"), ["--stdio"], { cwd: root, env: { ...process.env, @@ -129,29 +129,8 @@ 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 + binary = await Npm.which("@vue/language-server") } args.push("--stdio") const proc = spawn(binary, args, { @@ -214,7 +193,7 @@ export namespace LSPServer { log.info("installed VS Code ESLint server", { serverPath }) } - const proc = spawn(BunProc.which(), [serverPath, "--stdio"], { + const proc = spawn(await Npm.which("tsx"), [serverPath, "--stdio"], { cwd: root, env: { ...process.env, @@ -345,8 +324,8 @@ 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") + args = ["lsp-proxy", "--stdio"] } const proc = spawn(bin, args, { @@ -372,9 +351,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 +386,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 +491,8 @@ 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 + binary = await Npm.which("pyright") } args.push("--stdio") @@ -630,9 +594,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 +704,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 +741,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,22 +1007,8 @@ 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 + binary = await Npm.which("svelte-language-server") } args.push("--stdio") const proc = spawn(binary, args, { @@ -1096,22 +1040,8 @@ 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 + binary = await Npm.which("@astrojs/language-server") } args.push("--stdio") const proc = spawn(binary, args, { @@ -1360,31 +1290,8 @@ 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 + binary = await Npm.which("yaml-language-server") } args.push("--stdio") const proc = spawn(binary, args, { @@ -1413,9 +1320,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,22 +1456,8 @@ 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 + binary = await Npm.which("intelephense") } args.push("--stdio") const proc = spawn(binary, args, { @@ -1648,22 +1539,8 @@ 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 + binary = await Npm.which("bash-language-server") } args.push("start") const proc = spawn(binary, args, { @@ -1684,9 +1561,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 +1642,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,22 +1733,8 @@ 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 + binary = await Npm.which("dockerfile-language-server-nodejs") } args.push("--stdio") const proc = spawn(binary, args, { @@ -1966,9 +1825,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 0000000000..d6182d87a6 --- /dev/null +++ b/packages/opencode/src/npm/index.ts @@ -0,0 +1,180 @@ +// Workaround: Bun on Windows does not support the UV_FS_O_FILEMAP flag that +// the `tar` package uses for files < 512KB (fs.open returns EINVAL). +// tar silently swallows the error and skips writing files, leaving only empty +// directories. Setting __FAKE_PLATFORM__ makes tar fall back to the plain 'w' +// flag. See tar's get-write-flag.js. +// Must be set before @npmcli/arborist is imported since tar caches the flag +// at module evaluation time — so we use a dynamic import() below. +if (process.platform === "win32") { + process.env.__FAKE_PLATFORM__ = "linux" +} + +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 } from "fs/promises" +import { Filesystem } from "@/util/filesystem" + +const { Arborist } = await import("@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) + } + + export async function outdated(pkg: string, cachedVersion: string): Promise { + 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") + 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) { + log.info("package already installed", { pkg }) + return 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 first.path + } + + export async function install(dir: string) { + using _ = await Lock.write(`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 }>( + path.join(dir, "node_modules", pkg, "package.json"), + ).catch(() => undefined) + if (pkgJson?.bin) { + const bin = pkgJson.bin + if (typeof bin === "string") return path.basename(bin) + const keys = Object.keys(bin) + if (keys.length === 1) return keys[0] + const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg + return bin[unscoped] ? unscoped : keys[0] + } + return files[0] + } + + const bin = await pick() + if (bin) return path.join(binDir, bin) + + await add(pkg) + const resolved = await pick() + if (!resolved) throw new Error(`No binary found for package "${pkg}" after install`) + return path.join(binDir, resolved) + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 755ce2c211..f8d1faece9 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -4,7 +4,7 @@ import { Bus } from "../bus" import { Log } from "../util/log" import { createOpencodeClient } from "@opencode-ai/sdk" import { Server } from "../server/server" -import { BunProc } from "../bun" +import { Npm } from "../npm" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" import { CodexAuthPlugin } from "./codex" @@ -30,7 +30,9 @@ export namespace Plugin { : undefined, fetch: async (...args) => Server.Default().fetch(...args), }) + log.info("loading config") const config = await Config.get() + log.info("config loaded") const hooks: Hooks[] = [] const input: PluginInput = { client, @@ -40,7 +42,8 @@ export namespace Plugin { get serverUrl(): URL { return Server.url ?? new URL("http://localhost:4096") }, - $: Bun.$, + // @ts-expect-error + $: typeof Bun === "undefined" ? undefined : Bun.$, } for (const plugin of INTERNAL_PLUGINS) { @@ -59,16 +62,13 @@ export namespace Plugin { if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue log.info("loading plugin", { path: plugin }) if (!plugin.startsWith("file://")) { - const lastAtIndex = plugin.lastIndexOf("@") - const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin - const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest" - plugin = await BunProc.install(pkg, version).catch((err) => { + plugin = await Npm.add(plugin).catch((err) => { const cause = err instanceof Error ? err.cause : err const detail = cause instanceof Error ? cause.message : String(cause ?? err) - log.error("failed to install plugin", { pkg, version, error: detail }) + log.error("failed to install plugin", { plugin, error: detail }) Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ - message: `Failed to install plugin ${pkg}@${version}: ${detail}`, + message: `Failed to install plugin ${plugin}: ${detail}`, }).toObject(), }) return "" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index f7667fc2cb..11cf26cf58 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" @@ -1187,7 +1187,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) } 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 d607ae4782..0000000000 --- a/packages/opencode/test/bun.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, test } from "bun:test" -import fs from "fs/promises" -import path from "path" - -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') - } - }) -}) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index baf209d860..90727cf8a0 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,4 +1,4 @@ -import { test, expect, describe, mock, afterEach, spyOn } from "bun:test" +import { test, expect, describe, mock, afterEach } from "bun:test" import { Config } from "../../src/config/config" import { Instance } from "../../src/project/instance" import { Auth } from "../../src/auth" @@ -10,7 +10,6 @@ import { pathToFileURL } from "url" import { Global } from "../../src/global" import { ProjectID } from "../../src/project/schema" import { Filesystem } from "../../src/util/filesystem" -import { BunProc } from "../../src/bun" // Get managed config directory from environment (set in preload.ts) const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! @@ -764,39 +763,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { } }) -test("serializes concurrent config dependency installs", async () => { - await using tmp = await tmpdir() - const dirs = [path.join(tmp.path, "a"), path.join(tmp.path, "b")] - await Promise.all(dirs.map((dir) => fs.mkdir(dir, { recursive: true }))) - - const seen: string[] = [] - let active = 0 - let max = 0 - const run = spyOn(BunProc, "run").mockImplementation(async (_cmd, opts) => { - active++ - max = Math.max(max, active) - seen.push(opts?.cwd ?? "") - await new Promise((resolve) => setTimeout(resolve, 25)) - active-- - return { - code: 0, - stdout: Buffer.alloc(0), - stderr: Buffer.alloc(0), - } - }) - - try { - await Promise.all(dirs.map((dir) => Config.installDependencies(dir))) - } finally { - run.mockRestore() - } - - expect(max).toBe(1) - expect(seen.toSorted()).toEqual(dirs.toSorted()) - expect(await Filesystem.exists(path.join(dirs[0], "package.json"))).toBe(true) - expect(await Filesystem.exists(path.join(dirs[1], "package.json"))).toBe(true) -}) - test("resolves scoped npm plugins in config", async () => { await using tmp = await tmpdir({ init: async (dir) => {