From 5df049d081e4eb9d1a2cb700135397f845f885ee Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 27 Jun 2026 00:51:45 -0400 Subject: [PATCH] feat(cli): add self-update service Check the npm registry (at most once per 24h, file-locked) for newer @opencode-ai/cli releases and act on a host-level autoupdate policy read from config. Apply patch updates automatically, confirm minor updates interactively, and never auto-update across majors. Skip local installs and honor the disable flag. Wired into the default TUI command (interactive) and serve (background). --- bun.lock | 5 + packages/cli/package.json | 3 + packages/cli/src/commands/handlers/default.ts | 3 + packages/cli/src/commands/handlers/serve.ts | 3 + packages/cli/src/framework/runtime.ts | 7 +- packages/cli/src/index.ts | 2 + packages/cli/src/services/updater.test.ts | 25 +++ packages/cli/src/services/updater.ts | 211 ++++++++++++++++++ 8 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/services/updater.test.ts create mode 100644 packages/cli/src/services/updater.ts diff --git a/bun.lock b/bun.lock index 281d4abda16..85a54c847e5 100644 --- a/bun.lock +++ b/bun.lock @@ -105,12 +105,15 @@ "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "effect": "catalog:", + "jsonc-parser": "3.3.1", + "semver": "catalog:", "solid-js": "catalog:", }, "devDependencies": { "@opencode-ai/script": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", + "@types/semver": "catalog:", "@typescript/native-preview": "catalog:", }, }, @@ -5975,6 +5978,8 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], + "@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], diff --git a/packages/cli/package.json b/packages/cli/package.json index 4b21243fda3..2ca7a5ad668 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,12 +25,15 @@ "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "effect": "catalog:", + "jsonc-parser": "3.3.1", + "semver": "catalog:", "solid-js": "catalog:" }, "devDependencies": { "@opencode-ai/script": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", + "@types/semver": "catalog:", "@typescript/native-preview": "catalog:" } } diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 0e55185b145..71d9fe73b6a 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -3,11 +3,14 @@ import { Runtime } from "../../framework/runtime" import { Effect, Option } from "effect" import { Daemon } from "../../services/daemon" import { Standalone } from "../../services/standalone" +import { Updater } from "../../services/updater" export default Runtime.handler(Commands, (input) => Effect.gen(function* () { const directory = Option.getOrUndefined(input.directory) if (directory !== undefined) process.chdir(directory) + const updater = yield* Updater.Service + yield* updater.check({ interactive: true }) const daemon = yield* Daemon.Service const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport()) const { runTui } = yield* Effect.promise(() => import("../../tui")) diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index 45922295697..87fbece44ff 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -11,6 +11,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Daemon } from "../../services/daemon" +import { Updater } from "../../services/updater" export default Runtime.handler( Commands.commands.serve, @@ -32,6 +33,8 @@ export default Runtime.handler( if (input.register) yield* daemon.register(address) const url = HttpServer.formatAddress(address) console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`) + const updater = yield* Updater.Service + yield* updater.check({ interactive: false }).pipe(Effect.forkScoped) return yield* (input.stdio ? waitForStdinClose() : Effect.never) }).pipe(Effect.annotateLogs({ role: "server" })), ) diff --git a/packages/cli/src/framework/runtime.ts b/packages/cli/src/framework/runtime.ts index 060b5a0d894..44ed78ea727 100644 --- a/packages/cli/src/framework/runtime.ts +++ b/packages/cli/src/framework/runtime.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect" import * as Command from "effect/unstable/cli/Command" import { Spec } from "./spec" import { Daemon } from "../services/daemon" +import { Updater } from "../services/updater" import { Scope } from "effect" export type Input = @@ -11,11 +12,11 @@ export type Input = ? Input : never -type RuntimeHandler = (input: unknown) => Effect.Effect +type RuntimeHandler = (input: unknown) => Effect.Effect type Loader = () => Promise<{ - default: (input: Input) => Effect.Effect + default: (input: Input) => Effect.Effect }> -type ProvidedCommand = Command.Command +type ProvidedCommand = Command.Command export type Handlers = keyof Node["commands"] extends never ? Loader diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 18801f0388c..53c660b2b44 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -9,6 +9,7 @@ import { Commands } from "./commands/commands" import { Runtime } from "./framework/runtime" import { Daemon } from "./services/daemon" import { Logging } from "@opencode-ai/core/observability/logging" +import { Updater } from "./services/updater" const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe( Layer.provide(NodeFileSystem.layer), @@ -36,6 +37,7 @@ const Handlers = Runtime.handlers(Commands, { Runtime.run(Commands, Handlers, { version: "local" }).pipe( Effect.annotateLogs({ role: "cli" }), Effect.provide(Daemon.defaultLayer), + Effect.provide(Updater.defaultLayer), Effect.provide(LoggingLayer), Effect.provide(NodeServices.layer), Effect.scoped, diff --git a/packages/cli/src/services/updater.test.ts b/packages/cli/src/services/updater.test.ts new file mode 100644 index 00000000000..f982c77a105 --- /dev/null +++ b/packages/cli/src/services/updater.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { action, decodePolicy } from "./updater" + +describe("updater", () => { + test("reads autoupdate from JSONC", () => { + expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify") + expect(decodePolicy('{ "autoupdate": false }')).toBe(false) + expect(decodePolicy('{ "autoupdate": "invalid" }')).toBeUndefined() + }) + + test("automatically updates patches", () => { + expect(action("1.2.3", "1.2.4", true, false)).toBe("upgrade") + expect(action("1.2.3", "1.2.4", "notify", true)).toBe("confirm") + expect(action("1.2.3", "1.2.4", false, true)).toBe("none") + }) + + test("requires an interactive confirmation for minors", () => { + expect(action("1.2.3", "1.3.0", true, true)).toBe("confirm") + expect(action("1.2.3", "1.3.0", true, false)).toBe("none") + }) + + test("never automatically updates majors", () => { + expect(action("1.2.3", "2.0.0", true, true)).toBe("none") + }) +}) diff --git a/packages/cli/src/services/updater.ts b/packages/cli/src/services/updater.ts new file mode 100644 index 00000000000..a1bca4e6fb2 --- /dev/null +++ b/packages/cli/src/services/updater.ts @@ -0,0 +1,211 @@ +import { Global } from "@opencode-ai/core/global" +import { Flag } from "@opencode-ai/core/flag/flag" +import { AppProcess } from "@opencode-ai/core/process" +import { Flock } from "@opencode-ai/core/util/flock" +import { + InstallationChannel, + InstallationLocal, + InstallationVersion, +} from "@opencode-ai/core/installation/version" +import { Context, Duration, Effect, FileSystem, Layer, Option, Path, Schema, Terminal } from "effect" +import { Prompt } from "effect/unstable/cli" +import { ChildProcess } from "effect/unstable/process" +import { parse, type ParseError } from "jsonc-parser" +import path from "node:path" +import semver from "semver" + +export type Policy = boolean | "notify" +export type Action = "none" | "confirm" | "upgrade" +type Method = "npm" | "pnpm" | "bun" | "yarn" + +const packageName = "@opencode-ai/cli" +const checkInterval = 24 * 60 * 60 * 1_000 + +const State = Schema.Struct({ + checked: Schema.Number, + latest: Schema.String, + dismissed: Schema.optional(Schema.String), +}) +type State = typeof State.Type + +export interface Interface { + readonly check: (options: { interactive: boolean }) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/cli/Updater") {} + +export function decodePolicy(text: string): Policy | undefined { + // The CLI only projects this host-level preference instead of initializing + // the location-scoped server configuration graph. + const errors: ParseError[] = [] + const input: unknown = parse(text, errors, { allowTrailingComma: true }) + if (errors.length || typeof input !== "object" || input === null || !("autoupdate" in input)) return + const value = input.autoupdate + if (typeof value === "boolean" || value === "notify") return value +} + +export function action(current: string, latest: string, policy: Policy, interactive: boolean): Action { + if (policy === false) return "none" + if (!semver.valid(current) || !semver.valid(latest) || !semver.gt(latest, current)) return "none" + // Major upgrades are never offered or installed automatically. + if (semver.major(latest) !== semver.major(current)) return "none" + if (semver.minor(latest) !== semver.minor(current)) return interactive ? "confirm" : "none" + if (policy === "notify") return interactive ? "confirm" : "none" + return "upgrade" +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const global = yield* Global.Service + const appProcess = yield* AppProcess.Service + const effectPath = yield* Path.Path + const terminal = yield* Terminal.Terminal + const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-") + const stateFile = path.join(global.state, `updater-${channel}.json`) + const decodeState = Schema.decodeUnknownOption(Schema.fromJsonString(State)) + + const readPolicy = Effect.fnUntraced(function* () { + const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) => + fs + .readFileString(path.join(global.config, name)) + .pipe(Effect.map(decodePolicy), Effect.catch(() => Effect.succeed(undefined))), + ) + return values.findLast((value) => value !== undefined) ?? true + }) + + const readState = Effect.fnUntraced(function* () { + const text = yield* fs.readFileString(stateFile).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!text) return + return Option.getOrUndefined(decodeState(text)) + }) + + const writeState = Effect.fnUntraced(function* (state: State) { + const temp = stateFile + ".tmp" + yield* fs.makeDirectory(global.state, { recursive: true }) + yield* fs.writeFileString(temp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 }) + yield* fs.rename(temp, stateFile) + }) + + const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") { + return yield* appProcess + .run(ChildProcess.make(command[0], command.slice(1)), { + timeout, + maxOutputBytes: 100_000, + maxErrorBytes: 100_000, + }) + .pipe( + Effect.map((result) => ({ + code: result.exitCode, + stdout: result.stdout.toString("utf8"), + stderr: result.stderr.toString("utf8"), + })), + Effect.catch(() => Effect.succeed({ code: 1, stdout: "", stderr: "" })), + ) + }) + + const method = Effect.fnUntraced(function* () { + const checks: ReadonlyArray<{ method: Method; command: string[] }> = [ + { method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] }, + { method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] }, + { method: "bun", command: ["bun", "pm", "ls", "-g"] }, + { method: "yarn", command: ["yarn", "global", "list"] }, + ] + const results = yield* Effect.forEach( + checks, + (check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))), + { concurrency: "unbounded" }, + ) + return results.find((result) => result.result.stdout.includes(packageName))?.check.method + }) + + const latest = Effect.fnUntraced(function* () { + const response = yield* Effect.tryPromise({ + try: () => + fetch( + `https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(InstallationChannel)}`, + { headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) }, + ), + catch: (cause) => new Error("Failed to check for updates", { cause }), + }) + if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`)) + const data = yield* Effect.tryPromise({ + try: () => response.json(), + catch: (cause) => new Error("Failed to read update information", { cause }), + }) + if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") { + return yield* Effect.fail(new Error("Update information did not include a version")) + } + return data.version + }) + + const upgrade = Effect.fnUntraced(function* (method: Method, version: string) { + const target = `${packageName}@${version}` + const commands: Record = { + npm: ["npm", "install", "--global", target], + pnpm: ["pnpm", "install", "--global", target], + bun: ["bun", "install", "--global", target], + yarn: ["yarn", "global", "add", target], + } + const result = yield* run(commands[method], "5 minutes") + if (result.code === 0) return + return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`)) + }) + + const confirm = (version: string) => + Prompt.confirm({ + message: `Update OpenCode from ${InstallationVersion} to ${version}?`, + initial: true, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, effectPath), + Effect.provideService(Terminal.Terminal, terminal), + Effect.orElseSucceed(() => false), + ) + + const check = Effect.fn("cli.updater.check")(function* (options: { interactive: boolean }) { + if (InstallationLocal || Flag.OPENCODE_DISABLE_AUTOUPDATE) return + const policy = yield* readPolicy() + if (policy === false) return + + return yield* Effect.scoped( + Effect.gen(function* () { + yield* Flock.effect(`opencode-cli-updater-${channel}`, { dir: path.join(global.state, "locks") }) + const previous = yield* readState() + const now = Date.now() + const version = + previous && now - previous.checked < checkInterval + ? previous.latest + : yield* latest().pipe( + Effect.tap((value) => + writeState({ checked: now, latest: value, dismissed: previous?.dismissed }), + ), + ) + const next = action( + InstallationVersion, + version, + Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE ? "notify" : policy, + options.interactive && process.stdin.isTTY && process.stdout.isTTY, + ) + if (next === "none" || (next === "confirm" && previous?.dismissed === version)) return + const install = next === "upgrade" || (yield* confirm(version)) + if (!install) { + yield* writeState({ checked: previous?.checked ?? now, latest: version, dismissed: version }) + return + } + 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: InstallationVersion, to: version, method: detected }) + }), + ) + }, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause }))) + + return Service.of({ check }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer), Layer.provide(Global.defaultLayer)) + +export * as Updater from "./updater"