feat(cli): add self-update service · argszero/opencode@5df049d · GitHub
Skip to content

Commit 5df049d

Browse files
committed
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).
1 parent 573ab9c commit 5df049d

8 files changed

Lines changed: 256 additions & 3 deletions

File tree

bun.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/package.json

Lines changed: 3 additions & 0 deletions

packages/cli/src/commands/handlers/default.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ import { Runtime } from "../../framework/runtime"
33
import { Effect, Option } from "effect"
44
import { Daemon } from "../../services/daemon"
55
import { Standalone } from "../../services/standalone"
6+
import { Updater } from "../../services/updater"
67

78
export default Runtime.handler(Commands, (input) =>
89
Effect.gen(function* () {
910
const directory = Option.getOrUndefined(input.directory)
1011
if (directory !== undefined) process.chdir(directory)
12+
const updater = yield* Updater.Service
13+
yield* updater.check({ interactive: true })
1114
const daemon = yield* Daemon.Service
1215
const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
1316
const { runTui } = yield* Effect.promise(() => import("../../tui"))

packages/cli/src/commands/handlers/serve.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
1111
import { Commands } from "../commands"
1212
import { Runtime } from "../../framework/runtime"
1313
import { Daemon } from "../../services/daemon"
14+
import { Updater } from "../../services/updater"
1415

1516
export default Runtime.handler(
1617
Commands.commands.serve,
@@ -32,6 +33,8 @@ export default Runtime.handler(
3233
if (input.register) yield* daemon.register(address)
3334
const url = HttpServer.formatAddress(address)
3435
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
36+
const updater = yield* Updater.Service
37+
yield* updater.check({ interactive: false }).pipe(Effect.forkScoped)
3538
return yield* (input.stdio ? waitForStdinClose() : Effect.never)
3639
}).pipe(Effect.annotateLogs({ role: "server" })),
3740
)

packages/cli/src/framework/runtime.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"
22
import * as Command from "effect/unstable/cli/Command"
33
import { Spec } from "./spec"
44
import { Daemon } from "../services/daemon"
5+
import { Updater } from "../services/updater"
56
import { Scope } from "effect"
67

78
export type Input<Value> =
@@ -11,11 +12,11 @@ export type Input<Value> =
1112
? Input
1213
: never
1314

14-
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Scope.Scope>
15+
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Updater.Service | Scope.Scope>
1516
type Loader<Node extends Spec.Any> = () => Promise<{
16-
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Scope.Scope>
17+
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Updater.Service | Scope.Scope>
1718
}>
18-
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Scope.Scope>
19+
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Updater.Service | Scope.Scope>
1920

2021
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
2122
? Loader<Node>

packages/cli/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Commands } from "./commands/commands"
99
import { Runtime } from "./framework/runtime"
1010
import { Daemon } from "./services/daemon"
1111
import { Logging } from "@opencode-ai/core/observability/logging"
12+
import { Updater } from "./services/updater"
1213

1314
const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
1415
Layer.provide(NodeFileSystem.layer),
@@ -36,6 +37,7 @@ const Handlers = Runtime.handlers(Commands, {
3637
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
3738
Effect.annotateLogs({ role: "cli" }),
3839
Effect.provide(Daemon.defaultLayer),
40+
Effect.provide(Updater.defaultLayer),
3941
Effect.provide(LoggingLayer),
4042
Effect.provide(NodeServices.layer),
4143
Effect.scoped,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { action, decodePolicy } from "./updater"
3+
4+
describe("updater", () => {
5+
test("reads autoupdate from JSONC", () => {
6+
expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify")
7+
expect(decodePolicy('{ "autoupdate": false }')).toBe(false)
8+
expect(decodePolicy('{ "autoupdate": "invalid" }')).toBeUndefined()
9+
})
10+
11+
test("automatically updates patches", () => {
12+
expect(action("1.2.3", "1.2.4", true, false)).toBe("upgrade")
13+
expect(action("1.2.3", "1.2.4", "notify", true)).toBe("confirm")
14+
expect(action("1.2.3", "1.2.4", false, true)).toBe("none")
15+
})
16+
17+
test("requires an interactive confirmation for minors", () => {
18+
expect(action("1.2.3", "1.3.0", true, true)).toBe("confirm")
19+
expect(action("1.2.3", "1.3.0", true, false)).toBe("none")
20+
})
21+
22+
test("never automatically updates majors", () => {
23+
expect(action("1.2.3", "2.0.0", true, true)).toBe("none")
24+
})
25+
})
Lines changed: 211 additions & 0 deletions

0 commit comments

Comments
 (0)