fix: prevent bundled OpenCode self-upgrades (#2525)
* fix: prevent bundled OpenCode self-upgrades * feat(vscode): support OpenCode upgrades * fix: refresh OpenCode update status on runtime switch --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
c0405d3fa4
commit
c88dd16d2a
@@ -7,6 +7,7 @@ import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
|
||||
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
|
||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { useRouter } from '@/hooks/useRouter';
|
||||
@@ -107,6 +108,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
|
||||
<AgentManagerView />
|
||||
<OpenCodeUpdateToast />
|
||||
<Toaster position="top-center" />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
@@ -125,6 +127,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
|
||||
<VSCodeLayout />
|
||||
<OpenCodeUpdateToast />
|
||||
<Toaster position="top-center" />
|
||||
<ConfigUpdateOverlay />
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group/toast toast !rounded-[var(--radius-xl)] !border-0 !px-3.5 !py-3 !gap-2.5 !text-foreground",
|
||||
"group/toast toast !rounded-[var(--radius-xl)] !border-0 !px-3.5 !py-3 !gap-2.5 !text-foreground [&_[data-cancel]+[data-button]]:!ml-2",
|
||||
title: "typography-ui-label !font-medium !text-foreground",
|
||||
description: "typography-meta !text-muted-foreground !mt-0.5",
|
||||
actionButton:
|
||||
|
||||
@@ -5,6 +5,7 @@ import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import {
|
||||
@@ -127,39 +128,50 @@ export const OpenCodeUpdateToast: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const onUpdateAvailable = (event: Event) => {
|
||||
const version = resolveOpenCodeUpdateVersion((event as CustomEvent<unknown>).detail);
|
||||
showUpdateAvailableToast(version);
|
||||
};
|
||||
|
||||
let cancelled = false;
|
||||
const timeoutIds: Array<ReturnType<typeof setTimeout>> = [];
|
||||
|
||||
const checkForUpdate = async (attempt: number) => {
|
||||
const checkForUpdate = async (attempt: number, runtimeKey = getRuntimeKey()) => {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/opencode/upgrade-status', { headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) throw new Error(response.statusText || 'OpenCode upgrade status check failed');
|
||||
const status = await response.json().catch(() => null) as OpenCodeUpgradeStatusLike | null;
|
||||
const version = resolveOpenCodeUpgradeStatusVersion(status);
|
||||
if (!cancelled && version) {
|
||||
if (!cancelled && runtimeKey === getRuntimeKey() && version) {
|
||||
showUpdateAvailableToast(version);
|
||||
}
|
||||
} catch {
|
||||
const delay = CHECK_RETRY_DELAYS_MS[attempt];
|
||||
if (!cancelled && delay !== undefined) {
|
||||
timeoutIds.push(setTimeout(() => { void checkForUpdate(attempt + 1); }, delay));
|
||||
if (!cancelled && runtimeKey === getRuntimeKey() && delay !== undefined) {
|
||||
timeoutIds.push(setTimeout(() => { void checkForUpdate(attempt + 1, runtimeKey); }, delay));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onUpdateAvailable = (event: Event) => {
|
||||
const version = resolveOpenCodeUpdateVersion((event as CustomEvent<unknown>).detail);
|
||||
if (version) {
|
||||
void checkForUpdate(0);
|
||||
}
|
||||
};
|
||||
|
||||
if (showOpenCodeUpdateNotifications) {
|
||||
timeoutIds.push(setTimeout(() => { void checkForUpdate(0); }, INITIAL_CHECK_DELAY_MS));
|
||||
}
|
||||
|
||||
const unsubscribeRuntime = subscribeRuntimeEndpointChanged(({ runtimeKey }) => {
|
||||
seenVersionsRef.current.clear();
|
||||
toast.dismiss(UPDATE_TOAST_ID);
|
||||
if (useUIStore.getState().showOpenCodeUpdateNotifications) {
|
||||
void checkForUpdate(0, runtimeKey);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('openchamber:opencode-update-available', onUpdateAvailable);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
for (const timeoutId of timeoutIds) clearTimeout(timeoutId);
|
||||
unsubscribeRuntime();
|
||||
window.removeEventListener('openchamber:opencode-update-available', onUpdateAvailable);
|
||||
};
|
||||
}, [runUpgrade, showOpenCodeUpdateNotifications, t]);
|
||||
|
||||
@@ -181,6 +181,7 @@ describe('resolveOpenCodeUpgradeStatusVersion', () => {
|
||||
resolveOpenCodeUpgradeStatusVersion({
|
||||
available: true,
|
||||
latestVersion: '1.16.0',
|
||||
upgrade: { supported: true },
|
||||
}),
|
||||
).toBe('1.16.0');
|
||||
});
|
||||
@@ -190,6 +191,7 @@ describe('resolveOpenCodeUpgradeStatusVersion', () => {
|
||||
resolveOpenCodeUpgradeStatusVersion({
|
||||
available: true,
|
||||
latestVersion: ' 1.16.0 ',
|
||||
upgrade: { supported: true },
|
||||
}),
|
||||
).toBe('1.16.0');
|
||||
});
|
||||
@@ -211,6 +213,22 @@ describe('resolveOpenCodeUpgradeStatusVersion', () => {
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
test('fails closed when the server does not explicitly support upgrades', () => {
|
||||
expect(
|
||||
resolveOpenCodeUpgradeStatusVersion({
|
||||
available: true,
|
||||
latestVersion: '1.16.0',
|
||||
}),
|
||||
).toBe('');
|
||||
expect(
|
||||
resolveOpenCodeUpgradeStatusVersion({
|
||||
available: true,
|
||||
latestVersion: '1.16.0',
|
||||
upgrade: { supported: false },
|
||||
}),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
test('returns empty string when available is missing or null', () => {
|
||||
expect(
|
||||
resolveOpenCodeUpgradeStatusVersion({
|
||||
|
||||
@@ -79,6 +79,9 @@ export const resolveOpenCodeUpdateVersion = (detail: unknown): string => {
|
||||
export interface OpenCodeUpgradeStatusLike {
|
||||
readonly available?: boolean | null;
|
||||
readonly latestVersion?: string | null;
|
||||
readonly upgrade?: {
|
||||
readonly supported?: boolean | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,6 +93,7 @@ export const resolveOpenCodeUpgradeStatusVersion = (
|
||||
status: OpenCodeUpgradeStatusLike | null | undefined,
|
||||
): string => {
|
||||
if (!status) return '';
|
||||
if (status.upgrade?.supported !== true) return '';
|
||||
if (status.available !== true) return '';
|
||||
if (typeof status.latestVersion !== 'string') return '';
|
||||
return status.latestVersion.trim();
|
||||
|
||||
@@ -64,7 +64,6 @@ import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
|
||||
import { listGlobalSessionPages } from "@/stores/globalSessions"
|
||||
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
|
||||
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
|
||||
import { runtimeFetch } from "@/lib/runtime-fetch"
|
||||
import {
|
||||
EMPTY_SESSION_MESSAGE_LOAD_STATE,
|
||||
SessionMessageLoader,
|
||||
@@ -1741,40 +1740,6 @@ const dispatchOpenCodeUpdateAvailable = (payload: { version: string }) => {
|
||||
window.dispatchEvent(new CustomEvent("openchamber:opencode-update-available", { detail: payload }))
|
||||
}
|
||||
|
||||
let bundledOpenCodeRuntimeCache: { runtimeKey: string; promise: Promise<boolean> } | null = null
|
||||
|
||||
const isBundledOpenCodeRuntime = async () => {
|
||||
const runtimeKey = getRuntimeKey()
|
||||
if (!bundledOpenCodeRuntimeCache || bundledOpenCodeRuntimeCache.runtimeKey !== runtimeKey) {
|
||||
bundledOpenCodeRuntimeCache = {
|
||||
runtimeKey,
|
||||
promise: runtimeFetch("/api/config/opencode-resolution", { signal: AbortSignal.timeout(4000) })
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
const resolution = await response.json() as { source?: unknown; detectedSourceNow?: unknown }
|
||||
return resolution.source === "bundled" || resolution.detectedSourceNow === "bundled"
|
||||
}
|
||||
|
||||
const healthResponse = await runtimeFetch("/health", { signal: AbortSignal.timeout(4000) })
|
||||
if (!healthResponse.ok) return false
|
||||
const health = await healthResponse.json() as { opencodeBinarySource?: unknown }
|
||||
return health.opencodeBinarySource === "bundled"
|
||||
})
|
||||
.catch(() => false),
|
||||
}
|
||||
}
|
||||
return bundledOpenCodeRuntimeCache.promise
|
||||
}
|
||||
|
||||
const dispatchOpenCodeUpdateAvailableUnlessBundled = (payload: { version: string }) => {
|
||||
if (typeof window === "undefined") return
|
||||
void isBundledOpenCodeRuntime().then((isBundled) => {
|
||||
if (!isBundled) {
|
||||
dispatchOpenCodeUpdateAvailable(payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function SyncProvider(props: {
|
||||
sdk: OpencodeClient
|
||||
directory: string
|
||||
@@ -2017,7 +1982,7 @@ export function SyncProvider(props: {
|
||||
? (payload.properties as { version: string }).version
|
||||
: ""
|
||||
if (version) {
|
||||
dispatchOpenCodeUpdateAvailableUnlessBundled({ version })
|
||||
dispatchOpenCodeUpdateAvailable({ version })
|
||||
}
|
||||
}
|
||||
handleEvent(directory, payload, childStores, routingIndex, runtimeKey, false, currentDirectoryRef.current, batch)
|
||||
|
||||
@@ -59,6 +59,10 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r
|
||||
- System/editor/provider/quota/notification/update-check message handlers.
|
||||
- Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`).
|
||||
- Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`).
|
||||
- Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade.
|
||||
|
||||
- `opencode-upgrade-runtime.ts`
|
||||
- Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior.
|
||||
|
||||
- `bridge-permission-auto-accept-runtime.ts`
|
||||
- Owns the persisted VS Code permission auto-accept policy and its GET/PUT bridge contract.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProv
|
||||
import { fetchOpenCodeGoUsage } from './opencodeGoQuota';
|
||||
import { credentialStatus, deleteCredential, importCursorCredential, normalizeCredential, readCredential, validateCredential, writeCredential, type ManagedProvider } from './quotaCredentials';
|
||||
import { getSessionActivitySnapshot } from './sessionActivityWatcher';
|
||||
import { getOpenCodeUpgradeStatus, upgradeManagedOpenCode } from './opencode-upgrade-runtime';
|
||||
import type { BridgeContext, BridgeResponse } from './bridge';
|
||||
|
||||
type BridgeMessageInput = {
|
||||
@@ -269,6 +270,15 @@ export async function handleSystemBridgeMessage(
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:opencode/upgrade-status': {
|
||||
return { id, type, success: true, data: await getOpenCodeUpgradeStatus(ctx?.manager) };
|
||||
}
|
||||
|
||||
case 'api:opencode/upgrade': {
|
||||
const target = (payload as { target?: unknown } | undefined)?.target;
|
||||
return { id, type, success: true, data: await upgradeManagedOpenCode(ctx?.manager, target) };
|
||||
}
|
||||
|
||||
case 'api:session-activity:get': {
|
||||
return { id, type, success: true, data: getSessionActivitySnapshot() };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { getOpenCodeUpgradeStatus, upgradeManagedOpenCode, type OpenCodeUpgradeManager } from './opencode-upgrade-runtime';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const createManager = (mode: 'managed' | 'external' = 'managed') => {
|
||||
let restartCount = 0;
|
||||
const manager: OpenCodeUpgradeManager = {
|
||||
getApiUrl: () => 'http://127.0.0.1:4096',
|
||||
getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic test' }),
|
||||
getDebugInfo: () => ({ mode }),
|
||||
restart: async () => { restartCount += 1; },
|
||||
};
|
||||
return { manager, getRestartCount: () => restartCount };
|
||||
};
|
||||
|
||||
describe('VS Code OpenCode upgrades', () => {
|
||||
test('reports an available update for a managed OpenCode process', async () => {
|
||||
const { manager } = createManager();
|
||||
globalThis.fetch = (async (input: Parameters<typeof fetch>[0]) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/global/health')) return new Response(JSON.stringify({ version: '1.18.8' }));
|
||||
if (url.includes('registry.npmjs.org')) return new Response(JSON.stringify({ version: '1.18.9' }));
|
||||
return new Response(JSON.stringify({ tag_name: 'v1.18.9' }));
|
||||
}) as typeof fetch;
|
||||
|
||||
assert.deepEqual(await getOpenCodeUpgradeStatus(manager), {
|
||||
available: true,
|
||||
currentVersion: '1.18.8',
|
||||
latestVersion: '1.18.9',
|
||||
upgrade: { supported: true, manager: 'opencode', reason: null },
|
||||
});
|
||||
});
|
||||
|
||||
test('fails closed for externally managed OpenCode without contacting the updater', async () => {
|
||||
const { manager } = createManager('external');
|
||||
let fetchCount = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
fetchCount += 1;
|
||||
return new Response('{}');
|
||||
}) as typeof fetch;
|
||||
|
||||
assert.deepEqual(await upgradeManagedOpenCode(manager), {
|
||||
status: 409,
|
||||
body: {
|
||||
success: false,
|
||||
code: 'OPENCODE_UPGRADE_UNSUPPORTED',
|
||||
error: 'This OpenCode runtime cannot be upgraded by OpenChamber.',
|
||||
},
|
||||
});
|
||||
assert.equal(fetchCount, 0);
|
||||
});
|
||||
|
||||
test('upgrades then restarts the extension-owned OpenCode process', async () => {
|
||||
const { manager, getRestartCount } = createManager();
|
||||
let request: RequestInit | undefined;
|
||||
globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
assert.equal(String(input), 'http://127.0.0.1:4096/global/upgrade');
|
||||
request = init;
|
||||
return new Response(JSON.stringify({ success: true, version: '1.18.9' }));
|
||||
}) as typeof fetch;
|
||||
|
||||
assert.deepEqual(await upgradeManagedOpenCode(manager, '1.18.9'), {
|
||||
status: 200,
|
||||
body: { success: true, version: '1.18.9', restarted: true },
|
||||
});
|
||||
assert.equal(getRestartCount(), 1);
|
||||
assert.equal(request?.method, 'POST');
|
||||
assert.deepEqual(JSON.parse(String(request?.body)), { target: '1.18.9' });
|
||||
assert.equal((request?.headers as Record<string, string>).Authorization, 'Basic test');
|
||||
});
|
||||
|
||||
test('serializes concurrent managed upgrades', async () => {
|
||||
const { manager } = createManager();
|
||||
let release: (response: Response) => void = () => {};
|
||||
globalThis.fetch = (() => new Promise<Response>((resolve) => { release = resolve; })) as typeof fetch;
|
||||
|
||||
const first = upgradeManagedOpenCode(manager);
|
||||
const second = await upgradeManagedOpenCode(manager);
|
||||
assert.equal(second.status, 409);
|
||||
assert.equal(second.body.code, 'OPENCODE_UPGRADE_IN_PROGRESS');
|
||||
|
||||
release(new Response(JSON.stringify({ success: true })));
|
||||
assert.equal((await first).status, 200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
type UpgradeCapability = {
|
||||
supported: boolean;
|
||||
manager: 'opencode' | 'external' | null;
|
||||
reason: 'external' | 'unavailable' | null;
|
||||
};
|
||||
|
||||
export type OpenCodeUpgradeManager = {
|
||||
getApiUrl(): string | null;
|
||||
getOpenCodeAuthHeaders(): Record<string, string>;
|
||||
getDebugInfo(): { mode: 'managed' | 'external' };
|
||||
restart(): Promise<void>;
|
||||
};
|
||||
|
||||
type UpgradeResult = { status: number; body: Record<string, unknown> };
|
||||
|
||||
let openCodeUpgradePromise: Promise<UpgradeResult> | null = null;
|
||||
|
||||
const parseVersion = (value: unknown): { parts: number[]; prerelease: boolean } => {
|
||||
const normalized = String(value || '').replace(/^v/, '').split('+')[0];
|
||||
const prereleaseIndex = normalized.indexOf('-');
|
||||
const core = prereleaseIndex >= 0 ? normalized.slice(0, prereleaseIndex) : normalized;
|
||||
return {
|
||||
parts: core.split('.').map((part) => {
|
||||
const parsed = Number.parseInt(part || '0', 10);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}),
|
||||
prerelease: prereleaseIndex >= 0,
|
||||
};
|
||||
};
|
||||
|
||||
const compareVersions = (left: unknown, right: unknown): number => {
|
||||
const a = parseVersion(left);
|
||||
const b = parseVersion(right);
|
||||
for (let index = 0; index < Math.max(a.parts.length, b.parts.length); index += 1) {
|
||||
const difference = (a.parts[index] || 0) - (b.parts[index] || 0);
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
return a.prerelease === b.prerelease ? 0 : (a.prerelease ? -1 : 1);
|
||||
};
|
||||
|
||||
const getCapability = (manager?: OpenCodeUpgradeManager): UpgradeCapability => {
|
||||
if (!manager) return { supported: false, manager: null, reason: 'unavailable' };
|
||||
if (manager.getDebugInfo().mode !== 'managed') return { supported: false, manager: 'external', reason: 'external' };
|
||||
if (!manager.getApiUrl()) return { supported: false, manager: null, reason: 'unavailable' };
|
||||
return { supported: true, manager: 'opencode', reason: null };
|
||||
};
|
||||
|
||||
const getApiUrl = (manager?: OpenCodeUpgradeManager): string | null => {
|
||||
const apiUrl = manager?.getApiUrl();
|
||||
return apiUrl ? `${apiUrl.replace(/\/+$/, '')}/` : null;
|
||||
};
|
||||
|
||||
const fetchLatestVersion = async (): Promise<string> => {
|
||||
const results = await Promise.allSettled([
|
||||
fetch('https://registry.npmjs.org/opencode-ai/latest', { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`OpenCode npm registry responded with ${response.status}`);
|
||||
const payload = await response.json() as { version?: unknown };
|
||||
return typeof payload.version === 'string' ? payload.version.trim().replace(/^v/, '') : '';
|
||||
}),
|
||||
fetch('https://api.github.com/repos/anomalyco/opencode/releases/latest', { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`OpenCode releases responded with ${response.status}`);
|
||||
const payload = await response.json() as { tag_name?: unknown };
|
||||
return typeof payload.tag_name === 'string' ? payload.tag_name.trim().replace(/^v/, '') : '';
|
||||
}),
|
||||
]);
|
||||
const versions = results.flatMap((result) => result.status === 'fulfilled' && result.value ? [result.value] : []);
|
||||
if (versions.length === 0) throw new Error('Failed to resolve latest OpenCode version');
|
||||
return versions.sort((left, right) => compareVersions(right, left))[0];
|
||||
};
|
||||
|
||||
export const getOpenCodeUpgradeStatus = async (manager?: OpenCodeUpgradeManager): Promise<Record<string, unknown>> => {
|
||||
const upgrade = getCapability(manager);
|
||||
const apiUrl = getApiUrl(manager);
|
||||
if (!upgrade.supported || !apiUrl || !manager) return { available: false, currentVersion: null, latestVersion: null, upgrade };
|
||||
try {
|
||||
const [healthResponse, latestVersion] = await Promise.all([
|
||||
fetch(new URL('global/health', apiUrl).toString(), { method: 'GET', headers: { Accept: 'application/json', ...manager.getOpenCodeAuthHeaders() } }),
|
||||
fetchLatestVersion(),
|
||||
]);
|
||||
const health = await healthResponse.json().catch(() => null) as { version?: unknown; error?: unknown } | null;
|
||||
if (!healthResponse.ok) {
|
||||
const error = typeof health?.error === 'string' ? health.error : healthResponse.statusText || 'Failed to read OpenCode version';
|
||||
return { available: null, error, upgrade };
|
||||
}
|
||||
const currentVersion = typeof health?.version === 'string' && health.version.trim() ? health.version.trim().replace(/^v/, '') : null;
|
||||
return { available: currentVersion ? compareVersions(latestVersion, currentVersion) > 0 : null, currentVersion, latestVersion, upgrade };
|
||||
} catch (error) {
|
||||
return { available: null, error: error instanceof Error ? error.message : String(error), upgrade };
|
||||
}
|
||||
};
|
||||
|
||||
export const upgradeManagedOpenCode = async (manager: OpenCodeUpgradeManager | undefined, target?: unknown): Promise<UpgradeResult> => {
|
||||
const upgrade = getCapability(manager);
|
||||
const apiUrl = getApiUrl(manager);
|
||||
if (!upgrade.supported || !apiUrl || !manager) {
|
||||
return { status: 409, body: { success: false, code: 'OPENCODE_UPGRADE_UNSUPPORTED', error: 'This OpenCode runtime cannot be upgraded by OpenChamber.' } };
|
||||
}
|
||||
if (openCodeUpgradePromise) {
|
||||
return { status: 409, body: { success: false, code: 'OPENCODE_UPGRADE_IN_PROGRESS', error: 'An OpenCode upgrade is already in progress.' } };
|
||||
}
|
||||
const targetVersion = typeof target === 'string' ? target.trim() : '';
|
||||
const operation = (async (): Promise<UpgradeResult> => {
|
||||
try {
|
||||
const response = await fetch(new URL('global/upgrade', apiUrl).toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...manager.getOpenCodeAuthHeaders() },
|
||||
body: JSON.stringify(targetVersion ? { target: targetVersion } : {}),
|
||||
});
|
||||
const payload = await response.json().catch(() => null) as { error?: unknown } | null;
|
||||
if (!response.ok) return { status: response.status, body: { success: false, error: typeof payload?.error === 'string' ? payload.error : response.statusText || 'Failed to upgrade OpenCode' } };
|
||||
try {
|
||||
await manager.restart();
|
||||
} catch (error) {
|
||||
return { status: 500, body: { success: false, upgraded: true, error: error instanceof Error ? `OpenCode upgraded, but restart failed: ${error.message}` : 'OpenCode upgraded, but restart failed' } };
|
||||
}
|
||||
return { status: 200, body: { ...(payload && typeof payload === 'object' ? payload : { success: true }), restarted: true } };
|
||||
} catch (error) {
|
||||
return { status: 500, body: { success: false, error: error instanceof Error ? error.message : 'Failed to upgrade OpenCode' } };
|
||||
}
|
||||
})();
|
||||
openCodeUpgradePromise = operation;
|
||||
try {
|
||||
return await operation;
|
||||
} finally {
|
||||
if (openCodeUpgradePromise === operation) openCodeUpgradePromise = null;
|
||||
}
|
||||
};
|
||||
@@ -993,6 +993,17 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname === '/api/opencode/upgrade-status' && method === 'GET') {
|
||||
const data = await sendBridgeMessage('api:opencode/upgrade-status');
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
if (pathname === '/api/opencode/upgrade' && method === 'POST') {
|
||||
const body = await extractJsonBody(input, init, method);
|
||||
const result = await sendBridgeMessage<{ status: number; body: unknown }>('api:opencode/upgrade', body);
|
||||
return jsonResponse(result.body, result.status);
|
||||
}
|
||||
|
||||
if (pathname === '/api/zen/models' && method === 'GET') {
|
||||
try {
|
||||
const data = await sendBridgeMessage('api:zen:models');
|
||||
|
||||
@@ -69,6 +69,7 @@ import { createServerUtilsRuntime } from './lib/opencode/server-utils-runtime.js
|
||||
import { createStaticRoutesRuntime } from './lib/opencode/static-routes-runtime.js';
|
||||
import { createSettingsRuntime } from './lib/opencode/settings-runtime.js';
|
||||
import { createOpenCodeResolutionRuntime } from './lib/opencode/opencode-resolution-runtime.js';
|
||||
import { resolveOpenCodeUpgradeCapability } from './lib/opencode/upgrade-capability.js';
|
||||
import { createBootstrapRuntime } from './lib/opencode/bootstrap-runtime.js';
|
||||
import { createSessionRuntime } from './lib/opencode/session-runtime.js';
|
||||
import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js';
|
||||
@@ -666,6 +667,7 @@ const getLoginShellEnvSnapshot = (...args) => openCodeEnvRuntime.getLoginShellEn
|
||||
const ensureOpencodeCliEnv = (...args) => openCodeEnvRuntime.ensureOpencodeCliEnv(...args);
|
||||
const applyOpencodeBinaryFromSettings = (...args) => openCodeEnvRuntime.applyOpencodeBinaryFromSettings(...args);
|
||||
const resolveOpencodeCliPath = (...args) => openCodeEnvRuntime.resolveOpencodeCliPath(...args);
|
||||
const isBundledOpenCodeCliPath = (...args) => openCodeEnvRuntime.isBundledOpenCodeCliPath(...args);
|
||||
const isExecutable = (...args) => openCodeEnvRuntime.isExecutable(...args);
|
||||
const searchPathFor = (...args) => openCodeEnvRuntime.searchPathFor(...args);
|
||||
const resolveGitBinaryForSpawn = (...args) => openCodeEnvRuntime.resolveGitBinaryForSpawn(...args);
|
||||
@@ -1070,6 +1072,18 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
|
||||
},
|
||||
});
|
||||
|
||||
const getOpenCodeUpgradeCapability = () => {
|
||||
const activeBinary = lastOpenCodeLaunchDiagnostics?.sourceBinary
|
||||
|| lastOpenCodeLaunchDiagnostics?.binary
|
||||
|| resolvedOpencodeBinary;
|
||||
return resolveOpenCodeUpgradeCapability({
|
||||
isExternal: isExternalOpenCode,
|
||||
hasManagedProcess: Boolean(openCodeProcess),
|
||||
activeBinary,
|
||||
isBundledBinary: isBundledOpenCodeCliPath,
|
||||
});
|
||||
};
|
||||
|
||||
const restartOpenCode = (...args) => openCodeLifecycleRuntime.restartOpenCode(...args);
|
||||
const waitForOpenCodeReady = (...args) => openCodeLifecycleRuntime.waitForOpenCodeReady(...args);
|
||||
const waitForAgentPresence = (...args) => openCodeLifecycleRuntime.waitForAgentPresence(...args);
|
||||
@@ -1600,6 +1614,7 @@ async function main(options = {}) {
|
||||
readCustomThemesFromDisk,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
getOpenCodeResolutionSnapshot,
|
||||
getOpenCodeUpgradeCapability,
|
||||
formatSettingsResponse,
|
||||
readSettingsFromDisk,
|
||||
readSettingsFromDiskMigrated,
|
||||
|
||||
@@ -26,6 +26,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `packages/web/server/lib/opencode/static-routes-runtime.js`: static asset/SPA fallback route registration and manifest route wiring.
|
||||
- `packages/web/server/lib/opencode/feature-routes-runtime.js`: feature route composition runtime for dynamic import-backed config/skill/provider route registration.
|
||||
- `packages/web/server/lib/opencode/opencode-resolution-runtime.js`: OpenCode binary resolution snapshot runtime for settings routes and diagnostics.
|
||||
- `packages/web/server/lib/opencode/upgrade-capability.js`: authoritative upgrade ownership policy for the active OpenCode runtime. Bundled, external, and unresolved runtimes fail closed; only managed non-bundled runtimes delegate upgrades to OpenCode.
|
||||
- `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: tunnel service/routes composition runtime and active-port wiring for main server startup.
|
||||
- `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: server startup tail orchestration runtime for terminal/proxy/static/start-listen flow.
|
||||
- `packages/web/server/lib/agent-tool/runtime.js`: managed OpenCode custom-tool materialization, environment injection, loopback authentication, and fixed CLI action dispatch.
|
||||
@@ -76,8 +77,8 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `GET /api/config/settings`
|
||||
- `PUT /api/config/settings`
|
||||
- `GET /api/config/opencode-resolution`
|
||||
- `POST /api/opencode/upgrade` (proxies OpenCode upgrade, then restarts managed OpenCode so the new binary is active)
|
||||
- `GET /api/opencode/upgrade-status`
|
||||
- `POST /api/opencode/upgrade` (enforces the active runtime's upgrade capability, serializes supported OpenCode upgrades, then restarts managed OpenCode so the new binary is active)
|
||||
- `GET /api/opencode/upgrade-status` (returns version availability plus the authoritative `upgrade.supported`, `upgrade.manager`, and `upgrade.reason` capability)
|
||||
- `POST /api/opencode/directory`
|
||||
- `GET /api/provider/:providerId/source`
|
||||
- `DELETE /api/provider/:providerId/auth`
|
||||
|
||||
@@ -301,6 +301,23 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const canonicalExecutablePath = (candidate) => {
|
||||
if (typeof candidate !== 'string' || !candidate.trim()) return null;
|
||||
try {
|
||||
return fs.realpathSync.native(candidate.trim());
|
||||
} catch {
|
||||
return path.resolve(candidate.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const isBundledOpenCodeCliPath = (candidate) => {
|
||||
const canonicalCandidate = canonicalExecutablePath(candidate);
|
||||
if (!canonicalCandidate) return false;
|
||||
return bundledOpenCodeCliCandidates().some((bundledCandidate) => (
|
||||
canonicalExecutablePath(bundledCandidate) === canonicalCandidate
|
||||
));
|
||||
};
|
||||
|
||||
const bundledOpenCodeCliFallback = () => {
|
||||
const bundled = resolveBundledOpenCodeCliPath();
|
||||
if (!bundled) return null;
|
||||
@@ -1164,6 +1181,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
|
||||
applyOpencodeBinaryFromSettings,
|
||||
getLoginShellEnvSnapshot,
|
||||
resolveOpencodeCliPath,
|
||||
isBundledOpenCodeCliPath,
|
||||
resolveManagedOpenCodeLaunchSpec,
|
||||
isExecutable,
|
||||
searchPathFor,
|
||||
|
||||
@@ -183,6 +183,18 @@ describe('OpenCode env runtime', () => {
|
||||
expect(state.resolvedOpencodeBinarySource).toBe('bundled');
|
||||
});
|
||||
|
||||
it('recognizes the bundled CLI by canonical path', () => {
|
||||
const bundledDir = createTempDir('openchamber-bundled-opencode-');
|
||||
const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
|
||||
fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n');
|
||||
if (process.platform !== 'win32') fs.chmodSync(bundledBinary, 0o755);
|
||||
process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = bundledDir;
|
||||
const { runtime } = createRuntime({});
|
||||
|
||||
expect(runtime.isBundledOpenCodeCliPath(bundledBinary)).toBe(true);
|
||||
expect(runtime.isBundledOpenCodeCliPath(path.join(bundledDir, 'other'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps explicit OpenCode binary ahead of bundled CLI', () => {
|
||||
const bundledDir = createTempDir('openchamber-bundled-opencode-');
|
||||
const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
|
||||
|
||||
@@ -86,6 +86,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
readCustomThemesFromDisk,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
getOpenCodeResolutionSnapshot,
|
||||
getOpenCodeUpgradeCapability,
|
||||
formatSettingsResponse,
|
||||
readSettingsFromDisk,
|
||||
readSettingsFromDiskMigrated,
|
||||
@@ -121,6 +122,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
crypto,
|
||||
clientReloadDelayMs,
|
||||
getOpenCodeResolutionSnapshot,
|
||||
getOpenCodeUpgradeCapability,
|
||||
formatSettingsResponse,
|
||||
readSettingsFromDisk,
|
||||
readSettingsFromDiskMigrated,
|
||||
|
||||
@@ -241,6 +241,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
|
||||
const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv, shellEnvKeysCount = 0 }) => {
|
||||
let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
|
||||
const sourceBinary = binary;
|
||||
let args = ['serve', '--hostname', hostname, '--port', String(port)];
|
||||
let launchWrapperType = null;
|
||||
|
||||
@@ -264,6 +265,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
const pathEntryCount = pathValue ? pathValue.split(process.platform === 'win32' ? ';' : ':').filter(Boolean).length : 0;
|
||||
state.lastOpenCodeLaunchDiagnostics = {
|
||||
launchedAt: new Date().toISOString(),
|
||||
sourceBinary,
|
||||
binary,
|
||||
args,
|
||||
cwd,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { registerOpenCodeRoutes } from './routes.js';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const createApp = (overrides = {}) => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const dependencies = {
|
||||
getOpenCodeUpgradeCapability: () => ({
|
||||
supported: false,
|
||||
manager: 'openchamber',
|
||||
reason: 'bundled',
|
||||
}),
|
||||
buildOpenCodeUrl: (pathname) => `http://127.0.0.1:4096${pathname}`,
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
refreshOpenCodeAfterConfigChange: vi.fn(async () => {}),
|
||||
...overrides,
|
||||
};
|
||||
registerOpenCodeRoutes(app, dependencies);
|
||||
return { app, dependencies };
|
||||
};
|
||||
|
||||
describe('OpenCode upgrade routes', () => {
|
||||
it('fails closed without contacting the bundled OpenCode updater', async () => {
|
||||
globalThis.fetch = vi.fn();
|
||||
const { app } = createApp();
|
||||
|
||||
await request(app)
|
||||
.post('/api/opencode/upgrade')
|
||||
.send({})
|
||||
.expect(409, {
|
||||
success: false,
|
||||
code: 'OPENCODE_UPGRADE_MANAGED_BY_OPENCHAMBER',
|
||||
error: 'OpenCode is bundled with OpenChamber Desktop and updates with the app.',
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports bundled update ownership through the capability contract', async () => {
|
||||
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ healthy: true, version: '1.18.8' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
const { app } = createApp();
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/opencode/upgrade-status')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
available: false,
|
||||
currentVersion: '1.18.8',
|
||||
latestVersion: null,
|
||||
upgrade: {
|
||||
supported: false,
|
||||
manager: 'openchamber',
|
||||
reason: 'bundled',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes supported upgrades and preserves the in-flight lock', async () => {
|
||||
let releaseUpgrade;
|
||||
const upstreamResponse = new Promise((resolve) => {
|
||||
releaseUpgrade = () => resolve(new Response(JSON.stringify({ success: true, version: '1.18.9' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
});
|
||||
globalThis.fetch = vi.fn(() => upstreamResponse);
|
||||
const { app, dependencies } = createApp({
|
||||
getOpenCodeUpgradeCapability: () => ({
|
||||
supported: true,
|
||||
manager: 'opencode',
|
||||
reason: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const first = request(app)
|
||||
.post('/api/opencode/upgrade')
|
||||
.send({})
|
||||
.expect(200, {
|
||||
success: true,
|
||||
version: '1.18.9',
|
||||
restarted: true,
|
||||
})
|
||||
.then((response) => response);
|
||||
await vi.waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post('/api/opencode/upgrade')
|
||||
.send({})
|
||||
.expect(409, {
|
||||
success: false,
|
||||
code: 'OPENCODE_UPGRADE_IN_PROGRESS',
|
||||
error: 'An OpenCode upgrade is already in progress.',
|
||||
});
|
||||
|
||||
releaseUpgrade();
|
||||
await first;
|
||||
expect(dependencies.refreshOpenCodeAfterConfigChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
crypto,
|
||||
clientReloadDelayMs,
|
||||
getOpenCodeResolutionSnapshot,
|
||||
getOpenCodeUpgradeCapability,
|
||||
formatSettingsResponse,
|
||||
readSettingsFromDisk,
|
||||
readSettingsFromDiskMigrated,
|
||||
@@ -41,12 +42,6 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
return trimmed || null;
|
||||
};
|
||||
|
||||
const isBundledOpenCodeBinaryActive = async () => {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const resolution = await getOpenCodeResolutionSnapshot(settings);
|
||||
return resolution?.source === 'bundled' || resolution?.detectedSourceNow === 'bundled';
|
||||
};
|
||||
|
||||
const readOpenCodeCurrentVersion = async () => {
|
||||
const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
|
||||
method: 'GET',
|
||||
@@ -153,48 +148,84 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
}
|
||||
});
|
||||
|
||||
let openCodeUpgradePromise = null;
|
||||
|
||||
app.post('/api/opencode/upgrade', async (req, res) => {
|
||||
try {
|
||||
if (await isBundledOpenCodeBinaryActive()) {
|
||||
const capability = getOpenCodeUpgradeCapability();
|
||||
if (!capability.supported) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: 'OpenCode is bundled with OpenChamber Desktop and cannot be upgraded separately.',
|
||||
code: capability.reason === 'bundled'
|
||||
? 'OPENCODE_UPGRADE_MANAGED_BY_OPENCHAMBER'
|
||||
: 'OPENCODE_UPGRADE_UNSUPPORTED',
|
||||
error: capability.reason === 'bundled'
|
||||
? 'OpenCode is bundled with OpenChamber Desktop and updates with the app.'
|
||||
: 'This OpenCode runtime cannot be upgraded by OpenChamber.',
|
||||
});
|
||||
}
|
||||
if (openCodeUpgradePromise) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
code: 'OPENCODE_UPGRADE_IN_PROGRESS',
|
||||
error: 'An OpenCode upgrade is already in progress.',
|
||||
});
|
||||
}
|
||||
|
||||
const target = typeof req.body?.target === 'string' && req.body.target.trim().length > 0
|
||||
? req.body.target.trim()
|
||||
: undefined;
|
||||
const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...getOpenCodeAuthHeaders(),
|
||||
},
|
||||
body: JSON.stringify(target ? { target } : {}),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).json({
|
||||
success: false,
|
||||
error: payload?.error || response.statusText || 'Failed to upgrade OpenCode',
|
||||
const upgradeOperation = (async () => {
|
||||
const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...getOpenCodeAuthHeaders(),
|
||||
},
|
||||
body: JSON.stringify(target ? { target } : {}),
|
||||
});
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
status: response.status,
|
||||
body: {
|
||||
success: false,
|
||||
error: payload?.error || response.statusText || 'Failed to upgrade OpenCode',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshOpenCodeAfterConfigChange('OpenCode upgrade');
|
||||
} catch (restartError) {
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
success: false,
|
||||
upgraded: true,
|
||||
error: restartError instanceof Error
|
||||
? `OpenCode upgraded, but restart failed: ${restartError.message}`
|
||||
: 'OpenCode upgraded, but restart failed',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: { ...(payload ?? { success: true }), restarted: true },
|
||||
};
|
||||
})();
|
||||
openCodeUpgradePromise = upgradeOperation;
|
||||
|
||||
try {
|
||||
await refreshOpenCodeAfterConfigChange('OpenCode upgrade');
|
||||
} catch (restartError) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
upgraded: true,
|
||||
error: restartError instanceof Error
|
||||
? `OpenCode upgraded, but restart failed: ${restartError.message}`
|
||||
: 'OpenCode upgraded, but restart failed',
|
||||
});
|
||||
const result = await upgradeOperation;
|
||||
return res.status(result.status).json(result.body);
|
||||
} finally {
|
||||
if (openCodeUpgradePromise === upgradeOperation) {
|
||||
openCodeUpgradePromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ ...(payload ?? { success: true }), restarted: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to upgrade OpenCode:', error);
|
||||
return res.status(500).json({
|
||||
@@ -206,13 +237,14 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
|
||||
app.get('/api/opencode/upgrade-status', async (_req, res) => {
|
||||
try {
|
||||
if (await isBundledOpenCodeBinaryActive()) {
|
||||
const capability = getOpenCodeUpgradeCapability();
|
||||
if (!capability.supported) {
|
||||
const current = await readOpenCodeCurrentVersion().catch(() => ({ ok: false, currentVersion: null }));
|
||||
return res.json({
|
||||
available: false,
|
||||
currentVersion: current.ok ? current.currentVersion : null,
|
||||
latestVersion: null,
|
||||
source: 'bundled',
|
||||
upgrade: capability,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -239,6 +271,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
available,
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
upgrade: capability,
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export const resolveOpenCodeUpgradeCapability = ({
|
||||
isExternal,
|
||||
hasManagedProcess,
|
||||
activeBinary,
|
||||
isBundledBinary,
|
||||
}) => {
|
||||
if (isExternal) {
|
||||
return {
|
||||
supported: false,
|
||||
manager: 'external',
|
||||
reason: 'external',
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasManagedProcess || !activeBinary) {
|
||||
return {
|
||||
supported: false,
|
||||
manager: null,
|
||||
reason: 'unavailable',
|
||||
};
|
||||
}
|
||||
|
||||
if (isBundledBinary(activeBinary)) {
|
||||
return {
|
||||
supported: false,
|
||||
manager: 'openchamber',
|
||||
reason: 'bundled',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
manager: 'opencode',
|
||||
reason: null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { resolveOpenCodeUpgradeCapability } from './upgrade-capability.js';
|
||||
|
||||
describe('OpenCode upgrade capability', () => {
|
||||
it('assigns bundled binaries to the OpenChamber updater', () => {
|
||||
const isBundledBinary = vi.fn(() => true);
|
||||
|
||||
expect(resolveOpenCodeUpgradeCapability({
|
||||
isExternal: false,
|
||||
hasManagedProcess: true,
|
||||
activeBinary: '/Applications/OpenChamber.app/Contents/Resources/opencode-cli/opencode',
|
||||
isBundledBinary,
|
||||
})).toEqual({
|
||||
supported: false,
|
||||
manager: 'openchamber',
|
||||
reason: 'bundled',
|
||||
});
|
||||
});
|
||||
|
||||
it('never upgrades external or unresolved runtimes', () => {
|
||||
const isBundledBinary = vi.fn(() => false);
|
||||
|
||||
expect(resolveOpenCodeUpgradeCapability({
|
||||
isExternal: true,
|
||||
hasManagedProcess: false,
|
||||
activeBinary: null,
|
||||
isBundledBinary,
|
||||
})).toEqual({
|
||||
supported: false,
|
||||
manager: 'external',
|
||||
reason: 'external',
|
||||
});
|
||||
expect(resolveOpenCodeUpgradeCapability({
|
||||
isExternal: false,
|
||||
hasManagedProcess: false,
|
||||
activeBinary: '/usr/local/bin/opencode',
|
||||
isBundledBinary,
|
||||
})).toEqual({
|
||||
supported: false,
|
||||
manager: null,
|
||||
reason: 'unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows OpenCode to upgrade a managed non-bundled binary', () => {
|
||||
expect(resolveOpenCodeUpgradeCapability({
|
||||
isExternal: false,
|
||||
hasManagedProcess: true,
|
||||
activeBinary: '/Users/alice/.opencode/bin/opencode',
|
||||
isBundledBinary: () => false,
|
||||
})).toEqual({
|
||||
supported: true,
|
||||
manager: 'opencode',
|
||||
reason: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user