Просмотр исходного кода

feat: interactive update flow for non-patch releases (#18662)

Dax 4 недель назад
Родитель
Сommit
e2d03ce38c

+ 0 - 0
packages/opencode/git


+ 45 - 5
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 (

+ 9 - 5
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) {
         <text fg={theme.textMuted}>{props.message}</text>
       </box>
       <box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
-        <For each={["cancel", "confirm"]}>
+        <For each={["cancel", "confirm"] as const}>
           {(key) => (
             <box
               paddingLeft={1}
@@ -58,7 +61,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
               }}
             >
               <text fg={key === store.active ? theme.selectedListItemText : theme.textMuted}>
-                {Locale.titlecase(key)}
+                {Locale.titlecase(key === "cancel" ? (props.label ?? key) : key)}
               </text>
             </box>
           )}
@@ -68,8 +71,8 @@ export function DialogConfirm(props: DialogConfirmProps) {
   )
 }
 
-DialogConfirm.show = (dialog: DialogContext, title: string, message: string) => {
-  return new Promise<boolean>((resolve) => {
+DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: string) => {
+  return new Promise<DialogConfirmResult>((resolve) => {
     dialog.replace(
       () => (
         <DialogConfirm
@@ -77,9 +80,10 @@ DialogConfirm.show = (dialog: DialogContext, title: string, message: string) =>
           message={message}
           onConfirm={() => resolve(true)}
           onCancel={() => resolve(false)}
+          label={label}
         />
       ),
-      () => resolve(false),
+      () => resolve(undefined),
     )
   })
 }

+ 9 - 3
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
   }

+ 1 - 0
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"]

+ 15 - 0
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(),

+ 59 - 1
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)
+      },
     ),
 )

+ 76 - 50
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<T> {
   }
 }
 
+export class Auth extends HeyApiClient {
+  /**
+   * Remove auth credentials
+   *
+   * Remove authentication credentials
+   */
+  public remove<ThrowOnError extends boolean = false>(
+    parameters: {
+      providerID: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "providerID" }] }])
+    return (options?.client ?? this.client).delete<AuthRemoveResponses, AuthRemoveErrors, ThrowOnError>({
+      url: "/auth/{providerID}",
+      ...options,
+      ...params,
+    })
+  }
+
+  /**
+   * Set auth credentials
+   *
+   * Set authentication credentials
+   */
+  public set<ThrowOnError extends boolean = false>(
+    parameters: {
+      providerID: string
+      auth?: Auth3
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "providerID" },
+            { key: "auth", map: "body" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).put<AuthSetResponses, AuthSetErrors, ThrowOnError>({
+      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<ThrowOnError extends boolean = false>(
-    parameters: {
-      providerID: string
-    },
-    options?: Options<never, ThrowOnError>,
-  ) {
-    const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "providerID" }] }])
-    return (options?.client ?? this.client).delete<AuthRemoveResponses, AuthRemoveErrors, ThrowOnError>({
-      url: "/auth/{providerID}",
-      ...options,
-      ...params,
-    })
-  }
-
-  /**
-   * Set auth credentials
-   *
-   * Set authentication credentials
-   */
-  public set<ThrowOnError extends boolean = false>(
-    parameters: {
-      providerID: string
-      auth?: Auth3
+  public upgrade<ThrowOnError extends boolean = false>(
+    parameters?: {
+      target?: string
     },
     options?: Options<never, ThrowOnError>,
   ) {
-    const params = buildClientParams(
-      [parameters],
-      [
-        {
-          args: [
-            { in: "path", key: "providerID" },
-            { key: "auth", map: "body" },
-          ],
-        },
-      ],
-    )
-    return (options?.client ?? this.client).put<AuthSetResponses, AuthSetErrors, ThrowOnError>({
-      url: "/auth/{providerID}",
+    const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }])
+    return (options?.client ?? this.client).post<GlobalUpgradeResponses, GlobalUpgradeErrors, ThrowOnError>({
+      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 }))

+ 103 - 68
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
-  }
-  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
+export type GlobalUpgradeData = {
+  body?: {
+    target?: string
   }
+  path?: never
   query?: never
-  url: "/auth/{providerID}"
+  url: "/global/upgrade"
 }
 
-export type AuthSetErrors = {
+export type GlobalUpgradeErrors = {
   /**
    * Bad request
    */
   400: BadRequestError
 }
 
-export type AuthSetError = AuthSetErrors[keyof AuthSetErrors]
+export type GlobalUpgradeError = GlobalUpgradeErrors[keyof GlobalUpgradeErrors]
 
-export type AuthSetResponses = {
+export type GlobalUpgradeResponses = {
   /**
-   * Successfully set authentication credentials
+   * Upgrade result
    */
-  200: boolean
+  200:
+    | {
+        success: true
+        version: string
+      }
+    | {
+        success: false
+        error: string
+      }
 }
 
-export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses]
+export type GlobalUpgradeResponse = GlobalUpgradeResponses[keyof GlobalUpgradeResponses]
 
 export type ProjectListData = {
   body?: never