From 9df7c78ebe051a3e71ec6aa27e38a4baa0bbb4bc Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 23 Apr 2026 21:28:54 +0530 Subject: [PATCH] fix(npm): respect npmrc for version lookups (#24016) --- packages/opencode/src/installation/index.ts | 19 +++-- packages/opencode/src/npm/index.ts | 56 ++++++++----- .../test/installation/installation.test.ts | 46 ++++++++-- packages/opencode/test/npm.test.ts | 84 +++++++++++++++++++ 4 files changed, 171 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index babde9dc47..e39b14b8f3 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -132,6 +132,15 @@ export const layer: Layer.Layer Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })), ) + const viewVersion = Effect.fnUntraced(function* (method: "npm" | "pnpm" | "bun", spec: string) { + const args = method === "bun" ? ["pm", "view", spec, "version", "--json"] : ["view", spec, "version", "--json"] + const result = yield* run([method, ...args]) + if (result.code !== 0 || !result.stdout.trim()) { + return yield* new UpgradeFailedError({ stderr: result.stderr || result.stdout || `Failed to resolve ${spec}` }) + } + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(result.stdout) + }) + const getBrewFormula = Effect.fnUntraced(function* () { const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"]) if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode" @@ -217,15 +226,7 @@ export const layer: Layer.Layer()("NpmInstallFailedError", { @@ -106,7 +108,36 @@ export const layer = Layer.effect( const global = yield* Global.Service const fs = yield* FileSystem.FileSystem const flock = yield* EffectFlock.Service + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg)) + const runView = Effect.fnUntraced( + function* (cmd: string[]) { + const handle = yield* spawner.spawn( + ChildProcess.make(cmd[0], cmd.slice(1), { + extendEnv: true, + }), + ) + const [stdout, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + if (code !== 0 || !stdout.trim()) { + return yield* Effect.fail(stderr || stdout || `Failed to run ${cmd.join(" ")}`) + } + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(stdout) + }, + Effect.scoped, + ) + const viewLatestVersion = Effect.fnUntraced(function* (pkg: string) { + return yield* runView(["npm", "view", pkg, "dist-tags.latest", "--json"]).pipe( + Effect.catch(() => + runView(["pnpm", "view", pkg, "dist-tags.latest", "--json"]).pipe( + Effect.catch(() => runView(["bun", "pm", "view", pkg, "dist-tags.latest", "--json"])), + ), + ), + ) + }) const reify = (input: { dir: string; add?: string[] }) => Effect.gen(function* () { yield* flock.acquire(`npm-install:${input.dir}`) @@ -143,29 +174,15 @@ export const layer = Layer.effect( ) const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) { - const response = yield* Effect.tryPromise({ - try: () => fetch(`https://registry.npmjs.org/${pkg}`), - catch: () => undefined, - }).pipe(Effect.orElseSucceed(() => undefined)) - - if (!response || !response.ok) { - return false - } - - const data = yield* Effect.tryPromise({ - try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>, - catch: () => undefined, - }).pipe(Effect.orElseSucceed(() => undefined)) - - const latestVersion = data?.["dist-tags"]?.latest - if (!latestVersion) { + const latestVersion = yield* viewLatestVersion(pkg).pipe(Effect.option) + if (Option.isNone(latestVersion)) { return false } const range = /[\s^~*xX<>|=]/.test(cachedVersion) - if (range) return !semver.satisfies(latestVersion, cachedVersion) + if (range) return !semver.satisfies(latestVersion.value, cachedVersion) - return semver.lt(cachedVersion, latestVersion) + return semver.lt(cachedVersion, latestVersion.value) }) const add = Effect.fn("Npm.add")(function* (pkg: string) { @@ -304,6 +321,7 @@ export const defaultLayer = layer.pipe( Layer.provide(AppFileSystem.layer), Layer.provide(Global.layer), Layer.provide(NodeFileSystem.layer), + Layer.provide(CrossSpawnSpawner.defaultLayer), ) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index 2b04c38585..0d3e92989d 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -3,6 +3,7 @@ import { Effect, Layer, Stream } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { Installation } from "../../src/installation" +import { InstallationChannel } from "../../src/installation/version" const encoder = new TextEncoder() @@ -68,11 +69,15 @@ describe("installation", () => { expect(result).toBe("4.0.0-beta.1") }) - test("reads npm registry versions", async () => { + test("reads npm versions via npm view", async () => { + const calls: string[][] = [] const layer = testLayer( - () => jsonResponse({ version: "1.5.0" }), + () => { + throw new Error("unexpected http request") + }, (cmd, args) => { - if (cmd === "npm" && args.includes("registry")) return "https://registry.npmjs.org\n" + calls.push([cmd, ...args]) + if (cmd === "npm" && args[0] === "view") return '"1.5.0"\n' return "" }, ) @@ -81,18 +86,47 @@ describe("installation", () => { Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)), ) expect(result).toBe("1.5.0") + expect(calls).toContainEqual(["npm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"]) }) - test("reads npm registry versions for bun method", async () => { + test("reads npm versions via bun pm view", async () => { + const calls: string[][] = [] const layer = testLayer( - () => jsonResponse({ version: "1.6.0" }), - () => "", + () => { + throw new Error("unexpected http request") + }, + (cmd, args) => { + calls.push([cmd, ...args]) + if (cmd === "bun" && args[0] === "pm") return '"1.6.0"\n' + return "" + }, ) const result = await Effect.runPromise( Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)), ) expect(result).toBe("1.6.0") + expect(calls).toContainEqual(["bun", "pm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"]) + }) + + test("reads npm versions via pnpm view", async () => { + const calls: string[][] = [] + const layer = testLayer( + () => { + throw new Error("unexpected http request") + }, + (cmd, args) => { + calls.push([cmd, ...args]) + if (cmd === "pnpm" && args[0] === "view") return '"1.7.0"\n' + return "" + }, + ) + + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("pnpm")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("1.7.0") + expect(calls).toContainEqual(["pnpm", "view", `opencode-ai@${InstallationChannel}`, "version", "--json"]) }) test("reads scoop manifest versions", async () => { diff --git a/packages/opencode/test/npm.test.ts b/packages/opencode/test/npm.test.ts index a8ec92c2a7..b7680bb70d 100644 --- a/packages/opencode/test/npm.test.ts +++ b/packages/opencode/test/npm.test.ts @@ -1,10 +1,50 @@ import fs from "fs/promises" import path from "path" import { describe, expect, test } from "bun:test" +import { Effect, Layer, Stream } from "effect" +import { NodeFileSystem } from "@effect/platform-node" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Global } from "@opencode-ai/shared/global" +import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { Npm } from "../src/npm" import { tmpdir } from "./fixture/fixture" const win = process.platform === "win32" +const encoder = new TextEncoder() +function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") { + const spawner = ChildProcessSpawner.make((command) => { + const std = ChildProcess.isStandardCommand(command) ? command : undefined + const output = handler(std?.command ?? "", std?.args ?? []) + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(0), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any, + stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ) + }) + return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner) +} + +function testLayer(spawnHandler?: (cmd: string, args: readonly string[]) => string) { + return Npm.layer.pipe( + Layer.provide(mockSpawner(spawnHandler)), + Layer.provide(EffectFlock.layer), + Layer.provide(AppFileSystem.layer), + Layer.provide(Global.layer), + Layer.provide(NodeFileSystem.layer), + ) +} + const writePackage = (dir: string, pkg: Record) => Bun.write( path.join(dir, "package.json"), @@ -53,3 +93,47 @@ describe("Npm.install", () => { await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow() }) }) + +describe("Npm.outdated", () => { + test("checks latest via npm view", async () => { + const calls: string[][] = [] + const layer = testLayer((cmd, args) => { + calls.push([cmd, ...args]) + if (cmd === "npm" && args[0] === "view") return '"2.0.0"\n' + return "" + }) + + const result = await Effect.runPromise(Npm.Service.use((svc) => svc.outdated("example", "1.0.0")).pipe(Effect.provide(layer))) + + expect(result).toBe(true) + expect(calls).toContainEqual(["npm", "view", "example", "dist-tags.latest", "--json"]) + }) + + test("keeps range comparison behavior", async () => { + const layer = testLayer((cmd, args) => { + if (cmd === "npm" && args[0] === "view") return '"2.3.0"\n' + return "" + }) + + const result = await Effect.runPromise( + Npm.Service.use((svc) => svc.outdated("example", "^2.0.0")).pipe(Effect.provide(layer)), + ) + + expect(result).toBe(false) + }) + + test("falls back when npm view is unavailable", async () => { + const calls: string[][] = [] + const layer = testLayer((cmd, args) => { + calls.push([cmd, ...args]) + if (cmd === "pnpm" && args[0] === "view") return '"2.0.0"\n' + return "" + }) + + const result = await Effect.runPromise(Npm.Service.use((svc) => svc.outdated("example", "1.0.0")).pipe(Effect.provide(layer))) + + expect(result).toBe(true) + expect(calls).toContainEqual(["npm", "view", "example", "dist-tags.latest", "--json"]) + expect(calls).toContainEqual(["pnpm", "view", "example", "dist-tags.latest", "--json"]) + }) +})