|
| 1 | +import { fetch } from "undici" |
| 2 | +import type { OpenCodeUpdateResponse, OpenCodeUpdateStatus } from "../api-types" |
| 3 | +import type { SettingsService } from "../settings/service" |
| 4 | +import { BinaryResolver, type ResolvedBinary } from "../settings/binaries" |
| 5 | +import type { WorkspaceManager } from "../workspaces/manager" |
| 6 | +import { createInstanceClient } from "../workspaces/instance-client" |
| 7 | +import { probeBinaryVersion } from "../workspaces/spawn" |
| 8 | +import { compareVersionStrings, stripTagPrefix } from "../releases/release-monitor" |
| 9 | + |
| 10 | +const OPENCODE_LATEST_URL = "https://registry.npmjs.org/opencode-ai/latest" |
| 11 | +const LATEST_VERSION_CACHE_MS = 5 * 60_000 |
| 12 | +const UPGRADE_TIMEOUT_MS = 10 * 60_000 |
| 13 | +const inFlightUpgrades = new Map<string, Promise<OpenCodeUpdateResponse>>() |
| 14 | + |
| 15 | +type UpgradeResult = { success: true; version: string } | { success: false; error: string } |
| 16 | + |
| 17 | +export interface OpenCodeUpdateServiceDeps { |
| 18 | + resolveBinary: () => ResolvedBinary |
| 19 | + probeBinary: typeof probeBinaryVersion |
| 20 | + findReadyInstanceId: (binaryPath: string) => string | undefined |
| 21 | + upgradeInstance: (instanceId: string, target: string) => Promise<UpgradeResult> |
| 22 | + fetchLatestVersion: () => Promise<string> |
| 23 | + now?: () => number |
| 24 | +} |
| 25 | + |
| 26 | +export class OpenCodeUpdateError extends Error { |
| 27 | + constructor( |
| 28 | + readonly code: |
| 29 | + | "binary_unavailable" |
| 30 | + | "no_ready_instance" |
| 31 | + | "update_check_failed" |
| 32 | + | "upgrade_failed" |
| 33 | + | "upgrade_verification_failed", |
| 34 | + message: string, |
| 35 | + ) { |
| 36 | + super(message) |
| 37 | + this.name = "OpenCodeUpdateError" |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | +export class OpenCodeUpdateService { |
| 42 | + private latestVersionCache: { version: string; expiresAt: number } | null = null |
| 43 | + |
| 44 | + constructor(private readonly deps: OpenCodeUpdateServiceDeps) {} |
| 45 | + |
| 46 | + async getStatus(): Promise<OpenCodeUpdateStatus> { |
| 47 | + const binary = this.deps.resolveBinary() |
| 48 | + const currentVersion = this.readCurrentVersion(binary.path) |
| 49 | + let latestVersion: string |
| 50 | + try { |
| 51 | + latestVersion = await this.readLatestVersion() |
| 52 | + } catch (error) { |
| 53 | + if (!(error instanceof OpenCodeUpdateError) || error.code !== "update_check_failed") throw error |
| 54 | + return { |
| 55 | + currentVersion, |
| 56 | + latestVersion: null, |
| 57 | + updateAvailable: null, |
| 58 | + canUpgrade: false, |
| 59 | + checkError: "update_check_failed", |
| 60 | + } |
| 61 | + } |
| 62 | + const updateAvailable = compareVersionStrings(latestVersion, currentVersion) > 0 |
| 63 | + const readyInstanceId = this.deps.findReadyInstanceId(binary.path) |
| 64 | + |
| 65 | + return { |
| 66 | + currentVersion, |
| 67 | + latestVersion, |
| 68 | + updateAvailable, |
| 69 | + canUpgrade: updateAvailable && Boolean(readyInstanceId), |
| 70 | + } |
| 71 | + } |
| 72 | + |
| 73 | + upgrade(): Promise<OpenCodeUpdateResponse> { |
| 74 | + const binary = this.deps.resolveBinary() |
| 75 | + const existing = inFlightUpgrades.get(binary.path) |
| 76 | + if (existing) return existing |
| 77 | + |
| 78 | + const pending = this.performUpgrade(binary).finally(() => { |
| 79 | + if (inFlightUpgrades.get(binary.path) === pending) inFlightUpgrades.delete(binary.path) |
| 80 | + }) |
| 81 | + inFlightUpgrades.set(binary.path, pending) |
| 82 | + return pending |
| 83 | + } |
| 84 | + |
| 85 | + private async performUpgrade(binary: ResolvedBinary): Promise<OpenCodeUpdateResponse> { |
| 86 | + const currentVersion = this.readCurrentVersion(binary.path) |
| 87 | + const latestVersion = await this.readLatestVersion() |
| 88 | + |
| 89 | + if (compareVersionStrings(latestVersion, currentVersion) <= 0) { |
| 90 | + return { success: true, version: currentVersion } |
| 91 | + } |
| 92 | + |
| 93 | + const instanceId = this.deps.findReadyInstanceId(binary.path) |
| 94 | + if (!instanceId) { |
| 95 | + throw new OpenCodeUpdateError( |
| 96 | + "no_ready_instance", |
| 97 | + "No running OpenCode instance uses the configured binary", |
| 98 | + ) |
| 99 | + } |
| 100 | + |
| 101 | + try { |
| 102 | + const result = await this.deps.upgradeInstance(instanceId, latestVersion) |
| 103 | + if (!result.success) { |
| 104 | + throw new OpenCodeUpdateError("upgrade_failed", result.error) |
| 105 | + } |
| 106 | + const installedVersion = this.readCurrentVersion(binary.path) |
| 107 | + if (compareVersionStrings(installedVersion, latestVersion) !== 0) { |
| 108 | + throw new OpenCodeUpdateError( |
| 109 | + "upgrade_verification_failed", |
| 110 | + `OpenCode reported ${result.version}, but the configured binary is still ${installedVersion}`, |
| 111 | + ) |
| 112 | + } |
| 113 | + return { success: true, version: installedVersion } |
| 114 | + } catch (error) { |
| 115 | + if (error instanceof OpenCodeUpdateError) throw error |
| 116 | + throw new OpenCodeUpdateError( |
| 117 | + "upgrade_failed", |
| 118 | + error instanceof Error ? error.message : "OpenCode upgrade failed", |
| 119 | + ) |
| 120 | + } |
| 121 | + } |
| 122 | + |
| 123 | + private readCurrentVersion(binaryPath: string): string { |
| 124 | + if (process.platform === "win32" && /["\r\n]/.test(binaryPath)) { |
| 125 | + throw new OpenCodeUpdateError("binary_unavailable", "The configured OpenCode binary path is invalid") |
| 126 | + } |
| 127 | + const result = this.deps.probeBinary(binaryPath) |
| 128 | + const version = stripTagPrefix(result.version) |
| 129 | + if (!result.valid || !version) { |
| 130 | + throw new OpenCodeUpdateError("binary_unavailable", result.error ?? "Unable to read OpenCode version") |
| 131 | + } |
| 132 | + return version |
| 133 | + } |
| 134 | + |
| 135 | + private async readLatestVersion(): Promise<string> { |
| 136 | + const now = (this.deps.now ?? Date.now)() |
| 137 | + if (this.latestVersionCache && this.latestVersionCache.expiresAt > now) { |
| 138 | + return this.latestVersionCache.version |
| 139 | + } |
| 140 | + |
| 141 | + try { |
| 142 | + const version = stripTagPrefix(await this.deps.fetchLatestVersion()) |
| 143 | + if (!version || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { |
| 144 | + throw new Error("OpenCode registry returned an invalid version") |
| 145 | + } |
| 146 | + this.latestVersionCache = { version, expiresAt: now + LATEST_VERSION_CACHE_MS } |
| 147 | + return version |
| 148 | + } catch (error) { |
| 149 | + throw new OpenCodeUpdateError( |
| 150 | + "update_check_failed", |
| 151 | + error instanceof Error ? error.message : "Unable to check the latest OpenCode version", |
| 152 | + ) |
| 153 | + } |
| 154 | + } |
| 155 | +} |
| 156 | + |
| 157 | +export async function fetchLatestOpenCodeVersion(): Promise<string> { |
| 158 | + const response = await fetch(OPENCODE_LATEST_URL, { |
| 159 | + headers: { Accept: "application/json", "User-Agent": "CodeNomad-CLI" }, |
| 160 | + signal: AbortSignal.timeout(10_000), |
| 161 | + }) |
| 162 | + if (!response.ok) { |
| 163 | + throw new Error(`OpenCode registry responded with ${response.status}`) |
| 164 | + } |
| 165 | + const payload = (await response.json()) as { version?: unknown } |
| 166 | + if (typeof payload.version !== "string") { |
| 167 | + throw new Error("OpenCode registry response did not include a version") |
| 168 | + } |
| 169 | + return payload.version |
| 170 | +} |
| 171 | + |
| 172 | +export function createOpenCodeUpdateService( |
| 173 | + settings: SettingsService, |
| 174 | + workspaceManager: WorkspaceManager, |
| 175 | +): OpenCodeUpdateService { |
| 176 | + const binaryResolver = new BinaryResolver(settings) |
| 177 | + return new OpenCodeUpdateService({ |
| 178 | + resolveBinary: () => { |
| 179 | + const binary = binaryResolver.resolveDefault() |
| 180 | + return { ...binary, path: workspaceManager.resolveBinaryPath(binary.path) } |
| 181 | + }, |
| 182 | + probeBinary: probeBinaryVersion, |
| 183 | + findReadyInstanceId: (binaryPath) => workspaceManager.findReadyInstanceIdByBinary(binaryPath), |
| 184 | + fetchLatestVersion: fetchLatestOpenCodeVersion, |
| 185 | + upgradeInstance: async (instanceId, target) => { |
| 186 | + const client = createInstanceClient(workspaceManager, instanceId, { timeoutMs: UPGRADE_TIMEOUT_MS }) |
| 187 | + if (!client) { |
| 188 | + throw new OpenCodeUpdateError("no_ready_instance", "OpenCode instance is not ready") |
| 189 | + } |
| 190 | + const { data } = await client.global.upgrade({ target }, { throwOnError: true }) |
| 191 | + return data |
| 192 | + }, |
| 193 | + }) |
| 194 | +} |
0 commit comments