diff --git a/CLAUDE.md b/CLAUDE.md index e85547cf..1f41c7bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -268,7 +268,9 @@ bun run test:unit # Unit tests ### Local Development ```bash bun run dev # Build + start config server (http://localhost:3000) -./scripts/dev-install.sh # Build, pack, install globally +bun run dev:symlink # Symlink global 'ccs' → dev dist/ccs.js (fast iteration) +bun run dev:unlink # Restore original global ccs +./scripts/dev-install.sh # Build, pack, install globally (full install) rm -rf ~/.ccs # Clean environment ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe0a7c50..e45bcb1d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -265,8 +265,22 @@ cd ccs # Create feature branch git checkout -b your-feature-name +# Option 1: Test with built binary +# Test locally with ./dist/ccs.js + +# Option 2: Symlink for seamless testing (recommended) +bun run build +bun run dev:symlink # Symlinks global 'ccs' to dev version +# Now 'ccs' command uses your dev changes! + # Make changes -# Test locally with ./ccs +# Test with: ccs + +# When done developing: +bun run dev:unlink # Restores original global ccs + +# Run tests +# Test with: ccs # Run tests bun run test # All tests diff --git a/README.md b/README.md index a380165f..7a4cd177 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,14 @@ ccs sync Re-creates symlinks for shared commands, skills, and settings. +### Antigravity Quota Management + +```bash +ccs cliproxy doctor # Check quota status for all agy accounts +``` + +**Auto-Failover**: When an Antigravity account runs out of quota, CCS automatically switches to another account with remaining capacity. Shared GCP project accounts are excluded (pooled quota). +
## Configuration diff --git a/config/base-agy.settings.json b/config/base-agy.settings.json index 0b08be0b..398841bc 100644 --- a/config/base-agy.settings.json +++ b/config/base-agy.settings.json @@ -5,6 +5,6 @@ "ANTHROPIC_MODEL": "gemini-3-pro-preview", "ANTHROPIC_DEFAULT_OPUS_MODEL": "gemini-3-pro-preview", "ANTHROPIC_DEFAULT_SONNET_MODEL": "gemini-3-pro-preview", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-3-flash-preview" + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-claude-sonnet-4-5" } } diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index b0270543..7a2af2a6 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -154,7 +154,10 @@ process.stdin.on('error', () => { */ function isCliAvailable(cmd) { try { - const result = spawnSync('which', [cmd], { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where.exe' : 'which'; + + const result = spawnSync(whichCmd, [cmd], { encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'], diff --git a/package.json b/package.json index 2efabc7e..50656b37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.13.1", + "version": "7.13.1-dev.8", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", @@ -72,6 +72,8 @@ "test:npm": "bun test tests/npm/", "test:native": "bash tests/native/unix/edge-cases.sh", "dev": "bun run build:server && bun dist/ccs.js config --dev", + "dev:symlink": "bash scripts/dev-symlink.sh", + "dev:unlink": "bash scripts/dev-symlink.sh --restore", "ui:build": "cd ui && bun run build", "ui:preview": "cd ui && bun run preview", "ui:validate": "cd ui && bun run validate", diff --git a/scripts/dev-symlink.sh b/scripts/dev-symlink.sh new file mode 100755 index 00000000..7f6ada59 --- /dev/null +++ b/scripts/dev-symlink.sh @@ -0,0 +1,115 @@ +#!/bin/bash +# CCS Dev Symlink Setup +# Creates symlinks for testing dev version with 'ccs' command +# +# Usage: ./scripts/dev-symlink.sh [--restore] +# +# Without --restore: Creates symlink from global 'ccs' to dist/ccs.js +# With --restore: Restores original global 'ccs' from backup + +set -euo pipefail + +RESTORE=false + +# Parse arguments +for arg in "$@"; do + case $arg in + --restore) RESTORE=true ;; + -h|--help) + echo "Usage: $0 [--restore]" + echo "" + echo "Create symlink for dev testing:" + echo " $0" + echo "" + echo "Restore original global ccs:" + echo " $0 --restore" + exit 0 + ;; + *) + echo "[X] Unknown option: $arg" + echo "Use --help for usage" + exit 1 + ;; + esac +done + +# Get to the right directory +cd "$(dirname "$0")/.." + +# Check if dist/ccs.js exists +if [ ! -f "dist/ccs.js" ]; then + echo "[X] ERROR: dist/ccs.js not found. Run 'bun run build' first." + exit 1 +fi + +# Get absolute path to dev ccs.js +DEV_CCS_PATH="$(pwd)/dist/ccs.js" + +# Find global ccs installation +GLOBAL_CCS_PATH=$(which ccs 2>/dev/null || true) + +if [ -z "$GLOBAL_CCS_PATH" ]; then + echo "[X] ERROR: No global 'ccs' installation found." + echo "Install CCS globally first: npm install -g @kaitranntt/ccs" + exit 1 +fi + +echo "[i] Found global ccs at: $GLOBAL_CCS_PATH" + +if [ "$RESTORE" = true ]; then + # Restore original ccs from backup + BACKUP_PATH="${GLOBAL_CCS_PATH}.backup-dev" + + if [ ! -f "$BACKUP_PATH" ] && [ ! -L "$BACKUP_PATH" ]; then + echo "[X] ERROR: No backup found at $BACKUP_PATH" + echo "Cannot restore - backup may have been deleted" + exit 1 + fi + + echo "[i] Restoring original ccs from backup..." + rm -f "$GLOBAL_CCS_PATH" + if [ -L "$BACKUP_PATH" ]; then + # Restore symlink + cp -P "$BACKUP_PATH" "$GLOBAL_CCS_PATH" + else + # Restore regular file + cp "$BACKUP_PATH" "$GLOBAL_CCS_PATH" + fi + chmod +x "$GLOBAL_CCS_PATH" + rm -f "$BACKUP_PATH" + + echo "[OK] Restored original global ccs" + echo "Run 'ccs --version' to verify" + exit 0 +fi + +# Check if already symlinked to our dev version +if [ -L "$GLOBAL_CCS_PATH" ]; then + CURRENT_TARGET=$(readlink "$GLOBAL_CCS_PATH" 2>/dev/null || true) + if [ "$CURRENT_TARGET" = "$DEV_CCS_PATH" ]; then + echo "[OK] Already symlinked to dev version" + exit 0 + fi +fi + +# Create backup of current global ccs +BACKUP_PATH="${GLOBAL_CCS_PATH}.backup-dev" +if [ -f "$BACKUP_PATH" ] || [ -L "$BACKUP_PATH" ]; then + echo "[i] Backup already exists, skipping backup creation" +else + echo "[i] Creating backup of current global ccs..." + cp -P "$GLOBAL_CCS_PATH" "$BACKUP_PATH" + echo "[OK] Backup created at: $BACKUP_PATH" +fi + +# Create symlink +echo "[i] Creating symlink to dev version..." +rm -f "$GLOBAL_CCS_PATH" +ln -s "$DEV_CCS_PATH" "$GLOBAL_CCS_PATH" + +echo "[OK] Symlinked global 'ccs' to dev version" +echo "" +echo "Now you can test dev changes with: ccs " +echo "To restore original: $0 --restore" +echo "" +echo "Test with: ccs --version" diff --git a/src/cliproxy/auth-session-manager.ts b/src/cliproxy/auth-session-manager.ts index ce718be9..0ad0dd7c 100644 --- a/src/cliproxy/auth-session-manager.ts +++ b/src/cliproxy/auth-session-manager.ts @@ -8,6 +8,9 @@ import { EventEmitter } from 'events'; import { ChildProcess } from 'child_process'; +// H8: TTL for stale session cleanup (10 minutes - generous for OAuth flows) +const SESSION_TTL_MS = 10 * 60 * 1000; + export interface ActiveAuthSession { sessionId: string; provider: string; @@ -19,6 +22,31 @@ export const authSessionEvents = new EventEmitter(); const activeSessions = new Map(); +// H8: Periodic cleanup of stale sessions (prevents memory leak from orphaned sessions) +let cleanupInterval: ReturnType | null = null; + +function startCleanupInterval(): void { + if (cleanupInterval) return; + cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [sessionId, session] of activeSessions.entries()) { + if (now - session.startedAt > SESSION_TTL_MS) { + // Stale session - kill process if still running, then remove + if (session.process && !session.process.killed) { + session.process.kill('SIGTERM'); + } + activeSessions.delete(sessionId); + authSessionEvents.emit('session:expired', sessionId); + } + } + // Stop interval if no active sessions + if (activeSessions.size === 0 && cleanupInterval) { + clearInterval(cleanupInterval); + cleanupInterval = null; + } + }, 60000); // Check every minute +} + /** * Register an active OAuth session */ @@ -33,6 +61,8 @@ export function registerAuthSession( startedAt: Date.now(), process, }); + // H8: Start TTL cleanup when first session registered + startCleanupInterval(); authSessionEvents.emit('session:started', sessionId, provider); } diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 927c2e2a..42e3cc9e 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -15,17 +15,20 @@ import { AccountInfo } from '../account-manager'; * - Gemini: Authorization Code Flow with local callback server on port 8085 * - Codex: Authorization Code Flow with local callback server on port 1455 * - Agy: Authorization Code Flow with local callback server on port 51121 - * - Qwen: Device Code Flow (polling-based, NO callback port needed) * - Kiro: Authorization Code Flow with local callback server on port 9876 + * - iFlow: Authorization Code Flow with local callback server on port 11451 + * - Claude: Authorization Code Flow with local callback server on port 54545 (Anthropic OAuth) + * - Qwen: Device Code Flow (polling-based, NO callback port needed) * - GHCP: Device Code Flow (polling-based, NO callback port needed) */ export const OAUTH_CALLBACK_PORTS: Partial> = { gemini: 8085, kiro: 9876, - // codex uses 1455 - // agy uses 51121 - // qwen uses Device Code Flow - no callback port needed - // ghcp uses Device Code Flow - no callback port needed + codex: 1455, + agy: 51121, + iflow: 11451, + // qwen: Device Code Flow - no callback port + // ghcp: Device Code Flow - no callback port }; /** diff --git a/src/cliproxy/auth/gemini-token-refresh.ts b/src/cliproxy/auth/gemini-token-refresh.ts index f6679c4c..fedb48dd 100644 --- a/src/cliproxy/auth/gemini-token-refresh.ts +++ b/src/cliproxy/auth/gemini-token-refresh.ts @@ -106,11 +106,12 @@ export function isGeminiTokenExpiringSoon(): boolean { /** * Refresh Gemini access token using refresh_token - * @returns true if refresh succeeded, false otherwise + * @returns Result with success status, optional error, and expiry time */ export async function refreshGeminiToken(): Promise<{ success: boolean; error?: string; + expiresAt?: number; }> { const creds = readGeminiCreds(); if (!creds || !creds.refresh_token) { @@ -151,17 +152,18 @@ export async function refreshGeminiToken(): Promise<{ } // Update credentials file with new token + const expiresAt = Date.now() + (data.expires_in ?? 3600) * 1000; const updatedCreds: GeminiOAuthCreds = { ...creds, access_token: data.access_token, - expiry_date: Date.now() + (data.expires_in ?? 3600) * 1000, + expiry_date: expiresAt, }; const writeError = writeGeminiCreds(updatedCreds); if (writeError) { return { success: false, error: `Token refreshed but failed to save: ${writeError}` }; } - return { success: true }; + return { success: true, expiresAt }; } catch (err) { clearTimeout(timeoutId); if (err instanceof Error && err.name === 'AbortError') { diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index c09a9536..6b5cb997 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -17,13 +17,18 @@ import { isProjectList, generateSessionId, requestProjectSelection, + cancelProjectSelection, type GCloudProject, type ProjectSelectionPrompt, } from '../project-selection-handler'; import { ProviderOAuthConfig } from './auth-types'; import { getTimeoutTroubleshooting, showStep } from './environment-detector'; import { isAuthenticated, registerAccountFromToken } from './token-manager'; -import { deviceCodeEvents, type DeviceCodePrompt } from '../device-code-handler'; +import { + deviceCodeEvents, + DEVICE_CODE_TIMEOUT_MS, + type DeviceCodePrompt, +} from '../device-code-handler'; import { OAUTH_FLOW_TYPES } from '../../management'; import { registerAuthSession, @@ -150,7 +155,7 @@ async function handleStdout( provider: options.provider, userCode: state.userCode, verificationUrl, - expiresAt: Date.now() + 900000, // 15 minutes + expiresAt: Date.now() + DEVICE_CODE_TIMEOUT_MS, }; deviceCodeEvents.emit('deviceCode:received', deviceCodePrompt); @@ -311,8 +316,13 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise | null = null; + // H5: Signal handling - properly kill child process on SIGINT/SIGTERM + // H8: Also clear stdinKeepalive interval to prevent memory leak const cleanup = () => { + if (stdinKeepalive) clearInterval(stdinKeepalive); if (authProcess && !authProcess.killed) { authProcess.kill('SIGTERM'); } @@ -347,6 +357,20 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { + if (authProcess.stdin && !authProcess.stdin.destroyed) { + authProcess.stdin.write('\n'); + log('Sent stdin keepalive (skip manual URL prompt)'); + } + }, 16000); + } + authProcess.stdout?.on('data', async (data: Buffer) => { await handleStdout(data.toString(), state, options, authProcess, log); }); @@ -393,11 +417,14 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { + // H7: Clear stdin keepalive interval + if (stdinKeepalive) clearInterval(stdinKeepalive); // H5: Remove signal handlers before killing process process.removeListener('SIGINT', cleanup); process.removeListener('SIGTERM', cleanup); authSessionEvents.removeListener('session:cancelled', handleCancel); unregisterAuthSession(state.sessionId); + cancelProjectSelection(state.sessionId); authProcess.kill(); console.log(''); console.log(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`)); @@ -409,11 +436,14 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { clearTimeout(timeout); + // H7: Clear stdin keepalive interval + if (stdinKeepalive) clearInterval(stdinKeepalive); // H5: Remove signal handlers to prevent memory leaks process.removeListener('SIGINT', cleanup); process.removeListener('SIGTERM', cleanup); authSessionEvents.removeListener('session:cancelled', handleCancel); unregisterAuthSession(state.sessionId); + cancelProjectSelection(state.sessionId); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); if (code === 0) { @@ -462,11 +492,14 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { clearTimeout(timeout); + // H7: Clear stdin keepalive interval + if (stdinKeepalive) clearInterval(stdinKeepalive); // H5: Remove signal handlers to prevent memory leaks process.removeListener('SIGINT', cleanup); process.removeListener('SIGTERM', cleanup); authSessionEvents.removeListener('session:cancelled', handleCancel); unregisterAuthSession(state.sessionId); + cancelProjectSelection(state.sessionId); console.log(''); console.log(fail(`Failed to start auth process: ${error.message}`)); resolve(null); diff --git a/src/cliproxy/auth/provider-refreshers/index.ts b/src/cliproxy/auth/provider-refreshers/index.ts new file mode 100644 index 00000000..232fe8c9 --- /dev/null +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -0,0 +1,69 @@ +/** + * Provider Token Refreshers + * + * Exports refresh functions for each OAuth provider. + * Currently only Gemini is implemented; others return placeholder errors. + */ + +import { CLIProxyProvider } from '../../types'; +import { refreshGeminiToken } from '../gemini-token-refresh'; + +/** Token refresh result */ +export interface ProviderRefreshResult { + success: boolean; + error?: string; + expiresAt?: number; +} + +/** + * Refresh token for a specific provider and account + * @param provider Provider to refresh + * @param _accountId Account ID (currently unused, multi-account not yet implemented) + * @returns Refresh result with success status and optional error + */ +export async function refreshToken( + provider: CLIProxyProvider, + _accountId: string +): Promise { + switch (provider) { + case 'gemini': + return await refreshGeminiTokenWrapper(); + + case 'codex': + case 'agy': + case 'qwen': + case 'iflow': + case 'kiro': + case 'ghcp': + return { + success: false, + error: `Token refresh not yet implemented for ${provider}`, + }; + + default: + return { + success: false, + error: `Unknown provider: ${provider}`, + }; + } +} + +/** + * Wrapper for Gemini token refresh + * Converts gemini-token-refresh.ts format to provider-refreshers format + */ +async function refreshGeminiTokenWrapper(): Promise { + const result = await refreshGeminiToken(); + + if (!result.success) { + return { + success: false, + error: result.error, + }; + } + + return { + success: true, + expiresAt: result.expiresAt, + }; +} diff --git a/src/cliproxy/auth/token-expiry-checker.ts b/src/cliproxy/auth/token-expiry-checker.ts new file mode 100644 index 00000000..d3833173 --- /dev/null +++ b/src/cliproxy/auth/token-expiry-checker.ts @@ -0,0 +1,131 @@ +/** + * Token Expiry Checker + * + * Inspects token files to determine expiry times and refresh requirements. + * Supports expiry_date field with fallback to file modification time. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { CLIProxyProvider } from '../types'; +import { getProviderAccounts, getAccountTokenPath } from '../account-manager'; + +/** Preemptive refresh time: refresh tokens 45 minutes before expiry */ +export const PREEMPTIVE_REFRESH_MINUTES = 45; + +/** Fallback expiry: assume 50 minutes if no expiry_date field */ +export const FALLBACK_EXPIRY_MINUTES = 50; + +/** Maximum token file size in bytes (1MB) - prevent DoS from huge files */ +const MAX_TOKEN_FILE_SIZE = 1024 * 1024; + +/** Token expiry information for a single account */ +export interface TokenExpiryInfo { + /** Provider name */ + provider: CLIProxyProvider; + /** Account ID */ + accountId: string; + /** Path to token file */ + tokenFile: string; + /** Expiry timestamp (Unix ms) */ + expiresAt: number; + /** Whether token needs refresh (within preemptive window) */ + needsRefresh: boolean; + /** Token file last modified time */ + lastModified: Date; +} + +/** + * Token file structure + */ +interface TokenData { + access_token?: string; + refresh_token?: string; + expiry_date?: number; // Unix timestamp ms + type?: string; +} + +/** + * Get token expiry info for a specific account + * @returns null if token file doesn't exist or is invalid + */ +export function getTokenExpiryInfo( + provider: CLIProxyProvider, + accountId: string +): TokenExpiryInfo | null { + const tokenPath = getAccountTokenPath(provider, accountId); + if (!tokenPath || !fs.existsSync(tokenPath)) { + return null; + } + + try { + const stats = fs.statSync(tokenPath); + + // Prevent DoS from huge token files + if (stats.size > MAX_TOKEN_FILE_SIZE) { + return null; + } + + const content = fs.readFileSync(tokenPath, 'utf-8'); + const data: TokenData = JSON.parse(content); + + // Validate refresh_token exists (required for refresh) + if (!data.refresh_token || typeof data.refresh_token !== 'string') { + return null; + } + + // Calculate expiry time with validation + let expiresAt: number; + if ( + data.expiry_date && + typeof data.expiry_date === 'number' && + Number.isFinite(data.expiry_date) && + data.expiry_date > 0 + ) { + // Use expiry_date field if valid + expiresAt = data.expiry_date; + } else { + // Fallback: use file mtime + 50 minutes + expiresAt = stats.mtime.getTime() + FALLBACK_EXPIRY_MINUTES * 60 * 1000; + } + + // Check if needs refresh (within preemptive window) + const now = Date.now(); + const timeUntilExpiry = expiresAt - now; + const preemptiveMs = PREEMPTIVE_REFRESH_MINUTES * 60 * 1000; + const needsRefresh = timeUntilExpiry < preemptiveMs; + + return { + provider, + accountId, + tokenFile: path.basename(tokenPath), + expiresAt, + needsRefresh, + lastModified: stats.mtime, + }; + } catch { + return null; + } +} + +/** + * Get token expiry info for all accounts across all providers + * @returns Array of token expiry info, excluding invalid tokens + */ +export function getAllTokenExpiryInfo(): TokenExpiryInfo[] { + const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp']; + const results: TokenExpiryInfo[] = []; + + for (const provider of providers) { + const accounts = getProviderAccounts(provider); + + for (const account of accounts) { + const info = getTokenExpiryInfo(provider, account.id); + if (info) { + results.push(info); + } + } + } + + return results; +} diff --git a/src/cliproxy/auth/token-refresh-config.ts b/src/cliproxy/auth/token-refresh-config.ts new file mode 100644 index 00000000..39b25f42 --- /dev/null +++ b/src/cliproxy/auth/token-refresh-config.ts @@ -0,0 +1,31 @@ +/** + * Token Refresh Configuration + * + * Loads token refresh worker settings from unified config. + * Returns null if disabled or not configured. + */ + +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import type { TokenRefreshSettings } from '../../config/unified-config-types'; + +/** + * Get token refresh configuration from unified config + * @returns Config if enabled, null if disabled or not configured + */ +export function getTokenRefreshConfig(): TokenRefreshSettings | null { + const config = loadOrCreateUnifiedConfig(); + + // Return null if not configured or explicitly disabled + if (!config.cliproxy?.token_refresh?.enabled) { + return null; + } + + // Return config with defaults + return { + enabled: true, + interval_minutes: config.cliproxy.token_refresh.interval_minutes ?? 30, + preemptive_minutes: config.cliproxy.token_refresh.preemptive_minutes ?? 45, + max_retries: config.cliproxy.token_refresh.max_retries ?? 3, + verbose: config.cliproxy.token_refresh.verbose ?? false, + }; +} diff --git a/src/cliproxy/auth/token-refresh-worker.ts b/src/cliproxy/auth/token-refresh-worker.ts new file mode 100644 index 00000000..071e63d9 --- /dev/null +++ b/src/cliproxy/auth/token-refresh-worker.ts @@ -0,0 +1,280 @@ +/** + * Token Refresh Worker + * + * Background worker that periodically checks and refreshes OAuth tokens + * before they expire. Runs as interval loop with retry logic. + */ + +import { CLIProxyProvider } from '../types'; +import { getAllTokenExpiryInfo, TokenExpiryInfo } from './token-expiry-checker'; +import { refreshToken } from './provider-refreshers'; + +/** Worker configuration */ +export interface TokenRefreshConfig { + /** Refresh check interval in minutes (default: 30) */ + refreshInterval: number; + /** Preemptive refresh time in minutes (default: 45) */ + preemptiveTime: number; + /** Maximum retry attempts per token (default: 3) */ + maxRetries: number; + /** Base delay for exponential backoff in ms (default: 1000) */ + retryBaseDelay: number; + /** Timeout for refresh operations in ms (default: 10000) */ + refreshTimeout: number; + /** Enable verbose logging */ + verbose: boolean; +} + +/** Result of a token refresh attempt */ +export interface RefreshResult { + provider: CLIProxyProvider; + accountId: string; + success: boolean; + error?: string; + refreshedAt?: Date; + nextExpiry?: number; +} + +/** Default worker configuration */ +const DEFAULT_CONFIG: TokenRefreshConfig = { + refreshInterval: 30, + preemptiveTime: 45, + maxRetries: 3, + retryBaseDelay: 1000, + refreshTimeout: 10000, + verbose: false, +}; + +/** Minimum config values to prevent infinite loops */ +const MIN_REFRESH_INTERVAL = 1; // 1 minute minimum +const MIN_RETRY_BASE_DELAY = 100; // 100ms minimum + +/** Unrecoverable error patterns - don't retry these */ +const UNRECOVERABLE_ERRORS = [ + 'No refresh token', + 'Invalid client', + 'Invalid grant', + 'Token has been revoked', + 'Token not found', +]; + +/** Validate and sanitize config values */ +function sanitizeConfig(config: TokenRefreshConfig): TokenRefreshConfig { + return { + refreshInterval: Math.max( + MIN_REFRESH_INTERVAL, + config.refreshInterval || DEFAULT_CONFIG.refreshInterval + ), + preemptiveTime: Math.max(0, config.preemptiveTime || DEFAULT_CONFIG.preemptiveTime), + maxRetries: Math.max(1, config.maxRetries || DEFAULT_CONFIG.maxRetries), + retryBaseDelay: Math.max( + MIN_RETRY_BASE_DELAY, + config.retryBaseDelay || DEFAULT_CONFIG.retryBaseDelay + ), + refreshTimeout: Math.max(1000, config.refreshTimeout || DEFAULT_CONFIG.refreshTimeout), + verbose: config.verbose ?? DEFAULT_CONFIG.verbose, + }; +} + +/** Promise with timeout */ +function withTimeout(promise: Promise, timeoutMs: number, errorMsg: string): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error(errorMsg)), timeoutMs)), + ]); +} + +/** + * Background token refresh worker + * Manages periodic token refresh checks with retry logic + */ +export class TokenRefreshWorker { + private config: TokenRefreshConfig; + private intervalId: NodeJS.Timeout | null = null; + private running = false; + private lastResults: RefreshResult[] = []; + private exitHandler: (() => void) | null = null; + + constructor(config: Partial = {}) { + this.config = sanitizeConfig({ ...DEFAULT_CONFIG, ...config }); + } + + /** + * Start the worker + * Runs refresh loop immediately, then on interval + */ + start(): void { + if (this.running) { + return; + } + + this.running = true; + this.log('[i] Token refresh worker started'); + + // Register process exit handlers for cleanup + this.exitHandler = () => this.stop(); + process.on('SIGINT', this.exitHandler); + process.on('SIGTERM', this.exitHandler); + process.on('beforeExit', this.exitHandler); + + // Run immediately on start + void this.refreshLoop(); + + // Then run on interval + const intervalMs = this.config.refreshInterval * 60 * 1000; + this.intervalId = setInterval(() => { + void this.refreshLoop(); + }, intervalMs); + } + + /** + * Stop the worker + */ + stop(): void { + if (!this.running) { + return; + } + + this.running = false; + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + + // Remove process exit handlers + if (this.exitHandler) { + process.off('SIGINT', this.exitHandler); + process.off('SIGTERM', this.exitHandler); + process.off('beforeExit', this.exitHandler); + this.exitHandler = null; + } + + this.log('[i] Token refresh worker stopped'); + } + + /** + * Check if worker is active + */ + isActive(): boolean { + return this.running; + } + + /** + * Manually trigger refresh check now + */ + async refreshNow(): Promise { + return await this.refreshLoop(); + } + + /** + * Get results from last refresh cycle + */ + getLastRefreshResults(): RefreshResult[] { + return [...this.lastResults]; + } + + /** + * Main refresh loop + * Checks all tokens and refreshes those needing refresh + */ + private async refreshLoop(): Promise { + const results: RefreshResult[] = []; + + try { + const tokens = getAllTokenExpiryInfo(); + const tokensNeedingRefresh = tokens.filter((t) => t.needsRefresh); + + if (tokensNeedingRefresh.length === 0) { + this.log('[OK] All tokens valid, no refresh needed'); + this.lastResults = []; + return results; + } + + this.log(`[i] Refreshing ${tokensNeedingRefresh.length} token(s)...`); + + for (const token of tokensNeedingRefresh) { + const result = await this.refreshWithRetry(token); + results.push(result); + + if (result.success) { + this.log(`[OK] ${token.provider}/${token.accountId} refreshed`); + } else { + this.log(`[X] ${token.provider}/${token.accountId} failed: ${result.error}`); + } + } + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + this.log(`[X] Refresh loop error: ${msg}`); + } + + this.lastResults = results; + return results; + } + + /** + * Refresh a single token with retry logic + * Uses exponential backoff on failures + */ + private async refreshWithRetry(token: TokenExpiryInfo): Promise { + let lastError = 'Unknown error'; + + for (let attempt = 0; attempt < this.config.maxRetries; attempt++) { + try { + // Apply timeout to refresh operation + const result = await withTimeout( + refreshToken(token.provider, token.accountId), + this.config.refreshTimeout, + `Refresh timeout after ${this.config.refreshTimeout}ms` + ); + + if (result.success) { + return { + provider: token.provider, + accountId: token.accountId, + success: true, + refreshedAt: new Date(), + nextExpiry: result.expiresAt, + }; + } + + lastError = result.error || 'Refresh failed'; + + // Don't retry if error indicates unrecoverable issue + if (this.isUnrecoverableError(lastError)) { + break; + } + } catch (error) { + lastError = error instanceof Error ? error.message : 'Unknown error'; + } + + // Exponential backoff before retry + if (attempt < this.config.maxRetries - 1) { + const delay = this.config.retryBaseDelay * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + return { + provider: token.provider, + accountId: token.accountId, + success: false, + error: lastError, + }; + } + + /** + * Check if error is unrecoverable (should not retry) + */ + private isUnrecoverableError(error: string): boolean { + return UNRECOVERABLE_ERRORS.some((pattern) => error.includes(pattern)); + } + + /** + * Log message if verbose enabled + */ + private log(msg: string): void { + if (this.config.verbose) { + console.error(`[token-refresh] ${msg}`); + } + } +} diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 51a33d45..7e52e81a 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -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 { - 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 diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts index 30156777..51867a4e 100644 --- a/src/cliproxy/binary/lifecycle.ts +++ b/src/cliproxy/binary/lifecycle.ts @@ -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 { 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 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); 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/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 0de849d6..6a031402 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -58,6 +58,7 @@ import { import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector'; import { withStartupLock } from './startup-lock'; import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; +import { fetchAccountQuota, findAvailableAccount } from './quota-fetcher'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -463,6 +464,40 @@ export async function execClaudeWithCLIProxy( } } + // 3b. Preflight quota check - auto-switch to account with quota before launch + // Only for agy (Antigravity) which has quota tracking + if (provider === 'agy') { + const defaultAccount = getDefaultAccount(provider); + if (defaultAccount) { + log(`Checking quota for ${defaultAccount.email || defaultAccount.id}`); + const quota = await fetchAccountQuota(provider, defaultAccount.id); + + // Check if current account is exhausted (no model with >5% quota) + const hasQuota = quota.success && quota.models.some((m) => m.percentage > 5); + + if (!hasQuota && quota.success) { + // Current account exhausted, try to find alternative + log('Current account quota exhausted, searching for alternatives...'); + const alternative = await findAvailableAccount(provider, defaultAccount.id); + + if (alternative) { + // Auto-switch to account with remaining quota + setDefaultAccount(provider, alternative.account.id); + touchAccount(provider, alternative.account.id); + console.log( + info( + `Auto-switched to ${alternative.account.email || alternative.account.id} (current account quota exhausted)` + ) + ); + } else { + // No alternatives available - warn but continue + console.log(warn('All accounts appear quota-exhausted')); + console.log(` Run: ccs cliproxy doctor`); + } + } + } + } + // 4. First-run model configuration (interactive) // For supported providers, prompt user to select model on first run // Pass customSettingsPath for CLIProxy variants diff --git a/src/cliproxy/platform-detector.ts b/src/cliproxy/platform-detector.ts index 1d8333b2..bbe76688 100644 --- a/src/cliproxy/platform-detector.ts +++ b/src/cliproxy/platform-detector.ts @@ -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; diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 749d9393..d2daa236 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -9,6 +9,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { getAuthDir } from './config-generator'; import { CLIProxyProvider } from './types'; +import { getProviderAccounts, type AccountInfo } from './account-manager'; /** Individual model quota info */ export interface ModelQuota { @@ -40,6 +41,10 @@ export interface QuotaResult { expiresAt?: string; /** True if account hasn't been activated in official Antigravity app */ isUnprovisioned?: boolean; + /** Account ID (email) this quota belongs to */ + accountId?: string; + /** GCP project ID for this account */ + projectId?: string; } /** Google Cloud Code API endpoints */ @@ -523,3 +528,129 @@ export async function fetchAccountQuota( return result; } + +/** + * Read project ID directly from auth file without making API call + * Used for quick project ID comparison in doctor command + */ +export function readProjectIdFromAuthFile( + provider: CLIProxyProvider, + accountId: string +): string | null { + const authData = readAuthData(provider, accountId); + return authData?.projectId || null; +} + +/** Result for all accounts of a provider */ +export interface AllAccountsQuotaResult { + /** Provider name */ + provider: CLIProxyProvider; + /** Results per account */ + accounts: Array<{ + account: AccountInfo; + quota: QuotaResult; + }>; + /** Accounts grouped by project ID (for detecting shared projects) */ + projectGroups: Record; + /** Timestamp of fetch */ + lastUpdated: number; +} + +/** + * Fetch quota for all accounts of a provider + * Also detects accounts sharing same GCP project (failover won't help) + * + * @param provider - Provider name (only 'agy' supported for quota) + * @returns Results for all accounts with project grouping + */ +export async function fetchAllProviderQuotas( + provider: CLIProxyProvider +): Promise { + const accounts = getProviderAccounts(provider); + const results: AllAccountsQuotaResult = { + provider, + accounts: [], + projectGroups: {}, + lastUpdated: Date.now(), + }; + + if (accounts.length === 0) { + return results; + } + + // Fetch quota for each account in parallel + const quotaPromises = accounts.map(async (account) => { + const quota = await fetchAccountQuota(provider, account.id); + + // Read project ID from auth file if not in quota result + let projectId = quota.projectId; + if (!projectId) { + projectId = readProjectIdFromAuthFile(provider, account.id) || undefined; + } + + return { + account, + quota: { ...quota, accountId: account.id, projectId }, + }; + }); + + const quotaResults = await Promise.all(quotaPromises); + + // Build project groups for detecting shared projects + for (const { account, quota } of quotaResults) { + results.accounts.push({ account, quota }); + + if (quota.projectId) { + if (!results.projectGroups[quota.projectId]) { + results.projectGroups[quota.projectId] = []; + } + results.projectGroups[quota.projectId].push(account.id); + } + } + + return results; +} + +/** + * Find available account with remaining quota + * Used by preflight check for auto-switching + * + * @param provider - Provider name + * @param excludeAccountId - Account to exclude (current exhausted account) + * @returns Account with available quota, or null if none available + */ +export async function findAvailableAccount( + provider: CLIProxyProvider, + excludeAccountId?: string +): Promise<{ account: AccountInfo; quota: QuotaResult } | null> { + const allQuotas = await fetchAllProviderQuotas(provider); + + // Get excluded account's project ID to avoid switching to same-project accounts + const excludedProjectId = allQuotas.accounts.find((a) => a.account.id === excludeAccountId)?.quota + .projectId; + + for (const { account, quota } of allQuotas.accounts) { + // Skip excluded account + if (excludeAccountId && account.id === excludeAccountId) { + continue; + } + + // Skip failed quota fetches + if (!quota.success) { + continue; + } + + // Skip accounts sharing same GCP project (quota is pooled) + if (excludedProjectId && quota.projectId === excludedProjectId) { + continue; + } + + // Check if any model has remaining quota (> 5% to avoid edge cases) + const hasQuota = quota.models.some((m) => m.percentage > 5); + if (hasQuota) { + return { account, quota }; + } + } + + return null; +} diff --git a/src/cliproxy/service-manager.ts b/src/cliproxy/service-manager.ts index 5befc836..6788c66c 100644 --- a/src/cliproxy/service-manager.ts +++ b/src/cliproxy/service-manager.ts @@ -25,10 +25,15 @@ import { registerSession } from './session-tracker'; import { detectRunningProxy, waitForProxyHealthy } from './proxy-detector'; import { withStartupLock } from './startup-lock'; import { isCliproxyRunning } from './stats-fetcher'; +import { TokenRefreshWorker, type RefreshResult } from './auth/token-refresh-worker'; +import { getTokenRefreshConfig } from './auth/token-refresh-config'; /** Background proxy process reference */ let proxyProcess: ChildProcess | null = null; +/** Token refresh worker instance */ +let tokenRefreshWorker: TokenRefreshWorker | null = null; + /** Cleanup registered flag */ let cleanupRegistered = false; @@ -77,6 +82,12 @@ function registerCleanup(): void { if (cleanupRegistered) return; const cleanup = () => { + // Stop token refresh worker first + if (tokenRefreshWorker && tokenRefreshWorker.isActive()) { + tokenRefreshWorker.stop(); + tokenRefreshWorker = null; + } + // Then stop proxy process if (proxyProcess && !proxyProcess.killed) { proxyProcess.kill('SIGTERM'); proxyProcess = null; @@ -90,6 +101,38 @@ function registerCleanup(): void { cleanupRegistered = true; } +/** + * Start token refresh worker if configured + * @param verbose Enable verbose logging + */ +function startTokenRefreshWorker(verbose: boolean): void { + // Skip if already running + if (tokenRefreshWorker && tokenRefreshWorker.isActive()) { + return; + } + + // Load config + const config = getTokenRefreshConfig(); + if (!config) { + // Not configured or disabled + return; + } + + // Create and start worker + tokenRefreshWorker = new TokenRefreshWorker({ + refreshInterval: config.interval_minutes ?? 30, + preemptiveTime: config.preemptive_minutes ?? 45, + maxRetries: config.max_retries ?? 3, + verbose: config.verbose || verbose, + }); + + tokenRefreshWorker.start(); + + if (verbose) { + console.error('[i] Token refresh worker started'); + } +} + export interface ServiceStartResult { started: boolean; alreadyRunning: boolean; @@ -254,6 +297,9 @@ export async function ensureCliproxyService( log(`Session registered for PID ${proxyProcess.pid}`); } + // 6. Start token refresh worker if configured + startTokenRefreshWorker(verbose); + return { started: true, alreadyRunning: false, port }; }); } @@ -262,6 +308,13 @@ export async function ensureCliproxyService( * Stop the managed CLIProxy service */ export function stopCliproxyService(): boolean { + // Stop token refresh worker first + if (tokenRefreshWorker && tokenRefreshWorker.isActive()) { + tokenRefreshWorker.stop(); + tokenRefreshWorker = null; + } + + // Then stop proxy process if (proxyProcess && !proxyProcess.killed) { proxyProcess.kill('SIGTERM'); proxyProcess = null; @@ -283,3 +336,27 @@ export async function getServiceStatus(port: number = CLIPROXY_DEFAULT_PORT): Pr return { running, managedByUs, port }; } + +/** + * Check if token refresh worker is running + */ +export function isTokenRefreshWorkerRunning(): boolean { + return tokenRefreshWorker !== null && tokenRefreshWorker.isActive(); +} + +/** + * Get token refresh worker status + */ +export function getTokenRefreshStatus(): { + running: boolean; + lastResults: RefreshResult[] | null; +} { + if (!tokenRefreshWorker) { + return { running: false, lastResults: null }; + } + + return { + running: tokenRefreshWorker.isActive(), + lastResults: tokenRefreshWorker.getLastRefreshResults(), + }; +} diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index bce8a3b9..080d271b 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -21,6 +21,7 @@ import * as path from 'path'; import { getAllAuthStatus, getOAuthConfig, triggerOAuth } from '../cliproxy/auth-handler'; import { getProviderAccounts } from '../cliproxy/account-manager'; +import { fetchAllProviderQuotas } from '../cliproxy/quota-fetcher'; import { CLIPROXY_FALLBACK_VERSION } from '../cliproxy/platform-detector'; import { CLIPROXY_PROFILES, CLIProxyProfileName } from '../auth/profile-detector'; import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../cliproxy/model-catalog'; @@ -548,6 +549,7 @@ async function showHelp(): Promise { [ ['status', 'Show running CLIProxy status'], ['stop', 'Stop running CLIProxy instance'], + ['doctor', 'Quota diagnostics and shared project detection'], ], ], [ @@ -579,6 +581,116 @@ async function showHelp(): Promise { console.log(''); } +// ============================================================================ +// DOCTOR COMMAND - Quota diagnostics and shared project detection +// ============================================================================ + +async function handleDoctor(): Promise { + await initUI(); + console.log(header('CLIProxy Quota Diagnostics')); + console.log(''); + + // Check each OAuth provider (agy is the only one with quota) + const provider: CLIProxyProvider = 'agy'; + const accounts = getProviderAccounts(provider); + + if (accounts.length === 0) { + console.log(info('No Antigravity accounts configured')); + console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`); + return; + } + + console.log(subheader(`Antigravity Accounts (${accounts.length})`)); + console.log(''); + + // Fetch quota for all accounts + console.log(dim('Fetching quotas...')); + const quotaResult = await fetchAllProviderQuotas(provider); + + // Display per-account quota status + for (const { account, quota } of quotaResult.accounts) { + const accountLabel = account.email || account.id || 'Unknown Account'; + const defaultBadge = account.isDefault ? color(' (default)', 'info') : ''; + + if (!quota.success) { + console.log(` ${fail(accountLabel)}${defaultBadge}`); + console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); + if (quota.isUnprovisioned) { + console.log( + ` ${warn('Account not provisioned - open Gemini Code Assist in IDE first')}` + ); + } + console.log(''); + continue; + } + + // Calculate overall quota health (guard against empty models array) + const avgQuota = + quota.models.length > 0 + ? quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length + : 0; + const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail(''); + + console.log(` ${statusIcon}${accountLabel}${defaultBadge}`); + if (quota.projectId) { + console.log(` Project: ${dim(quota.projectId)}`); + } + + // Show model quotas + for (const model of quota.models) { + const bar = formatQuotaBar(model.percentage); + console.log(` ${model.name.padEnd(20)} ${bar} ${model.percentage.toFixed(0)}%`); + } + console.log(''); + } + + // Check for shared GCP projects (critical warning) + const sharedProjects = Object.entries(quotaResult.projectGroups).filter( + ([, accountIds]) => accountIds.length > 1 + ); + + if (sharedProjects.length > 0) { + console.log(''); + console.log(subheader('Shared Project Warning')); + console.log(''); + for (const [projectId, accountIds] of sharedProjects) { + console.log( + fail(`Project ${projectId.substring(0, 20)}... shared by ${accountIds.length} accounts:`) + ); + for (const accountId of accountIds) { + console.log(` - ${accountId}`); + } + console.log(''); + console.log(warn('These accounts share the same quota pool!')); + console.log(warn('Failover between them will NOT help when quota is exhausted.')); + console.log(info('Solution: Use accounts from different GCP projects.')); + } + } + + // Summary + console.log(''); + console.log(subheader('Summary')); + const healthyAccounts = quotaResult.accounts.filter( + ({ quota }) => quota.success && quota.models.some((m) => m.percentage > 5) + ); + console.log(` Accounts with quota: ${healthyAccounts.length}/${accounts.length}`); + if (sharedProjects.length > 0) { + console.log(` ${fail(`Shared projects: ${sharedProjects.length} (failover limited)`)}`); + } else if (accounts.length > 1) { + console.log(` ${ok('No shared projects (failover fully operational)')}`); + } + console.log(''); +} + +function formatQuotaBar(percentage: number): string { + const width = 20; + const clampedPct = Math.max(0, Math.min(100, percentage)); + const filled = Math.round((clampedPct / 100) * width); + const empty = width - filled; + const filledChar = clampedPct > 50 ? '█' : clampedPct > 10 ? '▓' : '░'; + return `[${filledChar.repeat(filled)}${' '.repeat(empty)}]`; +} + // ============================================================================ // MAIN ROUTER // ============================================================================ @@ -617,6 +729,11 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } + if (command === 'doctor' || command === 'diag') { + await handleDoctor(); + return; + } + const installIdx = args.indexOf('--install'); if (installIdx !== -1) { let version = args[installIdx + 1]; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index ddfa96ef..840ad3d5 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -256,6 +256,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); printSubSection('CLI Proxy Plus Management', [ ['ccs cliproxy', 'Show CLIProxy Plus status and version'], ['ccs cliproxy --help', 'Full CLIProxy Plus management help'], + ['ccs cliproxy doctor', 'Quota diagnostics (Antigravity)'], ['ccs cliproxy --install ', 'Install specific version (e.g., 6.6.6)'], ['ccs cliproxy --latest', 'Update to latest version'], ]); diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 166349cb..6a20a4d4 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -93,6 +93,23 @@ export interface CLIProxyLoggingConfig { request_log?: boolean; } +/** + * Token refresh configuration. + * Manages background token refresh worker settings. + */ +export interface TokenRefreshSettings { + /** Enable background token refresh (default: false) */ + enabled?: boolean; + /** Refresh check interval in minutes (default: 30) */ + interval_minutes?: number; + /** Preemptive refresh time in minutes (default: 45) */ + preemptive_minutes?: number; + /** Maximum retry attempts per token (default: 3) */ + max_retries?: number; + /** Enable verbose logging (default: false) */ + verbose?: boolean; +} + /** * CLIProxy configuration section. */ @@ -109,6 +126,8 @@ export interface CLIProxyConfig { kiro_no_incognito?: boolean; /** Global auth configuration for CLIProxyAPI */ auth?: CLIProxyAuthConfig; + /** Background token refresh worker settings */ + token_refresh?: TokenRefreshSettings; } /** diff --git a/src/management/oauth-port-diagnostics.ts b/src/management/oauth-port-diagnostics.ts index e3d28317..262d963d 100644 --- a/src/management/oauth-port-diagnostics.ts +++ b/src/management/oauth-port-diagnostics.ts @@ -31,7 +31,7 @@ export const OAUTH_CALLBACK_PORTS: Record = { codex: 1455, agy: 51121, qwen: null, // Device Code Flow - no callback port - iflow: null, // Device Code Flow - no callback port + iflow: 11451, // Authorization Code Flow kiro: 9876, // Authorization Code Flow ghcp: null, // Device Code Flow - no callback port }; @@ -49,7 +49,7 @@ export const OAUTH_FLOW_TYPES: Record = { codex: 'authorization_code', agy: 'authorization_code', qwen: 'device_code', - iflow: 'device_code', + iflow: 'authorization_code', kiro: 'authorization_code', ghcp: 'device_code', }; diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 7edfcddc..65ea5ed1 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -194,6 +194,44 @@ class SharedManager { } } } + + // Normalize plugin registry paths after linking + this.normalizePluginRegistryPaths(); + } + + /** + * Normalize plugin registry paths to use canonical ~/.claude/ paths + * instead of instance-specific ~/.ccs/instances// paths. + * + * This ensures installed_plugins.json is consistent regardless of + * which CCS instance installed the plugin. + */ + normalizePluginRegistryPaths(): void { + const registryPath = path.join(this.claudeDir, 'plugins', 'installed_plugins.json'); + + // Skip if registry doesn't exist + if (!fs.existsSync(registryPath)) { + return; + } + + try { + const original = fs.readFileSync(registryPath, 'utf8'); + + // Replace instance paths with canonical claude path + // Pattern: /.ccs/instances// -> /.claude/ + const normalized = original.replace(/\/\.ccs\/instances\/[^/]+\//g, '/.claude/'); + + // Only write if changes were made + if (normalized !== original) { + // Validate JSON before writing + JSON.parse(normalized); + fs.writeFileSync(registryPath, normalized, 'utf8'); + console.log(ok('Normalized plugin registry paths')); + } + } catch (err) { + // Log warning but don't fail - registry may be malformed + console.log(warn(`Could not normalize plugin registry: ${(err as Error).message}`)); + } } /** diff --git a/src/utils/port-utils.ts b/src/utils/port-utils.ts index 31f87208..4ddb9313 100644 --- a/src/utils/port-utils.ts +++ b/src/utils/port-utils.ts @@ -218,13 +218,16 @@ export async function testLocalhostBinding(port: number): Promise { - if (err.code === 'EADDRINUSE') { - resolve({ success: false, message: `Port ${port} is already in use` }); - } else if (err.code === 'EACCES') { - resolve({ success: false, message: `Permission denied for port ${port}` }); - } else { - resolve({ success: false, message: `Cannot bind to port ${port}: ${err.message}` }); - } + // H8: Close server to prevent fd leak on error path + server.close(() => { + if (err.code === 'EADDRINUSE') { + resolve({ success: false, message: `Port ${port} is already in use` }); + } else if (err.code === 'EACCES') { + resolve({ success: false, message: `Permission denied for port ${port}` }); + } else { + resolve({ success: false, message: `Cannot bind to port ${port}: ${err.message}` }); + } + }); }); server.once('listening', () => { diff --git a/src/web-server/health/cliproxy-checks.ts b/src/web-server/health/cliproxy-checks.ts index 5c1ee2f1..fb41c7af 100644 --- a/src/web-server/health/cliproxy-checks.ts +++ b/src/web-server/health/cliproxy-checks.ts @@ -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', 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/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts new file mode 100644 index 00000000..f9124a58 --- /dev/null +++ b/tests/unit/shared-manager.test.ts @@ -0,0 +1,104 @@ +/** + * Unit tests for SharedManager - plugin registry path normalization + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Test the normalization regex pattern directly +const normalizePluginPaths = (content: string): string => { + return content.replace(/\/\.ccs\/instances\/[^/]+\//g, '/.claude/'); +}; + +describe('SharedManager', () => { + describe('normalizePluginRegistryPaths', () => { + describe('regex pattern', () => { + it('should replace instance paths with canonical claude path', () => { + const input = '/home/user/.ccs/instances/ck/plugins/cache/plugin/0.0.2'; + const expected = '/home/user/.claude/plugins/cache/plugin/0.0.2'; + expect(normalizePluginPaths(input)).toBe(expected); + }); + + it('should handle different instance names', () => { + const inputs = [ + '/home/user/.ccs/instances/work/plugins/cache/plugin/1.0.0', + '/home/user/.ccs/instances/personal/plugins/cache/plugin/1.0.0', + '/home/user/.ccs/instances/test-account/plugins/cache/plugin/1.0.0', + ]; + for (const input of inputs) { + expect(normalizePluginPaths(input)).toContain('/.claude/'); + expect(normalizePluginPaths(input)).not.toContain('/.ccs/instances/'); + } + }); + + it('should handle multiple occurrences', () => { + const input = JSON.stringify({ + plugins: { + 'plugin-a': [{ installPath: '/home/user/.ccs/instances/ck/plugins/a' }], + 'plugin-b': [{ installPath: '/home/user/.ccs/instances/work/plugins/b' }], + }, + }); + const result = normalizePluginPaths(input); + expect(result).not.toContain('/.ccs/instances/'); + expect(result.match(/\.claude/g)?.length).toBe(2); + }); + + it('should not modify already-canonical paths', () => { + const input = '/home/user/.claude/plugins/cache/plugin/0.0.2'; + expect(normalizePluginPaths(input)).toBe(input); + }); + + it('should be idempotent', () => { + const input = '/home/user/.ccs/instances/ck/plugins/cache/plugin/0.0.2'; + const first = normalizePluginPaths(input); + const second = normalizePluginPaths(first); + expect(first).toBe(second); + }); + + it('should preserve JSON structure', () => { + const original = { + version: 2, + plugins: { + 'claude-hud@claude-hud': [ + { + scope: 'user', + installPath: '/home/kai/.ccs/instances/ck/plugins/cache/claude-hud/claude-hud/0.0.2', + version: '0.0.2', + }, + ], + }, + }; + const input = JSON.stringify(original, null, 2); + const result = normalizePluginPaths(input); + + // Should be valid JSON + expect(() => JSON.parse(result)).not.toThrow(); + + // Should have normalized path + const parsed = JSON.parse(result); + expect(parsed.plugins['claude-hud@claude-hud'][0].installPath).toBe( + '/home/kai/.claude/plugins/cache/claude-hud/claude-hud/0.0.2' + ); + }); + }); + + describe('edge cases', () => { + it('should handle empty object', () => { + const input = JSON.stringify({}); + expect(normalizePluginPaths(input)).toBe(input); + }); + + it('should handle plugins without installPath', () => { + const input = JSON.stringify({ plugins: {} }); + expect(normalizePluginPaths(input)).toBe(input); + }); + + it('should handle Windows-style paths (backslash)', () => { + // Windows paths use backslashes, regex should not match + const input = 'C:\\Users\\user\\.ccs\\instances\\ck\\plugins\\cache'; + expect(normalizePluginPaths(input)).toBe(input); + }); + }); + }); +}); diff --git a/ui/src/components/cliproxy/cliproxy-header.tsx b/ui/src/components/cliproxy/cliproxy-header.tsx index a2d39178..956d37ca 100644 --- a/ui/src/components/cliproxy/cliproxy-header.tsx +++ b/ui/src/components/cliproxy/cliproxy-header.tsx @@ -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(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'} + {versionInfo && ( + + {!versionInfo.isStable && }v + {versionInfo.currentVersion} + + )} + {lastUpdatedText && ( {lastUpdatedText} )} diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index f0801c16..2417b6d6 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -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} + + + ); +} + 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(''); + + // 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({ @@ -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 ( -
-
-
-
+
+ {/* Header row: Status dot, title, icon buttons */} +
+
+ {/* Status indicator */} +
+ CLIProxy Plus +
+ + {/* Right side: icon buttons when running */} +
+ {isLoading ? ( + + ) : isRunning ? ( + <> + restartProxy.mutate()} + disabled={isActioning} + isPending={restartProxy.isPending} + /> + stopProxy.mutate()} + disabled={isActioning} + isPending={stopProxy.isPending} + variant="destructive-ghost" + /> + setIsExpanded(!isExpanded)} + className={isExpanded ? 'bg-muted' : undefined} + /> + + ) : ( + )} - /> - CLIProxy Plus - {hasUpdate && ( - v${updateCheck?.latestVersion}`} +
+
+ + {/* Version row: version + update badge */} + {currentVersion && ( +
+ - - Update - - )} -
+ v{currentVersion} + + {(hasUpdate || isUnstable) && targetVersion && ( + handleInstallVersion(targetVersion)} + title={`Click to ${isUnstable ? 'downgrade' : 'update'}`} + > + {isUnstable ? ( + + ) : ( + + )} + {targetVersion} + + )} +
+ )} -
- {isLoading ? ( - - ) : isRunning ? ( - - ) : ( - - )} -
-
- - {isRunning && status ? ( - <> + {/* Stats row when running */} + {isRunning && status && (
Port {status.port} {status.sessionCount !== undefined && status.sessionCount > 0 && ( @@ -188,81 +370,138 @@ export function ProxyStatusWidget() { )}
- {/* Control buttons when running */} -
- +
+ + {/* Stability warning for selected version */} + {selectedVersion && + versionsData?.maxStableVersion && + isNewerVersionClient(selectedVersion, versionsData.maxStableVersion) && ( +
+ + Versions above {versionsData.maxStableVersion} have known issues +
+ )} + + {/* Sync time */} + {updateCheck?.checkedAt && ( +
+ Last checked {formatTimeAgo(updateCheck.checkedAt)} +
)} - onClick={handleRestart} - disabled={isActioning} - title={ - hasUpdate - ? `Restart to update: v${updateCheck?.currentVersion} -> v${updateCheck?.latestVersion}` - : 'Restart CLIProxy service' - } - > - {isActioning ? ( - - ) : hasUpdate ? ( - - ) : ( - - )} - {hasUpdate ? 'Update' : 'Restart'} - + + + )} + + {/* Not running state */} + {!isRunning && ( +
+ Not running
- - ) : ( -
- Not running - -
- )} + )} - {/* Version sync indicator */} - {updateCheck?.currentVersion && ( -
- v{updateCheck.currentVersion} - {updateCheck.checkedAt && ( - - Synced {formatTimeAgo(updateCheck.checkedAt)} - - )} -
- )} -
+ {/* 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..aae75b09 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -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('/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'),