From a69b2e9d109130abf8a2a99d76bf4560d64c831c Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 5 Jan 2026 13:12:46 -0500 Subject: [PATCH] feat(cliproxy): add version management UI with install/restart controls Implements comprehensive version management for CLIProxyAPI Plus: Backend APIs: - GET /versions: fetch available versions from GitHub releases - POST /install: install specific version with force flag for unstable - POST /restart: restart proxy without version change Frontend: - 4-button layout: Restart, Update/Downgrade, Stop, Settings gear - Collapsible version picker with dropdown + manual input - Confirmation dialog for unstable versions (>6.6.80) - Progressive disclosure (version picker hidden by default) Ref: plans/260105-1250-cliproxy-version-management/ --- src/cliproxy/binary/types.ts | 17 ++ src/cliproxy/binary/version-cache.ts | 56 +++- src/cliproxy/binary/version-checker.ts | 58 +++- .../routes/cliproxy-stats-routes.ts | 106 +++++++- .../monitoring/proxy-status-widget.tsx | 254 +++++++++++++++++- ui/src/hooks/use-cliproxy.ts | 56 ++++ ui/src/lib/api-client.ts | 37 +++ 7 files changed, 569 insertions(+), 15 deletions(-) diff --git a/src/cliproxy/binary/types.ts b/src/cliproxy/binary/types.ts index 133faaf4..12eda079 100644 --- a/src/cliproxy/binary/types.ts +++ b/src/cliproxy/binary/types.ts @@ -27,3 +27,20 @@ export const VERSION_PIN_FILE = '.version-pin'; /** GitHub API URL for latest release (CLIProxyAPIPlus fork with Kiro + Copilot support) */ export const GITHUB_API_LATEST_RELEASE = 'https://api.github.com/repos/router-for-me/CLIProxyAPIPlus/releases/latest'; + +/** GitHub API URL for all releases */ +export const GITHUB_API_ALL_RELEASES = + 'https://api.github.com/repos/router-for-me/CLIProxyAPIPlus/releases'; + +/** Version list cache structure */ +export interface VersionListCache { + versions: string[]; + latestStable: string; + latest: string; + checkedAt: number; +} + +/** Version list result from API */ +export interface VersionListResult extends VersionListCache { + fromCache: boolean; +} diff --git a/src/cliproxy/binary/version-cache.ts b/src/cliproxy/binary/version-cache.ts index d77cf7cf..63a19a56 100644 --- a/src/cliproxy/binary/version-cache.ts +++ b/src/cliproxy/binary/version-cache.ts @@ -6,7 +6,12 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCliproxyDir, getBinDir } from '../config-generator'; -import { VersionCache, VERSION_CACHE_DURATION_MS, VERSION_PIN_FILE } from './types'; +import { + VersionCache, + VERSION_CACHE_DURATION_MS, + VERSION_PIN_FILE, + VersionListCache, +} from './types'; /** * Get path to version cache file @@ -140,3 +145,52 @@ export function clearPinnedVersion(): void { export function isVersionPinned(): boolean { return getPinnedVersion() !== null; } + +// ==================== Version List Cache ==================== + +const VERSION_LIST_CACHE_FILE = '.version-list-cache.json'; + +/** + * Get path to version list cache file + */ +export function getVersionListCachePath(): string { + return path.join(getCliproxyDir(), VERSION_LIST_CACHE_FILE); +} + +/** + * Read version list cache if still valid + */ +export function readVersionListCache(): VersionListCache | null { + const cachePath = getVersionListCachePath(); + if (!fs.existsSync(cachePath)) { + return null; + } + + try { + const content = fs.readFileSync(cachePath, 'utf8'); + const cache: VersionListCache = JSON.parse(content); + + // Check if cache is still valid (1 hour) + if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) { + return cache; + } + + return null; + } catch { + return null; + } +} + +/** + * Write version list to cache + */ +export function writeVersionListCache(cache: VersionListCache): void { + const cachePath = getVersionListCachePath(); + + try { + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8'); + } catch { + // Silent fail - caching is optional + } +} diff --git a/src/cliproxy/binary/version-checker.ts b/src/cliproxy/binary/version-checker.ts index 6108bd82..8ebfeb4d 100644 --- a/src/cliproxy/binary/version-checker.ts +++ b/src/cliproxy/binary/version-checker.ts @@ -4,8 +4,20 @@ */ import { fetchJson } from './downloader'; -import { readVersionCache, writeVersionCache, readInstalledVersion } from './version-cache'; -import { UpdateCheckResult, GITHUB_API_LATEST_RELEASE } from './types'; +import { + readVersionCache, + writeVersionCache, + readInstalledVersion, + readVersionListCache, + writeVersionListCache, +} from './version-cache'; +import { + UpdateCheckResult, + GITHUB_API_LATEST_RELEASE, + GITHUB_API_ALL_RELEASES, + VersionListResult, +} from './types'; +import { CLIPROXY_MAX_STABLE_VERSION } from '../platform-detector'; /** * Compare semver versions (true if latest > current) @@ -85,3 +97,45 @@ export async function checkForUpdates( checkedAt: now, }; } + +/** + * Fetch all available versions from GitHub releases + * Caches result for 1 hour to avoid rate limiting + */ +export async function fetchAllVersions(verbose = false): Promise { + // Try cache first + const cache = readVersionListCache(); + if (cache) { + if (verbose) { + console.error(`[cliproxy] Using cached version list (${cache.versions.length} versions)`); + } + return { ...cache, fromCache: true }; + } + + // Fetch from GitHub API + const response = await fetchJson(GITHUB_API_ALL_RELEASES, verbose); + + // Extract and normalize versions + const releases = response as unknown as Array<{ tag_name: string }>; + const versions = releases + .map((r) => r.tag_name.replace(/^v/, '')) + .filter((v) => /^\d+\.\d+\.\d+(-\d+)?$/.test(v)); // Valid semver only + + const latest = versions[0] || ''; + + // Find latest stable (not newer than max stable) + const latestStable = + versions.find((v) => !isNewerVersion(v, CLIPROXY_MAX_STABLE_VERSION)) || + CLIPROXY_MAX_STABLE_VERSION; + + const result: VersionListResult = { + versions, + latestStable, + latest, + fromCache: false, + checkedAt: Date.now(), + }; + + writeVersionListCache(result); + return result; +} diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index ac11ca84..2c07ef99 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -21,7 +21,13 @@ import { } from '../../cliproxy/config-generator'; import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker'; import { ensureCliproxyService } from '../../cliproxy/service-manager'; -import { checkCliproxyUpdate } from '../../cliproxy/binary-manager'; +import { + checkCliproxyUpdate, + getInstalledCliproxyVersion, + installCliproxyVersion, +} from '../../cliproxy/binary-manager'; +import { fetchAllVersions, isNewerVersion } from '../../cliproxy/binary/version-checker'; +import { CLIPROXY_MAX_STABLE_VERSION } from '../../cliproxy/platform-detector'; const router = Router(); @@ -528,4 +534,102 @@ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): P } }); +// ==================== Version Management ==================== + +/** + * GET /api/cliproxy/versions - Get all available CLIProxyAPI versions + * Returns: { versions, latestStable, latest, currentVersion, maxStableVersion } + */ +router.get('/versions', async (_req: Request, res: Response): Promise => { + try { + const result = await fetchAllVersions(); + const currentVersion = getInstalledCliproxyVersion(); + + res.json({ + ...result, + currentVersion, + maxStableVersion: CLIPROXY_MAX_STABLE_VERSION, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cliproxy/install - Install specific CLIProxyAPI version + * Body: { version: string, force?: boolean } + * Returns: { success, requiresConfirmation?, message? } + */ +router.post('/install', async (req: Request, res: Response): Promise => { + try { + const { version, force } = req.body; + + if (!version || typeof version !== 'string') { + res.status(400).json({ error: 'Missing required field: version' }); + return; + } + + // Validate version format + if (!/^\d+\.\d+\.\d+(-\d+)?$/.test(version)) { + res.status(400).json({ error: 'Invalid version format. Expected: X.Y.Z or X.Y.Z-N' }); + return; + } + + // Check if version is unstable + const isUnstable = isNewerVersion(version, CLIPROXY_MAX_STABLE_VERSION); + + if (isUnstable && !force) { + res.json({ + success: false, + requiresConfirmation: true, + message: `Version ${version} is unstable (above max stable ${CLIPROXY_MAX_STABLE_VERSION}). Set force=true to proceed.`, + }); + return; + } + + // Stop proxy first if running + await stopProxy(); + + // Small delay to ensure port is released + await new Promise((r) => setTimeout(r, 500)); + + // Install the version + await installCliproxyVersion(version, true); + + res.json({ + success: true, + version, + isUnstable, + message: `Successfully installed CLIProxy Plus v${version}`, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cliproxy/restart - Restart CLIProxy without version change + * Returns: { success, port?, error? } + */ +router.post('/restart', async (_req: Request, res: Response): Promise => { + try { + // Stop proxy first + await stopProxy(); + + // Small delay to ensure port is released + await new Promise((r) => setTimeout(r, 500)); + + // Start proxy + const startResult = await ensureCliproxyService(); + + if (startResult.started || startResult.alreadyRunning) { + res.json({ success: true, port: startResult.port }); + } else { + res.json({ success: false, error: startResult.error || 'Failed to start proxy' }); + } + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + export default router; diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index 78734dc1..ea42c35a 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -6,6 +6,7 @@ * In remote mode: shows remote server info instead of local controls. */ +import { useState } from 'react'; import { Activity, Power, @@ -15,11 +16,32 @@ import { Square, RotateCw, ArrowUp, + ArrowDown, Globe, AlertTriangle, + Settings, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { useQuery } from '@tanstack/react-query'; import { api, type CliproxyServerConfig } from '@/lib/api-client'; import { @@ -27,9 +49,23 @@ import { useStartProxy, useStopProxy, useCliproxyUpdateCheck, + useCliproxyVersions, + useInstallVersion, + useRestartProxy, } from '@/hooks/use-cliproxy'; import { cn } from '@/lib/utils'; +/** Client-side semver comparison (true if a > b) */ +function isNewerVersionClient(a: string, b: string): boolean { + const aParts = a.replace(/-\d+$/, '').split('.').map(Number); + const bParts = b.replace(/-\d+$/, '').split('.').map(Number); + for (let i = 0; i < 3; i++) { + if ((aParts[i] || 0) > (bParts[i] || 0)) return true; + if ((aParts[i] || 0) < (bParts[i] || 0)) return false; + } + return false; +} + function formatUptime(startedAt?: string): string { if (!startedAt) return ''; const start = new Date(startedAt).getTime(); @@ -59,8 +95,20 @@ function formatTimeAgo(timestamp?: number): string { export function ProxyStatusWidget() { const { data: status, isLoading } = useProxyStatus(); const { data: updateCheck } = useCliproxyUpdateCheck(); + const { data: versionsData, isLoading: versionsLoading } = useCliproxyVersions(); const startProxy = useStartProxy(); const stopProxy = useStopProxy(); + const restartProxy = useRestartProxy(); + const installVersion = useInstallVersion(); + + // Version picker state + const [showVersionSettings, setShowVersionSettings] = useState(false); + const [selectedVersion, setSelectedVersion] = useState(''); + const [manualVersion, setManualVersion] = useState(''); + + // Confirmation dialog state for unstable versions + const [showUnstableConfirm, setShowUnstableConfirm] = useState(false); + const [pendingInstallVersion, setPendingInstallVersion] = useState(null); // Fetch cliproxy_server config for remote mode detection const { data: cliproxyConfig } = useQuery({ @@ -74,10 +122,45 @@ export function ProxyStatusWidget() { const isRemoteMode = remoteConfig?.enabled && remoteConfig?.host; const isRunning = status?.running ?? false; - const isActioning = startProxy.isPending || stopProxy.isPending; + const isActioning = + startProxy.isPending || + stopProxy.isPending || + restartProxy.isPending || + installVersion.isPending; const hasUpdate = updateCheck?.hasUpdate ?? false; const isUnstable = updateCheck?.isStable === false; + // Handle version install (shows confirmation for unstable) + const handleInstallVersion = (version: string) => { + if (!version) return; + const maxStable = versionsData?.maxStableVersion || '6.6.80'; + const isVersionUnstable = isNewerVersionClient(version, maxStable); + + if (isVersionUnstable) { + // Show confirmation dialog for unstable versions + setPendingInstallVersion(version); + setShowUnstableConfirm(true); + return; + } + + // Install directly if stable + installVersion.mutate({ version }); + }; + + // Confirm unstable version install + const handleConfirmUnstableInstall = () => { + if (pendingInstallVersion) { + installVersion.mutate({ version: pendingInstallVersion, force: true }); + } + setShowUnstableConfirm(false); + setPendingInstallVersion(null); + }; + + const handleCancelUnstableInstall = () => { + setShowUnstableConfirm(false); + setPendingInstallVersion(null); + }; + // Build remote display info const remoteDisplayHost = isRemoteMode ? (() => { @@ -190,8 +273,26 @@ export function ProxyStatusWidget() { )} - {/* Control buttons when running */} + {/* Control buttons when running: Restart | Update/Downgrade | Stop | Settings */}
+ {/* Restart button - pure restart, no version change */} + + + {/* Update/Downgrade button - version change */} + + {/* Stop button */}
+ + {/* Version Settings (collapsible) */} + + +
+ {/* Current version */} +
+ Current: + + v{updateCheck?.currentVersion} + {isUnstable && ' (unstable)'} + +
+ + {/* Version picker row */} +
+ {/* Dropdown */} + + + {/* Manual input */} + { + setManualVersion(e.target.value); + setSelectedVersion(''); + }} + className="h-7 text-xs w-24" + /> + + {/* Install button */} + +
+ + {/* Stability warning */} + {(selectedVersion || manualVersion) && + versionsData && + isNewerVersionClient( + manualVersion || selectedVersion, + versionsData.maxStableVersion + ) && ( +
+ + + Versions above {versionsData.maxStableVersion} have known stability issues + +
+ )} +
+
+
) : (
@@ -284,6 +484,38 @@ export function ProxyStatusWidget() { )}
)} + + {/* Unstable Version Confirmation Dialog */} + + + + + + Install Unstable Version? + + +

