diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 00917d40..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'; @@ -259,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 776ca579..d00985ca 100644 --- a/src/web-server/routes/config-routes.ts +++ b/src/web-server/routes/config-routes.ts @@ -107,15 +107,19 @@ router.post('/migrate', async (req: Request, res: Response): Promise => { * 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/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;