Compare commits

...

1 Commits

Author SHA1 Message Date
Dax Raad
6e22664485 fix(opencode): resolve npmrc with @npmcli/config 2026-04-09 10:43:44 -04:00
6 changed files with 472 additions and 136 deletions

View File

@@ -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/arborist@9.4.0", "", { "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/config@10.8.0", "", { "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/fs@5.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og=="],
"@npmcli/git": ["@npmcli/git@7.0.2", "", { "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=="],

View File

@@ -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:",

View File

@@ -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))
}
}

View File

@@ -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 range = /[\s^~*xX<>|=]/.test(cachedVersion)
if (range) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
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 result = await arborist
.reify({
add: [pkg],
save: true,
saveType: "prod",
const create = Effect.fnUntraced(function* (dir: string) {
return new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
...(yield* cfg.config(dir)),
})
})
.catch((cause) => {
throw new InstallFailedError(
{ pkg },
{
cause,
},
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
}),
)
const readPkg = <A>(file: string, fallback: A) =>
fs.readJson(file).pipe(
Effect.catch(() => Effect.succeed(fallback)),
Effect.map((value) => value as A),
)
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 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)
})
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 })
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")
}),
)
})
const which = Effect.fn("Npm.which")(function* (pkg: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
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)
export async function add(pkg: string) {
return runPromise((svc) => svc.add(pkg))
}
export async function install(dir: string) {
await using _ = await Flock.acquire(`npm-install:${dir}`)
log.info("checking dependencies", { dir })
return runPromise((svc) => svc.install(dir))
}
const reify = async () => {
const arb = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
})
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 outdated(pkg: string, cachedVersion: string) {
return runPromise((svc) => svc.outdated(pkg, cachedVersion))
}
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]
}
const bin = await pick()
if (bin) return path.join(binDir, bin)
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)
return runPromise((svc) => svc.which(pkg))
}
}

View File

@@ -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>
}

View File

@@ -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()
}
})
})