From ad741cbea0d916cdfbd9ddbbb0ea922b0060d6a1 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 19 Mar 2026 21:48:43 -0400 Subject: [PATCH] fix: scope Npm.add() lock to per-package key Use npm-install:${pkg} instead of a global npm-install lock so concurrent installs of different packages can run in parallel. --- packages/opencode/src/npm/index.ts | 180 +++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 packages/opencode/src/npm/index.ts diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts new file mode 100644 index 0000000000..12121b005f --- /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:${pkg}`) + log.info("installing package", { + pkg, + }) + const dir = directory(pkg) + + const arborist = new Arborist({ + path: dir, + binLinks: true, + progress: false, + savePrefix: "", + }) + const tree = await arborist.loadVirtual().catch(() => {}) + if (tree) { + const first = tree.edgesOut.values().next().value?.to + if (first) { + 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) + } +}