fix(settings): restore OpenCode V2 updates

The V2 migration first disabled and then removed the OpenCode update action because the native runtime no longer exposes V1's global upgrade endpoint. The remaining version card also queried the legacy opencode-ai package instead of the V2 beta channel.

Restore the settings action using the official @opencode-ai/cli beta installation flow for npm, pnpm, bun, and yarn. Restrict automatic updates to the managed opencode2 command, preserve custom binaries, compare monotonically numbered beta builds numerically, and verify the configured binary after installation before reporting success.

Validated against the live beta registry, 11 focused update tests, 307 server tests with 3 skipped, 544 UI tests, server and UI typechecks, the production UI build, and diff checks.
This commit is contained in:
Pascal André 2026-08-18 18:20:10 +02:00
parent ae6a2a8b2e
commit c4bc08ee03
No known key found for this signature in database
4 changed files with 214 additions and 35 deletions

View file

@ -1,15 +1,22 @@
import assert from "node:assert/strict"
import test from "node:test"
import { OpenCodeUpdateError, OpenCodeUpdateService, type OpenCodeUpdateServiceDeps } from "./service"
import {
OpenCodeUpdateError,
OpenCodeUpdateService,
buildOpenCodeUpgradeCommand,
compareOpenCodeVersionStrings,
detectOpenCodePackageManager,
type OpenCodeUpdateServiceDeps,
} from "./service"
function createDeps(overrides: Partial<OpenCodeUpdateServiceDeps> = {}): OpenCodeUpdateServiceDeps {
let currentVersion = "1.0.0"
return {
resolveBinary: () => ({ path: "opencode", label: "OpenCode" }),
probeBinary: () => ({ valid: true, version: currentVersion }),
findReadyInstanceId: () => "workspace-1",
canUpgradeBinary: () => true,
fetchLatestVersion: async () => "1.1.0",
upgradeInstance: async (_instanceId, target) => {
upgradeBinary: async (_binary, target) => {
currentVersion = target
return { success: true, version: target }
},
@ -36,8 +43,8 @@ test("reports an available update and caches the latest version", async () => {
assert.equal(checks, 1)
})
test("keeps the update visible when no matching instance is ready", async () => {
const service = new OpenCodeUpdateService(createDeps({ findReadyInstanceId: () => undefined }))
test("keeps the update visible for a custom binary", async () => {
const service = new OpenCodeUpdateService(createDeps({ canUpgradeBinary: () => false }))
assert.deepEqual(await service.getStatus(), {
currentVersion: "1.0.0",
@ -63,26 +70,26 @@ test("preserves the installed version when the registry check fails", async () =
})
})
test("upgrades through the matching OpenCode instance to the advertised version", async () => {
const calls: Array<{ instanceId: string; target: string }> = []
test("upgrades the managed OpenCode binary to the advertised version", async () => {
const calls: Array<{ path: string; target: string }> = []
let currentVersion = "1.0.0"
const service = new OpenCodeUpdateService(createDeps({
probeBinary: () => ({ valid: true, version: currentVersion }),
upgradeInstance: async (instanceId, target) => {
calls.push({ instanceId, target })
upgradeBinary: async (binary, target) => {
calls.push({ path: binary.path, target })
currentVersion = target
return { success: true, version: target }
},
}))
assert.deepEqual(await service.upgrade(), { success: true, version: "1.1.0" })
assert.deepEqual(calls, [{ instanceId: "workspace-1", target: "1.1.0" }])
assert.deepEqual(calls, [{ path: "opencode", target: "1.1.0" }])
})
test("rejects success when the configured binary was not updated", async () => {
const service = new OpenCodeUpdateService(createDeps({
probeBinary: () => ({ valid: true, version: "1.0.0" }),
upgradeInstance: async (_instanceId, target) => ({ success: true, version: target }),
upgradeBinary: async (_binary, target) => ({ success: true, version: target }),
}))
await assert.rejects(
@ -100,7 +107,7 @@ test("joins concurrent upgrades for the same binary", async () => {
})
const service = new OpenCodeUpdateService(createDeps({
probeBinary: () => ({ valid: true, version: currentVersion }),
upgradeInstance: async (_instanceId, target) => {
upgradeBinary: async (_binary, target) => {
upgrades += 1
await gate
currentVersion = target
@ -119,11 +126,40 @@ test("joins concurrent upgrades for the same binary", async () => {
assert.equal(upgrades, 1)
})
test("rejects an upgrade when no matching OpenCode instance is running", async () => {
const service = new OpenCodeUpdateService(createDeps({ findReadyInstanceId: () => undefined }))
test("rejects an upgrade for a custom binary", async () => {
const service = new OpenCodeUpdateService(createDeps({ canUpgradeBinary: () => false }))
await assert.rejects(
() => service.upgrade(),
(error: unknown) => error instanceof OpenCodeUpdateError && error.code === "no_ready_instance",
(error: unknown) => error instanceof OpenCodeUpdateError && error.code === "unsupported_binary",
)
})
test("builds official V2 package-manager update commands", () => {
assert.deepEqual(buildOpenCodeUpgradeCommand("0.0.0-beta-123", "npm"), {
command: "npm",
args: ["install", "-g", "@opencode-ai/cli@0.0.0-beta-123"],
})
assert.deepEqual(buildOpenCodeUpgradeCommand("0.0.0-beta-123", "pnpm"), {
command: "pnpm",
args: ["add", "-g", "--allow-build=@opencode-ai/cli", "@opencode-ai/cli@0.0.0-beta-123"],
})
assert.deepEqual(buildOpenCodeUpgradeCommand("0.0.0-beta-123", "bun"), {
command: "bun",
args: ["install", "-g", "--trust", "@opencode-ai/cli@0.0.0-beta-123"],
})
})
test("detects the package manager from the binary path or launch environment", () => {
assert.equal(detectOpenCodePackageManager("/home/me/.local/share/pnpm/opencode2", {}), "pnpm")
assert.equal(detectOpenCodePackageManager("C:\\Users\\me\\.bun\\bin\\opencode2.exe", {}), "bun")
assert.equal(detectOpenCodePackageManager("/usr/local/bin/opencode2", { npm_config_user_agent: "yarn/1.22" }), "yarn")
assert.equal(detectOpenCodePackageManager("C:\\Users\\me\\AppData\\Roaming\\npm\\opencode2.cmd", {}), "npm")
assert.equal(detectOpenCodePackageManager("C:\\Users\\me\\AppData\\Roaming\\npm\\opencode2.cmd", { npm_config_user_agent: "pnpm/10" }), "npm")
assert.equal(detectOpenCodePackageManager("/home/ubuntu/bin/opencode2", {}), "npm")
})
test("compares monotonically numbered V2 beta builds numerically", () => {
assert.equal(compareOpenCodeVersionStrings("0.0.0-beta-10000", "0.0.0-beta-9999") > 0, true)
assert.equal(compareOpenCodeVersionStrings("0.0.0-beta-9999", "0.0.0-beta-10000") < 0, true)
})

View file

@ -1,3 +1,4 @@
import { spawn } from "node:child_process"
import { fetch } from "undici"
import type { OpenCodeUpdateResponse, OpenCodeUpdateStatus } from "../api-types"
import type { SettingsService } from "../settings/service"
@ -6,7 +7,8 @@ import type { WorkspaceManager } from "../workspaces/manager"
import { probeBinaryVersion } from "../workspaces/spawn"
import { compareVersionStrings, stripTagPrefix } from "../releases/release-monitor"
const OPENCODE_LATEST_URL = "https://registry.npmjs.org/opencode-ai/latest"
const OPENCODE_LATEST_URL = "https://registry.npmjs.org/@opencode-ai%2fcli/beta"
const OPENCODE_PACKAGE_NAME = "@opencode-ai/cli"
const LATEST_VERSION_CACHE_MS = 5 * 60_000
const inFlightUpgrades = new Map<string, Promise<OpenCodeUpdateResponse>>()
@ -15,8 +17,8 @@ type UpgradeResult = { success: true; version: string } | { success: false; erro
export interface OpenCodeUpdateServiceDeps {
resolveBinary: () => ResolvedBinary
probeBinary: typeof probeBinaryVersion
findReadyInstanceId: (binaryPath: string) => string | undefined
upgradeInstance: (instanceId: string, target: string) => Promise<UpgradeResult>
canUpgradeBinary: (binary: ResolvedBinary) => boolean
upgradeBinary: (binary: ResolvedBinary, target: string) => Promise<UpgradeResult>
fetchLatestVersion: () => Promise<string>
now?: () => number
}
@ -25,7 +27,7 @@ export class OpenCodeUpdateError extends Error {
constructor(
readonly code:
| "binary_unavailable"
| "no_ready_instance"
| "unsupported_binary"
| "update_check_failed"
| "upgrade_failed"
| "upgrade_verification_failed",
@ -57,14 +59,14 @@ export class OpenCodeUpdateService {
checkError: "update_check_failed",
}
}
const updateAvailable = compareVersionStrings(latestVersion, currentVersion) > 0
const readyInstanceId = this.deps.findReadyInstanceId(binary.path)
const updateAvailable = compareOpenCodeVersionStrings(latestVersion, currentVersion) > 0
const canUpgrade = this.deps.canUpgradeBinary(binary)
return {
currentVersion,
latestVersion,
updateAvailable,
canUpgrade: updateAvailable && Boolean(readyInstanceId),
canUpgrade: updateAvailable && canUpgrade,
}
}
@ -84,25 +86,24 @@ export class OpenCodeUpdateService {
const currentVersion = this.readCurrentVersion(binary.path)
const latestVersion = await this.readLatestVersion()
if (compareVersionStrings(latestVersion, currentVersion) <= 0) {
if (compareOpenCodeVersionStrings(latestVersion, currentVersion) <= 0) {
return { success: true, version: currentVersion }
}
const instanceId = this.deps.findReadyInstanceId(binary.path)
if (!instanceId) {
if (!this.deps.canUpgradeBinary(binary)) {
throw new OpenCodeUpdateError(
"no_ready_instance",
"No running OpenCode instance uses the configured binary",
"unsupported_binary",
"Automatic updates are only available for the managed opencode2 command",
)
}
try {
const result = await this.deps.upgradeInstance(instanceId, latestVersion)
const result = await this.deps.upgradeBinary(binary, latestVersion)
if (!result.success) {
throw new OpenCodeUpdateError("upgrade_failed", result.error)
}
const installedVersion = this.readCurrentVersion(binary.path)
if (compareVersionStrings(installedVersion, latestVersion) !== 0) {
if (compareOpenCodeVersionStrings(installedVersion, latestVersion) !== 0) {
throw new OpenCodeUpdateError(
"upgrade_verification_failed",
`OpenCode reported ${result.version}, but the configured binary is still ${installedVersion}`,
@ -167,6 +168,76 @@ export async function fetchLatestOpenCodeVersion(): Promise<string> {
return payload.version
}
export type OpenCodePackageManager = "npm" | "pnpm" | "bun" | "yarn"
export function compareOpenCodeVersionStrings(left: string, right: string): number {
const leftBeta = stripTagPrefix(left)?.match(/^0\.0\.0-beta-(\d+)$/)
const rightBeta = stripTagPrefix(right)?.match(/^0\.0\.0-beta-(\d+)$/)
if (leftBeta && rightBeta) return Number(leftBeta[1]) - Number(rightBeta[1])
return compareVersionStrings(left, right)
}
export function detectOpenCodePackageManager(
binaryPath: string,
env: NodeJS.ProcessEnv = process.env,
): OpenCodePackageManager {
const pathSource = binaryPath.toLowerCase()
const launchSource = `${env.npm_config_user_agent ?? ""}\n${env.npm_execpath ?? ""}`.toLowerCase()
if (pathSource.includes("pnpm")) return "pnpm"
if (/[\\/]\.bun[\\/]/.test(pathSource)) return "bun"
if (pathSource.includes("yarn")) return "yarn"
if (/[\\/]npm[\\/]/.test(pathSource)) return "npm"
if (launchSource.includes("pnpm")) return "pnpm"
if (/(^|[\s/])bun(?:$|[\s/])/.test(launchSource)) return "bun"
if (launchSource.includes("yarn")) return "yarn"
return "npm"
}
export function buildOpenCodeUpgradeCommand(
version: string,
packageManager: OpenCodePackageManager,
): { command: string; args: string[] } {
const packageSpec = `${OPENCODE_PACKAGE_NAME}@${version}`
if (packageManager === "pnpm") {
return { command: "pnpm", args: ["add", "-g", `--allow-build=${OPENCODE_PACKAGE_NAME}`, packageSpec] }
}
if (packageManager === "bun") {
return { command: "bun", args: ["install", "-g", "--trust", packageSpec] }
}
if (packageManager === "yarn") {
return { command: "yarn", args: ["global", "add", packageSpec] }
}
return { command: "npm", args: ["install", "-g", packageSpec] }
}
export function installOpenCodeCli(
binary: ResolvedBinary,
version: string,
env: NodeJS.ProcessEnv = process.env,
): Promise<UpgradeResult> {
const upgrade = buildOpenCodeUpgradeCommand(version, detectOpenCodePackageManager(binary.path, env))
return new Promise((resolve) => {
const child = spawn(upgrade.command, upgrade.args, {
env,
shell: process.platform === "win32",
stdio: "ignore",
windowsHide: true,
})
child.once("error", (error) => resolve({ success: false, error: error.message }))
child.once("exit", (code, signal) => {
if (signal) {
resolve({ success: false, error: `OpenCode update stopped by signal ${signal}` })
return
}
if (code !== 0) {
resolve({ success: false, error: `OpenCode update exited with code ${code ?? "unknown"}` })
return
}
resolve({ success: true, version })
})
})
}
export function createOpenCodeUpdateService(
settings: SettingsService,
workspaceManager: WorkspaceManager,
@ -178,9 +249,8 @@ export function createOpenCodeUpdateService(
return { ...binary, path: workspaceManager.resolveBinaryPath(binary.path) }
},
probeBinary: probeBinaryVersion,
// The native V2 client has no self-upgrade operation.
findReadyInstanceId: () => undefined,
canUpgradeBinary: () => binaryResolver.resolveDefault().path === "opencode2",
fetchLatestVersion: fetchLatestOpenCodeVersion,
upgradeInstance: async () => ({ success: false, error: "OpenCode V2 does not expose self-upgrade" }),
upgradeBinary: installOpenCodeCli,
})
}

View file

@ -8,7 +8,7 @@ interface RouteDeps {
}
function statusCode(error: OpenCodeUpdateError): number {
if (error.code === "no_ready_instance") return 409
if (error.code === "unsupported_binary") return 409
if (error.code === "binary_unavailable") return 422
return 502
}

View file

@ -1,4 +1,4 @@
import { createResource, Show, type Component } from "solid-js"
import { createEffect, createResource, createSignal, Show, type Component } from "solid-js"
import { serverApi } from "../../lib/api-client"
import { useI18n } from "../../lib/i18n"
import { useConfig } from "../../stores/preferences"
@ -6,10 +6,41 @@ import { useConfig } from "../../stores/preferences"
export const OpenCodeUpdateCard: Component = () => {
const { t } = useI18n()
const { serverSettings } = useConfig()
const [status, { refetch }] = createResource(
const [status, { mutate, refetch }] = createResource(
() => serverSettings().opencodeBinary || "opencode2",
() => serverApi.fetchOpenCodeUpdateStatus(),
)
const [updating, setUpdating] = createSignal(false)
const [updatedVersion, setUpdatedVersion] = createSignal<string | null>(null)
const [updateFailed, setUpdateFailed] = createSignal(false)
createEffect(() => {
serverSettings().opencodeBinary
setUpdatedVersion(null)
setUpdateFailed(false)
})
const handleUpdate = async () => {
if (updating()) return
const binary = serverSettings().opencodeBinary || "opencode2"
setUpdating(true)
setUpdateFailed(false)
try {
const result = await serverApi.updateOpenCode()
if ((serverSettings().opencodeBinary || "opencode2") !== binary) return
setUpdatedVersion(result.version)
mutate({
currentVersion: result.version,
latestVersion: result.version,
updateAvailable: false,
canUpgrade: false,
})
} catch {
if ((serverSettings().opencodeBinary || "opencode2") === binary) setUpdateFailed(true)
} finally {
setUpdating(false)
}
}
return (
<div class="settings-card">
<div class="settings-card-header">
@ -41,10 +72,52 @@ export const OpenCodeUpdateCard: Component = () => {
</div>
</div>
<Show when={updateStatus().checkError}>
<div class="settings-error-message" role="status">{t("settings.opencode.update.checkFailed")}</div>
<div class="settings-info-actions">
<button type="button" class="settings-pill-button" onClick={() => void refetch()}>
{t("settings.opencode.update.retry")}
</button>
</div>
</Show>
<Show when={!updateStatus().checkError}>
<Show when={updateStatus().updateAvailable} fallback={
<div class="settings-toggle-caption" role="status">{t("settings.opencode.update.upToDate")}</div>
}>
<Show
when={updateStatus().canUpgrade}
fallback={<div class="settings-toggle-caption">{t("settings.opencode.update.availableUnsupported", { version: updateStatus().latestVersion ?? "" })}</div>}
>
<div class="settings-info-actions">
<button
type="button"
class="settings-pill-button"
disabled={updating()}
onClick={() => void handleUpdate()}
>
{updating()
? t("settings.opencode.update.updating")
: t("settings.opencode.update.action", { version: updateStatus().latestVersion ?? "" })}
</button>
</div>
</Show>
</Show>
</Show>
</>
)}
</Show>
</Show>
<Show when={updatedVersion()}>
{(version) => (
<div class="settings-info-toast" role="status" aria-live="polite">
{t("settings.opencode.update.success", { version: version() })}
</div>
)}
</Show>
<Show when={updateFailed()}>
<div class="settings-error-message" role="alert">{t("settings.opencode.update.failed")}</div>
</Show>
</div>
)
}