diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e3cc66b60c..049573e3e5 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -8,7 +8,7 @@ "scripts": { "prepare": "effect-language-service patch || true", "typecheck": "tsgo --noEmit", - "test": "bun test --timeout 30000 registry", + "test": "bun test --timeout 30000", "build": "bun run script/build.ts", "dev": "bun run --conditions=browser ./src/index.ts", "random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'", @@ -26,15 +26,9 @@ "exports": { "./*": "./src/*.ts" }, - "imports": { - "#db": { - "bun": "./src/storage/db.bun.ts", - "node": "./src/storage/db.node.ts", - "default": "./src/storage/db.bun.ts" - } - }, "devDependencies": { "@babel/core": "7.28.4", + "@effect/language-service": "0.79.0", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/script": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", @@ -51,14 +45,13 @@ "@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", "@types/yargs": "17.0.33", "@typescript/native-preview": "catalog:", - "effect": "catalog:", - "drizzle-kit": "catalog:", + "drizzle-kit": "1.0.0-beta.16-ea816b6", + "drizzle-orm": "1.0.0-beta.16-ea816b6", "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", @@ -91,12 +84,9 @@ "@clack/prompts": "1.0.0-alpha.1", "@gitlab/gitlab-ai-provider": "3.6.0", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@hono/node-server": "1.19.11", - "@hono/node-ws": "1.3.0", "@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:", @@ -123,7 +113,8 @@ "cross-spawn": "^7.0.6", "decimal.js": "10.5.0", "diff": "catalog:", - "drizzle-orm": "catalog:", + "drizzle-orm": "1.0.0-beta.16-ea816b6", + "effect": "catalog:", "fuzzysort": "3.1.0", "glob": "13.0.5", "google-auth-library": "10.5.0", diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts new file mode 100644 index 0000000000..d6c4538259 --- /dev/null +++ b/packages/opencode/src/bun/index.ts @@ -0,0 +1,127 @@ +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 new file mode 100644 index 0000000000..e43e20e6c5 --- /dev/null +++ b/packages/opencode/src/bun/registry.ts @@ -0,0 +1,44 @@ +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 594ebc17ae..47afdfd7d0 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 } from "url" +import { pathToFileURL, fileURLToPath } from "url" import { createRequire } from "module" import os from "os" import z from "zod" @@ -22,6 +22,7 @@ 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" @@ -29,11 +30,14 @@ 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 { Npm } from "@/npm" +import { Process } from "@/util/process" +import { Lock } from "@/util/lock" export namespace Config { const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) @@ -150,7 +154,8 @@ export namespace Config { deps.push( iife(async () => { - await installDependencies(dir) + const shouldInstall = await needsInstall(dir) + if (shouldInstall) await installDependencies(dir) }), ) @@ -266,10 +271,6 @@ 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 @@ -283,15 +284,43 @@ export namespace Config { await Filesystem.writeJson(pkg, json) const gitignore = path.join(dir, ".gitignore") - if (!(await Filesystem.exists(gitignore))) - await Filesystem.write( - gitignore, - ["node_modules", "plans", "package.json", "bun.lock", ".gitignore", "package-lock.json"].join("\n"), - ) + const hasGitIgnore = await Filesystem.exists(gitignore) + if (!hasGitIgnore) + await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n")) // Install any additional dependencies defined in the package.json // This allows local plugins and custom tools to use external packages - await Npm.install(dir) + 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 }) + }) } async function isWritable(dir: string) { @@ -303,6 +332,41 @@ 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 5424520cab..9e96b2305c 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() { - const p = which("gofmt") - if (p === null) return false - return [p, "-w", "$FILE"] + return which("gofmt") !== null }, } export const mix: Info = { name: "mix", + command: ["mix", "format", "$FILE"], extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"], async enabled() { - const p = which("mix") - if (p === null) return false - return [p, "format", "$FILE"] + return which("mix") !== null }, } export const prettier: Info = { name: "prettier", + command: [BunProc.which(), "x", "prettier", "--write", "$FILE"], environment: { BUN_BE_BUN: "1", }, @@ -73,9 +73,8 @@ export const prettier: Info = { dependencies?: Record devDependencies?: Record }>(item) - if (json.dependencies?.prettier || json.devDependencies?.prettier) { - return [await Npm.which("prettier"), "--write", "$FILE"] - } + if (json.dependencies?.prettier) return true + if (json.devDependencies?.prettier) return true } return false }, @@ -83,6 +82,7 @@ export const prettier: Info = { export const oxfmt: Info = { name: "oxfmt", + command: [BunProc.which(), "x", "oxfmt", "$FILE"], environment: { BUN_BE_BUN: "1", }, @@ -95,9 +95,8 @@ export const oxfmt: Info = { dependencies?: Record devDependencies?: Record }>(item) - if (json.dependencies?.oxfmt || json.devDependencies?.oxfmt) { - return [await Npm.which("oxfmt"), "$FILE"] - } + if (json.dependencies?.oxfmt) return true + if (json.devDependencies?.oxfmt) return true } return false }, @@ -105,6 +104,7 @@ 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 [await Npm.which("@biomejs/biome"), "check", "--write", "$FILE"] + return true } } return false @@ -150,49 +150,47 @@ export const biome: Info = { export const zig: Info = { name: "zig", + command: ["zig", "fmt", "$FILE"], extensions: [".zig", ".zon"], async enabled() { - const p = which("zig") - if (p === null) return false - return [p, "fmt", "$FILE"] + return which("zig") !== null }, } 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) - if (items.length === 0) return false - return ["clang-format", "-i", "$FILE"] + return items.length > 0 }, } export const ktlint: Info = { name: "ktlint", + command: ["ktlint", "-F", "$FILE"], extensions: [".kt", ".kts"], async enabled() { - const p = which("ktlint") - if (p === null) return false - return [p, "-F", "$FILE"] + return which("ktlint") !== null }, } export const ruff: Info = { name: "ruff", + command: ["ruff", "format", "$FILE"], extensions: [".py", ".pyi"], async enabled() { - const p = which("ruff") - if (p === null) return false + if (!which("ruff")) 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 [p, "format", "$FILE"] + if (content.includes("[tool.ruff]")) return true } else { - return [p, "format", "$FILE"] + return true } } } @@ -201,7 +199,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 [p, "format", "$FILE"] + if (content.includes("ruff")) return true } } return false @@ -210,13 +208,14 @@ 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([airPath, "--help"], { + const proc = Process.spawn(["air", "--help"], { stdout: "pipe", stderr: "pipe", }) @@ -228,10 +227,7 @@ export const rlang: Info = { const firstLine = output.split("\n")[0] const hasR = firstLine.includes("R language") const hasFormatter = firstLine.includes("formatter") - if (hasR && hasFormatter) { - return [airPath, "format", "$FILE"] - } - return false + return hasR && hasFormatter } catch (error) { return false } @@ -240,14 +236,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 - const uvPath = which("uv") - if (uvPath !== null) { - const proc = Process.spawn([uvPath, "format", "--help"], { stderr: "pipe", stdout: "pipe" }) + if (which("uv") !== null) { + const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" }) const code = await proc.exited - if (code === 0) return [uvPath, "format", "--", "$FILE"] + return code === 0 } return false }, @@ -255,118 +251,108 @@ export const uvformat: Info = { export const rubocop: Info = { name: "rubocop", + command: ["rubocop", "--autocorrect", "$FILE"], extensions: [".rb", ".rake", ".gemspec", ".ru"], async enabled() { - const path = which("rubocop") - if (path === null) return false - return [path, "--autocorrect", "$FILE"] + return which("rubocop") !== null }, } export const standardrb: Info = { name: "standardrb", + command: ["standardrb", "--fix", "$FILE"], extensions: [".rb", ".rake", ".gemspec", ".ru"], async enabled() { - const path = which("standardrb") - if (path === null) return false - return [path, "--fix", "$FILE"] + return which("standardrb") !== null }, } export const htmlbeautifier: Info = { name: "htmlbeautifier", + command: ["htmlbeautifier", "$FILE"], extensions: [".erb", ".html.erb"], async enabled() { - const path = which("htmlbeautifier") - if (path === null) return false - return [path, "$FILE"] + return which("htmlbeautifier") !== null }, } export const dart: Info = { name: "dart", + command: ["dart", "format", "$FILE"], extensions: [".dart"], async enabled() { - const path = which("dart") - if (path === null) return false - return [path, "format", "$FILE"] + return which("dart") !== null }, } export const ocamlformat: Info = { name: "ocamlformat", + command: ["ocamlformat", "-i", "$FILE"], extensions: [".ml", ".mli"], async enabled() { - const path = which("ocamlformat") - if (!path) return false + if (!which("ocamlformat")) return false const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree) - if (items.length === 0) return false - return [path, "-i", "$FILE"] + return items.length > 0 }, } export const terraform: Info = { name: "terraform", + command: ["terraform", "fmt", "$FILE"], extensions: [".tf", ".tfvars"], async enabled() { - const path = which("terraform") - if (path === null) return false - return [path, "fmt", "$FILE"] + return which("terraform") !== null }, } export const latexindent: Info = { name: "latexindent", + command: ["latexindent", "-w", "-s", "$FILE"], extensions: [".tex"], async enabled() { - const path = which("latexindent") - if (path === null) return false - return [path, "-w", "-s", "$FILE"] + return which("latexindent") !== null }, } export const gleam: Info = { name: "gleam", + command: ["gleam", "format", "$FILE"], extensions: [".gleam"], async enabled() { - const path = which("gleam") - if (path === null) return false - return [path, "format", "$FILE"] + return which("gleam") !== null }, } export const shfmt: Info = { name: "shfmt", + command: ["shfmt", "-w", "$FILE"], extensions: [".sh", ".bash"], async enabled() { - const path = which("shfmt") - if (path === null) return false - return [path, "-w", "$FILE"] + return which("shfmt") !== null }, } export const nixfmt: Info = { name: "nixfmt", + command: ["nixfmt", "$FILE"], extensions: [".nix"], async enabled() { - const path = which("nixfmt") - if (path === null) return false - return [path, "$FILE"] + return which("nixfmt") !== null }, } export const rustfmt: Info = { name: "rustfmt", + command: ["rustfmt", "$FILE"], extensions: [".rs"], async enabled() { - const path = which("rustfmt") - if (path === null) return false - return [path, "$FILE"] + return which("rustfmt") !== null }, } 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) @@ -375,9 +361,8 @@ export const pint: Info = { require?: Record "require-dev"?: Record }>(item) - if (json.require?.["laravel/pint"] || json["require-dev"]?.["laravel/pint"]) { - return ["./vendor/bin/pint", "$FILE"] - } + if (json.require?.["laravel/pint"]) return true + if (json["require-dev"]?.["laravel/pint"]) return true } return false }, @@ -385,30 +370,27 @@ export const pint: Info = { export const ormolu: Info = { name: "ormolu", + command: ["ormolu", "-i", "$FILE"], extensions: [".hs"], async enabled() { - const path = which("ormolu") - if (path === null) return false - return [path, "-i", "$FILE"] + return which("ormolu") !== null }, } export const cljfmt: Info = { name: "cljfmt", + command: ["cljfmt", "fix", "--quiet", "$FILE"], extensions: [".clj", ".cljs", ".cljc", ".edn"], async enabled() { - const path = which("cljfmt") - if (path === null) return false - return [path, "fix", "--quiet", "$FILE"] + return which("cljfmt") !== null }, } export const dfmt: Info = { name: "dfmt", + command: ["dfmt", "-i", "$FILE"], extensions: [".d"], async enabled() { - const path = which("dfmt") - if (path === null) return false - return [path, "-i", "$FILE"] + return which("dfmt") !== null }, } diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 8a7ee0af5c..6da8caa08c 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 () => info.command, + enabled: async () => true, } } } else { @@ -79,22 +79,13 @@ export namespace Format { } async function getFormatter(ext: string) { - const result: Array<{ - name: string - command: string[] - environment?: Record - }> = [] + const result = [] for (const item of Object.values(formatters)) { log.info("checking", { name: item.name, ext }) if (!item.extensions.includes(ext)) continue - const cmd = await isEnabled(item) - if (!cmd) continue + if (!(await isEnabled(item))) continue log.info("enabled", { name: item.name, ext }) - result.push({ - name: item.name, - command: cmd, - environment: item.environment, - }) + result.push(item) } return result } @@ -150,7 +141,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 23ff17ceeb..123e8aea86 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -3,6 +3,7 @@ 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" @@ -13,7 +14,6 @@ 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(await Npm.which("typescript-language-server"), ["--stdio"], { + const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], { cwd: root, env: { ...process.env, @@ -129,8 +129,29 @@ export namespace LSPServer { let binary = which("vue-language-server") const args: string[] = [] if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - binary = await Npm.which("@vue/language-server") + 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) } args.push("--stdio") const proc = spawn(binary, args, { @@ -193,7 +214,7 @@ export namespace LSPServer { log.info("installed VS Code ESLint server", { serverPath }) } - const proc = spawn(await Npm.which("tsx"), [serverPath, "--stdio"], { + const proc = spawn(BunProc.which(), [serverPath, "--stdio"], { cwd: root, env: { ...process.env, @@ -324,8 +345,8 @@ export namespace LSPServer { if (!bin) { const resolved = Module.resolve("biome", root) if (!resolved) return - bin = await Npm.which("biome") - args = ["lsp-proxy", "--stdio"] + bin = BunProc.which() + args = ["x", "biome", "lsp-proxy", "--stdio"] } const proc = spawn(bin, args, { @@ -351,7 +372,9 @@ export namespace LSPServer { }, extensions: [".go"], async spawn(root) { - let bin = which("gopls") + let bin = which("gopls", { + PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, + }) if (!bin) { if (!which("go")) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return @@ -386,7 +409,9 @@ export namespace LSPServer { root: NearestRoot(["Gemfile"]), extensions: [".rb", ".rake", ".gemspec", ".ru"], async spawn(root) { - let bin = which("rubocop") + let bin = which("rubocop", { + PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, + }) if (!bin) { const ruby = which("ruby") const gem = which("gem") @@ -491,8 +516,19 @@ export namespace LSPServer { let binary = which("pyright-langserver") const args = [] if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - binary = await Npm.which("pyright") + 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]) } args.push("--stdio") @@ -594,7 +630,9 @@ export namespace LSPServer { extensions: [".zig", ".zon"], root: NearestRoot(["build.zig"]), async spawn(root) { - let bin = which("zls") + let bin = which("zls", { + PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, + }) if (!bin) { const zig = which("zig") @@ -704,7 +742,9 @@ export namespace LSPServer { root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]), extensions: [".cs"], async spawn(root) { - let bin = which("csharp-ls") + let bin = which("csharp-ls", { + PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, + }) if (!bin) { if (!which("dotnet")) { log.error(".NET SDK is required to install csharp-ls") @@ -741,7 +781,9 @@ export namespace LSPServer { root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]), extensions: [".fs", ".fsi", ".fsx", ".fsscript"], async spawn(root) { - let bin = which("fsautocomplete") + let bin = which("fsautocomplete", { + PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, + }) if (!bin) { if (!which("dotnet")) { log.error(".NET SDK is required to install fsautocomplete") @@ -1007,8 +1049,22 @@ export namespace LSPServer { let binary = which("svelteserver") const args: string[] = [] if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - binary = await Npm.which("svelte-language-server") + 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) } args.push("--stdio") const proc = spawn(binary, args, { @@ -1040,8 +1096,22 @@ export namespace LSPServer { let binary = which("astro-ls") const args: string[] = [] if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - binary = await Npm.which("@astrojs/language-server") + 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) } args.push("--stdio") const proc = spawn(binary, args, { @@ -1290,8 +1360,31 @@ export namespace LSPServer { let binary = which("yaml-language-server") const args: string[] = [] if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - binary = await Npm.which("yaml-language-server") + 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) } args.push("--stdio") const proc = spawn(binary, args, { @@ -1320,7 +1413,9 @@ export namespace LSPServer { ]), extensions: [".lua"], async spawn(root) { - let bin = which("lua-language-server") + let bin = which("lua-language-server", { + PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, + }) if (!bin) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return @@ -1456,8 +1551,22 @@ export namespace LSPServer { let binary = which("intelephense") const args: string[] = [] if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - binary = await Npm.which("intelephense") + 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) } args.push("--stdio") const proc = spawn(binary, args, { @@ -1539,8 +1648,22 @@ export namespace LSPServer { let binary = which("bash-language-server") const args: string[] = [] if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - binary = await Npm.which("bash-language-server") + 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) } args.push("start") const proc = spawn(binary, args, { @@ -1561,7 +1684,9 @@ export namespace LSPServer { extensions: [".tf", ".tfvars"], root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]), async spawn(root) { - let bin = which("terraform-ls") + let bin = which("terraform-ls", { + PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, + }) if (!bin) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return @@ -1642,7 +1767,9 @@ export namespace LSPServer { extensions: [".tex", ".bib"], root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]), async spawn(root) { - let bin = which("texlab") + let bin = which("texlab", { + PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, + }) if (!bin) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return @@ -1733,8 +1860,22 @@ export namespace LSPServer { let binary = which("docker-langserver") const args: string[] = [] if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - binary = await Npm.which("dockerfile-language-server-nodejs") + 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) } args.push("--stdio") const proc = spawn(binary, args, { @@ -1825,7 +1966,9 @@ export namespace LSPServer { extensions: [".typ", ".typc"], root: NearestRoot(["typst.toml"]), async spawn(root) { - let bin = which("tinymist") + let bin = which("tinymist", { + PATH: process.env["PATH"] + path.delimiter + Global.Path.bin, + }) 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 deleted file mode 100644 index 544a675da7..0000000000 --- a/packages/opencode/src/npm/index.ts +++ /dev/null @@ -1,160 +0,0 @@ -// 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 isRange = /[\s^~*xX<>|=]/.test(cachedVersion) - if (isRange) 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 hash = pkg - const dir = directory(hash) - - 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) { - 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 = path.join(directory(pkg), "node_modules", ".bin") - const files = await readdir(dir).catch(() => []) - if (!files.length) { - await add(pkg) - const retry = await readdir(dir).catch(() => []) - if (!retry.length) throw new Error(`No binary found for package "${pkg}" after install`) - return path.join(dir, retry[0]) - } - return path.join(dir, files[0]) - } -} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index f8d1faece9..755ce2c211 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 { Npm } from "../npm" +import { BunProc } from "../bun" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" import { CodexAuthPlugin } from "./codex" @@ -30,9 +30,7 @@ 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, @@ -42,8 +40,7 @@ export namespace Plugin { get serverUrl(): URL { return Server.url ?? new URL("http://localhost:4096") }, - // @ts-expect-error - $: typeof Bun === "undefined" ? undefined : Bun.$, + $: Bun.$, } for (const plugin of INTERNAL_PLUGINS) { @@ -62,13 +59,16 @@ 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://")) { - plugin = await Npm.add(plugin).catch((err) => { + 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) => { const cause = err instanceof Error ? err.cause : err const detail = cause instanceof Error ? cause.message : String(cause ?? err) - log.error("failed to install plugin", { plugin, error: detail }) + log.error("failed to install plugin", { pkg, version, error: detail }) Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ - message: `Failed to install plugin ${plugin}: ${detail}`, + message: `Failed to install plugin ${pkg}@${version}: ${detail}`, }).toObject(), }) return "" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 11cf26cf58..f7667fc2cb 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 { Npm } from "../npm" +import { BunProc } from "../bun" 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 Npm.add(model.api.npm) + installedPath = await BunProc.install(model.api.npm, "latest") } 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 new file mode 100644 index 0000000000..d607ae4782 --- /dev/null +++ b/packages/opencode/test/bun.test.ts @@ -0,0 +1,53 @@ +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 90727cf8a0..baf209d860 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 } from "bun:test" +import { test, expect, describe, mock, afterEach, spyOn } from "bun:test" import { Config } from "../../src/config/config" import { Instance } from "../../src/project/instance" import { Auth } from "../../src/auth" @@ -10,6 +10,7 @@ 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! @@ -763,6 +764,39 @@ 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) => {