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:
Tam Nhu Tran committed 2026-06-22 12:06:58 -04:00
1 parent 2900743767
commit 8cfd13ba96
6 files changed
+105 -53

No files matched your search

+10 -1
View File
@@ -128,7 +128,16 @@ async function selectOption(
export function isFirstTimeInstall(): boolean { export function isFirstTimeInstall(): boolean {
// Check unified config first (config.yaml) // Check unified config first (config.yaml)
if (hasUnifiedConfig()) { 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 // Config exists but is corrupted/invalid - don't treat as first-time
if (loaded === null) { if (loaded === null) {
+26 -15
View File
@@ -25,6 +25,12 @@ import {
import type { UnifiedConfig } from './unified-config-types'; import type { UnifiedConfig } from './unified-config-types';
import { isUnifiedConfigEnabled } from './feature-flags'; 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 // Phase 1 re-exports: io-locks
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -167,22 +173,27 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
return parsed; return parsed;
} catch (err) { } catch (err) {
// U3: Provide better context for YAML syntax errors // U3: Provide better context for YAML syntax errors. Print at most once per
if (err instanceof yaml.YAMLException) { // path per process so repeated loads of a corrupt file do not spam the error.
const mark = err.mark; const alreadyWarned = warnedCorruptConfigPaths.has(yamlPath);
console.error(`[X] YAML syntax error in ${yamlPath}:`); if (!alreadyWarned) {
console.error( warnedCorruptConfigPaths.add(yamlPath);
` Line ${(mark?.line ?? 0) + 1}, Column ${(mark?.column ?? 0) + 1}: ${err.reason || 'Invalid syntax'}` if (err instanceof yaml.YAMLException) {
); const mark = err.mark;
if (mark?.snippet) { console.error(`[X] YAML syntax error in ${yamlPath}:`);
console.error(` ${mark.snippet}`); 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; throw err;
} }
+20 -1
View File
@@ -108,7 +108,26 @@ export class CLIProxyAuthChecker implements IHealthChecker {
name = 'CLIProxy Auth'; name = 'CLIProxy Auth';
run(results: HealthCheck): void { 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) { for (const status of authStatuses) {
const spinner = ora(`Checking ${status.provider} auth`).start(); const spinner = ora(`Checking ${status.provider} auth`).start();
const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1); const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1);
+4 -1
View File
@@ -72,10 +72,13 @@ export class ConfigFilesChecker implements IHealthChecker {
} catch (e) { } catch (e) {
spinner.fail(); spinner.fail();
console.log(` ${fail('config.yaml'.padEnd(22))} Invalid YAML`); 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( results.addCheck(
'config.yaml', 'config.yaml',
'error', 'error',
`Invalid YAML: ${(e as Error).message}`, `Invalid YAML: ${reason}`,
`Backup and recreate: mv ${configYamlPath} ${configYamlPath}.backup && npm install -g @kaitranntt/ccs --force`, `Backup and recreate: mv ${configYamlPath} ${configYamlPath}.backup && npm install -g @kaitranntt/ccs --force`,
{ status: 'ERROR', info: 'Invalid YAML' } { status: 'ERROR', info: 'Invalid YAML' }
); );
+14 -26
View File
@@ -40,19 +40,14 @@ export class ProfilesChecker implements IHealthChecker {
return; return;
} catch (e) { } catch (e) {
spinner.fail(); spinner.fail();
console.log( // First line only: a YAML parse error embeds a multi-line snippet (already
` ${fail('Profiles'.padEnd(22))} Invalid config.yaml: ${(e as Error).message}` // printed by the loader) that otherwise leaks into the summary table cell.
); const reason = (e as Error).message.split('\n')[0].trim();
results.addCheck( console.log(` ${fail('Profiles'.padEnd(22))} Invalid config.yaml: ${reason}`);
'Profiles', results.addCheck('Profiles', 'error', `Invalid config.yaml: ${reason}`, undefined, {
'error', status: 'ERROR',
`Invalid config.yaml: ${(e as Error).message}`, info: reason,
undefined, });
{
status: 'ERROR',
info: (e as Error).message,
}
);
return; return;
} }
} }
@@ -65,19 +60,12 @@ export class ProfilesChecker implements IHealthChecker {
return; return;
} catch (e) { } catch (e) {
spinner.fail(); spinner.fail();
console.log( const reason = (e as Error).message.split('\n')[0].trim();
` ${fail('Profiles'.padEnd(22))} Invalid config.json: ${(e as Error).message}` console.log(` ${fail('Profiles'.padEnd(22))} Invalid config.json: ${reason}`);
); results.addCheck('Profiles', 'error', `Invalid config.json: ${reason}`, undefined, {
results.addCheck( status: 'ERROR',
'Profiles', info: reason,
'error', });
`Invalid config.json: ${(e as Error).message}`,
undefined,
{
status: 'ERROR',
info: (e as Error).message,
}
);
return; return;
} }
} }
+31 -9
View File
@@ -46,49 +46,71 @@ class Doctor {
// Group 1: System // Group 1: System
console.log(header('SYSTEM')); console.log(header('SYSTEM'));
await runSystemChecks(this.results); await this.safe('System Checks', () => runSystemChecks(this.results));
console.log(''); console.log('');
// Group 2: Environment (OAuth readiness diagnostics) // Group 2: Environment (OAuth readiness diagnostics)
console.log(header('ENVIRONMENT')); console.log(header('ENVIRONMENT'));
runEnvironmentCheck(this.results); await this.safe('Environment Check', () => runEnvironmentCheck(this.results));
console.log(''); console.log('');
// Group 3: Configuration // Group 3: Configuration
console.log(header('CONFIGURATION')); console.log(header('CONFIGURATION'));
runConfigChecks(this.results); await this.safe('Configuration Checks', () => runConfigChecks(this.results));
this.runDockerKeyRotationCheck(); await this.safe('Docker Key Rotation', () => this.runDockerKeyRotationCheck());
console.log(''); console.log('');
// Group 4: Profiles & Delegation // Group 4: Profiles & Delegation
console.log(header('PROFILES & DELEGATION')); console.log(header('PROFILES & DELEGATION'));
runProfileChecks(this.results); await this.safe('Profile Checks', () => runProfileChecks(this.results));
console.log(''); console.log('');
// Group 5: System Health // Group 5: System Health
console.log(header('SYSTEM HEALTH')); console.log(header('SYSTEM HEALTH'));
runSymlinkChecks(this.results); await this.safe('Symlink Checks', () => runSymlinkChecks(this.results));
console.log(''); console.log('');
// Group 6: CLIProxy Plus (OAuth profiles) // Group 6: CLIProxy Plus (OAuth profiles)
console.log(header('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(''); console.log('');
// Group 7: OAuth Readiness (port availability) // Group 7: OAuth Readiness (port availability)
console.log(header('OAUTH READINESS')); console.log(header('OAUTH READINESS'));
await runOAuthChecks(this.results); await this.safe('OAuth Checks', () => runOAuthChecks(this.results));
console.log(''); console.log('');
// Group 8: Image Analysis Config // Group 8: Image Analysis Config
console.log(header('IMAGE ANALYSIS')); console.log(header('IMAGE ANALYSIS'));
await runImageAnalysisCheck(this.results); await this.safe('Image Analysis Check', () => runImageAnalysisCheck(this.results));
console.log(''); console.log('');
this.showReport(); this.showReport();
return this.results; 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 * Show health check report
*/ */