Skip to content

Commit e9fe0a0

Browse files
authored
fix(npm): respect npmrc for version lookups (anomalyco#24016)
1 parent 50cc8dd commit e9fe0a0

4 files changed

Lines changed: 171 additions & 34 deletions

File tree

packages/opencode/src/installation/index.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,15 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
132132
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
133133
)
134134

135+
const viewVersion = Effect.fnUntraced(function* (method: "npm" | "pnpm" | "bun", spec: string) {
136+
const args = method === "bun" ? ["pm", "view", spec, "version", "--json"] : ["view", spec, "version", "--json"]
137+
const result = yield* run([method, ...args])
138+
if (result.code !== 0 || !result.stdout.trim()) {
139+
return yield* new UpgradeFailedError({ stderr: result.stderr || result.stdout || `Failed to resolve ${spec}` })
140+
}
141+
return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(result.stdout)
142+
})
143+
135144
const getBrewFormula = Effect.fnUntraced(function* () {
136145
const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
137146
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
@@ -217,15 +226,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
217226
}
218227

219228
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
220-
const r = (yield* text(["npm", "config", "get", "registry"])).trim()
221-
const reg = r || "https://registry.npmjs.org"
222-
const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
223-
const channel = InstallationChannel
224-
const response = yield* httpOk.execute(
225-
HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
226-
)
227-
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
228-
return data.version
229+
return yield* viewVersion(detectedMethod, `opencode-ai@${InstallationChannel}`)
229230
}
230231

