Merge pull request #215 from kaitranntt/fix/214-config-recovery-before-commands

fix: run RecoveryManager before early-exit commands and improve config handling
This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-27 13:31:51 -08:00
committed by GitHub
13 changed files with 70 additions and 47 deletions
+18 -10
View File
@@ -291,6 +291,24 @@ async function main(): Promise<void> {
await autoMigrate();
}
// Auto-recovery for missing configuration (BEFORE any early-exit commands)
// This ensures ALL commands benefit from auto-recovery, not just profile-switching flow
// Recovery is safe to run early - it only creates missing files with safe defaults
// Wrapped in try-catch to prevent blocking --version/--help on permission errors
try {
const RecoveryManagerModule = await import('./management/recovery-manager');
const RecoveryManager = RecoveryManagerModule.default;
const recovery = new RecoveryManager();
const recovered = recovery.recoverAll();
if (recovered) {
recovery.showRecoveryHints();
}
} catch (err) {
// Recovery is best-effort - don't block basic CLI functionality
console.warn('[!] Recovery failed:', (err as Error).message);
}
// Special case: version command (check BEFORE profile detection)
if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') {
handleVersionCommand();
@@ -455,16 +473,6 @@ async function main(): Promise<void> {
return;
}
// Auto-recovery for missing configuration
const RecoveryManagerModule = await import('./management/recovery-manager');
const RecoveryManager = RecoveryManagerModule.default;
const recovery = new RecoveryManager();
const recovered = recovery.recoverAll();
if (recovered) {
recovery.showRecoveryHints();
}
// 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
+4 -3
View File
@@ -129,9 +129,10 @@ export function getConfigPathForPort(port: number): string {
}
/**
* Get config file path (default port)
* Get CLIProxy config file path (default port)
* Named distinctly from config-manager's getConfigPath to avoid confusion.
*/
export function getConfigPath(): string {
export function getCliproxyConfigPath(): string {
return getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
}
@@ -377,7 +378,7 @@ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string {
* @returns true if config should be regenerated
*/
export function configNeedsRegeneration(): boolean {
const configPath = getConfigPath();
const configPath = getCliproxyConfigPath();
if (!fs.existsSync(configPath)) {
return false; // Will be created on first use
}
+1 -1
View File
@@ -65,7 +65,7 @@ export {
getCliproxyDir,
getProviderAuthDir,
getAuthDir,
getConfigPath,
getCliproxyConfigPath,
getBinDir,
configExists,
deleteConfig,
+3 -3
View File
@@ -7,7 +7,7 @@
import * as fs from 'fs';
import * as yaml from 'js-yaml';
import { getConfigPath } from './config-generator';
import { getCliproxyConfigPath } from './config-generator';
/** Model alias configuration */
export interface OpenAICompatModel {
@@ -48,7 +48,7 @@ interface ConfigYaml {
* Load current config.yaml
*/
function loadConfig(): ConfigYaml {
const configPath = getConfigPath();
const configPath = getCliproxyConfigPath();
if (!fs.existsSync(configPath)) {
return {};
}
@@ -65,7 +65,7 @@ function loadConfig(): ConfigYaml {
* Save config.yaml with proper formatting
*/
function saveConfig(config: ConfigYaml): void {
const configPath = getConfigPath();
const configPath = getCliproxyConfigPath();
const content = yaml.dump(config, {
lineWidth: -1, // Disable line wrapping
quotingType: '"',
+2 -1
View File
@@ -1,6 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { initUI, box, color, dim, sectionHeader, subheader } from '../utils/ui';
import { isUnifiedMode } from '../config/unified-config-loader';
// Get version from package.json (same as version-command.ts)
const VERSION = JSON.parse(
@@ -235,7 +236,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
// Configuration
printConfigSection('Configuration', [
['Config File:', '~/.ccs/config.json'],
['Config File:', isUnifiedMode() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'],
['Profiles:', '~/.ccs/profiles.json'],
['Instances:', '~/.ccs/instances/'],
['Settings:', '~/.ccs/*.settings.json'],
+2 -2
View File
@@ -8,7 +8,7 @@ import {
isCLIProxyInstalled,
getCLIProxyPath,
getAllAuthStatus,
getConfigPath,
getCliproxyConfigPath,
getInstalledCliproxyVersion,
CLIPROXY_DEFAULT_PORT,
configNeedsRegeneration,
@@ -60,7 +60,7 @@ export class CLIProxyConfigChecker implements IHealthChecker {
run(results: HealthCheck): void {
const spinner = ora('Checking CLIProxy config').start();
const configPath = getConfigPath();
const configPath = getCliproxyConfigPath();
if (fs.existsSync(configPath)) {
// Check if config needs regeneration (version mismatch or missing features)
+4 -4
View File
@@ -17,10 +17,10 @@ export interface ProfilesConfig {
* Example: "flash" → gemini provider with gemini-2.5-flash model
*/
export interface CLIProxyVariantConfig {
/** CLIProxy provider to use (gemini, codex, agy, qwen) */
provider: 'gemini' | 'codex' | 'agy' | 'qwen';
/** Path to settings.json with custom model configuration */
settings: string;
/** CLIProxy provider to use */
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp';
/** Path to settings.json with custom model configuration (optional) */
settings?: string;
/** Account identifier for multi-account support (optional, defaults to 'default') */
account?: string;
/** Unique port for variant isolation (8318-8417) */
+26 -6
View File
@@ -27,12 +27,26 @@ export function getCcsDir(): string {
}
/**
* Get config file path
* Get config file path (legacy JSON path)
* @deprecated Use getActiveConfigPath() for mode-aware config path
*/
export function getConfigPath(): string {
return process.env.CCS_CONFIG || path.join(getCcsHome(), '.ccs', 'config.json');
}
/**
* Get the active config file path based on current mode.
* Returns config.yaml in unified mode, config.json in legacy mode.
* @returns Path to the active config file
*/
export function getActiveConfigPath(): string {
const ccsDir = getCcsDir();
if (isUnifiedMode()) {
return path.join(ccsDir, 'config.yaml');
}
return path.join(ccsDir, 'config.json');
}
/**
* Load and validate config.json
*/
@@ -107,9 +121,8 @@ export function loadConfigSafe(): Config {
cliproxy = {};
for (const [name, variant] of Object.entries(unifiedConfig.cliproxy.variants)) {
cliproxy[name] = {
// Cast provider - unified has more providers than legacy type
provider: variant.provider as 'gemini' | 'codex' | 'agy' | 'qwen',
settings: variant.settings || '',
provider: variant.provider,
settings: variant.settings,
account: variant.account,
port: variant.port,
};
@@ -126,11 +139,18 @@ export function loadConfigSafe(): Config {
const configPath = getConfigPath();
if (!fs.existsSync(configPath)) {
throw new Error(`Config not found: ${configPath}`);
// Return empty config for graceful degradation (matches unified mode behavior)
return { profiles: {} };
}
const raw = fs.readFileSync(configPath, 'utf8');
const parsed: unknown = JSON.parse(raw);
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (e) {
throw new Error(`Malformed JSON in config: ${configPath} - ${(e as Error).message}`);
}
if (!isConfig(parsed)) {
throw new Error(`Invalid config format: ${configPath}`);
+1 -1
View File
@@ -9,7 +9,7 @@ import {
isCLIProxyInstalled,
getInstalledCliproxyVersion,
getCLIProxyPath,
getConfigPath as getCliproxyConfigPath,
getCliproxyConfigPath,
getAllAuthStatus,
CLIPROXY_DEFAULT_PORT,
} from '../../cliproxy';
@@ -14,7 +14,7 @@ import {
} from '../../cliproxy/stats-fetcher';
import {
getCliproxyWritablePath,
getConfigPath,
getCliproxyConfigPath,
getAuthDir,
} from '../../cliproxy/config-generator';
import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker';
@@ -272,7 +272,7 @@ router.get('/error-logs/:name', async (req: Request, res: Response): Promise<voi
*/
router.get('/config.yaml', async (_req: Request, res: Response): Promise<void> => {
try {
const configPath = getConfigPath();
const configPath = getCliproxyConfigPath();
if (!fs.existsSync(configPath)) {
res.status(404).json({ error: 'Config file not found' });
return;
@@ -299,7 +299,7 @@ router.put('/config.yaml', async (req: Request, res: Response): Promise<void> =>
return;
}
const configPath = getConfigPath();
const configPath = getCliproxyConfigPath();
// Ensure parent directory exists
const configDir = path.dirname(configPath);
@@ -19,7 +19,7 @@ process.env.CCS_HOME = testHome;
const {
getConfigPathForPort,
getConfigPath,
getCliproxyConfigPath,
generateConfig,
regenerateConfig,
configExists,
@@ -98,9 +98,9 @@ describe('Config Generator Port', function () {
});
});
describe('getConfigPath', function () {
describe('getCliproxyConfigPath', function () {
it('returns path for default port', function () {
const configPath = getConfigPath();
const configPath = getCliproxyConfigPath();
const defaultPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
assert.strictEqual(configPath, defaultPath);
});
+2 -2
View File
@@ -362,7 +362,7 @@ auth-dir: "/test"
let testDir;
let originalCcsHome;
let regenerateConfig;
let getConfigPath;
let getCliproxyConfigPath;
beforeEach(() => {
// Create a temporary test directory
@@ -375,7 +375,7 @@ auth-dir: "/test"
delete require.cache[require.resolve('../../../dist/utils/config-manager')];
const configGenerator = require('../../../dist/cliproxy/config-generator');
regenerateConfig = configGenerator.regenerateConfig;
getConfigPath = configGenerator.getConfigPath;
getCliproxyConfigPath = configGenerator.getCliproxyConfigPath;
});
afterEach(() => {
@@ -27,14 +27,7 @@ describe('Beta Channel Implementation (Phase 3)', function () {
let httpsRequests = [];
beforeAll(async function () {
// Build the project first
const { execSync } = require('child_process');
try {
execSync('bun run build', { cwd: path.resolve(__dirname, '../../..'), stdio: 'pipe' });
} catch (error) {
console.warn('Build failed, tests may not work:', error.message);
}
// Note: Build is handled by CI before tests run (bun run build:all)
// Import the built module
updateCheckerModule = await import('../../../dist/utils/update-checker.js');
});