Skip to content

Commit e2d03ce

Browse files
authored
feat: interactive update flow for non-patch releases (#18662)
1 parent 32f9dc6 commit e2d03ce

9 files changed

Lines changed: 317 additions & 132 deletions

File tree

packages/opencode/git

Whitespace-only changes.

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { MouseButton, TextAttributes } from "@opentui/core"
55
import { RouteProvider, useRoute } from "@tui/context/route"
66
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
77
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
8-
import { Installation } from "@/installation"
98
import { Flag } from "@/flag/flag"
9+
import semver from "semver"
1010
import { DialogProvider, useDialog } from "@tui/ui/dialog"
1111
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
1212
import { SDKProvider, useSDK } from "@tui/context/sdk"
@@ -29,6 +29,7 @@ import { PromptHistoryProvider } from "./component/prompt/history"
2929
import { FrecencyProvider } from "./component/prompt/frecency"
3030
import { PromptStashProvider } from "./component/prompt/stash"
3131
import { DialogAlert } from "./ui/dialog-alert"
32+
import { DialogConfirm } from "./ui/dialog-confirm"
3233
import { ToastProvider, useToast } from "./ui/toast"
3334
import { ExitProvider, useExit } from "./context/exit"
3435
import { Session as SessionApi } from "@/session"
@@ -103,6 +104,7 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
103104
}
104105

105106
import type { EventSource } from "./context/sdk"
107+
import { Installation } from "@/installation"
106108

107109
export function tui(input: {
108110
url: string
@@ -729,13 +731,51 @@ function App() {
729731
})
730732
})
731733

732-
sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
734+
sdk.event.on("installation.update-available", async (evt) => {
735+
const version = evt.properties.version
736+
737+
const skipped = kv.get("skipped_version")
738+
if (skipped && !semver.gt(version, skipped)) return
739+
740+
const choice = await DialogConfirm.show(
741+
dialog,
742+
`Update Available`,
743+
`A new release v${version} is available. Would you like to update now?`,
744+
"skip",
745+
)
746+
747+
if (choice === false) {
748+
kv.set("skipped_version", version)
749+
return
750+
}
751+
752+
if (choice !== true) return
753+
733754
toast.show({
734755
variant: "info",
735-
title: "Update Available",
736-
message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`,
737-
duration: 10000,
756+
message: `Updating to v${version}...`,
757+
duration: 30000,
738758
})
759+
760+
const result = await sdk.client.global.upgrade({ target: version })
761+
762+
if (result.error || !result.data?.success) {
763+
toast.show({
764+
variant: "error",
765+
title: "Update Failed",
766+
message: "Update failed",
767+
duration: 10000,
768+
})
769+
return
770+
}
771+
772+
await DialogAlert.show(
773+
dialog,
774+
"Update Complete",
775+
`Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
776+
)
777+
778+
exit()
739779
})
740780

741781
return (

packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ export type DialogConfirmProps = {
1111
message: string
1212
onConfirm?: () => void
1313
onCancel?: () => void
14+
label?: string
1415
}
1516

17+
export type DialogConfirmResult = boolean | undefined
18+
1619
export function DialogConfirm(props: DialogConfirmProps) {
1720
const dialog = useDialog()
1821
const { theme } = useTheme()
@@ -45,7 +48,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
4548
<text fg={theme.textMuted}>{props.message}</text>
4649
</box>
4750
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
48-
<For each={["cancel", "confirm"]}>
51+
<For each={["cancel", "confirm"] as const}>
4952
{(key) => (
5053
<box
5154
paddingLeft={1}
@@ -58,7 +61,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
5861
}}
5962
>
6063
<text fg={key === store.active ? theme.selectedListItemText : theme.textMuted}>
61-
{Locale.titlecase(key)}
64+
{Locale.titlecase(key === "cancel" ? (props.label ?? key) : key)}
6265
</text>
6366
</box>
6467
)}
@@ -68,18 +71,19 @@ export function DialogConfirm(props: DialogConfirmProps) {
6871
)
6972
}
7073

