mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-09 12:23:30 +00:00
Merge pull request #283 from kaitranntt/dev
feat(release): v7.14.0 - OAuth fixes, quota failover, background token refresh
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
|
||||
+15
-1
@@ -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 <command>
|
||||
|
||||
# When done developing:
|
||||
bun run dev:unlink # Restores original global ccs
|
||||
|
||||
# Run tests
|
||||
# Test with: ccs <command>
|
||||
|
||||
# Run tests
|
||||
bun run test # All tests
|
||||
|
||||
@@ -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).
|
||||
|
||||
<br>
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
Executable
+115
@@ -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 <command>"
|
||||
echo "To restore original: $0 --restore"
|
||||
echo ""
|
||||
echo "Test with: ccs --version"
|
||||
@@ -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<string, ActiveAuthSession>();
|
||||
|
||||
// H8: Periodic cleanup of stale sessions (prevents memory leak from orphaned sessions)
|
||||
let cleanupInterval: ReturnType<typeof setInterval> | 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Record<CLIProxyProvider, number>> = {
|
||||
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
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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<Accou
|
||||
env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir },
|
||||
});
|
||||
|
||||
// H7: Mutable ref for stdin keepalive interval (set later, needed in cleanup)
|
||||
let stdinKeepalive: ReturnType<typeof setInterval> | 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<Accou
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// H7: Stdin keepalive for Authorization Code flows
|
||||
// CLIProxyAPIPlus has a 15-second timer that prompts for manual URL paste.
|
||||
// If the user completes browser auth after this timer fires but before the
|
||||
// non-blocking check, the prompt blocks forever on stdin.
|
||||
// Workaround: Send newline every 16s to skip the manual prompt and continue polling.
|
||||
if (!isDeviceCodeFlow && stdinMode === 'pipe') {
|
||||
stdinKeepalive = setInterval(() => {
|
||||
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<Accou
|
||||
// Timeout handling
|
||||
const timeoutMs = headless ? 300000 : 120000;
|
||||
const timeout = setTimeout(() => {
|
||||
// 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<Accou
|
||||
|
||||
authProcess.on('exit', async (code) => {
|
||||
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<Accou
|
||||
|
||||
authProcess.on('error', (error) => {
|
||||
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);
|
||||
|
||||
@@ -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<ProviderRefreshResult> {
|
||||
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<ProviderRefreshResult> {
|
||||
const result = await refreshGeminiToken();
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
expiresAt: result.expiresAt,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<T>(promise: Promise<T>, timeoutMs: number, errorMsg: string): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, 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<TokenRefreshConfig> = {}) {
|
||||
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<RefreshResult[]> {
|
||||
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<RefreshResult[]> {
|
||||
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<RefreshResult> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string, string[]>;
|
||||
/** 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<AllAccountsQuotaResult> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
[
|
||||
['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<void> {
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DOCTOR COMMAND - Quota diagnostics and shared project detection
|
||||
// ============================================================================
|
||||
|
||||
async function handleDoctor(): Promise<void> {
|
||||
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<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'doctor' || command === 'diag') {
|
||||
await handleDoctor();
|
||||
return;
|
||||
}
|
||||
|
||||
const installIdx = args.indexOf('--install');
|
||||
if (installIdx !== -1) {
|
||||
let version = args[installIdx + 1];
|
||||
|
||||
@@ -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 <ver>', 'Install specific version (e.g., 6.6.6)'],
|
||||
['ccs cliproxy --latest', 'Update to latest version'],
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,7 +31,7 @@ export const OAUTH_CALLBACK_PORTS: Record<CLIProxyProvider, number | null> = {
|
||||
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<CLIProxyProvider, OAuthFlowType> = {
|
||||
codex: 'authorization_code',
|
||||
agy: 'authorization_code',
|
||||
qwen: 'device_code',
|
||||
iflow: 'device_code',
|
||||
iflow: 'authorization_code',
|
||||
kiro: 'authorization_code',
|
||||
ghcp: 'device_code',
|
||||
};
|
||||
|
||||
@@ -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/<name>/ 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/<instance-name>/ -> /.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}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
-7
@@ -218,13 +218,16 @@ export async function testLocalhostBinding(port: number): Promise<BindingTestRes
|
||||
const server = net.createServer();
|
||||
|
||||
server.once('error', (err: NodeJS.ErrnoException) => {
|
||||
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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user