Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/src/v1/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const Info = Schema.Struct({
}),
autoupdate: Schema.optional(Schema.Union([Schema.Boolean, Schema.Literal("notify")])).annotate({
description:
"Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications",
"Check for new versions and show an update notice. Updates are never installed automatically; run /update or `opencode upgrade`. Set to false to disable update checks. true and 'notify' behave the same",
}),
disabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Disable providers that are loaded automatically",
Expand Down
42 changes: 39 additions & 3 deletions packages/opencode/src/cli/cmd/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { withNetworkOptions, resolveNetworkOptionsNoConfig, hasArg } from "@/cli
import { Filesystem } from "@/util/filesystem"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "@opencode-ai/tui/context/sdk"
import type { UpdateNotice, UpdateSource } from "@opencode-ai/tui"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { writeHeapSnapshot } from "v8"
import { ServerAuth } from "@/server/auth"
import { validateSession } from "../tui/validate-session"
Expand Down Expand Up @@ -49,6 +51,41 @@ function createEventSource(client: RpcClient): EventSource {
}
}

// Mirrors the V2 CLI updater: the TUI process checks every 10 minutes and installs on this machine,
// but only when the user picks an option in the /update dialog.
function createUpdater(latest: () => Promise<UpdateNotice | undefined>): UpdateSource {
const listeners = new Set<(notice: UpdateNotice) => void>()
let current: UpdateNotice | undefined
const check = () =>
latest()
.then((notice) => {
if (!notice || JSON.stringify(notice) === JSON.stringify(current)) return
current = notice
listeners.forEach((notify) => notify(notice))
})
.catch(() => {})
setTimeout(check, 1000).unref?.()
setInterval(check, 10 * 60 * 1000).unref?.()
return {
subscribe: (notify) => {
if (current) notify(current)
listeners.add(notify)
return () => listeners.delete(notify)
},
check: async () => {
const { Installation } = await import("@/installation")
return { current: InstallationVersion, latest: await Installation.latest(await Installation.method()) }
},
apply: async (target) => {
const { Installation } = await import("@/installation")
const method = await Installation.method()
if (method === "unknown") throw new Error("Installation method not found")
if (target.type === "major") return Installation.upgradeMajor(method)
return Installation.upgrade(method, target.version)
},
}
}

