diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 23937d8292b0..3ebaf9f49bca 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -1,6 +1,7 @@ import { Argument, Flag, GlobalFlag } from "effect/unstable/cli" import { Schema } from "effect" import { Spec } from "../framework/spec" +import { Updater } from "../services/updater" export const PrintLogs = GlobalFlag.setting("print-logs")({ flag: Flag.boolean("print-logs").pipe( @@ -56,6 +57,20 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional), }, commands: [ + Spec.make("upgrade", { + description: "Upgrade OpenCode to the latest or a specific version", + params: { + target: Argument.string("target").pipe( + Argument.withDescription("Version to upgrade to (with or without a leading v)"), + Argument.optional, + ), + method: Flag.choice("method", Updater.methods).pipe( + Flag.withAlias("m"), + Flag.withDescription("Installation method to use"), + Flag.optional, + ), + }, + }), Spec.make("acp", { description: "Start an Agent Client Protocol server" }), Spec.make("api", { description: "Make a request to the running server", diff --git a/packages/cli/src/commands/handlers/upgrade.ts b/packages/cli/src/commands/handlers/upgrade.ts new file mode 100644 index 000000000000..4b0f7542d980 --- /dev/null +++ b/packages/cli/src/commands/handlers/upgrade.ts @@ -0,0 +1,38 @@ +import { intro, log, outro, spinner } from "@clack/prompts" +import { Effect, Option } from "effect" +import { Commands } from "../commands" +import { Runtime } from "../../framework/runtime" +import { Updater } from "../../services/updater" +import { handlePromptErrors } from "../../ui/prompt" +import { OPENCODE_VERSION } from "../../version" + +export default Runtime.handler( + Commands.commands.upgrade, + Effect.fn("cli.upgrade")(function* (input) { + intro("Upgrade") + const updater = yield* Updater.Service + 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."), + ) + + log.info(`Using method: ${method}`) + const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest()) + const version = target.trim().replace(/^v/, "") + if (version === OPENCODE_VERSION) { + log.warn(`OpenCode upgrade skipped: ${version} is already installed`) + outro("Done") + return + } + + log.info(`From ${OPENCODE_VERSION} → ${version}`) + const progress = spinner() + progress.start("Upgrading...") + yield* updater.upgrade(method, target).pipe( + Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))), + Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))), + ) + outro("Done") + }, handlePromptErrors), +) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bbc4595b077e..123a7c2139d9 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -17,6 +17,7 @@ import { CpuProfile } from "./cpu-profile" const Handlers = Runtime.handlers(Commands, { $: () => import("./commands/handlers/default"), + upgrade: () => import("./commands/handlers/upgrade"), acp: () => import("./commands/handlers/acp"), api: () => import("./commands/handlers/api"), auth: { diff --git a/packages/cli/src/services/updater-action.ts b/packages/cli/src/services/updater-action.ts index 5cfff3b5ccd7..7578ccf9dcd4 100644 --- a/packages/cli/src/services/updater-action.ts +++ b/packages/cli/src/services/updater-action.ts @@ -15,7 +15,7 @@ export function action(current: string, latest: string, policy: Policy): Action return policy === "notify" ? "notify" : "upgrade" } -function parseReleaseVersion(input: string) { +export function parseReleaseVersion(input: string) { if (input.length > 256) return const match = input.trim().match(versionPattern) if (!match) return diff --git a/packages/cli/src/services/updater.ts b/packages/cli/src/services/updater.ts index 89f1c72286e1..73e03a297c7d 100644 --- a/packages/cli/src/services/updater.ts +++ b/packages/cli/src/services/updater.ts @@ -5,19 +5,21 @@ import { Context, Duration, Effect, FileSystem, Layer } from "effect" import { ChildProcess } from "effect/unstable/process" import { parse, type ParseError } from "jsonc-parser" import path from "node:path" -import { action, type Policy } from "./updater-action" +import { action, parseReleaseVersion, type Policy } from "./updater-action" declare const OPENCODE_CLI_NAME: string | undefined -type Method = "npm" | "pnpm" | "bun" | "yarn" | "curl" +export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const +export type Method = (typeof methods)[number] const packageName = - typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node" - ? OPENCODE_CLI_NAME - : "@opencode-ai/cli" + typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node" ? "opencode-node" : "@opencode-ai/cli" export interface Interface { readonly check: () => Effect.Effect + readonly method: () => Effect.Effect + readonly latest: () => Effect.Effect + readonly upgrade: (method: Method, version: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/cli/Updater") {} @@ -110,7 +112,9 @@ export const layer = Layer.effect( return data.version }) - const upgrade = Effect.fnUntraced(function* (method: Method, version: string) { + const upgrade = Effect.fnUntraced(function* (method: Method, input: string) { + if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`)) + const version = input.trim().replace(/^v/, "") const target = `${packageName}@${version}` const commands: Record, string[]> = { npm: ["npm", "install", "--global", target], @@ -138,7 +142,7 @@ export const layer = Layer.effect( } return yield* run(commands[method], "5 minutes") }), - ) + ).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause }))) if (result.code === 0) return return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`)) }) @@ -173,7 +177,7 @@ export const layer = Layer.effect( Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })), ) - return Service.of({ check }) + return Service.of({ check, method, latest, upgrade }) }), ) diff --git a/packages/cli/test/fixture/upgrade.ts b/packages/cli/test/fixture/upgrade.ts new file mode 100644 index 000000000000..c3a8adeba935 --- /dev/null +++ b/packages/cli/test/fixture/upgrade.ts @@ -0,0 +1,36 @@ +import { NodeServices } from "@effect/platform-node" +import { Effect } from "effect" +import { Command } from "effect/unstable/cli" +import { Commands } from "../../src/commands/commands" +import upgrade from "../../src/commands/handlers/upgrade" +import { Updater } from "../../src/services/updater" + +const record = (event: unknown) => console.log(`EVENT ${JSON.stringify(event)}`) + +await Effect.runPromise( + Command.runWith(Commands.commands.upgrade.spec.pipe(Command.withHandler(upgrade)), { version: "test" })( + process.argv.slice(2), + ).pipe( + Effect.provideService(Updater.Service, { + check: () => Effect.die("Manual upgrades must not run the automatic update check"), + method: () => + Effect.sync(() => { + record("method") + return Updater.methods.find((method) => method === (process.env.UPGRADE_TEST_METHOD ?? "npm")) + }), + latest: () => + Effect.suspend(() => { + record("latest") + return process.env.UPGRADE_TEST_LATEST_ERROR + ? Effect.fail(new Error("Update check failed")) + : Effect.succeed("0.0.0-beta-new") + }), + upgrade: (method, version) => + Effect.suspend(() => { + record({ method, version }) + return process.env.UPGRADE_TEST_INSTALL_ERROR ? Effect.fail(new Error("Permission denied")) : Effect.void + }), + }), + Effect.provide(NodeServices.layer), + ), +) diff --git a/packages/cli/test/updater-install.test.ts b/packages/cli/test/updater-install.test.ts new file mode 100644 index 000000000000..eb90ef01d99d --- /dev/null +++ b/packages/cli/test/updater-install.test.ts @@ -0,0 +1,227 @@ +import { NodeServices } from "@effect/platform-node" +import { Global } from "@opencode-ai/util/global" +import { AppProcess } from "@opencode-ai/util/process" +import { expect, test } from "bun:test" +import { Effect, FileSystem, Stream } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { existsSync } from "node:fs" +import path from "node:path" +import { Updater } from "../src/services/updater" +import { testEffect } from "../../core/test/lib/effect" + +const it = testEffect(NodeServices.layer) + +declare const OPENCODE_CLI_NAME: string | undefined + +function fixture( + respond: (command: ChildProcess.StandardCommand) => Partial & { + error?: AppProcess.AppProcessError + } = () => ({}), +) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" }) + const global = Global.make({ + home: path.join(root, "home"), + data: path.join(root, "data"), + cache: path.join(root, "cache"), + config: path.join(root, "config"), + state: path.join(root, "state"), + tmp: path.join(root, "tmp"), + bin: path.join(root, "bin"), + log: path.join(root, "log"), + repos: path.join(root, "repos"), + }) + const commands: string[][] = [] + const updater = yield* Updater.Service.pipe( + Effect.provide(Updater.layer), + Effect.provideService(Global.Service, global), + Effect.provideService( + AppProcess.Service, + AppProcess.Service.of({ + ...spawner, + run: (command) => + Effect.suspend(() => { + if (command._tag !== "StandardCommand") return Effect.die("Unexpected piped install command") + commands.push([command.command, ...command.args]) + const result = respond(command) + if (result.error) return Effect.fail(result.error) + return Effect.succeed({ + command: command.command, + exitCode: 0, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + stdoutTruncated: false, + stderrTruncated: false, + ...result, + }) + }), + runStream: () => Stream.die("Unexpected streaming install command"), + }), + ), + ) + return { updater, commands, global, fs } + }) +} + +const installs = [ + { method: "npm", command: ["npm", "install", "--global", "@opencode-ai/cli@2.3.4-beta.1"] }, + { + method: "pnpm", + command: ["pnpm", "add", "--global", "--allow-build=@opencode-ai/cli", "@opencode-ai/cli@2.3.4-beta.1"], + }, + { method: "yarn", command: ["yarn", "global", "add", "@opencode-ai/cli@2.3.4-beta.1"] }, +] as const + +installs.forEach(({ method, command }) => { + it.live(`${method} installs the explicit V2 package version without a leading v`, () => + Effect.gen(function* () { + const test = yield* fixture() + yield* test.updater.upgrade(method, "v2.3.4-beta.1") + expect(test.commands).toEqual([[...command]]) + }), + ) +}) +;[0, 1].forEach((exitCode) => { + it.live(`bun isolates and removes its install cache after exit ${exitCode}`, () => + Effect.gen(function* () { + const test = yield* fixture((command) => { + expect(command.command).toBe("bun") + expect(existsSync(command.args[4])).toBe(true) + return { exitCode, stderr: Buffer.from("bun install failed") } + }) + const result = yield* test.updater.upgrade("bun", "v2.3.4-beta.1").pipe(Effect.flip, Effect.option) + const cache = test.commands[0]?.[5] + expect(cache).toStartWith(path.join(test.global.cache, "update-")) + expect(test.commands).toEqual([ + ["bun", "install", "--global", "--trust", "--cache-dir", cache, "@opencode-ai/cli@2.3.4-beta.1"], + ]) + expect(yield* test.fs.readDirectory(test.global.cache)).toEqual([]) + expect(result._tag).toBe(exitCode === 0 ? "None" : "Some") + if (result._tag === "Some") expect(result.value.message).toBe("bun install failed") + }), + ) +}) +;["success", "download", "install"].forEach((failure) => { + it.live(`curl uses the V2 installer and cleans its directory: ${failure}`, () => + Effect.gen(function* () { + const test = yield* fixture((command) => { + const installer = command.command === "curl" ? command.args[2] : command.args[0] + expect(existsSync(path.dirname(installer))).toBe(true) + return { + exitCode: command.command === (failure === "download" ? "curl" : failure === "install" ? "bash" : "") ? 1 : 0, + stderr: Buffer.from(`${failure} failed`), + } + }) + const result = yield* test.updater.upgrade("curl", "v2.3.4-beta.1").pipe(Effect.flip, Effect.option) + const installer = test.commands[0]?.[3] + expect(installer).toStartWith(path.join(test.global.cache, "update-")) + expect(test.commands).toEqual([ + ["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"], + ...(failure === "download" ? [] : [["bash", installer, "--version", "2.3.4-beta.1", "--no-modify-path"]]), + ]) + expect(yield* test.fs.readDirectory(test.global.cache)).toEqual([]) + expect(result._tag).toBe(failure === "success" ? "None" : "Some") + if (result._tag === "Some") expect(result.value.message).toBe(`${failure} failed`) + }), + ) +}) + +it.live("invalid version targets never execute a command or create a cache", () => + Effect.gen(function* () { + const test = yield* fixture() + yield* Effect.forEach(Updater.methods, (method) => + Effect.forEach( + ["", "latest", "2.3", "01.2.3", "vv2.3.4", "2.3.4; echo unsafe", "--global", "v2.3.4\n--force"], + (version) => + Effect.gen(function* () { + const error = yield* test.updater.upgrade(method, version).pipe(Effect.flip) + expect(error.message).toBe(`Invalid version: ${version}`) + }), + ), + ) + expect(test.commands).toEqual([]) + expect(yield* test.fs.exists(test.global.cache)).toBe(false) + }), +) + +it.live("install failures expose stderr and process errors do not report success", () => + Effect.gen(function* () { + const failed = yield* fixture(() => ({ exitCode: 1, stderr: Buffer.from(" registry denied access\n") })) + const error = yield* failed.updater.upgrade("npm", "2.3.4").pipe(Effect.flip) + expect(error.message).toBe("registry denied access") + const missing = yield* fixture(() => ({ error: new AppProcess.AppProcessError({ command: "npm" }) })) + const unavailable = yield* missing.updater.upgrade("npm", "2.3.4").pipe(Effect.flip) + expect(unavailable.message).toBe("Failed to update with npm") + expect(failed.commands).toHaveLength(1) + expect(missing.commands).toHaveLength(1) + }), +) +;(["npm", "pnpm", "bun", "yarn", undefined] as const).forEach((method) => { + it.live(`method detection identifies ${method ?? "an unknown installation"} using the V2 package`, () => + Effect.gen(function* () { + const test = yield* fixture((command) => ({ + stdout: Buffer.from(command.command === method ? "@opencode-ai/cli@2.3.4" : "opencode-ai@1.0.0"), + })) + expect(yield* test.updater.method()).toBe(method) + expect(test.commands).toEqual([ + ["npm", "list", "-g", "--depth=0", "@opencode-ai/cli"], + ["pnpm", "list", "-g", "--depth=0", "@opencode-ai/cli"], + ["bun", "pm", "ls", "-g"], + ["yarn", "global", "list"], + ]) + }), + ) +}) + +it.live("method detection tolerates unavailable package managers", () => + Effect.gen(function* () { + const test = yield* fixture((command) => + command.command === "yarn" + ? { stdout: Buffer.from("@opencode-ai/cli@2.3.4") } + : { error: new AppProcess.AppProcessError({ command: command.command }) }, + ) + expect(yield* test.updater.method()).toBe("yarn") + expect(test.commands).toHaveLength(4) + }), +) + +test("Node distribution honors the compile-time CLI name", async () => { + const child = Bun.spawn( + [ + process.execPath, + "test", + import.meta.path, + "--define", + 'OPENCODE_CLI_NAME="opencode2-node"', + "--test-name-pattern", + "^Node distribution resolves the published npm package$", + ], + { cwd: path.join(import.meta.dir, ".."), stdout: "ignore", stderr: "pipe" }, + ) + const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]) + expect(code, stderr).toBe(0) + expect(stderr).toContain("1 pass") +}) + +if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node") { + it.live("Node distribution resolves the published npm package", () => + Effect.gen(function* () { + const test = yield* fixture((command) => ({ + stdout: Buffer.from(command.command === "npm" ? "opencode-node@2.3.4" : ""), + })) + expect(yield* test.updater.method()).toBe("npm") + yield* test.updater.upgrade("npm", "v2.3.4") + yield* test.updater.upgrade("pnpm", "v2.3.4") + expect(test.commands).toEqual([ + ["npm", "list", "-g", "--depth=0", "opencode-node"], + ["pnpm", "list", "-g", "--depth=0", "opencode-node"], + ["bun", "pm", "ls", "-g"], + ["yarn", "global", "list"], + ["npm", "install", "--global", "opencode-node@2.3.4"], + ["pnpm", "add", "--global", "--allow-build=opencode-node", "opencode-node@2.3.4"], + ]) + }), + ) +} diff --git a/packages/cli/test/upgrade.test.ts b/packages/cli/test/upgrade.test.ts new file mode 100644 index 000000000000..a9b773db5a0d --- /dev/null +++ b/packages/cli/test/upgrade.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +describe("upgrade command", () => { + test("is registered in root help and documents its options", async () => { + const root = await cli(["--help"], {}, "../src/index.ts") + const help = await cli(["upgrade", "--help"], {}, "../src/index.ts") + expect(root.exitCode).toBe(0) + expect(root.stdout).toContain("upgrade") + expect(help.exitCode).toBe(0) + expect(help.stdout).toContain("[]") + expect(help.stdout).toContain("--method") + expect(help.stdout).toContain("-m") + }) + + test("detects the installation method and resolves the latest version", async () => { + const result = await cli([]) + expect(result.exitCode).toBe(0) + expect(result.events).toEqual(["method", "latest", { method: "npm", version: "0.0.0-beta-new" }]) + expect(result.stdout).toContain("Upgrade complete") + }) + + test("accepts an explicit version and method without detection or a version lookup", async () => { + const result = await cli(["v0.0.0-beta-target", "--method", "pnpm"]) + expect(result.exitCode).toBe(0) + expect(result.events).toEqual([{ method: "pnpm", version: "v0.0.0-beta-target" }]) + expect(result.stdout).toContain("0.0.0-beta-old → 0.0.0-beta-target") + }) + + test("accepts the short method flag and an explicit major upgrade", async () => { + const result = await cli(["2.0.0", "-m", "bun"]) + expect(result.exitCode).toBe(0) + expect(result.events).toEqual([{ method: "bun", version: "2.0.0" }]) + }) + + test("skips the already installed version", async () => { + const result = await cli(["v0.0.0-beta-old"]) + expect(result.exitCode).toBe(0) + expect(result.events).toEqual(["method"]) + expect(result.stdout).toContain("already installed") + }) + + test("requires an explicit method when detection fails", async () => { + const result = await cli([], { UPGRADE_TEST_METHOD: "unknown" }) + expect(result.exitCode).toBe(1) + expect(result.events).toEqual(["method"]) + expect(result.stdout).toContain("Pass --method") + }) + + test("rejects unsupported methods before attempting an upgrade", async () => { + const result = await cli(["--method", "brew"]) + expect(result.exitCode).not.toBe(0) + expect(result.events).toEqual([]) + }) + + test("reports version lookup failures without installing", async () => { + const result = await cli([], { UPGRADE_TEST_LATEST_ERROR: "1" }) + expect(result.exitCode).toBe(1) + expect(result.events).toEqual(["method", "latest"]) + expect(result.stdout).toContain("Update check failed") + }) + + test("reports installation failures with a nonzero exit code", async () => { + const result = await cli([], { UPGRADE_TEST_INSTALL_ERROR: "1" }) + expect(result.exitCode).toBe(1) + expect(result.stdout).toContain("Upgrade failed") + expect(result.stdout).toContain("Permission denied") + expect(result.stdout).not.toContain("Upgrade complete") + }) +}) + +async function cli(args: string[], env: Record = {}, entry = "fixture/upgrade.ts") { + const root = await mkdtemp(path.join(os.tmpdir(), "opencode-upgrade-")) + try { + const child = Bun.spawn( + [process.execPath, "--define", 'OPENCODE_VERSION="0.0.0-beta-old"', path.join(import.meta.dir, entry), ...args], + { + cwd: path.join(import.meta.dir, ".."), + env: { + ...process.env, + OPENCODE_TEST_HOME: root, + XDG_DATA_HOME: path.join(root, "data"), + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_STATE_HOME: path.join(root, "state"), + OPENCODE_DISABLE_AUTOUPDATE: "1", + ...env, + }, + stdout: "pipe", + stderr: "pipe", + }, + ) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + const events = stdout + .split("\n") + .filter((line) => line.startsWith("EVENT ")) + .map((line) => JSON.parse(line.slice(6))) + expect(await Bun.file(path.join(root, "state", "opencode", "service-local.json")).exists()).toBe(false) + return { stdout, stderr, exitCode, events } + } finally { + await rm(root, { recursive: true, force: true }) + } +}