mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +00:00
fix(doctor): keep diagnostics running on corrupt config or unreadable auth
loadUnifiedConfig() printed the YAML error on every call (3-4x per run); print it once per path per process. Run each doctor check group defensively so a thrown check (corrupt config, EACCES auth dir) records an error and the SUMMARY/ERRORS sections with recovery hints still render instead of crashing mid-run. Trim embedded parser snippets to one line so they no longer leak into the summary table or repeat across checks.
This commit is contained in:
1 parent
2900743767
commit
8cfd13ba96
6 files changed
+105
-53
No files matched your search
@@ -128,7 +128,16 @@ async function selectOption(
|
||||
export function isFirstTimeInstall(): boolean {
|
||||
// Check unified config first (config.yaml)
|
||||
if (hasUnifiedConfig()) {
|
||||
const loaded = loadUnifiedConfig();
|
||||
let loaded: ReturnType<typeof loadUnifiedConfig>;
|
||||
try {
|
||||
loaded = loadUnifiedConfig();
|
||||
} catch {
|
||||
// Config exists but is corrupted/unparseable — treat as not first-time so we
|
||||
// don't run the setup wizard; the YAML error was already printed by loadUnifiedConfig.
|
||||
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 but is corrupted/invalid - don't treat as first-time
|
||||
if (loaded === null) {
|
||||
|
||||
@@ -25,6 +25,12 @@ import {
|
||||
import type { UnifiedConfig } from './unified-config-types';
|
||||
import { isUnifiedConfigEnabled } from './feature-flags';
|
||||
|
||||
// loadUnifiedConfig() runs several times per invocation (startup migration check,
|
||||
// first-time-install check, command handler, doctor checks). A corrupt config.yaml
|
||||
// must report its parse error ONCE, not once per call. Track paths already warned
|
||||
// so repeated loads in the same process stay quiet.
|
||||
const warnedCorruptConfigPaths = new Set<string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 1 re-exports: io-locks
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -167,22 +173,27 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
|
||||
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
// U3: Provide better context for YAML syntax errors
|
||||
if (err instanceof yaml.YAMLException) {
|
||||
const mark = err.mark;
|
||||
console.error(`[X] YAML syntax error in ${yamlPath}:`);
|
||||
console.error(
|
||||
` Line ${(mark?.line ?? 0) + 1}, Column ${(mark?.column ?? 0) + 1}: ${err.reason || 'Invalid syntax'}`
|
||||
);
|
||||
if (mark?.snippet) {
|
||||
console.error(` ${mark.snippet}`);
|
||||
// U3: Provide better context for YAML syntax errors. Print at most once per
|
||||
// path per process so repeated loads of a corrupt file do not spam the error.
|
||||
const alreadyWarned = warnedCorruptConfigPaths.has(yamlPath);
|
||||
if (!alreadyWarned) {
|
||||
warnedCorruptConfigPaths.add(yamlPath);
|
||||
if (err instanceof yaml.YAMLException) {
|
||||
const mark = err.mark;
|
||||
console.error(`[X] YAML syntax error in ${yamlPath}:`);
|
||||
console.error(
|
||||
` Line ${(mark?.line ?? 0) + 1}, Column ${(mark?.column ?? 0) + 1}: ${err.reason || 'Invalid syntax'}`
|
||||
);
|
||||
if (mark?.snippet) {
|
||||
console.error(` ${mark.snippet}`);
|
||||
}
|
||||
console.error(
|
||||
` Tip: Check for missing colons, incorrect indentation, or unquoted special characters.`
|
||||
);
|
||||
} else {
|
||||
const error = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error(`[X] Failed to load config: ${error}`);
|
||||
}
|
||||
console.error(
|
||||
` Tip: Check for missing colons, incorrect indentation, or unquoted special characters.`
|
||||
);
|
||||
} else {
|
||||
const error = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error(`[X] Failed to load config: ${error}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,26 @@ export class CLIProxyAuthChecker implements IHealthChecker {
|
||||
name = 'CLIProxy Auth';
|
||||
|
||||
run(results: HealthCheck): void {
|
||||
const authStatuses = getAllAuthStatus();
|
||||
// Reading auth status touches ~/.ccs; a permission error (EACCES) must not
|
||||
// crash doctor before the ERRORS section renders. Surface it as a recoverable
|
||||
// check instead of throwing.
|
||||
let authStatuses;
|
||||
try {
|
||||
authStatuses = getAllAuthStatus();
|
||||
} catch (err) {
|
||||
const spinner = ora('Checking CLIProxy auth').start();
|
||||
spinner.fail();
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.log(` ${warn('CLIProxy Auth'.padEnd(22))} Unable to read auth status`);
|
||||
results.addCheck(
|
||||
'CLIProxy Auth',
|
||||
'error',
|
||||
`Unable to read CLIProxy auth status: ${message}`,
|
||||
'sudo chown -R $USER ~/.ccs ~/.claude && chmod -R u+rwX ~/.ccs ~/.claude',
|
||||
{ status: 'ERROR', info: 'Auth status unavailable (permission or read error)' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const status of authStatuses) {
|
||||
const spinner = ora(`Checking ${status.provider} auth`).start();
|
||||
const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1);
|
||||
|
||||
@@ -72,10 +72,13 @@ export class ConfigFilesChecker implements IHealthChecker {
|
||||
} catch (e) {
|
||||
spinner.fail();
|
||||
console.log(` ${fail('config.yaml'.padEnd(22))} Invalid YAML`);
|
||||
// First line only: the parser message embeds a multi-line snippet the
|
||||
// loader already printed, so keep the ERRORS entry to a single line.
|
||||
const reason = (e as Error).message.split('\n')[0].trim();
|
||||
results.addCheck(
|
||||
'config.yaml',
|
||||
'error',
|
||||
`Invalid YAML: ${(e as Error).message}`,
|
||||
`Invalid YAML: ${reason}`,
|
||||
`Backup and recreate: mv ${configYamlPath} ${configYamlPath}.backup && npm install -g @kaitranntt/ccs --force`,
|
||||
{ status: 'ERROR', info: 'Invalid YAML' }
|
||||
);
|
||||
|
||||
@@ -40,19 +40,14 @@ export class ProfilesChecker implements IHealthChecker {
|
||||
return;
|
||||
} catch (e) {
|
||||
spinner.fail();
|
||||
console.log(
|
||||
` ${fail('Profiles'.padEnd(22))} Invalid config.yaml: ${(e as Error).message}`
|
||||
);
|
||||
results.addCheck(
|
||||
'Profiles',
|
||||
'error',
|
||||
`Invalid config.yaml: ${(e as Error).message}`,
|
||||
undefined,
|
||||
{
|
||||
status: 'ERROR',
|
||||
info: (e as Error).message,
|
||||
}
|
||||
);
|
||||
// First line only: a YAML parse error embeds a multi-line snippet (already
|
||||
// printed by the loader) that otherwise leaks into the summary table cell.
|
||||
const reason = (e as Error).message.split('\n')[0].trim();
|
||||
console.log(` ${fail('Profiles'.padEnd(22))} Invalid config.yaml: ${reason}`);
|
||||
results.addCheck('Profiles', 'error', `Invalid config.yaml: ${reason}`, undefined, {
|
||||
status: 'ERROR',
|
||||
info: reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -65,19 +60,12 @@ export class ProfilesChecker implements IHealthChecker {
|
||||
return;
|
||||
} catch (e) {
|
||||
spinner.fail();
|
||||
console.log(
|
||||
` ${fail('Profiles'.padEnd(22))} Invalid config.json: ${(e as Error).message}`
|
||||
);
|
||||
results.addCheck(
|
||||
'Profiles',
|
||||
'error',
|
||||
`Invalid config.json: ${(e as Error).message}`,
|
||||
undefined,
|
||||
{
|
||||
status: 'ERROR',
|
||||
info: (e as Error).message,
|
||||
}
|
||||
);
|
||||
const reason = (e as Error).message.split('\n')[0].trim();
|
||||
console.log(` ${fail('Profiles'.padEnd(22))} Invalid config.json: ${reason}`);
|
||||
results.addCheck('Profiles', 'error', `Invalid config.json: ${reason}`, undefined, {
|
||||
status: 'ERROR',
|
||||
info: reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,49 +46,71 @@ class Doctor {
|
||||
|
||||
// Group 1: System
|
||||
console.log(header('SYSTEM'));
|
||||
await runSystemChecks(this.results);
|
||||
await this.safe('System Checks', () => runSystemChecks(this.results));
|
||||
console.log('');
|
||||
|
||||
// Group 2: Environment (OAuth readiness diagnostics)
|
||||
console.log(header('ENVIRONMENT'));
|
||||
runEnvironmentCheck(this.results);
|
||||
await this.safe('Environment Check', () => runEnvironmentCheck(this.results));
|
||||
console.log('');
|
||||
|
||||
// Group 3: Configuration
|
||||
console.log(header('CONFIGURATION'));
|
||||
runConfigChecks(this.results);
|
||||
this.runDockerKeyRotationCheck();
|
||||
await this.safe('Configuration Checks', () => runConfigChecks(this.results));
|
||||
await this.safe('Docker Key Rotation', () => this.runDockerKeyRotationCheck());
|
||||
console.log('');
|
||||
|
||||
// Group 4: Profiles & Delegation
|
||||
console.log(header('PROFILES & DELEGATION'));
|
||||
runProfileChecks(this.results);
|
||||
await this.safe('Profile Checks', () => runProfileChecks(this.results));
|
||||
console.log('');
|
||||
|
||||
// Group 5: System Health
|
||||
console.log(header('SYSTEM HEALTH'));
|
||||
runSymlinkChecks(this.results);
|
||||
await this.safe('Symlink Checks', () => runSymlinkChecks(this.results));
|
||||
console.log('');
|
||||
|
||||
// Group 6: CLIProxy Plus (OAuth profiles)
|
||||
console.log(header('CLIPROXY PLUS (OAUTH PROFILES)'));
|
||||
await runCLIProxyChecks(this.results);
|
||||
await this.safe('CLIProxy Checks', () => runCLIProxyChecks(this.results));
|
||||
console.log('');
|
||||
|
||||
// Group 7: OAuth Readiness (port availability)
|
||||
console.log(header('OAUTH READINESS'));
|
||||
await runOAuthChecks(this.results);
|
||||
await this.safe('OAuth Checks', () => runOAuthChecks(this.results));
|
||||
console.log('');
|
||||
|
||||
// Group 8: Image Analysis Config
|
||||
console.log(header('IMAGE ANALYSIS'));
|
||||
await runImageAnalysisCheck(this.results);
|
||||
await this.safe('Image Analysis Check', () => runImageAnalysisCheck(this.results));
|
||||
console.log('');
|
||||
|
||||
this.showReport();
|
||||
return this.results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one check group defensively. A thrown check (e.g. a corrupt config.yaml
|
||||
* that makes a loader throw) is recorded as an error instead of aborting the
|
||||
* whole run, so the SUMMARY and ERRORS sections (with recovery hints) always
|
||||
* render.
|
||||
*/
|
||||
private async safe(label: string, fn: () => void | Promise<void>): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
// Use only the first line of the message: parser errors (e.g. js-yaml)
|
||||
// embed a multi-line snippet that the loader already printed once, so the
|
||||
// ERRORS section stays one line per failed check.
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
const message = raw.split('\n')[0].trim();
|
||||
this.results.addCheck(label, 'error', message, undefined, {
|
||||
status: 'ERROR',
|
||||
info: 'check failed (see ERRORS)',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show health check report
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user