diff --git a/packages/cli/src/services/fixtures/updater.ts b/packages/cli/src/services/fixtures/updater.ts new file mode 100644 index 000000000000..dca7d6783707 --- /dev/null +++ b/packages/cli/src/services/fixtures/updater.ts @@ -0,0 +1,58 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { Global } from "@opencode-ai/util/global" +import { AppProcess } from "@opencode-ai/util/process" +import { Effect, FileSystem, Layer, Stream } from "effect" +import { ChildProcessSpawner } from "effect/unstable/process" +import assert from "node:assert/strict" +import path from "node:path" +import { Updater } from "../updater" + +const latest = { version: "0.0.0-beta-17498" } +const installs: string[] = [] + +// This fixture runs in its own process; no real update requests or installs occur. +globalThis.fetch = Object.assign(async () => Response.json(latest), { preconnect() {} }) + +await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" }) + const dependencies = Layer.mergeAll( + Layer.succeed(FileSystem.FileSystem, fs), + Layer.succeed( + Global.Service, + Global.make({ home: directory, config: directory, cache: path.join(directory, "cache") }), + ), + Layer.succeed(AppProcess.Service, { + ...ChildProcessSpawner.make(() => Effect.die("Unexpected process spawn")), + runStream: () => Stream.die("Unexpected streaming process"), + run: (command) => { + assert.equal(command._tag, "StandardCommand") + if (command.command === "npm" && command.args[0] === "install") { + assert.ok(command.args.includes("--global")) + installs.push(command.args.at(-1)!) + } + return Effect.succeed({ + command: command.command, + exitCode: 0, + stdout: Buffer.from(command.command === "npm" ? "@opencode-ai/cli" : ""), + stderr: Buffer.alloc(0), + stdoutTruncated: false, + stderrTruncated: false, + }) + }, + }), + ) + yield* Effect.gen(function* () { + const updater = yield* Updater.Service + yield* updater.check() + assert.deepEqual(installs, ["@opencode-ai/cli@0.0.0-beta-17498"]) + yield* updater.check() + yield* updater.check() + assert.deepEqual(installs, ["@opencode-ai/cli@0.0.0-beta-17498"]) + latest.version = "0.0.0-beta-17499" + yield* updater.check() + assert.deepEqual(installs, ["@opencode-ai/cli@0.0.0-beta-17498", "@opencode-ai/cli@0.0.0-beta-17499"]) + }).pipe(Effect.provide(Updater.layer.pipe(Layer.provide(dependencies)))) + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), +) diff --git a/packages/cli/src/services/updater-action.ts b/packages/cli/src/services/updater-action.ts index 5cfff3b5ccd7..5199291ba8b6 100644 --- a/packages/cli/src/services/updater-action.ts +++ b/packages/cli/src/services/updater-action.ts @@ -5,9 +5,9 @@ const maximumComponent = "9007199254740991" const versionPattern = /^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ -export function action(current: string, latest: string, policy: Policy): Action { +export function action(current: string, latest: string, policy: Policy, installed = current): Action { if (policy === false) return "none" - const currentVersion = parseReleaseVersion(current) + const currentVersion = parseReleaseVersion(installed) const latestVersion = parseReleaseVersion(latest) if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none" // Major upgrades are never installed automatically. diff --git a/packages/cli/src/services/updater.test.ts b/packages/cli/src/services/updater.test.ts index 55cfae017bcb..4f0600e4c71e 100644 --- a/packages/cli/src/services/updater.test.ts +++ b/packages/cli/src/services/updater.test.ts @@ -1,8 +1,30 @@ import { describe, expect, test } from "bun:test" +import path from "node:path" import { action } from "./updater-action" import { decodePolicy } from "./updater" describe("updater", () => { + test("remembers successful installs across checks and accepts the next release", async () => { + // Isolate compiled version constants and the update endpoint from other tests. + const child = Bun.spawn( + [ + process.execPath, + "--define", + 'OPENCODE_VERSION="0.0.0-next-16473"', + "--define", + 'OPENCODE_CHANNEL="beta"', + path.join(import.meta.dir, "fixtures/updater.ts"), + ], + { env: { ...process.env, OPENCODE_DISABLE_AUTOUPDATE: "" }, stdout: "pipe", stderr: "pipe" }, + ) + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + expect(code, stdout + stderr).toBe(0) + }) + test("reads autoupdate from JSONC", () => { expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify") expect(decodePolicy('{ "autoupdate": false }')).toBe(false) @@ -32,6 +54,14 @@ describe("updater", () => { expect(action("1.2.3", "1.2.3", true)).toBe("none") }) + test("skips an installed update but still accepts the next release", () => { + const current = "0.0.0-next-16473" + const installed = "0.0.0-beta-17498" + expect(action(current, installed, true)).toBe("upgrade") + expect(action(current, installed, true, installed)).toBe("none") + expect(action(current, "0.0.0-beta-17499", true, installed)).toBe("upgrade") + }) + test("upgrades when latest is lower (rollback)", () => { expect(action("1.2.4", "1.2.3", true)).toBe("upgrade") }) diff --git a/packages/cli/src/services/updater.ts b/packages/cli/src/services/updater.ts index 89f1c72286e1..252f2110bca9 100644 --- a/packages/cli/src/services/updater.ts +++ b/packages/cli/src/services/updater.ts @@ -1,7 +1,7 @@ import { Global } from "@opencode-ai/util/global" import { AppProcess } from "@opencode-ai/util/process" import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version" -import { Context, Duration, Effect, FileSystem, Layer } from "effect" +import { Context, Duration, Effect, FileSystem, Layer, Ref } from "effect" import { ChildProcess } from "effect/unstable/process" import { parse, type ParseError } from "jsonc-parser" import path from "node:path" @@ -38,6 +38,7 @@ export const layer = Layer.effect( const fs = yield* FileSystem.FileSystem const global = yield* Global.Service const appProcess = yield* AppProcess.Service + const installed = yield* Ref.make(OPENCODE_VERSION) const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-") const readPolicy = Effect.fnUntraced(function* () { @@ -112,18 +113,20 @@ export const layer = Layer.effect( const upgrade = Effect.fnUntraced(function* (method: Method, version: string) { const target = `${packageName}@${version}` - const commands: Record, string[]> = { - npm: ["npm", "install", "--global", target], + const commands: Record, string[]> = { pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target], yarn: ["yarn", "global", "add", target], } const result = yield* Effect.scoped( Effect.gen(function* () { - if (method === "bun") { - // Bun does not prune old versions from its shared package cache. + if (method === "bun" || method === "npm") { yield* fs.makeDirectory(global.cache, { recursive: true }) const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" }) - return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes") + const command = + method === "bun" + ? ["bun", "install", "--global", "--trust", "--cache-dir", cache, target] + : ["npm", "install", "--global", "--cache", cache, target] + return yield* run(command, "5 minutes") } if (method === "curl") { yield* fs.makeDirectory(global.cache, { recursive: true }) @@ -156,18 +159,19 @@ export const layer = Layer.effect( return yield* Effect.gen(function* () { const version = yield* latest() + const current = yield* Ref.get(installed) yield* Effect.logInfo("update check", { - current: OPENCODE_VERSION, + current, latest: version, }) - const next = action(OPENCODE_VERSION, version, policy) + const next = action(OPENCODE_VERSION, version, policy, current) if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" }) - if (next === "notify") - return yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version }) + if (next === "notify") return yield* Effect.logInfo("OpenCode update available", { current, latest: version }) const detected = yield* method() if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found") yield* upgrade(detected, version) - yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected }) + yield* Ref.set(installed, version) + yield* Effect.logInfo("updated OpenCode", { from: current, to: version, method: detected }) }) }, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),