Преглед изворни кода

fix(opencode): resolve npmrc with @npmcli/config

Dax Raad пре 1 недеља
родитељ
комит
6e22664485

+ 3 - 0
bun.lock

@@ -339,6 +339,7 @@
         "@lydell/node-pty": "1.2.0-beta.10",
         "@modelcontextprotocol/sdk": "1.27.1",
         "@npmcli/arborist": "9.4.0",
+        "@npmcli/config": "10.8.0",
         "@octokit/graphql": "9.0.2",
         "@octokit/rest": "catalog:",
         "@openauthjs/openauth": "catalog:",
@@ -1423,6 +1424,8 @@
 
     "@npmcli/arborist": ["@npmcli/[email protected]", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^5.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/map-workspaces": "^5.0.0", "@npmcli/metavuln-calculator": "^9.0.2", "@npmcli/name-from-folder": "^4.0.0", "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/query": "^5.0.0", "@npmcli/redact": "^4.0.0", "@npmcli/run-script": "^10.0.0", "bin-links": "^6.0.0", "cacache": "^20.0.1", "common-ancestor-path": "^2.0.0", "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", "minimatch": "^10.0.3", "nopt": "^9.0.0", "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.0", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "pacote": "^21.0.2", "parse-conflict-json": "^5.0.1", "proc-log": "^6.0.0", "proggy": "^4.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "semver": "^7.3.7", "ssri": "^13.0.0", "treeverse": "^3.0.0", "walk-up-path": "^4.0.0" }, "bin": { "arborist": "bin/index.js" } }, "sha512-4Bm8hNixJG/sii1PMnag0V9i/sGOX9VRzFrUiZMSBJpGlLR38f+Btl85d07G9GL56xO0l0OZjvrGNYsDYp0xKA=="],
 
