mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 08:19:59 +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');
|
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
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user