fix(cli): update npm installs through their own prefix (#63152)

This commit is contained in:
Aiden Cline
2026-11-10 19:28:18 -06:00
committed by GitHub
Unverified
parent 63b42f6f02
commit be6f5a367e
4 changed files with 274 additions and 28 deletions
@@ -16,7 +16,8 @@ export default Runtime.handler(
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
if (!method)
return yield* Effect.fail(
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
(yield* updater.managed(Option.getOrUndefined(input.target))) ??
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
)
log.info(`Using method: ${method}`)
+97 -13
View File
@@ -44,6 +44,9 @@ export class UpgradeError extends Error {
const decodeVpPackages = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Array(Schema.Struct({ name: Schema.String }))),
)
const decodeManifestVersion = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Struct({ version: Schema.String })),
)
const installNames: Record<Method, string> = {
curl: "The OpenCode installer",
@@ -110,6 +113,8 @@ export interface Interface {
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
readonly apply: (version: string) => Effect.Effect<void, Error>
readonly method: () => Effect.Effect<Method | undefined>
/** Explains how to update a copy that Volta or mise manages, which `method` leaves undetected. */
readonly managed: (version?: string) => Effect.Effect<UpgradeError | undefined, Error>
readonly latest: (method?: Method) => Effect.Effect<string, Error>
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
readonly removal: (
@@ -160,6 +165,35 @@ const make = Effect.gen(function* () {
)
// WinGet extracts portable packages into a directory named after the package identifier.
const installedWinget = /[\\/]winget[\\/]packages[\\/]anomaly\.opencode_/i.test(executable)
// Volta packages (tools/image/packages) and mise's npm backend (installs/npm-<name>) keep their own record of the
// installed version, so updating the copy in place would leave it stale. npm globals under a Volta or mise Node
// (tools/image/node, installs/node) are ordinary npm installs.
const installedOwner = !installedPackage
? undefined
: /[\\/]tools[\\/]image[\\/]packages[\\/]/.test(executable)
? { name: "Volta", command: "volta install " }
: /[\\/]installs[\\/]npm-[^\\/]+[\\/]/.test(executable)
? { name: "mise", command: "mise use -g npm:" }
: undefined
// npm keeps global packages in <prefix>/lib/node_modules on POSIX. The npm on PATH may belong to another Node
// install (fnm, nvm, mise) or prefix, so target the prefix that owns the running binary. Windows uses
// <prefix>\node_modules, which Bun and pnpm global installs share, so it keeps the npm on PATH.
const packageDirectory = path.dirname(path.dirname(executable))
const npmPackageDirectory = installedPackage ? path.join("/lib/node_modules", installedPackage) : undefined
const npmPrefix =
npmPackageDirectory &&
!installedOwner &&
process.platform !== "win32" &&
packageDirectory.endsWith(npmPackageDirectory)
? packageDirectory.slice(0, -npmPackageDirectory.length) || undefined
: undefined
const npmPrefixArgs = npmPrefix ? ["--prefix", npmPrefix] : []
const npmBin = npmPrefix ? path.join(npmPrefix, "bin") : undefined
const prefixNpm =
npmBin && (yield* fs.exists(path.join(npmBin, "npm")).pipe(Effect.orElseSucceed(() => false)))
? path.join(npmBin, "npm")
: undefined
const npm = prefixNpm ?? "npm"
const readPolicy = Effect.fnUntraced(function* () {
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
@@ -172,8 +206,14 @@ const make = Effect.gen(function* () {
})
const exec = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "30 seconds") {
// The prefix's npm and the package's postinstall run `node` from PATH, which may be missing or belong to another
// Node install, so put the prefix's own node first.
const options =
command[0] === prefixNpm
? { env: { PATH: [npmBin, process.env.PATH].filter(Boolean).join(path.delimiter) }, extendEnv: true }
: undefined
return yield* appProcess
.run(ChildProcess.make(command[0], command.slice(1)), {
.run(ChildProcess.make(command[0], command.slice(1), options), {
timeout,
maxOutputBytes: 100_000,
maxErrorBytes: 100_000,
@@ -215,11 +255,11 @@ const make = Effect.gen(function* () {
if (executable === curlBinary) return "curl"
if (installedFormula) return "brew"
if (installedWinget) return "winget"
if (!installedPackage) return
if (!installedPackage || installedOwner) return
const parts = executable.toLowerCase().split(path.sep).map((part) => part.replace(/^\./, ""))
const checks: Array<{ method: Method; command: string[] }> = [
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", installedPackage] },
{ method: "npm", command: [npm, "list", "-g", "--depth=0", ...npmPrefixArgs, installedPackage] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
{ method: "yarn", command: ["yarn", "global", "list"] },
@@ -248,7 +288,7 @@ const make = Effect.gen(function* () {
if (method === "winget") return ["winget", "uninstall", ...wingetArgs]
if (method === "curl" || method === "brew" || !installedPackage) return undefined
const commands = {
npm: ["npm", "uninstall", "--global", installedPackage],
npm: [npm, "uninstall", "--global", ...npmPrefixArgs, installedPackage],
pnpm: ["pnpm", "remove", "--global", installedPackage],
bun: ["bun", "remove", "--global", installedPackage],
yarn: ["yarn", "global", "remove", installedPackage],
@@ -365,6 +405,24 @@ const make = Effect.gen(function* () {
),
)
const managedError = (owner: { name: string; command: string }, target: string) =>
new UpgradeError({
title: `OpenCode is managed by ${owner.name}`,
detail: `This copy was installed with ${owner.name}, which keeps its own record of the installed version, so OpenCode won't update it directly.`,
command: `${owner.command}${target}`,
retry: "Run the command above, then restart OpenCode.",
})
const managedMessage = (error: UpgradeError) => `${error.title}. Update it with ${error.command}`
const managed = Effect.fnUntraced(function* (input?: string) {
if (!installedOwner) return
if (input !== undefined && !parseReleaseVersion(input))
return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const data = yield* release("npm")
return managedError(installedOwner, `${data.package}@${input?.trim().replace(/^v/, "") ?? data.version}`)
})
const temporaryDirectory = (prefix: string) =>
Effect.acquireRelease(fs.makeTempDirectory({ directory: global.cache, prefix }), (directory) =>
fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
@@ -438,9 +496,10 @@ const make = Effect.gen(function* () {
const commands: Record<Exclude<Method, "bun" | "curl" | "brew" | "winget">, string[]> = {
// Keep the old package: uninstalling it can unlink the replacement command.
npm: [
"npm",
npm,
"install",
"--global",
...npmPrefixArgs,
`--allow-scripts=${packageName}`,
...((OPENCODE_ARTIFACT === "cli" && !installedPackage?.endsWith("/cli-node")) ||
(installedPackage && packageName !== installedPackage)
@@ -527,8 +586,22 @@ const make = Effect.gen(function* () {
{ cause },
),
),
Effect.asVoid,
)
// A package manager can succeed while updating a different copy, for example another prefix. pnpm keeps each
// version in its own store directory, so the running copy never changes there.
if (installedPackage !== packageName || !(method === "npm" || method === "bun" || method === "yarn")) return
const installed = yield* fs.readFileString(path.join(packageDirectory, "package.json")).pipe(
Effect.map((text) => Option.getOrUndefined(decodeManifestVersion(text))?.version),
Effect.orElseSucceed(() => undefined),
)
if (installed !== version)
return yield* Effect.fail(
new UpgradeError({
title: `${installNames[method]} did not update this copy of OpenCode`,
detail: `${installNames[method]} reported success, but ${executable} is still version ${installed ?? "unknown"}.`,
retry: `Update the copy at ${executable} with the package manager that installed it, then run opencode upgrade again.`,
}),
)
})
const inspect = Effect.fnUntraced(function* () {
@@ -575,12 +648,15 @@ const make = Effect.gen(function* () {
})
const apply = Effect.fn("cli.updater.apply")(function* (version: string) {
if (!(yield* install(version)))
return yield* Effect.fail(
new Error(
"Could not detect the installation method. Update with your package manager or download the latest release.",
),
)
if (yield* install(version)) return
const error = yield* managed(version)
return yield* Effect.fail(
new Error(
error
? managedMessage(error)
: "Could not detect the installation method. Update with your package manager or download the latest release.",
),
)
})
const check = Effect.fn("cli.updater.check")(function* () {
@@ -590,6 +666,14 @@ const make = Effect.gen(function* () {
message: "This build runs from a source checkout. Use an installed OpenCode release to check for updates.",
}
const detected = yield* method()
if (!detected && installedOwner) {
const data = yield* release("npm")
if (action(OPENCODE_VERSION, data.version, "auto") === "none") return undefined
return {
type: "unavailable" as const,
message: managedMessage(managedError(installedOwner, `${data.package}@${data.version}`)),
}
}
if (!detected)
return {
type: "unavailable" as const,
@@ -620,7 +704,7 @@ const make = Effect.gen(function* () {
Effect.catch((error) => Effect.logWarning("update check failed", { error }).pipe(Effect.as(undefined))),
)
return Service.of({ run, check, apply, method, latest, upgrade, removal })
return Service.of({ run, check, apply, method, managed, latest, upgrade, removal })
})
export const layer = Layer.effect(Service, make)
+1
View File
@@ -16,6 +16,7 @@ await Effect.runPromise(
check: () => Effect.die("Manual upgrades must not check for TUI updates"),
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
removal: () => undefined,
managed: () => Effect.succeed(undefined),
method: () =>
Effect.sync(() => {
record("method")
+174 -14
View File
@@ -6,7 +6,7 @@ import { AppProcess } from "@opencode/util/process"
import { expect, spyOn, test } from "bun:test"
import { Effect, FileSystem, Layer, PlatformError, Stream } from "effect"
import { ChildProcessSpawner } from "effect/process"
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
import path from "node:path"
import { Updater } from "../src/services/updater"
import { testEffect } from "../../core/test/lib/effect"
@@ -26,24 +26,28 @@ function fixture(
failCleanup = false,
releasePackage = name,
formula?: string,
executableOverride?: (root: string, home: string) => Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem>,
executableOverride?: (
root: string,
home: string,
) => Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem>,
) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
const execPath = process.execPath
const modules = path.join(root, "node_modules")
// npm's global layout on POSIX.
const modules = path.join(root, "lib", "node_modules")
const executable = executableOverride
? yield* executableOverride(root, path.join(root, "home"))
: formula
? path.join(root, "Cellar", formula, "2.0.20", "bin", "opencode")
: path.join(modules, "@opencode", "cli", "bin", "opencode")
: path.join(modules, ...name.split("/"), "bin", "opencode")
yield* fs.makeDirectory(path.dirname(executable), { recursive: true })
yield* fs.writeFileString(executable, "binary")
if (!formula && !executableOverride)
yield* fs.writeFileString(
path.join(modules, "@opencode", "cli", "package.json"),
path.join(modules, ...name.split("/"), "package.json"),
JSON.stringify({ name, bin: { opencode: "bin/opencode" } }),
)
const requests: string[] = []
@@ -83,6 +87,8 @@ function fixture(
yield* fs.writeFileString(bash, "bash")
}
const commands: string[][] = []
const paths: Array<string | undefined> = []
const manifest = path.join(modules, ...name.split("/"), "package.json")
const updater = yield* Updater.Service.pipe(
Effect.provide(Updater.layer),
Effect.provide(
@@ -116,10 +122,15 @@ function fixture(
const args = command.args.map(String)
const lookup = command.command === "git" && args[0] === "--exec-path"
if (!lookup) commands.push([command.command, ...args])
if (!lookup) paths.push(command.options.env?.PATH?.toString())
const result = lookup
? { stdout: Buffer.from(`${path.join(git, "mingw64", "libexec", "git-core")}\n`) }
: respond({ command: command.command, args })
if (result.error) return Effect.fail(result.error)
// Successful installs replace the running copy, which the updater verifies afterwards.
const installed = args.find((arg) => arg.startsWith(`${name}@`))?.slice(name.length + 1)
if (!formula && !executableOverride && installed && (result.exitCode ?? 0) === 0)
writeFileSync(manifest, JSON.stringify({ name, version: installed, bin: { opencode: "bin/opencode" } }))
return Effect.succeed({
command: command.command,
exitCode: 0,
@@ -134,7 +145,10 @@ function fixture(
}),
),
)
return { updater, commands, requests, global, fs, executable, bash }
const prefix = yield* fs.realPath(root)
// Windows keeps the npm on PATH and its default prefix.
const prefixArgs = process.platform === "win32" ? [] : ["--prefix", prefix]
return { updater, commands, paths, requests, global, fs, executable, bash, prefix, prefixArgs }
})
}
@@ -144,14 +158,22 @@ const unix = process.platform === "win32" ? it.live.skip : it.live
const installs = [
{
method: "npm",
command: ["npm", "install", "--global", "--allow-scripts=@opencode/cli", "--force", "@opencode/cli@2.3.4-beta.1"],
command: (prefixArgs: string[]) => [
"npm",
"install",
"--global",
...prefixArgs,
"--allow-scripts=@opencode/cli",
"--force",
"@opencode/cli@2.3.4-beta.1",
],
},
{
method: "pnpm",
command: ["pnpm", "add", "--global", "--allow-build=@opencode/cli", "@opencode/cli@2.3.4-beta.1"],
command: () => ["pnpm", "add", "--global", "--allow-build=@opencode/cli", "@opencode/cli@2.3.4-beta.1"],
},
{ method: "yarn", command: ["yarn", "global", "add", "@opencode/cli@2.3.4-beta.1"] },
{ method: "vp", command: ["vp", "update", "-g", "@opencode/cli@2.3.4-beta.1"] },
{ method: "yarn", command: () => ["yarn", "global", "add", "@opencode/cli@2.3.4-beta.1"] },
{ method: "vp", command: () => ["vp", "update", "-g", "@opencode/cli@2.3.4-beta.1"] },
] as const
installs.forEach(({ method, command }) => {
@@ -159,7 +181,138 @@ installs.forEach(({ method, command }) => {
Effect.gen(function* () {
const test = yield* fixture()
yield* test.updater.upgrade(method, "v2.3.4-beta.1")
expect(test.commands).toEqual([[...command]])
expect(test.commands).toEqual([command(test.prefixArgs)])
}),
)
})
// Installs a 2.2.7 package at <root>/<directory>, optionally with an npm in <root>/bin.
const installed = (directory: string[], npm = false) =>
fixture(
() => ({}),
"@opencode/cli",
false,
"@opencode/cli",
undefined,
(root) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const pkg = path.join(root, ...directory)
yield* fs.makeDirectory(pkg, { recursive: true })
yield* fs.writeFileString(
path.join(pkg, "package.json"),
JSON.stringify({ name: "@opencode/cli", version: "2.2.7", bin: { opencode: "bin/opencode" } }),
)
if (npm) {
yield* fs.makeDirectory(path.join(root, "bin"), { recursive: true })
yield* fs.writeFileString(path.join(root, "bin", "npm"), "npm")
}
return path.join(pkg, "bin", "opencode")
}),
)
const npmGlobal = ["lib", "node_modules", "@opencode", "cli"]
// Version managers such as fnm give each Node install its own prefix, while the npm on PATH may belong to another one.
unix("npm runs from the prefix that owns the running binary with that prefix's node first on PATH", () =>
Effect.gen(function* () {
const test = yield* installed(npmGlobal, true)
const npm = path.join(test.prefix, "bin", "npm")
yield* test.updater.method()
yield* test.updater.upgrade("npm", "2.2.7")
const removal = test.updater.removal("npm")
if (!removal) return yield* Effect.die("Expected npm removal command")
yield* removal.run
expect(test.commands[0]).toEqual([npm, "list", "-g", "--depth=0", "--prefix", test.prefix, "@opencode/cli"])
expect(test.commands.slice(5).map((command) => command.slice(0, 5))).toEqual([
[npm, "install", "--global", "--prefix", test.prefix],
[npm, "uninstall", "--global", "--prefix", test.prefix],
])
const first = path.join(test.prefix, "bin") + path.delimiter
expect(test.paths.map((value) => value?.startsWith(first) ?? false)).toEqual([
true,
false,
false,
false,
false,
true,
true,
])
}),
)
unix("npm only derives a prefix when the running package is that prefix's global package", () =>
Effect.gen(function* () {
const test = yield* installed(["lib", "node_modules", "wrapper", "node_modules", "@opencode", "cli"], true)
yield* test.updater.method()
expect(test.commands[0]).toEqual(["npm", "list", "-g", "--depth=0", "@opencode/cli"])
expect(test.paths[0]).toBeUndefined()
}),
)
it.live("npm upgrades fail when the running copy keeps its old version", () =>
Effect.gen(function* () {
const test = yield* installed(npmGlobal)
const error = yield* test.updater.upgrade("npm", "2.3.4").pipe(Effect.flip)
expect(test.commands).toHaveLength(1)
expect(error).toBeInstanceOf(Updater.UpgradeError)
expect(error.message).toBe(
`npm reported success, but ${yield* test.fs.realPath(test.executable)} is still version 2.2.7.`,
)
}),
)
const managers = [
{
name: "Volta",
label: "~/.volta",
directory: [".volta", "tools", "image", "packages", "@opencode", "cli", ...npmGlobal],
command: "volta install @opencode/cli",
},
{
name: "Volta",
label: "custom VOLTA_HOME",
directory: ["volta-home", "tools", "image", "packages", "@opencode", "cli", ...npmGlobal],
command: "volta install @opencode/cli",
},
{
name: "mise",
label: "mise npm backend",
directory: ["mise", "installs", "npm-opencode-cli", "2.2.7", ...npmGlobal],
command: "mise use -g npm:@opencode/cli",
},
] as const
managers.forEach((manager) => {
it.live(`${manager.label} installs print the ${manager.name} command instead of updating in place`, () =>
Effect.gen(function* () {
const test = yield* installed([...manager.directory], true)
expect(yield* test.updater.method()).toBeUndefined()
const latest = yield* test.updater.managed()
const pinned = yield* test.updater.managed("v2.2.6")
expect(latest?.title).toBe(`OpenCode is managed by ${manager.name}`)
expect(latest?.command).toBe(`${manager.command}@2.3.4`)
expect(pinned?.command).toBe(`${manager.command}@2.2.6`)
const applied = yield* test.updater.apply("2.3.4").pipe(Effect.flip)
expect(applied.message).toBe(`OpenCode is managed by ${manager.name}. Update it with ${manager.command}@2.3.4`)
// An explicit --method keeps its existing command, and the version check reports the untouched copy.
const explicit = yield* test.updater.upgrade("npm", "2.3.4").pipe(Effect.flip)
expect(explicit.message).toContain("is still version 2.2.7")
expect(test.commands).toEqual([
["npm", "install", "--global", "--allow-scripts=@opencode/cli", "--force", "@opencode/cli@2.3.4"],
])
}),
)
})
;[
["mise", "installs", "node", "24.22.2"],
[".volta", "tools", "image", "node", "22.23.4"],
].forEach((node) => {
it.live(`npm globals under ${node.slice(0, 3).join("/")} stay npm installs`, () =>
Effect.gen(function* () {
const test = yield* installed([...node, ...npmGlobal])
expect(yield* test.updater.managed()).toBeUndefined()
yield* test.updater.method()
expect(test.commands).toHaveLength(5)
}),
)
})
@@ -285,7 +438,7 @@ it.live("install failures expose stderr and process errors do not report success
}))
expect(yield* test.updater.method()).toBe(method)
expect(test.commands).toEqual([
["npm", "list", "-g", "--depth=0", "@opencode/cli"],
["npm", "list", "-g", "--depth=0", ...test.prefixArgs, "@opencode/cli"],
["pnpm", "list", "-g", "--depth=0", "@opencode/cli"],
["bun", "pm", "ls", "-g"],
["yarn", "global", "list"],
@@ -592,12 +745,19 @@ if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-no
yield* test.updater.upgrade("pnpm", "v2.3.4")
yield* test.updater.upgrade("vp", "v2.3.4")
expect(test.commands).toEqual([
["npm", "list", "-g", "--depth=0", "@opencode/cli-node"],
["npm", "list", "-g", "--depth=0", ...test.prefixArgs, "@opencode/cli-node"],
["pnpm", "list", "-g", "--depth=0", "@opencode/cli-node"],
["bun", "pm", "ls", "-g"],
["yarn", "global", "list"],
["vp", "list", "-g", "--json", "@opencode/cli-node"],
["npm", "install", "--global", "--allow-scripts=@opencode/cli-node", "@opencode/cli-node@2.3.4"],
[
"npm",
"install",
"--global",
...test.prefixArgs,
"--allow-scripts=@opencode/cli-node",
"@opencode/cli-node@2.3.4",
],
["pnpm", "add", "--global", "--allow-build=@opencode/cli-node", "@opencode/cli-node@2.3.4"],
["vp", "update", "-g", "@opencode/cli-node@2.3.4"],
])