diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index 6cb11363..069a93f7 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -835,6 +835,113 @@ export function discoverExistingAccounts(): void { saveAccountsRegistry(freshRegistry); } +/** Result of bulk pause/resume operations */ +export interface BulkOperationResult { + succeeded: string[]; + failed: Array<{ id: string; reason: string }>; +} + +/** Result of solo account operation */ +export interface SoloOperationResult { + activated: string; + paused: string[]; +} + +/** Provider-level locks for preventing race conditions in solo mode */ +const providerLocks = new Map>(); + +/** + * Bulk pause multiple accounts + * Pauses each account, collecting successes and failures (no fail-fast) + */ +export function bulkPauseAccounts( + provider: CLIProxyProvider, + accountIds: string[] +): BulkOperationResult { + const result: BulkOperationResult = { succeeded: [], failed: [] }; + + for (const id of accountIds) { + const success = pauseAccount(provider, id); + if (success) { + result.succeeded.push(id); + } else { + result.failed.push({ id, reason: 'Account not found' }); + } + } + + return result; +} + +/** + * Bulk resume multiple accounts + * Resumes each account, collecting successes and failures (no fail-fast) + */ +export function bulkResumeAccounts( + provider: CLIProxyProvider, + accountIds: string[] +): BulkOperationResult { + const result: BulkOperationResult = { succeeded: [], failed: [] }; + + for (const id of accountIds) { + const success = resumeAccount(provider, id); + if (success) { + result.succeeded.push(id); + } else { + result.failed.push({ id, reason: 'Account not found' }); + } + } + + return result; +} + +/** + * Solo mode: activate one account, pause all others in same provider + * Per validation: auto-resumes target if paused, keeps default unchanged + */ +export async function soloAccount( + provider: CLIProxyProvider, + accountId: string +): Promise { + // Wait for any pending operation on this provider + const pending = providerLocks.get(provider); + if (pending) await pending; + + const operation = (async () => { + const accounts = getProviderAccounts(provider); + const targetAccount = accounts.find((a) => a.id === accountId); + + if (!targetAccount) { + return null; + } + + const result: SoloOperationResult = { activated: accountId, paused: [] }; + + // Resume target account if paused (per validation decision) + if (targetAccount.paused) { + resumeAccount(provider, accountId); + } + + // Pause all other accounts + for (const account of accounts) { + if (account.id !== accountId && !account.paused) { + const success = pauseAccount(provider, account.id); + if (success) { + result.paused.push(account.id); + } + } + } + + return result; + })(); + + providerLocks.set(provider, operation); + try { + return await operation; + } finally { + providerLocks.delete(provider); + } +} + /** * Get summary of all accounts across providers */ diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index 841855b2..d87d6592 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -12,22 +12,40 @@ import { getAllAccountsSummary, setDefaultAccount as setCliproxyDefault, removeAccount as removeCliproxyAccount, + bulkPauseAccounts, + bulkResumeAccounts, + soloAccount, } from '../../cliproxy/account-manager'; -import { CLIProxyProvider } from '../../cliproxy/types'; +import type { CLIProxyProvider } from '../../cliproxy/types'; const router = Router(); const registry = new ProfileRegistry(); +/** Valid CLIProxy providers */ +const VALID_PROVIDERS: CLIProxyProvider[] = [ + 'gemini', + 'codex', + 'agy', + 'qwen', + 'iflow', + 'kiro', + 'ghcp', +]; + +/** Check if provider is valid */ +function isValidProvider(provider: string): provider is CLIProxyProvider { + return VALID_PROVIDERS.includes(provider as CLIProxyProvider); +} + /** Parse CLIProxy account key format: "provider:accountId" */ function parseCliproxyKey(key: string): { provider: CLIProxyProvider; accountId: string } | null { - const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp']; const colonIndex = key.indexOf(':'); if (colonIndex === -1) return null; const provider = key.slice(0, colonIndex) as CLIProxyProvider; const accountId = key.slice(colonIndex + 1); - if (!providers.includes(provider) || !accountId) return null; + if (!isValidProvider(provider) || !accountId) return null; return { provider, accountId }; } @@ -203,4 +221,107 @@ router.delete('/:name', (req: Request, res: Response): void => { } }); +/** + * POST /api/accounts/bulk-pause - Bulk pause multiple accounts + */ +router.post('/bulk-pause', (req: Request, res: Response): void => { + try { + const { provider, accountIds } = req.body; + + if (!provider || !Array.isArray(accountIds)) { + res.status(400).json({ error: 'Missing required fields: provider and accountIds (array)' }); + return; + } + + if (!isValidProvider(provider)) { + res.status(400).json({ error: `Invalid provider: ${provider}` }); + return; + } + + // Allow empty arrays - return early success + if (accountIds.length === 0) { + res.json({ succeeded: [], failed: [] }); + return; + } + + // Validate accountIds are non-empty strings + const invalidIds = accountIds.filter((id) => typeof id !== 'string' || id.trim().length === 0); + if (invalidIds.length > 0) { + res.status(400).json({ error: 'Invalid accountIds: must be non-empty strings' }); + return; + } + + const result = bulkPauseAccounts(provider, accountIds); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/accounts/bulk-resume - Bulk resume multiple accounts + */ +router.post('/bulk-resume', (req: Request, res: Response): void => { + try { + const { provider, accountIds } = req.body; + + if (!provider || !Array.isArray(accountIds)) { + res.status(400).json({ error: 'Missing required fields: provider and accountIds (array)' }); + return; + } + + if (!isValidProvider(provider)) { + res.status(400).json({ error: `Invalid provider: ${provider}` }); + return; + } + + // Allow empty arrays - return early success + if (accountIds.length === 0) { + res.json({ succeeded: [], failed: [] }); + return; + } + + // Validate accountIds are non-empty strings + const invalidIds = accountIds.filter((id) => typeof id !== 'string' || id.trim().length === 0); + if (invalidIds.length > 0) { + res.status(400).json({ error: 'Invalid accountIds: must be non-empty strings' }); + return; + } + + const result = bulkResumeAccounts(provider, accountIds); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/accounts/solo - Solo mode: activate one account, pause all others + */ +router.post('/solo', async (req: Request, res: Response): Promise => { + try { + const { provider, accountId } = req.body; + + if (!provider || !accountId) { + res.status(400).json({ error: 'Missing required fields: provider and accountId' }); + return; + } + + if (!isValidProvider(provider)) { + res.status(400).json({ error: `Invalid provider: ${provider}` }); + return; + } + + const result = await soloAccount(provider, accountId); + if (!result) { + res.status(404).json({ error: `Account not found: ${accountId}` }); + return; + } + + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + export default router;