+ You are about to install v{pendingInstallVersion}, which is above + the maximum stable version{' '} + v{versionsData?.maxStableVersion || '6.6.80'}. +

+

+ This version has known stability issues and may cause unexpected behavior. +

+

Are you sure you want to proceed?

+
+
+ + Cancel + + Install Anyway + + +
+
); } diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index d4b6ec9c..13e8efee 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -304,3 +304,59 @@ export function useCliproxyUpdateCheck() { refetchOnWindowFocus: false, // Don't refresh on window focus (save API calls) }); } + +// ==================== Version Management ==================== + +export function useCliproxyVersions() { + return useQuery({ + queryKey: ['cliproxy-versions'], + queryFn: () => api.cliproxy.versions(), + staleTime: 60 * 60 * 1000, // 1 hour (matches backend cache) + refetchOnWindowFocus: false, + }); +} + +export function useInstallVersion() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ version, force }: { version: string; force?: boolean }) => + api.cliproxy.install(version, force), + onSuccess: (data) => { + if (data.requiresConfirmation) { + // Don't show toast - let caller handle confirmation dialog + return; + } + queryClient.invalidateQueries({ queryKey: ['cliproxy-versions'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-update-check'] }); + queryClient.invalidateQueries({ queryKey: ['proxy-status'] }); + if (data.success) { + toast.success(data.message || `Installed v${data.version}`); + } else { + toast.error(data.error || 'Installation failed'); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +export function useRestartProxy() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => api.cliproxy.restart(), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['proxy-status'] }); + if (data.success) { + toast.success(`Proxy restarted on port ${data.port}`); + } else { + toast.error(data.error || 'Restart failed'); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index a2cf98a5..55f3519f 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -261,6 +261,34 @@ export interface CliproxyUpdateCheckResult { checkedAt: number; // Unix timestamp of last check } +/** Available versions list from GitHub releases */ +export interface CliproxyVersionsResponse { + versions: string[]; + latestStable: string; + latest: string; + currentVersion: string; + maxStableVersion: string; + fromCache: boolean; + checkedAt: number; +} + +/** Result from installing a specific version */ +export interface CliproxyInstallResult { + success: boolean; + version?: string; + isUnstable?: boolean; + requiresConfirmation?: boolean; + message?: string; + error?: string; +} + +/** Result from restarting the proxy */ +export interface CliproxyRestartResult { + success: boolean; + port?: number; + error?: string; +} + // API export const api = { profiles: { @@ -301,6 +329,15 @@ export const api = { proxyStop: () => request('/cliproxy/proxy-stop', { method: 'POST' }), updateCheck: () => request('/cliproxy/update-check'), + // Version management + versions: () => request('/cliproxy/versions'), + install: (version: string, force?: boolean) => + request('/cliproxy/install', { + method: 'POST', + body: JSON.stringify({ version, force }), + }), + restart: () => request('/cliproxy/restart', { method: 'POST' }), + // Stats and models for Overview tab stats: () => request<{ usage: Record }>('/cliproxy/usage'), models: () => request('/cliproxy/models'),