Merge pull request #144 from kaitranntt/kai/fix/cliproxy-update-notification

fix(cliproxy): prevent misleading update message when proxy is running
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2025-12-18 23:26:23 -05:00
commit a64f1818ea
5 files changed
+250 -39

No files matched your search

+56 -19
View File
@@ -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<string> {
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<CliproxyUpdateCheckResult> {
const manager = new BinaryManager();
return manager.checkForUpdates();
}
/**
* Get path to version pin file
* @returns Absolute path to .version-pin file
+28 -1
View File
@@ -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<void> => {
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<string, CliproxyModel[]>, totalCount: number }
+114 -19
View File
@@ -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 (
<div
@@ -47,6 +76,16 @@ export function ProxyStatusWidget() {
)}
/>
<span className="text-sm font-medium">CLIProxy Service</span>
{hasUpdate && (
<Badge
variant="secondary"
className="text-[10px] h-4 px-1.5 gap-0.5 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
title={`Update: v${updateCheck?.currentVersion} -> v${updateCheck?.latestVersion}`}
>
<ArrowUp className="w-2.5 h-2.5" />
Update
</Badge>
)}
</div>
<div className="flex items-center gap-1">
@@ -61,21 +100,65 @@ export function ProxyStatusWidget() {
</div>
{isRunning && status ? (
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">Port {status.port}</span>
{status.sessionCount !== undefined && status.sessionCount > 0 && (
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''}
</span>
)}
{status.startedAt && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatUptime(status.startedAt)}
</span>
)}
</div>
<>
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">Port {status.port}</span>
{status.sessionCount !== undefined && status.sessionCount > 0 && (
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''}
</span>
)}
{status.startedAt && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatUptime(status.startedAt)}
</span>
)}
</div>
{/* Control buttons when running */}
<div className="mt-2 flex items-center gap-2">
<Button
variant={hasUpdate ? 'default' : 'outline'}
size="sm"
className={cn(
'h-7 text-xs gap-1 flex-1',
hasUpdate && 'bg-amber-600 hover:bg-amber-700 text-white'
)}
onClick={handleRestart}
disabled={isActioning}
title={
hasUpdate
? `Restart to update: v${updateCheck?.currentVersion} -> v${updateCheck?.latestVersion}`
: 'Restart CLIProxy service'
}
>
{isActioning ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : hasUpdate ? (
<ArrowUp className="w-3 h-3" />
) : (
<RotateCw className="w-3 h-3" />
)}
{hasUpdate ? 'Update' : 'Restart'}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/30"
onClick={() => stopProxy.mutate()}
disabled={isActioning}
title="Stop CLIProxy service"
>
{stopProxy.isPending ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<Square className="w-3 h-3" />
)}
Stop
</Button>
</div>
</>
) : (
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-muted-foreground">Not running</span>
@@ -95,6 +178,18 @@ export function ProxyStatusWidget() {
</Button>
</div>
)}
{/* Version sync indicator */}
{updateCheck?.currentVersion && (
<div className="mt-2 pt-2 border-t border-muted flex items-center justify-between text-[10px] text-muted-foreground/70">
<span>v{updateCheck.currentVersion}</span>
{updateCheck.checkedAt && (
<span title={new Date(updateCheck.checkedAt).toLocaleString()}>
Synced {formatTimeAgo(updateCheck.checkedAt)}
</span>
)}
</div>
)}
</div>
);
}
+33
View File
@@ -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)
});
}
+19
View File
@@ -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<ProxyProcessStatus>('/cliproxy/proxy-status'),
proxyStart: () => request<ProxyStartResult>('/cliproxy/proxy-start', { method: 'POST' }),
proxyStop: () => request<ProxyStopResult>('/cliproxy/proxy-stop', { method: 'POST' }),
updateCheck: () => request<CliproxyUpdateCheckResult>('/cliproxy/update-check'),
// Stats and models for Overview tab
stats: () => request<{ usage: Record<string, unknown> }>('/cliproxy/usage'),