71-
DialogConfirm.show = (dialog: DialogContext, title: string, message: string) => {
72-
return new Promise<boolean>((resolve) => {
74+
DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: string) => {
75+
return new Promise<DialogConfirmResult>((resolve) => {
7376
dialog.replace(
7477
() => (
7578
<DialogConfirm
7679
title={title}
7780
message={message}
7881
onConfirm={() => resolve(true)}
7982
onCancel={() => resolve(false)}
83+
label={label}
8084
/>
8185
),
82-
() => resolve(false),
86+
() => resolve(undefined),
8387
)
8488
})
8589
}

packages/opencode/src/cli/upgrade.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,18 @@ export async function upgrade() {
88
const method = await Installation.method()
99
const latest = await Installation.latest(method).catch(() => {})
1010
if (!latest) return
11-
if (Installation.VERSION === latest) return
1211

13-
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) {
12+
if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
13+
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
1414
return
1515
}
16-
if (config.autoupdate === "notify") {
16+
17+
if (Installation.VERSION === latest) return
18+
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
19+
20+
const kind = Installation.getReleaseType(Installation.VERSION, latest)
21+
22+
if (config.autoupdate === "notify" || kind !== "patch") {
1723
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
1824
return
1925
}

packages/opencode/src/flag/flag.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export namespace Flag {
1818
export declare const OPENCODE_CONFIG_DIR: string | undefined
1919
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
2020
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
21+
export const OPENCODE_ALWAYS_NOTIFY_UPDATE = truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE")
2122
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
2223
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
2324
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]

packages/opencode/src/installation/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,15 @@ declare global {
1515
const OPENCODE_CHANNEL: string
1616
}
1717

18+
import semver from "semver"
19+
1820
export namespace Installation {
1921
const log = Log.create({ service: "installation" })
2022

2123
export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
2224

25+
export type ReleaseType = "patch" | "minor" | "major"
26+
2327
export const Event = {
2428
Updated: BusEvent.define(
2529
"installation.updated",
@@ -35,6 +39,17 @@ export namespace Installation {
3539
),
3640
}
3741

42+
export function getReleaseType(current: string, latest: string): ReleaseType {
43+
const currMajor = semver.major(current)
44+
const currMinor = semver.minor(current)
45+
const newMajor = semver.major(latest)
46+
const newMinor = semver.minor(latest)
47+
48+
if (newMajor > currMajor) return "major"
49+
if (newMinor > currMinor) return "minor"
50+
return "patch"
51+
}
52+
3853
export const Info = z
3954
.object({
4055
version: z.string(),

packages/opencode/src/server/routes/global.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Hono } from "hono"
2-
import { describeRoute, resolver, validator } from "hono-openapi"
2+
import { describeRoute, validator, resolver } from "hono-openapi"
33
import { streamSSE } from "hono/streaming"
44
import z from "zod"
5+
import { Bus } from "../../bus"
56
import { BusEvent } from "@/bus/bus-event"
67
import { GlobalBus } from "@/bus/global"
78
import { AsyncQueue } from "@/util/queue"
@@ -195,5 +196,62 @@ export const GlobalRoutes = lazy(() =>
195196
})
196197
return c.json(true)
197198
},
199+
)
200+
.post(
201+
"/upgrade",
202+
describeRoute({
203+
summary: "Upgrade opencode",
204+
description: "Upgrade opencode to the specified version or latest if not specified.",
205+
operationId: "global.upgrade",
206+
responses: {
207+
200: {
208+
description: "Upgrade result",
209+
content: {
210+
"application/json": {
211+
schema: resolver(
212+
z.union([
213+
z.object({
214+
success: z.literal(true),
215+
version: z.string(),
216+
}),
217+
z.object({
218+
success: z.literal(false),
219+
error: z.string(),
220+
}),
221+
]),
222+
),
223+
},
224+
},
225+
},
226+
...errors(400),
227+
},
228+
}),
229+
validator(
230+
"json",
231+
z.object({
232+
target: z.string().optional(),
233+
}),
234+
),
235+
async (c) => {
236+
const method = await Installation.method()
237+
if (method === "unknown") {
238+
return c.json({ success: false, error: "Unknown installation method" }, 400)
239+
}
240+
const target = c.req.valid("json").target || (await Installation.latest(method))
241+
const result = await Installation.upgrade(method, target)
242+
.then(() => ({ success: true as const, version: target }))
243+
.catch((e) => ({ success: false as const, error: e instanceof Error ? e.message : String(e) }))
244+
if (result.success) {
245+
GlobalBus.emit("event", {
246+
directory: "global",
247+
payload: {
248+
type: Installation.Event.Updated.type,
249+
properties: { version: target },
250+
},
251+
})
252+
return c.json(result)
253+
}
254+
return c.json(result, 500)
255+
},
198256
),
199257
)

0 commit comments

Comments
 (0)