From 33dd4e3ba8e91d6f034d9754e98c186238caec65 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 2 Sep 2026 12:08:41 -0400 Subject: [PATCH] feat(cli): apply managed updates when idle and wire up ui (#46485) --- packages/cli/src/commands/handlers/default.ts | 7 +- packages/cli/src/server-process.ts | 59 ++- .../cli/src/services/server-connection.ts | 2 +- packages/cli/src/services/updater-action.ts | 9 +- packages/cli/src/services/updater.test.ts | 58 ++- packages/cli/src/services/updater.ts | 469 ++++++++++++------ packages/cli/test/acp/subprocess.ts | 2 +- packages/cli/test/fixture/upgrade.ts | 2 + packages/cli/test/updater-monitor.test.ts | 107 ++++ .../client/src/promise/generated/types.ts | 2 +- packages/core/src/config/normalize.ts | 7 +- packages/core/src/v1/config/migrate.ts | 9 +- packages/core/test/config/config.test.ts | 13 +- packages/protocol/openapi.json | 13 +- packages/schema/src/config.ts | 8 +- packages/server/src/process.ts | 9 +- packages/server/test/process.test.ts | 16 +- packages/tui/src/app.tsx | 27 +- packages/tui/src/component/dialog-update.tsx | 147 ++++++ packages/www/openapi.json | 13 +- packages/www/public/openapi.json | 13 +- packages/www/src/docs/content/config.mdx | 11 +- packages/www/src/docs/content/migrate-v1.mdx | 3 +- 23 files changed, 770 insertions(+), 236 deletions(-) create mode 100644 packages/cli/test/updater-monitor.test.ts create mode 100644 packages/tui/src/component/dialog-update.tsx diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 35c5635a09f..96826f43378 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -47,7 +47,7 @@ export default Runtime.handler(Commands, (input) => ), ) const updater = yield* Updater.Service - yield* updater.check().pipe(Effect.forkScoped) + if (!server.service) yield* updater.check().pipe(Effect.forkScoped) preflight.loading() const config = yield* Config.Service const npm = yield* Npm.Service @@ -83,6 +83,11 @@ export default Runtime.handler(Commands, (input) => get: () => runPromise(config.get()), update: (update) => runPromise(config.update(update)), }, + updater: service + ? { + apply: (version) => runPromise(updater.apply(version)), + } + : undefined, packages: { resolve: (spec, install = true) => runPromise( diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 303046e1ec3..e3b99ed6f1e 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -7,7 +7,8 @@ import { Global } from "@opencode-ai/util/global" import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version" import { AppProcess } from "@opencode-ai/util/process" import { randomBytes, randomUUID } from "node:crypto" -import { Effect, Option, Redacted, Schedule, Schema } from "effect" +import { spawn } from "node:child_process" +import { Deferred, Effect, Option, Redacted, Schedule, Schema } from "effect" import { PersistentPty } from "@opencode-ai/schema/persistent-pty" import { HttpServer } from "effect/unstable/http" import { Env } from "./env" @@ -53,7 +54,8 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { ) const global = yield* Global.Service if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home)) - return yield* Effect.scoped( + const replacement = yield* Deferred.make() + const next = yield* Effect.scoped( Effect.gen(function* () { const foreground = options.mode === "default" const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined @@ -64,7 +66,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { serviceOptions !== undefined && port !== undefined ? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) }) : undefined - if (incumbent !== undefined) return + if (incumbent !== undefined) return Option.none() const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process")) const environmentPassword = yield* Env.password // Keep the lease credential out of the environment inherited by tools. @@ -161,19 +163,62 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { ) }), ) - if (server === undefined) return + if (server === undefined) return Option.none() const url = HttpServer.formatAddress(server.address) console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`) if (foreground && !environmentPassword) console.log(`server password ${password}`) const updater = yield* Updater.Service - yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped) + yield* updater + .monitor({ + url, + password, + managed: options.mode === "service", + notify: server.updateAvailable, + restart: (handoff) => Deferred.succeed(replacement, handoff).pipe(Effect.asVoid), + }) + .pipe(Effect.forkScoped) return yield* options.mode === "service" - ? server.shutdown + ? Effect.raceFirst( + server.shutdown.pipe(Effect.as(Option.none())), + Deferred.await(replacement).pipe(Effect.map(Option.some)), + ) : options.mode === "stdio" - ? waitForStdinClose() + ? waitForStdinClose().pipe(Effect.as(Option.none())) : Effect.never }).pipe(Effect.annotateLogs({ role: "server" })), ) + if (Option.isNone(next)) return + yield* spawnReplacement(next.value) +}) + +const spawnReplacement = Effect.fnUntraced(function* (handoff: PersistentPty.Handoff | null) { + const options = yield* ServiceConfig.options() + const [command, ...args] = options.command + if (!command) return yield* Effect.fail(new Error("Failed to resolve CLI command for restart")) + // We do not monitor the replacement after spawn. A managed TUI + // recovers with Service.ensure if startup fails; a future client + // restart signal could coordinate that recovery instead. + yield* Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + const child = spawn(command, args, { + detached: true, + stdio: "ignore", + windowsHide: true, + env: { + ...process.env, + ...options.env, + OPENCODE_PTY_HANDOFF: handoff ? JSON.stringify(handoff) : undefined, + }, + }) + child.once("spawn", () => { + child.unref() + resolve() + }) + child.once("error", reject) + }), + catch: (cause) => new Error("Failed to start replacement server", { cause }), + }) }) const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) { diff --git a/packages/cli/src/services/server-connection.ts b/packages/cli/src/services/server-connection.ts index 9237c033782..048966554dd 100644 --- a/packages/cli/src/services/server-connection.ts +++ b/packages/cli/src/services/server-connection.ts @@ -56,7 +56,7 @@ function managedService(options: EnsureOptions) { reconnect: () => Service.ensure(reconnectOptions), restart: () => Effect.gen(function* () { - yield* Service.stop(options) + yield* Service.stop({ file: options.file, pty: "handoff" }) yield* Service.ensure(reconnectOptions) }), } diff --git a/packages/cli/src/services/updater-action.ts b/packages/cli/src/services/updater-action.ts index 7578ccf9dcd..9b76507111f 100644 --- a/packages/cli/src/services/updater-action.ts +++ b/packages/cli/src/services/updater-action.ts @@ -1,4 +1,4 @@ -export type Policy = boolean | "notify" +export type Policy = "disable" | "notify" | "auto" export type Action = "none" | "notify" | "upgrade" const maximumComponent = "9007199254740991" @@ -6,13 +6,14 @@ 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 { - if (policy === false) return "none" + if (policy === "disable") return "none" const currentVersion = parseReleaseVersion(current) const latestVersion = parseReleaseVersion(latest) if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none" + if (policy === "notify") return "notify" // Major upgrades are never installed automatically. - if (currentVersion.major !== latestVersion.major) return "none" - return policy === "notify" ? "notify" : "upgrade" + if (currentVersion.major !== latestVersion.major) return "notify" + return "upgrade" } export function parseReleaseVersion(input: string) { diff --git a/packages/cli/src/services/updater.test.ts b/packages/cli/src/services/updater.test.ts index 55cfae017bc..e477675c259 100644 --- a/packages/cli/src/services/updater.test.ts +++ b/packages/cli/src/services/updater.test.ts @@ -3,46 +3,54 @@ import { action } from "./updater-action" import { 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("reads update policy from JSONC", () => { + expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify") + expect(decodePolicy('{ "update": "disable" }')).toBe("disable") + expect(decodePolicy('{ "update": "auto" }')).toBe("auto") + expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined() + }) + + test("maps the v1 update policy", () => { + expect(decodePolicy('{ "autoupdate": false }')).toBe("disable") + expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify") + expect(decodePolicy('{ "autoupdate": true }')).toBe("auto") }) test("automatically updates patches and minors", () => { - expect(action("1.2.3", "1.2.4", true)).toBe("upgrade") - expect(action("1.2.3", "1.3.0", true)).toBe("upgrade") + expect(action("1.2.3", "1.2.4", "auto")).toBe("upgrade") + expect(action("1.2.3", "1.3.0", "auto")).toBe("upgrade") }) test("reports patches and minors without automatically installing them", () => { expect(action("1.2.3", "1.2.4", "notify")).toBe("notify") expect(action("1.2.3", "1.3.0", "notify")).toBe("notify") + expect(action("1.2.3", "2.0.0", "notify")).toBe("notify") expect(action("1.2.3", "1.2.3", "notify")).toBe("none") }) - test("skips when autoupdate is disabled", () => { - expect(action("1.2.3", "1.2.4", false)).toBe("none") + test("skips when updates are disabled", () => { + expect(action("1.2.3", "1.2.4", "disable")).toBe("none") }) - test("never automatically updates majors", () => { - expect(action("1.2.3", "2.0.0", true)).toBe("none") + test("reports majors instead of automatically installing them", () => { + expect(action("1.2.3", "2.0.0", "auto")).toBe("notify") }) test("reports up-to-date only when versions match", () => { - expect(action("1.2.3", "1.2.3", true)).toBe("none") + expect(action("1.2.3", "1.2.3", "auto")).toBe("none") }) test("upgrades when latest is lower (rollback)", () => { - expect(action("1.2.4", "1.2.3", true)).toBe("upgrade") + expect(action("1.2.4", "1.2.3", "auto")).toBe("upgrade") }) test("accepts strict release version variants", () => { - expect(action("v1.2.3", " 1.2.4\n", true)).toBe("upgrade") - expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", true)).toBe("upgrade") - expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", true)).toBe("upgrade") - expect(action("0.0.0-next-17403", "0.0.0-beta-17404", true)).toBe("upgrade") - expect(action("1.2.3+old", "1.2.3+new", true)).toBe("none") - expect(action("v1.2.3+old", "1.2.3", true)).toBe("none") + expect(action("v1.2.3", " 1.2.4\n", "auto")).toBe("upgrade") + expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "auto")).toBe("upgrade") + expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "auto")).toBe("upgrade") + expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "auto")).toBe("upgrade") + expect(action("1.2.3+old", "1.2.3+new", "auto")).toBe("none") + expect(action("v1.2.3+old", "1.2.3", "auto")).toBe("none") }) test("preserves strict validity", () => { @@ -63,21 +71,21 @@ describe("updater", () => { "0.9007199254740992.0", "0.0.9007199254740992", ] - invalid.forEach((version) => expect(action("1.2.3", version, true), version).toBe("none")) + invalid.forEach((version) => expect(action("1.2.3", version, "auto"), version).toBe("none")) }) test("handles numeric limits without losing precision", () => { - expect(action("9007199254740991.0.0", "9007199254740991.0.1", true)).toBe("upgrade") - expect(action("9007199254740990.0.0", "9007199254740991.0.0", true)).toBe("none") + expect(action("9007199254740991.0.0", "9007199254740991.0.1", "auto")).toBe("upgrade") + expect(action("9007199254740990.0.0", "9007199254740991.0.0", "auto")).toBe("notify") }) test("preserves equality for oversized numeric prerelease identifiers", () => { - expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", true)).toBe("none") - expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", true)).toBe("upgrade") + expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "auto")).toBe("none") + expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "auto")).toBe("upgrade") }) test("rejects versions longer than semver's limit before trimming", () => { - expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, true)).toBe("none") - expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, true)).toBe("upgrade") + expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "auto")).toBe("none") + expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "auto")).toBe("upgrade") }) }) diff --git a/packages/cli/src/services/updater.ts b/packages/cli/src/services/updater.ts index 73e03a297c7..10263a9003e 100644 --- a/packages/cli/src/services/updater.ts +++ b/packages/cli/src/services/updater.ts @@ -1,11 +1,13 @@ import { Global } from "@opencode-ai/util/global" import { AppProcess } from "@opencode-ai/util/process" +import { OpenCode } from "@opencode-ai/client" +import { PersistentPty } from "@opencode-ai/schema/persistent-pty" import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version" -import { Context, Duration, Effect, FileSystem, Layer } from "effect" +import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule, Semaphore, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { parse, type ParseError } from "jsonc-parser" import path from "node:path" -import { action, parseReleaseVersion, type Policy } from "./updater-action" +import { action, parseReleaseVersion, type Action, type Policy } from "./updater-action" declare const OPENCODE_CLI_NAME: string | undefined @@ -17,11 +19,143 @@ const packageName = export interface Interface { readonly check: () => Effect.Effect + readonly monitor: (input: { + readonly url: string + readonly password: string + readonly managed: boolean + readonly notify: (version: string) => Effect.Effect + readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect + }) => Effect.Effect + readonly apply: (version: string) => Effect.Effect readonly method: () => Effect.Effect readonly latest: () => Effect.Effect readonly upgrade: (method: Method, version: string) => Effect.Effect } +export type Inspection = + | { readonly action: "none" } + | { readonly action: Exclude; readonly version: string } + +type State = + | { readonly type: "current" } + | { readonly type: "available"; readonly version: string; readonly availableSince: number } + | { readonly type: "ready-to-restart"; readonly version: string } + +export interface MonitorInput { + readonly url: string + readonly password: string + readonly managed: boolean + readonly inspect: () => Effect.Effect + readonly install: (version: string) => Effect.Effect + readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect + readonly interval?: Duration.Input + readonly notificationThreshold?: Duration.Input + readonly notify: (version: string) => Effect.Effect +} + +export const monitorServer = Effect.fnUntraced(function* (input: MonitorInput) { + const state = yield* Ref.make({ type: "current" }) + const applyLock = yield* Semaphore.make(1) + const client = OpenCode.make({ + baseUrl: input.url, + headers: { authorization: `Basic ${btoa(`opencode:${input.password}`)}` }, + }) + + const applyIfIdle = () => + applyLock.withPermit( + Effect.gen(function* () { + const pending = yield* Ref.get(state) + if (pending.type !== "available") return + const active = yield* Effect.tryPromise({ + try: () => client.session.active(), + catch: (cause) => new Error("Failed to read active sessions", { cause }), + }) + if (Object.keys(active).length > 0) return + const latest = yield* input.inspect() + if (latest.action !== "upgrade") { + yield* Ref.set(state, { type: "current" }) + return + } + const installed = yield* input + .install(latest.version) + .pipe( + Effect.catch((error) => + Effect.logWarning("automatic update failed", { cause: error }).pipe(Effect.as(false)), + ), + ) + if (!installed) return + const handoff = input.managed + ? yield* Effect.tryPromise({ + try: () => client.experimental.persistentPty.handoff(), + catch: (cause) => new Error("Failed to prepare persistent terminals for restart", { cause }), + }) + : undefined + yield* Ref.set(state, { type: "ready-to-restart", version: latest.version }) + if (handoff) yield* input.restart(handoff.handoff) + }), + ) + + const checkServer = Effect.gen(function* () { + const result = yield* input.inspect() + if (result.action === "notify") { + yield* input.notify(result.version) + return + } + if (result.action !== "upgrade") { + yield* Ref.update( + state, + (current): State => (current.type === "ready-to-restart" ? current : { type: "current" }), + ) + return + } + yield* Ref.update(state, (current): State => { + if (current.type === "ready-to-restart" && current.version === result.version) return current + return { + type: "available", + version: result.version, + availableSince: current.type === "available" ? current.availableSince : Date.now(), + } + }) + yield* applyIfIdle() + const pending = yield* Ref.get(state) + if ( + pending.type === "available" && + Date.now() - pending.availableSince >= Duration.toMillis(input.notificationThreshold ?? "3 days") + ) + yield* input.notify(pending.version) + }).pipe(Effect.catch((cause) => Effect.logWarning("automatic update check failed", { cause }))) + + const subscribe = Effect.suspend(() => + Stream.fromAsyncIterable( + client.event.subscribe(), + (cause) => new Error("Update event stream failed", { cause }), + ).pipe( + Stream.runForEach((event) => { + if (event.type === "server.connected") return applyIfIdle() + if ( + event.type !== "session.execution.succeeded" && + event.type !== "session.execution.failed" && + event.type !== "session.execution.interrupted" + ) + return Effect.void + return Effect.tryPromise({ + try: () => client.session.wait({ sessionID: event.data.sessionID }), + catch: (cause) => new Error(`Failed to wait for Session ${event.data.sessionID}`, { cause }), + }).pipe(Effect.andThen(applyIfIdle())) + }), + Effect.catch((cause) => Effect.logWarning("update event stream disconnected", { cause })), + ), + ).pipe(Effect.repeat(Schedule.spaced("1 second"))) + + return yield* Effect.all( + [checkServer.pipe(Effect.repeat(Schedule.spaced(input.interval ?? "10 minutes"))), subscribe], + { + concurrency: "unbounded", + discard: true, + }, + ) +}) + export class Service extends Context.Service()("@opencode/cli/Updater") {} export function decodePolicy(text: string): Policy | undefined { @@ -29,157 +163,196 @@ export function decodePolicy(text: string): Policy | undefined { // 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 + if (errors.length || typeof input !== "object" || input === null) return + if ("update" in input) { + const value = input.update + if (value === "disable" || value === "notify" || value === "auto") return value + return + } + if (!("autoupdate" in input)) return + if (input.autoupdate === false) return "disable" + if (input.autoupdate === "notify") return "notify" + if (input.autoupdate === true) return "auto" } -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 channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-") +const make = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const global = yield* Global.Service + const appProcess = yield* AppProcess.Service + const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-") - 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.orElseSucceed(() => undefined), - ), - ) - return values.findLast((value) => value !== undefined) ?? true - }) - - 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.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })), - ) - }) - - const method = Effect.fnUntraced(function* () { - const binary = path.join( - global.home, - ".opencode", - "bin", - process.platform === "win32" ? "opencode2.exe" : "opencode2", - ) - if (path.resolve(process.execPath) === path.resolve(binary)) return "curl" - - 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://update.opencode.ai/api/${encodeURIComponent(channel)}/cli/npm`, { - headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` }, - 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, 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], - 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. - 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") - } - if (method === "curl") { - yield* fs.makeDirectory(global.cache, { recursive: true }) - const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" }) - const installer = path.join(directory, "install") - const download = yield* run( - ["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"], - "5 minutes", - ) - if (download.code !== 0) return download - return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes") - } - 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}`)) - }) - - const check = Effect.fn("cli.updater.check")( - function* () { - if (OPENCODE_LOCAL || ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")) - return yield* Effect.logInfo("update check skipped", { - reason: OPENCODE_LOCAL ? "local-install" : "disabled", - version: OPENCODE_VERSION, - channel: OPENCODE_CHANNEL, - }) - const policy = yield* readPolicy() - if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" }) - - return yield* Effect.gen(function* () { - const version = yield* latest() - yield* Effect.logInfo("update check", { - current: OPENCODE_VERSION, - latest: version, - }) - const next = action(OPENCODE_VERSION, version, policy) - 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 }) - 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 }) - }) - }, - Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })), + 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.orElseSucceed(() => undefined), + ), ) + return values.findLast((value) => value !== undefined) ?? "auto" + }) - return Service.of({ check, method, latest, upgrade }) - }), -) + 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.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })), + ) + }) + + const method = Effect.fnUntraced(function* () { + const binary = path.join( + global.home, + ".opencode", + "bin", + process.platform === "win32" ? "opencode2.exe" : "opencode2", + ) + if (path.resolve(process.execPath) === path.resolve(binary)) return "curl" + + 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://update.opencode.ai/api/${encodeURIComponent(channel)}/cli/npm`, { + headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` }, + 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, 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], + 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. + 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") + } + if (method === "curl") { + yield* fs.makeDirectory(global.cache, { recursive: true }) + const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" }) + const installer = path.join(directory, "install") + const download = yield* run(["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"], "5 minutes") + if (download.code !== 0) return download + return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes") + } + 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}`)) + }) + + const inspect = Effect.fnUntraced(function* (): Effect.fn.Return { + if (OPENCODE_LOCAL || ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")) { + yield* Effect.logInfo("update check skipped", { + reason: OPENCODE_LOCAL ? "local-install" : "disabled", + version: OPENCODE_VERSION, + channel: OPENCODE_CHANNEL, + }) + return { action: "none" } + } + const policy = yield* readPolicy() + if (policy === "disable") { + yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" }) + return { action: "none" } + } + + const version = yield* latest() + yield* Effect.logInfo("update check", { + current: OPENCODE_VERSION, + latest: version, + }) + const next = action(OPENCODE_VERSION, version, policy) + if (next === "none") { + yield* Effect.logInfo("update check done", { action: "up-to-date" }) + return { action: "none" } + } + if (next === "notify") { + yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version }) + return { action: next, version } + } + return { action: next, version } + }) + + const install = Effect.fnUntraced(function* (version: string) { + const detected = yield* method() + if (!detected) { + yield* Effect.logWarning("automatic update skipped: installation method not found") + return false + } + yield* upgrade(detected, version) + yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected }) + return true + }) + + const apply = Effect.fn("cli.updater.apply")(function* (version: string) { + if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found")) + }) + + const check = Effect.fn("cli.updater.check")( + function* () { + const result = yield* inspect() + if (result.action !== "upgrade") return + yield* install(result.version) + }, + Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })), + ) + + const monitor = Effect.fn("cli.updater.monitor")(function* (input: { + readonly url: string + readonly password: string + readonly managed: boolean + readonly notify: (version: string) => Effect.Effect + readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect + }) { + return yield* monitorServer({ ...input, inspect, install }) + }) + + return Service.of({ check, monitor, apply, method, latest, upgrade }) +}) + +export const layer = Layer.effect(Service, make) export * as Updater from "./updater" export { action, type Action, type Policy } from "./updater-action" diff --git a/packages/cli/test/acp/subprocess.ts b/packages/cli/test/acp/subprocess.ts index 90ece2708c0..9a3413aa1f1 100644 --- a/packages/cli/test/acp/subprocess.ts +++ b/packages/cli/test/acp/subprocess.ts @@ -179,7 +179,7 @@ function verifierConfig(llmUrl: string, skills?: string) { limit: { context: 100_000, output: 10_000 }, } return { - autoupdate: false, + update: "disable", model: "test/test-model", ...(skills ? { skills: [skills] } : {}), providers: { diff --git a/packages/cli/test/fixture/upgrade.ts b/packages/cli/test/fixture/upgrade.ts index c3a8adeba93..70263f2d15e 100644 --- a/packages/cli/test/fixture/upgrade.ts +++ b/packages/cli/test/fixture/upgrade.ts @@ -13,6 +13,8 @@ await Effect.runPromise( ).pipe( Effect.provideService(Updater.Service, { check: () => Effect.die("Manual upgrades must not run the automatic update check"), + monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"), + apply: () => Effect.die("Manual upgrades must not apply automatic updates"), method: () => Effect.sync(() => { record("method") diff --git a/packages/cli/test/updater-monitor.test.ts b/packages/cli/test/updater-monitor.test.ts new file mode 100644 index 00000000000..866d59dceee --- /dev/null +++ b/packages/cli/test/updater-monitor.test.ts @@ -0,0 +1,107 @@ +import { expect } from "bun:test" +import { Deferred, Effect, Layer, Option } from "effect" +import { testEffect } from "../../core/test/lib/effect" +import { Updater } from "../src/services/updater" + +const it = testEffect(Layer.empty) + +it.live("installs and restarts after the final Session settles", () => + Effect.gen(function* () { + const fixture = yield* Effect.acquireRelease(Effect.sync(makeServer), (server) => Effect.sync(() => server.stop())) + const installed = yield* Deferred.make() + const restarted = yield* Deferred.make() + yield* Updater.monitorServer({ + url: fixture.url, + password: "test", + managed: true, + inspect: () => Effect.succeed({ action: "upgrade", version: "1.1.0" }), + install: (version) => Deferred.succeed(installed, version).pipe(Effect.as(true)), + restart: () => Deferred.succeed(restarted, undefined).pipe(Effect.asVoid), + notify: () => Effect.void, + }).pipe(Effect.forkScoped) + yield* wait(fixture.activeRead, () => "Updater did not check active Sessions") + yield* wait(fixture.eventOpened, () => "Updater did not open the server event stream") + expect(Option.isNone(yield* Deferred.poll(installed))).toBe(true) + + fixture.settle() + yield* wait(fixture.waited, () => "Updater did not receive the settlement event") + expect( + yield* Effect.raceFirst( + Deferred.await(installed), + Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not install the update")))), + ), + ).toBe("1.1.0") + yield* Effect.raceFirst( + Deferred.await(restarted), + Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not restart the server")))), + ) + }), +) + +const wait = (promise: Promise, message: () => string) => + Effect.tryPromise(() => Promise.race([promise, Bun.sleep(1_000).then(() => Promise.reject(new Error(message())))])) + +function makeServer() { + const encoder = new TextEncoder() + const activeRead = Promise.withResolvers() + const eventOpened = Promise.withResolvers() + const waited = Promise.withResolvers() + let active = true + let events: ReadableStreamDefaultController | undefined + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const url = new URL(request.url) + if (url.pathname === "/api/session/active") { + activeRead.resolve() + return Response.json({ data: active ? { ses_test: { type: "running" } } : {} }) + } + if (url.pathname === "/api/session/ses_test/wait" && request.method === "POST") { + waited.resolve() + return new Response(null, { status: 204 }) + } + if (url.pathname === "/api/experimental/persistent-pty/handoff" && request.method === "POST") { + return Response.json({ handoff: null }) + } + if (url.pathname === "/api/event") { + return new Response( + new ReadableStream({ + start(controller) { + events = controller + eventOpened.resolve() + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + } + return new Response("Not found", { status: 404 }) + }, + }) + + return { + url: server.url.origin, + activeRead: activeRead.promise, + eventOpened: eventOpened.promise, + waited: waited.promise, + settle() { + active = false + events?.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + id: "evt_settled", + created: Date.now(), + type: "session.execution.succeeded", + durable: { aggregateID: "ses_test", seq: 0, version: 1 }, + data: { sessionID: "ses_test" }, + })}\n\n`, + ), + ) + events?.close() + events = undefined + }, + stop() { + server.stop(true) + }, + } +} diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index c4bb2f45b6e..2e61de115a3 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1892,7 +1892,7 @@ export type ConfigEntry = shell?: string model?: string | { providerID: string; model: string; variant?: string } default_agent?: string - autoupdate?: boolean | "notify" + update?: "disable" | "notify" | "auto" share?: "manual" | "auto" | "disabled" enterprise?: { url?: string } username?: string diff --git a/packages/core/src/config/normalize.ts b/packages/core/src/config/normalize.ts index 15767d0edf5..2dd14cf64cc 100644 --- a/packages/core/src/config/normalize.ts +++ b/packages/core/src/config/normalize.ts @@ -24,6 +24,7 @@ import { ConfigMCPV1 } from "../v1/config/mcp.js" import { ConfigPermissionV1 } from "../v1/config/permission.js" import { ConfigPluginV1 } from "../v1/config/plugin.js" import { ConfigProviderV1 } from "../v1/config/provider.js" +import { ConfigV1 } from "../v1/config/config.js" import { ConfigMigrateV1 } from "../v1/config/migrate.js" import { PositiveInt } from "../schema.js" @@ -69,6 +70,9 @@ export function normalize(input: unknown): Result { const legacySnapshots = own(input, "snapshot") ? decodeEncoded(Schema.Boolean, input.snapshot, ["snapshot"], diagnostics) : undefined + const legacyUpdate = own(input, "autoupdate") + ? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics) + : undefined const legacyShare = own(input, "autoshare") ? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true ? "auto" @@ -82,6 +86,7 @@ export function normalize(input: unknown): Result { if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated) } if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots + if (legacyUpdate !== undefined) encoded.update = ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update if (legacyShare !== undefined) encoded.share = legacyShare const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded) @@ -191,7 +196,7 @@ export function normalize(input: unknown): Result { shell: Info.fields.shell, model: Info.fields.model, default_agent: Info.fields.default_agent, - autoupdate: Info.fields.autoupdate, + update: Info.fields.update, share: Info.fields.share, enterprise: Info.fields.enterprise, username: Info.fields.username, diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 364e9dac8eb..63715995dbd 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -26,7 +26,14 @@ export function migrate(info: typeof ConfigV1.Info.Type) { shell: info.shell, model: modelSelection(info.model), default_agent: info.default_agent, - autoupdate: info.autoupdate, + update: + info.autoupdate === false + ? "disable" + : info.autoupdate === "notify" + ? "notify" + : info.autoupdate === true + ? "auto" + : undefined, share: info.share ?? (info.autoshare ? "auto" : undefined), enterprise: info.enterprise, username: info.username, diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 7a3ee296db8..0edc12d8bc0 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -586,6 +586,13 @@ describe("Config", () => { }) }) + test("migrates the v1 update policy", () => { + expect(ConfigMigrateV1.migrate({ autoupdate: false }).update).toBe("disable") + expect(ConfigMigrateV1.migrate({ autoupdate: "notify" }).update).toBe("notify") + expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("auto") + expect(ConfigMigrateV1.migrate({}).update).toBeUndefined() + }) + test("migrates v1 provider lists to policies", () => { expect( ConfigMigrateV1.migrate({ @@ -1026,7 +1033,7 @@ describe("Config", () => { shell: "/bin/bash", model: "anthropic/claude", default_agent: "reviewer", - autoupdate: "notify", + update: "notify", share: "disabled", enterprise: { url: "https://share.example.com" }, username: "test-user", @@ -1109,7 +1116,7 @@ describe("Config", () => { expect(documents[0]?.info.shell).toBe("/bin/bash") expect(documents[0]?.info.model).toEqual(selection("anthropic/claude")) expect(documents[0]?.info.default_agent).toBe("reviewer") - expect(documents[0]?.info.autoupdate).toBe("notify") + expect(documents[0]?.info.update).toBe("notify") expect(documents[0]?.info.share).toBe("disabled") expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" }) expect(documents[0]?.info.username).toBe("test-user") @@ -1236,6 +1243,7 @@ describe("Config", () => { JSON.stringify({ shell: "/bin/zsh", default_agent: "reviewer", + autoupdate: false, snapshot: false, autoshare: true, permission: { @@ -1312,6 +1320,7 @@ describe("Config", () => { expect(documents[0]?.info).toBeInstanceOf(Info) expect(documents[0]?.info.shell).toBe("/bin/zsh") expect(documents[0]?.info.default_agent).toBe("reviewer") + expect(documents[0]?.info.update).toBe("disable") expect(documents[0]?.info.snapshots).toBe(false) expect(documents[0]?.info.share).toBe("auto") expect(documents[0]?.info.permissions).toEqual([ diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index e2834db93db..97b3282be2d 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -13897,16 +13897,9 @@ "default_agent": { "type": "string" }, - "autoupdate": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": ["notify"] - } - ] + "update": { + "type": "string", + "enum": ["disable", "notify", "auto"] }, "share": { "type": "string", diff --git a/packages/schema/src/config.ts b/packages/schema/src/config.ts index 16d94d6fb3b..653819d6b51 100644 --- a/packages/schema/src/config.ts +++ b/packages/schema/src/config.ts @@ -34,11 +34,9 @@ export class Info extends Schema.Class("Config.Info")({ default_agent: Schema.String.pipe(optional).annotate({ description: "Default primary agent to use when no session agent is selected", }), - autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")]) - .pipe(optional) - .annotate({ - description: "Automatically update or notify when a new version is available", - }), + update: Schema.Literals(["disable", "notify", "auto"]).pipe(optional).annotate({ + description: "Disable updates, notify when one is available, or install automatically", + }), share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({ description: "Control whether sessions may be shared manually, automatically, or not at all", }), diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index 99f3b37f1b4..f515b1a20c2 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -1,9 +1,11 @@ export * as ServerProcess from "./process" import { NodeHttpServer } from "@effect/platform-node" +import { Bus } from "@opencode-ai/core/bus" import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty" +import { InstallationEvent } from "@opencode-ai/schema/installation-event" import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect" import { HttpMiddleware, @@ -114,7 +116,12 @@ export const start = Effect.fn("ServerProcess.start")(function* ( ) yield* Ref.set(application, Option.some(transform ? transform(app) : app)) yield* status.ready - return { address: bound.http.address, shutdown: shutdown.await } + return { + address: bound.http.address, + shutdown: shutdown.await, + updateAvailable: (version: string) => + Context.get(context, Bus.Service).publish(InstallationEvent.UpdateAvailable, { version }).pipe(Effect.asVoid), + } }).pipe( Effect.catchCause((cause) => { if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause) diff --git a/packages/server/test/process.test.ts b/packages/server/test/process.test.ts index 0b36bcfef6f..228f4c52bdc 100644 --- a/packages/server/test/process.test.ts +++ b/packages/server/test/process.test.ts @@ -1,4 +1,5 @@ import { expect } from "bun:test" +import { InstallationEvent } from "@opencode-ai/schema/installation-event" import { Effect } from "effect" import { HttpServer, HttpServerError, HttpServerResponse } from "effect/unstable/http" import { it } from "../../core/test/lib/effect" @@ -99,7 +100,12 @@ it.live("allows browser preflight requests without credentials", () => ) expect(event.status).toBe(200) expect(event.headers.get("content-encoding")).toBeNull() - yield* Effect.promise(() => event.body?.cancel() ?? Promise.resolve()) + if (!event.body) return yield* Effect.die(new Error("Event response has no body")) + const reader = event.body.getReader() + yield* Effect.promise(() => readUntil(reader, "server.connected")) + yield* server.updateAvailable("2.0.0") + yield* Effect.promise(() => readUntil(reader, "installation.update-available")) + yield* Effect.promise(() => reader.cancel()) const missing = yield* Effect.promise(() => fetch(new URL("/missing", HttpServer.formatAddress(server.address)), { @@ -124,3 +130,11 @@ it.live("allows browser preflight requests without credentials", () => ) }), ) + +async function readUntil(reader: ReadableStreamDefaultReader, expected: string) { + while (true) { + const next = await reader.read() + if (next.done) throw new Error(`Event stream ended before ${expected}`) + if (new TextDecoder().decode(next.value).includes(expected)) return + } +} diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 752830b5a77..917d8aeb194 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -64,6 +64,7 @@ import { DialogStatus } from "./component/dialog-status" import { DialogConfig } from "./component/dialog-config" import { DialogDebug } from "./component/dialog-debug" import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair" +import { DialogUpdate } from "./component/dialog-update" import { DialogThemeList } from "./component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "./component/dialog-agent" @@ -184,6 +185,9 @@ export type TuiInput = { } args: Args config: Config.Interface + updater?: { + apply: (version: string) => Promise + } packages: PackageResolver environment?: Readonly> terminalHandoff?: () => Promise< @@ -216,6 +220,9 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const service = managed ? { reconnect: async (signal: AbortSignal) => { + // Give the server a chance to respawn itself before starting client-side recovery. + await new Promise((resolve) => setTimeout(resolve, 50)) + if (signal.aborted) throw signal.reason ?? new Error("Server reconnect cancelled") const endpoint = await managed.reconnect(signal) const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) } return { api: OpenCode.make(next), url: endpoint.url } @@ -397,6 +404,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { directories={pluginDirectories} > ("layout", { initial: { verticalTabsWidth: SESSION_SIDEBAR_WIDTH }, }) + const [updateNotifications, markUpdateNotification] = useStorage().store<{ versions: string[] }>( + "update-notifications", + { initial: { versions: [] } }, + ) const tabsResize = createPaneResize({ value: () => layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH, defaultValue: () => SESSION_SIDEBAR_WIDTH, @@ -1203,6 +1215,19 @@ function App(props: { pair?: DialogPairCredentials }) { }) }) + event.on("installation.update-available", (evt) => { + const updater = props.updater + const restart = client.restart + if (!updater || !restart) return + const version = evt.data.version + if (updateNotifications.versions.includes(version)) return + void markUpdateNotification((draft) => { + draft.versions = [...draft.versions, version].slice(-100) + }).catch((error) => log.error("failed to persist update notification", { error })) + dialog.replace(() => updater.apply(version)} restart={restart} />) + dialog.setCentered(true) + }) + event.on("tui.session.select", (evt, { workspace }) => { if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return route.navigate({ diff --git a/packages/tui/src/component/dialog-update.tsx b/packages/tui/src/component/dialog-update.tsx new file mode 100644 index 00000000000..c07d53f7eb9 --- /dev/null +++ b/packages/tui/src/component/dialog-update.tsx @@ -0,0 +1,147 @@ +/** @jsxImportSource @opentui/solid */ +import { TextAttributes } from "@opentui/core" +import { createSignal, For, Match, Show, Switch } from "solid-js" +import { Keymap } from "../context/keymap" +import { useTheme } from "../context/theme" +import { errorMessage } from "../util/error" +import { useDialog } from "../ui/dialog" +import { Spinner } from "./spinner" + +type State = + | { type: "ready"; active: "update" | "ignore" } + | { type: "installing" } + | { type: "restarting" } + | { type: "failed"; message: string } + +export function DialogUpdate(props: { version: string; install: () => Promise; restart: () => Promise }) { + const dialog = useDialog() + const theme = useTheme("elevated") + const [state, setState] = createSignal({ type: "ready", active: "update" }) + + const install = async () => { + setState({ type: "installing" }) + await props.install() + setState({ type: "restarting" }) + await props.restart() + dialog.clear() + } + + const beginInstall = () => { + if (state().type !== "ready") return + void install().catch((error) => setState({ type: "failed", message: errorMessage(error) })) + } + + const run = () => { + const current = state() + if (current.type !== "ready") return + if (current.active === "ignore") return dialog.clear() + beginInstall() + } + + const toggle = () => + setState((current) => + current.type === "ready" ? { ...current, active: current.active === "update" ? "ignore" : "update" } : current, + ) + + const selected = (action: "update" | "ignore") => { + const current = state() + return current.type === "ready" && current.active === action + } + + const failure = () => { + const current = state() + return current.type === "failed" ? current.message : "" + } + + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ + { + bind: "return", + title: "Confirm update action", + group: "Dialog", + run: () => (state().type === "failed" ? dialog.clear() : run()), + }, + { + bind: "left", + title: "Previous update action", + group: "Dialog", + run: toggle, + }, + { + bind: "right", + title: "Next update action", + group: "Dialog", + run: toggle, + }, + ], + })) + + return ( + + + + Update + + dialog.clear()}> + esc + + + + + + + Update to v{props.version}? It will be applied in the background and active sessions will be restarted. + + + + Installing OpenCode {props.version}… + + + Restarting the background service… + + + {failure()} + + + + + + dialog.clear()} + > + close + + + + } + > + + + {(action) => ( + { + if (action === "ignore") return dialog.clear() + beginInstall() + }} + > + + {action === "update" ? "Update" : "Ignore"} + + + )} + + + + + ) +} diff --git a/packages/www/openapi.json b/packages/www/openapi.json index e2834db93db..97b3282be2d 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -13897,16 +13897,9 @@ "default_agent": { "type": "string" }, - "autoupdate": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": ["notify"] - } - ] + "update": { + "type": "string", + "enum": ["disable", "notify", "auto"] }, "share": { "type": "string", diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index e2834db93db..97b3282be2d 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -13897,16 +13897,9 @@ "default_agent": { "type": "string" }, - "autoupdate": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": ["notify"] - } - ] + "update": { + "type": "string", + "enum": ["disable", "notify", "auto"] }, "share": { "type": "string", diff --git a/packages/www/src/docs/content/config.mdx b/packages/www/src/docs/content/config.mdx index 6ac20222a14..c9e10a28be8 100644 --- a/packages/www/src/docs/content/config.mdx +++ b/packages/www/src/docs/content/config.mdx @@ -127,16 +127,17 @@ Choose the primary agent used when a session does not select one explicitly. See the [agents guide](/agents) for built-in and custom agents. -### Autoupdate +### Updates -Control automatic updates from the global config. Set this to `false` to -disable updates, or `"notify"` to report available updates without installing -them. Set this to `true` to automatically install compatible non-major updates. +Control updates from the global config. Set `update` to `"disable"` to skip +updates, `"notify"` to report available updates without installing them, or +`"auto"` to automatically install compatible non-major updates. +Major updates are reported but never installed automatically. Project-level values are ignored. ```jsonc { - "autoupdate": false, + "update": "auto", } ``` diff --git a/packages/www/src/docs/content/migrate-v1.mdx b/packages/www/src/docs/content/migrate-v1.mdx index 7c98a732774..0a2ccf3ae03 100644 --- a/packages/www/src/docs/content/migrate-v1.mdx +++ b/packages/www/src/docs/content/migrate-v1.mdx @@ -399,13 +399,14 @@ See [Models](/models) for the complete native model shape. ### Supported fields without direct native equivalents -Most fields that keep the same shape, including `shell`, `model`, `default_agent`, `autoupdate`, `watcher`, `formatter`, +Most fields that keep the same shape, including `shell`, `model`, `default_agent`, `watcher`, `formatter`, `lsp`, `instructions`, `enterprise`, and `tool_output`, require no migration. The V1 provider filters do not have one-to-one native V2 config fields, but their behavior remains supported: - `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers. - `disabled_providers` becomes internal deny policies for the listed providers. +- `autoupdate` becomes `update`: `false` maps to `"disable"`, `"notify"` remains `"notify"`, and `true` maps to `"auto"`. - `small_model` becomes the `model` selection for the built-in `title` agent. Native V2 configuration should use `agents.title.model` instead.