231232
if (detectedMethod === "choco") {

packages/opencode/src/npm/index.ts

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import npa from "npm-package-arg"
66
import semver from "semver"
77
import Config from "@npmcli/config"
88
import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
9-
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
9+
import { Effect, Schema, Context, Layer, Option, FileSystem, Stream } from "effect"
1010
import { NodeFileSystem } from "@effect/platform-node"
1111
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
1212
import { Global } from "@opencode-ai/shared/global"
1313
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
14+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
1415

16+
import * as CrossSpawnSpawner from "../effect/cross-spawn-spawner"
1517
import { makeRuntime } from "../effect/runtime"
1618

1719
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
@@ -106,7 +108,36 @@ export const layer = Layer.effect(
106108
const global = yield* Global.Service
107109
const fs = yield* FileSystem.FileSystem
108110
const flock = yield* EffectFlock.Service
111+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
109112
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
113+
const runView = Effect.fnUntraced(
114+
function* (cmd: string[]) {
115+
const handle = yield* spawner.spawn(
116+
ChildProcess.make(cmd[0], cmd.slice(1), {
117+
extendEnv: true,
118+
}),
119+
)
120+
const [stdout, stderr] = yield* Effect.all(
121+
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
122+
{ concurrency: 2 },
123+
)
124+
const code = yield* handle.exitCode
125+
if (code !== 0 || !stdout.trim()) {
126+
return yield* Effect.fail(stderr || stdout || `Failed to run ${cmd.join(" ")}`)
127+
}
128+
return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(stdout)
129+
},
130+
Effect.scoped,
131+
)
132+
const viewLatestVersion = Effect.fnUntraced(function* (pkg: string) {
133+
return yield* runView(["npm", "view", pkg, "dist-tags.latest", "--json"]).pipe(
134+
Effect.catch(() =>
135+
runView(["pnpm", "view", pkg, "dist-tags.latest", "--json"]).pipe(
136+
Effect.catch(() => runView(["bun", "pm", "view", pkg, "dist-tags.latest", "--json"])),
137+
),
138+
),
139+
)
140+
})
110141
const reify = (input: { dir: string; add?: string[] }) =>
111142
Effect.gen(function* () {
112143
yield* flock.acquire(`npm-install:${input.dir}`)
@@ -143,29 +174,15 @@ export const layer = Layer.effect(
143174
)
144175

145176
const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
146-
const response = yield* Effect.tryPromise({
147-
try: () => fetch(`https://registry.npmjs.org/${pkg}`),
148-
catch: () => undefined,
149-
}).pipe(Effect.orElseSucceed(() => undefined))
150-
151-
if (!response || !response.ok) {
152-
return false
153-
}
154-
155-
const data = yield* Effect.tryPromise({
156-
try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
157-
catch: () => undefined,
158-
}).pipe(Effect.orElseSucceed(() => undefined))
159-
160-
const latestVersion = data?.["dist-tags"]?.latest
161-
if (!latestVersion) {
177+
const latestVersion = yield* viewLatestVersion(pkg).pipe(Effect.option)
178+
if (Option.isNone(latestVersion)) {
162179
return false
163180
}
164181

165182
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
166-
if (range) return !semver.satisfies(latestVersion, cachedVersion)
183+
if (range) return !semver.satisfies(latestVersion.value, cachedVersion)
167184

168-
return semver.lt(cachedVersion, latestVersion)
185+
return semver.lt(cachedVersion, latestVersion.value)
169186
})
170187

171188
const add = Effect.fn("Npm.add")(function* (pkg: string) {
@@ -304,6 +321,7 @@ export const defaultLayer = layer.pipe(
304321
Layer.provide(AppFileSystem.layer),
305322
Layer.provide(Global.layer),
306323
Layer.provide(NodeFileSystem.layer),
324+
Layer.provide(CrossSpawnSpawner.defaultLayer),
307325
)
308326

309327
const { runPromise } = makeRuntime(Service, defaultLayer)

packages/opencode/test/installation/installation.test.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Effect, Layer, Stream } from "effect"
33
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
44
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
55
import { Installation } from "../../src/installation"
6+
import { InstallationChannel } from "../../src/installation/version"
67

78
const encoder = new TextEncoder()
89

@@ -68,11 +69,15 @@ describe("installation", () => {
6869
expect(result).toBe("4.0.0-beta.1")
6970
})
7071

71-
test("reads npm registry versions", async () => {
72+
test("reads npm versions via npm view", async () => {
73+
const calls: string[][] = []
7274
const layer = testLayer(
73-
() => jsonResponse({ version: "1.5.0" }),
75+
() => {
76+
throw new Error("unexpected http request")
77+
},
7478
(cmd, args) => {
75-
if (cmd === "npm" && args.includes("registry")) return "https://registry.npmjs.org\n"
79+
calls.push([cmd, ...args])
80+
if (cmd === "npm" && args[0] === "view") return '"1.5.0"\n'
7681
return ""
7782
},
7883
)
@@ -81,18 +86,47 @@ describe("installation", () => {
8186
Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)),
8287
)
8388
expect(result).toBe("1.5.0")
89+
expect(calls).toContainEqual(["npm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"])
8490
})
8591

86-
test("reads npm registry versions for bun method", async () => {
92+
test("reads npm versions via bun pm view", async () => {
93+
const calls: string[][] = []
8794
const layer = testLayer(
88-
() => jsonResponse({ version: "1.6.0" }),
89-
() => "",
95+
() => {
96+
throw new Error("unexpected http request")
97+
},
98+
(cmd, args) => {
99+
calls.push([cmd, ...args])
100+
if (cmd === "bun" && args[0] === "pm") return '"1.6.0"\n'
101+
return ""
102+
},
90103
)
91104

92105
const result = await Effect.runPromise(
93106
Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)),
94107
)
95108
expect(result).toBe("1.6.0")
109+
expect(calls).toContainEqual(["bun", "pm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"])
110+
})
111+
112+
test("reads npm versions via pnpm view", async () => {
113+
const calls: string[][] = []
114+
const layer = testLayer(
115+
() => {
116+
throw new Error("unexpected http request")
117+
},
118+
(cmd, args) => {
119+
calls.push([cmd, ...args])
120+
if (cmd === "pnpm" && args[0] === "view") return '"1.7.0"\n'
121+
return ""
122+
},
123+
)
124+
125+
const result = await Effect.runPromise(
126+
Installation.Service.use((svc) => svc.latest("pnpm")).pipe(Effect.provide(layer)),
127+
)
128+
expect(result).toBe("1.7.0")
129+
expect(calls).toContainEqual(["pnpm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"])
96130
})
97131

98132
test("reads scoop manifest versions", async () => {

packages/opencode/test/npm.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,50 @@
11
import fs from "fs/promises"
22
import path from "path"
33
import { describe, expect, test } from "bun:test"
4+
import { Effect, Layer, Stream } from "effect"
5+
import { NodeFileSystem } from "@effect/platform-node"
6+
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
7+
import { Global } from "@opencode-ai/shared/global"
8+
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
9+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
410
import { Npm } from "../src/npm"
511
import { tmpdir } from "./fixture/fixture"
612

713
const win = process.platform === "win32"
14+
const encoder = new TextEncoder()
15+
function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") {
16+
const spawner = ChildProcessSpawner.make((command) => {
17+
const std = ChildProcess.isStandardCommand(command) ? command : undefined
18+
const output = handler(std?.command ?? "", std?.args ?? [])
19+
return Effect.succeed(
20+
ChildProcessSpawner.makeHandle({
21+
pid: ChildProcessSpawner.ProcessId(0),
22+
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
23+
isRunning: Effect.succeed(false),
24+
kill: () => Effect.void,
25+
stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
26+
stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty,
27+
stderr: Stream.empty,
28+
all: Stream.empty,
29+
getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
30+
getOutputFd: () => Stream.empty,
31+
unref: Effect.succeed(Effect.void),
32+
}),
33+
)
34+
})
35+
return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)
36+
}
37+
38+
function testLayer(spawnHandler?: (cmd: string, args: readonly string[]) => string) {
39+
return Npm.layer.pipe(
40+
Layer.provide(mockSpawner(spawnHandler)),
41+
Layer.provide(EffectFlock.layer),
42+
Layer.provide(AppFileSystem.layer),
43+
Layer.provide(Global.layer),
44+
Layer.provide(NodeFileSystem.layer),
45+
)
46+
}
47+
848
const writePackage = (dir: string, pkg: Record<string, unknown>) =>
949
Bun.write(
1050
path.join(dir, "package.json"),
@@ -53,3 +93,47 @@ describe("Npm.install", () => {
5393
await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow()
5494
})
5595
})
96+
97+
describe("Npm.outdated", () => {
98+
test("checks latest via npm view", async () => {
99+
const calls: string[][] = []
100+
const layer = testLayer((cmd, args) => {
101+
calls.push([cmd, ...args])
102+
if (cmd === "npm" && args[0] === "view") return '"2.0.0"\n'
103+
return ""
104+
})
105+
106+
const result = await Effect.runPromise(Npm.Service.use((svc) => svc.outdated("example", "1.0.0")).pipe(Effect.provide(layer)))
107+
108+
expect(result).toBe(true)
109+
expect(calls).toContainEqual(["npm", "view", "example", "dist-tags.latest", "--json"])
110+
})
111+
112+
test("keeps range comparison behavior", async () => {
113+
const layer = testLayer((cmd, args) => {
114+
if (cmd === "npm" && args[0] === "view") return '"2.3.0"\n'
115+
return ""
116+
})
117+
118+
const result = await Effect.runPromise(
119+
Npm.Service.use((svc) => svc.outdated("example", "^2.0.0")).pipe(Effect.provide(layer)),
120+
)
121+
122+
expect(result).toBe(false)
123+
})
124+
125+
test("falls back when npm view is unavailable", async () => {
126+
const calls: string[][] = []
127+
const layer = testLayer((cmd, args) => {
128+
calls.push([cmd, ...args])
129+
if (cmd === "pnpm" && args[0] === "view") return '"2.0.0"\n'
130+
return ""
131+
})
132+
133+
const result = await Effect.runPromise(Npm.Service.use((svc) => svc.outdated("example", "1.0.0")).pipe(Effect.provide(layer)))
134+
135+
expect(result).toBe(true)
136+
expect(calls).toContainEqual(["npm", "view", "example", "dist-tags.latest", "--json"])
137+
expect(calls).toContainEqual(["pnpm", "view", "example", "dist-tags.latest", "--json"])
138+
})
139+
})

0 commit comments

Comments
 (0)