From 8cfd13ba96956532babbc171c9d9f997401620b0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 11:40:10 -0400 Subject: [PATCH] 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. --- src/commands/setup-command.ts | 11 ++++++- src/config/unified-config-loader.ts | 41 ++++++++++++++++--------- src/management/checks/cliproxy-check.ts | 21 ++++++++++++- src/management/checks/config-check.ts | 5 ++- src/management/checks/profile-check.ts | 40 +++++++++--------------- src/management/doctor.ts | 40 ++++++++++++++++++------ 6 files changed, 105 insertions(+), 53 deletions(-) diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index d5cccc2f..60e23b83 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -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; + 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) { diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 59fe94ea..dbd3cb3c 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -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(); + // --------------------------------------------------------------------------- // 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; } diff --git a/src/management/checks/cliproxy-check.ts b/src/management/checks/cliproxy-check.ts index 4a4998b6..eb85d73e 100644 --- a/src/management/checks/cliproxy-check.ts +++ b/src/management/checks/cliproxy-check.ts @@ -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); diff --git a/src/management/checks/config-check.ts b/src/management/checks/config-check.ts index f3ac17e0..beee75e7 100644 --- a/src/management/checks/config-check.ts +++ b/src/management/checks/config-check.ts @@ -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' } ); diff --git a/src/management/checks/profile-check.ts b/src/management/checks/profile-check.ts index dd55ecee..e1717f39 100644 --- a/src/management/checks/profile-check.ts +++ b/src/management/checks/profile-check.ts @@ -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; } } diff --git a/src/management/doctor.ts b/src/management/doctor.ts index 13d0fa3a..458cdb8d 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -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): Promise { + 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 */