Refactor npm config handling (#24565)

This commit is contained in:
Dax 2026-04-26 23:54:59 -04:00 committed by GitHub
commit a9b62d67df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 207 additions and 293 deletions

View file

@ -1,7 +1,6 @@
import type { Argv } from "yargs"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "../../installation"
import { Global } from "@opencode-ai/core/global"
import fs from "fs/promises"
@ -58,7 +57,7 @@ export const UninstallCommand = {
UI.empty()
prompts.intro("Uninstall OpenCode")
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const method = await Installation.method()
prompts.log.info(`Installation method: ${method}`)
const targets = await collectRemovalTargets(args, method)

View file

@ -1,7 +1,6 @@
import type { Argv } from "yargs"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "../../installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
@ -26,7 +25,7 @@ export const UpgradeCommand = {
UI.println(UI.logo(" "))
UI.empty()
prompts.intro("Upgrade")
const detectedMethod = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const detectedMethod = await Installation.method()
const method = (args.method as Installation.Method) ?? detectedMethod
if (method === "unknown") {
prompts.log.error(`opencode is installed to ${process.execPath} and may be managed by a package manager`)
@ -44,9 +43,7 @@ export const UpgradeCommand = {
}
}
prompts.log.info("Using method: " + method)
const target = args.target
? args.target.replace(/^v/, "")
: await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest()
if (InstallationVersion === target) {
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
@ -57,9 +54,7 @@ export const UpgradeCommand = {
prompts.log.info(`From ${InstallationVersion}${target}`)
const spinner = prompts.spinner()
spinner.start("Upgrading...")
const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch(
(err) => err,
)
const err = await Installation.upgrade(method, target).catch((err) => err)
if (err) {
spinner.stop("Upgrade failed", 1)
if (err instanceof Installation.UpgradeFailedError) {

View file

@ -8,8 +8,8 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version"
export async function upgrade() {
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
const method = await Installation.method()
const latest = await Installation.latest(method).catch(() => {})
if (!latest) return
if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
@ -27,7 +27,7 @@ export async function upgrade() {
}
if (method === "unknown") return
await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, latest)))
await Installation.upgrade(method, latest)
.then(() => Bus.publish(Installation.Event.Updated, { version: latest }))
.catch(() => {})
}

View file

@ -8,9 +8,10 @@ import z from "zod"
import { BusEvent } from "@/bus/bus-event"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Log } from "../util"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import semver from "semver"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { NpmConfig } from "@opencode-ai/core/npm-config"
const log = Log.create({ service: "installation" })
@ -132,18 +133,6 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
)
// Use the package manager's resolver so registries, mirrors, auth, proxies, and dist-tags match upgrade behavior.
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"
@ -235,7 +224,13 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
}
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
return yield* viewVersion(detectedMethod, `opencode-ai@${InstallationChannel}`)
const response = yield* httpOk.execute(
HttpClientRequest.get(
`${yield* NpmConfig.registry(process.cwd())}/opencode-ai/${InstallationChannel}`,
).pipe(HttpClientRequest.acceptJson),
)
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
return data.version
}
if (detectedMethod === "choco") {
@ -335,4 +330,10 @@ export const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export const latest = (...args: Parameters<Interface["latest"]>) => runPromise((s) => s.latest(...args))
export const method = () => runPromise((s) => s.method())
export const upgrade = (...args: Parameters<Interface["upgrade"]>) => runPromise((s) => s.upgrade(...args))
export * as Installation from "."

View file

@ -988,7 +988,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
const noopNpm = Layer.mock(Npm.Service)({
install: () => Effect.void,
add: () => Effect.die("not implemented"),
outdated: () => Effect.succeed(false),
which: () => Effect.succeed(Option.none()),
})
const testLayer = Config.layer.pipe(

View file

@ -69,64 +69,46 @@ describe("installation", () => {
expect(result).toBe("4.0.0-beta.1")
})
test("reads npm versions via npm view", async () => {
const calls: string[][] = []
const layer = testLayer(
() => {
throw new Error("unexpected http request")
},
(cmd, args) => {
calls.push([cmd, ...args])
if (cmd === "npm" && args[0] === "view") return '"1.5.0"\n'
return ""
},
)
test("reads npm versions via registry", async () => {
const calls: string[] = []
const layer = testLayer((request) => {
calls.push(request.url)
return jsonResponse({ version: "1.5.0" })
})
const result = await Effect.runPromise(
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"])
expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
})
test("reads npm versions via bun pm view", async () => {
const calls: string[][] = []
const layer = testLayer(
() => {
throw new Error("unexpected http request")
},
(cmd, args) => {
calls.push([cmd, ...args])
if (cmd === "bun" && args[0] === "pm") return '"1.6.0"\n'
return ""
},
)
test("reads bun versions via registry", async () => {
const calls: string[] = []
const layer = testLayer((request) => {
calls.push(request.url)
return jsonResponse({ version: "1.6.0" })
})
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"])
expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
})
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 ""
},
)
test("reads pnpm versions via registry", async () => {
const calls: string[] = []
const layer = testLayer((request) => {
calls.push(request.url)
return jsonResponse({ version: "1.7.0" })
})
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"])
expect(calls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
})
test("reads scoop manifest versions", async () => {

View file

@ -1,143 +0,0 @@
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/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { Npm } from "@opencode-ai/core/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<string, unknown>) =>
Bun.write(
path.join(dir, "package.json"),
JSON.stringify({
version: "1.0.0",
...pkg,
}),
)
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
expect(Npm.sanitize("@opencode/acme@1.0.0")).toBe("@opencode/acme@1.0.0")
expect(Npm.sanitize("prettier")).toBe("prettier")
})
test("handles git https specs", () => {
const spec = "acme@git+https://github.com/opencode/acme.git"
const expected = win ? "acme@git+https_//github.com/opencode/acme.git" : spec
expect(Npm.sanitize(spec)).toBe(expected)
})
})
describe("Npm.install", () => {
test("respects omit from project .npmrc", async () => {
await using tmp = await tmpdir()
await writePackage(tmp.path, {
name: "fixture",
dependencies: {
"prod-pkg": "file:./prod-pkg",
},
devDependencies: {
"dev-pkg": "file:./dev-pkg",
},
})
await Bun.write(path.join(tmp.path, ".npmrc"), "omit=dev\n")
await fs.mkdir(path.join(tmp.path, "prod-pkg"))
await fs.mkdir(path.join(tmp.path, "dev-pkg"))
await writePackage(path.join(tmp.path, "prod-pkg"), { name: "prod-pkg" })
await writePackage(path.join(tmp.path, "dev-pkg"), { name: "dev-pkg" })
await Npm.install(tmp.path)
await expect(fs.stat(path.join(tmp.path, "node_modules", "prod-pkg"))).resolves.toBeDefined()
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"])
})
})