+    "@npmcli/config": ["@npmcli/[email protected]", "", { "dependencies": { "@npmcli/map-workspaces": "^5.0.0", "@npmcli/package-json": "^7.0.0", "ci-info": "^4.0.0", "ini": "^6.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "walk-up-path": "^4.0.0" } }, "sha512-YkhoXZQU7zxyGi3V7J0zdK2pghzF9YXHiRdpRX8QNhsefk/zAJZJjRsbbw1hD67hlMp2gSygUGgW4y7FlrUThw=="],
+
     "@npmcli/fs": ["@npmcli/[email protected]", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og=="],
 
     "@npmcli/git": ["@npmcli/[email protected]", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/promise-spawn": "^9.0.0", "ini": "^6.0.0", "lru-cache": "^11.2.1", "npm-pick-manifest": "^11.0.1", "proc-log": "^6.0.0", "semver": "^7.3.5", "which": "^6.0.0" } }, "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg=="],

+ 1 - 0
packages/opencode/package.json

@@ -109,6 +109,7 @@
     "@lydell/node-pty": "1.2.0-beta.10",
     "@modelcontextprotocol/sdk": "1.27.1",
     "@npmcli/arborist": "9.4.0",
+    "@npmcli/config": "10.8.0",
     "@octokit/graphql": "9.0.2",
     "@octokit/rest": "catalog:",
     "@openauthjs/openauth": "catalog:",

+ 91 - 0
packages/opencode/src/npm/config.ts

@@ -0,0 +1,91 @@
+import { createRequire } from "module"
+import path from "path"
+import Config from "@npmcli/config"
+import { definitions, flatten, shorthands } from "@npmcli/config/lib/definitions/index.js"
+import { Effect, Layer, ServiceMap } from "effect"
+import { makeRuntime } from "@/effect/run-service"
+import { AppFileSystem } from "@/filesystem"
+import { Global } from "@/global"
+
+export namespace NpmConfig {
+  type Data = Record<string, unknown>
+  type Where = "project" | "user" | "global"
+
+  export interface Interface {
+    readonly config: (dir: string) => Effect.Effect<Data, Error>
+    readonly paths: (dir: string) => Effect.Effect<string[], Error>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/NpmConfig") {}
+
+  const require = createRequire(import.meta.url)
+  const npmPath = (() => {
+    try {
+      return path.dirname(require.resolve("npm/package.json"))
+    } catch {
+      return path.join(Global.Path.cache, "npm")
+    }
+  })()
+
+  function source(conf: Config, where: Where) {
+    return conf.data.get(where)?.source
+  }
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const fs = yield* AppFileSystem.Service
+
+      const load = Effect.fnUntraced(function* (dir: string) {
+        const conf = new Config({
+          argv: [],
+          cwd: AppFileSystem.resolve(dir),
+          definitions,
+          env: { ...process.env },
+          execPath: process.execPath,
+          flatten,
+          npmPath,
+          platform: process.platform,
+          shorthands,
+          warn: false,
+        })
+        yield* Effect.tryPromise({
+          try: () => conf.load(),
+          catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
+        })
+        return conf
+      })
+
+      const config = Effect.fn("NpmConfig.config")(function* (dir: string) {
+        return (yield* load(dir)).flat as Data
+      })
+
+      const paths = Effect.fn("NpmConfig.paths")(function* (dir: string) {
+        const conf = yield* load(dir)
+        const list = yield* Effect.forEach(["project", "user", "global"] as const, (where) =>
+          Effect.gen(function* () {
+            const file = source(conf, where)
+            if (!file || !path.isAbsolute(file)) return
+            const resolved = AppFileSystem.resolve(file)
+            if (!(yield* fs.existsSafe(resolved))) return
+            return resolved
+          }),
+        )
+        return list.filter((item): item is string => item !== undefined)
+      })
+
+      return Service.of({ config, paths })
+    }),
+  )
+
+  export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
+  const { runPromise } = makeRuntime(Service, defaultLayer)
+
+  export async function config(dir: string) {
+    return runPromise((svc) => svc.config(dir))
+  }
+
+  export async function paths(dir: string) {
+    return runPromise((svc) => svc.paths(dir))
+  }
+}

+ 227 - 130
packages/opencode/src/npm/index.ts

@@ -1,13 +1,17 @@
+import path from "path"
 import semver from "semver"
 import z from "zod"
+import { Effect, Layer, ServiceMap } from "effect"
+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
 import { NamedError } from "@opencode-ai/util/error"
+import { makeRuntime } from "@/effect/run-service"
+import { AppFileSystem } from "@/filesystem"
 import { Global } from "../global"
 import { Log } from "../util/log"
-import path from "path"
-import { readdir, rm } from "fs/promises"
-import { Filesystem } from "@/util/filesystem"
 import { Flock } from "@/util/flock"
 import { Arborist } from "@npmcli/arborist"
+import { NpmConfig } from "./config"
+import { withTransientReadRetry } from "@/util/effect-http-client"
 
 export namespace Npm {
   const log = Log.create({ service: "npm" })
@@ -20,6 +24,37 @@ export namespace Npm {
     }),
   )
 
+  export interface Interface {
+    readonly add: (
+      pkg: string,
+    ) => Effect.Effect<
+      { directory: string; entrypoint: string | undefined },
+      Error | AppFileSystem.Error | InstanceType<typeof InstallFailedError>
+    >
+    readonly install: (dir: string) => Effect.Effect<void, Error | AppFileSystem.Error>
+    readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
+    readonly which: (
+      pkg: string,
+    ) => Effect.Effect<string | undefined, Error | AppFileSystem.Error | InstanceType<typeof InstallFailedError>>
+  }
+
+  type Pkg = {
+    dependencies?: Record<string, string>
+    devDependencies?: Record<string, string>
+    peerDependencies?: Record<string, string>
+    optionalDependencies?: Record<string, string>
+  }
+
+  type Lock = {
+    packages?: Record<string, Pkg>
+  }
+
+  type Bin = {
+    bin?: string | Record<string, string>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Npm") {}
+
   export function sanitize(pkg: string) {
     if (!illegal) return pkg
     return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
@@ -34,155 +69,217 @@ export namespace Npm {
     try {
       entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
     } catch {}
-    const result = {
+    return {
       directory: dir,
       entrypoint,
     }
-    return result
   }
 
-  export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
-    const response = await fetch(`https://registry.npmjs.org/${pkg}`)
-    if (!response.ok) {
-      log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
-      return false
-    }
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const fs = yield* AppFileSystem.Service
+      const cfg = yield* NpmConfig.Service
+      const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
 
-    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 create = Effect.fnUntraced(function* (dir: string) {
+        return new Arborist({
+          path: dir,
+          binLinks: true,
+          progress: false,
+          savePrefix: "",
+          ignoreScripts: true,
+          ...(yield* cfg.config(dir)),
+        })
+      })
 
-    const range = /[\s^~*xX<>|=]/.test(cachedVersion)
-    if (range) return !semver.satisfies(latestVersion, cachedVersion)
+      const lock = <A, E>(key: string, body: Effect.Effect<A, E>) =>
+        Effect.scoped(
+          Effect.gen(function* () {
+            yield* Effect.acquireRelease(Effect.promise(() => Flock.acquire(key)).pipe(Effect.orDie), (lease) =>
+              Effect.promise(() => lease.release()).pipe(Effect.orDie),
+            )
+            return yield* body
+          }),
+        )
 
-    return semver.lt(cachedVersion, latestVersion)
-  }
+      const readPkg = <A>(file: string, fallback: A) =>
+        fs.readJson(file).pipe(
+          Effect.catch(() => Effect.succeed(fallback)),
+          Effect.map((value) => value as A),
+        )
 
-  export async function add(pkg: string) {
-    const dir = directory(pkg)
-    await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
-    log.info("installing package", {
-      pkg,
-    })
-
-    const arborist = new Arborist({
-      path: dir,
-      binLinks: true,
-      progress: false,
-      savePrefix: "",
-      ignoreScripts: true,
-    })
-    const tree = await arborist.loadVirtual().catch(() => {})
-    if (tree) {
-      const first = tree.edgesOut.values().next().value?.to
-      if (first) {
-        return resolveEntryPoint(first.name, first.path)
-      }
-    }
+      const reify = Effect.fnUntraced(function* (dir: string) {
+        const arb = yield* create(dir)
+        yield* Effect.promise(() => arb.reify()).pipe(Effect.catch(() => Effect.void))
+      })
+
+      const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
+        const url = `https://registry.npmjs.org/${pkg}`
+        const data = yield* HttpClientRequest.get(url).pipe(
+          HttpClientRequest.acceptJson,
+          http.execute,
+          Effect.flatMap((res) => res.json),
+          Effect.catch(() =>
+            Effect.sync(() => {
+              log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
+              return undefined
+            }),
+          ),
+        )
 
-    const result = await arborist
-      .reify({
-        add: [pkg],
-        save: true,
-        saveType: "prod",
+        const latestVersion =
+          data &&
+          typeof data === "object" &&
+          "dist-tags" in data &&
+          data["dist-tags"] &&
+          typeof data["dist-tags"] === "object"
+            ? (data["dist-tags"] as { latest?: string }).latest
+            : undefined
+
+        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)
       })
-      .catch((cause) => {
-        throw new InstallFailedError(
-          { pkg },
-          {
-            cause,
-          },
+
+      const add = Effect.fn("Npm.add")(function* (pkg: string) {
+        const dir = directory(pkg)
+        const key = `npm-install:${AppFileSystem.resolve(dir)}`
+
+        return yield* lock(
+          key,
+          Effect.gen(function* () {
+            log.info("installing package", { pkg })
+            const arb = yield* create(dir)
+            const tree = yield* Effect.promise(() => arb.loadVirtual()).pipe(
+              Effect.catch(() => Effect.succeed(undefined)),
+            )
+            const cached = tree?.edgesOut.values().next().value?.to
+            if (cached) return resolveEntryPoint(cached.name, cached.path)
+
+            const result = yield* Effect.tryPromise({
+              try: () => arb.reify({ add: [pkg], save: true, saveType: "prod" }),
+              catch: (cause) =>
+                new InstallFailedError(
+                  { pkg },
+                  {
+                    cause,
+                  },
+                ),
+            })
+
+            const first = result.edgesOut.values().next().value?.to
+            if (!first) return yield* Effect.fail(new InstallFailedError({ pkg }))
+            return resolveEntryPoint(first.name, first.path)
+          }),
         )
       })
 
-    const first = result.edgesOut.values().next().value?.to
-    if (!first) throw new InstallFailedError({ pkg })
-    return resolveEntryPoint(first.name, first.path)
-  }
+      const install = Effect.fn("Npm.install")(function* (dir: string) {
+        const key = `npm-install:${dir}`
+        yield* lock(
+          key,
+          Effect.gen(function* () {
+            log.info("checking dependencies", { dir })
 
-  export async function install(dir: string) {
-    await using _ = await Flock.acquire(`npm-install:${dir}`)
-    log.info("checking dependencies", { dir })
-
-    const reify = async () => {
-      const arb = new Arborist({
-        path: dir,
-        binLinks: true,
-        progress: false,
-        savePrefix: "",
-        ignoreScripts: true,
+            if (!(yield* fs.existsSafe(path.join(dir, "node_modules")))) {
+              log.info("node_modules missing, reifying")
+              yield* reify(dir)
+              return
+            }
+
+            const pkg = yield* readPkg<Pkg>(path.join(dir, "package.json"), {})
+            const lock = yield* readPkg<Lock>(path.join(dir, "package-lock.json"), {})
+            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)) continue
+              log.info("dependency not in lock file, reifying", { name })
+              yield* reify(dir)
+              return
+            }
+
+            log.info("dependencies in sync")
+          }),
+        )
       })
-      await arb.reify().catch(() => {})
-    }
 
-    if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
-      log.info("node_modules missing, reifying")
-      await reify()
-      return
-    }
+      const which = Effect.fn("Npm.which")(function* (pkg: string) {
+        const dir = directory(pkg)
+        const binDir = path.join(dir, "node_modules", ".bin")
 
-    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
-      }
-    }
+        const pick = Effect.fnUntraced(function* () {
+          const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
+          if (files.length === 0) return undefined
+          if (files.length === 1) return files[0]
+
+          const pkgJson = yield* readPkg<Bin | undefined>(
+            path.join(dir, "node_modules", pkg, "package.json"),
+            undefined,
+          )
+          if (!pkgJson?.bin) return files[0]
+
+          const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
+          if (typeof pkgJson.bin === "string") return unscoped
+
+          const keys = Object.keys(pkgJson.bin)
+          if (keys.length === 1) return keys[0]
+          return pkgJson.bin[unscoped] ? unscoped : keys[0]
+        })
+
+        const bin = yield* pick()
+        if (bin) return path.join(binDir, bin)
+
+        yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.catch(() => Effect.void))
+        yield* add(pkg)
+        const resolved = yield* pick()
+        if (!resolved) return
+        return path.join(binDir, resolved)
+      })
+
+      return Service.of({ add, install, outdated, which })
+    }),
+  )
+
+  export const defaultLayer = layer.pipe(
+    Layer.provide(FetchHttpClient.layer),
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(NpmConfig.defaultLayer),
+  )
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
-    log.info("dependencies in sync")
+  export async function add(pkg: string) {
+    return runPromise((svc) => svc.add(pkg))
   }
 
