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.
This commit is contained in:
kaitranntt
2025-12-23 22:34:32 -05:00
parent b34469d75f
commit ca78993e76
4 changed files with 173 additions and 63 deletions
+75 -37
View File
@@ -93,7 +93,15 @@ function createConfigFiles() {
const ccsDir = path.join(homedir, '.ccs'); const ccsDir = path.join(homedir, '.ccs');
// Create ~/.ccs/ directory if missing // 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 }); fs.mkdirSync(ccsDir, { recursive: true, mode: 0o755 });
console.log('[OK] Created directory: ~/.ccs/'); console.log('[OK] Created directory: ~/.ccs/');
} }
@@ -149,8 +157,22 @@ function createConfigFiles() {
if (!fs.existsSync(configYamlPath)) { if (!fs.existsSync(configYamlPath)) {
// Check for legacy config.json - autoMigrate() in ccs.ts will handle migration // Check for legacy config.json - autoMigrate() in ccs.ts will handle migration
if (fs.existsSync(legacyConfigPath)) { if (fs.existsSync(legacyConfigPath)) {
console.log('[OK] Legacy config.json found - will migrate to config.yaml on first run'); // Validate legacy config.json before assuming migration will work
} else { 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 to use unified config loader if dist is available
try { try {
const { saveUnifiedConfig } = require('../dist/config/unified-config-loader'); const { saveUnifiedConfig } = require('../dist/config/unified-config-loader');
@@ -163,42 +185,58 @@ function createConfigFiles() {
console.log('[OK] Created config: ~/.ccs/config.yaml'); console.log('[OK] Created config: ~/.ccs/config.yaml');
} catch (loaderErr) { } catch (loaderErr) {
// Dist not built yet (fresh clone) - create minimal config.yaml manually // Dist not built yet (fresh clone) - create minimal config.yaml manually
const yaml = require('js-yaml'); // Wrap js-yaml require in try-catch in case it's not available
const config = { let yaml;
version: '2.0',
profiles: {},
accounts: {},
cliproxy: {
variants: {},
oauth_accounts: {}
},
cliproxy_server: {
local: {
port: 8317,
auto_start: true
}
}
};
try { try {
const yamlContent = yaml.dump(config, { yaml = require('js-yaml');
indent: 2, } catch {
lineWidth: -1, // js-yaml not available - fallback to JSON
noRefs: true, console.warn('[!] js-yaml not available, creating legacy config.json');
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 fallbackConfig = { profiles: {} };
const tmpPath = `${legacyConfigPath}.tmp`; const tmpPath = `${legacyConfigPath}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8'); fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8');
fs.renameSync(tmpPath, legacyConfigPath); fs.renameSync(tmpPath, legacyConfigPath);
console.log('[OK] Created config: ~/.ccs/config.json (fallback)'); 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)'); console.log('[OK] Config exists: ~/.ccs/config.yaml (preserved)');
} }
// Handle legacy config.json migrations (for users upgrading) // Warn if both config files exist (user may want to clean up)
if (fs.existsSync(legacyConfigPath) && !fs.existsSync(configYamlPath)) { if (fs.existsSync(legacyConfigPath) && fs.existsSync(configYamlPath)) {
// Migration will happen via autoMigrate() in ccs.ts on first run console.log('[!] Both config.yaml and config.json exist');
console.log('[i] Legacy config.json will be migrated to config.yaml on first run'); console.log(' config.json will be ignored - consider removing it');
} }
// NOTE: GLM, GLMT, and Kimi profiles are NO LONGER auto-created during install // NOTE: GLM, GLMT, and Kimi profiles are NO LONGER auto-created during install
+10 -9
View File
@@ -448,16 +448,17 @@ async function main(): Promise<void> {
if (recovered) { if (recovered) {
recovery.showRecoveryHints(); recovery.showRecoveryHints();
}
// First-time install: offer setup wizard for interactive users // First-time install: offer setup wizard for interactive users
// Skip if headless, CI, or non-TTY environment // Check independently of recovery status (user may have empty config.yaml)
const { isFirstTimeInstall } = await import('./commands/setup-command'); // Skip if headless, CI, or non-TTY environment
if (process.stdout.isTTY && !process.env['CI'] && isFirstTimeInstall()) { const { isFirstTimeInstall } = await import('./commands/setup-command');
console.log(''); if (process.stdout.isTTY && !process.env['CI'] && isFirstTimeInstall()) {
console.log(info('First-time install detected. Run `ccs setup` for guided configuration.')); console.log('');
console.log(' Or use `ccs config` for the web dashboard.'); console.log(info('First-time install detected. Run `ccs setup` for guided configuration.'));
console.log(''); console.log(' Or use `ccs config` for the web dashboard.');
} console.log('');
} }
// Detect profile // Detect profile
+62 -7
View File
@@ -16,11 +16,20 @@ import * as readline from 'readline';
import { initUI, header, ok, info, warn } from '../utils/ui'; import { initUI, header, ok, info, warn } from '../utils/ui';
import { import {
loadOrCreateUnifiedConfig, loadOrCreateUnifiedConfig,
loadUnifiedConfig,
saveUnifiedConfig, saveUnifiedConfig,
hasUnifiedConfig, hasUnifiedConfig,
} from '../config/unified-config-loader'; } from '../config/unified-config-loader';
import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types'; 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 * Create readline interface for interactive prompts
*/ */
@@ -33,15 +42,23 @@ function createReadline(): readline.Interface {
/** /**
* Prompt user for input with optional default value * Prompt user for input with optional default value
* Handles Ctrl+C gracefully by rejecting with UserCancelledError
*/ */
async function prompt( async function prompt(
rl: readline.Interface, rl: readline.Interface,
question: string, question: string,
defaultValue?: string defaultValue?: string
): Promise<string> { ): Promise<string> {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
const displayQuestion = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `; const displayQuestion = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `;
const onClose = () => {
reject(new UserCancelledError());
};
rl.once('close', onClose);
rl.question(displayQuestion, (answer) => { rl.question(displayQuestion, (answer) => {
rl.removeListener('close', onClose);
resolve(answer.trim() || defaultValue || ''); resolve(answer.trim() || defaultValue || '');
}); });
}); });
@@ -102,8 +119,18 @@ export function isFirstTimeInstall(): boolean {
return true; return true;
} }
// Config exists - check if it's meaningfully configured // Try loading config directly to detect corruption
const config = loadOrCreateUnifiedConfig(); 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 // Check for any meaningful configuration
const hasProfiles = Object.keys(config.profiles || {}).length > 0; 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(' Example: your-server.example.com');
console.log(''); console.log('');
// Host // Host - with protocol stripping
const host = await prompt(rl, 'Remote host (hostname or IP)'); let host = await prompt(rl, 'Remote host (hostname or IP)');
if (!host) { if (!host) {
throw new Error('Host is required for remote proxy mode'); 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 // Protocol
const protocol = (await selectOption(rl, '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' }, { label: 'HTTP', value: 'http', description: 'Unencrypted connection' },
])) as 'http' | 'https'; ])) as 'http' | 'https';
// Port (optional) // Port (optional) - with validation
const defaultPort = protocol === 'https' ? '443' : '80'; const defaultPort = protocol === 'https' ? '443' : '80';
const portStr = await prompt(rl, `Port (leave empty for default ${defaultPort})`); 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 // Auth token
console.log(''); console.log('');
@@ -320,6 +360,21 @@ async function runSetupWizard(force: boolean = false): Promise<void> {
console.log(info('Configuration saved to: ~/.ccs/config.yaml')); console.log(info('Configuration saved to: ~/.ccs/config.yaml'));
console.log(''); 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 { } finally {
rl.close(); rl.close();
} }
+26 -10
View File
@@ -9,7 +9,11 @@ import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import { info } from '../utils/ui'; import { info } from '../utils/ui';
import { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION } from '../config/unified-config-types'; 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) * 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) * This is the primary config format (YAML unified config)
*/ */
ensureConfigYaml(): boolean { ensureConfigYaml(): boolean {
// Skip if config.yaml already exists // Skip if config.yaml already exists AND is valid
if (hasUnifiedConfig()) { 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 // Check for legacy config.json - if exists, let autoMigrate handle it
@@ -75,14 +85,20 @@ class RecoveryManager {
saveUnifiedConfig(config); saveUnifiedConfig(config);
this.recovered.push('Created ~/.ccs/config.yaml'); this.recovered.push('Created ~/.ccs/config.yaml');
return true; return true;
} catch (_e) { } catch (_saveErr) {
// Fallback: create minimal config.json for backward compat // Fallback: create minimal config.json for backward compat
const fallbackConfig = { profiles: {} }; try {
const tmpPath = `${legacyConfigPath}.tmp`; const fallbackConfig = { profiles: {} };
fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8'); const tmpPath = `${legacyConfigPath}.tmp`;
fs.renameSync(tmpPath, legacyConfigPath); fs.writeFileSync(tmpPath, JSON.stringify(fallbackConfig, null, 2) + '\n', 'utf8');
this.recovered.push('Created ~/.ccs/config.json (fallback)'); fs.renameSync(tmpPath, legacyConfigPath);
return true; 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;
}
} }
} }