fix(npm): respect npmrc for version lookups (#24016)
This commit is contained in:
@@ -132,6 +132,15 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
|
|||||||
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
|
Effect.catch(() => 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 getBrewFormula = Effect.fnUntraced(function* () {
|
||||||
const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
|
const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
|
||||||
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
|
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
|
||||||
@@ -217,15 +226,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
|
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
|
||||||
const r = (yield* text(["npm", "config", "get", "registry"])).trim()
|
return yield* viewVersion(detectedMethod, `opencode-ai@${InstallationChannel}`)
|
||||||
const reg = r || "https://registry.npmjs.org"
|
|
||||||
const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
|
|
||||||
const channel = InstallationChannel
|
|
||||||
const response = yield* httpOk.execute(
|
|
||||||
HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
|
|
||||||
)
|
|
||||||
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
|
|
||||||
return data.version
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detectedMethod === "choco") {
|
if (detectedMethod === "choco") {
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import npa from "npm-package-arg"
|
|||||||
import semver from "semver"
|
import semver from "semver"
|
||||||
import Config from "@npmcli/config"
|
import Config from "@npmcli/config"
|
||||||
import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
|
import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
|
||||||
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
|
import { Effect, Schema, Context, Layer, Option, FileSystem, Stream } from "effect"
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
import { Global } from "@opencode-ai/shared/global"
|
import { Global } from "@opencode-ai/shared/global"
|
||||||
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
||||||
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
|
|
||||||
|
import * as CrossSpawnSpawner from "../effect/cross-spawn-spawner"
|
||||||
import { makeRuntime } from "../effect/runtime"
|
import { makeRuntime } from "../effect/runtime"
|
||||||
|
|
||||||
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
|
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
|
||||||
@@ -106,7 +108,36 @@ export const layer = Layer.effect(
|
|||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
const flock = yield* EffectFlock.Service
|
const flock = yield* EffectFlock.Service
|
||||||
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
|
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[] }) =>
|
const reify = (input: { dir: string; add?: string[] }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* flock.acquire(`npm-install:${input.dir}`)
|
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 outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
|
||||||
const response = yield* Effect.tryPromise({
|
const latestVersion = yield* viewLatestVersion(pkg).pipe(Effect.option)
|
||||||
try: () => fetch(`https://registry.npmjs.org/${pkg}`),
|
if (Option.isNone(latestVersion)) {
|
||||||
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) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
|
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) {
|
const add = Effect.fn("Npm.add")(function* (pkg: string) {
|
||||||
@@ -304,6 +321,7 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(AppFileSystem.layer),
|
Layer.provide(AppFileSystem.layer),
|
||||||
Layer.provide(Global.layer),
|
Layer.provide(Global.layer),
|
||||||
Layer.provide(NodeFileSystem.layer),
|
Layer.provide(NodeFileSystem.layer),
|
||||||
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Effect, Layer, Stream } from "effect"
|
|||||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
import { Installation } from "../../src/installation"
|
import { Installation } from "../../src/installation"
|
||||||
|
import { InstallationChannel } from "../../src/installation/version"
|
||||||
|
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
|
|
||||||
@@ -68,11 +69,15 @@ describe("installation", () => {
|
|||||||
expect(result).toBe("4.0.0-beta.1")
|
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(
|
const layer = testLayer(
|
||||||
() => jsonResponse({ version: "1.5.0" }),
|
() => {
|
||||||
|
throw new Error("unexpected http request")
|
||||||
|
},
|
||||||
(cmd, args) => {
|
(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 ""
|
return ""
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -81,18 +86,47 @@ describe("installation", () => {
|
|||||||
Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)),
|
Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)),
|
||||||
)
|
)
|
||||||
expect(result).toBe("1.5.0")
|
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(
|
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(
|
const result = await Effect.runPromise(
|
||||||
Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)),
|
Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)),
|
||||||
)
|
)
|
||||||
expect(result).toBe("1.6.0")
|
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 () => {
|
test("reads scoop manifest versions", async () => {
|
||||||
|
|||||||
@@ -1,10 +1,50 @@
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { describe, expect, test } from "bun:test"
|
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 { Npm } from "../src/npm"
|
||||||
import { tmpdir } from "./fixture/fixture"
|
import { tmpdir } from "./fixture/fixture"
|
||||||
|
|
||||||
const win = process.platform === "win32"
|
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<string, unknown>) =>
|
const writePackage = (dir: string, pkg: Record<string, unknown>) =>
|
||||||
Bun.write(
|
Bun.write(
|
||||||
path.join(dir, "package.json"),
|
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()
|
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"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user