diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index 3568ff20e245..aadebe93101e 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -1,4 +1,3 @@ -import semver from "semver" import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { Global } from "../global" @@ -8,10 +7,14 @@ import { readdir, rm } from "fs/promises" import { Filesystem } from "@/util/filesystem" import { Flock } from "@/util/flock" import { Arborist } from "@npmcli/arborist" +import Config from "@npmcli/config" +// @ts-ignore documented @npmcli/config integration uses this subpath, but @types/npmcli__config does not declare it +import { definitions, flatten, shorthands } from "@npmcli/config/lib/definitions" export namespace Npm { const log = Log.create({ service: "npm" }) const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined + const npmdir = import.meta.dirname export const InstallFailedError = NamedError.create( "NpmInstallFailedError", @@ -41,24 +44,20 @@ export namespace Npm { return result } - 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) + async function opts(cwd: string, env = process.env) { + const conf = new Config({ + cwd, + env, + argv: [], + execPath: process.execPath, + platform: process.platform, + npmPath: npmdir, + definitions, + shorthands, + flatten, + }) + await conf.load() + return conf.flat } export async function add(pkg: string) { @@ -68,7 +67,9 @@ export namespace Npm { pkg, }) + const cfg = await opts(Global.Path.cache) const arborist = new Arborist({ + ...cfg, path: dir, binLinks: true, progress: false, @@ -89,7 +90,7 @@ export namespace Npm { save: true, saveType: "prod", }) - .catch((cause) => { + .catch((cause: unknown) => { throw new InstallFailedError( { pkg }, { @@ -108,7 +109,9 @@ export namespace Npm { log.info("checking dependencies", { dir }) const reify = async () => { + const cfg = await opts(dir) const arb = new Arborist({ + ...cfg, path: dir, binLinks: true, progress: false, diff --git a/packages/opencode/test/npm/index.test.ts b/packages/opencode/test/npm/index.test.ts new file mode 100644 index 000000000000..9ac53026f454 --- /dev/null +++ b/packages/opencode/test/npm/index.test.ts @@ -0,0 +1,102 @@ +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { tmpdir } from "../fixture/fixture" + +const base = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-npm-")) +const xdg = path.join(base, "xdg") +const home = path.join(base, "home") +const prev = { + HOME: process.env.HOME, + NPM_CONFIG_USERCONFIG: process.env.NPM_CONFIG_USERCONFIG, + XDG_CACHE_HOME: process.env.XDG_CACHE_HOME, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + XDG_DATA_HOME: process.env.XDG_DATA_HOME, + XDG_STATE_HOME: process.env.XDG_STATE_HOME, + npm_config_registry: process.env.npm_config_registry, + npm_config_userconfig: process.env.npm_config_userconfig, +} + +await fs.mkdir(xdg, { recursive: true }) +await fs.mkdir(home, { recursive: true }) + +process.env.HOME = home +process.env.XDG_CACHE_HOME = path.join(xdg, "cache") +process.env.XDG_CONFIG_HOME = path.join(xdg, "config") +process.env.XDG_DATA_HOME = path.join(xdg, "data") +process.env.XDG_STATE_HOME = path.join(xdg, "state") + +const seen: Array> = [] + +mock.module("@npmcli/arborist", () => ({ + Arborist: class { + constructor(input: Record) { + seen.push(input) + } + + async loadVirtual() { + return undefined + } + + async reify() { + return { + edgesOut: new Map([["pkg", { to: { name: "@tngtech/opencode-skainet", path: base } }]]), + } + } + }, +})) + +const { Global } = await import("../../src/global") +const { Npm } = await import("../../src/npm") + +beforeEach(async () => { + seen.length = 0 + delete process.env.NPM_CONFIG_USERCONFIG + delete process.env.npm_config_registry + delete process.env.npm_config_userconfig + await fs.rm(home, { recursive: true, force: true }) + await fs.mkdir(home, { recursive: true }) + await fs.rm(Global.Path.cache, { recursive: true, force: true }) + await fs.mkdir(Global.Path.cache, { recursive: true }) + await fs.writeFile(path.join(Global.Path.cache, "version"), "21") +}) + +afterAll(async () => { + for (const [key, value] of Object.entries(prev)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + await fs.rm(base, { recursive: true, force: true }) +}) + +describe("npm", () => { + test("add reads scoped registry from user npmrc", async () => { + await fs.writeFile(path.join(home, ".npmrc"), "@tngtech:registry=https://user.example/\n") + + await Npm.add("@tngtech/opencode-skainet@latest") + + expect(seen[0]?.["@tngtech:registry"]).toBe("https://user.example/") + expect(seen[0]?.path).toBe( + path.join(Global.Path.cache, "packages", Npm.sanitize("@tngtech/opencode-skainet@latest")), + ) + }) + + test("add keeps cache root npmrc as local config", async () => { + await fs.writeFile(path.join(Global.Path.cache, ".npmrc"), "@tngtech:registry=https://cache.example/\n") + + await Npm.add("@tngtech/opencode-skainet@latest") + + expect(seen[0]?.["@tngtech:registry"]).toBe("https://cache.example/") + }) + + test("install reads local npmrc from install dir", async () => { + await using tmp = await tmpdir() + await fs.writeFile(path.join(tmp.path, ".npmrc"), "registry=https://dir.example/\n") + + await Npm.install(tmp.path) + + expect(seen[0]?.registry).toBe("https://dir.example/") + expect(seen[0]?.path).toBe(tmp.path) + }) +})