-  export async function which(pkg: string) {
-    const dir = directory(pkg)
-    const binDir = path.join(dir, "node_modules", ".bin")
-
-    const pick = async () => {
-      const files = await readdir(binDir).catch(() => [])
-      if (files.length === 0) return undefined
-      if (files.length === 1) return files[0]
-      // Multiple binaries — resolve from package.json bin field like npx does
-      const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
-        path.join(dir, "node_modules", pkg, "package.json"),
-      ).catch(() => undefined)
-      if (pkgJson?.bin) {
-        const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
-        const bin = pkgJson.bin
-        if (typeof bin === "string") return unscoped
-        const keys = Object.keys(bin)
-        if (keys.length === 1) return keys[0]
-        return bin[unscoped] ? unscoped : keys[0]
-      }
-      return files[0]
-    }
+  export async function install(dir: string) {
+    return runPromise((svc) => svc.install(dir))
+  }
 
-    const bin = await pick()
-    if (bin) return path.join(binDir, bin)
+  export async function outdated(pkg: string, cachedVersion: string) {
+    return runPromise((svc) => svc.outdated(pkg, cachedVersion))
+  }
 
-    await rm(path.join(dir, "package-lock.json"), { force: true })
-    await add(pkg)
-    const resolved = await pick()
-    if (!resolved) return
-    return path.join(binDir, resolved)
+  export async function which(pkg: string) {
+    return runPromise((svc) => svc.which(pkg))
   }
 }

