Merge pull request #272 from kaitranntt/fix/cliproxyapi-downgrade

fix(cliproxy): cap auto-update to v80 due to v81+ context bugs
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-01-05 13:58:28 -08:00
commit 916ed9ed19
12 files changed
+820 -127

No files matched your search

+20 -2
View File
@@ -8,7 +8,7 @@
import { info, warn } from '../utils/ui';
import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator';
import { BinaryInfo, BinaryManagerConfig } from './types';
import { CLIPROXY_FALLBACK_VERSION } from './platform-detector';
import { CLIPROXY_FALLBACK_VERSION, CLIPROXY_MAX_STABLE_VERSION } from './platform-detector';
import { isProxyRunning, stopProxy } from './services/proxy-lifecycle-service';
import { waitForPortFree } from '../utils/port-utils';
import {
@@ -151,11 +151,29 @@ export interface CliproxyUpdateCheckResult {
latestVersion: string;
fromCache: boolean;
checkedAt: number;
// Stability fields
isStable: boolean;
maxStableVersion: string;
stabilityMessage?: string;
}
/** Check for CLIProxyAPI binary updates */
export async function checkCliproxyUpdate(): Promise<CliproxyUpdateCheckResult> {
return new BinaryManager().checkForUpdates();
const result = await new BinaryManager().checkForUpdates();
// Import isNewerVersion for stability check
const { isNewerVersion } = await import('./binary/version-checker');
const isStable = !isNewerVersion(result.currentVersion, CLIPROXY_MAX_STABLE_VERSION);
const stabilityMessage = isStable
? undefined
: `v${result.currentVersion} has known stability issues. Max stable: v${CLIPROXY_MAX_STABLE_VERSION}`;
return {
...result,
isStable,
maxStableVersion: CLIPROXY_MAX_STABLE_VERSION,
stabilityMessage,
};
}
// Re-export version pin functions
+57 -7
View File
@@ -7,22 +7,68 @@ import * as fs from 'fs';
import { BinaryManagerConfig } from '../types';
import { checkForUpdates, fetchLatestVersion, isNewerVersion } from './version-checker';
import { downloadAndInstall, deleteBinary, getBinaryPath } from './installer';
import { info } from '../../utils/ui';
import { info, warn } from '../../utils/ui';
import { isCliproxyRunning } from '../stats-fetcher';
import { CLIPROXY_DEFAULT_PORT } from '../config-generator';
import { CLIPROXY_MAX_STABLE_VERSION } from '../platform-detector';
/** Log helper */
function log(message: string, verbose: boolean): void {
if (verbose) console.error(`[cliproxy] ${message}`);
}
/**
* Check if version is above max stable (known unstable)
*/
function isAboveMaxStable(version: string): boolean {
return isNewerVersion(version, CLIPROXY_MAX_STABLE_VERSION);
}
/**
* Clamp version to max stable if newer versions are unstable
* Returns max stable version if input is empty/invalid
*/
function clampToMaxStable(version: string | undefined, verbose: boolean): string {
if (!version) {
log(`Empty version, using max stable ${CLIPROXY_MAX_STABLE_VERSION}`, verbose);
return CLIPROXY_MAX_STABLE_VERSION;
}
if (isAboveMaxStable(version)) {
log(`Clamping ${version} to max stable ${CLIPROXY_MAX_STABLE_VERSION}`, verbose);
return CLIPROXY_MAX_STABLE_VERSION;
}
return version;
}
/** Handle auto-update when binary exists */
async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): Promise<void> {
const updateResult = await checkForUpdates(config.binPath, config.version, verbose);
const currentVersion = updateResult.currentVersion;
const latestVersion = updateResult.latestVersion;
// Check if user is on known unstable version - inform but don't force downgrade
if (isAboveMaxStable(currentVersion)) {
console.log(
warn(
`CLIProxy Plus v${currentVersion} has known stability issues. ` +
`Stable version: v${CLIPROXY_MAX_STABLE_VERSION}`
)
);
console.log(info('Run "ccs cliproxy install 80" to downgrade, or wait for upstream fix'));
}
if (!updateResult.hasUpdate) return;
// Clamp to max stable version
const targetVersion = clampToMaxStable(latestVersion, verbose);
if (!isNewerVersion(targetVersion, currentVersion)) {
log(`Already at max stable version ${currentVersion}`, verbose);
return;
}
const proxyRunning = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT);
const updateMsg = `CLIProxy Plus update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}`;
const latestNote = isAboveMaxStable(latestVersion) ? ` (latest v${latestVersion} unstable)` : '';
const updateMsg = `CLIProxy Plus update: v${currentVersion} -> v${targetVersion}${latestNote}`;
if (proxyRunning) {
console.log(info(updateMsg));
@@ -32,7 +78,7 @@ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean):
console.log(info(updateMsg));
console.log(info('Updating CLIProxy Plus...'));
deleteBinary(config.binPath, verbose);
config.version = updateResult.latestVersion;
config.version = targetVersion;
await downloadAndInstall(config, verbose);
}
}
@@ -70,12 +116,16 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise<string>
if (!config.forceVersion) {
try {
const latestVersion = await fetchLatestVersion(verbose);
if (latestVersion && isNewerVersion(latestVersion, config.version)) {
log(`Using latest version: ${latestVersion} (instead of ${config.version})`, verbose);
config.version = latestVersion;
const targetVersion = clampToMaxStable(latestVersion, verbose);
if (targetVersion && isNewerVersion(targetVersion, config.version)) {
log(`Using version: ${targetVersion} (instead of ${config.version})`, verbose);
config.version = targetVersion;
}
} catch {
log(`Using pinned version: ${config.version}`, verbose);
// API failed - use fallback but still clamp to max stable
const fallbackVersion = clampToMaxStable(config.version, verbose);
config.version = fallbackVersion;
log(`Using fallback version: ${fallbackVersion}`, verbose);
}
} else {
log(`Force version mode: using specified version ${config.version}`, verbose);
+17
View File
@@ -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;
}
+55 -1
View File
@@ -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
}
}
+56 -2
View File
@@ -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<VersionListResult> {
// 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;
}
+7
View File
@@ -14,6 +14,13 @@ import { PlatformInfo, SupportedOS, SupportedArch, ArchiveExtension } from './ty
*/
export const CLIPROXY_FALLBACK_VERSION = '6.6.40-0';
/**
* Maximum stable version cap - prevents auto-update to known unstable releases
* v81+ has context cancellation bugs causing intermittent 500 errors
* See: https://github.com/kaitranntt/ccs/issues/269
*/
export const CLIPROXY_MAX_STABLE_VERSION = '6.6.80-0';
/** @deprecated Use CLIPROXY_FALLBACK_VERSION instead */
export const CLIPROXY_VERSION = CLIPROXY_FALLBACK_VERSION;
+17
View File
@@ -15,6 +15,8 @@ import {
} from '../../cliproxy';
import { getPortProcess, isCLIProxyProcess } from '../../utils/port-utils';
import type { HealthCheck } from './types';
import { CLIPROXY_MAX_STABLE_VERSION } from '../../cliproxy/platform-detector';
import { isNewerVersion } from '../../cliproxy/binary/version-checker';
/**
* Check CLIProxy binary installation
@@ -23,6 +25,21 @@ export function checkCliproxyBinary(): HealthCheck {
if (isCLIProxyInstalled()) {
const version = getInstalledCliproxyVersion();
const binaryPath = getCLIProxyPath();
// Check if version exceeds stable cap
const isUnstable = isNewerVersion(version, CLIPROXY_MAX_STABLE_VERSION);
if (isUnstable) {
return {
id: 'cliproxy-binary',
name: 'CLIProxy Binary',
status: 'warning',
message: `v${version} (unstable)`,
details: binaryPath,
fix: `Downgrade: ccs cliproxy install ${CLIPROXY_MAX_STABLE_VERSION.replace(/-\d+$/, '')}`,
};
}
return {
id: 'cliproxy-binary',
name: 'CLIProxy Binary',
+105 -1
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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;
+38 -1
View File
@@ -6,11 +6,17 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { RefreshCw, Loader2 } from 'lucide-react';
import { RefreshCw, Loader2, AlertTriangle } from 'lucide-react';
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
import { cn } from '@/lib/utils';
interface VersionInfo {
currentVersion: string;
isStable: boolean;
stabilityMessage?: string;
}
interface LoginButtonProps {
provider: string;
displayName: string;
@@ -110,6 +116,22 @@ export function CliproxyHeader({
const { data: authData } = useCliproxyAuth();
const { provider: authProvider, isAuthenticating, startAuth } = useCliproxyAuthFlow();
const lastUpdatedText = useRelativeTime(lastUpdated);
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
useEffect(() => {
fetch('/api/cliproxy/update-check')
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data) {
setVersionInfo({
currentVersion: data.currentVersion,
isStable: data.isStable,
stabilityMessage: data.stabilityMessage,
});
}
})
.catch(() => {}); // Silently fail
}, []);
const providers = [
{ id: 'claude', displayName: 'Claude' },
@@ -170,6 +192,21 @@ export function CliproxyHeader({
{isRunning ? 'Running' : 'Offline'}
</Badge>
{versionInfo && (
<Badge
variant={versionInfo.isStable ? 'secondary' : 'destructive'}
className={cn(
'gap-1.5',
!versionInfo.isStable &&
'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30'
)}
title={versionInfo.stabilityMessage}
>
{!versionInfo.isStable && <AlertTriangle className="w-3 h-3" />}v
{versionInfo.currentVersion}
</Badge>
)}
{lastUpdatedText && (
<span className="text-xs text-muted-foreground">{lastUpdatedText}</span>
)}
@@ -4,8 +4,11 @@
* Displays CLIProxy process status with start/stop/restart controls.
* Shows: running state, port, session count, uptime, update availability.
* In remote mode: shows remote server info instead of local controls.
*
* Design: Two-state widget (collapsed/expanded) with icon-only control buttons.
*/
import { useState } from 'react';
import {
Activity,
Power,
@@ -15,10 +18,34 @@ import {
Square,
RotateCw,
ArrowUp,
ArrowDown,
Globe,
AlertTriangle,
Settings,
X,
Download,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useQuery } from '@tanstack/react-query';
import { api, type CliproxyServerConfig } from '@/lib/api-client';
import {
@@ -26,9 +53,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();
@@ -55,11 +96,69 @@ function formatTimeAgo(timestamp?: number): string {
return `${hours}h ago`;
}
/** Icon button with tooltip wrapper */
function IconButton({
icon: Icon,
tooltip,
onClick,
disabled,
isPending,
className,
variant = 'ghost',
}: {
icon: React.ElementType;
tooltip: string;
onClick: () => void;
disabled?: boolean;
isPending?: boolean;
className?: string;
variant?: 'ghost' | 'outline' | 'destructive-ghost';
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={variant === 'destructive-ghost' ? 'ghost' : variant}
size="sm"
className={cn(
'h-7 w-7 p-0',
variant === 'destructive-ghost' &&
'hover:bg-destructive/10 hover:text-destructive hover:border-destructive/30',
className
)}
onClick={onClick}
disabled={disabled}
>
{isPending ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<Icon className="w-3.5 h-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{tooltip}
</TooltipContent>
</Tooltip>
);
}
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 (expanded section)
const [isExpanded, setIsExpanded] = useState(false);
const [selectedVersion, setSelectedVersion] = useState<string>('');
// Confirmation dialog state for unstable versions
const [showUnstableConfirm, setShowUnstableConfirm] = useState(false);
const [pendingInstallVersion, setPendingInstallVersion] = useState<string | null>(null);
// Fetch cliproxy_server config for remote mode detection
const { data: cliproxyConfig } = useQuery<CliproxyServerConfig>({
@@ -73,8 +172,50 @@ 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;
const currentVersion = updateCheck?.currentVersion;
// Target version for update/downgrade badge
const targetVersion = isUnstable
? updateCheck?.maxStableVersion || versionsData?.latestStable
: updateCheck?.latestVersion;
// 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
@@ -87,14 +228,6 @@ export function ProxyStatusWidget() {
})()
: null;
// 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();
};
// Remote mode: show remote server info
if (isRemoteMode) {
return (
@@ -130,49 +263,98 @@ export function ProxyStatusWidget() {
);
}
// Local mode: show original controls
// Local mode: Two-state widget (collapsed/expanded)
return (
<div
className={cn(
'rounded-lg border p-3 transition-colors',
isRunning ? 'border-green-500/30 bg-green-500/5' : 'border-muted bg-muted/30'
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
isRunning ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground/30'
<TooltipProvider delayDuration={300}>
<div
className={cn(
'rounded-lg border p-3 transition-colors',
isRunning ? 'border-green-500/30 bg-green-500/5' : 'border-muted bg-muted/30'
)}
>
{/* Header row: Status dot, title, icon buttons */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{/* Status indicator */}
<div
className={cn(
'w-2 h-2 rounded-full',
isRunning ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground/30'
)}
/>
<span className="text-sm font-medium">CLIProxy Plus</span>
</div>
{/* Right side: icon buttons when running */}
<div className="flex items-center gap-1">
{isLoading ? (
<RefreshCw className="w-3 h-3 animate-spin text-muted-foreground" />
) : isRunning ? (
<>
<IconButton
icon={RotateCw}
tooltip="Restart"
onClick={() => restartProxy.mutate()}
disabled={isActioning}
isPending={restartProxy.isPending}
/>
<IconButton
icon={Square}
tooltip="Stop"
onClick={() => stopProxy.mutate()}
disabled={isActioning}
isPending={stopProxy.isPending}
variant="destructive-ghost"
/>
<IconButton
icon={isExpanded ? X : Settings}
tooltip={isExpanded ? 'Close' : 'Version settings'}
onClick={() => setIsExpanded(!isExpanded)}
className={isExpanded ? 'bg-muted' : undefined}
/>
</>
) : (
<Power className="w-3 h-3 text-muted-foreground" />
)}
/>
<span className="text-sm font-medium">CLIProxy Plus</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}`}
</div>
</div>
{/* Version row: version + update badge */}
{currentVersion && (
<div className="mt-1.5 flex items-center gap-2">
<span
className={cn(
'text-xs font-mono text-muted-foreground',
isUnstable && 'text-amber-600 dark:text-amber-400'
)}
>
<ArrowUp className="w-2.5 h-2.5" />
Update
</Badge>
)}
</div>
v{currentVersion}
</span>
{(hasUpdate || isUnstable) && targetVersion && (
<Badge
variant="secondary"
className={cn(
'text-[10px] h-4 px-1.5 gap-0.5 cursor-pointer transition-colors',
isUnstable
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50'
: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50'
)}
onClick={() => handleInstallVersion(targetVersion)}
title={`Click to ${isUnstable ? 'downgrade' : 'update'}`}
>
{isUnstable ? (
<ArrowDown className="w-2.5 h-2.5" />
) : (
<ArrowUp className="w-2.5 h-2.5" />
)}
{targetVersion}
</Badge>
)}
</div>
)}
<div className="flex items-center gap-1">
{isLoading ? (
<RefreshCw className="w-3 h-3 animate-spin text-muted-foreground" />
) : isRunning ? (
<Activity className="w-3 h-3 text-green-600" />
) : (
<Power className="w-3 h-3 text-muted-foreground" />
)}
</div>
</div>
{isRunning && status ? (
<>
{/* Stats row when running */}
{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 && (
@@ -188,81 +370,138 @@ export function ProxyStatusWidget() {
</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-sidebar-accent hover:bg-sidebar-accent/90 text-sidebar-accent-foreground'
)}
{/* Expanded section: Version Management */}
{isRunning && (
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CollapsibleContent className="mt-3 pt-3 border-t border-muted">
{/* Section header */}
<h4 className="text-xs font-medium text-muted-foreground mb-3">Version Management</h4>
{/* Version picker row */}
<div className="flex items-center gap-2">
{/* Dropdown - full width, no truncation */}
<Select
value={selectedVersion}
onValueChange={setSelectedVersion}
disabled={versionsLoading}
>
<SelectTrigger className="h-8 text-xs flex-1">
<SelectValue placeholder="Select version to install..." />
</SelectTrigger>
<SelectContent>
{versionsData?.versions.slice(0, 20).map((v) => {
const vIsUnstable =
versionsData?.maxStableVersion &&
isNewerVersionClient(v, versionsData.maxStableVersion);
return (
<SelectItem key={v} value={v} className="text-xs">
<span className="flex items-center gap-2">
v{v}
{v === versionsData.latestStable && (
<span className="text-green-600 dark:text-green-400">(stable)</span>
)}
{vIsUnstable && (
<span className="text-amber-600 dark:text-amber-400">⚠</span>
)}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
{/* Install button */}
<Button
variant="outline"
size="sm"
className="h-8 text-xs gap-1.5 px-3"
onClick={() => handleInstallVersion(selectedVersion)}
disabled={installVersion.isPending || !selectedVersion}
>
{installVersion.isPending ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<Download className="w-3.5 h-3.5" />
)}
Install
</Button>
</div>
{/* Stability warning for selected version */}
{selectedVersion &&
versionsData?.maxStableVersion &&
isNewerVersionClient(selectedVersion, versionsData.maxStableVersion) && (
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-amber-600 dark:text-amber-400">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0" />
<span>Versions above {versionsData.maxStableVersion} have known issues</span>
</div>
)}
{/* Sync time */}
{updateCheck?.checkedAt && (
<div className="mt-2 text-[10px] text-muted-foreground/60">
Last checked {formatTimeAgo(updateCheck.checkedAt)}
</div>
)}
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>
</CollapsibleContent>
</Collapsible>
)}
{/* Not running state */}
{!isRunning && (
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-muted-foreground">Not running</span>
<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"
className="h-7 text-xs gap-1"
onClick={() => startProxy.mutate()}
disabled={startProxy.isPending}
>
{stopProxy.isPending ? (
{startProxy.isPending ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<Square className="w-3 h-3" />
<Power className="w-3 h-3" />
)}
Stop
Start
</Button>
</div>
</>
) : (
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-muted-foreground">Not running</span>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => startProxy.mutate()}
disabled={startProxy.isPending}
>
{startProxy.isPending ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<Power className="w-3 h-3" />
)}
Start
</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>
{/* Unstable Version Confirmation Dialog */}
<AlertDialog open={showUnstableConfirm} onOpenChange={setShowUnstableConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-500" />
Install Unstable Version?
</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p>
You are about to install <strong>v{pendingInstallVersion}</strong>, which is above
the maximum stable version{' '}
<strong>v{versionsData?.maxStableVersion || '6.6.80'}</strong>.
</p>
<p className="text-amber-600 dark:text-amber-400">
This version has known stability issues and may cause unexpected behavior.
</p>
<p>Are you sure you want to proceed?</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelUnstableInstall}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmUnstableInstall}
className="bg-amber-500 hover:bg-amber-600 text-white"
>
Install Anyway
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TooltipProvider>
);
}
+56
View File
@@ -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);
},
});
}
+40
View File
@@ -259,6 +259,37 @@ export interface CliproxyUpdateCheckResult {
latestVersion: string;
fromCache: boolean;
checkedAt: number; // Unix timestamp of last check
isStable: boolean; // Whether current version is at or below max stable
maxStableVersion: string; // Maximum stable version (e.g., "6.6.80")
stabilityMessage?: string; // Warning message if running unstable version
}
/** 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
@@ -301,6 +332,15 @@ export const api = {
proxyStop: () => request<ProxyStopResult>('/cliproxy/proxy-stop', { method: 'POST' }),
updateCheck: () => request<CliproxyUpdateCheckResult>('/cliproxy/update-check'),
// Version management
versions: () => request<CliproxyVersionsResponse>('/cliproxy/versions'),
install: (version: string, force?: boolean) =>
request<CliproxyInstallResult>('/cliproxy/install', {
method: 'POST',
body: JSON.stringify({ version, force }),
}),
restart: () => request<CliproxyRestartResult>('/cliproxy/restart', { method: 'POST' }),
// Stats and models for Overview tab
stats: () => request<{ usage: Record<string, unknown> }>('/cliproxy/usage'),
models: () => request<CliproxyModelsResponse>('/cliproxy/models'),