From c4bc08ee036b99deedd96bbf3b0d477f142741fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 18 Aug 2026 18:20:10 +0200 Subject: [PATCH] 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. --- .../src/opencode-update/service.test.ts | 66 ++++++++--- .../server/src/opencode-update/service.ts | 104 +++++++++++++++--- .../src/server/routes/opencode-update.ts | 2 +- .../settings/opencode-update-card.tsx | 77 ++++++++++++- 4 files changed, 214 insertions(+), 35 deletions(-) diff --git a/packages/server/src/opencode-update/service.test.ts b/packages/server/src/opencode-update/service.test.ts index 2b631529..6c8de74e 100644 --- a/packages/server/src/opencode-update/service.test.ts +++ b/packages/server/src/opencode-update/service.test.ts @@ -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 { 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) +}) diff --git a/packages/server/src/opencode-update/service.ts b/packages/server/src/opencode-update/service.ts index a9b031be..34cf7c4f 100644 --- a/packages/server/src/opencode-update/service.ts +++ b/packages/server/src/opencode-update/service.ts @@ -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>() @@ -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 + canUpgradeBinary: (binary: ResolvedBinary) => boolean + upgradeBinary: (binary: ResolvedBinary, target: string) => Promise fetchLatestVersion: () => Promise 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 { 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 { + 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, }) } diff --git a/packages/server/src/server/routes/opencode-update.ts b/packages/server/src/server/routes/opencode-update.ts index 58e328d0..54ef593b 100644 --- a/packages/server/src/server/routes/opencode-update.ts +++ b/packages/server/src/server/routes/opencode-update.ts @@ -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 } diff --git a/packages/ui/src/components/settings/opencode-update-card.tsx b/packages/ui/src/components/settings/opencode-update-card.tsx index 14b2732e..a312c2b1 100644 --- a/packages/ui/src/components/settings/opencode-update-card.tsx +++ b/packages/ui/src/components/settings/opencode-update-card.tsx @@ -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(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 (
@@ -41,10 +72,52 @@ export const OpenCodeUpdateCard: Component = () => {
+ +
{t("settings.opencode.update.checkFailed")}
+
+ +
+
+ + + {t("settings.opencode.update.upToDate")} + }> + {t("settings.opencode.update.availableUnsupported", { version: updateStatus().latestVersion ?? "" })}} + > +
+ +
+
+
+
)} + + {(version) => ( +
+ {t("settings.opencode.update.success", { version: version() })} +
+ )} +
+ + + ) }