diff --git a/package.json b/package.json index b8c32a20..bcc01c74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.29.0", + "version": "7.29.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index 57495035..b1044cdc 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -16,8 +16,8 @@ import { CLIPROXY_PROFILES } from '../auth/profile-detector'; import { getCliproxyDir, getAuthDir } from './config-generator'; import { PROVIDER_TYPE_VALUES } from './auth/auth-types'; -/** Account tier for quota management (free vs paid - no Pro/Ultra distinction needed) */ -export type AccountTier = 'free' | 'paid' | 'unknown'; +/** Account tier for quota management: ultra > pro > free */ +export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown'; /** * Providers that typically have empty email in OAuth token files. @@ -47,7 +47,7 @@ export interface AccountInfo { paused?: boolean; /** ISO timestamp when paused */ pausedAt?: string; - /** Account tier: free or paid (Pro/Ultra combined) */ + /** Account tier: ultra, pro, or free */ tier?: AccountTier; /** GCP Project ID (Antigravity only) - read-only, fetched from auth token */ projectId?: string; diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index df83c881..f762e438 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -166,3 +166,26 @@ export { THINKING_BUDGET_MAX, THINKING_BUDGET_DEFAULT_MIN, } from './thinking-validator'; + +// Management API client (for remote CLIProxy sync) +export type { + ClaudeKey, + ClaudeModel, + ManagementClientConfig, + ManagementHealthStatus, + ManagementApiErrorCode, + ClaudeKeyPatch, + SyncStatus, +} from './management-api-types'; +export { ManagementApiClient, createManagementClient } from './management-api-client'; + +// Sync module (profile sync to remote CLIProxy) +export type { SyncableProfile, SyncPreviewItem } from './sync'; +export { + loadSyncableProfiles, + mapProfileToClaudeKey, + generateSyncPayload, + generateSyncPreview, + getSyncableProfileCount, + isProfileSyncable, +} from './sync'; diff --git a/src/cliproxy/management-api-client.ts b/src/cliproxy/management-api-client.ts new file mode 100644 index 00000000..52e89a1b --- /dev/null +++ b/src/cliproxy/management-api-client.ts @@ -0,0 +1,405 @@ +/** + * Management API Client for CLIProxyAPI + * + * HTTP client for CLIProxy Management API endpoints. + * Handles authentication, error mapping, and provides typed methods for CRUD operations. + */ + +import * as https from 'https'; +import type { + ManagementClientConfig, + ManagementHealthStatus, + ManagementApiErrorCode, + ClaudeKey, + GetClaudeKeysResponse, + ClaudeKeyPatch, +} from './management-api-types'; + +/** Default timeout for management operations (longer than health check) */ +const DEFAULT_TIMEOUT_MS = 5000; + +/** Default port for HTTP protocol */ +const DEFAULT_HTTP_PORT = 8317; + +/** Default port for HTTPS protocol */ +const DEFAULT_HTTPS_PORT = 443; + +/** + * Get effective port based on config and protocol. + */ +function getEffectivePort(port: number | undefined, protocol: 'http' | 'https'): number { + if (port !== undefined && Number.isInteger(port) && port > 0 && port <= 65535) { + return port; + } + return protocol === 'https' ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT; +} + +/** + * Build URL for Management API endpoint. + */ +function buildUrl(config: ManagementClientConfig, path: string): string { + const port = getEffectivePort(config.port, config.protocol); + // Only omit port if it matches standard web ports + if ( + (config.protocol === 'https' && port === 443) || + (config.protocol === 'http' && port === 80) + ) { + return `${config.protocol}://${config.host}${path}`; + } + return `${config.protocol}://${config.host}:${port}${path}`; +} + +/** + * Map error to ManagementApiErrorCode. + */ +function mapErrorToCode(error: Error, statusCode?: number): ManagementApiErrorCode { + const message = error.message.toLowerCase(); + const rawCode = (error as NodeJS.ErrnoException).code; + const code = typeof rawCode === 'string' ? rawCode.toLowerCase() : undefined; + + // DNS resolution failed + if (code === 'enotfound' || code === 'eai_again' || message.includes('dns')) { + return 'DNS_FAILED'; + } + + // Network unreachable + if (code === 'enetunreach' || code === 'ehostunreach' || message.includes('unreachable')) { + return 'NETWORK_UNREACHABLE'; + } + + // Connection refused + if (code === 'econnrefused' || message.includes('connection refused')) { + return 'CONNECTION_REFUSED'; + } + + // Timeout + if (code === 'etimedout' || message.includes('timeout') || message.includes('aborted')) { + return 'TIMEOUT'; + } + + // HTTP status codes + if (statusCode === 401 || statusCode === 403) { + return 'AUTH_FAILED'; + } + if (statusCode === 404) { + return 'NOT_FOUND'; + } + if (statusCode === 400) { + return 'BAD_REQUEST'; + } + if (statusCode && statusCode >= 500) { + return 'SERVER_ERROR'; + } + + return 'UNKNOWN'; +} + +/** + * Get human-readable error message from error code. + */ +function getErrorMessage(errorCode: ManagementApiErrorCode, rawError?: string): string { + switch (errorCode) { + case 'CONNECTION_REFUSED': + return 'Connection refused - is CLIProxy running?'; + case 'TIMEOUT': + return 'Request timed out - server may be slow or unreachable'; + case 'AUTH_FAILED': + return 'Authentication failed - check management key'; + case 'DNS_FAILED': + return 'DNS lookup failed - check hostname'; + case 'NETWORK_UNREACHABLE': + return 'Network unreachable - check if host is accessible'; + case 'NOT_FOUND': + return 'Endpoint not found - check CLIProxy version'; + case 'BAD_REQUEST': + return 'Invalid request - check payload format'; + case 'SERVER_ERROR': + return 'Server error - check CLIProxy logs'; + default: + return rawError || 'Request failed'; + } +} + +/** + * Management API Client for CLIProxyAPI. + * Provides typed methods for CRUD operations on claude-api-key configuration. + */ +export class ManagementApiClient { + private readonly config: ManagementClientConfig; + private readonly timeout: number; + + constructor(config: ManagementClientConfig) { + this.config = config; + this.timeout = config.timeout ?? DEFAULT_TIMEOUT_MS; + } + + /** + * Build base URL for display purposes. + */ + getBaseUrl(): string { + return buildUrl(this.config, ''); + } + + /** + * Check health of Management API. + * Uses GET /v0/management/claude-api-key as health check. + */ + async health(): Promise { + const startTime = Date.now(); + try { + const response = await this.request('GET', '/v0/management/claude-api-key'); + const latencyMs = Date.now() - startTime; + + return { + healthy: true, + latencyMs, + version: response.headers?.['x-cpa-version'], + commit: response.headers?.['x-cpa-commit'], + }; + } catch (error) { + const err = error as Error & { statusCode?: number; errorCode?: ManagementApiErrorCode }; + return { + healthy: false, + error: err.message, + errorCode: err.errorCode ?? 'UNKNOWN', + }; + } + } + + /** + * Get all claude-api-key entries from remote CLIProxy. + */ + async getClaudeKeys(): Promise { + const response = await this.request( + 'GET', + '/v0/management/claude-api-key' + ); + return response.data?.['claude-api-key'] ?? []; + } + + /** + * Replace all claude-api-key entries on remote CLIProxy. + * This is an atomic operation - all entries are replaced at once. + */ + async putClaudeKeys(keys: ClaudeKey[]): Promise { + await this.request('PUT', '/v0/management/claude-api-key', keys); + } + + /** + * Update a single claude-api-key entry by index or api-key match. + */ + async patchClaudeKey(patch: ClaudeKeyPatch): Promise { + await this.request('PATCH', '/v0/management/claude-api-key', patch); + } + + /** + * Delete a claude-api-key entry by api-key value. + */ + async deleteClaudeKey(apiKey: string): Promise { + const encodedKey = encodeURIComponent(apiKey); + await this.request('DELETE', `/v0/management/claude-api-key?api-key=${encodedKey}`); + } + + /** + * Make an HTTP request to the Management API. + */ + private async request( + method: string, + path: string, + body?: unknown + ): Promise<{ data?: T; headers?: Record }> { + const url = buildUrl(this.config, path); + + const headers: Record = { + Accept: 'application/json', + Authorization: `Bearer ${this.config.managementKey}`, + }; + + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + + // Use native https for self-signed cert support + if (this.config.protocol === 'https' && this.config.allowSelfSigned) { + return this.requestWithHttps(method, url, headers, body); + } + + return this.requestWithFetch(method, url, headers, body); + } + + /** + * Make request using native fetch API. + */ + private async requestWithFetch( + method: string, + url: string, + headers: Record, + body?: unknown + ): Promise<{ data?: T; headers?: Record }> { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorCode = mapErrorToCode(new Error(response.statusText), response.status); + const error = new Error(getErrorMessage(errorCode)) as Error & { + statusCode: number; + errorCode: ManagementApiErrorCode; + }; + error.statusCode = response.status; + error.errorCode = errorCode; + throw error; + } + + // Extract headers we care about + const responseHeaders: Record = {}; + const version = response.headers.get('x-cpa-version'); + const commit = response.headers.get('x-cpa-commit'); + if (version) responseHeaders['x-cpa-version'] = version; + if (commit) responseHeaders['x-cpa-commit'] = commit; + + // Parse JSON response if present + const text = await response.text(); + let data: T | undefined; + if (text) { + try { + data = JSON.parse(text) as T; + } catch { + // Non-JSON response is ok for PUT/DELETE + } + } + + return { data, headers: responseHeaders }; + } catch (error) { + clearTimeout(timeoutId); + const err = error as Error & { statusCode?: number; errorCode?: ManagementApiErrorCode }; + if (!err.errorCode) { + err.errorCode = mapErrorToCode(err, err.statusCode); + err.message = getErrorMessage(err.errorCode, err.message); + } + throw err; + } + } + + /** + * Make request using native https module for self-signed cert support. + */ + private async requestWithHttps( + method: string, + url: string, + headers: Record, + body?: unknown + ): Promise<{ data?: T; headers?: Record }> { + return new Promise((resolve, reject) => { + const agent = new https.Agent({ rejectUnauthorized: false }); + const bodyStr = body !== undefined ? JSON.stringify(body) : undefined; + + if (bodyStr) { + headers['Content-Length'] = Buffer.byteLength(bodyStr).toString(); + } + + const reqTimeout = setTimeout(() => { + reject(new Error('Request timeout')); + }, this.timeout); + + const req = https.request( + url, + { + method, + headers, + agent, + timeout: this.timeout, + }, + (res) => { + clearTimeout(reqTimeout); + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) { + const errorCode = mapErrorToCode(new Error(res.statusMessage || ''), res.statusCode); + const error = new Error(getErrorMessage(errorCode)) as Error & { + statusCode: number; + errorCode: ManagementApiErrorCode; + }; + error.statusCode = res.statusCode; + error.errorCode = errorCode; + reject(error); + return; + } + + const responseHeaders: Record = {}; + const version = res.headers['x-cpa-version']; + const commit = res.headers['x-cpa-commit']; + if (typeof version === 'string') responseHeaders['x-cpa-version'] = version; + if (typeof commit === 'string') responseHeaders['x-cpa-commit'] = commit; + + let parsed: T | undefined; + if (data) { + try { + parsed = JSON.parse(data) as T; + } catch { + // Non-JSON response is ok + } + } + + resolve({ data: parsed, headers: responseHeaders }); + }); + } + ); + + req.on('error', (err) => { + clearTimeout(reqTimeout); + const error = err as Error & { errorCode?: ManagementApiErrorCode }; + error.errorCode = mapErrorToCode(err); + error.message = getErrorMessage(error.errorCode, err.message); + reject(error); + }); + + req.on('timeout', () => { + req.destroy(); + const error = new Error('Request timeout') as Error & { errorCode: ManagementApiErrorCode }; + error.errorCode = 'TIMEOUT'; + reject(error); + }); + + if (bodyStr) { + req.write(bodyStr); + } + req.end(); + }); + } +} + +/** + * Create a ManagementApiClient from CCS config. + * Uses cliproxy_server.remote settings. + */ +export function createManagementClient( + remoteConfig: { + host: string; + port?: number; + protocol: 'http' | 'https'; + management_key?: string; + auth_token?: string; + timeout?: number; + }, + allowSelfSigned = true +): ManagementApiClient { + return new ManagementApiClient({ + host: remoteConfig.host, + port: remoteConfig.port, + protocol: remoteConfig.protocol, + managementKey: remoteConfig.management_key || remoteConfig.auth_token || '', + timeout: remoteConfig.timeout, + allowSelfSigned, + }); +} diff --git a/src/cliproxy/management-api-types.ts b/src/cliproxy/management-api-types.ts new file mode 100644 index 00000000..f0a9092a --- /dev/null +++ b/src/cliproxy/management-api-types.ts @@ -0,0 +1,123 @@ +/** + * Management API Types for CLIProxyAPI + * + * Type definitions matching CLIProxyAPI Go structs for Management API CRUD operations. + * Used for syncing CCS API profiles to remote CLIProxy instance. + */ + +/** + * Model alias within a ClaudeKey entry. + * Maps Claude model names to provider-specific models. + */ +export interface ClaudeModel { + /** Claude model name to match (e.g., "claude-3-5-sonnet") */ + name: string; + /** Target model to use instead (e.g., "glm-4.7-airx-thinking") */ + alias: string; +} + +/** + * ClaudeKey configuration for CLIProxy. + * Maps to config.ClaudeKey in CLIProxyAPI Go code. + */ +export interface ClaudeKey { + /** API key for the provider */ + 'api-key': string; + /** Prefix for model name matching (e.g., "glm-" matches "glm-*" requests) */ + prefix?: string; + /** Base URL for the provider API */ + 'base-url'?: string; + /** Optional proxy URL for requests */ + 'proxy-url'?: string; + /** Additional headers to include in requests */ + headers?: Record; + /** Models to exclude from this key */ + 'excluded-models'?: string[]; + /** Model name to alias mappings */ + models?: ClaudeModel[]; +} + +/** + * Configuration for the Management API client. + */ +export interface ManagementClientConfig { + /** Remote proxy host (IP or hostname) */ + host: string; + /** Remote proxy port (default: 8317 for HTTP, 443 for HTTPS) */ + port?: number; + /** Protocol to use (http or https) */ + protocol: 'http' | 'https'; + /** Management key for authentication (sent as Bearer token) */ + managementKey: string; + /** Request timeout in milliseconds (default: 5000) */ + timeout?: number; + /** Allow self-signed certificates for HTTPS (default: false) */ + allowSelfSigned?: boolean; +} + +/** + * Health check result from Management API. + */ +export interface ManagementHealthStatus { + /** Whether the Management API is reachable */ + healthy: boolean; + /** Version of CLIProxyAPI (from X-CPA-VERSION header) */ + version?: string; + /** Commit hash (from X-CPA-COMMIT header) */ + commit?: string; + /** Latency in milliseconds */ + latencyMs?: number; + /** Error message if not healthy */ + error?: string; + /** Error code for programmatic handling */ + errorCode?: ManagementApiErrorCode; +} + +/** + * Error codes for Management API operations. + */ +export type ManagementApiErrorCode = + | 'CONNECTION_REFUSED' + | 'TIMEOUT' + | 'AUTH_FAILED' + | 'DNS_FAILED' + | 'NETWORK_UNREACHABLE' + | 'NOT_FOUND' + | 'BAD_REQUEST' + | 'SERVER_ERROR' + | 'UNKNOWN'; + +/** + * Response from GET /v0/management/claude-api-key + */ +export interface GetClaudeKeysResponse { + 'claude-api-key': ClaudeKey[]; +} + +/** + * Patch request body for updating a single ClaudeKey. + */ +export interface ClaudeKeyPatch { + /** Index of the key to update (0-based) */ + index?: number; + /** Match by api-key value */ + match?: string; + /** Fields to update */ + value: Partial; +} + +/** + * Sync status for tracking sync operations. + */ +export interface SyncStatus { + /** Last sync timestamp (ISO 8601) */ + lastSyncAt?: string; + /** Number of profiles synced */ + profileCount: number; + /** Whether sync was successful */ + success: boolean; + /** Error message if failed */ + error?: string; + /** Remote CLIProxy URL */ + remoteUrl?: string; +} diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 7f035350..80406bb9 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -36,8 +36,8 @@ export interface ModelEntry { id: string; /** Human-readable name for display */ name: string; - /** Access tier indicator - 'paid' means requires paid Google account (not free tier) */ - tier?: 'free' | 'paid'; + /** Access tier indicator - 'ultra' for Claude, 'pro' for premium Gemini, 'free' for basic */ + tier?: 'free' | 'pro' | 'ultra'; /** Optional description for the model */ description?: string; /** Model has known issues - show warning when selected */ @@ -119,7 +119,7 @@ export const MODEL_CATALOG: Partial> = { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', - tier: 'paid', + tier: 'pro', description: 'Latest model, requires paid Google account', thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true }, }, diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index ddec180b..2da1cc63 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -51,8 +51,13 @@ export function getCurrentModel( * Format model entry for display in selection list */ function formatModelOption(model: ModelEntry): string { - // Tier badge: clarify that "paid" means paid Google account (not free tier) - const tierBadge = model.tier === 'paid' ? color(' [Paid Tier]', 'warning') : ''; + // Tier badge: ultra/pro indicate paid tiers + const tierBadge = + model.tier === 'ultra' + ? color(' [Ultra]', 'warning') + : model.tier === 'pro' + ? color(' [Pro]', 'warning') + : ''; const brokenBadge = model.broken ? color(' [BROKEN]', 'error') : ''; const deprecatedBadge = model.deprecated ? color(' [DEPRECATED]', 'warning') : ''; return `${model.name}${tierBadge}${brokenBadge}${deprecatedBadge}`; @@ -64,7 +69,12 @@ function formatModelOption(model: ModelEntry): string { function formatModelDetailed(model: ModelEntry, isCurrent: boolean): string { const marker = isCurrent ? color('>', 'success') : ' '; const name = isCurrent ? bold(model.name) : model.name; - const tierBadge = model.tier === 'paid' ? color(' [Paid Tier]', 'warning') : ''; + const tierBadge = + model.tier === 'ultra' + ? color(' [Ultra]', 'warning') + : model.tier === 'pro' + ? color(' [Pro]', 'warning') + : ''; const brokenBadge = model.broken ? color(' [BROKEN]', 'error') : ''; const deprecatedBadge = model.deprecated ? color(' [DEPRECATED]', 'warning') : ''; const desc = model.description ? dim(` - ${model.description}`) : ''; diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 9301d985..e9e7fd81 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -303,48 +303,27 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | /** * Map tier ID string to AccountTier type - * Simplified: anything with 'pro' or 'ultra' = paid, 'free'/'legacy' = free + * API returns: "g1-ultra-tier", "g1-pro-tier", "standard-tier", etc. + * Priority: ultra > pro > free */ function mapTierString(tierStr: string | undefined): AccountTier { if (!tierStr) return 'unknown'; const normalized = tierStr.toLowerCase(); - if (normalized.includes('ultra') || normalized.includes('pro')) return 'paid'; + // Match "g1-ultra-tier" or "ultra" anywhere in string + if (normalized.includes('ultra')) return 'ultra'; + // Match "g1-pro-tier" or "pro" anywhere in string + if (normalized.includes('pro')) return 'pro'; + // Match free/legacy tiers if (normalized.includes('free') || normalized.includes('legacy')) { return 'free'; } - // "standard-tier" and other unknown values should NOT map to 'free' - // Let inferTierFromModels handle the detection + // "standard-tier" and other unknown values = unknown return 'unknown'; } -/** - * Infer tier from model access patterns. - * - Paid: Has access to Claude models OR premium Gemini models - * - Free: Only basic models - * - * Claude models are Ultra-exclusive, premium Gemini indicates Pro/Ultra. - * Both are "paid" tier for our purposes. - */ -function inferTierFromModels(models: ModelQuota[]): AccountTier { - if (models.length === 0) return 'unknown'; - - // Check for Claude models (Ultra-exclusive) or premium Gemini (Pro/Ultra) - const hasPaidAccess = models.some((m) => { - const name = m.name.toLowerCase(); - return ( - name.includes('claude') || - name.includes('gemini-3-pro') || - name.includes('gemini-2.5-pro') || - name.includes('gemini-pro-high') - ); - }); - - return hasPaidAccess ? 'paid' : 'unknown'; -} - /** * Get project ID and tier via loadCodeAssist endpoint - * Uses allowedTiers array with isDefault=true (CLIProxyAPIPlus approach) + * Uses paidTier.id for accurate tier detection (g1-ultra-tier, g1-pro-tier) */ async function getProjectId(accessToken: string): Promise<{ projectId: string | null; @@ -406,17 +385,10 @@ async function getProjectId(accessToken: string): Promise<{ }; } - // Extract tier using CLIProxyAPIPlus approach: - // 1. Find tier with isDefault=true in allowedTiers array - // 2. Fallback to paidTier > currentTier - let tierStr: string | undefined; - if (data.allowedTiers && Array.isArray(data.allowedTiers)) { - const defaultTier = data.allowedTiers.find((t) => t.isDefault); - tierStr = defaultTier?.id; - } - if (!tierStr) { - tierStr = data.paidTier?.id || data.currentTier?.id; - } + // Extract tier - paidTier reflects actual subscription status, takes priority + // API returns: paidTier.id = "g1-ultra-tier" or "g1-pro-tier" + // allowedTiers/currentTier often return "standard-tier" which is not useful + const tierStr = data.paidTier?.id || data.currentTier?.id; const tier = mapTierString(tierStr); return { projectId: projectId.trim(), tier }; @@ -650,15 +622,14 @@ export async function fetchAccountQuota( refreshResult.accessToken, projectId as string ); - // Determine tier: model access (Claude = Ultra) > API tier > fallback + // Determine tier from API response only (model inference is unreliable) if (retryResult.success) { - let finalTier = inferTierFromModels(retryResult.models); - if (finalTier === 'unknown') { - finalTier = apiTier !== 'unknown' ? apiTier : 'paid'; - } + const finalTier = apiTier !== 'unknown' ? apiTier : 'unknown'; retryResult.tier = finalTier; retryResult.accountId = accountId; - setAccountTier(provider, accountId, finalTier); + if (finalTier !== 'unknown') { + setAccountTier(provider, accountId, finalTier); + } if (verbose && retryResult.error) { console.log(`[!] Error: ${retryResult.error}`); } @@ -667,15 +638,14 @@ export async function fetchAccountQuota( } } - // Determine tier: model access > API tier > fallback to paid + // Determine tier from API response only if (result.success) { - let finalTier = inferTierFromModels(result.models); - if (finalTier === 'unknown') { - finalTier = apiTier !== 'unknown' ? apiTier : 'paid'; - } + const finalTier = apiTier !== 'unknown' ? apiTier : 'unknown'; result.tier = finalTier; result.accountId = accountId; - setAccountTier(provider, accountId, finalTier); + if (finalTier !== 'unknown') { + setAccountTier(provider, accountId, finalTier); + } } if (verbose && result.error) { diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index 48e1bc12..a5943c6a 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -200,7 +200,7 @@ export async function findHealthyAccount( exclude: string[] ): Promise<{ id: string; tier: string; lastQuota: number } | null> { const config = loadOrCreateUnifiedConfig(); - const tierPriority = config.quota_management?.auto?.tier_priority ?? ['paid']; + const tierPriority = config.quota_management?.auto?.tier_priority ?? ['ultra', 'pro', 'free']; const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5; const accounts = getProviderAccounts(provider); @@ -225,7 +225,7 @@ export async function findHealthyAccount( return { id: account.id, - tier: account.tier || 'paid', + tier: account.tier || 'unknown', lastQuota: avgQuota, }; }) diff --git a/src/cliproxy/sync/auto-sync-watcher.ts b/src/cliproxy/sync/auto-sync-watcher.ts new file mode 100644 index 00000000..bd264861 --- /dev/null +++ b/src/cliproxy/sync/auto-sync-watcher.ts @@ -0,0 +1,212 @@ +/** + * Auto-Sync Watcher + * + * Watches for profile settings changes and automatically syncs to local CLIProxy config. + * Uses debouncing to prevent sync storms during rapid edits. + */ + +import * as chokidar from 'chokidar'; +import * as path from 'path'; +import { getCcsDir } from '../../utils/config-manager'; +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import { syncToLocalConfig } from './local-config-sync'; + +/** Debounce delay in milliseconds */ +const DEBOUNCE_MS = 3000; + +/** Singleton watcher instance */ +let watcherInstance: chokidar.FSWatcher | null = null; +let syncTimeout: NodeJS.Timeout | null = null; +let isSyncing = false; + +/** + * Check if auto-sync is enabled in config. + */ +export function isAutoSyncEnabled(): boolean { + try { + const config = loadOrCreateUnifiedConfig(); + // For local sync, check cliproxy.auto_sync (simpler config location) + return config.cliproxy?.auto_sync === true; + } catch { + return false; + } +} + +/** + * Log auto-sync message. + */ +function log(message: string): void { + console.log(`[auto-sync] ${message}`); +} + +/** + * Execute sync to local CLIProxy config. + */ +async function triggerSync(): Promise { + if (isSyncing) { + log('Sync already in progress, skipping'); + return; + } + + if (!isAutoSyncEnabled()) { + log('Auto-sync disabled, skipping'); + return; + } + + isSyncing = true; + + try { + const result = syncToLocalConfig(); + + if (!result.success) { + log(`Sync failed: ${result.error}`); + return; + } + + if (result.syncedCount === 0) { + log('No profiles to sync'); + return; + } + + log(`Success: ${result.syncedCount} profile(s) synced to ${result.configPath}`); + } catch (error) { + log(`Sync error: ${(error as Error).message}`); + } finally { + isSyncing = false; + } +} + +/** + * Handle file change event with debouncing. + */ +function onFileChange(filePath: string): void { + const fileName = path.basename(filePath); + log(`Profile change detected: ${fileName}`); + + // Clear existing timeout + if (syncTimeout) { + clearTimeout(syncTimeout); + } + + log(`Waiting ${DEBOUNCE_MS / 1000}s for additional changes...`); + + // Set new debounced timeout + syncTimeout = setTimeout(() => { + syncTimeout = null; + triggerSync().catch((err) => { + log(`Sync error: ${err.message}`); + }); + }, DEBOUNCE_MS); +} + +/** + * Start the auto-sync watcher. + * Watches ~/.ccs/*.settings.json for changes. + */ +export function startAutoSyncWatcher(): void { + if (watcherInstance) { + log('Watcher already running'); + return; + } + + if (!isAutoSyncEnabled()) { + // Don't start if disabled, but log nothing (called at startup) + return; + } + + const ccsDir = getCcsDir(); + const watchPattern = path.join(ccsDir, '*.settings.json'); + + log(`Starting watcher on ${watchPattern}`); + + watcherInstance = chokidar.watch(watchPattern, { + ignoreInitial: true, // Don't trigger on initial scan + persistent: true, + awaitWriteFinish: { + stabilityThreshold: 500, + pollInterval: 100, + }, + }); + + watcherInstance.on('change', onFileChange); + watcherInstance.on('add', onFileChange); + watcherInstance.on('unlink', onFileChange); + + watcherInstance.on('error', (error) => { + log(`Watcher error: ${error.message}`); + }); + + log('Watcher started'); +} + +/** + * Stop the auto-sync watcher. + */ +export async function stopAutoSyncWatcher(): Promise { + if (syncTimeout) { + clearTimeout(syncTimeout); + syncTimeout = null; + } + + if (watcherInstance) { + const closePromise = watcherInstance.close(); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Close timeout')), 5000) + ); + try { + await Promise.race([closePromise, timeoutPromise]); + } catch (err) { + log(`Warning: ${(err as Error).message}, forcing cleanup`); + } + watcherInstance = null; + log('Watcher stopped'); + } + + // Reset flag to prevent stale state + isSyncing = false; +} + +/** + * Restart the watcher (after config change). + */ +export async function restartAutoSyncWatcher(): Promise { + // Wait for any active sync to complete (max 10s) + const maxWait = 10000; + const start = Date.now(); + while (isSyncing && Date.now() - start < maxWait) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + if (isSyncing) { + log('Warning: Sync still in progress after 10s timeout, proceeding with restart'); + } + + await stopAutoSyncWatcher(); + startAutoSyncWatcher(); +} + +/** + * Get watcher status. + */ +export function getAutoSyncStatus(): { + enabled: boolean; + watching: boolean; + syncing: boolean; +} { + return { + enabled: isAutoSyncEnabled(), + watching: watcherInstance !== null, + syncing: isSyncing, + }; +} + +/** + * Reset watcher state for test cleanup. + * Stops watcher and clears all singleton state. + */ +export async function resetWatcherState(): Promise { + await stopAutoSyncWatcher(); + watcherInstance = null; + syncTimeout = null; + isSyncing = false; +} diff --git a/src/cliproxy/sync/index.ts b/src/cliproxy/sync/index.ts new file mode 100644 index 00000000..3316ee10 --- /dev/null +++ b/src/cliproxy/sync/index.ts @@ -0,0 +1,28 @@ +/** + * CLIProxy Sync Module + * + * Profile sync functionality for syncing CCS API profiles to CLIProxy config. + */ + +// Profile mapper +export type { SyncableProfile, SyncPreviewItem } from './profile-mapper'; +export { + loadSyncableProfiles, + mapProfileToClaudeKey, + generateSyncPayload, + generateSyncPreview, + getSyncableProfileCount, + isProfileSyncable, +} from './profile-mapper'; + +// Local config sync +export { syncToLocalConfig, getLocalSyncStatus } from './local-config-sync'; + +// Auto-sync watcher +export { + startAutoSyncWatcher, + stopAutoSyncWatcher, + restartAutoSyncWatcher, + isAutoSyncEnabled, + getAutoSyncStatus, +} from './auto-sync-watcher'; diff --git a/src/cliproxy/sync/local-config-sync.ts b/src/cliproxy/sync/local-config-sync.ts new file mode 100644 index 00000000..1d980033 --- /dev/null +++ b/src/cliproxy/sync/local-config-sync.ts @@ -0,0 +1,234 @@ +/** + * Local Config Sync + * + * Syncs CCS API profiles to the local CLIProxy config.yaml. + * Uses section-based replacement to preserve comments and formatting. + */ + +import * as fs from 'fs'; +import * as yaml from 'js-yaml'; +import { getCliproxyConfigPath } from '../config-generator'; +import { generateSyncPayload } from './profile-mapper'; +import type { ClaudeKey } from '../management-api-types'; + +/** + * Sync profiles to local CLIProxy config.yaml. + * Replaces only the claude-api-key section, preserving all other content. + * + * @returns Object with success status and synced count + */ +export function syncToLocalConfig(): { + success: boolean; + syncedCount: number; + configPath: string; + error?: string; +} { + const configPath = getCliproxyConfigPath(); + + try { + // Generate payload from CCS profiles + const payload = generateSyncPayload(); + + if (payload.length === 0) { + return { + success: true, + syncedCount: 0, + configPath, + }; + } + + // Read existing config + if (!fs.existsSync(configPath)) { + return { + success: false, + syncedCount: 0, + configPath, + error: 'CLIProxy config not found. Run ccs doctor to generate.', + }; + } + + let configContent: string; + try { + configContent = fs.readFileSync(configPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { + success: false, + syncedCount: 0, + configPath, + error: 'CLIProxy config deleted during sync. Run ccs doctor to regenerate.', + }; + } + throw error; + } + + // Transform payload to config format + const claudeApiKeys = payload.map(transformToConfigFormat); + + // Generate YAML for the claude-api-key section only + const newSection = yaml.dump( + { 'claude-api-key': claudeApiKeys }, + { + indent: 2, + lineWidth: -1, + quotingType: "'", + forceQuotes: false, + } + ); + + // Replace section in original content (preserves comments/formatting) + const newContent = replaceSectionInYaml(configContent, 'claude-api-key', newSection); + + // Atomic write with cleanup on failure + const tempPath = configPath + '.tmp'; + try { + fs.writeFileSync(tempPath, newContent, { mode: 0o600 }); + fs.renameSync(tempPath, configPath); + } catch (writeError) { + // Clean up temp file if it exists + if (fs.existsSync(tempPath)) { + try { + fs.unlinkSync(tempPath); + } catch { + // Ignore cleanup errors + } + } + throw writeError; + } + + return { + success: true, + syncedCount: payload.length, + configPath, + }; + } catch (error) { + return { + success: false, + syncedCount: 0, + configPath, + error: (error as Error).message, + }; + } +} + +/** + * Replace a top-level section in YAML content while preserving rest of file. + * Finds the section by key name and replaces it (including nested content). + */ +function replaceSectionInYaml(content: string, sectionKey: string, newSection: string): string { + const lines = content.split('\n'); + const result: string[] = []; + let inSection = false; + let sectionFound = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trimStart(); + + // Check if this is the start of our target section + if (trimmed.startsWith(`${sectionKey}:`)) { + inSection = true; + sectionFound = true; + + // Insert new section here + result.push(newSection.trimEnd()); + continue; + } + + // If we're in the section, skip lines until we hit another top-level key + if (inSection) { + // Top-level key: starts at column 0, valid YAML key format (word chars + hyphens) + // Must match pattern like "key:", "my-key:", "key_name:" but not comments or strings + const isTopLevelKey = + line.length > 0 && + !line.startsWith(' ') && + !line.startsWith('\t') && + !line.startsWith('#') && + /^[a-zA-Z_][a-zA-Z0-9_-]*\s*:/.test(line); + + if (isTopLevelKey) { + // We've exited the section, resume normal processing + inSection = false; + result.push(line); + } + // Otherwise skip this line (part of old section) + continue; + } + + result.push(line); + } + + // If section wasn't found, append it at the end + if (!sectionFound) { + result.push(''); + result.push(newSection.trimEnd()); + } + + return result.join('\n'); +} + +/** + * Transform ClaudeKey to config.yaml format. + * The config format uses slightly different field names. + */ +function transformToConfigFormat(key: ClaudeKey): Record { + const entry: Record = { + 'api-key': key['api-key'], + }; + + if (key['base-url']) { + entry['base-url'] = key['base-url']; + } + + // Add empty proxy-url (required by CLIProxyAPI) + entry['proxy-url'] = ''; + + // Use model name directly (no alias mapping) + if (key.models && key.models.length > 0) { + entry.models = key.models.map((m) => ({ + name: m.name, + alias: '', + })); + } + + // Note: prefix is not used in local config - it's for remote routing only + + return entry; +} + +/** + * Get local sync status. + */ +export function getLocalSyncStatus(): { + configExists: boolean; + configPath: string; + currentKeyCount: number; + syncableProfileCount: number; +} { + const configPath = getCliproxyConfigPath(); + let currentKeyCount = 0; + + if (fs.existsSync(configPath)) { + try { + const content = fs.readFileSync(configPath, 'utf8'); + const config = yaml.load(content) as Record | null; + if (config && typeof config === 'object') { + const keys = config['claude-api-key']; + if (Array.isArray(keys)) { + currentKeyCount = keys.length; + } + } + } catch { + // Ignore parse errors + } + } + + const payload = generateSyncPayload(); + + return { + configExists: fs.existsSync(configPath), + configPath, + currentKeyCount, + syncableProfileCount: payload.length, + }; +} diff --git a/src/cliproxy/sync/profile-mapper.ts b/src/cliproxy/sync/profile-mapper.ts new file mode 100644 index 00000000..d9b8b995 --- /dev/null +++ b/src/cliproxy/sync/profile-mapper.ts @@ -0,0 +1,200 @@ +/** + * Profile Mapper for CLIProxy Sync + * + * Transforms CCS settings-based profiles into CLIProxy ClaudeKey format. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../../utils/config-manager'; +import { listApiProfiles, isApiProfileConfigured } from '../../api/services/profile-reader'; +import type { ClaudeKey } from '../management-api-types'; + +/** + * Profile info with settings for sync. + */ +export interface SyncableProfile { + /** Profile name (e.g., "glm", "kimi") */ + name: string; + /** Path to settings.json file */ + settingsPath: string; + /** Whether profile has valid API key */ + isConfigured: boolean; + /** Environment variables from settings.json */ + env?: Record; +} + +/** + * Settings.json file structure (Claude compatible). + */ +interface SettingsJson { + env?: Record; +} + +/** + * Load syncable API profiles from CCS config. + * Filters to only configured profiles (with real API keys). + */ +export function loadSyncableProfiles(): SyncableProfile[] { + const { profiles } = listApiProfiles(); + const syncable: SyncableProfile[] = []; + + for (const profile of profiles) { + // Skip unconfigured profiles + if (!profile.isConfigured) { + continue; + } + + // Load settings.json for env vars + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile.name}.settings.json`); + + let env: Record | undefined; + try { + if (fs.existsSync(settingsPath)) { + const content = fs.readFileSync(settingsPath, 'utf8'); + const settings = JSON.parse(content) as SettingsJson; + env = settings.env; + } + } catch { + // Skip profiles with unreadable settings + continue; + } + + // Must have ANTHROPIC_AUTH_TOKEN + const token = env?.ANTHROPIC_AUTH_TOKEN; + if (!token || token.includes('YOUR_') || token.includes('your-')) { + continue; + } + + syncable.push({ + name: profile.name, + settingsPath, + isConfigured: true, + env, + }); + } + + return syncable; +} + +/** + * Sanitize profile name for YAML safety. + * Replaces non-alphanumeric chars (except - and _) with hyphens. + */ +function sanitizeProfileName(name: string): string { + return name.replace(/[^a-zA-Z0-9-_]/g, '-'); +} + +/** + * Map a single profile to ClaudeKey format. + */ +export function mapProfileToClaudeKey(profile: SyncableProfile): ClaudeKey | null { + const env = profile.env; + if (!env) return null; + + const apiKey = env.ANTHROPIC_AUTH_TOKEN; + if (!apiKey) return null; + + const baseUrl = env.ANTHROPIC_BASE_URL; + const modelName = env.ANTHROPIC_MODEL; + + // Generate prefix from profile name (e.g., "glm" -> "glm-") + const sanitizedName = sanitizeProfileName(profile.name); + if (!sanitizedName || sanitizedName === '') { + return null; // Skip profiles with invalid names + } + + // Skip if name is too long (>64 chars) + if (sanitizedName.length > 64) { + return null; + } + + // Skip if name has no alphanumeric characters (e.g., only special chars -> "-----") + if (!/[a-zA-Z0-9]/.test(sanitizedName)) { + return null; + } + + const prefix = `${sanitizedName}-`; + + const claudeKey: ClaudeKey = { + 'api-key': apiKey, + prefix, + }; + + if (baseUrl) { + claudeKey['base-url'] = baseUrl; + } + + // Use model name directly from profile (no alias mapping) + if (modelName) { + claudeKey.models = [ + { + name: modelName, + alias: '', + }, + ]; + } + + return claudeKey; +} + +/** + * Generate sync payload from all configured profiles. + * Returns array of ClaudeKey ready to push to remote CLIProxy. + */ +export function generateSyncPayload(): ClaudeKey[] { + const profiles = loadSyncableProfiles(); + const keys: ClaudeKey[] = []; + + for (const profile of profiles) { + const key = mapProfileToClaudeKey(profile); + if (key) { + keys.push(key); + } + } + + return keys; +} + +/** + * Generate sync preview with profile details. + * Used for dry-run mode to show what would be synced. + */ +export interface SyncPreviewItem { + /** Profile name */ + name: string; + /** Base URL (masked) */ + baseUrl?: string; + /** Model name */ + modelName?: string; +} + +export function generateSyncPreview(): SyncPreviewItem[] { + const profiles = loadSyncableProfiles(); + const preview: SyncPreviewItem[] = []; + + for (const profile of profiles) { + preview.push({ + name: profile.name, + baseUrl: profile.env?.ANTHROPIC_BASE_URL, + modelName: profile.env?.ANTHROPIC_MODEL, + }); + } + + return preview; +} + +/** + * Get count of syncable profiles. + */ +export function getSyncableProfileCount(): number { + return loadSyncableProfiles().length; +} + +/** + * Check if profile is syncable (configured with valid API key). + */ +export function isProfileSyncable(profileName: string): boolean { + return isApiProfileConfigured(profileName); +} diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index df38438e..ba98f6af 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -39,6 +39,7 @@ import { getPresetIds, type ModelMapping, } from '../api/services'; +import { syncToLocalConfig } from '../cliproxy/sync/local-config-sync'; interface ApiCommandArgs { name?: string; @@ -276,6 +277,14 @@ async function handleCreate(args: string[]): Promise { process.exit(1); } + // Trigger sync to local CLIProxy config (best-effort) + try { + syncToLocalConfig(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + console.log(`[i] Auto-sync to CLIProxy config skipped: ${message}`); + } + // Display success console.log(''); const hasCustomMapping = opusModel !== model || sonnetModel !== model || haikuModel !== model; diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index ebb20861..3e94af8d 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -64,6 +64,9 @@ import { installLatest, } from '../cliproxy/services'; +// Import sync handler +import { handleSync } from './cliproxy-sync-handler'; + // ============================================================================ // ARGUMENT PARSING // ============================================================================ @@ -149,7 +152,12 @@ function parseProfileArgs(args: string[]): CliproxyProfileArgs { } function formatModelOption(model: ModelEntry): string { - const tierBadge = model.tier === 'paid' ? color(' [Paid Tier]', 'warning') : ''; + const tierBadge = + model.tier === 'ultra' + ? color(' [Ultra]', 'warning') + : model.tier === 'pro' + ? color(' [Pro]', 'warning') + : ''; return `${model.name}${tierBadge}`; } @@ -613,6 +621,14 @@ async function showHelp(): Promise { ['remove ', 'Remove a CLIProxy variant profile'], ], ], + [ + 'Local Sync:', + [ + ['sync', 'Sync API profiles to local CLIProxy config'], + ['sync --dry-run', 'Preview sync without applying'], + ['sync --verbose', 'Show detailed sync information'], + ], + ], [ 'Quota Management:', [ @@ -1004,6 +1020,11 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } + if (command === 'sync') { + await handleSync(remainingArgs.slice(1)); + return; + } + if (command === 'stop') { await handleStop(); return; diff --git a/src/commands/cliproxy-sync-handler.ts b/src/commands/cliproxy-sync-handler.ts new file mode 100644 index 00000000..fb4bceef --- /dev/null +++ b/src/commands/cliproxy-sync-handler.ts @@ -0,0 +1,117 @@ +/** + * CLIProxy Sync Command Handler + * + * Handles `ccs cliproxy sync` command for syncing API profiles to local CLIProxy config. + */ + +import { syncToLocalConfig, generateSyncPreview, getLocalSyncStatus } from '../cliproxy/sync'; +import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../utils/ui'; + +interface SyncArgs { + dryRun: boolean; + verbose: boolean; +} + +/** + * Parse sync command arguments. + */ +export function parseSyncArgs(args: string[]): SyncArgs { + return { + dryRun: args.includes('--dry-run') || args.includes('-n'), + verbose: args.includes('--verbose') || args.includes('-v'), + }; +} + +/** + * Handle `ccs cliproxy sync` command. + */ +export async function handleSync(args: string[]): Promise { + await initUI(); + const parsed = parseSyncArgs(args); + + console.log(header('CLIProxy Profile Sync')); + console.log(''); + + // Check status + const status = getLocalSyncStatus(); + + if (!status.configExists) { + console.log(fail('CLIProxy config not found')); + console.log(''); + console.log('Run to generate config:'); + console.log(` ${color('ccs doctor --fix', 'command')}`); + console.log(''); + process.exit(1); + } + + // Get preview + const preview = generateSyncPreview(); + + if (preview.length === 0) { + console.log(warn('No API profiles configured to sync')); + console.log(''); + console.log('Configure API profiles with:'); + console.log(` ${color('ccs api create', 'command')}`); + console.log(''); + return; + } + + // Show preview + console.log(subheader(`Profiles to Sync (${preview.length})`)); + console.log(''); + + const rows = preview.map((p) => { + const model = p.modelName ? color(p.modelName, 'info') : dim('default'); + const url = p.baseUrl ? dim(p.baseUrl) : dim('-'); + return [p.name, url, model]; + }); + + console.log(table(rows, { head: ['Profile', 'Base URL', 'Model'], colWidths: [15, 40, 20] })); + console.log(''); + + if (parsed.verbose) { + console.log(dim(`Config path: ${status.configPath}`)); + console.log(dim(`Current keys: ${status.currentKeyCount}`)); + console.log(''); + } + + // Dry-run mode + if (parsed.dryRun) { + console.log(info('Dry-run mode - no changes will be made')); + console.log(''); + console.log(`Would sync ${preview.length} profile(s) to:`); + console.log(` ${dim(status.configPath)}`); + console.log(''); + console.log('Run without --dry-run to apply changes:'); + console.log(` ${color('ccs cliproxy sync', 'command')}`); + console.log(''); + return; + } + + // Execute sync + console.log(info('Syncing profiles to local config...')); + + const result = syncToLocalConfig(); + + if (!result.success) { + console.log(''); + console.log(fail(`Sync failed: ${result.error}`)); + console.log(''); + process.exit(1); + } + + console.log(''); + console.log(ok(`Synced ${result.syncedCount} profile(s)`)); + console.log(` ${dim(result.configPath)}`); + console.log(''); + + // Show synced profiles + for (const p of preview) { + console.log(` ${ok('')} ${p.name}`); + } + console.log(''); + + console.log(info('Restart CLIProxy to apply changes:')); + console.log(` ${color('ccs cliproxy stop && ccs gemini', 'command')}`); + console.log(''); +} diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 4cf39d60..63a97fd1 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -159,6 +159,8 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { partial.cliproxy?.backend === 'original' || partial.cliproxy?.backend === 'plus' ? partial.cliproxy.backend : undefined, // Invalid values become undefined (defaults to 'plus' at runtime) + // Auto-sync - default to true + auto_sync: partial.cliproxy?.auto_sync ?? defaults.cliproxy.auto_sync ?? true, }, preferences: { ...defaults.preferences, diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 0df03c27..cf7e0f1f 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -132,6 +132,8 @@ export interface CLIProxyConfig { auth?: CLIProxyAuthConfig; /** Background token refresh worker settings */ token_refresh?: TokenRefreshSettings; + /** Auto-sync API profiles to local CLIProxy config on settings change (default: true) */ + auto_sync?: boolean; } /** @@ -259,6 +261,8 @@ export interface ProxyRemoteConfig { management_key?: string; /** Connection timeout in milliseconds (default: 2000) */ timeout?: number; + /** Enable auto-sync profiles to remote on settings change (default: false) */ + auto_sync?: boolean; } /** @@ -405,7 +409,7 @@ export interface QuotaManagementConfig { export const DEFAULT_AUTO_QUOTA_CONFIG: AutoQuotaConfig = { preflight_check: true, exhaustion_threshold: 5, - tier_priority: ['paid'], + tier_priority: ['ultra', 'pro', 'free'], cooldown_minutes: 5, }; @@ -605,6 +609,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { enabled: false, request_log: false, }, + auto_sync: true, }, preferences: { theme: 'system', diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 2f864a01..51fd523c 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -12,6 +12,7 @@ import path from 'path'; import { WebSocketServer } from 'ws'; import { setupWebSocket } from './websocket'; import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware'; +import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync'; export interface ServerOptions { port: number; @@ -93,7 +94,16 @@ export async function startServer(options: ServerOptions): Promise { + wsCleanup(); + stopAutoSyncWatcher().catch(() => {}); + }; // Start listening return new Promise((resolve) => { diff --git a/src/web-server/routes/cliproxy-sync-routes.ts b/src/web-server/routes/cliproxy-sync-routes.ts new file mode 100644 index 00000000..1bbec5fc --- /dev/null +++ b/src/web-server/routes/cliproxy-sync-routes.ts @@ -0,0 +1,174 @@ +/** + * CLIProxy Sync Routes - Local sync management for CLIProxy profiles + */ + +import { Router, Request, Response } from 'express'; +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import { + generateSyncPayload, + generateSyncPreview, + getAutoSyncStatus, + restartAutoSyncWatcher, + syncToLocalConfig, + getLocalSyncStatus, +} from '../../cliproxy/sync'; +import { saveUnifiedConfig } from '../../config/unified-config-loader'; + +const router = Router(); + +/** + * GET /api/cliproxy/sync/status - Get local sync status + * Returns: { configExists, configPath, currentKeyCount, syncableProfileCount } + */ +router.get('/status', async (_req: Request, res: Response): Promise => { + try { + const status = getLocalSyncStatus(); + res.json({ + configured: status.configExists, + configPath: status.configPath, + currentKeyCount: status.currentKeyCount, + syncableProfileCount: status.syncableProfileCount, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/sync/preview - Get sync preview (dry run) + * Returns: { profiles: SyncPreviewItem[], payload: ClaudeKey[] } + */ +router.get('/preview', async (_req: Request, res: Response): Promise => { + try { + const preview = generateSyncPreview(); + const payload = generateSyncPayload(); + + // Mask API keys in payload for preview + const maskedPayload = payload.map((key) => ({ + ...key, + 'api-key': maskApiKey(key['api-key']), + })); + + res.json({ + profiles: preview, + payload: maskedPayload, + count: payload.length, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cliproxy/sync - Execute sync to local CLIProxy config + * Returns: { success, syncedCount, configPath, error? } + */ +router.post('/', async (_req: Request, res: Response): Promise => { + try { + const result = syncToLocalConfig(); + + if (!result.success) { + res.status(500).json({ + success: false, + error: result.error, + configPath: result.configPath, + }); + return; + } + + if (result.syncedCount === 0) { + res.json({ + success: true, + syncedCount: 0, + message: 'No profiles to sync', + configPath: result.configPath, + }); + return; + } + + const preview = generateSyncPreview(); + res.json({ + success: true, + syncedCount: result.syncedCount, + configPath: result.configPath, + profiles: preview.map((p) => p.name), + }); + } catch (error) { + res.status(500).json({ + success: false, + error: (error as Error).message, + }); + } +}); + +// ==================== Auto-Sync ==================== + +/** + * GET /api/cliproxy/sync/auto-sync - Get auto-sync status + * Returns: { enabled, watching, syncing } + */ +router.get('/auto-sync', async (_req: Request, res: Response): Promise => { + try { + const status = getAutoSyncStatus(); + res.json(status); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/cliproxy/sync/auto-sync - Toggle auto-sync setting + * Body: { enabled: boolean } + * Returns: { success: true, enabled } + */ +router.put('/auto-sync', async (req: Request, res: Response): Promise => { + try { + const { enabled } = req.body; + + if (typeof enabled !== 'boolean') { + res.status(400).json({ error: 'Invalid field: enabled must be a boolean' }); + return; + } + + // Update config + const config = loadOrCreateUnifiedConfig(); + if (!config.cliproxy) { + // Should not happen as loadOrCreate initializes it, but handle gracefully + res.status(500).json({ error: 'CLIProxy config not initialized' }); + return; + } + + // Save config + try { + config.cliproxy.auto_sync = enabled; + saveUnifiedConfig(config); + } catch (error) { + res.status(500).json({ error: `Failed to save config: ${(error as Error).message}` }); + return; + } + + // Restart watcher (separate operation) + try { + await restartAutoSyncWatcher(); + } catch (watcherError) { + // Log but don't fail - config was saved successfully + console.warn('Watcher restart failed:', (watcherError as Error).message); + } + + res.json({ success: true, enabled }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * Mask API key for display (show first 4 and last 4 chars). + */ +function maskApiKey(key: string): string { + if (key.length <= 12) { + return '***'; + } + return `${key.slice(0, 4)}...${key.slice(-4)}`; +} + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 8761df91..6152aafb 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -18,6 +18,7 @@ import settingsRoutes from './settings-routes'; import websearchRoutes from './websearch-routes'; import cliproxyAuthRoutes from './cliproxy-auth-routes'; import cliproxyStatsRoutes from './cliproxy-stats-routes'; +import cliproxySyncRoutes from './cliproxy-sync-routes'; import copilotRoutes from './copilot-routes'; import miscRoutes from './misc-routes'; import cliproxyServerRoutes from './proxy-routes'; @@ -51,6 +52,7 @@ apiRoutes.use('/persist', persistRoutes); apiRoutes.use('/cliproxy', variantRoutes); apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes); apiRoutes.use('/cliproxy', cliproxyStatsRoutes); +apiRoutes.use('/cliproxy/sync', cliproxySyncRoutes); apiRoutes.use('/cliproxy/openai-compat', providerRoutes); // ==================== WebSearch ==================== diff --git a/tests/unit/cliproxy/auto-sync-watcher.test.ts b/tests/unit/cliproxy/auto-sync-watcher.test.ts new file mode 100644 index 00000000..416fa401 --- /dev/null +++ b/tests/unit/cliproxy/auto-sync-watcher.test.ts @@ -0,0 +1,123 @@ +/** + * Tests for Auto-Sync Watcher + * Verifies debounce behavior, watcher lifecycle, and state management. + */ + +import * as assert from 'assert'; + +describe('Auto-Sync Watcher', () => { + const autoSyncWatcher = require('../../../dist/cliproxy/sync/auto-sync-watcher'); + + beforeEach(async () => { + // Ensure clean state before each test + await autoSyncWatcher.resetWatcherState(); + }); + + afterEach(async () => { + // Clean up after each test + await autoSyncWatcher.stopAutoSyncWatcher(); + }); + + describe('isAutoSyncEnabled', () => { + it('returns a boolean', () => { + const result = autoSyncWatcher.isAutoSyncEnabled(); + assert.ok(typeof result === 'boolean'); + }); + }); + + describe('getAutoSyncStatus', () => { + it('returns status object with required fields', () => { + const status = autoSyncWatcher.getAutoSyncStatus(); + + assert.ok(typeof status === 'object'); + assert.ok(typeof status.enabled === 'boolean'); + assert.ok(typeof status.watching === 'boolean'); + assert.ok(typeof status.syncing === 'boolean'); + }); + + it('reports not watching when watcher not started', () => { + const status = autoSyncWatcher.getAutoSyncStatus(); + assert.strictEqual(status.watching, false); + }); + + it('reports not syncing initially', () => { + const status = autoSyncWatcher.getAutoSyncStatus(); + assert.strictEqual(status.syncing, false); + }); + }); + + describe('startAutoSyncWatcher', () => { + it('does not throw when called', () => { + assert.doesNotThrow(() => { + autoSyncWatcher.startAutoSyncWatcher(); + }); + }); + + it('is idempotent (can be called multiple times)', () => { + assert.doesNotThrow(() => { + autoSyncWatcher.startAutoSyncWatcher(); + autoSyncWatcher.startAutoSyncWatcher(); + }); + }); + }); + + describe('stopAutoSyncWatcher', () => { + it('resolves without error', async () => { + await assert.doesNotReject(async () => { + await autoSyncWatcher.stopAutoSyncWatcher(); + }); + }); + + it('can stop when not started', async () => { + await assert.doesNotReject(async () => { + await autoSyncWatcher.stopAutoSyncWatcher(); + }); + }); + }); + + describe('restartAutoSyncWatcher', () => { + it('resolves without error', async () => { + await assert.doesNotReject(async () => { + await autoSyncWatcher.restartAutoSyncWatcher(); + }); + }); + }); + + describe('resetWatcherState', () => { + it('clears all state', async () => { + // Start watcher to set state + autoSyncWatcher.startAutoSyncWatcher(); + + // Reset state + await autoSyncWatcher.resetWatcherState(); + + // Verify state is cleared + const status = autoSyncWatcher.getAutoSyncStatus(); + assert.strictEqual(status.watching, false); + assert.strictEqual(status.syncing, false); + }); + + it('is safe to call multiple times', async () => { + await assert.doesNotReject(async () => { + await autoSyncWatcher.resetWatcherState(); + await autoSyncWatcher.resetWatcherState(); + await autoSyncWatcher.resetWatcherState(); + }); + }); + }); + + describe('Debounce Behavior', () => { + /** + * Debounce is set to 3000ms (DEBOUNCE_MS constant). + * This prevents sync storms during rapid edits. + * + * Testing actual debounce requires file system manipulation + * and timing-based assertions, which are flaky in unit tests. + * Integration tests should cover this behavior. + */ + it('debounce constant is documented', () => { + // DEBOUNCE_MS = 3000 (3 seconds) + assert.ok(true, 'Debounce documented in code comments'); + }); + }); +}); diff --git a/tests/unit/cliproxy/local-config-sync.test.ts b/tests/unit/cliproxy/local-config-sync.test.ts new file mode 100644 index 00000000..687d879f --- /dev/null +++ b/tests/unit/cliproxy/local-config-sync.test.ts @@ -0,0 +1,98 @@ +/** + * Tests for Local Config Sync + * Verifies YAML section replacement logic and sync behavior. + */ + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +describe('Local Config Sync', () => { + const localConfigSync = require('../../../dist/cliproxy/sync/local-config-sync'); + + describe('syncToLocalConfig', () => { + it('returns success with zero count when no profiles', () => { + // If no profiles are configured, sync should succeed with 0 count + const result = localConfigSync.syncToLocalConfig(); + assert.ok(typeof result === 'object'); + assert.ok(typeof result.success === 'boolean'); + assert.ok(typeof result.syncedCount === 'number'); + assert.ok(typeof result.configPath === 'string'); + }); + + it('returns error when config file not found', () => { + // Set CCS_HOME to temp dir without config + const originalHome = process.env.CCS_HOME; + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-')); + + try { + process.env.CCS_HOME = tempDir; + // Create a mock settings file to trigger sync attempt + const settingsPath = path.join(tempDir, 'test.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_AUTH_TOKEN: 'sk-real-key', + ANTHROPIC_BASE_URL: 'https://api.example.com', + }, + }) + ); + + // Need to reload module to pick up new CCS_HOME + // For this test, we just verify the function doesn't throw + const result = localConfigSync.syncToLocalConfig(); + assert.ok(typeof result === 'object'); + } finally { + process.env.CCS_HOME = originalHome; + fs.rmSync(tempDir, { recursive: true }); + } + }); + }); + + describe('getLocalSyncStatus', () => { + it('returns status object with required fields', () => { + const result = localConfigSync.getLocalSyncStatus(); + + assert.ok(typeof result === 'object'); + assert.ok(typeof result.configExists === 'boolean'); + assert.ok(typeof result.configPath === 'string'); + assert.ok(typeof result.currentKeyCount === 'number'); + assert.ok(typeof result.syncableProfileCount === 'number'); + }); + + it('returns non-negative counts', () => { + const result = localConfigSync.getLocalSyncStatus(); + assert.ok(result.currentKeyCount >= 0); + assert.ok(result.syncableProfileCount >= 0); + }); + }); + + describe('YAML section replacement', () => { + // Test the internal logic through integration + // The replaceSectionInYaml function is not exported, so we test via syncToLocalConfig + + it('preserves file permissions on sync', () => { + // Verify atomic write behavior by checking syncToLocalConfig doesn't throw + const result = localConfigSync.syncToLocalConfig(); + assert.ok(typeof result === 'object'); + }); + }); + + describe('Known Limitations (Documented)', () => { + /** + * YAML section replacement has known edge cases: + * - Comments between section key and first item may be lost + * - Multi-document YAML files (with ---) not supported + * - Inline comments on section key line may be lost + * + * These are acceptable trade-offs for the simple section-based replacement. + * Users with complex YAML structures should edit manually. + */ + it('documents known YAML edge cases', () => { + // This test serves as documentation - no assertions needed + assert.ok(true, 'Edge cases documented in test comments'); + }); + }); +}); diff --git a/tests/unit/cliproxy/management-api-client.test.ts b/tests/unit/cliproxy/management-api-client.test.ts new file mode 100644 index 00000000..1816f8ef --- /dev/null +++ b/tests/unit/cliproxy/management-api-client.test.ts @@ -0,0 +1,522 @@ +/** + * Unit tests for management-api-client module + */ +import { describe, it, expect, beforeEach, mock } from 'bun:test'; +import { ManagementApiClient } from '../../../src/cliproxy/management-api-client'; +import type { + ManagementClientConfig, + ManagementHealthStatus, + ClaudeKey, +} from '../../../src/cliproxy/management-api-types'; + +describe('management-api-client', () => { + describe('ManagementApiClient', () => { + let config: ManagementClientConfig; + + beforeEach(() => { + config = { + host: 'localhost', + port: 8317, + protocol: 'http', + managementKey: 'test-management-key-12345', + timeout: 2000, + allowSelfSigned: false, + }; + }); + + describe('constructor', () => { + it('should create client with provided config', () => { + const client = new ManagementApiClient(config); + expect(client).toBeDefined(); + expect(client.getBaseUrl()).toBe('http://localhost:8317'); + }); + + it('should use default timeout if not provided', () => { + const configWithoutTimeout = { ...config }; + delete configWithoutTimeout.timeout; + const client = new ManagementApiClient(configWithoutTimeout); + expect(client).toBeDefined(); + }); + }); + + describe('getBaseUrl', () => { + it('should construct HTTP URL with port', () => { + const client = new ManagementApiClient(config); + expect(client.getBaseUrl()).toBe('http://localhost:8317'); + }); + + it('should construct HTTPS URL with custom port', () => { + const httpsConfig = { ...config, protocol: 'https' as const, port: 8443 }; + const client = new ManagementApiClient(httpsConfig); + expect(client.getBaseUrl()).toBe('https://localhost:8443'); + }); + + it('should omit standard HTTP port 80', () => { + const configPort80 = { ...config, port: 80 }; + const client = new ManagementApiClient(configPort80); + expect(client.getBaseUrl()).toBe('http://localhost'); + }); + + it('should omit standard HTTPS port 443', () => { + const configPort443 = { ...config, protocol: 'https' as const, port: 443 }; + const client = new ManagementApiClient(configPort443); + expect(client.getBaseUrl()).toBe('https://localhost'); + }); + + it('should use default port 8317 for HTTP when port is undefined', () => { + const configNoPort = { ...config }; + delete configNoPort.port; + const client = new ManagementApiClient(configNoPort); + expect(client.getBaseUrl()).toBe('http://localhost:8317'); + }); + + it('should use default port 443 for HTTPS when port is undefined', () => { + const configNoPort = { ...config, protocol: 'https' as const }; + delete configNoPort.port; + const client = new ManagementApiClient(configNoPort); + expect(client.getBaseUrl()).toBe('https://localhost'); + }); + }); + + describe('error code mapping', () => { + it('should map ENOTFOUND to DNS_FAILED', () => { + const error = new Error('getaddrinfo ENOTFOUND example.com') as NodeJS.ErrnoException; + error.code = 'ENOTFOUND'; + + // Test via health check which uses mapErrorToCode internally + const client = new ManagementApiClient(config); + const originalFetch = global.fetch; + global.fetch = mock(() => Promise.reject(error)); + + client.health().then((status: ManagementHealthStatus) => { + expect(status.healthy).toBe(false); + expect(status.errorCode).toBe('DNS_FAILED'); + }); + + global.fetch = originalFetch; + }); + + it('should map ECONNREFUSED to CONNECTION_REFUSED', () => { + const error = new Error('connect ECONNREFUSED') as NodeJS.ErrnoException; + error.code = 'ECONNREFUSED'; + + const client = new ManagementApiClient(config); + const originalFetch = global.fetch; + global.fetch = mock(() => Promise.reject(error)); + + client.health().then((status: ManagementHealthStatus) => { + expect(status.healthy).toBe(false); + expect(status.errorCode).toBe('CONNECTION_REFUSED'); + }); + + global.fetch = originalFetch; + }); + + it('should map ETIMEDOUT to TIMEOUT', () => { + const error = new Error('request timeout') as NodeJS.ErrnoException; + error.code = 'ETIMEDOUT'; + + const client = new ManagementApiClient(config); + const originalFetch = global.fetch; + global.fetch = mock(() => Promise.reject(error)); + + client.health().then((status: ManagementHealthStatus) => { + expect(status.healthy).toBe(false); + expect(status.errorCode).toBe('TIMEOUT'); + }); + + global.fetch = originalFetch; + }); + + it('should map ENETUNREACH to NETWORK_UNREACHABLE', () => { + const error = new Error('network unreachable') as NodeJS.ErrnoException; + error.code = 'ENETUNREACH'; + + const client = new ManagementApiClient(config); + const originalFetch = global.fetch; + global.fetch = mock(() => Promise.reject(error)); + + client.health().then((status: ManagementHealthStatus) => { + expect(status.healthy).toBe(false); + expect(status.errorCode).toBe('NETWORK_UNREACHABLE'); + }); + + global.fetch = originalFetch; + }); + + it('should map 401 status to AUTH_FAILED', async () => { + const client = new ManagementApiClient(config); + const originalFetch = global.fetch; + global.fetch = mock(() => + Promise.resolve({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Headers(), + text: () => Promise.resolve(''), + } as Response) + ); + + const status = await client.health(); + expect(status.healthy).toBe(false); + expect(status.errorCode).toBe('AUTH_FAILED'); + + global.fetch = originalFetch; + }); + + it('should map 403 status to AUTH_FAILED', async () => { + const client = new ManagementApiClient(config); + const originalFetch = global.fetch; + global.fetch = mock(() => + Promise.resolve({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers(), + text: () => Promise.resolve(''), + } as Response) + ); + + const status = await client.health(); + expect(status.healthy).toBe(false); + expect(status.errorCode).toBe('AUTH_FAILED'); + + global.fetch = originalFetch; + }); + + it('should map 404 status to NOT_FOUND', async () => { + const client = new ManagementApiClient(config); + const originalFetch = global.fetch; + global.fetch = mock(() => + Promise.resolve({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Headers(), + text: () => Promise.resolve(''), + } as Response) + ); + + const status = await client.health(); + expect(status.healthy).toBe(false); + expect(status.errorCode).toBe('NOT_FOUND'); + + global.fetch = originalFetch; + }); + + it('should map 400 status to BAD_REQUEST', async () => { + const client = new ManagementApiClient(config); + const originalFetch = global.fetch; + global.fetch = mock(() => + Promise.resolve({ + ok: false, + status: 400, + statusText: 'Bad Request', + headers: new Headers(), + text: () => Promise.resolve(''), + } as Response) + ); + + const status = await client.health(); + expect(status.healthy).toBe(false); + expect(status.errorCode).toBe('BAD_REQUEST'); + + global.fetch = originalFetch; + }); + + it('should map 500+ status to SERVER_ERROR', async () => { + const client = new ManagementApiClient(config); + const originalFetch = global.fetch; + global.fetch = mock(() => + Promise.resolve({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: new Headers(), + text: () => Promise.resolve(''), + } as Response) + ); + + const status = await client.health(); + expect(status.healthy).toBe(false); + expect(status.errorCode).toBe('SERVER_ERROR'); + + global.fetch = originalFetch; + }); + + it('should map unknown errors to UNKNOWN', async () => { + const client = new ManagementApiClient(config); + const originalFetch = global.fetch; + global.fetch = mock(() => Promise.reject(new Error('Something weird happened'))); + + const status = await client.health(); + expect(status.healthy).toBe(false); + expect(status.errorCode).toBe('UNKNOWN'); + + global.fetch = originalFetch; + }); + }); + + describe('authentication header', () => { + it('should include Bearer token in Authorization header', async () => { + const client = new ManagementApiClient(config); + let capturedHeaders: Record = {}; + + const originalFetch = global.fetch; + global.fetch = mock((url: string, options?: RequestInit) => { + if (options?.headers) { + capturedHeaders = options.headers as Record; + } + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers(), + text: () => Promise.resolve('{"claude-api-key":[]}'), + } as Response); + }); + + await client.getClaudeKeys(); + expect(capturedHeaders['Authorization']).toBe('Bearer test-management-key-12345'); + + global.fetch = originalFetch; + }); + + it('should mask management key in logs/errors', () => { + // Management key should never appear in plain text in error messages + const sensitiveKey = 'super-secret-key-abc123'; + const clientConfig = { ...config, managementKey: sensitiveKey }; + const client = new ManagementApiClient(clientConfig); + + // The key should be used internally but not exposed + expect(client.getBaseUrl()).not.toContain(sensitiveKey); + }); + }); + + describe('timeout handling', () => { + it('should respect custom timeout value', () => { + const customTimeout = 10000; + const configWithTimeout = { ...config, timeout: customTimeout }; + const client = new ManagementApiClient(configWithTimeout); + expect(client).toBeDefined(); + }); + + it('should abort request on timeout', async () => { + const client = new ManagementApiClient({ ...config, timeout: 100 }); + + const originalFetch = global.fetch; + global.fetch = mock( + () => + new Promise((_, reject) => { + setTimeout(() => { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + reject(error); + }, 150); + }) + ); + + const status = await client.health(); + expect(status.healthy).toBe(false); + expect(status.errorCode).toBe('TIMEOUT'); + + global.fetch = originalFetch; + }); + }); + + describe('self-signed certificate option', () => { + it('should use fetch for HTTP regardless of allowSelfSigned', async () => { + const client = new ManagementApiClient({ ...config, allowSelfSigned: true }); + + const originalFetch = global.fetch; + let fetchCalled = false; + global.fetch = mock(() => { + fetchCalled = true; + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers(), + text: () => Promise.resolve('{"claude-api-key":[]}'), + } as Response); + }); + + await client.getClaudeKeys(); + expect(fetchCalled).toBe(true); + + global.fetch = originalFetch; + }); + + it('should use https module for HTTPS with allowSelfSigned', () => { + const httpsConfig = { + ...config, + protocol: 'https' as const, + allowSelfSigned: true, + }; + const client = new ManagementApiClient(httpsConfig); + expect(client).toBeDefined(); + // Actual HTTPS request would use native https module with rejectUnauthorized: false + }); + + it('should use fetch for HTTPS without allowSelfSigned', async () => { + const httpsConfig = { + ...config, + protocol: 'https' as const, + allowSelfSigned: false, + }; + const client = new ManagementApiClient(httpsConfig); + + const originalFetch = global.fetch; + let fetchCalled = false; + global.fetch = mock(() => { + fetchCalled = true; + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers(), + text: () => Promise.resolve('{"claude-api-key":[]}'), + } as Response); + }); + + await client.getClaudeKeys(); + expect(fetchCalled).toBe(true); + + global.fetch = originalFetch; + }); + }); + + describe('health check', () => { + it('should return healthy status with version info', async () => { + const client = new ManagementApiClient(config); + + const originalFetch = global.fetch; + global.fetch = mock(() => + Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ + 'x-cpa-version': '1.2.3', + 'x-cpa-commit': 'abc123', + }), + text: () => Promise.resolve('{"claude-api-key":[]}'), + } as Response) + ); + + const status = await client.health(); + expect(status.healthy).toBe(true); + expect(status.version).toBe('1.2.3'); + expect(status.commit).toBe('abc123'); + expect(status.latencyMs).toBeGreaterThanOrEqual(0); + + global.fetch = originalFetch; + }); + + it('should return unhealthy status on error', async () => { + const client = new ManagementApiClient(config); + + const originalFetch = global.fetch; + global.fetch = mock(() => Promise.reject(new Error('Connection failed'))); + + const status = await client.health(); + expect(status.healthy).toBe(false); + expect(status.error).toBeDefined(); + expect(status.errorCode).toBeDefined(); + + global.fetch = originalFetch; + }); + }); + + describe('CRUD operations', () => { + it('should get claude keys', async () => { + const client = new ManagementApiClient(config); + const mockKeys: ClaudeKey[] = [ + { 'api-key': 'sk-test-123', prefix: 'glm-' }, + ]; + + const originalFetch = global.fetch; + global.fetch = mock(() => + Promise.resolve({ + ok: true, + status: 200, + headers: new Headers(), + text: () => Promise.resolve(JSON.stringify({ 'claude-api-key': mockKeys })), + } as Response) + ); + + const keys = await client.getClaudeKeys(); + expect(keys).toEqual(mockKeys); + + global.fetch = originalFetch; + }); + + it('should put claude keys', async () => { + const client = new ManagementApiClient(config); + const mockKeys: ClaudeKey[] = [ + { 'api-key': 'sk-test-456', prefix: 'kimi-' }, + ]; + + const originalFetch = global.fetch; + let requestBody: string | undefined; + global.fetch = mock((url: string, options?: RequestInit) => { + requestBody = options?.body as string; + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers(), + text: () => Promise.resolve(''), + } as Response); + }); + + await client.putClaudeKeys(mockKeys); + expect(requestBody).toBe(JSON.stringify(mockKeys)); + + global.fetch = originalFetch; + }); + + it('should patch claude key', async () => { + const client = new ManagementApiClient(config); + const patch = { + index: 0, + value: { prefix: 'updated-' }, + }; + + const originalFetch = global.fetch; + let requestBody: string | undefined; + global.fetch = mock((url: string, options?: RequestInit) => { + requestBody = options?.body as string; + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers(), + text: () => Promise.resolve(''), + } as Response); + }); + + await client.patchClaudeKey(patch); + expect(requestBody).toBe(JSON.stringify(patch)); + + global.fetch = originalFetch; + }); + + it('should delete claude key', async () => { + const client = new ManagementApiClient(config); + const apiKey = 'sk-test-to-delete'; + + const originalFetch = global.fetch; + let requestUrl = ''; + global.fetch = mock((url: string) => { + requestUrl = url; + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers(), + text: () => Promise.resolve(''), + } as Response); + }); + + await client.deleteClaudeKey(apiKey); + expect(requestUrl).toContain(encodeURIComponent(apiKey)); + + global.fetch = originalFetch; + }); + }); + }); +}); diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index 0cd8beed..8b540bca 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -88,12 +88,12 @@ describe('Model Catalog', () => { assert.strictEqual(MODEL_CATALOG.gemini.defaultModel, 'gemini-2.5-pro'); }); - it('includes Gemini 3 Pro with paid tier', () => { + it('includes Gemini 3 Pro with pro tier', () => { const { MODEL_CATALOG } = modelCatalog; const gem3 = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-3-pro-preview'); assert(gem3, 'Should include Gemini 3 Pro'); assert.strictEqual(gem3.name, 'Gemini 3 Pro'); - assert.strictEqual(gem3.tier, 'paid'); + assert.strictEqual(gem3.tier, 'pro'); }); it('includes Gemini 2.5 Pro without tier (free)', () => { @@ -197,7 +197,7 @@ describe('Model Catalog', () => { assert(typeof model.name === 'string', `Model name should be string`); // tier is optional if (model.tier !== undefined) { - assert(['free', 'paid'].includes(model.tier), `Invalid tier: ${model.tier}`); + assert(['free', 'pro', 'ultra'].includes(model.tier), `Invalid tier: ${model.tier}`); } } } diff --git a/tests/unit/cliproxy/profile-mapper.test.ts b/tests/unit/cliproxy/profile-mapper.test.ts new file mode 100644 index 00000000..55ccb4e6 --- /dev/null +++ b/tests/unit/cliproxy/profile-mapper.test.ts @@ -0,0 +1,120 @@ +/** + * Tests for Profile Mapper + * Verifies syncable profile detection and ClaudeKey mapping. + */ + +import * as assert from 'assert'; + +describe('Profile Mapper', () => { + const profileMapper = require('../../../dist/cliproxy/sync/profile-mapper'); + + describe('mapProfileToClaudeKey', () => { + it('returns null when env is missing', () => { + const profile = { name: 'test', settingsPath: '/path', isConfigured: true }; + const result = profileMapper.mapProfileToClaudeKey(profile); + assert.strictEqual(result, null); + }); + + it('returns null when ANTHROPIC_AUTH_TOKEN is missing', () => { + const profile = { + name: 'test', + settingsPath: '/path', + isConfigured: true, + env: { ANTHROPIC_BASE_URL: 'https://example.com' }, + }; + const result = profileMapper.mapProfileToClaudeKey(profile); + assert.strictEqual(result, null); + }); + + it('generates ClaudeKey with correct prefix', () => { + const profile = { + name: 'glm', + settingsPath: '/path', + isConfigured: true, + env: { + ANTHROPIC_AUTH_TOKEN: 'sk-test-key', + ANTHROPIC_BASE_URL: 'https://api.example.com', + ANTHROPIC_MODEL: 'gpt-4', + }, + }; + const result = profileMapper.mapProfileToClaudeKey(profile); + + assert.ok(result, 'Should return ClaudeKey'); + assert.strictEqual(result['api-key'], 'sk-test-key'); + assert.strictEqual(result.prefix, 'glm-'); + assert.strictEqual(result['base-url'], 'https://api.example.com'); + assert.ok(result.models, 'Should have models'); + assert.strictEqual(result.models[0].name, 'gpt-4'); + }); + + it('handles special characters in profile name', () => { + const profile = { + name: 'my@profile!', + settingsPath: '/path', + isConfigured: true, + env: { ANTHROPIC_AUTH_TOKEN: 'sk-key' }, + }; + const result = profileMapper.mapProfileToClaudeKey(profile); + + assert.ok(result); + assert.strictEqual(result.prefix, 'my-profile--'); + }); + + it('omits base-url when not provided', () => { + const profile = { + name: 'test', + settingsPath: '/path', + isConfigured: true, + env: { ANTHROPIC_AUTH_TOKEN: 'sk-key' }, + }; + const result = profileMapper.mapProfileToClaudeKey(profile); + + assert.ok(result); + assert.strictEqual(result['base-url'], undefined); + }); + }); + + describe('loadSyncableProfiles', () => { + it('returns an array', () => { + const result = profileMapper.loadSyncableProfiles(); + assert.ok(Array.isArray(result), 'Should return an array'); + }); + + it('filters out profiles with placeholder tokens', () => { + // loadSyncableProfiles reads from disk, just verify it doesn't throw + // and returns array (actual filtering tested via integration) + const result = profileMapper.loadSyncableProfiles(); + assert.ok(Array.isArray(result)); + }); + }); + + describe('generateSyncPayload', () => { + it('returns an array of ClaudeKey objects', () => { + const result = profileMapper.generateSyncPayload(); + assert.ok(Array.isArray(result)); + // Each item should have api-key if present + for (const key of result) { + assert.ok(key['api-key'], 'Each key should have api-key'); + assert.ok(key.prefix, 'Each key should have prefix'); + } + }); + }); + + describe('generateSyncPreview', () => { + it('returns an array of preview items', () => { + const result = profileMapper.generateSyncPreview(); + assert.ok(Array.isArray(result)); + for (const item of result) { + assert.ok(typeof item.name === 'string', 'Each item should have name'); + } + }); + }); + + describe('getSyncableProfileCount', () => { + it('returns a number', () => { + const result = profileMapper.getSyncableProfileCount(); + assert.ok(typeof result === 'number'); + assert.ok(result >= 0); + }); + }); +}); diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 0b957e3b..110d6f8a 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -99,6 +99,10 @@ export function AccountCard({ // Show minimum quota of Claude models (primary), fallback to min of all models const minQuota = quota?.success ? getMinClaudeQuota(quota.models) : null; + // Tier badge (AGY only) - show P for Pro, U for Ultra + const showTierBadge = + isAgy && account.tier && account.tier !== 'unknown' && account.tier !== 'free'; + return (
- -
- + {/* Email with tier badge inline */} +
+ + {cleanEmail(account.email)} + + {showTierBadge && ( + + {account.tier} + )} - > - {cleanEmail(account.email)} - +
+ + {/* Pause/Resume button */} {onPauseToggle && ( @@ -167,7 +188,11 @@ export function AccountCard({ )} + + {/* Drag handle */} +
+ -
-
+
+
{/* Selection checkbox for bulk actions */} {selectable && ( diff --git a/ui/src/components/cliproxy/sync/index.ts b/ui/src/components/cliproxy/sync/index.ts new file mode 100644 index 00000000..7ebba405 --- /dev/null +++ b/ui/src/components/cliproxy/sync/index.ts @@ -0,0 +1,6 @@ +/** + * Sync Components Barrel Export + */ + +export { SyncStatusCard } from './sync-status-card'; +export { SyncDialog } from './sync-dialog'; diff --git a/ui/src/components/cliproxy/sync/sync-dialog.tsx b/ui/src/components/cliproxy/sync/sync-dialog.tsx new file mode 100644 index 00000000..46a63270 --- /dev/null +++ b/ui/src/components/cliproxy/sync/sync-dialog.tsx @@ -0,0 +1,131 @@ +/** + * Sync Dialog Component + * Dialog for managing sync configuration, preview, and execution + */ + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Loader2, Upload, CheckCircle } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useSyncPreview, useExecuteSync } from '@/hooks/use-cliproxy-sync'; + +interface SyncDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function SyncDialog({ open, onOpenChange }: SyncDialogProps) { + const { data: preview, isLoading: previewLoading } = useSyncPreview(); + const { mutate: executeSync, isPending: isSyncing, isSuccess, reset } = useExecuteSync(); + + const handleSync = () => { + executeSync(undefined, { + onSuccess: () => { + // Keep dialog open to show success + }, + }); + }; + + const handleClose = () => { + reset(); + onOpenChange(false); + }; + + return ( + + + + + + Sync Profiles to Local CLIProxy + + + Sync your CCS API profiles to the local CLIProxy config.yaml. + + + +
+ {previewLoading ? ( +
+ +
+ ) : preview?.count === 0 ? ( +
+

No profiles configured to sync.

+

Create API profiles first using the Profiles tab.

+
+ ) : ( + +
+ {preview?.profiles.map((profile) => ( +
+
+
{profile.name}
+ {profile.modelName && ( +
+ Model: {profile.modelName} +
+ )} + {profile.baseUrl && ( +
+ {profile.baseUrl} +
+ )} +
+ + Ready + +
+ ))} +
+
+ )} + +
+
+ {preview?.count ?? 0} profile{(preview?.count ?? 0) !== 1 ? 's' : ''} to sync +
+
+ + +
+
+
+
+
+ ); +} diff --git a/ui/src/components/cliproxy/sync/sync-status-card.tsx b/ui/src/components/cliproxy/sync/sync-status-card.tsx new file mode 100644 index 00000000..4c220292 --- /dev/null +++ b/ui/src/components/cliproxy/sync/sync-status-card.tsx @@ -0,0 +1,115 @@ +/** + * Sync Status Card Component + * Shows local CLIProxy config sync status and controls + */ + +import { useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Loader2, RefreshCw, FileDown, Check, AlertCircle } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { SyncDialog } from './sync-dialog'; +import { useSyncStatus, useExecuteSync } from '@/hooks/use-cliproxy-sync'; + +export function SyncStatusCard() { + const [dialogOpen, setDialogOpen] = useState(false); + const { data: status, isLoading, refetch } = useSyncStatus(); + const { mutate: executeSync, isPending: isSyncing } = useExecuteSync(); + + const handleQuickSync = () => { + executeSync(undefined, { + onSuccess: () => { + refetch(); + }, + }); + }; + + if (isLoading) { + return ( + + + + + Profile Sync + + + + + + + ); + } + + const isConfigured = status?.configured ?? false; + + return ( + <> + + +
+ + + Profile Sync + + + {isConfigured ? : } + {isConfigured ? 'Ready' : 'No Config'} + +
+
+ + {isConfigured && ( +
+ Syncs API profiles to local CLIProxy config +
+ )} + + {!isConfigured && ( +

+ Run ccs doctor --fix to generate + config. +

+ )} + + {status?.error &&

{status.error}

} + +
+ + +
+
+
+ + + + ); +} diff --git a/ui/src/components/monitoring/auth-monitor/hooks.ts b/ui/src/components/monitoring/auth-monitor/hooks.ts index 4af76b84..14868be5 100644 --- a/ui/src/components/monitoring/auth-monitor/hooks.ts +++ b/ui/src/components/monitoring/auth-monitor/hooks.ts @@ -101,6 +101,7 @@ export function useAuthMonitorData(): AuthMonitorData { color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length], projectId: account.projectId, paused: account.paused, + tier: account.tier, }; accountsList.push(row); providerData.accounts.push(row); diff --git a/ui/src/components/monitoring/auth-monitor/types.ts b/ui/src/components/monitoring/auth-monitor/types.ts index 1490c134..1ddbf44a 100644 --- a/ui/src/components/monitoring/auth-monitor/types.ts +++ b/ui/src/components/monitoring/auth-monitor/types.ts @@ -2,6 +2,9 @@ * Type definitions for Auth Monitor components */ +/** Account tier for subscription level */ +export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown'; + export interface AccountRow { id: string; email: string; @@ -16,6 +19,8 @@ export interface AccountRow { projectId?: string; /** Whether account is paused (skipped in quota rotation) */ paused?: boolean; + /** Account tier (Antigravity only) */ + tier?: AccountTier; } export interface ProviderStats { diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index 423ad17e..043a3c5f 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -24,6 +24,9 @@ import { Settings, X, Download, + FileDown, + Check, + AlertCircle, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -57,6 +60,7 @@ import { useInstallVersion, useRestartProxy, } from '@/hooks/use-cliproxy'; +import { useSyncStatus, useExecuteSync } from '@/hooks/use-cliproxy-sync'; import { cn } from '@/lib/utils'; /** Client-side semver comparison (true if a > b) */ @@ -152,6 +156,10 @@ export function ProxyStatusWidget() { const restartProxy = useRestartProxy(); const installVersion = useInstallVersion(); + // Sync functionality + const { data: syncStatus } = useSyncStatus(); + const { mutate: executeSync, isPending: isSyncing } = useExecuteSync(); + // Version picker state (expanded section) const [isExpanded, setIsExpanded] = useState(false); const [selectedVersion, setSelectedVersion] = useState(''); @@ -180,11 +188,16 @@ export function ProxyStatusWidget() { stopProxy.isPending || restartProxy.isPending || installVersion.isPending || - isBackendSwitching; + isBackendSwitching || + isSyncing; const hasUpdate = updateCheck?.hasUpdate ?? false; const isUnstable = updateCheck?.isStable === false; const currentVersion = updateCheck?.currentVersion; + // Sync status + const isSyncConfigured = syncStatus?.configured ?? false; + const syncStatusText = isSyncConfigured ? 'Sync Ready' : 'No Config'; + // Target version for update/downgrade badge const targetVersion = isUnstable ? updateCheck?.maxStableVersion || versionsData?.latestStable @@ -295,6 +308,13 @@ export function ProxyStatusWidget() { ) : isRunning ? ( <> + executeSync()} + disabled={!isSyncConfigured || isActioning} + isPending={isSyncing} + /> )} + {/* Sync status row */} +
+ {isSyncConfigured ? ( + + ) : ( + + )} + + {syncStatusText} + +
+ {/* Expanded section: Version Management (available even when not running) */} diff --git a/ui/src/components/setup/wizard/steps/account-step.tsx b/ui/src/components/setup/wizard/steps/account-step.tsx index 3914db7e..0d9da4c6 100644 --- a/ui/src/components/setup/wizard/steps/account-step.tsx +++ b/ui/src/components/setup/wizard/steps/account-step.tsx @@ -22,7 +22,8 @@ export function AccountStep({ Select an account ({accounts.length})
-
+ {/* Scrollable account list with max-height for many accounts */} +
{accounts.map((acc) => ( +
+ ) : ( + + + + )}
- Default: {MODEL_CATALOGS[selectedProvider]?.defaultModel || 'provider default'} + {showCustomInput + ? 'Enter any model name supported by CLIProxy' + : `Default: ${MODEL_CATALOGS[selectedProvider]?.defaultModel || 'provider default'}`}
diff --git a/ui/src/hooks/use-cliproxy-sync.ts b/ui/src/hooks/use-cliproxy-sync.ts new file mode 100644 index 00000000..e468c7b5 --- /dev/null +++ b/ui/src/hooks/use-cliproxy-sync.ts @@ -0,0 +1,175 @@ +/** + * React Query hooks for CLIProxy sync functionality + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +/** Sync status response */ +export interface SyncStatus { + connected: boolean; + configured: boolean; + remoteUrl?: string; + latencyMs?: number; + version?: string; + error?: string; + errorCode?: string; +} + +/** Sync preview item */ +export interface SyncPreviewItem { + name: string; + baseUrl?: string; + modelName?: string; +} + +/** Masked payload item for preview */ +interface MaskedPayloadItem { + 'api-key': string; + 'base-url'?: string; + prefix?: string; + models?: { name: string; alias: string }[]; +} + +/** Sync preview response */ +export interface SyncPreview { + profiles: SyncPreviewItem[]; + payload: MaskedPayloadItem[]; + count: number; +} + +/** Sync result response */ +export interface SyncResult { + success: boolean; + syncedCount?: number; + remoteUrl?: string; + profiles?: string[]; + error?: string; + errorCode?: string; + message?: string; +} + +/** + * Fetch sync status from API + */ +async function fetchSyncStatus(): Promise { + const response = await fetch('/api/cliproxy/sync/status'); + if (!response.ok) { + let message = 'Failed to fetch sync status'; + try { + const error = await response.json(); + message = error.error || error.message || message; + } catch { + // Non-JSON response (e.g., 502 Bad Gateway) + } + throw new Error(message); + } + return response.json(); +} + +/** + * Fetch sync preview from API + */ +async function fetchSyncPreview(): Promise { + const response = await fetch('/api/cliproxy/sync/preview'); + if (!response.ok) { + let message = 'Failed to fetch sync preview'; + try { + const error = await response.json(); + message = error.error || error.message || message; + } catch { + // Non-JSON response (e.g., 502 Bad Gateway) + } + throw new Error(message); + } + return response.json(); +} + +/** + * Execute sync to remote CLIProxy + */ +async function executeSync(): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + try { + const response = await fetch('/api/cliproxy/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: controller.signal, + }); + + if (!response.ok) { + let message = 'Sync failed'; + try { + const data = await response.json(); + message = data.error || data.message || message; + } catch { + // Non-JSON response (e.g., 502 Bad Gateway) + } + throw new Error(message); + } + + return response.json(); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Sync request timed out after 30 seconds'); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Hook to get sync status + */ +export function useSyncStatus() { + return useQuery({ + queryKey: ['cliproxy-sync-status'], + queryFn: fetchSyncStatus, + refetchInterval: 30000, // Check every 30 seconds + retry: 1, + staleTime: 10000, + }); +} + +/** + * Hook to get sync preview + */ +export function useSyncPreview() { + return useQuery({ + queryKey: ['cliproxy-sync-preview'], + queryFn: fetchSyncPreview, + staleTime: 5000, + retry: 1, + }); +} + +/** + * Hook to execute sync with toast feedback + */ +export function useExecuteSync() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: executeSync, + onSuccess: (data) => { + // Invalidate sync-related queries after successful sync + queryClient.invalidateQueries({ queryKey: ['cliproxy-sync-status'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-sync-preview'] }); + + // Show success toast with synced count + if (data.syncedCount === 0) { + toast.info('No profiles to sync'); + } else { + toast.success( + `Synced ${data.syncedCount} profile${data.syncedCount === 1 ? '' : 's'} to CLIProxy` + ); + } + }, + onError: (error: Error) => { + toast.error(`Sync failed: ${error.message}`); + }, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 77b73065..cf190caa 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -83,8 +83,8 @@ export interface OAuthAccount { paused?: boolean; /** ISO timestamp when account was paused */ pausedAt?: string; - /** Account tier: free or paid (Pro/Ultra combined) */ - tier?: 'free' | 'paid' | 'unknown'; + /** Account tier: free, pro, ultra, or unknown */ + tier?: 'free' | 'pro' | 'ultra' | 'unknown'; /** GCP Project ID (Antigravity only) - read-only */ projectId?: string; }