mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 06:17:09 +00:00
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:
+75
-37
@@ -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
|
||||
|
||||
+10
-9
@@ -448,16 +448,17 @@ async function main(): Promise<void> {
|
||||
|
||||
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
|
||||
|
||||
@@ -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<string> {
|
||||
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<void> {
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user