diff --git a/packages/opencode/git b/packages/opencode/git
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 8bb17ff13..dc052c4d2 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -5,8 +5,8 @@ import { MouseButton, TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
-import { Installation } from "@/installation"
import { Flag } from "@/flag/flag"
+import semver from "semver"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { SDKProvider, useSDK } from "@tui/context/sdk"
@@ -29,6 +29,7 @@ import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
import { DialogAlert } from "./ui/dialog-alert"
+import { DialogConfirm } from "./ui/dialog-confirm"
import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider, useExit } from "./context/exit"
import { Session as SessionApi } from "@/session"
@@ -103,6 +104,7 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
}
import type { EventSource } from "./context/sdk"
+import { Installation } from "@/installation"
export function tui(input: {
url: string
@@ -729,13 +731,51 @@ function App() {
})
})
- sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
+ sdk.event.on("installation.update-available", async (evt) => {
+ const version = evt.properties.version
+
+ const skipped = kv.get("skipped_version")
+ if (skipped && !semver.gt(version, skipped)) return
+
+ const choice = await DialogConfirm.show(
+ dialog,
+ `Update Available`,
+ `A new release v${version} is available. Would you like to update now?`,
+ "skip",
+ )
+
+ if (choice === false) {
+ kv.set("skipped_version", version)
+ return
+ }
+
+ if (choice !== true) return
+
toast.show({
variant: "info",
- title: "Update Available",
- message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`,
- duration: 10000,
+ message: `Updating to v${version}...`,
+ duration: 30000,
})
+
+ const result = await sdk.client.global.upgrade({ target: version })
+
+ if (result.error || !result.data?.success) {
+ toast.show({
+ variant: "error",
+ title: "Update Failed",
+ message: "Update failed",
+ duration: 10000,
+ })
+ return
+ }
+
+ await DialogAlert.show(
+ dialog,
+ "Update Complete",
+ `Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
+ )
+
+ exit()
})
return (
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx
index b86bd4325..ef75764a2 100644
--- a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx
@@ -11,8 +11,11 @@ export type DialogConfirmProps = {
message: string
onConfirm?: () => void
onCancel?: () => void
+ label?: string
}
+export type DialogConfirmResult = boolean | undefined
+
export function DialogConfirm(props: DialogConfirmProps) {
const dialog = useDialog()
const { theme } = useTheme()
@@ -45,7 +48,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
{props.message}
-
+
{(key) => (
- {Locale.titlecase(key)}
+ {Locale.titlecase(key === "cancel" ? (props.label ?? key) : key)}
)}
@@ -68,8 +71,8 @@ export function DialogConfirm(props: DialogConfirmProps) {
)
}
-DialogConfirm.show = (dialog: DialogContext, title: string, message: string) => {
- return new Promise((resolve) => {
+DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: string) => {
+ return new Promise((resolve) => {
dialog.replace(
() => (
message={message}
onConfirm={() => resolve(true)}
onCancel={() => resolve(false)}
+ label={label}
/>
),
- () => resolve(false),
+ () => resolve(undefined),
)
})
}
diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts
index 2d46ae39f..e40750a2e 100644
--- a/packages/opencode/src/cli/upgrade.ts
+++ b/packages/opencode/src/cli/upgrade.ts
@@ -8,12 +8,18 @@ export async function upgrade() {
const method = await Installation.method()
const latest = await Installation.latest(method).catch(() => {})
if (!latest) return
- if (Installation.VERSION === latest) return
- if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) {
+ if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
+ await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
return
}
- if (config.autoupdate === "notify") {
+
+ if (Installation.VERSION === latest) return
+ if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
+
+ const kind = Installation.getReleaseType(Installation.VERSION, latest)
+
+ if (config.autoupdate === "notify" || kind !== "patch") {
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
return
}
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index 05f04c85c..0c55187b9 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/packages/opencode/src/flag/flag.ts
@@ -18,6 +18,7 @@ export namespace Flag {
export declare const OPENCODE_CONFIG_DIR: string | undefined
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
+ export const OPENCODE_ALWAYS_NOTIFY_UPDATE = truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE")
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts
index 1e4e45f2c..3551c861e 100644
--- a/packages/opencode/src/installation/index.ts
+++ b/packages/opencode/src/installation/index.ts
@@ -15,11 +15,15 @@ declare global {
const OPENCODE_CHANNEL: string
}
+import semver from "semver"
+
export namespace Installation {
const log = Log.create({ service: "installation" })
export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
+ export type ReleaseType = "patch" | "minor" | "major"
+
export const Event = {
Updated: BusEvent.define(
"installation.updated",
@@ -35,6 +39,17 @@ export namespace Installation {
),
}
+ export function getReleaseType(current: string, latest: string): ReleaseType {
+ const currMajor = semver.major(current)
+ const currMinor = semver.minor(current)
+ const newMajor = semver.major(latest)
+ const newMinor = semver.minor(latest)
+
+ if (newMajor > currMajor) return "major"
+ if (newMinor > currMinor) return "minor"
+ return "patch"
+ }
+
export const Info = z
.object({
version: z.string(),
diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts
index 4a6a3ebc7..4dd30db2a 100644
--- a/packages/opencode/src/server/routes/global.ts
+++ b/packages/opencode/src/server/routes/global.ts
@@ -1,7 +1,8 @@
import { Hono } from "hono"
-import { describeRoute, resolver, validator } from "hono-openapi"
+import { describeRoute, validator, resolver } from "hono-openapi"
import { streamSSE } from "hono/streaming"
import z from "zod"
+import { Bus } from "../../bus"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { AsyncQueue } from "@/util/queue"
@@ -195,5 +196,62 @@ export const GlobalRoutes = lazy(() =>
})
return c.json(true)
},
+ )
+ .post(
+ "/upgrade",
+ describeRoute({
+ summary: "Upgrade opencode",
+ description: "Upgrade opencode to the specified version or latest if not specified.",
+ operationId: "global.upgrade",
+ responses: {
+ 200: {
+ description: "Upgrade result",
+ content: {
+ "application/json": {
+ schema: resolver(
+ z.union([
+ z.object({
+ success: z.literal(true),
+ version: z.string(),
+ }),
+ z.object({
+ success: z.literal(false),
+ error: z.string(),
+ }),
+ ]),
+ ),
+ },
+ },
+ },
+ ...errors(400),
+ },
+ }),
+ validator(
+ "json",
+ z.object({
+ target: z.string().optional(),
+ }),
+ ),
+ async (c) => {
+ const method = await Installation.method()
+ if (method === "unknown") {
+ return c.json({ success: false, error: "Unknown installation method" }, 400)
+ }
+ const target = c.req.valid("json").target || (await Installation.latest(method))
+ const result = await Installation.upgrade(method, target)
+ .then(() => ({ success: true as const, version: target }))
+ .catch((e) => ({ success: false as const, error: e instanceof Error ? e.message : String(e) }))
+ if (result.success) {
+ GlobalBus.emit("event", {
+ directory: "global",
+ payload: {
+ type: Installation.Event.Updated.type,
+ properties: { version: target },
+ },
+ })
+ return c.json(result)
+ }
+ return c.json(result, 500)
+ },
),
)
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index b6821322e..f5e22ebfb 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -46,6 +46,8 @@ import type {
GlobalDisposeResponses,
GlobalEventResponses,
GlobalHealthResponses,
+ GlobalUpgradeErrors,
+ GlobalUpgradeResponses,
InstanceDisposeResponses,
LspStatusResponses,
McpAddErrors,
@@ -228,6 +230,62 @@ class HeyApiRegistry {
}
}
+export class Auth extends HeyApiClient {
+ /**
+ * Remove auth credentials
+ *
+ * Remove authentication credentials
+ */
+ public remove(
+ parameters: {
+ providerID: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "providerID" }] }])
+ return (options?.client ?? this.client).delete({
+ url: "/auth/{providerID}",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Set auth credentials
+ *
+ * Set authentication credentials
+ */
+ public set(
+ parameters: {
+ providerID: string
+ auth?: Auth3
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "providerID" },
+ { key: "auth", map: "body" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).put({
+ url: "/auth/{providerID}",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+}
+
export class Config extends HeyApiClient {
/**
* Get global configuration
@@ -303,57 +361,20 @@ export class Global extends HeyApiClient {
})
}
- private _config?: Config
- get config(): Config {
- return (this._config ??= new Config({ client: this.client }))
- }
-}
-
-export class Auth extends HeyApiClient {
/**
- * Remove auth credentials
+ * Upgrade opencode
*
- * Remove authentication credentials
+ * Upgrade opencode to the specified version or latest if not specified.
*/
- public remove(
- parameters: {
- providerID: string
+ public upgrade(
+ parameters?: {
+ target?: string
},
options?: Options,
) {
- const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "providerID" }] }])
- return (options?.client ?? this.client).delete({
- url: "/auth/{providerID}",
- ...options,
- ...params,
- })
- }
-
- /**
- * Set auth credentials
- *
- * Set authentication credentials
- */
- public set(
- parameters: {
- providerID: string
- auth?: Auth3
- },
- options?: Options,
- ) {
- const params = buildClientParams(
- [parameters],
- [
- {
- args: [
- { in: "path", key: "providerID" },
- { key: "auth", map: "body" },
- ],
- },
- ],
- )
- return (options?.client ?? this.client).put({
- url: "/auth/{providerID}",
+ const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }])
+ return (options?.client ?? this.client).post({
+ url: "/global/upgrade",
...options,
...params,
headers: {
@@ -363,6 +384,11 @@ export class Auth extends HeyApiClient {
},
})
}
+
+ private _config?: Config
+ get config(): Config {
+ return (this._config ??= new Config({ client: this.client }))
+ }
}
export class Project extends HeyApiClient {
@@ -3906,16 +3932,16 @@ export class OpencodeClient extends HeyApiClient {
OpencodeClient.__registry.set(this, args?.key)
}
- private _global?: Global
- get global(): Global {
- return (this._global ??= new Global({ client: this.client }))
- }
-
private _auth?: Auth
get auth(): Auth {
return (this._auth ??= new Auth({ client: this.client }))
}
+ private _global?: Global
+ get global(): Global {
+ return (this._global ??= new Global({ client: this.client }))
+ }
+
private _project?: Project
get project(): Project {
return (this._project ??= new Project({ client: this.client }))
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index f7aab687e..d284234cc 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -4,6 +4,36 @@ export type ClientOptions = {
baseUrl: `${string}://${string}` | (string & {})
}
+export type BadRequestError = {
+ data: unknown
+ errors: Array<{
+ [key: string]: unknown
+ }>
+ success: false
+}
+
+export type OAuth = {
+ type: "oauth"
+ refresh: string
+ access: string
+ expires: number
+ accountId?: string
+ enterpriseUrl?: string
+}
+
+export type ApiAuth = {
+ type: "api"
+ key: string
+}
+
+export type WellKnownAuth = {
+ type: "wellknown"
+ key: string
+ token: string
+}
+
+export type Auth = OAuth | ApiAuth | WellKnownAuth
+
export type EventInstallationUpdated = {
type: "installation.updated"
properties: {
@@ -1506,36 +1536,6 @@ export type Config = {
}
}
-export type BadRequestError = {
- data: unknown
- errors: Array<{
- [key: string]: unknown
- }>
- success: false
-}
-
-export type OAuth = {
- type: "oauth"
- refresh: string
- access: string
- expires: number
- accountId?: string
- enterpriseUrl?: string
-}
-
-export type ApiAuth = {
- type: "api"
- key: string
-}
-
-export type WellKnownAuth = {
- type: "wellknown"
- key: string
- token: string
-}
-
-export type Auth = OAuth | ApiAuth | WellKnownAuth
-
export type NotFoundError = {
name: "NotFoundError"
data: {
@@ -1938,6 +1938,60 @@ export type FormatterStatus = {
enabled: boolean
}
+export type AuthRemoveData = {
+ body?: never
+ path: {
+ providerID: string
+ }
+ query?: never
+ url: "/auth/{providerID}"
+}
+
+export type AuthRemoveErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors]
+
+export type AuthRemoveResponses = {
+ /**
+ * Successfully removed authentication credentials
+ */
+ 200: boolean
+}
+
+export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses]
+
+export type AuthSetData = {
+ body?: Auth
+ path: {
+ providerID: string
+ }
+ query?: never
+ url: "/auth/{providerID}"
+}
+
+export type AuthSetErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type AuthSetError = AuthSetErrors[keyof AuthSetErrors]
+
+export type AuthSetResponses = {
+ /**
+ * Successfully set authentication credentials
+ */
+ 200: boolean
+}
+
+export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses]
+
export type GlobalHealthData = {
body?: never
path?: never
@@ -2030,59 +2084,40 @@ export type GlobalDisposeResponses = {
export type GlobalDisposeResponse = GlobalDisposeResponses[keyof GlobalDisposeResponses]
-export type AuthRemoveData = {
- body?: never
- path: {
- providerID: string
+export type GlobalUpgradeData = {
+ body?: {
+ target?: string
}
+ path?: never
query?: never
- url: "/auth/{providerID}"
+ url: "/global/upgrade"
}
-export type AuthRemoveErrors = {
+export type GlobalUpgradeErrors = {
/**
* Bad request
*/
400: BadRequestError
}
-export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors]
+export type GlobalUpgradeError = GlobalUpgradeErrors[keyof GlobalUpgradeErrors]
-export type AuthRemoveResponses = {
+export type GlobalUpgradeResponses = {
/**
- * Successfully removed authentication credentials
+ * Upgrade result
*/
- 200: boolean
+ 200:
+ | {
+ success: true
+ version: string
+ }
+ | {
+ success: false
+ error: string
+ }
}
-export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses]
-
-export type AuthSetData = {
- body?: Auth
- path: {
- providerID: string
- }
- query?: never
- url: "/auth/{providerID}"
-}
-
-export type AuthSetErrors = {
- /**
- * Bad request
- */
- 400: BadRequestError
-}
-
-export type AuthSetError = AuthSetErrors[keyof AuthSetErrors]
-
-export type AuthSetResponses = {
- /**
- * Successfully set authentication credentials
- */
- 200: boolean
-}
-
-export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses]
+export type GlobalUpgradeResponse = GlobalUpgradeResponses[keyof GlobalUpgradeResponses]
export type ProjectListData = {
body?: never