diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index e0013e9c..151781ee 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -332,6 +332,42 @@ export class ProfileRegistry { config.accounts[name].last_used = new Date().toISOString(); saveUnifiedConfig(config); } + + // ========================================== + // DRY Helper Methods (consolidated logic) + // ========================================== + + /** + * Get all profiles merged from both legacy and unified config. + * Unified config takes precedence for duplicate names. + * DRY helper to consolidate merge logic used in multiple places. + */ + getAllProfilesMerged(): Record { + const legacyProfiles = this.getAllProfiles(); + const unifiedAccounts = this.getAllAccountsUnified(); + + // Start with legacy profiles + const merged: Record = { ...legacyProfiles }; + + // Override with unified config accounts (takes precedence) + for (const [name, account] of Object.entries(unifiedAccounts)) { + merged[name] = { + type: 'account', + created: account.created, + last_used: account.last_used, + }; + } + + return merged; + } + + /** + * Get resolved default profile from unified config first, fallback to legacy. + * DRY helper to consolidate default resolution logic. + */ + getDefaultResolved(): string | null { + return this.getDefaultUnified() ?? this.getDefaultProfile(); + } } export default ProfileRegistry; diff --git a/src/web-server/file-watcher.ts b/src/web-server/file-watcher.ts index a57bce39..90de9027 100644 --- a/src/web-server/file-watcher.ts +++ b/src/web-server/file-watcher.ts @@ -22,7 +22,8 @@ export function createFileWatcher(onChange: FileChangeCallback): FSWatcher { const watcher = chokidar.watch( [ - path.join(ccsDir, 'config.json'), + path.join(ccsDir, 'config.json'), // Legacy config + path.join(ccsDir, 'config.yaml'), // Unified config path.join(ccsDir, '*.settings.json'), path.join(ccsDir, 'profiles.json'), path.join(ccsDir, 'cliproxy', 'sessions.json'), // Proxy session tracking @@ -41,7 +42,7 @@ export function createFileWatcher(onChange: FileChangeCallback): FSWatcher { const basename = path.basename(filePath); let type: FileChangeEvent['type']; - if (basename === 'config.json') { + if (basename === 'config.json' || basename === 'config.yaml') { type = 'config-changed'; } else if (basename === 'profiles.json') { type = 'profiles-changed'; diff --git a/src/web-server/health-service.ts b/src/web-server/health-service.ts index 25696d85..21a8723d 100644 --- a/src/web-server/health-service.ts +++ b/src/web-server/health-service.ts @@ -142,6 +142,17 @@ export function fixHealthIssue(checkId: string): { success: boolean; message: st return { success: true, message: 'Created ~/.ccs directory' }; case 'config-file': { + // Use appropriate config based on unified mode + const { isUnifiedMode } = require('../config/unified-config-loader'); + if (isUnifiedMode()) { + const { + loadOrCreateUnifiedConfig, + saveUnifiedConfig, + } = require('../config/unified-config-loader'); + const config = loadOrCreateUnifiedConfig(); + saveUnifiedConfig(config); + return { success: true, message: 'Created/updated config.yaml' }; + } const configPath = getConfigPath(); fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.writeFileSync(configPath, JSON.stringify({ profiles: {} }, null, 2) + '\n'); @@ -149,6 +160,12 @@ export function fixHealthIssue(checkId: string): { success: boolean; message: st } case 'profiles-file': { + // Use appropriate storage based on unified mode + const { isUnifiedMode: isUnified } = require('../config/unified-config-loader'); + if (isUnified()) { + // In unified mode, accounts are stored in config.yaml + return { success: true, message: 'Accounts stored in config.yaml (unified mode)' }; + } const profilesPath = path.join(ccsDir, 'profiles.json'); fs.mkdirSync(ccsDir, { recursive: true }); fs.writeFileSync(profilesPath, JSON.stringify({ profiles: {} }, null, 2) + '\n'); diff --git a/src/web-server/health/config-checks.ts b/src/web-server/health/config-checks.ts index 30cc881c..45c0c2c8 100644 --- a/src/web-server/health/config-checks.ts +++ b/src/web-server/health/config-checks.ts @@ -1,18 +1,57 @@ /** * Configuration Health Checks * - * Check config.json, settings files, and Claude settings. + * Check config.json, config.yaml, settings files, and Claude settings. + * Supports both legacy (config.json) and unified (config.yaml) modes. */ import * as fs from 'fs'; import * as path from 'path'; import { getConfigPath } from '../../utils/config-manager'; +import { isUnifiedMode, hasUnifiedConfig } from '../../config/unified-config-loader'; import type { HealthCheck } from './types'; /** - * Check config.json file + * Check config file (config.json or config.yaml based on mode) */ export function checkConfigFile(): HealthCheck { + // In unified mode, check config.yaml + if (isUnifiedMode() || hasUnifiedConfig()) { + const ccsDir = path.join(process.env.HOME || '', '.ccs'); + const yamlPath = path.join(ccsDir, 'config.yaml'); + + if (!fs.existsSync(yamlPath)) { + return { + id: 'config-file', + name: 'config.yaml', + status: 'warning', + message: 'Not found (unified mode)', + details: yamlPath, + fixable: true, + }; + } + + try { + fs.readFileSync(yamlPath, 'utf8'); + return { + id: 'config-file', + name: 'config.yaml', + status: 'ok', + message: 'Valid (unified mode)', + details: yamlPath, + }; + } catch { + return { + id: 'config-file', + name: 'config.yaml', + status: 'error', + message: 'Cannot read file', + details: yamlPath, + }; + } + } + + // Legacy mode: check config.json const configPath = getConfigPath(); if (!fs.existsSync(configPath)) { diff --git a/src/web-server/health/profile-checks.ts b/src/web-server/health/profile-checks.ts index 4c2a30e5..83955611 100644 --- a/src/web-server/health/profile-checks.ts +++ b/src/web-server/health/profile-checks.ts @@ -2,16 +2,52 @@ * Profile Health Checks * * Check profiles, instances, and delegation. + * Supports both legacy (config.json) and unified (config.yaml) modes. */ import * as fs from 'fs'; import * as path from 'path'; +import { isUnifiedMode, loadUnifiedConfig } from '../../config/unified-config-loader'; import type { HealthCheck } from './types'; /** - * Check profiles configuration + * Check profiles configuration (API profiles from config.json or config.yaml) */ export function checkProfiles(ccsDir: string): HealthCheck { + // Check unified config first + if (isUnifiedMode()) { + const config = loadUnifiedConfig(); + if (!config) { + return { + id: 'profiles', + name: 'Profiles', + status: 'info', + message: 'config.yaml not loaded', + }; + } + + const profileCount = Object.keys(config.profiles || {}).length; + const profileNames = Object.keys(config.profiles || {}).join(', '); + + if (profileCount === 0) { + return { + id: 'profiles', + name: 'Profiles', + status: 'ok', + message: 'No API profiles (unified mode)', + }; + } + + return { + id: 'profiles', + name: 'Profiles', + status: 'ok', + message: `${profileCount} configured (unified)`, + details: profileNames.length > 40 ? profileNames.substring(0, 37) + '...' : profileNames, + }; + } + + // Legacy mode: check config.json const configPath = path.join(ccsDir, 'config.json'); if (!fs.existsSync(configPath)) { diff --git a/src/web-server/overview-routes.ts b/src/web-server/overview-routes.ts index 0ddaf1da..b2cd6525 100644 --- a/src/web-server/overview-routes.ts +++ b/src/web-server/overview-routes.ts @@ -5,12 +5,11 @@ */ import { Router, Request, Response } from 'express'; -import * as fs from 'fs'; -import * as path from 'path'; -import { getCcsDir, loadConfig } from '../utils/config-manager'; +import { loadConfig } from '../utils/config-manager'; import { runHealthChecks } from './health-service'; import { getAllAuthStatus, initializeAccounts } from '../cliproxy/auth-handler'; import { getVersion } from '../utils/version'; +import ProfileRegistry from '../auth/profile-registry'; export const overviewRoutes = Router(); @@ -64,12 +63,16 @@ overviewRoutes.get('/', async (_req: Request, res: Response) => { function getAccountCount(): number { try { - const profilesPath = path.join(getCcsDir(), 'profiles.json'); + const registry = new ProfileRegistry(); - if (!fs.existsSync(profilesPath)) return 0; + // Merge legacy (profiles.json) and unified (config.yaml) accounts + const legacyProfiles = registry.getAllProfiles(); + const unifiedAccounts = registry.getAllAccountsUnified(); - const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8')); - return Object.keys(data.profiles || {}).length; + // Count unique accounts (unified takes precedence for duplicates) + const allNames = new Set([...Object.keys(legacyProfiles), ...Object.keys(unifiedAccounts)]); + + return allNames.size; } catch { return 0; } diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index 848383f5..6b512b3f 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -1,38 +1,57 @@ /** - * Account Routes - CRUD operations for Claude accounts (profiles.json) + * Account Routes - CRUD operations for Claude accounts * - * Separated from profile-routes.ts to avoid dual-mounting conflicts. + * Uses ProfileRegistry to read from both legacy (profiles.json) + * and unified config (config.yaml) for consistent data with CLI. */ import { Router, Request, Response } from 'express'; -import * as fs from 'fs'; -import * as path from 'path'; -import { getCcsDir } from '../../utils/config-manager'; +import ProfileRegistry from '../../auth/profile-registry'; +import { isUnifiedMode } from '../../config/unified-config-loader'; const router = Router(); +const registry = new ProfileRegistry(); /** - * GET /api/accounts - List accounts from profiles.json + * GET /api/accounts - List accounts from both profiles.json and config.yaml */ router.get('/', (_req: Request, res: Response): void => { try { - const profilesPath = path.join(getCcsDir(), 'profiles.json'); + // Get profiles from both legacy and unified config (same logic as CLI) + const legacyProfiles = registry.getAllProfiles(); + const unifiedAccounts = registry.getAllAccountsUnified(); - if (!fs.existsSync(profilesPath)) { - res.json({ accounts: [], default: null }); - return; + // Merge profiles: unified config takes precedence + const merged: Record = {}; + + // Add legacy profiles first + for (const [name, meta] of Object.entries(legacyProfiles)) { + merged[name] = { + type: meta.type || 'account', + created: meta.created, + last_used: meta.last_used || null, + }; } - 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, + // Override with unified config accounts (takes precedence) + for (const [name, account] of Object.entries(unifiedAccounts)) { + merged[name] = { + type: 'account', + created: account.created, + last_used: account.last_used, }; - }); + } - res.json({ accounts, default: data.default || null }); + // Convert to array format + const accounts = Object.entries(merged).map(([name, meta]) => ({ + name, + ...meta, + })); + + // Get default from unified config first, fallback to legacy + const defaultProfile = registry.getDefaultUnified() ?? registry.getDefaultProfile() ?? null; + + res.json({ accounts, default: defaultProfile }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } @@ -50,14 +69,12 @@ router.post('/default', (req: Request, res: Response): void => { 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'); + // Use unified config if in unified mode, otherwise use legacy + if (isUnifiedMode()) { + registry.setDefaultUnified(name); + } else { + registry.setDefaultProfile(name); + } res.json({ default: name }); } catch (error) { diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 04ec189a..555c3bac 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -222,7 +222,11 @@ router.post('/:profile/presets', (req: Request, res: Response): void => { }; settings.presets.push(preset); - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + + // Atomic write: temp file + rename + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); res.status(201).json({ preset }); } catch (error) { @@ -250,7 +254,11 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void => } settings.presets = settings.presets.filter((p) => p.name !== name); - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + + // Atomic write: temp file + rename + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); res.json({ success: true }); } catch (error) {