diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 8137c108..fa037060 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -19,7 +19,8 @@ import * as crypto from 'crypto'; import * as zlib from 'zlib'; import { ProgressIndicator } from '../utils/progress-indicator'; import { ok, info } from '../utils/ui'; -import { getBinDir, getCliproxyDir } from './config-generator'; +import { getBinDir, getCliproxyDir, CLIPROXY_DEFAULT_PORT } from './config-generator'; +import { isCliproxyRunning } from './stats-fetcher'; import { BinaryInfo, BinaryManagerConfig, @@ -58,6 +59,7 @@ interface UpdateCheckResult { currentVersion: string; latestVersion: string; fromCache: boolean; + checkedAt: number; // Unix timestamp of last check } /** Default configuration */ @@ -103,17 +105,31 @@ export class BinaryManager { try { const updateResult = await this.checkForUpdates(); if (updateResult.hasUpdate) { - console.log( - info( - `CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}` - ) - ); - console.log(info('Updating CLIProxyAPI...')); + // Check if CLIProxyAPI is currently running - can't update while running + const proxyRunning = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT); + if (proxyRunning) { + // Proxy is running - can't update, just notify user + console.log( + info( + `CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}` + ) + ); + console.log(info('Run "ccs cliproxy stop" then restart to apply update')); + this.log('Skipping update: CLIProxyAPI is currently running'); + } else { + // Proxy not running - safe to update + console.log( + info( + `CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}` + ) + ); + console.log(info('Updating CLIProxyAPI...')); - // Delete old binary and download new version - this.deleteBinary(); - this.config.version = updateResult.latestVersion; - await this.downloadAndInstall(); + // Delete old binary and download new version + this.deleteBinary(); + this.config.version = updateResult.latestVersion; + await this.downloadAndInstall(); + } } } catch (error) { // Silent fail - don't block startup if update check fails @@ -157,19 +173,21 @@ export class BinaryManager { const currentVersion = this.getInstalledVersion(); // Try cache first - const cachedVersion = this.getCachedLatestVersion(); - if (cachedVersion) { - this.log(`Using cached version: ${cachedVersion}`); + const cache = this.getVersionCache(); + if (cache) { + this.log(`Using cached version: ${cache.latestVersion}`); return { - hasUpdate: this.isNewerVersion(cachedVersion, currentVersion), + hasUpdate: this.isNewerVersion(cache.latestVersion, currentVersion), currentVersion, - latestVersion: cachedVersion, + latestVersion: cache.latestVersion, fromCache: true, + checkedAt: cache.checkedAt, }; } // Fetch from GitHub API const latestVersion = await this.fetchLatestVersion(); + const now = Date.now(); this.cacheLatestVersion(latestVersion); return { @@ -177,6 +195,7 @@ export class BinaryManager { currentVersion, latestVersion, fromCache: false, + checkedAt: now, }; } @@ -272,9 +291,9 @@ export class BinaryManager { } /** - * Get cached latest version if still valid + * Get version cache data if still valid */ - private getCachedLatestVersion(): string | null { + private getVersionCache(): VersionCache | null { const cachePath = this.getVersionCachePath(); if (!fs.existsSync(cachePath)) { return null; @@ -286,7 +305,7 @@ export class BinaryManager { // Check if cache is still valid if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) { - return cache.latestVersion; + return cache; } // Cache expired @@ -980,6 +999,24 @@ export async function fetchLatestCliproxyVersion(): Promise { return result.latestVersion; } +/** Update check result for API response */ +export interface CliproxyUpdateCheckResult { + hasUpdate: boolean; + currentVersion: string; + latestVersion: string; + fromCache: boolean; + checkedAt: number; // Unix timestamp of last check +} + +/** + * Check for CLIProxyAPI binary updates + * @returns Update check result with version info + */ +export async function checkCliproxyUpdate(): Promise { + const manager = new BinaryManager(); + return manager.checkForUpdates(); +} + /** * Get path to version pin file * @returns Absolute path to .version-pin file diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 9e1cd207..6f68fc27 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -44,8 +44,9 @@ import { } from '../cliproxy/account-manager'; import type { CLIProxyProvider } from '../cliproxy/types'; import { getClaudeEnvVars } from '../cliproxy/config-generator'; -import { getProxyStatus as getProxyProcessStatus } from '../cliproxy/session-tracker'; +import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../cliproxy/session-tracker'; import { ensureCliproxyService } from '../cliproxy/service-manager'; +import { checkCliproxyUpdate } from '../cliproxy/binary-manager'; // Unified config imports import { hasUnifiedConfig, @@ -1389,6 +1390,32 @@ apiRoutes.post('/cliproxy/proxy-start', async (_req: Request, res: Response): Pr } }); +/** + * POST /api/cliproxy/proxy-stop - Stop the CLIProxy service + * Returns: { stopped, pid?, sessionCount?, error? } + */ +apiRoutes.post('/cliproxy/proxy-stop', (_req: Request, res: Response): void => { + try { + const result = stopProxy(); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/update-check - Check for CLIProxyAPI binary updates + * Returns: { hasUpdate, currentVersion, latestVersion, fromCache } + */ +apiRoutes.get('/cliproxy/update-check', async (_req: Request, res: Response): Promise => { + try { + const result = await checkCliproxyUpdate(); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + /** * GET /api/cliproxy/models - Get available models from CLIProxyAPI * Returns: { models: CliproxyModel[], byCategory: Record, totalCount: number } diff --git a/ui/src/components/proxy-status-widget.tsx b/ui/src/components/proxy-status-widget.tsx index a85981c2..d3d4cd1d 100644 --- a/ui/src/components/proxy-status-widget.tsx +++ b/ui/src/components/proxy-status-widget.tsx @@ -1,13 +1,19 @@ /** * Proxy Status Widget * - * Displays CLIProxy process status with start button for recovery. - * Shows: running state, port, session count, uptime. + * Displays CLIProxy process status with start/stop/restart controls. + * Shows: running state, port, session count, uptime, update availability. */ -import { Activity, Power, RefreshCw, Clock, Users } from 'lucide-react'; +import { Activity, Power, RefreshCw, Clock, Users, Square, RotateCw, ArrowUp } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { useProxyStatus, useStartProxy } from '@/hooks/use-cliproxy'; +import { Badge } from '@/components/ui/badge'; +import { + useProxyStatus, + useStartProxy, + useStopProxy, + useCliproxyUpdateCheck, +} from '@/hooks/use-cliproxy'; import { cn } from '@/lib/utils'; function formatUptime(startedAt?: string): string { @@ -25,11 +31,34 @@ function formatUptime(startedAt?: string): string { return `${minutes}m`; } +function formatTimeAgo(timestamp?: number): string { + if (!timestamp) return ''; + const diff = Date.now() - timestamp; + const minutes = Math.floor(diff / (1000 * 60)); + const hours = Math.floor(diff / (1000 * 60 * 60)); + + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + return `${hours}h ago`; +} + export function ProxyStatusWidget() { const { data: status, isLoading } = useProxyStatus(); + const { data: updateCheck } = useCliproxyUpdateCheck(); const startProxy = useStartProxy(); + const stopProxy = useStopProxy(); const isRunning = status?.running ?? false; + const isActioning = startProxy.isPending || stopProxy.isPending; + const hasUpdate = updateCheck?.hasUpdate ?? false; + + // Restart = stop then start + const handleRestart = async () => { + await stopProxy.mutateAsync(); + // Small delay to ensure port is released + await new Promise((r) => setTimeout(r, 500)); + startProxy.mutate(); + }; return (
CLIProxy Service + {hasUpdate && ( + v${updateCheck?.latestVersion}`} + > + + Update + + )}
@@ -61,21 +100,65 @@ export function ProxyStatusWidget() {
{isRunning && status ? ( -
- Port {status.port} - {status.sessionCount !== undefined && status.sessionCount > 0 && ( - - - {status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''} - - )} - {status.startedAt && ( - - - {formatUptime(status.startedAt)} - - )} -
+ <> +
+ Port {status.port} + {status.sessionCount !== undefined && status.sessionCount > 0 && ( + + + {status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''} + + )} + {status.startedAt && ( + + + {formatUptime(status.startedAt)} + + )} +
+ {/* Control buttons when running */} +
+ + +
+ ) : (
Not running @@ -95,6 +178,18 @@ export function ProxyStatusWidget() {
)} + + {/* Version sync indicator */} + {updateCheck?.currentVersion && ( +
+ v{updateCheck.currentVersion} + {updateCheck.checkedAt && ( + + Synced {formatTimeAgo(updateCheck.checkedAt)} + + )} +
+ )} ); } diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index 9f23842e..fa93c06f 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -240,3 +240,36 @@ export function useStartProxy() { }, }); } + +export function useStopProxy() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => api.cliproxy.proxyStop(), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['proxy-status'] }); + if (data.stopped) { + toast.success( + `CLIProxy stopped${data.sessionCount ? ` (${data.sessionCount} session(s) disconnected)` : ''}` + ); + } else { + toast.error(data.error || 'Failed to stop CLIProxy'); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +// ==================== Update Check ==================== + +export function useCliproxyUpdateCheck() { + return useQuery({ + queryKey: ['cliproxy-update-check'], + queryFn: () => api.cliproxy.updateCheck(), + staleTime: 60 * 60 * 1000, // 1 hour (matches backend cache) + refetchInterval: 60 * 60 * 1000, // Refresh every hour + refetchOnWindowFocus: false, // Don't refresh on window focus (save API calls) + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 9732272b..73815560 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -182,6 +182,23 @@ export interface ProxyStartResult { error?: string; } +/** Result from stopping proxy service */ +export interface ProxyStopResult { + stopped: boolean; + pid?: number; + sessionCount?: number; + error?: string; +} + +/** Result from checking for CLIProxyAPI updates */ +export interface CliproxyUpdateCheckResult { + hasUpdate: boolean; + currentVersion: string; + latestVersion: string; + fromCache: boolean; + checkedAt: number; // Unix timestamp of last check +} + // API export const api = { profiles: { @@ -216,6 +233,8 @@ export const api = { // Proxy process status and control proxyStatus: () => request('/cliproxy/proxy-status'), proxyStart: () => request('/cliproxy/proxy-start', { method: 'POST' }), + proxyStop: () => request('/cliproxy/proxy-stop', { method: 'POST' }), + updateCheck: () => request('/cliproxy/update-check'), // Stats and models for Overview tab stats: () => request<{ usage: Record }>('/cliproxy/usage'),