mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-21 06:13:26 +00:00
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:
parent
ae6a2a8b2e
commit
c4bc08ee03
4 changed files with 214 additions and 35 deletions
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue