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..4b8de047 --- /dev/null +++ b/src/web-server/routes/cliproxy-sync-routes.ts @@ -0,0 +1,234 @@ +/** + * 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, + listAllAliases, + addProfileAlias, + removeProfileAlias, + 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, + }); + } +}); + +// ==================== Model Aliases ==================== + +/** + * GET /api/cliproxy/sync/aliases - List all model aliases + * Returns: { aliases: Record } + */ +router.get('/aliases', async (_req: Request, res: Response): Promise => { + try { + const aliases = listAllAliases(); + res.json({ aliases }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cliproxy/sync/aliases - Add a model alias + * Body: { profile: string, from: string, to: string } + * Returns: { success: true } + */ +router.post('/aliases', async (req: Request, res: Response): Promise => { + try { + const { profile, from, to } = req.body; + + if (!profile || !from || !to) { + res.status(400).json({ error: 'Missing required fields: profile, from, to' }); + return; + } + + addProfileAlias(profile, from, to); + res.json({ success: true, profile, from, to }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * DELETE /api/cliproxy/sync/aliases - Remove a model alias + * Body: { profile: string, from: string } + * Returns: { success: boolean } + */ +router.delete('/aliases', async (req: Request, res: Response): Promise => { + try { + const { profile, from } = req.body; + + if (!profile || !from) { + res.status(400).json({ error: 'Missing required fields: profile, from' }); + return; + } + + const removed = removeProfileAlias(profile, from); + res.json({ success: removed, profile, from }); + } catch (error) { + res.status(500).json({ 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 ====================