Skip to content

Commit 0259932

Browse files
authored
Merge branch 'dev' into fix/tauri-about-menu
2 parents eda59c3 + e586ea3 commit 0259932

69 files changed

Lines changed: 1333 additions & 220 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/server/src/api-types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,19 @@ export interface BinaryValidationResult {
356356
error?: string
357357
}
358358

359+
export interface OpenCodeUpdateStatus {
360+
currentVersion: string
361+
latestVersion: string | null
362+
updateAvailable: boolean | null
363+
canUpgrade: boolean
364+
checkError?: "update_check_failed"
365+
}
366+
367+
export interface OpenCodeUpdateResponse {
368+
success: boolean
369+
version: string
370+
}
371+
359372
export interface SpeechSegment {
360373
startMs: number
361374
endMs: number
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import { OpenCodeUpdateError, OpenCodeUpdateService, type OpenCodeUpdateServiceDeps } from "./service"
4+
5+
function createDeps(overrides: Partial<OpenCodeUpdateServiceDeps> = {}): OpenCodeUpdateServiceDeps {
6+
let currentVersion = "1.0.0"
7+
return {
8+
resolveBinary: () => ({ path: "opencode", label: "OpenCode" }),
9+
probeBinary: () => ({ valid: true, version: currentVersion }),
10+
findReadyInstanceId: () => "workspace-1",
11+
fetchLatestVersion: async () => "1.1.0",
12+
upgradeInstance: async (_instanceId, target) => {
13+
currentVersion = target
14+
return { success: true, version: target }
15+
},
16+
...overrides,
17+
}
18+
}
19+
20+
test("reports an available update and caches the latest version", async () => {
21+
let checks = 0
22+
const service = new OpenCodeUpdateService(createDeps({
23+
fetchLatestVersion: async () => {
24+
checks += 1
25+
return "1.1.0"
26+
},
27+
}))
28+
29+
assert.deepEqual(await service.getStatus(), {
30+
currentVersion: "1.0.0",
31+
latestVersion: "1.1.0",
32+
updateAvailable: true,
33+
canUpgrade: true,
34+
})
35+
await service.getStatus()
36+
assert.equal(checks, 1)
37+
})
38+
39+
test("keeps the update visible when no matching instance is ready", async () => {
40+
const service = new OpenCodeUpdateService(createDeps({ findReadyInstanceId: () => undefined }))
41+
42+
assert.deepEqual(await service.getStatus(), {
43+
currentVersion: "1.0.0",
44+
latestVersion: "1.1.0",
45+
updateAvailable: true,
46+
canUpgrade: false,
47+
})
48+
})
49+
50+
test("preserves the installed version when the registry check fails", async () => {
51+
const service = new OpenCodeUpdateService(createDeps({
52+
fetchLatestVersion: async () => {
53+
throw new Error("registry unavailable")
54+
},
55+
}))
56+
57+
assert.deepEqual(await service.getStatus(), {
58+
currentVersion: "1.0.0",
59+
latestVersion: null,
60+
updateAvailable: null,
61+
canUpgrade: false,
62+
checkError: "update_check_failed",
63+
})
64+
})
65+
66+
test("upgrades through the matching OpenCode instance to the advertised version", async () => {
67+
const calls: Array<{ instanceId: string; target: string }> = []
68+
let currentVersion = "1.0.0"
69+
const service = new OpenCodeUpdateService(createDeps({
70+
probeBinary: () => ({ valid: true, version: currentVersion }),
71+
upgradeInstance: async (instanceId, target) => {
72+
calls.push({ instanceId, target })
73+
currentVersion = target
74+
return { success: true, version: target }
75+
},
76+
}))
77+
78+
assert.deepEqual(await service.upgrade(), { success: true, version: "1.1.0" })
79+
assert.deepEqual(calls, [{ instanceId: "workspace-1", target: "1.1.0" }])
80+
})
81+
82+
test("rejects success when the configured binary was not updated", async () => {
83+
const service = new OpenCodeUpdateService(createDeps({
84+
probeBinary: () => ({ valid: true, version: "1.0.0" }),
85+
upgradeInstance: async (_instanceId, target) => ({ success: true, version: target }),
86+
}))
87+
88+
await assert.rejects(
89+
() => service.upgrade(),
90+
(error: unknown) => error instanceof OpenCodeUpdateError && error.code === "upgrade_verification_failed",
91+
)
92+
})
93+
94+
test("joins concurrent upgrades for the same binary", async () => {
95+
let currentVersion = "1.0.0"
96+
let upgrades = 0
97+
let finishUpgrade: (() => void) | undefined
98+
const gate = new Promise<void>((resolve) => {
99+
finishUpgrade = resolve
100+
})
101+
const service = new OpenCodeUpdateService(createDeps({
102+
probeBinary: () => ({ valid: true, version: currentVersion }),
103+
upgradeInstance: async (_instanceId, target) => {
104+
upgrades += 1
105+
await gate
106+
currentVersion = target
107+
return { success: true, version: target }
108+
},
109+
}))
110+
111+
const first = service.upgrade()
112+
const second = service.upgrade()
113+
finishUpgrade?.()
114+
115+
assert.deepEqual(await Promise.all([first, second]), [
116+
{ success: true, version: "1.1.0" },
117+
{ success: true, version: "1.1.0" },
118+
])
119+
assert.equal(upgrades, 1)
120+
})
121+
122+
test("rejects an upgrade when no matching OpenCode instance is running", async () => {
123+
const service = new OpenCodeUpdateService(createDeps({ findReadyInstanceId: () => undefined }))
124+
125+
await assert.rejects(
126+
() => service.upgrade(),
127+
(error: unknown) => error instanceof OpenCodeUpdateError && error.code === "no_ready_instance",
128+
)
129+
})
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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+
}

packages/server/src/server/http-server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes"
2525
import { registerYoloRoutes } from "./routes/yolo"
2626
import { registerWorktreeRoutes } from "./routes/worktrees"
2727
import { registerSpeechRoutes } from "./routes/speech"
28+
import { registerOpenCodeUpdateRoutes } from "./routes/opencode-update"
2829
import { registerRemoteServerRoutes } from "./routes/remote-servers"
2930
import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
3031
import { registerSideCarRoutes } from "./routes/sidecars"
@@ -45,6 +46,7 @@ import { VoiceModeManager } from "../plugins/voice-mode"
4546
import type { SideCarManager } from "../sidecars/manager"
4647
import type { PreviewManager } from "../previews/manager"
4748
import type { RemoteProxySessionManager } from "./remote-proxy"
49+
import { createOpenCodeUpdateService } from "../opencode-update/service"
4850

4951
interface HttpServerDeps {
5052
bindHost: string
@@ -283,6 +285,10 @@ export function createHttpServer(deps: HttpServerDeps) {
283285

284286
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
285287
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
288+
registerOpenCodeUpdateRoutes(app, {
289+
service: createOpenCodeUpdateService(deps.settings, deps.workspaceManager),
290+
logger: apiLogger,
291+
})
286292
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
287293
registerConfigFileRoutes(app)
288294
registerMetaRoutes(app, { serverMeta: deps.serverMeta })

0 commit comments

Comments
 (0)