Skip to content

Commit 546c0f4

Browse files
committed
fix(plugin): load npm config for Arborist installs
1 parent d1f05b0 commit 546c0f4

4 files changed

Lines changed: 135 additions & 1 deletion

File tree

bun.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/opencode/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"@types/mime-types": "3.0.1",
6363
"@types/npm-package-arg": "6.1.4",
6464
"@types/npmcli__arborist": "6.3.3",
65+
"@types/npmcli__config": "6.0.3",
6566
"@types/semver": "^7.5.8",
6667
"@types/turndown": "5.0.5",
6768
"@types/which": "3.0.4",
@@ -109,6 +110,7 @@
109110
"@lydell/node-pty": "catalog:",
110111
"@modelcontextprotocol/sdk": "1.27.1",
111112
"@npmcli/arborist": "9.4.0",
113+
"@npmcli/config": "10.8.1",
112114
"@octokit/graphql": "9.0.2",
113115
"@octokit/rest": "catalog:",
114116
"@openauthjs/openauth": "catalog:",

packages/opencode/src/npm/index.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ import { readdir, rm } from "fs/promises"
88
import { Filesystem } from "@/util/filesystem"
99
import { Flock } from "@/util/flock"
1010
import { Arborist } from "@npmcli/arborist"
11+
import Config from "@npmcli/config"
12+
// @ts-ignore documented @npmcli/config integration uses this subpath, but @types/npmcli__config does not declare it
13+
import { definitions, flatten, shorthands } from "@npmcli/config/lib/definitions"
1114

1215
export namespace Npm {
1316
const log = Log.create({ service: "npm" })
1417
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
18+
const npmdir = import.meta.dirname
1519

1620
export const InstallFailedError = NamedError.create(
1721
"NpmInstallFailedError",
@@ -41,6 +45,22 @@ export namespace Npm {
4145
return result
4246
}
4347

48+
async function opts(cwd: string, env = process.env) {
49+
const conf = new Config({
50+
cwd,
51+
env,
52+
argv: [],
53+
execPath: process.execPath,
54+
platform: process.platform,
55+
npmPath: npmdir,
56+
definitions,
57+
shorthands,
58+
flatten,
59+
})
60+
await conf.load()
61+
return conf.flat
62+
}
63+
4464
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
4565
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
4666
if (!response.ok) {
@@ -68,7 +88,9 @@ export namespace Npm {
6888
pkg,
6989
})
7090

91+
const cfg = await opts(Global.Path.cache)
7192
const arborist = new Arborist({
93+
...cfg,
7294
path: dir,
7395
binLinks: true,
7496
progress: false,
@@ -89,7 +111,7 @@ export namespace Npm {
89111
save: true,
90112
saveType: "prod",
91113
})
92-
.catch((cause) => {
114+
.catch((cause: unknown) => {
93115
throw new InstallFailedError(
94116
{ pkg },
95117
{
@@ -108,7 +130,9 @@ export namespace Npm {
108130
log.info("checking dependencies", { dir })
109131

110132
const reify = async () => {
133+
const cfg = await opts(dir)
111134
const arb = new Arborist({
135+
...cfg,
112136
path: dir,
113137
binLinks: true,
114138
progress: false,
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
2+
import fs from "fs/promises"
3+
import os from "os"
4+
import path from "path"
5+
import { tmpdir } from "../fixture/fixture"
6+
7+
const base = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-npm-"))
8+
const xdg = path.join(base, "xdg")
9+
const home = path.join(base, "home")
10+
const prev = {
11+
HOME: process.env.HOME,
12+
NPM_CONFIG_USERCONFIG: process.env.NPM_CONFIG_USERCONFIG,
13+
XDG_CACHE_HOME: process.env.XDG_CACHE_HOME,
14+
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
15+
XDG_DATA_HOME: process.env.XDG_DATA_HOME,
16+
XDG_STATE_HOME: process.env.XDG_STATE_HOME,
17+
npm_config_registry: process.env.npm_config_registry,
18+
npm_config_userconfig: process.env.npm_config_userconfig,
19+
}
20+
21+
await fs.mkdir(xdg, { recursive: true })
22+
await fs.mkdir(home, { recursive: true })
23+
24+
process.env.HOME = home
25+
process.env.XDG_CACHE_HOME = path.join(xdg, "cache")
26+
process.env.XDG_CONFIG_HOME = path.join(xdg, "config")
27+
process.env.XDG_DATA_HOME = path.join(xdg, "data")
28+
process.env.XDG_STATE_HOME = path.join(xdg, "state")
29+
30+
const seen: Array<Record<string, unknown>> = []
31+
32+
mock.module("@npmcli/arborist", () => ({
33+
Arborist: class {
34+
constructor(input: Record<string, unknown>) {
35+
seen.push(input)
36+
}
37+
38+
async loadVirtual() {
39+
return undefined
40+
}
41+
42+
async reify() {
43+
return {
44+
edgesOut: new Map([["pkg", { to: { name: "@tngtech/opencode-skainet", path: base } }]]),
45+
}
46+
}
47+
},
48+
}))
49+
50+
const { Global } = await import("../../src/global")
51+
const { Npm } = await import("../../src/npm")
52+
53+
beforeEach(async () => {
54+
seen.length = 0
55+
delete process.env.NPM_CONFIG_USERCONFIG
56+
delete process.env.npm_config_registry
57+
delete process.env.npm_config_userconfig
58+
await fs.rm(home, { recursive: true, force: true })
59+
await fs.mkdir(home, { recursive: true })
60+
await fs.rm(Global.Path.cache, { recursive: true, force: true })
61+
await fs.mkdir(Global.Path.cache, { recursive: true })
62+
await fs.writeFile(path.join(Global.Path.cache, "version"), "21")
63+
})
64+
65+
afterAll(async () => {
66+
for (const [key, value] of Object.entries(prev)) {
67+
if (value === undefined) delete process.env[key]
68+
else process.env[key] = value
69+
}
70+
await fs.rm(base, { recursive: true, force: true })
71+
})
72+
73+
describe("npm", () => {
74+
test("add reads scoped registry from user npmrc", async () => {
75+
await fs.writeFile(path.join(home, ".npmrc"), "@tngtech:registry=https://user.example/\n")
76+
77+
await Npm.add("@tngtech/opencode-skainet@latest")
78+
79+
expect(seen[0]?.["@tngtech:registry"]).toBe("https://user.example/")
80+
expect(seen[0]?.path).toBe(
81+
path.join(Global.Path.cache, "packages", Npm.sanitize("@tngtech/opencode-skainet@latest")),
82+
)
83+
})
84+
85+
test("add keeps cache root npmrc as local config", async () => {
86+
await fs.writeFile(path.join(Global.Path.cache, ".npmrc"), "@tngtech:registry=https://cache.example/\n")
87+
88+
await Npm.add("@tngtech/opencode-skainet@latest")
89+
90+
expect(seen[0]?.["@tngtech:registry"]).toBe("https://cache.example/")
91+
})
92+
93+
test("install reads local npmrc from install dir", async () => {
94+
await using tmp = await tmpdir()
95+
await fs.writeFile(path.join(tmp.path, ".npmrc"), "registry=https://dir.example/\n")
96+
97+
await Npm.install(tmp.path)
98+
99+
expect(seen[0]?.registry).toBe("https://dir.example/")
100+
expect(seen[0]?.path).toBe(tmp.path)
101+
})
102+
})

0 commit comments

Comments
 (0)