diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts new file mode 100644 index 00000000..848383f5 --- /dev/null +++ b/src/web-server/routes/account-routes.ts @@ -0,0 +1,68 @@ +/** + * Account Routes - CRUD operations for Claude accounts (profiles.json) + * + * Separated from profile-routes.ts to avoid dual-mounting conflicts. + */ + +import { Router, Request, Response } from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../../utils/config-manager'; + +const router = Router(); + +/** + * GET /api/accounts - List accounts from profiles.json + */ +router.get('/', (_req: Request, res: Response): void => { + try { + const profilesPath = path.join(getCcsDir(), 'profiles.json'); + + if (!fs.existsSync(profilesPath)) { + res.json({ accounts: [], default: null }); + return; + } + + const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8')); + const accounts = Object.entries(data.profiles || {}).map(([name, meta]) => { + const metadata = meta as Record; + return { + name, + ...metadata, + }; + }); + + res.json({ accounts, default: data.default || null }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/accounts/default - Set default account + */ +router.post('/default', (req: Request, res: Response): void => { + try { + const { name } = req.body; + + if (!name) { + res.status(400).json({ error: 'Missing required field: name' }); + return; + } + + const profilesPath = path.join(getCcsDir(), 'profiles.json'); + + const data = fs.existsSync(profilesPath) + ? JSON.parse(fs.readFileSync(profilesPath, 'utf8')) + : { profiles: {} }; + + data.default = name; + fs.writeFileSync(profilesPath, JSON.stringify(data, null, 2) + '\n'); + + res.json({ default: name }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 6d5d16b2..5f24ac0a 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -32,57 +32,61 @@ const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'i * GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles * Also fetches CLIProxyAPI stats to update lastUsedAt for active providers */ -router.get('/', async (_req: Request, res: Response) => { - // Initialize accounts from existing tokens on first request - initializeAccounts(); +router.get('/', async (_req: Request, res: Response): Promise => { + try { + // Initialize accounts from existing tokens on first request + initializeAccounts(); - // Fetch CLIProxyAPI usage stats to determine active providers - const stats = await fetchCliproxyStats(); + // Fetch CLIProxyAPI usage stats to determine active providers + const stats = await fetchCliproxyStats(); - // Map CLIProxyAPI provider names to our internal provider names - const statsProviderMap: Record = { - gemini: 'gemini', - antigravity: 'agy', - codex: 'codex', - qwen: 'qwen', - iflow: 'iflow', - }; + // Map CLIProxyAPI provider names to our internal provider names + const statsProviderMap: Record = { + gemini: 'gemini', + antigravity: 'agy', + codex: 'codex', + qwen: 'qwen', + iflow: 'iflow', + }; - // Update lastUsedAt for providers with recent activity - if (stats?.requestsByProvider) { - for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) { - if (requestCount > 0) { - const provider = statsProviderMap[statsProvider.toLowerCase()]; - if (provider) { - // Touch the default account for this provider (or all accounts) - const accounts = getProviderAccounts(provider); - for (const account of accounts) { - // Only touch if this is the default account (most likely being used) - if (account.isDefault) { - touchAccount(provider, account.id); + // Update lastUsedAt for providers with recent activity + if (stats?.requestsByProvider) { + for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) { + if (requestCount > 0) { + const provider = statsProviderMap[statsProvider.toLowerCase()]; + if (provider) { + // Touch the default account for this provider (or all accounts) + const accounts = getProviderAccounts(provider); + for (const account of accounts) { + // Only touch if this is the default account (most likely being used) + if (account.isDefault) { + touchAccount(provider, account.id); + } } } } } } + + const statuses = getAllAuthStatus(); + + const authStatus = statuses.map((status) => { + const oauthConfig = getOAuthConfig(status.provider); + return { + provider: status.provider, + displayName: oauthConfig.displayName, + authenticated: status.authenticated, + lastAuth: status.lastAuth?.toISOString() || null, + tokenFiles: status.tokenFiles.length, + accounts: status.accounts, + defaultAccount: status.defaultAccount, + }; + }); + + res.json({ authStatus }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - const statuses = getAllAuthStatus(); - - const authStatus = statuses.map((status) => { - const oauthConfig = getOAuthConfig(status.provider); - return { - provider: status.provider, - displayName: oauthConfig.displayName, - authenticated: status.authenticated, - lastAuth: status.lastAuth?.toISOString() || null, - tokenFiles: status.tokenFiles.length, - accounts: status.accounts, - defaultAccount: status.defaultAccount, - }; - }); - - res.json({ authStatus }); }); // ==================== Account Management ==================== @@ -91,11 +95,16 @@ router.get('/', async (_req: Request, res: Response) => { * GET /api/cliproxy/accounts - Get all accounts across all providers */ router.get('/accounts', (_req: Request, res: Response) => { - // Initialize accounts from existing tokens - initializeAccounts(); + try { + // Initialize accounts from existing tokens + initializeAccounts(); - const accounts = getAllAccountsSummary(); - res.json({ accounts }); + const accounts = getAllAccountsSummary(); + res.json({ accounts }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to list accounts'; + res.status(500).json({ error: message }); + } }); /** @@ -110,8 +119,13 @@ router.get('/accounts/:provider', (req: Request, res: Response): void => { return; } - const accounts = getProviderAccounts(provider as CLIProxyProvider); - res.json({ provider, accounts }); + try { + const accounts = getProviderAccounts(provider as CLIProxyProvider); + res.json({ provider, accounts }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get provider accounts'; + res.status(500).json({ error: message }); + } }); /** @@ -132,12 +146,19 @@ router.post('/accounts/:provider/default', (req: Request, res: Response): void = return; } - const success = setDefaultAccountFn(provider as CLIProxyProvider, accountId); + try { + const success = setDefaultAccountFn(provider as CLIProxyProvider, accountId); - if (success) { - res.json({ provider, defaultAccount: accountId }); - } else { - res.status(404).json({ error: 'Account not found' }); + if (success) { + res.json({ provider, defaultAccount: accountId }); + } else { + res + .status(404) + .json({ error: `Account '${accountId}' not found for provider '${provider}'` }); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to set default account'; + res.status(500).json({ error: message }); } }); @@ -153,12 +174,19 @@ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): v return; } - const success = removeAccountFn(provider as CLIProxyProvider, accountId); + try { + const success = removeAccountFn(provider as CLIProxyProvider, accountId); - if (success) { - res.json({ provider, accountId, deleted: true }); - } else { - res.status(404).json({ error: 'Account not found' }); + if (success) { + res.json({ provider, accountId, deleted: true }); + } else { + res + .status(404) + .json({ error: `Account '${accountId}' not found for provider '${provider}'` }); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to remove account'; + res.status(500).json({ error: message }); } }); diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index c1b4a676..85517dff 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -3,6 +3,7 @@ */ import { Router, Request, Response } from 'express'; +import * as fs from 'fs'; import * as path from 'path'; import { fetchCliproxyStats, @@ -11,7 +12,11 @@ import { fetchCliproxyErrorLogs, fetchCliproxyErrorLogContent, } from '../../cliproxy/stats-fetcher'; -import { getCliproxyWritablePath } from '../../cliproxy/config-generator'; +import { + getCliproxyWritablePath, + getConfigPath, + getAuthDir, +} from '../../cliproxy/config-generator'; import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker'; import { ensureCliproxyService } from '../../cliproxy/service-manager'; import { checkCliproxyUpdate } from '../../cliproxy/binary-manager'; @@ -19,10 +24,9 @@ import { checkCliproxyUpdate } from '../../cliproxy/binary-manager'; const router = Router(); /** - * GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics - * Returns: CliproxyStats or error if proxy not running + * Shared handler for stats/usage endpoint */ -router.get('/stats', async (_req: Request, res: Response): Promise => { +const handleStatsRequest = async (_req: Request, res: Response): Promise => { try { // Check if proxy is running first const running = await isCliproxyRunning(); @@ -48,7 +52,18 @@ router.get('/stats', async (_req: Request, res: Response): Promise => { } catch (error) { res.status(500).json({ error: (error as Error).message }); } -}); +}; + +/** + * GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics + * Returns: CliproxyStats or error if proxy not running + */ +router.get('/stats', handleStatsRequest); + +/** + * GET /api/cliproxy/usage - Alias for /stats (frontend compatibility) + */ +router.get('/usage', handleStatsRequest); /** * GET /api/cliproxy/status - Check CLIProxyAPI running status @@ -249,4 +264,170 @@ router.get('/error-logs/:name', async (req: Request, res: Response): Promise => { + try { + const configPath = getConfigPath(); + if (!fs.existsSync(configPath)) { + res.status(404).json({ error: 'Config file not found' }); + return; + } + + const content = fs.readFileSync(configPath, 'utf8'); + res.type('text/yaml').send(content); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/cliproxy/config.yaml - Save CLIProxy YAML config content + * Body: { content: string } + * Returns: { success: true, path: string } + */ +router.put('/config.yaml', async (req: Request, res: Response): Promise => { + try { + const { content } = req.body; + + if (typeof content !== 'string') { + res.status(400).json({ error: 'Missing required field: content' }); + return; + } + + const configPath = getConfigPath(); + + // Ensure parent directory exists + const configDir = path.dirname(configPath); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + // Write atomically + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, content); + fs.renameSync(tempPath, configPath); + + res.json({ success: true, path: configPath }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +// ==================== Auth Files ==================== + +/** + * GET /api/cliproxy/auth-files - List auth files in auth directory + * Returns: { files: Array<{ name, size, mtime }> } + */ +router.get('/auth-files', async (_req: Request, res: Response): Promise => { + try { + const authDir = getAuthDir(); + + if (!fs.existsSync(authDir)) { + res.json({ files: [] }); + return; + } + + const entries = fs.readdirSync(authDir, { withFileTypes: true }); + const files = entries + .filter((entry) => entry.isFile()) + .map((entry) => { + const filePath = path.join(authDir, entry.name); + const stat = fs.statSync(filePath); + return { + name: entry.name, + size: stat.size, + mtime: stat.mtime.getTime(), + }; + }); + + res.json({ files, directory: authDir }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/auth-files/download - Download auth file content + * Query: ?name=filename + * Returns: file content as octet-stream + */ +router.get('/auth-files/download', async (req: Request, res: Response): Promise => { + try { + const { name } = req.query; + + if (!name || typeof name !== 'string') { + res.status(400).json({ error: 'Missing required query parameter: name' }); + return; + } + + // Validate filename - prevent path traversal + if (name.includes('..') || name.includes('/') || name.includes('\\')) { + res.status(400).json({ error: 'Invalid filename' }); + return; + } + + const authDir = getAuthDir(); + const filePath = path.join(authDir, name); + + if (!fs.existsSync(filePath)) { + res.status(404).json({ error: 'Auth file not found' }); + return; + } + + const content = fs.readFileSync(filePath); + res.setHeader('Content-Disposition', `attachment; filename="${name}"`); + res.type('application/octet-stream').send(content); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +// ==================== Model Updates ==================== + +/** + * PUT /api/cliproxy/models/:provider - Update model for a provider + * Body: { model: string } + * Returns: { success: true, provider, model } + */ +router.put('/models/:provider', async (req: Request, res: Response): Promise => { + try { + const { provider } = req.params; + const { model } = req.body; + + if (!model || typeof model !== 'string') { + res.status(400).json({ error: 'Missing required field: model' }); + return; + } + + // Get the settings file for this provider + const ccsDir = getCliproxyWritablePath(); + const settingsPath = path.join(ccsDir, `${provider}.settings.json`); + + if (!fs.existsSync(settingsPath)) { + res.status(404).json({ error: `Settings file not found for provider: ${provider}` }); + return; + } + + // Read and update settings + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + settings.env = settings.env || {}; + settings.env.ANTHROPIC_MODEL = model; + + // Write atomically + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); + + res.json({ success: true, provider, model }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + export default router; diff --git a/src/web-server/routes/config-routes.ts b/src/web-server/routes/config-routes.ts index 1c1b7f20..d00985ca 100644 --- a/src/web-server/routes/config-routes.ts +++ b/src/web-server/routes/config-routes.ts @@ -24,12 +24,16 @@ const router = Router(); /** * GET /api/config/format - Return current config format and migration status */ -router.get('/format', (_req: Request, res: Response) => { - res.json({ - format: getConfigFormat(), - migrationNeeded: needsMigration(), - backups: getBackupDirectories(), - }); +router.get('/format', (_req: Request, res: Response): void => { + try { + res.json({ + format: getConfigFormat(), + migrationNeeded: needsMigration(), + backups: getBackupDirectories(), + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } }); /** @@ -89,25 +93,33 @@ router.put('/', (req: Request, res: Response): void => { /** * POST /api/config/migrate - Trigger migration from JSON to YAML */ -router.post('/migrate', async (req: Request, res: Response) => { - const dryRun = req.query.dryRun === 'true'; - const result = await migrate(dryRun); - res.json(result); +router.post('/migrate', async (req: Request, res: Response): Promise => { + try { + const dryRun = req.query.dryRun === 'true'; + const result = await migrate(dryRun); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } }); /** * POST /api/config/rollback - Rollback migration to JSON format */ router.post('/rollback', async (req: Request, res: Response): Promise => { - const { backupPath } = req.body; + try { + const { backupPath } = req.body; - if (!backupPath || typeof backupPath !== 'string') { - res.status(400).json({ error: 'Missing required field: backupPath' }); - return; + if (!backupPath || typeof backupPath !== 'string') { + res.status(400).json({ error: 'Missing required field: backupPath' }); + return; + } + + const success = await rollback(backupPath); + res.json({ success }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - const success = await rollback(backupPath); - res.json({ success }); }); export default router; diff --git a/src/web-server/routes/health-routes.ts b/src/web-server/routes/health-routes.ts index 5740633a..0331b4f8 100644 --- a/src/web-server/routes/health-routes.ts +++ b/src/web-server/routes/health-routes.ts @@ -10,22 +10,30 @@ const router = Router(); /** * GET /api/health - Run health checks */ -router.get('/', async (_req: Request, res: Response) => { - const report = await runHealthChecks(); - res.json(report); +router.get('/', async (_req: Request, res: Response): Promise => { + try { + const report = await runHealthChecks(); + res.json(report); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } }); /** * POST /api/health/fix/:checkId - Fix a health issue */ router.post('/fix/:checkId', (req: Request, res: Response): void => { - const { checkId } = req.params; - const result = fixHealthIssue(checkId); + try { + const { checkId } = req.params; + const result = fixHealthIssue(checkId); - if (result.success) { - res.json({ success: true, message: result.message }); - } else { - res.status(400).json({ success: false, message: result.message }); + if (result.success) { + res.json({ success: true, message: result.message }); + } else { + res.status(400).json({ success: false, message: result.message }); + } + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } }); diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 819cb717..6b967ac3 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -9,6 +9,7 @@ import { Router } from 'express'; // Import domain routers import profileRoutes from './profile-routes'; +import accountRoutes from './account-routes'; import configRoutes from './config-routes'; import healthRoutes from './health-routes'; import providerRoutes from './provider-routes'; @@ -28,7 +29,7 @@ export const apiRoutes = Router(); // Profile CRUD, settings management, presets, accounts apiRoutes.use('/profiles', profileRoutes); apiRoutes.use('/settings', settingsRoutes); -apiRoutes.use('/accounts', profileRoutes); +apiRoutes.use('/accounts', accountRoutes); // ==================== Unified Config ==================== // Config format, migration diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 311d5426..b7943bea 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -1,13 +1,11 @@ /** - * Profile Routes - CRUD operations for user profiles and accounts + * Profile Routes - CRUD operations for user profiles * * Uses unified config (config.yaml) when available, falls back to legacy (config.json). + * Note: Account routes have been moved to account-routes.ts */ import { Router, Request, Response } from 'express'; -import * as fs from 'fs'; -import * as path from 'path'; -import { getCcsDir } from '../../utils/config-manager'; import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; import { createApiProfile, removeApiProfile } from '../../api/services/profile-writer'; import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader'; @@ -20,15 +18,19 @@ const router = Router(); /** * GET /api/profiles - List all profiles */ -router.get('/', (_req: Request, res: Response) => { - const result = listApiProfiles(); - // Map isConfigured -> configured for UI compatibility - const profiles = result.profiles.map((p) => ({ - name: p.name, - settingsPath: p.settingsPath, - configured: p.isConfigured, - })); - res.json({ profiles }); +router.get('/', (_req: Request, res: Response): void => { + try { + const result = listApiProfiles(); + // Map isConfigured -> configured for UI compatibility + const profiles = result.profiles.map((p) => ({ + name: p.name, + settingsPath: p.settingsPath, + configured: p.isConfigured, + })); + res.json({ profiles }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } }); /** @@ -117,52 +119,4 @@ router.delete('/:name', (req: Request, res: Response): void => { res.json({ name, deleted: true }); }); -// ==================== Accounts ==================== - -/** - * GET /api/accounts - List accounts from profiles.json - */ -router.get('/accounts', (_req: Request, res: Response): void => { - const profilesPath = path.join(getCcsDir(), 'profiles.json'); - - if (!fs.existsSync(profilesPath)) { - res.json({ accounts: [], default: null }); - return; - } - - const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8')); - const accounts = Object.entries(data.profiles || {}).map(([name, meta]) => { - const metadata = meta as Record; - return { - name, - ...metadata, - }; - }); - - res.json({ accounts, default: data.default || null }); -}); - -/** - * POST /api/accounts/default - Set default account - */ -router.post('/accounts/default', (req: Request, res: Response): void => { - const { name } = req.body; - - if (!name) { - res.status(400).json({ error: 'Missing required field: name' }); - return; - } - - const profilesPath = path.join(getCcsDir(), 'profiles.json'); - - const data = fs.existsSync(profilesPath) - ? JSON.parse(fs.readFileSync(profilesPath, 'utf8')) - : { profiles: {} }; - - data.default = name; - fs.writeFileSync(profilesPath, JSON.stringify(data, null, 2) + '\n'); - - res.json({ default: name }); -}); - export default router; diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index c810c568..02f03874 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -32,103 +32,115 @@ function maskApiKeys(settings: Settings): Settings { * GET /api/settings/:profile - Get settings with masked API keys */ router.get('/:profile', (req: Request, res: Response): void => { - const { profile } = req.params; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + try { + const { profile } = req.params; + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - if (!fs.existsSync(settingsPath)) { - res.status(404).json({ error: 'Settings not found' }); - return; + if (!fs.existsSync(settingsPath)) { + res.status(404).json({ error: 'Settings not found' }); + return; + } + + const stat = fs.statSync(settingsPath); + const settings = loadSettings(settingsPath); + const masked = maskApiKeys(settings); + + res.json({ + profile, + settings: masked, + mtime: stat.mtime.getTime(), + path: settingsPath, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - const stat = fs.statSync(settingsPath); - const settings = loadSettings(settingsPath); - const masked = maskApiKeys(settings); - - res.json({ - profile, - settings: masked, - mtime: stat.mtime.getTime(), - path: settingsPath, - }); }); /** * GET /api/settings/:profile/raw - Get full settings (for editing) */ router.get('/:profile/raw', (req: Request, res: Response): void => { - const { profile } = req.params; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + try { + const { profile } = req.params; + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - if (!fs.existsSync(settingsPath)) { - res.status(404).json({ error: 'Settings not found' }); - return; + if (!fs.existsSync(settingsPath)) { + res.status(404).json({ error: 'Settings not found' }); + return; + } + + const stat = fs.statSync(settingsPath); + const settings = loadSettings(settingsPath); + + res.json({ + profile, + settings, + mtime: stat.mtime.getTime(), + path: settingsPath, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - const stat = fs.statSync(settingsPath); - const settings = loadSettings(settingsPath); - - res.json({ - profile, - settings, - mtime: stat.mtime.getTime(), - path: settingsPath, - }); }); /** * PUT /api/settings/:profile - Update settings with conflict detection and backup */ router.put('/:profile', (req: Request, res: Response): void => { - const { profile } = req.params; - const { settings, expectedMtime } = req.body; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + try { + const { profile } = req.params; + const { settings, expectedMtime } = req.body; + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - const fileExists = fs.existsSync(settingsPath); + const fileExists = fs.existsSync(settingsPath); - // Only check conflict if file exists and expectedMtime was provided - if (fileExists && expectedMtime) { - const stat = fs.statSync(settingsPath); - if (stat.mtime.getTime() !== expectedMtime) { - res.status(409).json({ - error: 'File modified externally', - currentMtime: stat.mtime.getTime(), - }); - return; + // Only check conflict if file exists and expectedMtime was provided + if (fileExists && expectedMtime) { + const stat = fs.statSync(settingsPath); + if (stat.mtime.getTime() !== expectedMtime) { + res.status(409).json({ + error: 'File modified externally', + currentMtime: stat.mtime.getTime(), + }); + return; + } } - } - // Create backup only if file exists - let backupPath: string | undefined; - if (fileExists) { - const backupDir = path.join(ccsDir, 'backups'); - if (!fs.existsSync(backupDir)) { - fs.mkdirSync(backupDir, { recursive: true }); + // Create backup only if file exists + let backupPath: string | undefined; + if (fileExists) { + const backupDir = path.join(ccsDir, 'backups'); + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`); + fs.copyFileSync(settingsPath, backupPath); } - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`); - fs.copyFileSync(settingsPath, backupPath); + + // Ensure directory exists for new files + if (!fileExists) { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + } + + // Write new settings atomically + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); + + const newStat = fs.statSync(settingsPath); + res.json({ + profile, + mtime: newStat.mtime.getTime(), + backupPath, + created: !fileExists, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - // Ensure directory exists for new files - if (!fileExists) { - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - } - - // Write new settings atomically - const tempPath = settingsPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); - fs.renameSync(tempPath, settingsPath); - - const newStat = fs.statSync(settingsPath); - res.json({ - profile, - mtime: newStat.mtime.getTime(), - backupPath, - created: !fileExists, - }); }); // ==================== Presets ==================== @@ -137,85 +149,97 @@ router.put('/:profile', (req: Request, res: Response): void => { * GET /api/settings/:profile/presets - Get saved presets for a provider */ router.get('/:profile/presets', (req: Request, res: Response): void => { - const { profile } = req.params; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + try { + const { profile } = req.params; + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - if (!fs.existsSync(settingsPath)) { - res.json({ presets: [] }); - return; + if (!fs.existsSync(settingsPath)) { + res.json({ presets: [] }); + return; + } + + const settings = loadSettings(settingsPath); + res.json({ presets: settings.presets || [] }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - const settings = loadSettings(settingsPath); - res.json({ presets: settings.presets || [] }); }); /** * POST /api/settings/:profile/presets - Create a new preset */ router.post('/:profile/presets', (req: Request, res: Response): void => { - const { profile } = req.params; - const { name, default: defaultModel, opus, sonnet, haiku } = req.body; + try { + const { profile } = req.params; + const { name, default: defaultModel, opus, sonnet, haiku } = req.body; - if (!name || !defaultModel) { - res.status(400).json({ error: 'Missing required fields: name, default' }); - return; + if (!name || !defaultModel) { + res.status(400).json({ error: 'Missing required fields: name, default' }); + return; + } + + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + + // Create settings file if it doesn't exist + if (!fs.existsSync(settingsPath)) { + fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n'); + } + + const settings = loadSettings(settingsPath); + settings.presets = settings.presets || []; + + // Check for duplicate name + if (settings.presets.some((p) => p.name === name)) { + res.status(409).json({ error: 'Preset with this name already exists' }); + return; + } + + const preset = { + name, + default: defaultModel, + opus: opus || defaultModel, + sonnet: sonnet || defaultModel, + haiku: haiku || defaultModel, + }; + + settings.presets.push(preset); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + + res.status(201).json({ preset }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - - // Create settings file if it doesn't exist - if (!fs.existsSync(settingsPath)) { - fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n'); - } - - const settings = loadSettings(settingsPath); - settings.presets = settings.presets || []; - - // Check for duplicate name - if (settings.presets.some((p) => p.name === name)) { - res.status(409).json({ error: 'Preset with this name already exists' }); - return; - } - - const preset = { - name, - default: defaultModel, - opus: opus || defaultModel, - sonnet: sonnet || defaultModel, - haiku: haiku || defaultModel, - }; - - settings.presets.push(preset); - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); - - res.status(201).json({ preset }); }); /** * DELETE /api/settings/:profile/presets/:name - Delete a preset */ router.delete('/:profile/presets/:name', (req: Request, res: Response): void => { - const { profile, name } = req.params; - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${profile}.settings.json`); + try { + const { profile, name } = req.params; + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${profile}.settings.json`); - if (!fs.existsSync(settingsPath)) { - res.status(404).json({ error: 'Settings not found' }); - return; + if (!fs.existsSync(settingsPath)) { + res.status(404).json({ error: 'Settings not found' }); + return; + } + + const settings = loadSettings(settingsPath); + if (!settings.presets || !settings.presets.some((p) => p.name === name)) { + res.status(404).json({ error: 'Preset not found' }); + return; + } + + settings.presets = settings.presets.filter((p) => p.name !== name); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - const settings = loadSettings(settingsPath); - if (!settings.presets || !settings.presets.some((p) => p.name === name)) { - res.status(404).json({ error: 'Preset not found' }); - return; - } - - settings.presets = settings.presets.filter((p) => p.name !== name); - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); - - res.json({ success: true }); }); export default router; diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts index 1ce3d601..e402bbaa 100644 --- a/src/web-server/routes/variant-routes.ts +++ b/src/web-server/routes/variant-routes.ts @@ -78,82 +78,90 @@ router.post('/', (req: Request, res: Response): void => { * PUT /api/cliproxy/:name - Update cliproxy variant */ router.put('/:name', (req: Request, res: Response): void => { - const { name } = req.params; - const { provider, account, model } = req.body; + try { + const { name } = req.params; + const { provider, account, model } = req.body; - const config = readConfigSafe(); + const config = readConfigSafe(); - if (!config.cliproxy?.[name]) { - res.status(404).json({ error: 'Variant not found' }); - return; - } - - const variant = config.cliproxy[name]; - - // Update fields if provided - if (provider) { - variant.provider = provider; - } - if (account !== undefined) { - if (account) { - variant.account = account; - } else { - delete variant.account; // Remove account to use default + if (!config.cliproxy?.[name]) { + res.status(404).json({ error: 'Variant not found' }); + return; } - } - // Update model in settings file if provided - if (model !== undefined) { - const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); - if (fs.existsSync(settingsPath)) { - const settings = loadSettings(settingsPath); - if (model) { - settings.env = settings.env || {}; - settings.env.ANTHROPIC_MODEL = model; - } else if (settings.env) { - delete settings.env.ANTHROPIC_MODEL; + const variant = config.cliproxy[name]; + + // Update fields if provided + if (provider) { + variant.provider = provider; + } + if (account !== undefined) { + if (account) { + variant.account = account; + } else { + delete variant.account; // Remove account to use default } - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); } + + // Update model in settings file if provided + if (model !== undefined) { + const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); + if (fs.existsSync(settingsPath)) { + const settings = loadSettings(settingsPath); + if (model) { + settings.env = settings.env || {}; + settings.env.ANTHROPIC_MODEL = model; + } else if (settings.env) { + delete settings.env.ANTHROPIC_MODEL; + } + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + } + } + + writeConfig(config); + + res.json({ + name, + provider: variant.provider, + account: variant.account || 'default', + settings: variant.settings, + updated: true, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - writeConfig(config); - - res.json({ - name, - provider: variant.provider, - account: variant.account || 'default', - settings: variant.settings, - updated: true, - }); }); /** * DELETE /api/cliproxy/:name - Delete cliproxy variant */ router.delete('/:name', (req: Request, res: Response): void => { - const { name } = req.params; + try { + const { name } = req.params; - const config = readConfigSafe(); + const config = readConfigSafe(); - if (!config.cliproxy?.[name]) { - res.status(404).json({ error: 'Variant not found' }); - return; - } - - // Never delete settings files for reserved provider names (safety guard) - if (!isReservedName(name)) { - // Only delete settings file for non-reserved variant names - const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); - if (fs.existsSync(settingsPath)) { - fs.unlinkSync(settingsPath); + if (!config.cliproxy?.[name]) { + res.status(404).json({ error: 'Variant not found' }); + return; } + + // Never delete settings files for reserved provider names (safety guard) + if (!isReservedName(name)) { + // Only delete settings file for non-reserved variant names + const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); + if (fs.existsSync(settingsPath)) { + fs.unlinkSync(settingsPath); + } + } + + delete config.cliproxy[name]; + writeConfig(config); + + res.json({ name, deleted: true }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); } - - delete config.cliproxy[name]; - writeConfig(config); - - res.json({ name, deleted: true }); }); export default router; diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 36254505..3ecbefe3 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -307,16 +307,18 @@ export const api = { // Multi-account management accounts: { - list: () => request<{ accounts: ProviderAccountsMap }>('/cliproxy/accounts'), + list: () => request<{ accounts: ProviderAccountsMap }>('/cliproxy/auth/accounts'), listByProvider: (provider: string) => - request<{ provider: string; accounts: OAuthAccount[] }>(`/cliproxy/accounts/${provider}`), + request<{ provider: string; accounts: OAuthAccount[] }>( + `/cliproxy/auth/accounts/${provider}` + ), setDefault: (provider: string, accountId: string) => - request(`/cliproxy/accounts/${provider}/default`, { + request(`/cliproxy/auth/accounts/${provider}/default`, { method: 'POST', body: JSON.stringify({ accountId }), }), remove: (provider: string, accountId: string) => - request(`/cliproxy/accounts/${provider}/${accountId}`, { method: 'DELETE' }), + request(`/cliproxy/auth/accounts/${provider}/${accountId}`, { method: 'DELETE' }), }, // OAuth flow auth: {