diff --git a/packages/cli/src/commands/handlers/upgrade.ts b/packages/cli/src/commands/handlers/upgrade.ts index af7c221f2e9..b29b59302d3 100644 --- a/packages/cli/src/commands/handlers/upgrade.ts +++ b/packages/cli/src/commands/handlers/upgrade.ts @@ -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}`) diff --git a/packages/cli/src/services/updater.ts b/packages/cli/src/services/updater.ts index d08e929960c..0c38bab2205 100644 --- a/packages/cli/src/services/updater.ts +++ b/packages/cli/src/services/updater.ts @@ -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 = { curl: "The OpenCode installer", @@ -110,6 +113,8 @@ export interface Interface { readonly check: () => Effect.Effect readonly apply: (version: string) => Effect.Effect readonly method: () => Effect.Effect + /** Explains how to update a copy that Volta or mise manages, which `method` leaves undetected. */ + readonly managed: (version?: string) => Effect.Effect readonly latest: (method?: Method) => Effect.Effect readonly upgrade: (method: Method, version: string) => Effect.Effect 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-) 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 /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 + // \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, 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) diff --git a/packages/cli/test/fixture/upgrade.ts b/packages/cli/test/fixture/upgrade.ts index 149433d9465..9a5b95e0b4d 100644 --- a/packages/cli/test/fixture/upgrade.ts +++ b/packages/cli/test/fixture/upgrade.ts @@ -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") diff --git a/packages/cli/test/updater-install.test.ts b/packages/cli/test/updater-install.test.ts index 28968469f0a..f9121e760ac 100644 --- a/packages/cli/test/updater-install.test.ts +++ b/packages/cli/test/updater-install.test.ts @@ -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, + executableOverride?: ( + root: string, + home: string, + ) => Effect.Effect, ) { 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 = [] + 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 /, optionally with an npm in /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"], ])