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