+ 29 - 0
packages/opencode/src/npm/npmcli-config.d.ts

@@ -0,0 +1,29 @@
+declare module "@npmcli/config" {
+  type Data = Record<string, unknown>
+  type Where = "default" | "builtin" | "global" | "user" | "project" | "env" | "cli"
+
+  export default class Config {
+    constructor(input: {
+      argv: string[]
+      cwd: string
+      definitions: Data
+      env: NodeJS.ProcessEnv
+      execPath: string
+      flatten: (input: Data, flat?: Data) => Data
+      npmPath: string
+      platform: NodeJS.Platform
+      shorthands: Record<string, string[]>
+      warn?: boolean
+    })
+
+    readonly data: Map<Where, { source: string | null }>
+    readonly flat: Data
+    load(): Promise<void>
+  }
+}
+
+declare module "@npmcli/config/lib/definitions/index.js" {
+  export const definitions: Record<string, unknown>
+  export const shorthands: Record<string, string[]>
+  export const flatten: (input: Record<string, unknown>, flat?: Record<string, unknown>) => Record<string, unknown>
+}

+ 115 - 0
packages/opencode/test/npm/config.test.ts

@@ -0,0 +1,115 @@
+import { describe, expect, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { NpmConfig } from "../../src/npm/config"
+import { tmpdir } from "../fixture/fixture"
+
+function env(next: Record<string, string | undefined>) {
+  const prev = Object.fromEntries(Object.keys(next).map((key) => [key, process.env[key]]))
+
+  for (const [key, value] of Object.entries(next)) {
+    if (value === undefined) delete process.env[key]
+    else process.env[key] = value
+  }
+
+  return () => {
+    for (const [key, value] of Object.entries(prev)) {
+      if (value === undefined) delete process.env[key]
+      else process.env[key] = value
+    }
+  }
+}
+
+describe("NpmConfig", () => {
+  test("returns selected config file paths in precedence order", async () => {
+    await using tmp = await tmpdir()
+    const global = path.join(tmp.path, "global.npmrc")
+    const user = path.join(tmp.path, "user.npmrc")
+    const root = path.join(tmp.path, ".npmrc")
+    const child = path.join(tmp.path, "repo", ".npmrc")
+    const pkg = path.join(tmp.path, "repo", "package.json")
+    const dir = path.join(tmp.path, "repo", ".opencode")
+
+    await fs.mkdir(dir, { recursive: true })
+    await Bun.write(global, "registry=https://global.example/\n")
+    await Bun.write(user, "registry=https://user.example/\n")
+    await Bun.write(root, "registry=https://root.example/\n")
+    await Bun.write(child, "registry=https://child.example/\n")
+    await Bun.write(pkg, '{"name":"repo","version":"1.0.0"}\n')
+
+    const restore = env({
+      npm_config_globalconfig: global,
+      npm_config_userconfig: user,
+    })
+
+    try {
+      expect(await NpmConfig.paths(dir)).toEqual([child, user, global])
+    } finally {
+      restore()
+    }
+  })
+
+  test("merges config relative to a directory with env last", async () => {
+    await using tmp = await tmpdir()
+    const global = path.join(tmp.path, "global.npmrc")
+    const user = path.join(tmp.path, "user.npmrc")
+    const root = path.join(tmp.path, ".npmrc")
+    const child = path.join(tmp.path, "repo", ".npmrc")
+    const pkg = path.join(tmp.path, "repo", "package.json")
+    const dir = path.join(tmp.path, "repo", ".opencode")
+
+    await fs.mkdir(dir, { recursive: true })
+    await Bun.write(global, "registry=https://global.example/\nignore-scripts=false\n")
+    await Bun.write(user, "registry=https://user.example/\nignore-scripts=false\n")
+    await Bun.write(root, "registry=https://root.example/\nignore-scripts=false\n")
+    await Bun.write(child, "ignore-scripts=true\nbin-links=false\n@scope:registry=https://scope.example/\n")
+    await Bun.write(pkg, '{"name":"repo","version":"1.0.0"}\n')
+
+    const restore = env({
+      npm_config_globalconfig: global,
+      npm_config_userconfig: user,
+      npm_config_ignore_scripts: "false",
+      npm_config_registry: "https://env.example/",
+    })
+
+    try {
+      const cfg = await NpmConfig.config(dir)
+      expect(cfg.registry).toBe("https://env.example/")
+      expect(cfg.ignoreScripts).toBe(false)
+      expect(cfg.binLinks).toBe(false)
+      expect(cfg["@scope:registry"]).toBe("https://scope.example/")
+    } finally {
+      restore()
+    }
+  })
+
+  test("reloads config on each call", async () => {
+    await using tmp = await tmpdir()
+    const global = path.join(tmp.path, "global.npmrc")
+    const user = path.join(tmp.path, "user.npmrc")
+    const local = path.join(tmp.path, "repo", ".npmrc")
+    const pkg = path.join(tmp.path, "repo", "package.json")
+    const dir = path.join(tmp.path, "repo", ".opencode")
+
+    await fs.mkdir(dir, { recursive: true })
+    await Bun.write(global, "registry=https://global.example/\n")
+    await Bun.write(user, "registry=https://user.example/\n")
+    await Bun.write(local, "ignore-scripts=true\n")
+    await Bun.write(pkg, '{"name":"repo","version":"1.0.0"}\n')
+
+    const restore = env({
+      npm_config_globalconfig: global,
+      npm_config_userconfig: user,
+    })
+
+    try {
+      const first = await NpmConfig.config(dir)
+      await Bun.write(local, "ignore-scripts=false\n")
+      const second = await NpmConfig.config(dir)
+      expect(first.ignoreScripts).toBe(true)
+      expect(second.ignoreScripts).toBe(false)
+    } finally {
+      restore()
+    }
+  })
+})