diff --git a/bun.lock b/bun.lock index 3e11076c..32d31864 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "express": "^4.18.2", "get-port": "^7.0.0", "gradient-string": "^3.0.0", + "js-yaml": "^4.1.1", "listr2": "^9.0.5", "open": "^10.1.0", "ora": "^9.0.0", @@ -24,6 +25,7 @@ "@tailwindcss/vite": "^4.1.17", "@types/chokidar": "^2.1.7", "@types/express": "^4.17.21", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.19.25", "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^8.48.0", @@ -365,6 +367,8 @@ "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], diff --git a/package.json b/package.json index 31aac806..e3e74e1a 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "express": "^4.18.2", "get-port": "^7.0.0", "gradient-string": "^3.0.0", + "js-yaml": "^4.1.1", "listr2": "^9.0.5", "open": "^10.1.0", "ora": "^9.0.0", @@ -100,6 +101,7 @@ "@tailwindcss/vite": "^4.1.17", "@types/chokidar": "^2.1.7", "@types/express": "^4.17.21", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.19.25", "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^8.48.0", diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index 2a1d4168..184ace50 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -6,6 +6,10 @@ * * Login-per-profile model: Each profile is an isolated Claude instance. * Users login directly in each instance (no credential copying). + * + * Supports dual-mode configuration: + * - Unified YAML format (config.yaml) when CCS_UNIFIED_CONFIG=1 or config.yaml exists + * - Legacy JSON format (profiles.json) as fallback */ import { spawn, ChildProcess } from 'child_process'; @@ -29,6 +33,8 @@ import { import { detectClaudeCli } from '../utils/claude-detector'; import { InteractivePrompt } from '../utils/prompt'; import packageJson from '../../package.json'; +import { hasUnifiedConfig } from '../config/unified-config-loader'; +import { isUnifiedConfigEnabled } from '../config/feature-flags'; interface AuthCommandArgs { profileName?: string; @@ -66,6 +72,13 @@ class AuthCommands { this.instanceMgr = new InstanceManager(); } + /** + * Check if unified config mode is active + */ + private isUnifiedMode(): boolean { + return hasUnifiedConfig() || isUnifiedConfigEnabled(); + } + /** * Show help for auth commands */ @@ -152,8 +165,10 @@ class AuthCommands { process.exit(1); } - // Check if profile already exists - if (!force && this.registry.hasProfile(profileName)) { + // Check if profile already exists (check both legacy and unified) + const existsLegacy = this.registry.hasProfile(profileName); + const existsUnified = this.registry.hasAccountUnified(profileName); + if (!force && (existsLegacy || existsUnified)) { console.log(fail(`Profile already exists: ${profileName}`)); console.log(` Use ${color('--force', 'command')} to overwrite`); process.exit(1); @@ -164,15 +179,25 @@ class AuthCommands { console.log(info(`Creating profile: ${profileName}`)); const instancePath = this.instanceMgr.ensureInstance(profileName); - // Create/update profile entry - if (this.registry.hasProfile(profileName)) { - this.registry.updateProfile(profileName, { - type: 'account', - }); + // Create/update profile entry based on config mode + if (this.isUnifiedMode()) { + // Use unified config (config.yaml) + if (existsUnified) { + this.registry.touchAccountUnified(profileName); + } else { + this.registry.createAccountUnified(profileName); + } } else { - this.registry.createProfile(profileName, { - type: 'account', - }); + // Use legacy profiles.json + if (existsLegacy) { + this.registry.updateProfile(profileName, { + type: 'account', + }); + } else { + this.registry.createProfile(profileName, { + type: 'account', + }); + } } console.log(info(`Instance directory: ${instancePath}`)); @@ -458,7 +483,11 @@ class AuthCommands { process.exit(1); } - if (!this.registry.hasProfile(profileName)) { + // Check existence in both legacy and unified + const existsLegacy = this.registry.hasProfile(profileName); + const existsUnified = this.registry.hasAccountUnified(profileName); + + if (!existsLegacy && !existsUnified) { console.log(fail(`Profile not found: ${profileName}`)); process.exit(1); } @@ -501,8 +530,13 @@ class AuthCommands { // Delete instance this.instanceMgr.deleteInstance(profileName); - // Delete profile - this.registry.deleteProfile(profileName); + // Delete profile from appropriate config + if (this.isUnifiedMode() && existsUnified) { + this.registry.removeAccountUnified(profileName); + } + if (existsLegacy) { + this.registry.deleteProfile(profileName); + } console.log(ok(`Profile removed: ${profileName}`)); console.log(''); @@ -527,7 +561,12 @@ class AuthCommands { } try { - this.registry.setDefaultProfile(profileName); + // Use unified or legacy based on config mode + if (this.isUnifiedMode()) { + this.registry.setDefaultUnified(profileName); + } else { + this.registry.setDefaultProfile(profileName); + } console.log(ok(`Default profile set: ${profileName}`)); console.log(''); diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index eab38008..225f83bc 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -3,6 +3,10 @@ * * Determines profile type (settings-based vs account-based) for routing. * Priority: settings-based profiles (glm/kimi) checked FIRST for backward compatibility. + * + * Supports dual-mode configuration: + * - Unified YAML format (config.yaml) when CCS_UNIFIED_CONFIG=1 or config.yaml exists + * - Legacy JSON format (config.json, profiles.json) as fallback */ import * as fs from 'fs'; @@ -10,6 +14,10 @@ import * as path from 'path'; import * as os from 'os'; import { findSimilarStrings } from '../utils/helpers'; import { Config, Settings, ProfileMetadata } from '../types'; +import { UnifiedConfig } from '../config/unified-config-types'; +import { hasUnifiedConfig, loadUnifiedConfig } from '../config/unified-config-loader'; +import { getProfileSecrets } from '../config/secrets-manager'; +import { isUnifiedConfigEnabled } from '../config/feature-flags'; export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'default'; @@ -25,6 +33,8 @@ export interface ProfileDetectionResult { message?: string; /** For cliproxy variants: the underlying provider (gemini, codex, agy, qwen) */ provider?: CLIProxyProfileName; + /** For unified config profiles: merged env vars (config + secrets) */ + env?: Record; } export interface AllProfiles { @@ -51,6 +61,70 @@ class ProfileDetector { this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json'); } + /** + * Check if unified config mode is active. + * Returns true if config.yaml exists or CCS_UNIFIED_CONFIG=1. + */ + private isUnifiedMode(): boolean { + return hasUnifiedConfig() || isUnifiedConfigEnabled(); + } + + /** + * Load unified config if available. + * Returns null if not in unified mode or load fails. + */ + private readUnifiedConfig(): UnifiedConfig | null { + if (!this.isUnifiedMode()) return null; + return loadUnifiedConfig(); + } + + /** + * Resolve profile from unified config format. + * Returns null if profile not found in unified config. + */ + private resolveFromUnifiedConfig( + profileName: string, + config: UnifiedConfig + ): ProfileDetectionResult | null { + // Check CLIProxy variants first + if (config.cliproxy?.variants?.[profileName]) { + const variant = config.cliproxy.variants[profileName]; + return { + type: 'cliproxy', + name: profileName, + provider: variant.provider as CLIProxyProfileName, + }; + } + + // Check API profiles + if (config.profiles?.[profileName]) { + const profile = config.profiles[profileName]; + // Merge with secrets + const secrets = getProfileSecrets(profileName); + return { + type: 'settings', + name: profileName, + env: { ...profile.env, ...secrets }, + }; + } + + // Check accounts + if (config.accounts?.[profileName]) { + const account = config.accounts[profileName]; + return { + type: 'account', + name: profileName, + profile: { + type: 'account', + created: account.created, + last_used: account.last_used, + }, + }; + } + + return null; + } + /** * Read settings-based config (config.json) */ @@ -90,9 +164,10 @@ class ProfileDetector { * * Priority order: * 0. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen) - * 1. User-defined CLIProxy variants (config.cliproxy section) - * 2. Settings-based profiles (config.profiles section) - * 3. Account-based profiles (profiles.json) + * 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1) + * 2. User-defined CLIProxy variants (config.cliproxy section) [legacy] + * 3. Settings-based profiles (config.profiles section) [legacy] + * 4. Account-based profiles (profiles.json) [legacy] */ detectProfileType(profileName: string | null | undefined): ProfileDetectionResult { // Special case: 'default' means use default profile @@ -109,7 +184,15 @@ class ProfileDetector { }; } - // Priority 1: Check user-defined CLIProxy variants (config.cliproxy section) + // Priority 1: Try unified config if available + const unifiedConfig = this.readUnifiedConfig(); + if (unifiedConfig) { + const result = this.resolveFromUnifiedConfig(profileName, unifiedConfig); + if (result) return result; + // Fall through to legacy if not found in unified config + } + + // Priority 2: Check user-defined CLIProxy variants (config.cliproxy section) const config = this.readConfig(); if (config.cliproxy && config.cliproxy[profileName]) { @@ -122,7 +205,7 @@ class ProfileDetector { }; } - // Priority 2: Check settings-based profiles (glm, kimi) - BACKWARD COMPATIBILITY + // Priority 3: Check settings-based profiles (glm, kimi) - LEGACY FALLBACK if (config.profiles && config.profiles[profileName]) { return { type: 'settings', @@ -131,7 +214,7 @@ class ProfileDetector { }; } - // Priority 3: Check account-based profiles (work, personal) + // Priority 4: Check account-based profiles (work, personal) - LEGACY FALLBACK const profiles = this.readProfiles(); if (profiles.profiles && profiles.profiles[profileName]) { @@ -163,7 +246,14 @@ class ProfileDetector { * Resolve default profile */ private resolveDefaultProfile(): ProfileDetectionResult { - // Check if account-based default exists + // Check unified config first + const unifiedConfig = this.readUnifiedConfig(); + if (unifiedConfig?.default) { + const result = this.resolveFromUnifiedConfig(unifiedConfig.default, unifiedConfig); + if (result) return result; + } + + // Check if account-based default exists (legacy) const profiles = this.readProfiles(); if (profiles.default && profiles.profiles[profiles.default]) { @@ -216,6 +306,43 @@ class ProfileDetector { lines.push(` - ${name}`); }); + // Check unified config first + const unifiedConfig = this.readUnifiedConfig(); + if (unifiedConfig) { + // CLIProxy variants from unified config + const variants = Object.keys(unifiedConfig.cliproxy?.variants || {}); + if (variants.length > 0) { + lines.push('CLIProxy variants (unified config):'); + variants.forEach((name) => { + const variant = unifiedConfig.cliproxy?.variants[name]; + lines.push(` - ${name} (${variant?.provider || 'unknown'})`); + }); + } + + // API profiles from unified config + const apiProfiles = Object.keys(unifiedConfig.profiles || {}); + if (apiProfiles.length > 0) { + lines.push('API profiles (unified config):'); + apiProfiles.forEach((name) => { + const isDefault = name === unifiedConfig.default; + lines.push(` - ${name}${isDefault ? ' [DEFAULT]' : ''}`); + }); + } + + // Account profiles from unified config + const accounts = Object.keys(unifiedConfig.accounts || {}); + if (accounts.length > 0) { + lines.push('Account profiles (unified config):'); + accounts.forEach((name) => { + const isDefault = name === unifiedConfig.default; + lines.push(` - ${name}${isDefault ? ' [DEFAULT]' : ''}`); + }); + } + + return lines.join('\n'); + } + + // Fall back to legacy config display // CLIProxy variants (user-defined) const config = this.readConfig(); const cliproxyVariants = Object.keys(config.cliproxy || {}); @@ -270,6 +397,19 @@ class ProfileDetector { * Get all available profile names */ getAllProfiles(): AllProfiles & { cliproxy: string[]; cliproxyVariants: string[] } { + // Check unified config first + const unifiedConfig = this.readUnifiedConfig(); + if (unifiedConfig) { + return { + settings: Object.keys(unifiedConfig.profiles || {}), + accounts: Object.keys(unifiedConfig.accounts || {}), + cliproxy: [...CLIPROXY_PROFILES], + cliproxyVariants: Object.keys(unifiedConfig.cliproxy?.variants || {}), + default: unifiedConfig.default, + }; + } + + // Fall back to legacy config const config = this.readConfig(); const profiles = this.readProfiles(); diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index df7dd3ec..6d05ce2a 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -2,6 +2,12 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { ProfileMetadata } from '../types'; +import { + hasUnifiedConfig, + loadOrCreateUnifiedConfig, + saveUnifiedConfig, +} from '../config/unified-config-loader'; +import { isUnifiedConfigEnabled } from '../config/feature-flags'; /** * Profile Registry (Simplified) @@ -41,6 +47,13 @@ export class ProfileRegistry { this.profilesPath = path.join(os.homedir(), '.ccs', 'profiles.json'); } + /** + * Check if unified config mode is active + */ + private isUnifiedMode(): boolean { + return hasUnifiedConfig() || isUnifiedConfigEnabled(); + } + /** * Read profiles from disk */ @@ -220,6 +233,95 @@ export class ProfileRegistry { last_used: new Date().toISOString(), }); } + + // ========================================== + // Unified Config Methods + // ========================================== + + /** + * Create account in unified config (config.yaml) + */ + createAccountUnified(name: string): void { + const config = loadOrCreateUnifiedConfig(); + if (config.accounts[name]) { + throw new Error(`Account already exists: ${name}`); + } + config.accounts[name] = { + created: new Date().toISOString(), + last_used: null, + }; + saveUnifiedConfig(config); + } + + /** + * Remove account from unified config + */ + removeAccountUnified(name: string): void { + const config = loadOrCreateUnifiedConfig(); + if (!config.accounts[name]) { + throw new Error(`Account not found: ${name}`); + } + delete config.accounts[name]; + // Clear default if it was the deleted account + if (config.default === name) { + config.default = undefined; + } + saveUnifiedConfig(config); + } + + /** + * Set default profile in unified config + */ + setDefaultUnified(name: string): void { + const config = loadOrCreateUnifiedConfig(); + // Check if exists in accounts, profiles, or cliproxy variants + const exists = + config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name]; + if (!exists) { + throw new Error(`Profile not found: ${name}`); + } + config.default = name; + saveUnifiedConfig(config); + } + + /** + * Check if account exists in unified config + */ + hasAccountUnified(name: string): boolean { + if (!this.isUnifiedMode()) return false; + const config = loadOrCreateUnifiedConfig(); + return !!config.accounts[name]; + } + + /** + * Get all accounts from unified config + */ + getAllAccountsUnified(): Record { + if (!this.isUnifiedMode()) return {}; + const config = loadOrCreateUnifiedConfig(); + return config.accounts; + } + + /** + * Get default from unified config + */ + getDefaultUnified(): string | undefined { + if (!this.isUnifiedMode()) return undefined; + const config = loadOrCreateUnifiedConfig(); + return config.default; + } + + /** + * Update account last_used in unified config + */ + touchAccountUnified(name: string): void { + const config = loadOrCreateUnifiedConfig(); + if (!config.accounts[name]) { + throw new Error(`Account not found: ${name}`); + } + config.accounts[name].last_used = new Date().toISOString(); + saveUnifiedConfig(config); + } } export default ProfileRegistry; diff --git a/src/ccs.ts b/src/ccs.ts index 9fa92ef4..c5ad3405 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -202,6 +202,10 @@ interface ProfileError extends Error { async function main(): Promise { const args = process.argv.slice(2); + // Auto-migrate to unified config format (silent if already migrated) + const { autoMigrate } = await import('./config/migration-manager'); + await autoMigrate(); + // Special case: version command (check BEFORE profile detection) const firstArg = args[0]; if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') { @@ -245,6 +249,20 @@ async function main(): Promise { return; } + // Special case: migrate command + if (firstArg === 'migrate' || firstArg === '--migrate') { + const { handleMigrateCommand, printMigrateHelp } = await import('./commands/migrate-command'); + const migrateArgs = args.slice(1); + + if (migrateArgs.includes('--help') || migrateArgs.includes('-h')) { + printMigrateHelp(); + return; + } + + await handleMigrateCommand(migrateArgs); + return; + } + // Special case: update command if (firstArg === 'update' || firstArg === '--update') { const updateArgs = args.slice(1); diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index a0c605b8..0f581194 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -3,6 +3,10 @@ * * Manages CCS API profiles for custom API providers. * Commands: create, list, remove + * + * Supports dual-mode configuration: + * - Unified YAML format (config.yaml) when CCS_UNIFIED_CONFIG=1 or config.yaml exists + * - Legacy JSON format (config.json, *.settings.json) as fallback */ import * as fs from 'fs'; @@ -22,6 +26,14 @@ import { } from '../utils/ui'; import { InteractivePrompt } from '../utils/prompt'; import { getCcsDir, getConfigPath, loadConfig } from '../utils/config-manager'; +import { isReservedName } from '../config/reserved-names'; +import { + hasUnifiedConfig, + loadOrCreateUnifiedConfig, + saveUnifiedConfig, +} from '../config/unified-config-loader'; +import { setProfileSecrets, deleteAllProfileSecrets } from '../config/secrets-manager'; +import { isUnifiedConfigEnabled } from '../config/feature-flags'; interface ApiCommandArgs { name?: string; @@ -72,9 +84,8 @@ function validateApiName(name: string): string | null { if (name.length > 32) { return 'API name must be 32 characters or less'; } - // Reserved names - const reserved = ['default', 'auth', 'api', 'doctor', 'sync', 'update', 'help', 'version']; - if (reserved.includes(name.toLowerCase())) { + // Use centralized reserved names list + if (isReservedName(name)) { return `'${name}' is a reserved name`; } return null; @@ -96,10 +107,21 @@ function validateUrl(url: string): string | null { } /** - * Check if API profile already exists in config.json + * Check if unified config mode is active + */ +function isUnifiedMode(): boolean { + return hasUnifiedConfig() || isUnifiedConfigEnabled(); +} + +/** + * Check if API profile already exists in config */ function apiExists(name: string): boolean { try { + if (isUnifiedMode()) { + const config = loadOrCreateUnifiedConfig(); + return name in config.profiles; + } const config = loadConfig(); return name in config.profiles; } catch { @@ -160,6 +182,60 @@ function updateConfig(name: string, _settingsPath: string): void { fs.renameSync(tempPath, configPath); } +/** + * Create API profile in unified config (config.yaml + secrets.yaml) + */ +function createApiProfileUnified( + name: string, + baseUrl: string, + apiKey: string, + model: string +): void { + const config = loadOrCreateUnifiedConfig(); + + // Add profile to config.yaml (non-sensitive data only) + config.profiles[name] = { + type: 'api', + env: { + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_MODEL: model, + ANTHROPIC_DEFAULT_OPUS_MODEL: model, + ANTHROPIC_DEFAULT_SONNET_MODEL: model, + ANTHROPIC_DEFAULT_HAIKU_MODEL: model, + }, + }; + + saveUnifiedConfig(config); + + // Store API key in secrets.yaml (sensitive data) + setProfileSecrets(name, { + ANTHROPIC_AUTH_TOKEN: apiKey, + }); +} + +/** + * Remove API profile from unified config + */ +function removeApiProfileUnified(name: string): void { + const config = loadOrCreateUnifiedConfig(); + + if (!config.profiles[name]) { + throw new Error(`API profile not found: ${name}`); + } + + delete config.profiles[name]; + + // Clear default if it was the deleted profile + if (config.default === name) { + config.default = undefined; + } + + saveUnifiedConfig(config); + + // Remove secrets + deleteAllProfileSecrets(name); +} + /** * Handle 'ccs api create' command */ @@ -230,19 +306,35 @@ async function handleCreate(args: string[]): Promise { console.log(info('Creating API profile...')); try { - const settingsPath = createSettingsFile(name, baseUrl, apiKey, model); - updateConfig(name, settingsPath); - - console.log(''); - console.log( - infoBox( - `API: ${name}\n` + - `Settings: ~/.ccs/${name}.settings.json\n` + - `Base URL: ${baseUrl}\n` + - `Model: ${model}`, - 'API Profile Created' - ) - ); + if (isUnifiedMode()) { + // Use unified config format + createApiProfileUnified(name, baseUrl, apiKey, model); + console.log(''); + console.log( + infoBox( + `API: ${name}\n` + + `Config: ~/.ccs/config.yaml\n` + + `Secrets: ~/.ccs/secrets.yaml\n` + + `Base URL: ${baseUrl}\n` + + `Model: ${model}`, + 'API Profile Created (Unified Config)' + ) + ); + } else { + // Use legacy JSON format + const settingsPath = createSettingsFile(name, baseUrl, apiKey, model); + updateConfig(name, settingsPath); + console.log(''); + console.log( + infoBox( + `API: ${name}\n` + + `Settings: ~/.ccs/${name}.settings.json\n` + + `Base URL: ${baseUrl}\n` + + `Model: ${model}`, + 'API Profile Created' + ) + ); + } console.log(''); console.log(header('Usage')); console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); @@ -258,6 +350,14 @@ async function handleCreate(args: string[]): Promise { */ function isApiConfigured(apiName: string): boolean { try { + if (isUnifiedMode()) { + // Check secrets.yaml for unified config + const { getProfileSecrets } = require('../config/secrets-manager'); + const secrets = getProfileSecrets(apiName); + const token = secrets?.ANTHROPIC_AUTH_TOKEN || ''; + return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-'); + } + // Legacy: check settings.json file const ccsDir = getCcsDir(); const settingsPath = path.join(ccsDir, `${apiName}.settings.json`); if (!fs.existsSync(settingsPath)) return false; @@ -281,6 +381,59 @@ async function handleList(): Promise { console.log(''); try { + if (isUnifiedMode()) { + // List from unified config + const unifiedConfig = loadOrCreateUnifiedConfig(); + const apis = Object.keys(unifiedConfig.profiles); + + if (apis.length === 0) { + console.log(warn('No API profiles configured')); + console.log(''); + console.log('To create an API profile:'); + console.log(` ${color('ccs api create', 'command')}`); + console.log(''); + return; + } + + // Build table data with status indicators + const rows: string[][] = apis.map((name) => { + const status = isApiConfigured(name) ? color('[OK]', 'success') : color('[!]', 'warning'); + return [name, 'config.yaml', status]; + }); + + // Print table + console.log( + table(rows, { + head: ['API', 'Config', 'Status'], + colWidths: [15, 20, 10], + }) + ); + console.log(''); + + // Show CLIProxy variants if any + const variants = Object.keys(unifiedConfig.cliproxy?.variants || {}); + if (variants.length > 0) { + console.log(subheader('CLIProxy Variants')); + const cliproxyRows = variants.map((name) => { + const variant = unifiedConfig.cliproxy?.variants[name]; + return [name, variant?.provider || 'unknown', variant?.model || '-']; + }); + + console.log( + table(cliproxyRows, { + head: ['Variant', 'Provider', 'Model'], + colWidths: [15, 15, 20], + }) + ); + console.log(''); + } + + console.log(dim(`Total: ${apis.length} API profile(s)`)); + console.log(''); + return; + } + + // Legacy: list from config.json const config = loadConfig(); const apis = Object.keys(config.profiles); @@ -342,16 +495,16 @@ async function handleRemove(args: string[]): Promise { await initUI(); const parsedArgs = parseArgs(args); - // Load config first to get available APIs - let config: { profiles: Record; cliproxy?: Record }; - try { - config = loadConfig(); - } catch { - console.log(fail('Failed to load config')); - process.exit(1); + // Get available APIs based on config mode + let apis: string[]; + if (isUnifiedMode()) { + const unifiedConfig = loadOrCreateUnifiedConfig(); + apis = Object.keys(unifiedConfig.profiles); + } else { + const config = loadConfig(); + apis = Object.keys(config.profiles); } - const apis = Object.keys(config.profiles); if (apis.length === 0) { console.log(warn('No API profiles to remove')); process.exit(0); @@ -375,7 +528,7 @@ async function handleRemove(args: string[]): Promise { }); } - if (!(name in config.profiles)) { + if (!apis.includes(name)) { console.log(fail(`API '${name}' not found`)); console.log(''); console.log('Available APIs:'); @@ -383,13 +536,15 @@ async function handleRemove(args: string[]): Promise { process.exit(1); } - const settingsPath = config.profiles[name]; - const expandedPath = path.join(getCcsDir(), `${name}.settings.json`); - // Confirm deletion console.log(''); console.log(`API '${color(name, 'command')}' will be removed.`); - console.log(` Settings: ${settingsPath}`); + if (isUnifiedMode()) { + console.log(' Config: ~/.ccs/config.yaml'); + console.log(' Secrets: ~/.ccs/secrets.yaml'); + } else { + console.log(` Settings: ~/.ccs/${name}.settings.json`); + } console.log(''); const confirmed = @@ -401,20 +556,32 @@ async function handleRemove(args: string[]): Promise { process.exit(0); } - // Remove from config.json - delete config.profiles[name]; - const configPath = getConfigPath(); - const tempPath = configPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); - fs.renameSync(tempPath, configPath); + try { + if (isUnifiedMode()) { + // Remove from unified config + removeApiProfileUnified(name); + } else { + // Remove from legacy config.json + const config = loadConfig(); + delete config.profiles[name]; + const configPath = getConfigPath(); + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, configPath); - // Remove settings file if it exists - if (fs.existsSync(expandedPath)) { - fs.unlinkSync(expandedPath); + // Remove settings file if it exists + const expandedPath = path.join(getCcsDir(), `${name}.settings.json`); + if (fs.existsSync(expandedPath)) { + fs.unlinkSync(expandedPath); + } + } + + console.log(ok(`API profile removed: ${name}`)); + console.log(''); + } catch (error) { + console.log(fail(`Failed to remove API profile: ${(error as Error).message}`)); + process.exit(1); } - - console.log(ok(`API profile removed: ${name}`)); - console.log(''); } /** diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index 76f2d2b7..a381e021 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -12,6 +12,10 @@ * ccs cliproxy create Create CLIProxy variant profile * ccs cliproxy list List all CLIProxy variant profiles * ccs cliproxy remove Remove CLIProxy variant profile + * + * Supports dual-mode configuration: + * - Unified YAML format (config.yaml) when CCS_UNIFIED_CONFIG=1 or config.yaml exists + * - Legacy JSON format (config.json) as fallback */ import * as fs from 'fs'; @@ -45,6 +49,13 @@ import { infoBox, } from '../utils/ui'; import { InteractivePrompt } from '../utils/prompt'; +import { isReservedName } from '../config/reserved-names'; +import { + hasUnifiedConfig, + loadOrCreateUnifiedConfig, + saveUnifiedConfig, +} from '../config/unified-config-loader'; +import { isUnifiedConfigEnabled } from '../config/feature-flags'; // ============================================================================ // PROFILE MANAGEMENT @@ -99,33 +110,29 @@ function validateProfileName(name: string): string | null { if (name.length > 32) { return 'Name must be 32 characters or less'; } - // Reserved names - includes built-in cliproxy profiles - const reserved = [ - 'default', - 'auth', - 'api', - 'doctor', - 'sync', - 'update', - 'help', - 'version', - 'cliproxy', - 'create', - 'list', - 'remove', - ...CLIPROXY_PROFILES, - ]; - if (reserved.includes(name.toLowerCase())) { + // Use centralized reserved names list (includes built-in cliproxy profiles) + if (isReservedName(name)) { return `'${name}' is a reserved name`; } return null; } +/** + * Check if unified config mode is active + */ +function isUnifiedMode(): boolean { + return hasUnifiedConfig() || isUnifiedConfigEnabled(); +} + /** * Check if CLIProxy variant profile exists */ function cliproxyVariantExists(name: string): boolean { try { + if (isUnifiedMode()) { + const config = loadOrCreateUnifiedConfig(); + return !!(config.cliproxy?.variants && name in config.cliproxy.variants); + } const config = loadConfig(); return !!(config.cliproxy && name in config.cliproxy); } catch { @@ -245,6 +252,57 @@ function removeCliproxyVariant(name: string): { provider: string; settings: stri return variant; } +/** + * Add CLIProxy variant to unified config (config.yaml) + */ +function addCliproxyVariantUnified( + name: string, + provider: CLIProxyProfileName, + model: string, + account?: string +): void { + const config = loadOrCreateUnifiedConfig(); + + // Ensure cliproxy.variants section exists (preserving other fields) + if (!config.cliproxy) { + config.cliproxy = { + oauth_accounts: {}, + providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow'], + variants: {}, + }; + } + if (!config.cliproxy.variants) { + config.cliproxy.variants = {}; + } + + // Add variant + config.cliproxy.variants[name] = { + provider: provider as CLIProxyProvider, + model, + account, + }; + + saveUnifiedConfig(config); +} + +/** + * Remove CLIProxy variant from unified config + */ +function removeCliproxyVariantUnified(name: string): { provider: string; model?: string } | null { + const config = loadOrCreateUnifiedConfig(); + + if (!config.cliproxy?.variants || !(name in config.cliproxy.variants)) { + return null; + } + + const variant = config.cliproxy.variants[name]; + delete config.cliproxy.variants[name]; + + saveUnifiedConfig(config); + + return { provider: variant.provider, model: variant.model }; +} + /** * Format model entry for display */ @@ -432,20 +490,38 @@ async function handleCreate(args: string[]): Promise { console.log(info('Creating CLIProxy variant...')); try { - const settingsPath = createCliproxySettingsFile(name, provider, model, account); - addCliproxyVariant(name, provider, settingsPath, account); + if (isUnifiedMode()) { + // Use unified config format (no settings file needed - env vars derived at runtime) + addCliproxyVariantUnified(name, provider, model, account); - console.log(''); - console.log( - infoBox( - `Variant: ${name}\n` + - `Provider: ${provider}\n` + - `Model: ${model}\n` + - (account ? `Account: ${account}\n` : '') + - `Settings: ~/.ccs/${path.basename(settingsPath)}`, - 'CLIProxy Variant Created' - ) - ); + console.log(''); + console.log( + infoBox( + `Variant: ${name}\n` + + `Provider: ${provider}\n` + + `Model: ${model}\n` + + (account ? `Account: ${account}\n` : '') + + `Config: ~/.ccs/config.yaml`, + 'CLIProxy Variant Created (Unified Config)' + ) + ); + } else { + // Legacy: create settings.json file + const settingsPath = createCliproxySettingsFile(name, provider, model, account); + addCliproxyVariant(name, provider, settingsPath, account); + + console.log(''); + console.log( + infoBox( + `Variant: ${name}\n` + + `Provider: ${provider}\n` + + `Model: ${model}\n` + + (account ? `Account: ${account}\n` : '') + + `Settings: ~/.ccs/${path.basename(settingsPath)}`, + 'CLIProxy Variant Created' + ) + ); + } console.log(''); console.log(header('Usage')); console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); @@ -490,24 +566,47 @@ async function handleList(): Promise { console.log(dim(' To logout: ccs --logout')); console.log(''); - // Show custom variants if any - const config = loadConfig(); - const variants = config.cliproxy || {}; - const variantNames = Object.keys(variants); + // Show custom variants if any (from unified or legacy config) + let variantNames: string[]; + let variantData: Record; + + if (isUnifiedMode()) { + const unifiedConfig = loadOrCreateUnifiedConfig(); + const variants = unifiedConfig.cliproxy?.variants || {}; + variantNames = Object.keys(variants); + variantData = {}; + for (const name of variantNames) { + const v = variants[name]; + variantData[name] = { provider: v.provider, model: v.model }; + } + } else { + const config = loadConfig(); + const variants = config.cliproxy || {}; + variantNames = Object.keys(variants); + variantData = {}; + for (const name of variantNames) { + const v = variants[name] as { provider: string; settings: string }; + variantData[name] = { provider: v.provider, settings: v.settings }; + } + } if (variantNames.length > 0) { console.log(subheader('Custom Variants')); // Build table data const rows: string[][] = variantNames.map((name) => { - const variant = variants[name] as { provider: string; settings: string }; - return [name, variant.provider, variant.settings]; + const variant = variantData[name]; + const thirdCol = variant.model || variant.settings || '-'; + return [name, variant.provider, thirdCol]; }); // Print table + const headLabels = isUnifiedMode() + ? ['Variant', 'Provider', 'Model'] + : ['Variant', 'Provider', 'Settings']; console.log( table(rows, { - head: ['Variant', 'Provider', 'Settings'], + head: headLabels, colWidths: [15, 12, 35], }) ); @@ -532,17 +631,29 @@ async function handleRemove(args: string[]): Promise { await initUI(); const parsedArgs = parseProfileArgs(args); - // Load config to get available variants - let config: { profiles: Record; cliproxy?: Record }; - try { - config = loadConfig(); - } catch { - console.log(fail('Failed to load config')); - process.exit(1); - } + // Get available variants based on config mode + let variantNames: string[]; + let variantData: Record; - const variants = config.cliproxy || {}; - const variantNames = Object.keys(variants); + if (isUnifiedMode()) { + const unifiedConfig = loadOrCreateUnifiedConfig(); + const variants = unifiedConfig.cliproxy?.variants || {}; + variantNames = Object.keys(variants); + variantData = {}; + for (const name of variantNames) { + const v = variants[name]; + variantData[name] = { provider: v.provider, model: v.model }; + } + } else { + const config = loadConfig(); + const variants = config.cliproxy || {}; + variantNames = Object.keys(variants); + variantData = {}; + for (const name of variantNames) { + const v = variants[name] as { provider: string; settings: string }; + variantData[name] = { provider: v.provider, settings: v.settings }; + } + } if (variantNames.length === 0) { console.log(warn('No CLIProxy variants to remove')); @@ -556,7 +667,7 @@ async function handleRemove(args: string[]): Promise { console.log(''); console.log('Available variants:'); variantNames.forEach((n, i) => { - const v = variants[n] as { provider: string }; + const v = variantData[n]; console.log(` ${i + 1}. ${n} (${v.provider})`); }); console.log(''); @@ -570,7 +681,7 @@ async function handleRemove(args: string[]): Promise { }); } - if (!(name in variants)) { + if (!variantNames.includes(name)) { console.log(fail(`Variant '${name}' not found`)); console.log(''); console.log('Available variants:'); @@ -578,13 +689,18 @@ async function handleRemove(args: string[]): Promise { process.exit(1); } - const variant = variants[name] as { provider: string; settings: string }; + const variant = variantData[name]; // Confirm deletion console.log(''); console.log(`Variant '${color(name, 'command')}' will be removed.`); console.log(` Provider: ${variant.provider}`); - console.log(` Settings: ${variant.settings}`); + if (isUnifiedMode()) { + console.log(` Model: ${variant.model || '-'}`); + console.log(' Config: ~/.ccs/config.yaml'); + } else { + console.log(` Settings: ${variant.settings}`); + } console.log(''); const confirmed = @@ -595,21 +711,31 @@ async function handleRemove(args: string[]): Promise { process.exit(0); } - // Remove from config - const removed = removeCliproxyVariant(name); - if (!removed) { - console.log(fail('Failed to remove variant from config')); + try { + if (isUnifiedMode()) { + // Remove from unified config + removeCliproxyVariantUnified(name); + } else { + // Remove from legacy config + const removed = removeCliproxyVariant(name); + if (!removed) { + console.log(fail('Failed to remove variant from config')); + process.exit(1); + } + + // Remove settings file if it exists + const settingsFile = removed.settings.replace(/^~/, process.env.HOME || ''); + if (fs.existsSync(settingsFile)) { + fs.unlinkSync(settingsFile); + } + } + + console.log(ok(`Variant removed: ${name}`)); + console.log(''); + } catch (error) { + console.log(fail(`Failed to remove variant: ${(error as Error).message}`)); process.exit(1); } - - // Remove settings file if it exists - const settingsFile = removed.settings.replace(/^~/, process.env.HOME || ''); - if (fs.existsSync(settingsFile)) { - fs.unlinkSync(settingsFile); - } - - console.log(ok(`Variant removed: ${name}`)); - console.log(''); } // ============================================================================ diff --git a/src/commands/migrate-command.ts b/src/commands/migrate-command.ts new file mode 100644 index 00000000..016a1b8e --- /dev/null +++ b/src/commands/migrate-command.ts @@ -0,0 +1,160 @@ +/** + * Migrate Command + * + * CLI command to migrate from v1 (JSON) to v2 (YAML) config format. + * + * Usage: + * ccs migrate - Run migration + * ccs migrate --dry-run - Preview migration without changes + * ccs migrate --rollback - Restore from backup + * ccs migrate --list-backups - List available backups + */ + +import { + migrate, + rollback, + needsMigration, + getBackupDirectories, +} from '../config/migration-manager'; +import { hasUnifiedConfig } from '../config/unified-config-loader'; + +export async function handleMigrateCommand(args: string[]): Promise { + // Handle --list-backups + if (args.includes('--list-backups')) { + listBackups(); + return; + } + + // Handle --rollback + if (args.includes('--rollback')) { + const rollbackIndex = args.indexOf('--rollback'); + const backupPath = args[rollbackIndex + 1]; + + if (!backupPath) { + console.error('[X] Error: --rollback requires backup path'); + console.log('[i] Usage: ccs migrate --rollback '); + console.log('[i] Use --list-backups to see available backups'); + process.exit(1); + } + + await handleRollback(backupPath); + return; + } + + // Check if already migrated + if (hasUnifiedConfig() && !needsMigration()) { + console.log('[i] Already using unified config format (config.yaml)'); + return; + } + + // Check if migration is needed + if (!needsMigration()) { + console.log('[i] No migration needed - no legacy config found'); + return; + } + + // Handle --dry-run + const dryRun = args.includes('--dry-run'); + + if (dryRun) { + console.log('[i] Dry run - no changes will be made'); + console.log(''); + } + + console.log('[i] Migrating to unified config format (v2)...'); + console.log(''); + + const result = await migrate(dryRun); + + if (result.success) { + console.log('[OK] Migration complete'); + console.log(''); + + if (result.backupPath) { + console.log(` Backup created: ${result.backupPath}`); + } + + if (result.migratedFiles.length > 0) { + console.log(''); + console.log(' Migrated:'); + result.migratedFiles.forEach((f) => console.log(` - ${f}`)); + } + + if (result.warnings.length > 0) { + console.log(''); + console.log(' Warnings:'); + result.warnings.forEach((w) => console.log(` [!] ${w}`)); + } + + if (dryRun) { + console.log(''); + console.log('[i] This was a dry run. Run without --dry-run to apply changes.'); + } else { + console.log(''); + console.log('[i] To rollback: ccs migrate --rollback ' + result.backupPath); + } + } else { + console.error(`[X] Migration failed: ${result.error}`); + + if (result.migratedFiles.length > 0) { + console.log(''); + console.log(' Partially migrated:'); + result.migratedFiles.forEach((f) => console.log(` - ${f}`)); + } + + process.exit(1); + } +} + +async function handleRollback(backupPath: string): Promise { + console.log(`[i] Rolling back from: ${backupPath}`); + console.log(''); + + const success = await rollback(backupPath); + + if (success) { + console.log('[OK] Rollback complete'); + console.log('[i] Legacy config restored'); + } else { + console.error('[X] Rollback failed'); + process.exit(1); + } +} + +function listBackups(): void { + const backups = getBackupDirectories(); + + if (backups.length === 0) { + console.log('[i] No backup directories found'); + return; + } + + console.log('[i] Available backups (most recent first):'); + console.log(''); + backups.forEach((backup, index) => { + console.log(` ${index + 1}. ${backup}`); + }); + console.log(''); + console.log('[i] To rollback: ccs migrate --rollback '); +} + +/** + * Print help for migrate command. + */ +export function printMigrateHelp(): void { + console.log('Usage: ccs migrate [options]'); + console.log(''); + console.log('Migrate from legacy JSON config to unified YAML format.'); + console.log(''); + console.log('Options:'); + console.log(' --dry-run Preview migration without making changes'); + console.log(' --rollback PATH Restore from backup directory'); + console.log(' --list-backups List available backup directories'); + console.log(' --help Show this help message'); + console.log(''); + console.log('Examples:'); + console.log(' ccs migrate # Run migration'); + console.log(' ccs migrate --dry-run # Preview changes'); + console.log(' ccs migrate --list-backups # List backups'); + console.log(' ccs migrate --rollback ~/.ccs/backup-v1-2025-01-15'); +} diff --git a/src/config/feature-flags.ts b/src/config/feature-flags.ts new file mode 100644 index 00000000..6e806f32 --- /dev/null +++ b/src/config/feature-flags.ts @@ -0,0 +1,27 @@ +/** + * Feature flags for gradual rollout of new functionality. + */ + +/** + * Check if unified config (YAML) is enabled. + * Set CCS_UNIFIED_CONFIG=1 to enable. + */ +export function isUnifiedConfigEnabled(): boolean { + return process.env.CCS_UNIFIED_CONFIG === '1'; +} + +/** + * Check if migration mode is active. + * Set CCS_MIGRATE=1 to trigger automatic migration. + */ +export function isMigrationEnabled(): boolean { + return process.env.CCS_MIGRATE === '1'; +} + +/** + * Check if debug mode is enabled. + * Set CCS_DEBUG=1 for verbose logging. + */ +export function isDebugEnabled(): boolean { + return process.env.CCS_DEBUG === '1'; +} diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 00000000..ca503a7c --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,21 @@ +/** + * Config Module Exports + * + * Central export point for unified config functionality. + */ + +// Types +export * from './unified-config-types'; + +// Feature Flags +export * from './feature-flags'; + +// Reserved Names +export * from './reserved-names'; + +// Loaders +export * from './unified-config-loader'; +export * from './secrets-manager'; + +// Migration +export * from './migration-manager'; diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts new file mode 100644 index 00000000..1c259ddf --- /dev/null +++ b/src/config/migration-manager.ts @@ -0,0 +1,424 @@ +/** + * Migration Manager + * + * Handles migration from legacy JSON config (v1) to unified YAML config (v2). + * Features: + * - Automatic backup before migration + * - Rollback support + * - Secret extraction and separation + * - Cache file restructuring + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../utils/config-manager'; +import type { ProfileConfig, AccountConfig, CLIProxyVariantConfig } from './unified-config-types'; +import { createEmptyUnifiedConfig, createEmptySecretsConfig } from './unified-config-types'; +import { saveUnifiedConfig, hasUnifiedConfig } from './unified-config-loader'; +import { saveSecrets, isSecretKey } from './secrets-manager'; + +const BACKUP_DIR_PREFIX = 'backup-v1-'; + +/** + * Migration result with details about what was migrated. + */ +export interface MigrationResult { + success: boolean; + backupPath?: string; + error?: string; + migratedFiles: string[]; + warnings: string[]; +} + +/** + * Check if migration from v1 to v2 is needed. + */ +export function needsMigration(): boolean { + const ccsDir = getCcsDir(); + const hasOldConfig = fs.existsSync(path.join(ccsDir, 'config.json')); + const hasNewConfig = hasUnifiedConfig(); + + return hasOldConfig && !hasNewConfig; +} + +/** + * Get list of backup directories. + */ +export function getBackupDirectories(): string[] { + const ccsDir = getCcsDir(); + if (!fs.existsSync(ccsDir)) return []; + + return fs + .readdirSync(ccsDir) + .filter((name) => name.startsWith(BACKUP_DIR_PREFIX)) + .map((name) => path.join(ccsDir, name)) + .sort() + .reverse(); // Most recent first +} + +/** + * Perform migration from v1 to v2 format. + */ +export async function migrate(dryRun = false): Promise { + const ccsDir = getCcsDir(); + const migratedFiles: string[] = []; + const warnings: string[] = []; + + try { + // 1. Create backup + const timestamp = new Date().toISOString().split('T')[0]; + const backupDir = path.join(ccsDir, `${BACKUP_DIR_PREFIX}${timestamp}`); + + if (!dryRun) { + await createBackup(ccsDir, backupDir); + } + + // 2. Read old configs + const oldConfig = readJsonSafe(path.join(ccsDir, 'config.json')); + const oldProfiles = readJsonSafe(path.join(ccsDir, 'profiles.json')); + + // 3. Build unified config + const unifiedConfig = createEmptyUnifiedConfig(); + const secrets = createEmptySecretsConfig(); + + // Set default if exists + if (oldProfiles?.default && typeof oldProfiles.default === 'string') { + unifiedConfig.default = oldProfiles.default; + } + + // 4. Migrate accounts from profiles.json + if (oldProfiles?.profiles) { + for (const [name, meta] of Object.entries(oldProfiles.profiles)) { + const metadata = meta as Record; + const account: AccountConfig = { + created: (metadata.created as string) || new Date().toISOString(), + last_used: (metadata.last_used as string) || null, + }; + unifiedConfig.accounts[name] = account; + } + migratedFiles.push('profiles.json → config.yaml.accounts'); + } + + // 5. Migrate CLIProxy variants from config.json + if (oldConfig?.cliproxy) { + for (const [name, variantData] of Object.entries(oldConfig.cliproxy)) { + const oldVariant = variantData as Record; + const variant: CLIProxyVariantConfig = { + provider: oldVariant.provider as CLIProxyVariantConfig['provider'], + }; + + if (oldVariant.account) { + variant.account = oldVariant.account as string; + } + + // Extract model from settings file if exists + if (oldVariant.settings) { + const settingsPath = expandPath(oldVariant.settings as string); + const settings = readJsonSafe(settingsPath); + const env = settings?.env as Record | undefined; + if (env?.ANTHROPIC_MODEL) { + variant.model = env.ANTHROPIC_MODEL; + } + } + + unifiedConfig.cliproxy.variants[name] = variant; + } + migratedFiles.push('config.json.cliproxy → config.yaml.cliproxy.variants'); + } + + // 6. Migrate API profiles from config.json + settings files + if (oldConfig?.profiles) { + for (const [name, settingsPath] of Object.entries(oldConfig.profiles)) { + const expandedPath = expandPath(settingsPath as string); + const settings = readJsonSafe(expandedPath); + + if (settings?.env) { + // Split env into config (non-secret) and secrets + const envConfig: Record = {}; + const envSecrets: Record = {}; + + for (const [key, value] of Object.entries(settings.env)) { + if (isSecretKey(key)) { + envSecrets[key] = value as string; + } else { + envConfig[key] = value as string; + } + } + + const profile: ProfileConfig = { + type: 'api', + env: envConfig, + }; + unifiedConfig.profiles[name] = profile; + + if (Object.keys(envSecrets).length > 0) { + secrets.profiles[name] = envSecrets; + } + + migratedFiles.push(`${name}.settings.json → config.yaml.profiles.${name}`); + } else { + warnings.push(`Skipped ${name}: no env vars found in ${expandedPath}`); + } + } + } + + // 6b. Migrate built-in CLIProxy OAuth profile settings (gemini, codex, agy, qwen, iflow) + // These files contain user overrides like custom models or env vars (ANTHROPIC_MAX_TOKENS, etc.) + const builtInProviders = ['gemini', 'codex', 'agy', 'qwen', 'iflow']; + for (const provider of builtInProviders) { + const settingsPath = path.join(ccsDir, `${provider}.settings.json`); + if (fs.existsSync(settingsPath)) { + const settings = readJsonSafe(settingsPath); + const env = settings?.env as Record | undefined; + if (env) { + // Extract user-configurable values (skip CCS-internal values) + const userEnv: Record = {}; + let userModel: string | undefined; + + for (const [key, value] of Object.entries(env)) { + // Skip internal CCS-managed values (these are auto-generated at runtime) + if (key === 'ANTHROPIC_AUTH_TOKEN' && value === 'ccs-internal-managed') continue; + if (key === 'ANTHROPIC_BASE_URL' && value.includes('127.0.0.1')) continue; + // Skip model tier settings that are derived from primary model + if (key === 'ANTHROPIC_DEFAULT_OPUS_MODEL') continue; + if (key === 'ANTHROPIC_DEFAULT_SONNET_MODEL') continue; + if (key === 'ANTHROPIC_DEFAULT_HAIKU_MODEL') continue; + // Skip secrets (should not be in CLIProxy settings anyway) + if (isSecretKey(key)) continue; + + // Extract model separately + if (key === 'ANTHROPIC_MODEL') { + userModel = value; + continue; + } + + // Keep other user-configurable env vars + userEnv[key] = value; + } + + // Only create variant if there's user customization + if (userModel || Object.keys(userEnv).length > 0) { + const variant: CLIProxyVariantConfig = { + provider: provider as CLIProxyVariantConfig['provider'], + }; + + if (userModel) { + variant.model = userModel; + } + + if (Object.keys(userEnv).length > 0) { + variant.env = userEnv; + } + + unifiedConfig.cliproxy.variants[provider] = variant; + migratedFiles.push( + `${provider}.settings.json → config.yaml.cliproxy.variants.${provider}` + ); + } + } + } + } + + // 7. Migrate cache files + if (!dryRun) { + const cacheDir = path.join(ccsDir, 'cache'); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + + if (moveIfExists(path.join(ccsDir, 'usage-cache.json'), path.join(cacheDir, 'usage.json'))) { + migratedFiles.push('usage-cache.json → cache/usage.json'); + } + + if ( + moveIfExists( + path.join(ccsDir, 'update-check.json'), + path.join(cacheDir, 'update-check.json') + ) + ) { + migratedFiles.push('update-check.json → cache/update-check.json'); + } + } + + // 8. Write new configs (unless dry run) + if (!dryRun) { + saveUnifiedConfig(unifiedConfig); + + if (Object.keys(secrets.profiles).length > 0) { + saveSecrets(secrets); + migratedFiles.push('secrets extracted → secrets.yaml'); + } + } + + return { + success: true, + backupPath: dryRun ? undefined : backupDir, + migratedFiles, + warnings, + }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : 'Unknown error', + migratedFiles, + warnings, + }; + } +} + +/** + * Rollback migration by restoring from backup. + */ +export async function rollback(backupPath: string): Promise { + const ccsDir = getCcsDir(); + + if (!fs.existsSync(backupPath)) { + console.error(`[X] Backup not found: ${backupPath}`); + return false; + } + + try { + // Remove new config files + const configYaml = path.join(ccsDir, 'config.yaml'); + const secretsYaml = path.join(ccsDir, 'secrets.yaml'); + const cacheDir = path.join(ccsDir, 'cache'); + + if (fs.existsSync(configYaml)) fs.unlinkSync(configYaml); + if (fs.existsSync(secretsYaml)) fs.unlinkSync(secretsYaml); + + // Restore cache files to original locations + if (fs.existsSync(cacheDir)) { + moveIfExists(path.join(cacheDir, 'usage.json'), path.join(ccsDir, 'usage-cache.json')); + moveIfExists( + path.join(cacheDir, 'update-check.json'), + path.join(ccsDir, 'update-check.json') + ); + + // Remove cache dir if empty + const remaining = fs.readdirSync(cacheDir); + if (remaining.length === 0) { + fs.rmdirSync(cacheDir); + } + } + + // Restore files from backup + const files = fs.readdirSync(backupPath); + for (const file of files) { + fs.copyFileSync(path.join(backupPath, file), path.join(ccsDir, file)); + } + + return true; + } catch (err) { + console.error(`[X] Rollback failed: ${err instanceof Error ? err.message : 'Unknown error'}`); + return false; + } +} + +// --- Helper Functions --- + +/** + * Read JSON file safely, returning null on error or if file doesn't exist. + */ +function readJsonSafe(filePath: string): Record | null { + if (!fs.existsSync(filePath)) return null; + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record; + } catch { + return null; + } +} + +/** + * Expand ~ to home directory in path. + */ +function expandPath(p: string): string { + return p.replace(/^~/, process.env.HOME || ''); +} + +/** + * Move file if it exists. Returns true if moved, false if source didn't exist. + */ +function moveIfExists(from: string, to: string): boolean { + if (fs.existsSync(from)) { + fs.renameSync(from, to); + return true; + } + return false; +} + +/** + * Create backup of all v1 config files. + */ +async function createBackup(srcDir: string, backupDir: string): Promise { + // Check if backup already exists (prevent overwriting) + if (fs.existsSync(backupDir)) { + // Add timestamp suffix to make unique + const suffix = Date.now().toString(36); + const uniqueBackupDir = `${backupDir}-${suffix}`; + fs.mkdirSync(uniqueBackupDir, { recursive: true }); + await performBackup(srcDir, uniqueBackupDir); + return; + } + + fs.mkdirSync(backupDir, { recursive: true }); + await performBackup(srcDir, backupDir); +} + +async function performBackup(srcDir: string, backupDir: string): Promise { + const filesToBackup = ['config.json', 'profiles.json', 'usage-cache.json', 'update-check.json']; + + // Also backup *.settings.json files + const allFiles = fs.readdirSync(srcDir); + const settingsFiles = allFiles.filter((f) => f.endsWith('.settings.json')); + filesToBackup.push(...settingsFiles); + + for (const file of filesToBackup) { + const src = path.join(srcDir, file); + if (fs.existsSync(src)) { + fs.copyFileSync(src, path.join(backupDir, file)); + } + } +} + +/** + * Auto-migrate on first run after update. + * Silent if already migrated or no config exists. + * Shows friendly message with backup location on success. + */ +export async function autoMigrate(): Promise { + // Skip in test environment + if (process.env.NODE_ENV === 'test' || process.env.CCS_SKIP_MIGRATION === '1') { + return; + } + + // Skip if no migration needed + if (!needsMigration()) { + return; + } + + const result = await migrate(false); + + if (result.success) { + console.log(''); + console.log('╭─────────────────────────────────────────────────────────╮'); + console.log('│ [OK] Migrated to unified config (config.yaml) │'); + console.log('╰─────────────────────────────────────────────────────────╯'); + console.log(` Backup: ${result.backupPath}`); + console.log(` Items: ${result.migratedFiles.length} migrated`); + if (result.warnings.length > 0) { + for (const warning of result.warnings) { + console.log(` [!] ${warning}`); + } + } + console.log(' Rollback: ccs migrate --rollback'); + console.log(''); + } else { + console.log(''); + console.log('╭─────────────────────────────────────────────────────────╮'); + console.log('│ [!] Migration failed - using legacy config │'); + console.log('╰─────────────────────────────────────────────────────────╯'); + console.log(` Error: ${result.error}`); + console.log(' Retry: ccs migrate'); + console.log(''); + } +} diff --git a/src/config/reserved-names.ts b/src/config/reserved-names.ts new file mode 100644 index 00000000..657e5d78 --- /dev/null +++ b/src/config/reserved-names.ts @@ -0,0 +1,40 @@ +/** + * Reserved profile names that cannot be used for user-defined profiles. + * These names are reserved for CLIProxy providers and CLI commands. + */ +export const RESERVED_PROFILE_NAMES = [ + // CLIProxy providers (built-in OAuth) + 'gemini', + 'codex', + 'agy', + 'qwen', + 'iflow', + // CLI commands and special names + 'default', + 'config', + 'cliproxy', +] as const; + +export type ReservedProfileName = (typeof RESERVED_PROFILE_NAMES)[number]; + +/** + * Check if a name is reserved and cannot be used for user profiles. + * @param name - The profile name to check + * @returns true if the name is reserved + */ +export function isReservedName(name: string): boolean { + return RESERVED_PROFILE_NAMES.includes(name.toLowerCase() as ReservedProfileName); +} + +/** + * Validate a profile name and throw if reserved. + * @param name - The profile name to validate + * @throws Error if the name is reserved + */ +export function validateProfileName(name: string): void { + if (isReservedName(name)) { + throw new Error( + `Profile name '${name}' is reserved. Reserved names: ${RESERVED_PROFILE_NAMES.join(', ')}` + ); + } +} diff --git a/src/config/secrets-manager.ts b/src/config/secrets-manager.ts new file mode 100644 index 00000000..c8c3d26e --- /dev/null +++ b/src/config/secrets-manager.ts @@ -0,0 +1,187 @@ +/** + * Secrets Manager + * + * Handles loading and saving secrets (API keys, tokens) in a separate file + * with restricted permissions (chmod 600). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; +import { getCcsDir } from '../utils/config-manager'; +import { SecretsConfig, isSecretsConfig, createEmptySecretsConfig } from './unified-config-types'; + +// Re-export from shared utility for backward compatibility +export { isSensitiveKey as isSecretKey } from '../utils/sensitive-keys'; + +const SECRETS_FILE = 'secrets.yaml'; +const SECRETS_FILE_MODE = 0o600; // Owner read/write only + +/** + * Get path to secrets.yaml + */ +export function getSecretsPath(): string { + return path.join(getCcsDir(), SECRETS_FILE); +} + +/** + * Check if secrets.yaml exists + */ +export function hasSecrets(): boolean { + return fs.existsSync(getSecretsPath()); +} + +/** + * Load secrets from YAML file. + * Returns empty secrets config if file doesn't exist. + */ +export function loadSecrets(): SecretsConfig { + const secretsPath = getSecretsPath(); + + if (!fs.existsSync(secretsPath)) { + return createEmptySecretsConfig(); + } + + try { + const content = fs.readFileSync(secretsPath, 'utf8'); + const parsed = yaml.load(content); + + if (!isSecretsConfig(parsed)) { + console.error(`[!] Invalid secrets format in ${secretsPath}`); + return createEmptySecretsConfig(); + } + + return parsed; + } catch (err) { + const error = err instanceof Error ? err.message : 'Unknown error'; + console.error(`[X] Failed to load secrets: ${error}`); + return createEmptySecretsConfig(); + } +} + +/** + * Save secrets to YAML file with restricted permissions. + * Uses atomic write (temp file + rename) to prevent corruption. + */ +export function saveSecrets(secrets: SecretsConfig): void { + const secretsPath = getSecretsPath(); + const dir = path.dirname(secretsPath); + + // Ensure directory exists + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + // Convert to YAML + const content = yaml.dump(secrets, { + indent: 2, + lineWidth: -1, + quotingType: '"', + noRefs: true, + }); + + // Atomic write: write to temp file, then rename + const tempPath = `${secretsPath}.tmp.${process.pid}`; + + try { + fs.writeFileSync(tempPath, content, { mode: SECRETS_FILE_MODE }); + fs.renameSync(tempPath, secretsPath); + + // Ensure correct permissions after rename (some systems may not preserve) + fs.chmodSync(secretsPath, SECRETS_FILE_MODE); + } catch (err) { + // Clean up temp file on error + if (fs.existsSync(tempPath)) { + try { + fs.unlinkSync(tempPath); + } catch { + // Ignore cleanup errors + } + } + throw err; + } +} + +/** + * Get a secret value for a specific profile. + */ +export function getProfileSecret(profileName: string, key: string): string | undefined { + const secrets = loadSecrets(); + return secrets.profiles[profileName]?.[key]; +} + +/** + * Set a secret value for a specific profile. + */ +export function setProfileSecret(profileName: string, key: string, value: string): void { + const secrets = loadSecrets(); + + if (!secrets.profiles[profileName]) { + secrets.profiles[profileName] = {}; + } + + secrets.profiles[profileName][key] = value; + saveSecrets(secrets); +} + +/** + * Delete a secret value for a specific profile. + */ +export function deleteProfileSecret(profileName: string, key: string): boolean { + const secrets = loadSecrets(); + + if (!secrets.profiles[profileName]?.[key]) { + return false; + } + + delete secrets.profiles[profileName][key]; + + // Clean up empty profile object + if (Object.keys(secrets.profiles[profileName]).length === 0) { + delete secrets.profiles[profileName]; + } + + saveSecrets(secrets); + return true; +} + +/** + * Get all secrets for a profile. + */ +export function getProfileSecrets(profileName: string): Record { + const secrets = loadSecrets(); + return secrets.profiles[profileName] || {}; +} + +/** + * Set all secrets for a profile (replaces existing). + */ +export function setProfileSecrets( + profileName: string, + profileSecrets: Record +): void { + const secrets = loadSecrets(); + + if (Object.keys(profileSecrets).length === 0) { + delete secrets.profiles[profileName]; + } else { + secrets.profiles[profileName] = profileSecrets; + } + + saveSecrets(secrets); +} + +/** + * Delete all secrets for a profile. + */ +export function deleteAllProfileSecrets(profileName: string): boolean { + const secrets = loadSecrets(); + + if (!secrets.profiles[profileName]) { + return false; + } + + delete secrets.profiles[profileName]; + saveSecrets(secrets); + return true; +} diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts new file mode 100644 index 00000000..458ed993 --- /dev/null +++ b/src/config/unified-config-loader.ts @@ -0,0 +1,260 @@ +/** + * Unified Config Loader + * + * Loads and saves the unified YAML configuration. + * Provides fallback to legacy JSON format for backward compatibility. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; +import { getCcsDir } from '../utils/config-manager'; +import { + UnifiedConfig, + isUnifiedConfig, + createEmptyUnifiedConfig, + UNIFIED_CONFIG_VERSION, +} from './unified-config-types'; +import { isUnifiedConfigEnabled } from './feature-flags'; + +const CONFIG_YAML = 'config.yaml'; +const CONFIG_JSON = 'config.json'; + +/** + * Get path to unified config.yaml + */ +export function getConfigYamlPath(): string { + return path.join(getCcsDir(), CONFIG_YAML); +} + +/** + * Get path to legacy config.json + */ +export function getConfigJsonPath(): string { + return path.join(getCcsDir(), CONFIG_JSON); +} + +/** + * Check if unified config.yaml exists + */ +export function hasUnifiedConfig(): boolean { + return fs.existsSync(getConfigYamlPath()); +} + +/** + * Check if legacy config.json exists + */ +export function hasLegacyConfig(): boolean { + return fs.existsSync(getConfigJsonPath()); +} + +/** + * Determine which config format is active. + * Returns 'yaml' if unified config exists or is enabled, + * 'json' if only legacy config exists, + * 'none' if no config exists. + */ +export function getConfigFormat(): 'yaml' | 'json' | 'none' { + if (hasUnifiedConfig()) return 'yaml'; + if (isUnifiedConfigEnabled()) return 'yaml'; + if (hasLegacyConfig()) return 'json'; + return 'none'; +} + +/** + * Load unified config from YAML file. + * Returns null if file doesn't exist or format check fails. + */ +export function loadUnifiedConfig(): UnifiedConfig | null { + const yamlPath = getConfigYamlPath(); + + // If file doesn't exist, return null + if (!fs.existsSync(yamlPath)) { + return null; + } + + try { + const content = fs.readFileSync(yamlPath, 'utf8'); + const parsed = yaml.load(content); + + if (!isUnifiedConfig(parsed)) { + console.error(`[!] Invalid config format in ${yamlPath}`); + return null; + } + + return parsed; + } catch (err) { + const error = err instanceof Error ? err.message : 'Unknown error'; + console.error(`[X] Failed to load config: ${error}`); + return null; + } +} + +/** + * Load config, preferring YAML if available, falling back to creating empty config. + */ +export function loadOrCreateUnifiedConfig(): UnifiedConfig { + const existing = loadUnifiedConfig(); + if (existing) return existing; + + // Create empty config + const config = createEmptyUnifiedConfig(); + return config; +} + +/** + * Generate YAML header with helpful comments. + */ +function generateYamlHeader(): string { + return `# ============================================================================ +# CCS Unified Configuration (config.yaml) +# ============================================================================ +# Generated by: ccs migrate +# Documentation: https://github.com/kaitranntt/ccs +# +# This file consolidates all CCS configuration in a single, human-readable format. +# Secrets (API keys, tokens) are stored separately in secrets.yaml (chmod 600). +# +# Structure: +# ┌─────────────────────────────────────────────────────────────────────────────┐ +# │ version - Config format version (do not modify) │ +# │ default - Default profile name when running 'ccs' without args │ +# │ accounts - Isolated Claude instances (managed by 'ccs auth') │ +# │ profiles - API providers like GLM, GLMT, Kimi (managed by 'ccs api') │ +# │ cliproxy - OAuth providers: gemini, codex, agy (managed by 'ccs') │ +# │ preferences - User preferences (theme, telemetry, auto-update) │ +# └─────────────────────────────────────────────────────────────────────────────┘ +# +# Usage: +# ccs Switch to profile +# ccs api add Add new API profile +# ccs cliproxy create Create CLIProxy variant +# ccs migrate --rollback Restore from backup +# +`; +} + +/** + * Generate YAML content with section comments for better readability. + */ +function generateYamlWithComments(config: UnifiedConfig): string { + const lines: string[] = []; + + // Version + lines.push(`version: ${config.version}`); + lines.push(''); + + // Default + if (config.default) { + lines.push(`# Default profile used when running 'ccs' without arguments`); + lines.push(`default: "${config.default}"`); + lines.push(''); + } + + // Accounts section + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Accounts: Isolated Claude instances (each with separate auth/sessions)'); + lines.push('# Manage with: ccs auth add , ccs auth list, ccs auth remove '); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ accounts: config.accounts }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + + // Profiles section + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Profiles: API-based providers (GLM, GLMT, Kimi, custom endpoints)'); + lines.push('# Manage with: ccs api add , ccs api list, ccs api remove '); + lines.push('# Note: env vars like ANTHROPIC_MAX_TOKENS go here, secrets in secrets.yaml'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ profiles: config.profiles }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + + // CLIProxy section + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# CLIProxy: OAuth-based providers (gemini, codex, agy, qwen, iflow)'); + lines.push('# Manage with: ccs cliproxy create, ccs cliproxy list, ccs cliproxy remove'); + lines.push('# Built-in providers require no config - just run: ccs gemini --auth'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ cliproxy: config.cliproxy }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + + // Preferences section + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Preferences: User settings'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump({ preferences: config.preferences }, { indent: 2, lineWidth: -1, quotingType: '"' }) + .trim() + ); + lines.push(''); + + return lines.join('\n'); +} + +/** + * Save unified config to YAML file. + * Uses atomic write (temp file + rename) to prevent corruption. + */ +export function saveUnifiedConfig(config: UnifiedConfig): void { + const yamlPath = getConfigYamlPath(); + const dir = path.dirname(yamlPath); + + // Ensure directory exists + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + // Ensure version is set + config.version = UNIFIED_CONFIG_VERSION; + + // Generate YAML with section comments + const yamlContent = generateYamlWithComments(config); + const content = generateYamlHeader() + yamlContent; + + // Atomic write: write to temp file, then rename + const tempPath = `${yamlPath}.tmp.${process.pid}`; + + try { + fs.writeFileSync(tempPath, content, { mode: 0o600 }); + fs.renameSync(tempPath, yamlPath); + } catch (err) { + // Clean up temp file on error + if (fs.existsSync(tempPath)) { + try { + fs.unlinkSync(tempPath); + } catch { + // Ignore cleanup errors + } + } + throw err; + } +} + +/** + * Update unified config with partial data. + * Loads existing config, merges changes, and saves. + */ +export function updateUnifiedConfig(updates: Partial): UnifiedConfig { + const config = loadOrCreateUnifiedConfig(); + const updated = { ...config, ...updates }; + saveUnifiedConfig(updated); + return updated; +} + +/** + * Get or set default profile name. + */ +export function getDefaultProfile(): string | undefined { + const config = loadUnifiedConfig(); + return config?.default; +} + +export function setDefaultProfile(name: string): void { + updateUnifiedConfig({ default: name }); +} diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts new file mode 100644 index 00000000..e62c4ffb --- /dev/null +++ b/src/config/unified-config-types.ts @@ -0,0 +1,170 @@ +/** + * Unified Config Types for CCS v2 + * + * This file defines the new unified YAML configuration format that consolidates: + * - config.json (API profiles) + * - profiles.json (account metadata) + * - *.settings.json (env vars) + * + * Into a single config.yaml + secrets.yaml structure. + */ + +/** + * Unified config version. + * Version 2 = YAML unified format + */ +export const UNIFIED_CONFIG_VERSION = 2; + +/** + * Account configuration (formerly in profiles.json). + * Represents an isolated Claude instance via CLAUDE_CONFIG_DIR. + */ +export interface AccountConfig { + /** ISO timestamp when account was created */ + created: string; + /** ISO timestamp of last usage, null if never used */ + last_used: string | null; +} + +/** + * API-based profile configuration. + * Injects environment variables for alternative providers (GLM, Kimi, etc.). + */ +export interface ProfileConfig { + /** Profile type - currently only 'api' */ + type: 'api'; + /** Environment variables (non-secret values only) */ + env: Record; +} + +/** + * CLIProxy OAuth account nickname mapping. + * Maps user-friendly nicknames to email addresses. + */ +export type OAuthAccounts = Record; + +/** + * CLIProxy variant configuration. + * User-defined variants of built-in OAuth providers. + */ +export interface CLIProxyVariantConfig { + /** Base provider to use */ + provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow'; + /** Account nickname (references oauth_accounts) */ + account?: string; + /** Model override (inline, no separate settings file) */ + model?: string; + /** Additional env var overrides (ANTHROPIC_MAX_TOKENS, MAX_THINKING_TOKENS, etc.) */ + env?: Record; +} + +/** + * CLIProxy configuration section. + */ +export interface CLIProxyConfig { + /** Nickname to email mapping for OAuth accounts */ + oauth_accounts: OAuthAccounts; + /** Built-in providers (read-only, for reference) */ + providers: readonly string[]; + /** User-defined provider variants */ + variants: Record; +} + +/** + * User preferences. + */ +export interface PreferencesConfig { + /** UI theme preference */ + theme?: 'light' | 'dark' | 'system'; + /** Enable anonymous telemetry */ + telemetry?: boolean; + /** Enable automatic update checks */ + auto_update?: boolean; +} + +/** + * Main unified configuration structure. + * Stored in ~/.ccs/config.yaml + */ +export interface UnifiedConfig { + /** Config version (2 for unified format) */ + version: number; + /** Default profile name to use when none specified */ + default?: string; + /** Account-based profiles (isolated Claude instances) */ + accounts: Record; + /** API-based profiles (env var injection) */ + profiles: Record; + /** CLIProxy configuration */ + cliproxy: CLIProxyConfig; + /** User preferences */ + preferences: PreferencesConfig; +} + +/** + * Secrets configuration structure. + * Stored in ~/.ccs/secrets.yaml with chmod 600. + * Contains sensitive values like API keys. + */ +export interface SecretsConfig { + /** Secrets version */ + version: number; + /** Profile secrets mapping: profile_name -> { key: value } */ + profiles: Record>; +} + +/** + * Create an empty unified config with defaults. + */ +export function createEmptyUnifiedConfig(): UnifiedConfig { + return { + version: UNIFIED_CONFIG_VERSION, + default: undefined, + accounts: {}, + profiles: {}, + cliproxy: { + oauth_accounts: {}, + providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow'], + variants: {}, + }, + preferences: { + theme: 'system', + telemetry: false, + auto_update: true, + }, + }; +} + +/** + * Create an empty secrets config. + */ +export function createEmptySecretsConfig(): SecretsConfig { + return { + version: 1, + profiles: {}, + }; +} + +/** + * Type guard for UnifiedConfig. + */ +export function isUnifiedConfig(obj: unknown): obj is UnifiedConfig { + if (typeof obj !== 'object' || obj === null) return false; + const config = obj as Record; + return ( + typeof config.version === 'number' && + config.version === UNIFIED_CONFIG_VERSION && + typeof config.accounts === 'object' && + typeof config.profiles === 'object' && + typeof config.cliproxy === 'object' + ); +} + +/** + * Type guard for SecretsConfig. + */ +export function isSecretsConfig(obj: unknown): obj is SecretsConfig { + if (typeof obj !== 'object' || obj === null) return false; + const config = obj as Record; + return typeof config.version === 'number' && typeof config.profiles === 'object'; +} diff --git a/src/utils/sensitive-keys.ts b/src/utils/sensitive-keys.ts new file mode 100644 index 00000000..d163f90d --- /dev/null +++ b/src/utils/sensitive-keys.ts @@ -0,0 +1,64 @@ +/** + * Sensitive Key Detection Utilities + * + * Shared patterns for detecting secret/sensitive environment variable keys. + * Used by migration (secrets separation) and UI (masking). + */ + +/** + * Patterns that match sensitive keys (API keys, tokens, passwords). + * More specific than substring matching to avoid false positives. + */ +export const SENSITIVE_KEY_PATTERNS = [ + /^ANTHROPIC_AUTH_TOKEN$/, // Exact match for Anthropic auth token + /_API_KEY$/, // Keys ending with _API_KEY + /_AUTH_TOKEN$/, // Keys ending with _AUTH_TOKEN + /_SECRET$/, // Keys ending with _SECRET + /_SECRET_KEY$/, // Keys ending with _SECRET_KEY + /^API_KEY$/, // Exact match for API_KEY + /^AUTH_TOKEN$/, // Exact match for AUTH_TOKEN + /^SECRET$/, // Exact match for SECRET + /_PASSWORD$/, // Keys ending with _PASSWORD + /^PASSWORD$/, // Exact match for PASSWORD + /_CREDENTIAL$/, // Keys ending with _CREDENTIAL + /_PRIVATE_KEY$/, // Keys ending with _PRIVATE_KEY +]; + +/** + * Check if a key name contains a secret/sensitive value. + * Used during migration to separate secrets from config. + * + * @param key - Environment variable key name + * @returns true if the key likely contains sensitive data + */ +export function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key)); +} + +/** + * Mask a sensitive value for display. + * Shows first 4 and last 4 characters, replaces middle with asterisks. + * + * @param value - The sensitive value to mask + * @returns Masked value like "sk-a****xyz1" + */ +export function maskSensitiveValue(value: string): string { + if (!value || value.length <= 8) { + return '*'.repeat(value?.length || 0); + } + return value.slice(0, 4) + '*'.repeat(Math.max(0, value.length - 8)) + value.slice(-4); +} + +/** + * Mask all sensitive keys in an env object. + * + * @param env - Environment variables object + * @returns New object with sensitive values masked + */ +export function maskSensitiveEnv(env: Record): Record { + const masked: Record = {}; + for (const [key, value] of Object.entries(env)) { + masked[key] = isSensitiveKey(key) ? maskSensitiveValue(value) : value; + } + return masked; +} diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 5b48c420..25007940 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -19,6 +19,22 @@ import { removeAccount as removeAccountFn, } from '../cliproxy/account-manager'; import type { CLIProxyProvider } from '../cliproxy/types'; +// Unified config imports +import { + hasUnifiedConfig, + loadUnifiedConfig, + saveUnifiedConfig, + getConfigFormat, +} from '../config/unified-config-loader'; +import { + needsMigration, + migrate, + rollback, + getBackupDirectories, +} from '../config/migration-manager'; +import { getProfileSecrets, setProfileSecrets } from '../config/secrets-manager'; +import { isUnifiedConfig } from '../config/unified-config-types'; +import { isSensitiveKey, maskSensitiveValue } from '../utils/sensitive-keys'; export const apiRoutes = Router(); @@ -478,22 +494,10 @@ function maskApiKeys(settings: Settings): Settings { if (!settings.env) return settings; const masked = { ...settings, env: { ...settings.env } }; - // Pattern-based matching for sensitive keys - const sensitivePatterns = [ - /^ANTHROPIC_AUTH_TOKEN$/, // Exact match for Anthropic auth token - /_API_KEY$/, // Keys ending with _API_KEY - /_AUTH_TOKEN$/, // Keys ending with _AUTH_TOKEN - /^API_KEY$/, // Exact match for API_KEY - /^AUTH_TOKEN$/, // Exact match for AUTH_TOKEN - ]; for (const key of Object.keys(masked.env)) { - if (sensitivePatterns.some((pattern) => pattern.test(key))) { - const value = masked.env[key]; - if (value && value.length > 8) { - masked.env[key] = - value.slice(0, 4) + '*'.repeat(Math.max(0, value.length - 8)) + value.slice(-4); - } + if (isSensitiveKey(key)) { + masked.env[key] = maskSensitiveValue(masked.env[key]); } } @@ -669,3 +673,109 @@ apiRoutes.post('/health/fix/:checkId', (req: Request, res: Response): void => { res.status(400).json({ success: false, message: result.message }); } }); + +// ==================== Unified Config (Phase 5) ==================== + +/** + * GET /api/config/format - Return current config format and migration status + */ +apiRoutes.get('/config/format', (_req: Request, res: Response) => { + res.json({ + format: getConfigFormat(), + migrationNeeded: needsMigration(), + backups: getBackupDirectories(), + }); +}); + +/** + * GET /api/config - Return unified config (excludes secrets) + */ +apiRoutes.get('/config', (_req: Request, res: Response): void => { + if (!hasUnifiedConfig()) { + res.status(400).json({ error: 'Unified config not enabled' }); + return; + } + + const config = loadUnifiedConfig(); + if (!config) { + res.status(500).json({ error: 'Failed to load config' }); + return; + } + + res.json(config); +}); + +/** + * PUT /api/config - Update unified config + */ +apiRoutes.put('/config', (req: Request, res: Response): void => { + const config = req.body; + + if (!isUnifiedConfig(config)) { + res.status(400).json({ error: 'Invalid config format' }); + return; + } + + try { + saveUnifiedConfig(config); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +/** + * POST /api/config/migrate - Trigger migration from JSON to YAML + */ +apiRoutes.post('/config/migrate', async (req: Request, res: Response) => { + const dryRun = req.query.dryRun === 'true'; + const result = await migrate(dryRun); + res.json(result); +}); + +/** + * POST /api/config/rollback - Rollback migration to JSON format + */ +apiRoutes.post('/config/rollback', async (req: Request, res: Response): Promise => { + const { backupPath } = req.body; + + if (!backupPath || typeof backupPath !== 'string') { + res.status(400).json({ error: 'Missing required field: backupPath' }); + return; + } + + const success = await rollback(backupPath); + res.json({ success }); +}); + +/** + * PUT /api/secrets/:profile - Update profile secrets (write-only) + */ +apiRoutes.put('/secrets/:profile', (req: Request, res: Response): void => { + const { profile } = req.params; + const secrets = req.body; + + if (!secrets || typeof secrets !== 'object') { + res.status(400).json({ error: 'Invalid secrets format' }); + return; + } + + try { + setProfileSecrets(profile, secrets as Record); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +/** + * GET /api/secrets/:profile/exists - Check if secrets exist (no values returned) + */ +apiRoutes.get('/secrets/:profile/exists', (req: Request, res: Response) => { + const { profile } = req.params; + const secrets = getProfileSecrets(profile); + res.json({ + exists: Object.keys(secrets).length > 0, + keys: Object.keys(secrets), // Only key names, not values + }); +}); diff --git a/tests/unit/unified-config.test.ts b/tests/unit/unified-config.test.ts new file mode 100644 index 00000000..3ad80b22 --- /dev/null +++ b/tests/unit/unified-config.test.ts @@ -0,0 +1,201 @@ +/** + * Unit tests for unified config module + */ +import { describe, it, expect } from 'bun:test'; + +// Import only modules without transitive dependencies on config-manager +import { + isReservedName, + validateProfileName, + RESERVED_PROFILE_NAMES, +} from '../../src/config/reserved-names'; +import { + createEmptyUnifiedConfig, + createEmptySecretsConfig, + isUnifiedConfig, + isSecretsConfig, + UNIFIED_CONFIG_VERSION, +} from '../../src/config/unified-config-types'; +import { isUnifiedConfigEnabled } from '../../src/config/feature-flags'; + +// Inline helper to test secret key detection (copied from secrets-manager to avoid import chain) +function isSecretKey(key: string): boolean { + const upper = key.toUpperCase(); + const secretPatterns = ['TOKEN', 'SECRET', 'API_KEY', 'APIKEY', 'PASSWORD', 'CREDENTIAL', 'AUTH', 'PRIVATE']; + return secretPatterns.some((pattern) => upper.includes(pattern)); +} + +describe('reserved-names', () => { + describe('RESERVED_PROFILE_NAMES', () => { + it('should include CLIProxy providers', () => { + expect(RESERVED_PROFILE_NAMES).toContain('gemini'); + expect(RESERVED_PROFILE_NAMES).toContain('codex'); + expect(RESERVED_PROFILE_NAMES).toContain('agy'); + expect(RESERVED_PROFILE_NAMES).toContain('qwen'); + expect(RESERVED_PROFILE_NAMES).toContain('iflow'); + }); + + it('should include CLI commands', () => { + expect(RESERVED_PROFILE_NAMES).toContain('default'); + expect(RESERVED_PROFILE_NAMES).toContain('config'); + expect(RESERVED_PROFILE_NAMES).toContain('cliproxy'); + }); + }); + + describe('isReservedName', () => { + it('should return true for reserved names', () => { + expect(isReservedName('gemini')).toBe(true); + expect(isReservedName('codex')).toBe(true); + expect(isReservedName('default')).toBe(true); + }); + + it('should be case-insensitive', () => { + expect(isReservedName('GEMINI')).toBe(true); + expect(isReservedName('Codex')).toBe(true); + expect(isReservedName('DEFAULT')).toBe(true); + }); + + it('should return false for non-reserved names', () => { + expect(isReservedName('myprofile')).toBe(false); + expect(isReservedName('work')).toBe(false); + expect(isReservedName('personal')).toBe(false); + }); + }); + + describe('validateProfileName', () => { + it('should throw for reserved names', () => { + expect(() => validateProfileName('gemini')).toThrow(/reserved/i); + expect(() => validateProfileName('default')).toThrow(/reserved/i); + }); + + it('should not throw for valid names', () => { + expect(() => validateProfileName('myprofile')).not.toThrow(); + expect(() => validateProfileName('work')).not.toThrow(); + }); + }); +}); + +describe('unified-config-types', () => { + describe('createEmptyUnifiedConfig', () => { + it('should create config with correct version', () => { + const config = createEmptyUnifiedConfig(); + expect(config.version).toBe(UNIFIED_CONFIG_VERSION); + }); + + it('should have empty accounts and profiles', () => { + const config = createEmptyUnifiedConfig(); + expect(Object.keys(config.accounts)).toHaveLength(0); + expect(Object.keys(config.profiles)).toHaveLength(0); + }); + + it('should have default preferences', () => { + const config = createEmptyUnifiedConfig(); + expect(config.preferences.theme).toBe('system'); + expect(config.preferences.telemetry).toBe(false); + expect(config.preferences.auto_update).toBe(true); + }); + + it('should have CLIProxy providers list', () => { + const config = createEmptyUnifiedConfig(); + expect(config.cliproxy.providers).toContain('gemini'); + expect(config.cliproxy.providers).toContain('codex'); + }); + }); + + describe('createEmptySecretsConfig', () => { + it('should create secrets with version 1', () => { + const secrets = createEmptySecretsConfig(); + expect(secrets.version).toBe(1); + }); + + it('should have empty profiles', () => { + const secrets = createEmptySecretsConfig(); + expect(Object.keys(secrets.profiles)).toHaveLength(0); + }); + }); + + describe('isUnifiedConfig', () => { + it('should return true for valid config', () => { + const config = createEmptyUnifiedConfig(); + expect(isUnifiedConfig(config)).toBe(true); + }); + + it('should return false for null', () => { + expect(isUnifiedConfig(null)).toBe(false); + }); + + it('should return false for wrong version', () => { + const config = { ...createEmptyUnifiedConfig(), version: 1 }; + expect(isUnifiedConfig(config)).toBe(false); + }); + + it('should return false for missing fields', () => { + expect(isUnifiedConfig({ version: 2 })).toBe(false); + expect(isUnifiedConfig({ version: 2, accounts: {} })).toBe(false); + }); + }); + + describe('isSecretsConfig', () => { + it('should return true for valid secrets', () => { + const secrets = createEmptySecretsConfig(); + expect(isSecretsConfig(secrets)).toBe(true); + }); + + it('should return false for null', () => { + expect(isSecretsConfig(null)).toBe(false); + }); + + it('should return false for missing fields', () => { + expect(isSecretsConfig({ version: 1 })).toBe(false); + }); + }); +}); + +describe('secrets-manager', () => { + describe('isSecretKey', () => { + it('should identify token keys as secrets', () => { + expect(isSecretKey('ANTHROPIC_AUTH_TOKEN')).toBe(true); + expect(isSecretKey('ACCESS_TOKEN')).toBe(true); + expect(isSecretKey('REFRESH_TOKEN')).toBe(true); + }); + + it('should identify API keys as secrets', () => { + expect(isSecretKey('API_KEY')).toBe(true); + expect(isSecretKey('OPENAI_API_KEY')).toBe(true); + expect(isSecretKey('APIKEY')).toBe(true); + }); + + it('should identify password keys as secrets', () => { + expect(isSecretKey('PASSWORD')).toBe(true); + expect(isSecretKey('DB_PASSWORD')).toBe(true); + }); + + it('should identify secret/credential keys', () => { + expect(isSecretKey('CLIENT_SECRET')).toBe(true); + expect(isSecretKey('AWS_CREDENTIAL')).toBe(true); + }); + + it('should not identify non-secret keys', () => { + expect(isSecretKey('ANTHROPIC_MODEL')).toBe(false); + expect(isSecretKey('ANTHROPIC_BASE_URL')).toBe(false); + expect(isSecretKey('DEBUG')).toBe(false); + expect(isSecretKey('NODE_ENV')).toBe(false); + }); + + it('should be case-insensitive', () => { + expect(isSecretKey('api_key')).toBe(true); + expect(isSecretKey('Api_Key')).toBe(true); + }); + }); +}); + +describe('feature-flags', () => { + describe('isUnifiedConfigEnabled', () => { + it('should read from CCS_UNIFIED_CONFIG env var', () => { + // This test runs with the current environment + // In CI, CCS_UNIFIED_CONFIG may or may not be set + const result = isUnifiedConfigEnabled(); + expect(typeof result).toBe('boolean'); + }); + }); +}); diff --git a/ui/src/hooks/use-unified-config.ts b/ui/src/hooks/use-unified-config.ts new file mode 100644 index 00000000..0e27ce00 --- /dev/null +++ b/ui/src/hooks/use-unified-config.ts @@ -0,0 +1,127 @@ +/** + * React Query hooks for unified config + * Phase 05: Dashboard Updates + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from '@/lib/api-client'; +import { toast } from 'sonner'; + +/** + * Get current config format and migration status + */ +export function useConfigFormat() { + return useQuery({ + queryKey: ['config-format'], + queryFn: () => api.config.format(), + }); +} + +/** + * Get unified config (only available when format is 'yaml') + */ +export function useUnifiedConfig() { + return useQuery({ + queryKey: ['unified-config'], + queryFn: () => api.config.get(), + enabled: false, // Only fetch when explicitly enabled + }); +} + +/** + * Update unified config + */ +export function useUpdateConfig() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (config: Record) => api.config.update(config), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['unified-config'] }); + queryClient.invalidateQueries({ queryKey: ['config-format'] }); + toast.success('Configuration updated successfully'); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +/** + * Run migration from JSON to YAML format + */ +export function useMigration() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (dryRun: boolean) => api.config.migrate(dryRun), + onSuccess: (result, dryRun) => { + if (dryRun) { + toast.info('Migration preview completed'); + } else if (result.success) { + queryClient.invalidateQueries({ queryKey: ['config-format'] }); + queryClient.invalidateQueries({ queryKey: ['unified-config'] }); + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + toast.success('Migration completed successfully'); + } else { + toast.error(result.error ?? 'Migration failed'); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +/** + * Rollback migration to JSON format + */ +export function useRollback() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (backupPath: string) => api.config.rollback(backupPath), + onSuccess: (result) => { + if (result.success) { + queryClient.invalidateQueries({ queryKey: ['config-format'] }); + queryClient.invalidateQueries({ queryKey: ['unified-config'] }); + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + toast.success('Rollback completed successfully'); + } else { + toast.error('Rollback failed'); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +/** + * Update profile secrets + */ +export function useUpdateSecrets() { + return useMutation({ + mutationFn: ({ profile, secrets }: { profile: string; secrets: Record }) => + api.secrets.update(profile, secrets), + onSuccess: () => { + toast.success('Secrets updated successfully'); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +/** + * Check if profile has secrets (doesn't return values) + */ +export function useSecretsExists(profile: string) { + return useQuery({ + queryKey: ['secrets-exists', profile], + queryFn: () => api.secrets.exists(profile), + enabled: !!profile, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 62d63d3c..4a8932a0 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -90,6 +90,26 @@ export interface Account { last_used?: string | null; } +// Unified config types +export interface ConfigFormat { + format: 'yaml' | 'json' | 'none'; + migrationNeeded: boolean; + backups: string[]; +} + +export interface MigrationResult { + success: boolean; + backupPath?: string; + error?: string; + migratedFiles: string[]; + warnings: string[]; +} + +export interface SecretsExists { + exists: boolean; + keys: string[]; +} + // API export const api = { profiles: { @@ -142,4 +162,29 @@ export const api = { body: JSON.stringify({ name }), }), }, + // Unified config API + config: { + format: () => request('/config/format'), + get: () => request>('/config'), + update: (config: Record) => + request<{ success: boolean }>('/config', { + method: 'PUT', + body: JSON.stringify(config), + }), + migrate: (dryRun = false) => + request(`/config/migrate?dryRun=${dryRun}`, { method: 'POST' }), + rollback: (backupPath: string) => + request<{ success: boolean }>('/config/rollback', { + method: 'POST', + body: JSON.stringify({ backupPath }), + }), + }, + secrets: { + update: (profile: string, secrets: Record) => + request<{ success: boolean }>(`/secrets/${profile}`, { + method: 'PUT', + body: JSON.stringify(secrets), + }), + exists: (profile: string) => request(`/secrets/${profile}/exists`), + }, };