async function target() {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
const dist = new URL("./cli/tui/worker.js", import.meta.url)
Expand Down Expand Up @@ -262,9 +299,7 @@ export const TuiThreadCommand = cmd({
return
}

setTimeout(() => {
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
}, 1000).unref?.()
const updater = createUpdater(async () => await client.call("checkUpgrade", { directory: cwd }))

try {
const { Effect } = await import("effect")
Expand All @@ -284,6 +319,7 @@ export const TuiThreadCommand = cmd({
fetch: transport.fetch,
headers: transport.headers,
events: transport.events,
updater,
args: {
continue: args.continue,
sessionID: args.session,
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/tui/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export const rpc = {
},
async checkUpgrade(input: { directory: string }) {
await InstanceRuntime.load({ directory: input.directory })
await upgrade().catch(() => {})
return upgrade().catch(() => undefined)
},
async reload() {
await AppRuntime.runPromise(
Expand Down
66 changes: 25 additions & 41 deletions packages/opencode/src/cli/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,51 +3,35 @@ import { AppRuntime } from "@/effect/app-runtime"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Installation } from "@/installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { GlobalBus } from "@/bus/global"

// Checking never installs anything. Updates only happen when the user runs `/update` or `opencode upgrade`.
export function shouldNotify(input: {
autoupdate: boolean | "notify" | undefined
disabled: boolean
always: boolean
current: string
latest: string
}) {
if (input.autoupdate === false || input.disabled) return false
if (input.always) return true
return input.current !== input.latest
}

// Returns undefined when checks are disabled or fail, so the TUI only suggests OpenCode 2 after a successful check.
export async function upgrade() {
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
const method = await Installation.method()
const latest = await Installation.latest(method).catch(() => {})
const latest = await Installation.latest(await Installation.method()).catch(() => {})
if (!latest) return

if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.UpdateAvailable.type,
properties: { version: latest },
},
})
return
}

if (InstallationVersion === latest) return

const kind = Installation.getReleaseType(InstallationVersion, latest)

if (config.autoupdate === "notify" || kind !== "patch") {
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.UpdateAvailable.type,
properties: { version: latest },
},
if (
shouldNotify({
autoupdate: config.autoupdate,
disabled: Flag.OPENCODE_DISABLE_AUTOUPDATE,
always: Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE,
current: InstallationVersion,
latest,
})
return
}

if (method === "unknown") return
await Installation.upgrade(method, latest)
.then(() =>
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.Updated.type,
properties: { version: latest },
},
}),
)
.catch(() => {})
)
return { type: "available" as const, version: latest }
return { type: "major" as const }
}
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,8 @@ Options:
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
--pure run without external plugins [boolean]
-m, --method installation method to use
[string] [choices: "curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"]"
[string] [choices: "curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"]
--major upgrade to OpenCode 2 and remove OpenCode 1 [boolean]"
`;
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode uninstall --help 1`] = `
Expand Down
29 changes: 29 additions & 0 deletions packages/opencode/test/cli/upgrade.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test"
import { shouldNotify } from "../../src/cli/upgrade"

const base = { autoupdate: undefined, disabled: false, always: false, current: "1.4.0", latest: "1.4.1" }

describe("upgrade check", () => {
test("notifies about every newer release, including patches", () => {
expect(shouldNotify(base)).toBe(true)
expect(shouldNotify({ ...base, latest: "2.0.0" })).toBe(true)
})

test("treats true and notify the same", () => {
expect(shouldNotify({ ...base, autoupdate: true })).toBe(true)
expect(shouldNotify({ ...base, autoupdate: "notify" })).toBe(true)
})

test("does not notify when already on the latest version", () => {
expect(shouldNotify({ ...base, latest: "1.4.0" })).toBe(false)
})

test("never notifies when update checks are disabled", () => {
expect(shouldNotify({ ...base, autoupdate: false })).toBe(false)
expect(shouldNotify({ ...base, disabled: true, always: true })).toBe(false)
})

test("always-notify flag forces a notice for the current version", () => {
expect(shouldNotify({ ...base, latest: "1.4.0", always: true })).toBe(true)
})
})
2 changes: 1 addition & 1 deletion packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1927,7 +1927,7 @@ export type Config = {
share?: "manual" | "auto" | "disabled"
autoshare?: boolean
/**
* Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications
* Check for new versions and show an update notice. Updates are never installed automatically; run /update or `opencode upgrade`. Set to false to disable update checks. true and 'notify' behave the same
*/
autoupdate?: boolean | "notify"
disabled_providers?: Array<string>
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -21263,7 +21263,7 @@
"enum": ["notify"]
}
],
"description": "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications"
"description": "Check for new versions and show an update notice. Updates are never installed automatically; run /update or `opencode upgrade`. Set to false to disable update checks. true and 'notify' behave the same"
},
"disabled_providers": {
"type": "array",
Expand Down
91 changes: 20 additions & 71 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,10 @@ import { Session } from "./routes/session"
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 { isDefaultTitle } from "./util/session"
import { KVProvider, useKV } from "./context/kv"
import { UpdateNotificationProvider, useUpdateNotification, type UpdateSource } from "./context/update-notification"
import * as Model from "./util/model"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import { openUrl } from "@opencode-ai/core/open"
Expand Down Expand Up @@ -149,6 +148,7 @@ export type TuiInput = {
headers?: RequestInit["headers"]
events?: EventSource
pluginHost: TuiPluginHost
updater?: UpdateSource
}

function errorMessage(error: unknown) {
Expand All @@ -166,23 +166,6 @@ function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error)
}

function isVersionGreater(left: string, right: string) {
const parse = (value: string) => {
const [core, prerelease] = value.replace(/^v/, "").split("-", 2)
return { core: core.split(".").map((part) => Number.parseInt(part, 10) || 0), prerelease }
}
const a = parse(left)
const b = parse(right)
for (let index = 0; index < Math.max(a.core.length, b.core.length); index++) {
const difference = (a.core[index] ?? 0) - (b.core[index] ?? 0)
if (difference) return difference > 0
}
if (a.prerelease === b.prerelease) return false
if (!a.prerelease) return true
if (!b.prerelease) return false
return a.prerelease.localeCompare(b.prerelease, undefined, { numeric: true }) > 0
}

export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const global = yield* Global.Service
const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
Expand Down Expand Up @@ -315,10 +298,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptRefProvider>
<EditorContextProvider>
<LocationProvider>
<App
onSnapshot={input.onSnapshot}
pluginHost={input.pluginHost}
/>
<UpdateNotificationProvider updater={input.updater}>
<App
onSnapshot={input.onSnapshot}
pluginHost={input.pluginHost}
/>
</UpdateNotificationProvider>
</LocationProvider>
</EditorContextProvider>
</PromptRefProvider>
Expand Down Expand Up @@ -386,6 +371,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
const pluginRuntime = usePluginRuntime()
const attention = createTuiAttention({ renderer, config: tuiConfig, kv })
const clipboard = useClipboard()
const update = useUpdateNotification()

const api = createTuiApi(
createTuiApiAdapters({
Expand Down Expand Up @@ -771,6 +757,17 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
},
category: "System",
},
...(update.open
? [
{
name: "opencode.update",
title: "Update OpenCode",
slashName: "update",
run: () => update.open?.(),
category: "System",
},
]
: []),
{
name: "opencode.debug",
title: "View debug info",
Expand Down Expand Up @@ -1030,54 +1027,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
})
})

event.on("installation.update-available", async (evt) => {
console.log("installation.update-available", evt)
const version = evt.properties.version

const skipped = kv.get("skipped_version")
if (skipped && !isVersionGreater(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",
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.`,
)

void exit()
})

const plugin = createMemo(() => {
if (!ready()) return
if (route.data.type !== "plugin") return
Expand Down
Loading
Loading