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).
This commit is contained in:
Dax Raad 2026-06-27 00:51:45 -04:00
parent 573ab9c24b
commit 5df049d081
8 changed files with 256 additions and 3 deletions

View file

@ -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=="],

View file

@ -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:"
}
}

View file

@ -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"))

View file

@ -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" })),
)

View file

@ -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<Value> =
@ -11,11 +12,11 @@ export type Input<Value> =
? Input
: never
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Scope.Scope>
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Updater.Service | Scope.Scope>
type Loader<Node extends Spec.Any> = () => Promise<{
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Scope.Scope>
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Updater.Service | Scope.Scope>
}>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Scope.Scope>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Updater.Service | Scope.Scope>
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
? Loader<Node>

View file

@ -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,

View file

@ -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")
})
})

View file

@ -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<void>
}
export class Service extends Context.Service<Service, Interface>()("@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<Method, string[]> = {
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"