From cec616d530d9cf61a3a45032465b01e9a4037558 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 23 Dec 2025 21:28:38 -0500 Subject: [PATCH 1/3] feat(setup): add first-time setup wizard for config initialization Addresses Issue #142 - remote CLIProxyAPI configuration for Docker/server deployments. Changes: - Add `ccs setup` command with interactive wizard - Support local vs remote CLIProxy mode selection - Guide users through remote proxy config (host, port, auth token) - Auto-detect first-time install and suggest setup wizard - Update help command to include setup Scenarios covered: 1. New users - wizard guides through local/remote/skip choice 2. Remote proxy users - configure host/port/protocol/auth_token 3. Local users - default auto-start behavior 4. Skip CLIProxy - only API profiles or Claude accounts --- src/ccs.ts | 17 ++ src/commands/help-command.ts | 1 + src/commands/setup-command.ts | 355 ++++++++++++++++++++++++++++++++++ 3 files changed, 373 insertions(+) create mode 100644 src/commands/setup-command.ts diff --git a/src/ccs.ts b/src/ccs.ts index f347819f..25dd7171 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -404,6 +404,13 @@ async function main(): Promise { return; } + // Special case: setup command (first-time wizard) + if (firstArg === 'setup' || firstArg === '--setup') { + const { handleSetupCommand } = await import('./commands/setup-command'); + await handleSetupCommand(args.slice(1)); + return; + } + // Special case: copilot command (GitHub Copilot integration) // Only route to command handler for known subcommands, otherwise treat as profile const COPILOT_SUBCOMMANDS = [ @@ -441,6 +448,16 @@ async function main(): Promise { if (recovered) { recovery.showRecoveryHints(); + + // First-time install: offer setup wizard for interactive users + // Skip if headless, CI, or non-TTY environment + const { isFirstTimeInstall } = await import('./commands/setup-command'); + if (process.stdout.isTTY && !process.env['CI'] && isFirstTimeInstall()) { + console.log(''); + console.log(info('First-time install detected. Run `ccs setup` for guided configuration.')); + console.log(' Or use `ccs config` for the web dashboard.'); + console.log(''); + } } // Detect profile diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 6261dc2a..224230ae 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -213,6 +213,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // Diagnostics printSubSection('Diagnostics', [ + ['ccs setup', 'First-time setup wizard'], ['ccs doctor', 'Run health check and diagnostics'], ['ccs cleanup', 'Remove old CLIProxy logs'], ['ccs config', 'Open web configuration dashboard'], diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts new file mode 100644 index 00000000..ec990812 --- /dev/null +++ b/src/commands/setup-command.ts @@ -0,0 +1,355 @@ +/** + * Setup Command Handler + * + * Interactive first-time setup wizard for CCS. + * Guides users through initial configuration including: + * - Local vs Remote CLIProxy mode selection + * - Remote proxy configuration (host, port, auth token) + * - Default profile selection + * + * Usage: ccs setup + * + * Related: Issue #142 - remote CLIProxyAPI configuration + */ + +import * as readline from 'readline'; +import { initUI, header, ok, info, warn } from '../utils/ui'; +import { + loadOrCreateUnifiedConfig, + saveUnifiedConfig, + hasUnifiedConfig, +} from '../config/unified-config-loader'; +import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types'; + +/** + * Create readline interface for interactive prompts + */ +function createReadline(): readline.Interface { + return readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); +} + +/** + * Prompt user for input with optional default value + */ +async function prompt( + rl: readline.Interface, + question: string, + defaultValue?: string +): Promise { + return new Promise((resolve) => { + const displayQuestion = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `; + rl.question(displayQuestion, (answer) => { + resolve(answer.trim() || defaultValue || ''); + }); + }); +} + +/** + * Prompt user for yes/no confirmation + */ +async function confirm( + rl: readline.Interface, + question: string, + defaultYes: boolean = true +): Promise { + const hint = defaultYes ? '[Y/n]' : '[y/N]'; + const answer = await prompt(rl, `${question} ${hint}`); + + if (answer === '') return defaultYes; + return answer.toLowerCase().startsWith('y'); +} + +/** + * Prompt user to select from numbered options + */ +async function selectOption( + rl: readline.Interface, + question: string, + options: { label: string; value: string; description?: string }[] +): Promise { + console.log(''); + console.log(question); + console.log(''); + + options.forEach((opt, idx) => { + const desc = opt.description ? ` - ${opt.description}` : ''; + console.log(` ${idx + 1}) ${opt.label}${desc}`); + }); + + console.log(''); + const answer = await prompt(rl, 'Enter choice (number)', '1'); + const idx = parseInt(answer, 10) - 1; + + if (idx >= 0 && idx < options.length) { + return options[idx].value; + } + + // Invalid selection, default to first + console.log(warn(`Invalid selection, using default: ${options[0].label}`)); + return options[0].value; +} + +/** + * Check if this is a first-time install (no config exists) + */ +export function isFirstTimeInstall(): boolean { + return !hasUnifiedConfig(); +} + +/** + * Configure remote CLIProxy settings interactively + */ +async function configureRemoteProxy(rl: readline.Interface): Promise<{ + host: string; + port?: number; + protocol: 'http' | 'https'; + authToken: string; +}> { + console.log(''); + console.log(info('Configure Remote CLIProxyAPI Connection')); + console.log(''); + console.log(' Enter the details for your remote CLIProxyAPI server.'); + console.log(' Example: your-server.example.com'); + console.log(''); + + // Host + const host = await prompt(rl, 'Remote host (hostname or IP)'); + if (!host) { + throw new Error('Host is required for remote proxy mode'); + } + + // Protocol + const protocol = (await selectOption(rl, 'Protocol:', [ + { label: 'HTTPS', value: 'https', description: 'Secure connection (recommended)' }, + { label: 'HTTP', value: 'http', description: 'Unencrypted connection' }, + ])) as 'http' | 'https'; + + // Port (optional) + const defaultPort = protocol === 'https' ? '443' : '80'; + const portStr = await prompt(rl, `Port (leave empty for default ${defaultPort})`); + const port = portStr ? parseInt(portStr, 10) : undefined; + + // Auth token + console.log(''); + console.log(info('Authentication')); + console.log(' The auth token is configured in your CLIProxyAPI config.yaml'); + console.log(' under api-keys section. Example: "ccs-internal-managed"'); + console.log(''); + + const authToken = await prompt(rl, 'Auth token', 'ccs-internal-managed'); + + return { host, port, protocol, authToken }; +} + +/** + * Main setup wizard + */ +async function runSetupWizard(force: boolean = false): Promise { + const rl = createReadline(); + + try { + console.log(''); + console.log(header('CCS First-Time Setup')); + console.log(''); + + // Check if already configured + if (!force && !isFirstTimeInstall()) { + console.log(info('CCS is already configured.')); + console.log(' Use --force to reconfigure, or run `ccs config` for the dashboard.'); + console.log(''); + rl.close(); + return; + } + + console.log('Welcome to CCS (Claude Code Switch)!'); + console.log('This wizard will help you configure CCS for first-time use.'); + console.log(''); + + // Step 1: Local vs Remote mode + const proxyMode = await selectOption( + rl, + 'How do you want to use CLIProxy providers (gemini, codex, agy)?', + [ + { + label: 'Local (Recommended)', + value: 'local', + description: 'CCS auto-starts CLIProxyAPI binary on your machine', + }, + { + label: 'Remote Server', + value: 'remote', + description: 'Connect to a remote CLIProxyAPI instance (Issue #142)', + }, + { + label: 'Skip CLIProxy', + value: 'skip', + description: 'Only use API profiles (GLM, Kimi) or Claude accounts', + }, + ] + ); + + // Load or create config + const config = loadOrCreateUnifiedConfig(); + + if (proxyMode === 'remote') { + // Configure remote proxy + const remoteConfig = await configureRemoteProxy(rl); + + config.cliproxy_server = { + remote: { + enabled: true, + host: remoteConfig.host, + port: remoteConfig.port, + protocol: remoteConfig.protocol, + auth_token: remoteConfig.authToken, + }, + fallback: { + enabled: true, + auto_start: false, + }, + local: { + port: 8317, + auto_start: false, // Disable local auto-start when using remote + }, + }; + + console.log(''); + console.log(ok('Remote proxy configured successfully!')); + console.log(''); + console.log( + ` URL: ${remoteConfig.protocol}://${remoteConfig.host}${remoteConfig.port ? `:${remoteConfig.port}` : ''}` + ); + console.log(` Auth: ${remoteConfig.authToken ? '[configured]' : '[none]'}`); + } else if (proxyMode === 'local') { + // Ensure local mode is configured + config.cliproxy_server = { + ...DEFAULT_CLIPROXY_SERVER_CONFIG, + remote: { + enabled: false, + host: '', + protocol: 'http', + auth_token: '', + }, + local: { + port: 8317, + auto_start: true, + }, + }; + + console.log(''); + console.log(ok('Local proxy mode configured!')); + console.log(' CLIProxyAPI will auto-start when you use gemini/codex/agy profiles.'); + } else { + // Skip CLIProxy - just use local config + console.log(''); + console.log(ok('CLIProxy skipped.')); + console.log(' You can still use API profiles (GLM, Kimi) or Claude accounts.'); + } + + // Step 2: Ask about API profiles + console.log(''); + const wantsApiProfile = await confirm( + rl, + 'Do you want to set up an API profile (GLM, Kimi, custom)?', + false + ); + + if (wantsApiProfile) { + console.log(''); + console.log(info('Creating API profiles...')); + console.log(' Use the following commands to create profiles:'); + console.log(''); + console.log(' ccs api create glm --preset glm'); + console.log(' ccs api create kimi --preset kimi'); + console.log(' ccs api create custom --prompt'); + console.log(''); + console.log(' After creating, edit the settings file to add your API key.'); + } + + // Save config + saveUnifiedConfig(config); + + // Final summary + console.log(''); + console.log(header('Setup Complete!')); + console.log(''); + console.log('Quick start commands:'); + console.log(''); + + if (proxyMode !== 'skip') { + console.log(' ccs gemini # Use Gemini via CLIProxy (OAuth)'); + console.log(' ccs codex # Use Codex via CLIProxy (OAuth)'); + console.log(' ccs agy # Use Antigravity via CLIProxy (OAuth)'); + } + + console.log(' ccs # Use default Claude CLI'); + console.log(' ccs config # Open web dashboard'); + console.log(' ccs doctor # Check configuration health'); + console.log(''); + + if (proxyMode === 'remote') { + console.log(info('Remote proxy tip:')); + console.log(' If connection fails, CCS will offer to start local proxy as fallback.'); + console.log(' Edit ~/.ccs/config.yaml to adjust remote settings.'); + console.log(''); + } + + console.log(info('Configuration saved to: ~/.ccs/config.yaml')); + console.log(''); + } finally { + rl.close(); + } +} + +/** + * Parse command line arguments + */ +function parseArgs(args: string[]): { force: boolean; help: boolean } { + return { + force: args.includes('--force') || args.includes('-f'), + help: args.includes('--help') || args.includes('-h'), + }; +} + +/** + * Show help message + */ +function showHelp(): void { + console.log(''); + console.log('Usage: ccs setup [options]'); + console.log(''); + console.log('Interactive first-time setup wizard for CCS.'); + console.log(''); + console.log('Options:'); + console.log(' --force, -f Force setup even if already configured'); + console.log(' --help, -h Show this help message'); + console.log(''); + console.log('This wizard helps you configure:'); + console.log(' - Local vs Remote CLIProxy mode'); + console.log(' - Remote proxy connection (host, port, auth token)'); + console.log(' - API profile creation'); + console.log(''); + console.log('Examples:'); + console.log(' ccs setup Run setup wizard'); + console.log(' ccs setup --force Force reconfiguration'); + console.log(''); +} + +/** + * Handle setup command + */ +export async function handleSetupCommand(args: string[]): Promise { + await initUI(); + + const options = parseArgs(args); + + if (options.help) { + showHelp(); + return; + } + + await runSetupWizard(options.force); +} From b34469d75fd2c2b7fd4f4cc4c0cc28885001649b Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 23 Dec 2025 21:54:55 -0500 Subject: [PATCH 2/3] refactor(config): migrate to config.yaml as primary format - Update postinstall.js to create config.yaml instead of config.json - Update recovery-manager to create config.yaml as primary config - Fix isFirstTimeInstall() to check for meaningful config content (profiles, accounts, variants, oauth_accounts, remote proxy) - Update validation to accept config.yaml OR config.json - Preserve backward compatibility: legacy config.json is migrated to config.yaml on first run via autoMigrate() - Update postinstall tests to verify config.yaml creation Fixes #142 - remote CLIProxyAPI configuration --- scripts/postinstall.js | 138 +++++++++++++++-------------- src/commands/setup-command.ts | 25 +++++- src/management/recovery-manager.ts | 60 +++++++------ tests/npm/postinstall.test.js | 30 +++++-- 4 files changed, 147 insertions(+), 106 deletions(-) diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 60251501..0da1a18b 100755 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -68,24 +68,13 @@ function validateConfiguration() { errors.push('~/.ccs/ directory not found'); } - // Check required files (GLM/GLMT/Kimi are now optional - created via presets) - const requiredFiles = [ - { path: path.join(ccsDir, 'config.json'), name: 'config.json' } - ]; + // Check for config file - prefer config.yaml, fallback to config.json + const configYaml = path.join(ccsDir, 'config.yaml'); + const configJson = path.join(ccsDir, 'config.json'); + const hasConfig = fs.existsSync(configYaml) || fs.existsSync(configJson); - for (const file of requiredFiles) { - if (!fs.existsSync(file.path)) { - errors.push(`${file.name} not found`); - continue; - } - - // Validate JSON syntax - try { - const content = fs.readFileSync(file.path, 'utf8'); - JSON.parse(content); - } catch (e) { - errors.push(`${file.name} has invalid JSON: ${e.message}`); - } + if (!hasConfig) { + errors.push('config.yaml (or config.json) not found'); } // Check ~/.claude/settings.json (warning only, not critical) @@ -150,62 +139,77 @@ function createConfigFiles() { // Users can run "ccs sync" to install CCS commands/skills to ~/.claude/ // This gives users control over when to modify their Claude configuration - // Create config.json if missing + // Create config.yaml if missing (primary format) // NOTE: gemini/codex profiles NOT included - they are added on-demand when user // runs `ccs gemini` or `ccs codex` for first time (requires OAuth auth first) // NOTE: GLM/GLMT/Kimi profiles are now created via UI/CLI presets, not auto-created - const configPath = path.join(ccsDir, 'config.json'); - if (!fs.existsSync(configPath)) { - // NOTE: No 'default' entry - when no profile specified, CCS passes through - // to Claude's native auth without --settings flag. This prevents env var - // pollution from affecting the default profile. - // Profiles are empty by default - users create via `ccs api create --preset` or UI - const config = { - profiles: {} - }; + const configYamlPath = path.join(ccsDir, 'config.yaml'); + const legacyConfigPath = path.join(ccsDir, 'config.json'); - // Atomic write: temp file → rename - const tmpPath = `${configPath}.tmp`; - fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); - fs.renameSync(tmpPath, configPath); - - console.log('[OK] Created config: ~/.ccs/config.json'); - } else { - // Update existing config (migration for older versions) - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - // Ensure profiles object exists - if (!config.profiles) { - config.profiles = {}; - } - let configUpdated = false; - - // Migration: Add glmt if missing (v3.x) - if (!config.profiles.glmt) { - config.profiles.glmt = '~/.ccs/glmt.settings.json'; - configUpdated = true; - } - - // Migration: Remove 'default' entry pointing to ~/.claude/settings.json (v5.4.0) - // This entry caused the default profile to pass --settings flag, which could - // pick up stale env vars (ANTHROPIC_BASE_URL) from previous profile sessions. - // Fix: Let CCS pass through to Claude's native auth without --settings flag. - if (config.profiles.default === '~/.claude/settings.json') { - delete config.profiles.default; - configUpdated = true; - console.log('[OK] Removed legacy default profile (now uses native Claude auth)'); - } - - // NOTE: gemini/codex profiles added on-demand, not during migration - if (configUpdated) { - const tmpPath = `${configPath}.tmp`; - fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); - fs.renameSync(tmpPath, configPath); - if (!config.profiles.glmt) { - console.log('[OK] Updated config with glmt profile'); - } + if (!fs.existsSync(configYamlPath)) { + // Check for legacy config.json - autoMigrate() in ccs.ts will handle migration + if (fs.existsSync(legacyConfigPath)) { + console.log('[OK] Legacy config.json found - will migrate to config.yaml on first run'); } else { - console.log('[OK] Config exists: ~/.ccs/config.json (preserved)'); + // Try to use unified config loader if dist is available + try { + const { saveUnifiedConfig } = require('../dist/config/unified-config-loader'); + const { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION } = require('../dist/config/unified-config-types'); + + const config = createEmptyUnifiedConfig(); + config.version = UNIFIED_CONFIG_VERSION; + saveUnifiedConfig(config); + + console.log('[OK] Created config: ~/.ccs/config.yaml'); + } catch (loaderErr) { + // Dist not built yet (fresh clone) - create minimal config.yaml manually + const yaml = require('js-yaml'); + const config = { + version: '2.0', + profiles: {}, + accounts: {}, + cliproxy: { + variants: {}, + oauth_accounts: {} + }, + cliproxy_server: { + local: { + port: 8317, + auto_start: true + } + } + }; + + try { + const yamlContent = yaml.dump(config, { + indent: 2, + lineWidth: -1, + noRefs: true, + sortKeys: false + }); + const tmpPath = `${configYamlPath}.tmp`; + fs.writeFileSync(tmpPath, yamlContent, 'utf8'); + fs.renameSync(tmpPath, configYamlPath); + console.log('[OK] Created config: ~/.ccs/config.yaml'); + } catch (yamlErr) { + // Final fallback: create legacy config.json + console.warn('[!] YAML write failed, creating legacy config.json'); + const fallbackConfig = { profiles: {} }; + const tmpPath = `${legacyConfigPath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8'); + fs.renameSync(tmpPath, legacyConfigPath); + console.log('[OK] Created config: ~/.ccs/config.json (fallback)'); + } + } } + } else { + console.log('[OK] Config exists: ~/.ccs/config.yaml (preserved)'); + } + + // Handle legacy config.json migrations (for users upgrading) + if (fs.existsSync(legacyConfigPath) && !fs.existsSync(configYamlPath)) { + // Migration will happen via autoMigrate() in ccs.ts on first run + console.log('[i] Legacy config.json will be migrated to config.yaml on first run'); } // NOTE: GLM, GLMT, and Kimi profiles are NO LONGER auto-created during install diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index ec990812..ff867eaf 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -93,10 +93,31 @@ async function selectOption( } /** - * Check if this is a first-time install (no config exists) + * Check if this is a first-time install (config exists but is empty/unconfigured) + * Returns true if user should be prompted to run setup wizard */ export function isFirstTimeInstall(): boolean { - return !hasUnifiedConfig(); + // No config at all → definitely first time + if (!hasUnifiedConfig()) { + return true; + } + + // Config exists - check if it's meaningfully configured + const config = loadOrCreateUnifiedConfig(); + + // Check for any meaningful configuration + const hasProfiles = Object.keys(config.profiles || {}).length > 0; + const hasAccounts = Object.keys(config.accounts || {}).length > 0; + const hasVariants = Object.keys(config.cliproxy?.variants || {}).length > 0; + const hasOAuthAccounts = Object.keys(config.cliproxy?.oauth_accounts || {}).length > 0; + const hasRemoteProxy = + config.cliproxy_server?.remote?.enabled && config.cliproxy_server?.remote?.host; + + // If any of these exist, user has configured something + const isConfigured = + hasProfiles || hasAccounts || hasVariants || hasOAuthAccounts || hasRemoteProxy; + + return !isConfigured; } /** diff --git a/src/management/recovery-manager.ts b/src/management/recovery-manager.ts index 9c43dd1e..b93621d3 100644 --- a/src/management/recovery-manager.ts +++ b/src/management/recovery-manager.ts @@ -8,6 +8,8 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { info } from '../utils/ui'; +import { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION } from '../config/unified-config-types'; +import { saveUnifiedConfig, hasUnifiedConfig } from '../config/unified-config-loader'; /** * Get CCS home directory (respects CCS_HOME env for test isolation) @@ -49,37 +51,39 @@ class RecoveryManager { } /** - * Ensure ~/.ccs/config.json exists with defaults + * Ensure ~/.ccs/config.yaml exists with defaults + * This is the primary config format (YAML unified config) */ - ensureConfigJson(): boolean { - const configPath = path.join(this.ccsDir, 'config.json'); - - // Check if exists and valid - if (fs.existsSync(configPath)) { - try { - const content = fs.readFileSync(configPath, 'utf8'); - JSON.parse(content); // Validate JSON - return false; // No recovery needed - } catch (_e) { - // Corrupted - backup and recreate - const backupPath = `${configPath}.backup.${Date.now()}`; - fs.renameSync(configPath, backupPath); - this.recovered.push(`Backed up corrupted config.json to ${path.basename(backupPath)}`); - } + ensureConfigYaml(): boolean { + // Skip if config.yaml already exists + if (hasUnifiedConfig()) { + return false; } - // Create default config (matches postinstall.js) - // NOTE: Empty profiles - users create profiles via `ccs api create` or UI - const defaultConfig = { - profiles: {}, - }; + // Check for legacy config.json - if exists, let autoMigrate handle it + const legacyConfigPath = path.join(this.ccsDir, 'config.json'); + if (fs.existsSync(legacyConfigPath)) { + // Legacy config exists - autoMigrate() in ccs.ts will handle migration + return false; + } - const tmpPath = `${configPath}.tmp`; - fs.writeFileSync(tmpPath, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf8'); - fs.renameSync(tmpPath, configPath); + // Create fresh config.yaml with defaults + const config = createEmptyUnifiedConfig(); + config.version = UNIFIED_CONFIG_VERSION; - this.recovered.push('Created ~/.ccs/config.json'); - return true; + try { + saveUnifiedConfig(config); + this.recovered.push('Created ~/.ccs/config.yaml'); + return true; + } catch (_e) { + // Fallback: create minimal config.json for backward compat + const fallbackConfig = { profiles: {} }; + const tmpPath = `${legacyConfigPath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8'); + fs.renameSync(tmpPath, legacyConfigPath); + this.recovered.push('Created ~/.ccs/config.json (fallback)'); + return true; + } } /** @@ -281,8 +285,8 @@ class RecoveryManager { this.ensureSharedDirectories(); this.ensureClaudeSettings(); - // Config files (core only - no GLM/GLMT/Kimi auto-creation) - this.ensureConfigJson(); + // Config files - use YAML as primary format + this.ensureConfigYaml(); // Shell completions this.ensureShellCompletions(); diff --git a/tests/npm/postinstall.test.js b/tests/npm/postinstall.test.js index 07572362..136df681 100644 --- a/tests/npm/postinstall.test.js +++ b/tests/npm/postinstall.test.js @@ -19,19 +19,25 @@ describe('npm postinstall', () => { } }); - it('creates config.json', () => { + it('creates config.yaml (primary format)', () => { execSync(`node "${postinstallScript}"`, { stdio: 'ignore', env: { ...process.env, CCS_HOME: testEnv.testHome } }); - assert(testEnv.fileExists('config.json'), 'config.json should be created'); + // config.yaml is now the primary format (v6.x+) + assert(testEnv.fileExists('config.yaml'), 'config.yaml should be created'); - const config = testEnv.readFile('config.json', true); - assert(config.profiles, 'config.json should have profiles'); + // Read YAML config and verify structure + const yaml = require('js-yaml'); + const configContent = testEnv.readFile('config.yaml', false); + const config = yaml.load(configContent); + + assert(config.profiles !== undefined, 'config.yaml should have profiles'); assert(typeof config.profiles === 'object', 'profiles should be an object'); // Profiles are now empty by default - users create via presets assert.deepStrictEqual(config.profiles, {}, 'profiles should be empty by default'); + assert(config.version, 'config.yaml should have version'); }); it('does NOT auto-create glm.settings.json (v6.0 - use presets instead)', () => { @@ -49,24 +55,30 @@ describe('npm postinstall', () => { it('is idempotent', () => { const env = { ...process.env, CCS_HOME: testEnv.testHome }; + const yaml = require('js-yaml'); // Run postinstall first time execSync(`node "${postinstallScript}"`, { stdio: 'ignore', env }); - // Create custom config + // Create custom config.yaml to test preservation const customConfig = { + version: '2.0', profiles: { custom: '~/.custom.json', glm: '~/.ccs/glm.settings.json' - } + }, + accounts: {}, + cliproxy: { variants: {}, oauth_accounts: {} } }; - testEnv.createFile('config.json', customConfig); + const yamlContent = yaml.dump(customConfig, { indent: 2 }); + testEnv.createFile('config.yaml', yamlContent); // Run postinstall again execSync(`node "${postinstallScript}"`, { stdio: 'ignore', env }); // Verify custom config preserved - const config = testEnv.readFile('config.json', true); + const configContent = testEnv.readFile('config.yaml', false); + const config = yaml.load(configContent); assert(config.profiles.custom, 'Custom profile should be preserved'); assert.strictEqual(config.profiles.custom, '~/.custom.json'); }); @@ -97,7 +109,7 @@ describe('npm postinstall', () => { // Verify existing file still exists and new files are created assert(testEnv.fileExists('existing.txt'), 'Existing files should be preserved'); - assert(testEnv.fileExists('config.json'), 'config.json should be created'); + assert(testEnv.fileExists('config.yaml'), 'config.yaml should be created'); // GLM/GLMT/Kimi are no longer auto-created assert(!testEnv.fileExists('glm.settings.json'), 'glm.settings.json should NOT be auto-created'); }); From ca78993e7612143b3193e3cec3f8976be909e2d6 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 23 Dec 2025 22:34:32 -0500 Subject: [PATCH 3/3] fix(config): improve edge case handling for config initialization - setup-command.ts: Add Ctrl+C handling (UserCancelledError), port validation (1-65535), protocol stripping from host, try-catch with user-friendly error messages - postinstall.js: Add ~/.ccs file check (not directory), wrap js-yaml require in try-catch with JSON fallback, validate config.json before migration, warn when both config files exist - recovery-manager.ts: Verify config is loadable (not just exists), add nested error handling for fallback write - ccs.ts: Make first-time install hint independent of recovery status (shows even when user manually created empty config.yaml) All edge cases identified by code review addressed. --- scripts/postinstall.js | 112 +++++++++++++++++++---------- src/ccs.ts | 19 ++--- src/commands/setup-command.ts | 69 ++++++++++++++++-- src/management/recovery-manager.ts | 36 +++++++--- 4 files changed, 173 insertions(+), 63 deletions(-) diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 0da1a18b..46c6bbf0 100755 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -93,7 +93,15 @@ function createConfigFiles() { const ccsDir = path.join(homedir, '.ccs'); // Create ~/.ccs/ directory if missing - if (!fs.existsSync(ccsDir)) { + if (fs.existsSync(ccsDir)) { + // Check if it's a file instead of directory (edge case) + const stats = fs.statSync(ccsDir); + if (!stats.isDirectory()) { + console.error('[X] ~/.ccs exists but is not a directory'); + console.error(' Remove or rename it: mv ~/.ccs ~/.ccs.bak'); + process.exit(1); + } + } else { fs.mkdirSync(ccsDir, { recursive: true, mode: 0o755 }); console.log('[OK] Created directory: ~/.ccs/'); } @@ -149,8 +157,22 @@ function createConfigFiles() { if (!fs.existsSync(configYamlPath)) { // Check for legacy config.json - autoMigrate() in ccs.ts will handle migration if (fs.existsSync(legacyConfigPath)) { - console.log('[OK] Legacy config.json found - will migrate to config.yaml on first run'); - } else { + // Validate legacy config.json before assuming migration will work + try { + const content = fs.readFileSync(legacyConfigPath, 'utf8'); + JSON.parse(content); + console.log('[OK] Legacy config.json found - will migrate to config.yaml on first run'); + } catch { + console.warn('[!] Legacy config.json is corrupted/invalid'); + console.warn(' Backup: mv ~/.ccs/config.json ~/.ccs/config.json.bak'); + console.warn(' Creating fresh config.yaml instead'); + // Fall through to create new config.yaml + fs.renameSync(legacyConfigPath, `${legacyConfigPath}.bak`); + } + } + + // Create config.yaml if it doesn't exist (and legacy wasn't valid) + if (!fs.existsSync(configYamlPath) && !fs.existsSync(legacyConfigPath)) { // Try to use unified config loader if dist is available try { const { saveUnifiedConfig } = require('../dist/config/unified-config-loader'); @@ -163,42 +185,58 @@ function createConfigFiles() { console.log('[OK] Created config: ~/.ccs/config.yaml'); } catch (loaderErr) { // Dist not built yet (fresh clone) - create minimal config.yaml manually - const yaml = require('js-yaml'); - const config = { - version: '2.0', - profiles: {}, - accounts: {}, - cliproxy: { - variants: {}, - oauth_accounts: {} - }, - cliproxy_server: { - local: { - port: 8317, - auto_start: true - } - } - }; - + // Wrap js-yaml require in try-catch in case it's not available + let yaml; try { - const yamlContent = yaml.dump(config, { - indent: 2, - lineWidth: -1, - noRefs: true, - sortKeys: false - }); - const tmpPath = `${configYamlPath}.tmp`; - fs.writeFileSync(tmpPath, yamlContent, 'utf8'); - fs.renameSync(tmpPath, configYamlPath); - console.log('[OK] Created config: ~/.ccs/config.yaml'); - } catch (yamlErr) { - // Final fallback: create legacy config.json - console.warn('[!] YAML write failed, creating legacy config.json'); + yaml = require('js-yaml'); + } catch { + // js-yaml not available - fallback to JSON + console.warn('[!] js-yaml not available, creating legacy config.json'); const fallbackConfig = { profiles: {} }; const tmpPath = `${legacyConfigPath}.tmp`; fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8'); fs.renameSync(tmpPath, legacyConfigPath); console.log('[OK] Created config: ~/.ccs/config.json (fallback)'); + yaml = null; + } + + if (yaml) { + const config = { + version: '2.0', + profiles: {}, + accounts: {}, + cliproxy: { + variants: {}, + oauth_accounts: {} + }, + cliproxy_server: { + local: { + port: 8317, + auto_start: true + } + } + }; + + try { + const yamlContent = yaml.dump(config, { + indent: 2, + lineWidth: -1, + noRefs: true, + sortKeys: false + }); + const tmpPath = `${configYamlPath}.tmp`; + fs.writeFileSync(tmpPath, yamlContent, 'utf8'); + fs.renameSync(tmpPath, configYamlPath); + console.log('[OK] Created config: ~/.ccs/config.yaml'); + } catch (yamlErr) { + // Final fallback: create legacy config.json + console.warn('[!] YAML write failed, creating legacy config.json'); + const fallbackConfig = { profiles: {} }; + const tmpPath = `${legacyConfigPath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8'); + fs.renameSync(tmpPath, legacyConfigPath); + console.log('[OK] Created config: ~/.ccs/config.json (fallback)'); + } } } } @@ -206,10 +244,10 @@ function createConfigFiles() { console.log('[OK] Config exists: ~/.ccs/config.yaml (preserved)'); } - // Handle legacy config.json migrations (for users upgrading) - if (fs.existsSync(legacyConfigPath) && !fs.existsSync(configYamlPath)) { - // Migration will happen via autoMigrate() in ccs.ts on first run - console.log('[i] Legacy config.json will be migrated to config.yaml on first run'); + // Warn if both config files exist (user may want to clean up) + if (fs.existsSync(legacyConfigPath) && fs.existsSync(configYamlPath)) { + console.log('[!] Both config.yaml and config.json exist'); + console.log(' config.json will be ignored - consider removing it'); } // NOTE: GLM, GLMT, and Kimi profiles are NO LONGER auto-created during install diff --git a/src/ccs.ts b/src/ccs.ts index 25dd7171..ced3fc28 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -448,16 +448,17 @@ async function main(): Promise { if (recovered) { recovery.showRecoveryHints(); + } - // First-time install: offer setup wizard for interactive users - // Skip if headless, CI, or non-TTY environment - const { isFirstTimeInstall } = await import('./commands/setup-command'); - if (process.stdout.isTTY && !process.env['CI'] && isFirstTimeInstall()) { - console.log(''); - console.log(info('First-time install detected. Run `ccs setup` for guided configuration.')); - console.log(' Or use `ccs config` for the web dashboard.'); - console.log(''); - } + // First-time install: offer setup wizard for interactive users + // Check independently of recovery status (user may have empty config.yaml) + // Skip if headless, CI, or non-TTY environment + const { isFirstTimeInstall } = await import('./commands/setup-command'); + if (process.stdout.isTTY && !process.env['CI'] && isFirstTimeInstall()) { + console.log(''); + console.log(info('First-time install detected. Run `ccs setup` for guided configuration.')); + console.log(' Or use `ccs config` for the web dashboard.'); + console.log(''); } // Detect profile diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index ff867eaf..dc0c5801 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -16,11 +16,20 @@ import * as readline from 'readline'; import { initUI, header, ok, info, warn } from '../utils/ui'; import { loadOrCreateUnifiedConfig, + loadUnifiedConfig, saveUnifiedConfig, hasUnifiedConfig, } from '../config/unified-config-loader'; import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types'; +/** Custom error for user cancellation (Ctrl+C) */ +class UserCancelledError extends Error { + constructor() { + super('Setup cancelled by user'); + this.name = 'UserCancelledError'; + } +} + /** * Create readline interface for interactive prompts */ @@ -33,15 +42,23 @@ function createReadline(): readline.Interface { /** * Prompt user for input with optional default value + * Handles Ctrl+C gracefully by rejecting with UserCancelledError */ async function prompt( rl: readline.Interface, question: string, defaultValue?: string ): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const displayQuestion = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `; + + const onClose = () => { + reject(new UserCancelledError()); + }; + + rl.once('close', onClose); rl.question(displayQuestion, (answer) => { + rl.removeListener('close', onClose); resolve(answer.trim() || defaultValue || ''); }); }); @@ -102,8 +119,18 @@ export function isFirstTimeInstall(): boolean { return true; } - // Config exists - check if it's meaningfully configured - const config = loadOrCreateUnifiedConfig(); + // Try loading config directly to detect corruption + const loaded = loadUnifiedConfig(); + if (loaded === null) { + // Config exists but is corrupted/invalid - don't treat as first-time + // User should fix or delete the file, or use --force + console.log(warn('Warning: ~/.ccs/config.yaml exists but appears corrupted')); + console.log(info(' Run `ccs setup --force` to reset, or `ccs doctor` to diagnose')); + return false; + } + + // Config exists and is valid - check if it's meaningfully configured + const config = loaded; // Check for any meaningful configuration const hasProfiles = Object.keys(config.profiles || {}).length > 0; @@ -136,11 +163,15 @@ async function configureRemoteProxy(rl: readline.Interface): Promise<{ console.log(' Example: your-server.example.com'); console.log(''); - // Host - const host = await prompt(rl, 'Remote host (hostname or IP)'); + // Host - with protocol stripping + let host = await prompt(rl, 'Remote host (hostname or IP)'); if (!host) { throw new Error('Host is required for remote proxy mode'); } + // Strip protocol if user included it (common mistake) + host = host.replace(/^https?:\/\//, ''); + // Strip trailing slashes + host = host.replace(/\/+$/, ''); // Protocol const protocol = (await selectOption(rl, 'Protocol:', [ @@ -148,10 +179,19 @@ async function configureRemoteProxy(rl: readline.Interface): Promise<{ { label: 'HTTP', value: 'http', description: 'Unencrypted connection' }, ])) as 'http' | 'https'; - // Port (optional) + // Port (optional) - with validation const defaultPort = protocol === 'https' ? '443' : '80'; const portStr = await prompt(rl, `Port (leave empty for default ${defaultPort})`); - const port = portStr ? parseInt(portStr, 10) : undefined; + let port: number | undefined; + if (portStr) { + const parsed = parseInt(portStr, 10); + if (isNaN(parsed) || parsed < 1 || parsed > 65535 || !Number.isInteger(parsed)) { + console.log(warn(`Invalid port "${portStr}", using default: ${defaultPort}`)); + port = undefined; // Use default + } else { + port = parsed; + } + } // Auth token console.log(''); @@ -320,6 +360,21 @@ async function runSetupWizard(force: boolean = false): Promise { console.log(info('Configuration saved to: ~/.ccs/config.yaml')); console.log(''); + } catch (err) { + // Handle user cancellation gracefully + if (err instanceof UserCancelledError) { + console.log(''); + console.log(info('Setup cancelled.')); + console.log(' Run `ccs setup` when ready to configure.'); + console.log(''); + return; + } + // Handle other errors with user-friendly message + const message = err instanceof Error ? err.message : String(err); + console.log(''); + console.log(warn(`Setup failed: ${message}`)); + console.log(info(' Run `ccs setup` to try again.')); + console.log(''); } finally { rl.close(); } diff --git a/src/management/recovery-manager.ts b/src/management/recovery-manager.ts index b93621d3..ec2169a2 100644 --- a/src/management/recovery-manager.ts +++ b/src/management/recovery-manager.ts @@ -9,7 +9,11 @@ import * as path from 'path'; import * as os from 'os'; import { info } from '../utils/ui'; import { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION } from '../config/unified-config-types'; -import { saveUnifiedConfig, hasUnifiedConfig } from '../config/unified-config-loader'; +import { + saveUnifiedConfig, + hasUnifiedConfig, + loadUnifiedConfig, +} from '../config/unified-config-loader'; /** * Get CCS home directory (respects CCS_HOME env for test isolation) @@ -55,9 +59,15 @@ class RecoveryManager { * This is the primary config format (YAML unified config) */ ensureConfigYaml(): boolean { - // Skip if config.yaml already exists + // Skip if config.yaml already exists AND is valid if (hasUnifiedConfig()) { - return false; + // Verify it's loadable (not corrupted) + const loaded = loadUnifiedConfig(); + if (loaded !== null) { + return false; // Config exists and is valid + } + // Config exists but is corrupted - will be recreated below + this.recovered.push('Detected corrupted ~/.ccs/config.yaml'); } // Check for legacy config.json - if exists, let autoMigrate handle it @@ -75,14 +85,20 @@ class RecoveryManager { saveUnifiedConfig(config); this.recovered.push('Created ~/.ccs/config.yaml'); return true; - } catch (_e) { + } catch (_saveErr) { // Fallback: create minimal config.json for backward compat - const fallbackConfig = { profiles: {} }; - const tmpPath = `${legacyConfigPath}.tmp`; - fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8'); - fs.renameSync(tmpPath, legacyConfigPath); - this.recovered.push('Created ~/.ccs/config.json (fallback)'); - return true; + try { + const fallbackConfig = { profiles: {} }; + const tmpPath = `${legacyConfigPath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8'); + fs.renameSync(tmpPath, legacyConfigPath); + this.recovered.push('Created ~/.ccs/config.json (fallback)'); + return true; + } catch (_fallbackErr) { + // Both writes failed - log but don't crash + this.recovered.push('Failed to create config file (permission issue?)'); + return false; + } } }