From 0eb2030dc2af6e351a88801dc42ce739208bfc2e Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 11:47:55 -0500 Subject: [PATCH] refactor(management): modularize doctor health checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extract 9 check modules to checks/ directory (1260 → 186 lines) - add repair/ directory with auto-repair extraction - system, env, config, profile, cliproxy, oauth, symlink checks - shared types.ts for check result interfaces Phase 3: Large Files Breakdown (doctor.ts) --- src/management/checks/cliproxy-check.ts | 199 ++++ src/management/checks/config-check.ts | 168 ++++ src/management/checks/env-check.ts | 76 ++ src/management/checks/index.ts | 51 ++ src/management/checks/oauth-check.ts | 76 ++ src/management/checks/profile-check.ts | 190 ++++ src/management/checks/symlink-check.ts | 218 +++++ src/management/checks/system-check.ts | 138 +++ src/management/checks/types.ts | 114 +++ src/management/doctor.ts | 1120 +---------------------- src/management/repair/auto-repair.ts | 153 ++++ src/management/repair/index.ts | 5 + 12 files changed, 1411 insertions(+), 1097 deletions(-) create mode 100644 src/management/checks/cliproxy-check.ts create mode 100644 src/management/checks/config-check.ts create mode 100644 src/management/checks/env-check.ts create mode 100644 src/management/checks/index.ts create mode 100644 src/management/checks/oauth-check.ts create mode 100644 src/management/checks/profile-check.ts create mode 100644 src/management/checks/symlink-check.ts create mode 100644 src/management/checks/system-check.ts create mode 100644 src/management/checks/types.ts create mode 100644 src/management/repair/auto-repair.ts create mode 100644 src/management/repair/index.ts diff --git a/src/management/checks/cliproxy-check.ts b/src/management/checks/cliproxy-check.ts new file mode 100644 index 00000000..2ad57d1d --- /dev/null +++ b/src/management/checks/cliproxy-check.ts @@ -0,0 +1,199 @@ +/** + * CLIProxy Health Checks - Binary, config, auth, and port status + */ + +import * as fs from 'fs'; +import { ok, warn, info } from '../../utils/ui'; +import { + isCLIProxyInstalled, + getCLIProxyPath, + getAllAuthStatus, + getConfigPath, + getInstalledCliproxyVersion, + CLIPROXY_DEFAULT_PORT, + configNeedsRegeneration, + regenerateConfig, + CLIPROXY_CONFIG_VERSION, +} from '../../cliproxy'; +import { getPortProcess, isCLIProxyProcess } from '../../utils/port-utils'; +import { HealthCheck, IHealthChecker, createSpinner } from './types'; + +const ora = createSpinner(); + +/** + * Check CLIProxy binary installation + */ +export class CLIProxyBinaryChecker implements IHealthChecker { + name = 'CLIProxy Binary'; + + run(results: HealthCheck): void { + const spinner = ora('Checking CLIProxy binary').start(); + + if (isCLIProxyInstalled()) { + const binaryPath = getCLIProxyPath(); + const installedVersion = getInstalledCliproxyVersion(); + spinner.succeed(); + console.log(` ${ok('CLIProxy Binary'.padEnd(22))} v${installedVersion}`); + results.addCheck('CLIProxy Binary', 'success', undefined, undefined, { + status: 'OK', + info: `v${installedVersion} (${binaryPath})`, + }); + } else { + spinner.info(); + console.log( + ` ${info('CLIProxy Binary'.padEnd(22))} Not installed (downloads on first use)` + ); + results.addCheck( + 'CLIProxy Binary', + 'success', + 'Not installed yet', + 'Run: ccs gemini "test" (will download automatically)', + { status: 'OK', info: 'Not installed (downloads on first use)' } + ); + } + } +} + +/** + * Check CLIProxy config file + */ +export class CLIProxyConfigChecker implements IHealthChecker { + name = 'CLIProxy Config'; + + run(results: HealthCheck): void { + const spinner = ora('Checking CLIProxy config').start(); + const configPath = getConfigPath(); + + if (fs.existsSync(configPath)) { + // Check if config needs regeneration (version mismatch or missing features) + if (configNeedsRegeneration()) { + spinner.warn(); + console.log( + ` ${warn('CLIProxy Config'.padEnd(22))} Outdated config, upgrading to v${CLIPROXY_CONFIG_VERSION}...` + ); + + // Regenerate config with new features + regenerateConfig(); + + console.log( + ` ${ok('CLIProxy Config'.padEnd(22))} Upgraded to v${CLIPROXY_CONFIG_VERSION}` + ); + results.addCheck('CLIProxy Config', 'success', undefined, undefined, { + status: 'OK', + info: `Upgraded to v${CLIPROXY_CONFIG_VERSION}`, + }); + } else { + spinner.succeed(); + console.log( + ` ${ok('CLIProxy Config'.padEnd(22))} cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})` + ); + results.addCheck('CLIProxy Config', 'success', undefined, undefined, { + status: 'OK', + info: `cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})`, + }); + } + } else { + spinner.info(); + console.log(` ${info('CLIProxy Config'.padEnd(22))} Not created (on first use)`); + results.addCheck('CLIProxy Config', 'success', 'Not created yet', undefined, { + status: 'OK', + info: 'Generated on first use', + }); + } + } +} + +/** + * Check OAuth status for all providers + */ +export class CLIProxyAuthChecker implements IHealthChecker { + name = 'CLIProxy Auth'; + + run(results: HealthCheck): void { + const authStatuses = getAllAuthStatus(); + for (const status of authStatuses) { + const spinner = ora(`Checking ${status.provider} auth`).start(); + const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1); + + if (status.authenticated) { + const lastAuth = status.lastAuth ? ` (${status.lastAuth.toLocaleDateString()})` : ''; + spinner.succeed(); + console.log(` ${ok(`${providerName} Auth`.padEnd(22))} Authenticated${lastAuth}`); + results.addCheck(`${providerName} Auth`, 'success', undefined, undefined, { + status: 'OK', + info: `Authenticated${lastAuth}`, + }); + } else { + spinner.info(); + console.log(` ${info(`${providerName} Auth`.padEnd(22))} Not authenticated`); + results.addCheck( + `${providerName} Auth`, + 'success', + 'Not authenticated', + `Run: ccs ${status.provider} --auth`, + { status: 'OK', info: 'Not authenticated (run ccs to login)' } + ); + } + } + } +} + +/** + * Check CLIProxy port status + */ +export class CLIProxyPortChecker implements IHealthChecker { + name = 'CLIProxy Port'; + + async run(results: HealthCheck): Promise { + const spinner = ora(`Checking port ${CLIPROXY_DEFAULT_PORT}`).start(); + const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT); + + if (!portProcess) { + // Port is free + spinner.info(); + console.log( + ` ${info('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} free (proxy not running)` + ); + results.addCheck('CLIProxy Port', 'success', undefined, undefined, { + status: 'OK', + info: `Port ${CLIPROXY_DEFAULT_PORT} free`, + }); + } else if (isCLIProxyProcess(portProcess)) { + // CLIProxy is running (expected) + spinner.succeed(); + console.log(` ${ok('CLIProxy Port'.padEnd(22))} CLIProxy running (PID ${portProcess.pid})`); + results.addCheck('CLIProxy Port', 'success', undefined, undefined, { + status: 'OK', + info: `CLIProxy running (PID ${portProcess.pid})`, + }); + } else { + // Port conflict - different process + spinner.warn(); + console.log( + ` ${warn('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} occupied by ${portProcess.processName}` + ); + results.addCheck( + 'CLIProxy Port', + 'warning', + `Port ${CLIPROXY_DEFAULT_PORT} occupied by ${portProcess.processName} (PID ${portProcess.pid})`, + `Kill process: kill ${portProcess.pid} (or restart conflicting application)`, + { status: 'WARN', info: `Occupied by ${portProcess.processName}` } + ); + } + } +} + +/** + * Run all CLIProxy checks + */ +export async function runCLIProxyChecks(results: HealthCheck): Promise { + const binaryChecker = new CLIProxyBinaryChecker(); + const configChecker = new CLIProxyConfigChecker(); + const authChecker = new CLIProxyAuthChecker(); + const portChecker = new CLIProxyPortChecker(); + + binaryChecker.run(results); + configChecker.run(results); + authChecker.run(results); + await portChecker.run(results); +} diff --git a/src/management/checks/config-check.ts b/src/management/checks/config-check.ts new file mode 100644 index 00000000..4a643814 --- /dev/null +++ b/src/management/checks/config-check.ts @@ -0,0 +1,168 @@ +/** + * Configuration Health Checks - Config files and Claude settings + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ok, fail, warn } from '../../utils/ui'; +import { HealthCheck, IHealthChecker, createSpinner } from './types'; + +const ora = createSpinner(); + +/** + * Check CCS config files exist and are valid JSON + */ +export class ConfigFilesChecker implements IHealthChecker { + name = 'Config Files'; + private readonly ccsDir: string; + + constructor() { + this.ccsDir = path.join(os.homedir(), '.ccs'); + } + + run(results: HealthCheck): void { + const files = [ + { path: path.join(this.ccsDir, 'config.json'), name: 'config.json', key: 'config.json' }, + { + path: path.join(this.ccsDir, 'glm.settings.json'), + name: 'glm.settings.json', + key: 'GLM Settings', + profile: 'glm', + }, + { + path: path.join(this.ccsDir, 'kimi.settings.json'), + name: 'kimi.settings.json', + key: 'Kimi Settings', + profile: 'kimi', + }, + ]; + + const { DelegationValidator } = require('../../utils/delegation-validator'); + + for (const file of files) { + const spinner = ora(`Checking ${file.name}`).start(); + + if (!fs.existsSync(file.path)) { + spinner.fail(); + console.log(` ${fail(file.name.padEnd(22))} Not found`); + results.addCheck( + file.name, + 'error', + `${file.name} not found`, + 'Run: npm install -g @kaitranntt/ccs --force', + { status: 'ERROR', info: 'Not found' } + ); + continue; + } + + // Validate JSON + try { + const content = fs.readFileSync(file.path, 'utf8'); + JSON.parse(content); + + // Extract useful info based on file type + let fileInfo = 'Valid'; + let status: 'OK' | 'WARN' = 'OK'; + + if (file.profile) { + // For settings files, check if API key is configured + const validation = DelegationValidator.validate(file.profile); + + if (validation.valid) { + fileInfo = 'Key configured'; + status = 'OK'; + } else if (validation.error && validation.error.includes('placeholder')) { + fileInfo = 'Placeholder key'; + status = 'WARN'; + } else { + fileInfo = 'Valid JSON'; + status = 'OK'; + } + } + + if (status === 'WARN') { + spinner.warn(); + console.log(` ${warn(file.name.padEnd(22))} ${fileInfo}`); + } else { + spinner.succeed(); + console.log(` ${ok(file.name.padEnd(22))} ${fileInfo}`); + } + + results.addCheck(file.name, status === 'OK' ? 'success' : 'warning', undefined, undefined, { + status: status, + info: fileInfo, + }); + } catch (e) { + spinner.fail(); + console.log(` ${fail(file.name.padEnd(22))} Invalid JSON`); + results.addCheck( + file.name, + 'error', + `Invalid JSON: ${(e as Error).message}`, + `Backup and recreate: mv ${file.path} ${file.path}.backup && npm install -g @kaitranntt/ccs --force`, + { status: 'ERROR', info: 'Invalid JSON' } + ); + } + } + } +} + +/** + * Check Claude settings.json + */ +export class ClaudeSettingsChecker implements IHealthChecker { + name = 'Claude Settings'; + private readonly claudeDir: string; + + constructor() { + this.claudeDir = path.join(os.homedir(), '.claude'); + } + + run(results: HealthCheck): void { + const spinner = ora('Checking ~/.claude/settings.json').start(); + const settingsPath = path.join(this.claudeDir, 'settings.json'); + const settingsName = '~/.claude/settings.json'; + + if (!fs.existsSync(settingsPath)) { + spinner.warn(); + console.log(` ${warn(settingsName.padEnd(22))} Not found`); + results.addCheck( + 'Claude Settings', + 'warning', + '~/.claude/settings.json not found', + 'Run: claude /login' + ); + return; + } + + // Validate JSON + try { + const content = fs.readFileSync(settingsPath, 'utf8'); + JSON.parse(content); + spinner.succeed(); + console.log(` ${ok(settingsName.padEnd(22))} Valid`); + results.addCheck('Claude Settings', 'success'); + } catch (e) { + spinner.warn(); + console.log(` ${warn(settingsName.padEnd(22))} Invalid JSON`); + results.addCheck( + 'Claude Settings', + 'warning', + `Invalid JSON: ${(e as Error).message}`, + 'Run: claude /login' + ); + } + } +} + +/** + * Run all config checks + */ +export function runConfigChecks(results: HealthCheck): void { + const configChecker = new ConfigFilesChecker(); + const claudeChecker = new ClaudeSettingsChecker(); + + configChecker.run(results); + claudeChecker.run(results); +} diff --git a/src/management/checks/env-check.ts b/src/management/checks/env-check.ts new file mode 100644 index 00000000..c586b07a --- /dev/null +++ b/src/management/checks/env-check.ts @@ -0,0 +1,76 @@ +/** + * Environment Health Check - OAuth readiness diagnostics + */ + +import { getEnvironmentDiagnostics } from '../environment-diagnostics'; +import { ok, warn } from '../../utils/ui'; +import { HealthCheck, IHealthChecker, createSpinner } from './types'; + +const ora = createSpinner(); + +/** + * Check environment for OAuth readiness + * Helps diagnose Windows headless false positives + */ +export class EnvironmentChecker implements IHealthChecker { + name = 'Environment'; + + run(results: HealthCheck): void { + const spinner = ora('Checking environment').start(); + const diag = getEnvironmentDiagnostics(); + + // Determine overall environment health + let envStatus: 'OK' | 'WARN' = 'OK'; + let envMessage = 'Browser available'; + + // Check for potential issues + if (diag.detectedHeadless) { + if (diag.platform === 'win32' && diag.ttyStatus === 'undefined') { + // Windows false positive - this is actually a warning + envStatus = 'WARN'; + envMessage = 'Headless detected (may be false positive on Windows)'; + } else if (diag.sshSession) { + envMessage = 'SSH session (headless mode)'; + } else { + envMessage = 'Headless environment'; + } + } + + if (envStatus === 'WARN') { + spinner.warn(); + console.log(` ${warn('Environment'.padEnd(22))} ${envMessage}`); + } else { + spinner.succeed(); + console.log(` ${ok('Environment'.padEnd(22))} ${envMessage}`); + } + + // Show key environment details + console.log(` ${''.padEnd(24)} Platform: ${diag.platformName}`); + if (diag.sshSession) { + console.log(` ${''.padEnd(24)} SSH: Yes (${diag.sshReason})`); + } + if (diag.ttyStatus === 'undefined') { + console.log(` ${''.padEnd(24)} TTY: undefined [!]`); + } + console.log(` ${''.padEnd(24)} Browser: ${diag.browserReason}`); + + results.addCheck( + 'Environment', + envStatus === 'OK' ? 'success' : 'warning', + envMessage, + envStatus === 'WARN' ? 'If browser opens correctly, this warning can be ignored' : undefined, + { + status: envStatus, + info: envMessage, + } + ); + } +} + +/** + * Run environment check + */ +export function runEnvironmentCheck(results: HealthCheck): void { + const checker = new EnvironmentChecker(); + checker.run(results); +} diff --git a/src/management/checks/index.ts b/src/management/checks/index.ts new file mode 100644 index 00000000..1ea17e5f --- /dev/null +++ b/src/management/checks/index.ts @@ -0,0 +1,51 @@ +/** + * Health Check Registry - Central export for all checks + */ + +// Types and utilities +export { + HealthCheck, + HealthCheckDetails, + HealthCheckItem, + HealthIssue, + IHealthChecker, + Spinner, + createSpinner, +} from './types'; + +// System checks +export { ClaudeCliChecker, CcsDirectoryChecker, runSystemChecks } from './system-check'; + +// Environment checks +export { EnvironmentChecker, runEnvironmentCheck } from './env-check'; + +// Config checks +export { ConfigFilesChecker, ClaudeSettingsChecker, runConfigChecks } from './config-check'; + +// Profile checks +export { + ProfilesChecker, + InstancesChecker, + DelegationChecker, + runProfileChecks, +} from './profile-check'; + +// Symlink checks +export { + PermissionsChecker, + CcsSymlinksChecker, + SettingsSymlinksChecker, + runSymlinkChecks, +} from './symlink-check'; + +// CLIProxy checks +export { + CLIProxyBinaryChecker, + CLIProxyConfigChecker, + CLIProxyAuthChecker, + CLIProxyPortChecker, + runCLIProxyChecks, +} from './cliproxy-check'; + +// OAuth checks +export { OAuthPortsChecker, runOAuthChecks } from './oauth-check'; diff --git a/src/management/checks/oauth-check.ts b/src/management/checks/oauth-check.ts new file mode 100644 index 00000000..05ad54bf --- /dev/null +++ b/src/management/checks/oauth-check.ts @@ -0,0 +1,76 @@ +/** + * OAuth Port Health Checks - Pre-flight check for OAuth authentication + */ + +import { ok, warn, info } from '../../utils/ui'; +import { checkAuthCodePorts } from '../oauth-port-diagnostics'; +import { HealthCheck, IHealthChecker, createSpinner } from './types'; + +const ora = createSpinner(); + +/** + * Check OAuth callback ports availability + */ +export class OAuthPortsChecker implements IHealthChecker { + name = 'OAuth Ports'; + + async run(results: HealthCheck): Promise { + const spinner = ora('Checking OAuth callback ports').start(); + const portDiagnostics = await checkAuthCodePorts(); + + // Count issues + const conflicts = portDiagnostics.filter((d) => d.status === 'occupied'); + + if (conflicts.length === 0) { + spinner.succeed(); + console.log(` ${ok('OAuth Ports'.padEnd(22))} All callback ports available`); + results.addCheck('OAuth Ports', 'success', undefined, undefined, { + status: 'OK', + info: 'All callback ports available', + }); + } else { + spinner.warn(); + console.log(` ${warn('OAuth Ports'.padEnd(22))} ${conflicts.length} port conflict(s)`); + results.addCheck( + 'OAuth Ports', + 'warning', + `${conflicts.length} port conflict(s)`, + 'Close conflicting applications before OAuth', + { status: 'WARN', info: `${conflicts.length} conflict(s)` } + ); + } + + // Show individual port status + for (const diag of portDiagnostics) { + const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1); + const portStr = diag.port !== null ? `(${diag.port})` : ''; + + let statusIcon: string; + switch (diag.status) { + case 'free': + case 'cliproxy': + statusIcon = ok(`${providerName} ${portStr}`.padEnd(20)); + break; + case 'occupied': + statusIcon = warn(`${providerName} ${portStr}`.padEnd(20)); + break; + default: + statusIcon = info(`${providerName} ${portStr}`.padEnd(20)); + } + + console.log(` ${statusIcon} ${diag.message}`); + + if (diag.recommendation && diag.status === 'occupied') { + console.log(` ${''.padEnd(24)} Fix: ${diag.recommendation}`); + } + } + } +} + +/** + * Run OAuth port checks + */ +export async function runOAuthChecks(results: HealthCheck): Promise { + const checker = new OAuthPortsChecker(); + await checker.run(results); +} diff --git a/src/management/checks/profile-check.ts b/src/management/checks/profile-check.ts new file mode 100644 index 00000000..cc449042 --- /dev/null +++ b/src/management/checks/profile-check.ts @@ -0,0 +1,190 @@ +/** + * Profile and Delegation Health Checks + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ok, fail, warn, info } from '../../utils/ui'; +import { HealthCheck, IHealthChecker, createSpinner } from './types'; + +const ora = createSpinner(); + +/** + * Check profile configurations in config.json + */ +export class ProfilesChecker implements IHealthChecker { + name = 'Profiles'; + private readonly ccsDir: string; + + constructor() { + this.ccsDir = path.join(os.homedir(), '.ccs'); + } + + run(results: HealthCheck): void { + const spinner = ora('Checking profiles').start(); + const configPath = path.join(this.ccsDir, 'config.json'); + + if (!fs.existsSync(configPath)) { + spinner.info(); + console.log(` ${info('Profiles'.padEnd(22))} config.json not found`); + return; + } + + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + + if (!config.profiles || typeof config.profiles !== 'object') { + spinner.fail(); + console.log(` ${fail('Profiles'.padEnd(22))} Missing profiles object`); + results.addCheck( + 'Profiles', + 'error', + 'config.json missing profiles object', + 'Run: npm install -g @kaitranntt/ccs --force', + { status: 'ERROR', info: 'Missing profiles object' } + ); + return; + } + + const profileCount = Object.keys(config.profiles).length; + const profileNames = Object.keys(config.profiles).join(', '); + + spinner.succeed(); + console.log(` ${ok('Profiles'.padEnd(22))} ${profileCount} configured (${profileNames})`); + results.addCheck('Profiles', 'success', `${profileCount} profiles configured`, undefined, { + status: 'OK', + info: `${profileCount} configured (${profileNames.length > 30 ? profileNames.substring(0, 27) + '...' : profileNames})`, + }); + } catch (e) { + spinner.fail(); + console.log(` ${fail('Profiles'.padEnd(22))} ${(e as Error).message}`); + results.addCheck('Profiles', 'error', (e as Error).message, undefined, { + status: 'ERROR', + info: (e as Error).message, + }); + } + } +} + +/** + * Check instance directories (account-based profiles) + */ +export class InstancesChecker implements IHealthChecker { + name = 'Instances'; + private readonly ccsDir: string; + + constructor() { + this.ccsDir = path.join(os.homedir(), '.ccs'); + } + + run(results: HealthCheck): void { + const spinner = ora('Checking instances').start(); + const instancesDir = path.join(this.ccsDir, 'instances'); + + if (!fs.existsSync(instancesDir)) { + spinner.info(); + console.log(` ${info('Instances'.padEnd(22))} No account profiles`); + results.addCheck('Instances', 'success', 'No account profiles configured'); + return; + } + + const instances = fs.readdirSync(instancesDir).filter((name) => { + return fs.statSync(path.join(instancesDir, name)).isDirectory(); + }); + + if (instances.length === 0) { + spinner.info(); + console.log(` ${info('Instances'.padEnd(22))} No account profiles`); + results.addCheck('Instances', 'success', 'No account profiles'); + return; + } + + spinner.succeed(); + console.log(` ${ok('Instances'.padEnd(22))} ${instances.length} account profiles`); + results.addCheck('Instances', 'success', `${instances.length} account profiles`); + } +} + +/** + * Check delegation system (commands and ready profiles) + */ +export class DelegationChecker implements IHealthChecker { + name = 'Delegation'; + private readonly ccsDir: string; + + constructor() { + this.ccsDir = path.join(os.homedir(), '.ccs'); + } + + run(results: HealthCheck): void { + const spinner = ora('Checking delegation').start(); + + // Check if delegation commands exist in ~/.ccs/.claude/commands/ + const ccsClaudeCommandsDir = path.join(this.ccsDir, '.claude', 'commands'); + const hasCcsCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs.md')); + const hasContinueCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs', 'continue.md')); + + if (!hasCcsCommand || !hasContinueCommand) { + spinner.warn(); + console.log(` ${warn('Delegation'.padEnd(22))} Not installed`); + results.addCheck( + 'Delegation', + 'warning', + 'Delegation commands not found', + 'Install with: npm install -g @kaitranntt/ccs --force', + { status: 'WARN', info: 'Not installed' } + ); + return; + } + + // Check profile validity using DelegationValidator + const { DelegationValidator } = require('../../utils/delegation-validator'); + const readyProfiles: string[] = []; + + for (const profile of ['glm', 'kimi']) { + const validation = DelegationValidator.validate(profile); + if (validation.valid) { + readyProfiles.push(profile); + } + } + + if (readyProfiles.length === 0) { + spinner.warn(); + console.log(` ${warn('Delegation'.padEnd(22))} No profiles ready`); + results.addCheck( + 'Delegation', + 'warning', + 'Delegation installed but no profiles configured', + 'Configure profiles with valid API keys (not placeholders)', + { status: 'WARN', info: 'No profiles ready' } + ); + return; + } + + spinner.succeed(); + console.log( + ` ${ok('Delegation'.padEnd(22))} ${readyProfiles.length} profiles ready (${readyProfiles.join(', ')})` + ); + results.addCheck( + 'Delegation', + 'success', + `${readyProfiles.length} profile(s) ready: ${readyProfiles.join(', ')}`, + undefined, + { status: 'OK', info: `${readyProfiles.length} profiles ready` } + ); + } +} + +/** + * Run all profile checks + */ +export function runProfileChecks(results: HealthCheck): void { + const profilesChecker = new ProfilesChecker(); + const instancesChecker = new InstancesChecker(); + const delegationChecker = new DelegationChecker(); + + profilesChecker.run(results); + instancesChecker.run(results); + delegationChecker.run(results); +} diff --git a/src/management/checks/symlink-check.ts b/src/management/checks/symlink-check.ts new file mode 100644 index 00000000..0b943295 --- /dev/null +++ b/src/management/checks/symlink-check.ts @@ -0,0 +1,218 @@ +/** + * Symlink and Permission Health Checks + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ok, fail, warn } from '../../utils/ui'; +import { HealthCheck, IHealthChecker, createSpinner } from './types'; + +const ora = createSpinner(); +const homedir = os.homedir(); +const ccsDir = path.join(homedir, '.ccs'); +const claudeDir = path.join(homedir, '.claude'); + +/** + * Check file permissions on ~/.ccs/ + */ +export class PermissionsChecker implements IHealthChecker { + name = 'Permissions'; + + run(results: HealthCheck): void { + const spinner = ora('Checking permissions').start(); + const testFile = path.join(ccsDir, '.permission-test'); + + try { + fs.writeFileSync(testFile, 'test', 'utf8'); + fs.unlinkSync(testFile); + spinner.succeed(); + console.log(` ${ok('Permissions'.padEnd(22))} Write access verified`); + results.addCheck('Permissions', 'success', undefined, undefined, { + status: 'OK', + info: 'Write access verified', + }); + } catch (_e) { + spinner.fail(); + console.log(` ${fail('Permissions'.padEnd(22))} Cannot write to ~/.ccs/`); + results.addCheck( + 'Permissions', + 'error', + 'Cannot write to ~/.ccs/', + 'Fix: sudo chown -R $USER ~/.ccs ~/.claude && chmod 755 ~/.ccs ~/.claude', + { status: 'ERROR', info: 'Cannot write to ~/.ccs/' } + ); + } + } +} + +/** + * Check CCS symlinks to ~/.claude/ + */ +export class CcsSymlinksChecker implements IHealthChecker { + name = 'CCS Symlinks'; + + run(results: HealthCheck): void { + const spinner = ora('Checking CCS symlinks').start(); + + try { + const { ClaudeSymlinkManager } = require('../../utils/claude-symlink-manager'); + const manager = new ClaudeSymlinkManager(); + const health = manager.checkHealth(); + + if (health.healthy) { + const cnt = manager.ccsItems.length; + spinner.succeed(); + console.log(` ${ok('CCS Symlinks'.padEnd(22))} ${cnt}/${cnt} items linked`); + results.addCheck('CCS Symlinks', 'success', 'All CCS items properly symlinked', undefined, { + status: 'OK', + info: `${cnt}/${cnt} items synced`, + }); + } else { + spinner.warn(); + console.log(` ${warn('CCS Symlinks'.padEnd(22))} ${health.issues.length} issues found`); + results.addCheck('CCS Symlinks', 'warning', health.issues.join(', '), 'Run: ccs sync', { + status: 'WARN', + info: `${health.issues.length} issues`, + }); + } + } catch (e) { + spinner.warn(); + console.log(` ${warn('CCS Symlinks'.padEnd(22))} Could not check`); + results.addCheck( + 'CCS Symlinks', + 'warning', + 'Could not check CCS symlinks: ' + (e as Error).message, + 'Run: ccs sync', + { status: 'WARN', info: 'Could not check' } + ); + } + } +} + +/** Helper: Check if symlink points to expected target */ +function isValidSymlink(symlinkPath: string, expectedTarget: string): boolean { + if (!fs.existsSync(symlinkPath)) return false; + try { + const stats = fs.lstatSync(symlinkPath); + if (!stats.isSymbolicLink()) return false; + const target = fs.readlinkSync(symlinkPath); + const resolved = path.resolve(path.dirname(symlinkPath), target); + return resolved === expectedTarget; + } catch { + return false; + } +} + +/** + * Check settings.json symlinks for instances + */ +export class SettingsSymlinksChecker implements IHealthChecker { + name = 'Settings Symlinks'; + + run(results: HealthCheck): void { + const spinner = ora('Checking settings.json symlinks').start(); + const label = 'settings.json'; + const sharedSettings = path.join(ccsDir, 'shared', 'settings.json'); + const claudeSettings = path.join(claudeDir, 'settings.json'); + + try { + // Check shared settings symlink + if (!fs.existsSync(sharedSettings)) { + spinner.warn(); + console.log(` ${warn(label.padEnd(22))} Not found (shared)`); + results.addCheck( + 'Settings Symlinks', + 'warning', + 'Shared settings.json not found', + 'Run: ccs sync' + ); + return; + } + + const sharedStats = fs.lstatSync(sharedSettings); + if (!sharedStats.isSymbolicLink()) { + spinner.warn(); + console.log(` ${warn(label.padEnd(22))} Not a symlink (shared)`); + results.addCheck( + 'Settings Symlinks', + 'warning', + 'Shared settings.json is not a symlink', + 'Run: ccs sync' + ); + return; + } + + if (!isValidSymlink(sharedSettings, claudeSettings)) { + spinner.warn(); + console.log(` ${warn(label.padEnd(22))} Wrong target (shared)`); + results.addCheck( + 'Settings Symlinks', + 'warning', + 'Shared symlink points to wrong target', + 'Run: ccs sync' + ); + return; + } + + // Check instances + const instancesDir = path.join(ccsDir, 'instances'); + if (!fs.existsSync(instancesDir)) { + spinner.succeed(); + console.log(` ${ok(label.padEnd(22))} Shared symlink valid`); + results.addCheck('Settings Symlinks', 'success', 'Shared symlink valid', undefined, { + status: 'OK', + info: 'Shared symlink valid', + }); + return; + } + + const instances = fs + .readdirSync(instancesDir) + .filter((n) => fs.statSync(path.join(instancesDir, n)).isDirectory()); + + const broken = instances.filter((inst) => { + const instSettings = path.join(instancesDir, inst, 'settings.json'); + return !isValidSymlink(instSettings, sharedSettings); + }).length; + + if (broken > 0) { + spinner.warn(); + console.log(` ${warn(label.padEnd(22))} ${broken} broken instance(s)`); + results.addCheck( + 'Settings Symlinks', + 'warning', + `${broken} instance(s) have broken symlinks`, + 'Run: ccs sync', + { status: 'WARN', info: `${broken} broken instance(s)` } + ); + } else { + spinner.succeed(); + console.log(` ${ok(label.padEnd(22))} ${instances.length} instance(s) valid`); + results.addCheck('Settings Symlinks', 'success', 'All instance symlinks valid', undefined, { + status: 'OK', + info: `${instances.length} instance(s) valid`, + }); + } + } catch (err) { + spinner.warn(); + console.log(` ${warn(label.padEnd(22))} Check failed`); + results.addCheck( + 'Settings Symlinks', + 'warning', + `Failed to check: ${(err as Error).message}`, + 'Run: ccs sync', + { status: 'WARN', info: 'Check failed' } + ); + } + } +} + +/** + * Run all symlink checks + */ +export function runSymlinkChecks(results: HealthCheck): void { + new PermissionsChecker().run(results); + new CcsSymlinksChecker().run(results); + new SettingsSymlinksChecker().run(results); +} diff --git a/src/management/checks/system-check.ts b/src/management/checks/system-check.ts new file mode 100644 index 00000000..65b1b39e --- /dev/null +++ b/src/management/checks/system-check.ts @@ -0,0 +1,138 @@ +/** + * System Health Checks - Claude CLI and CCS Directory + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { spawn } from 'child_process'; +import { getClaudeCliInfo } from '../../utils/claude-detector'; +import { escapeShellArg } from '../../utils/shell-executor'; +import { ok, fail } from '../../utils/ui'; +import { HealthCheck, IHealthChecker, createSpinner } from './types'; + +const ora = createSpinner(); + +/** + * Check Claude CLI availability and version + */ +export class ClaudeCliChecker implements IHealthChecker { + name = 'Claude CLI'; + + async run(results: HealthCheck): Promise { + const spinner = ora('Checking Claude CLI').start(); + + const cliInfo = getClaudeCliInfo(); + + if (!cliInfo) { + spinner.fail(); + console.log(` ${fail('Claude CLI'.padEnd(22))} Not found in PATH`); + results.addCheck( + 'Claude CLI', + 'error', + 'Claude CLI not found in PATH', + 'Install from: https://docs.claude.com/en/docs/claude-code/installation', + { status: 'ERROR', info: 'Not installed' } + ); + return; + } + + const { path: claudeCli, needsShell } = cliInfo; + + // Try to execute claude --version + try { + const result = await new Promise((resolve, reject) => { + // When shell is needed (Windows .cmd/.bat files), concatenate into string + // to avoid DEP0190 warning about passing args with shell: true + const child = needsShell + ? spawn([claudeCli, '--version'].map(escapeShellArg).join(' '), { + stdio: 'pipe', + timeout: 5000, + shell: true, + }) + : spawn(claudeCli, ['--version'], { + stdio: 'pipe', + timeout: 5000, + }); + + let output = ''; + child.stdout?.on('data', (data: Buffer) => (output += data)); + child.stderr?.on('data', (data: Buffer) => (output += data)); + + child.on('close', (code: number | null) => { + if (code === 0) resolve(output); + else reject(new Error('Exit code ' + code)); + }); + + child.on('error', reject); + }); + + // Extract version from output + const versionMatch = result.match(/(\d+\.\d+\.\d+)/); + const version = versionMatch ? versionMatch[1] : 'unknown'; + + spinner.succeed(); + console.log(` ${ok('Claude CLI'.padEnd(22))} v${version} (${claudeCli})`); + results.addCheck('Claude CLI', 'success', `Found: ${claudeCli}`, undefined, { + status: 'OK', + info: `v${version} (${claudeCli})`, + }); + } catch (_err) { + spinner.fail(); + console.log(` ${fail('Claude CLI'.padEnd(22))} Not found or not working`); + results.addCheck( + 'Claude CLI', + 'error', + 'Claude CLI not found or not working', + 'Install from: https://docs.claude.com/en/docs/claude-code/installation', + { status: 'ERROR', info: 'Not installed' } + ); + } + } +} + +/** + * Check ~/.ccs/ directory exists + */ +export class CcsDirectoryChecker implements IHealthChecker { + name = 'CCS Directory'; + private readonly ccsDir: string; + + constructor() { + this.ccsDir = path.join(os.homedir(), '.ccs'); + } + + run(results: HealthCheck): void { + const spinner = ora('Checking ~/.ccs/ directory').start(); + + if (fs.existsSync(this.ccsDir)) { + spinner.succeed(); + console.log(` ${ok('CCS Directory'.padEnd(22))} ~/.ccs/`); + results.addCheck('CCS Directory', 'success', undefined, undefined, { + status: 'OK', + info: '~/.ccs/', + }); + } else { + spinner.fail(); + console.log(` ${fail('CCS Directory'.padEnd(22))} Not found`); + results.addCheck( + 'CCS Directory', + 'error', + '~/.ccs/ directory not found', + 'Run: npm install -g @kaitranntt/ccs --force', + { status: 'ERROR', info: 'Not found' } + ); + } + } +} + +/** + * Run all system checks + */ +export async function runSystemChecks(results: HealthCheck): Promise { + const cliChecker = new ClaudeCliChecker(); + const dirChecker = new CcsDirectoryChecker(); + + await cliChecker.run(results); + dirChecker.run(results); +} diff --git a/src/management/checks/types.ts b/src/management/checks/types.ts new file mode 100644 index 00000000..4485575a --- /dev/null +++ b/src/management/checks/types.ts @@ -0,0 +1,114 @@ +/** + * Health Check Types and Interfaces + */ + +/** + * Spinner interface for ora or fallback + */ +export interface Spinner { + start(): { + succeed(msg?: string): void; + fail(msg?: string): void; + warn(msg?: string): void; + info(msg?: string): void; + text: string; + }; +} + +/** + * Details for individual health check + */ +export interface HealthCheckDetails { + status: 'OK' | 'ERROR' | 'WARN'; + info: string; +} + +/** + * Individual health check item + */ +export interface HealthCheckItem { + name: string; + status: 'success' | 'error' | 'warning'; + message?: string; + fix?: string; +} + +/** + * Health issue (error or warning) + */ +export interface HealthIssue { + name: string; + message: string; + fix?: string; +} + +/** + * Health check results container + */ +export class HealthCheck { + public checks: HealthCheckItem[] = []; + public warnings: HealthIssue[] = []; + public errors: HealthIssue[] = []; + public details: Record = {}; + + addCheck( + name: string, + status: 'success' | 'error' | 'warning', + message = '', + fix: string | undefined = undefined, + details: HealthCheckDetails | undefined = undefined + ): void { + this.checks.push({ name, status, message, fix }); + + if (status === 'error') this.errors.push({ name, message, fix }); + if (status === 'warning') this.warnings.push({ name, message, fix }); + + // Store details for summary table + if (details) { + this.details[name] = details; + } + } + + hasErrors(): boolean { + return this.errors.length > 0; + } + + hasWarnings(): boolean { + return this.warnings.length > 0; + } + + isHealthy(): boolean { + return !this.hasErrors(); + } +} + +/** + * Base interface for all health checks + */ +export interface IHealthChecker { + name: string; + run(results: HealthCheck): Promise | void; +} + +/** + * Create ora spinner with fallback for environments where ora is unavailable + */ +export function createSpinner(): (text: string) => Spinner { + try { + const oraModule = require('ora'); + return oraModule.default || oraModule; + } catch (_e) { + // ora not available, create fallback spinner that uses console.log + return function (text: string): Spinner { + return { + start: () => ({ + succeed: (msg?: string) => console.log(msg || `[OK] ${text}`), + fail: (msg?: string) => console.log(msg || `[X] ${text}`), + warn: (msg?: string) => console.log(msg || `[!] ${text}`), + info: (msg?: string) => console.log(msg || `[i] ${text}`), + text: '', + }), + }; + }; + } +} diff --git a/src/management/doctor.ts b/src/management/doctor.ts index c26c7073..8667e405 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -1,134 +1,29 @@ /** - * CCS Health Check and Diagnostics + * CCS Health Check and Diagnostics - Main Orchestrator */ -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { spawn } from 'child_process'; -import { getClaudeCliInfo } from '../utils/claude-detector'; -import { escapeShellArg } from '../utils/shell-executor'; -import { initUI, header, box, table, color, ok, fail, warn, info } from '../utils/ui'; +import { initUI, header, box, table, color, ok, fail, warn } from '../utils/ui'; import packageJson from '../../package.json'; import { - isCLIProxyInstalled, - getCLIProxyPath, - getAllAuthStatus, - getConfigPath, - getInstalledCliproxyVersion, - CLIPROXY_DEFAULT_PORT, - configNeedsRegeneration, - regenerateConfig, - CLIPROXY_CONFIG_VERSION, -} from '../cliproxy'; -import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; -import { getEnvironmentDiagnostics } from './environment-diagnostics'; -import { checkAuthCodePorts } from './oauth-port-diagnostics'; -import { killProcessOnPort, getPlatformName } from '../utils/platform-commands'; - -// Make ora optional (might not be available during npm install postinstall) -// ora v9+ is an ES module, need to use .default for CommonJS -interface Spinner { - start(): { - succeed(msg?: string): void; - fail(msg?: string): void; - warn(msg?: string): void; - info(msg?: string): void; - text: string; - }; -} - -let ora: (text: string) => Spinner; -try { - const oraModule = require('ora'); - ora = oraModule.default || oraModule; -} catch (_e) { - // ora not available, create fallback spinner that uses console.log - ora = function (text: string): Spinner { - return { - start: () => ({ - succeed: (msg?: string) => console.log(msg || `[OK] ${text}`), - fail: (msg?: string) => console.log(msg || `[X] ${text}`), - warn: (msg?: string) => console.log(msg || `[!] ${text}`), - info: (msg?: string) => console.log(msg || `[i] ${text}`), - text: '', - }), - }; - }; -} - -interface HealthCheckDetails { - status: 'OK' | 'ERROR' | 'WARN'; - info: string; -} - -interface HealthCheckItem { - name: string; - status: 'success' | 'error' | 'warning'; - message?: string; - fix?: string; -} - -interface HealthIssue { - name: string; - message: string; - fix?: string; -} + HealthCheck, + runSystemChecks, + runEnvironmentCheck, + runConfigChecks, + runProfileChecks, + runSymlinkChecks, + runCLIProxyChecks, + runOAuthChecks, +} from './checks'; +import { runAutoRepair } from './repair'; /** - * Health check results - */ -class HealthCheck { - public checks: HealthCheckItem[] = []; - public warnings: HealthIssue[] = []; - public errors: HealthIssue[] = []; - public details: Record = {}; - - addCheck( - name: string, - status: 'success' | 'error' | 'warning', - message = '', - fix: string | undefined = undefined, - details: HealthCheckDetails | undefined = undefined - ): void { - this.checks.push({ name, status, message, fix }); - - if (status === 'error') this.errors.push({ name, message, fix }); - if (status === 'warning') this.warnings.push({ name, message, fix }); - - // Store details for summary table - if (details) { - this.details[name] = details; - } - } - - hasErrors(): boolean { - return this.errors.length > 0; - } - - hasWarnings(): boolean { - return this.warnings.length > 0; - } - - isHealthy(): boolean { - return !this.hasErrors(); - } -} - -/** - * Doctor Class + * Doctor Class - Orchestrates health checks */ class Doctor { - private readonly homedir: string; - private readonly ccsDir: string; - private readonly claudeDir: string; private readonly results: HealthCheck; private readonly ccsVersion: string; constructor() { - this.homedir = os.homedir(); - this.ccsDir = path.join(this.homedir, '.ccs'); - this.claudeDir = path.join(this.homedir, '.claude'); this.results = new HealthCheck(); this.ccsVersion = packageJson.version; } @@ -148,887 +43,43 @@ class Doctor { // Group 1: System console.log(header('SYSTEM')); - await this.checkClaudeCli(); - this.checkCcsDirectory(); + await runSystemChecks(this.results); console.log(''); - // Group 2: Environment (NEW - OAuth readiness diagnostics) + // Group 2: Environment (OAuth readiness diagnostics) console.log(header('ENVIRONMENT')); - this.checkEnvironment(); + runEnvironmentCheck(this.results); console.log(''); // Group 3: Configuration console.log(header('CONFIGURATION')); - this.checkConfigFiles(); - this.checkClaudeSettings(); + runConfigChecks(this.results); console.log(''); // Group 4: Profiles & Delegation console.log(header('PROFILES & DELEGATION')); - this.checkProfiles(); - this.checkInstances(); - this.checkDelegation(); + runProfileChecks(this.results); console.log(''); // Group 5: System Health console.log(header('SYSTEM HEALTH')); - this.checkPermissions(); - this.checkCcsSymlinks(); - this.checkSettingsSymlinks(); + runSymlinkChecks(this.results); console.log(''); // Group 6: CLIProxy (OAuth profiles) console.log(header('CLIPROXY (OAUTH PROFILES)')); - await this.checkCLIProxy(); + await runCLIProxyChecks(this.results); console.log(''); - // Group 7: OAuth Readiness (NEW - port availability) + // Group 7: OAuth Readiness (port availability) console.log(header('OAUTH READINESS')); - await this.checkOAuthPorts(); + await runOAuthChecks(this.results); console.log(''); this.showReport(); return this.results; } - /** - * Check 1: Claude CLI availability - */ - private async checkClaudeCli(): Promise { - const spinner = ora('Checking Claude CLI').start(); - - const cliInfo = getClaudeCliInfo(); - - if (!cliInfo) { - spinner.fail(); - console.log(` ${fail('Claude CLI'.padEnd(22))} Not found in PATH`); - this.results.addCheck( - 'Claude CLI', - 'error', - 'Claude CLI not found in PATH', - 'Install from: https://docs.claude.com/en/docs/claude-code/installation', - { status: 'ERROR', info: 'Not installed' } - ); - return; - } - - const { path: claudeCli, needsShell } = cliInfo; - - // Try to execute claude --version - try { - const result = await new Promise((resolve, reject) => { - // When shell is needed (Windows .cmd/.bat files), concatenate into string - // to avoid DEP0190 warning about passing args with shell: true - const child = needsShell - ? spawn([claudeCli, '--version'].map(escapeShellArg).join(' '), { - stdio: 'pipe', - timeout: 5000, - shell: true, - }) - : spawn(claudeCli, ['--version'], { - stdio: 'pipe', - timeout: 5000, - }); - - let output = ''; - child.stdout?.on('data', (data: Buffer) => (output += data)); - child.stderr?.on('data', (data: Buffer) => (output += data)); - - child.on('close', (code: number | null) => { - if (code === 0) resolve(output); - else reject(new Error('Exit code ' + code)); - }); - - child.on('error', reject); - }); - - // Extract version from output - const versionMatch = result.match(/(\d+\.\d+\.\d+)/); - const version = versionMatch ? versionMatch[1] : 'unknown'; - - spinner.succeed(); - console.log(` ${ok('Claude CLI'.padEnd(22))} v${version} (${claudeCli})`); - this.results.addCheck('Claude CLI', 'success', `Found: ${claudeCli}`, undefined, { - status: 'OK', - info: `v${version} (${claudeCli})`, - }); - } catch (_err) { - spinner.fail(); - console.log(` ${fail('Claude CLI'.padEnd(22))} Not found or not working`); - this.results.addCheck( - 'Claude CLI', - 'error', - 'Claude CLI not found or not working', - 'Install from: https://docs.claude.com/en/docs/claude-code/installation', - { status: 'ERROR', info: 'Not installed' } - ); - } - } - - /** - * Check 2: ~/.ccs/ directory - */ - private checkCcsDirectory(): void { - const spinner = ora('Checking ~/.ccs/ directory').start(); - - if (fs.existsSync(this.ccsDir)) { - spinner.succeed(); - console.log(` ${ok('CCS Directory'.padEnd(22))} ~/.ccs/`); - this.results.addCheck('CCS Directory', 'success', undefined, undefined, { - status: 'OK', - info: '~/.ccs/', - }); - } else { - spinner.fail(); - console.log(` ${fail('CCS Directory'.padEnd(22))} Not found`); - this.results.addCheck( - 'CCS Directory', - 'error', - '~/.ccs/ directory not found', - 'Run: npm install -g @kaitranntt/ccs --force', - { status: 'ERROR', info: 'Not found' } - ); - } - } - - /** - * Check 3: Config files - */ - private checkConfigFiles(): void { - const files = [ - { path: path.join(this.ccsDir, 'config.json'), name: 'config.json', key: 'config.json' }, - { - path: path.join(this.ccsDir, 'glm.settings.json'), - name: 'glm.settings.json', - key: 'GLM Settings', - profile: 'glm', - }, - { - path: path.join(this.ccsDir, 'kimi.settings.json'), - name: 'kimi.settings.json', - key: 'Kimi Settings', - profile: 'kimi', - }, - ]; - - const { DelegationValidator } = require('../utils/delegation-validator'); - - for (const file of files) { - const spinner = ora(`Checking ${file.name}`).start(); - - if (!fs.existsSync(file.path)) { - spinner.fail(); - console.log(` ${fail(file.name.padEnd(22))} Not found`); - this.results.addCheck( - file.name, - 'error', - `${file.name} not found`, - 'Run: npm install -g @kaitranntt/ccs --force', - { status: 'ERROR', info: 'Not found' } - ); - continue; - } - - // Validate JSON - try { - const content = fs.readFileSync(file.path, 'utf8'); - JSON.parse(content); - - // Extract useful info based on file type - let fileInfo = 'Valid'; - let status: 'OK' | 'WARN' = 'OK'; - - if (file.profile) { - // For settings files, check if API key is configured - const validation = DelegationValidator.validate(file.profile); - - if (validation.valid) { - fileInfo = 'Key configured'; - status = 'OK'; - } else if (validation.error && validation.error.includes('placeholder')) { - fileInfo = 'Placeholder key'; - status = 'WARN'; - } else { - fileInfo = 'Valid JSON'; - status = 'OK'; - } - } - - if (status === 'WARN') { - spinner.warn(); - console.log(` ${warn(file.name.padEnd(22))} ${fileInfo}`); - } else { - spinner.succeed(); - console.log(` ${ok(file.name.padEnd(22))} ${fileInfo}`); - } - - this.results.addCheck( - file.name, - status === 'OK' ? 'success' : 'warning', - undefined, - undefined, - { - status: status, - info: fileInfo, - } - ); - } catch (e) { - spinner.fail(); - console.log(` ${fail(file.name.padEnd(22))} Invalid JSON`); - this.results.addCheck( - file.name, - 'error', - `Invalid JSON: ${(e as Error).message}`, - `Backup and recreate: mv ${file.path} ${file.path}.backup && npm install -g @kaitranntt/ccs --force`, - { status: 'ERROR', info: 'Invalid JSON' } - ); - } - } - } - - /** - * Check 4: Claude settings - */ - private checkClaudeSettings(): void { - const spinner = ora('Checking ~/.claude/settings.json').start(); - const settingsPath = path.join(this.claudeDir, 'settings.json'); - const settingsName = '~/.claude/settings.json'; - - if (!fs.existsSync(settingsPath)) { - spinner.warn(); - console.log(` ${warn(settingsName.padEnd(22))} Not found`); - this.results.addCheck( - 'Claude Settings', - 'warning', - '~/.claude/settings.json not found', - 'Run: claude /login' - ); - return; - } - - // Validate JSON - try { - const content = fs.readFileSync(settingsPath, 'utf8'); - JSON.parse(content); - spinner.succeed(); - console.log(` ${ok(settingsName.padEnd(22))} Valid`); - this.results.addCheck('Claude Settings', 'success'); - } catch (e) { - spinner.warn(); - console.log(` ${warn(settingsName.padEnd(22))} Invalid JSON`); - this.results.addCheck( - 'Claude Settings', - 'warning', - `Invalid JSON: ${(e as Error).message}`, - 'Run: claude /login' - ); - } - } - - /** - * Check 5: Profile configurations - */ - private checkProfiles(): void { - const spinner = ora('Checking profiles').start(); - const configPath = path.join(this.ccsDir, 'config.json'); - - if (!fs.existsSync(configPath)) { - spinner.info(); - console.log(` ${info('Profiles'.padEnd(22))} config.json not found`); - return; - } - - try { - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - - if (!config.profiles || typeof config.profiles !== 'object') { - spinner.fail(); - console.log(` ${fail('Profiles'.padEnd(22))} Missing profiles object`); - this.results.addCheck( - 'Profiles', - 'error', - 'config.json missing profiles object', - 'Run: npm install -g @kaitranntt/ccs --force', - { status: 'ERROR', info: 'Missing profiles object' } - ); - return; - } - - const profileCount = Object.keys(config.profiles).length; - const profileNames = Object.keys(config.profiles).join(', '); - - spinner.succeed(); - console.log(` ${ok('Profiles'.padEnd(22))} ${profileCount} configured (${profileNames})`); - this.results.addCheck( - 'Profiles', - 'success', - `${profileCount} profiles configured`, - undefined, - { - status: 'OK', - info: `${profileCount} configured (${profileNames.length > 30 ? profileNames.substring(0, 27) + '...' : profileNames})`, - } - ); - } catch (e) { - spinner.fail(); - console.log(` ${fail('Profiles'.padEnd(22))} ${(e as Error).message}`); - this.results.addCheck('Profiles', 'error', (e as Error).message, undefined, { - status: 'ERROR', - info: (e as Error).message, - }); - } - } - - /** - * Check 6: Instance directories (account-based profiles) - */ - private checkInstances(): void { - const spinner = ora('Checking instances').start(); - const instancesDir = path.join(this.ccsDir, 'instances'); - - if (!fs.existsSync(instancesDir)) { - spinner.info(); - console.log(` ${info('Instances'.padEnd(22))} No account profiles`); - this.results.addCheck('Instances', 'success', 'No account profiles configured'); - return; - } - - const instances = fs.readdirSync(instancesDir).filter((name) => { - return fs.statSync(path.join(instancesDir, name)).isDirectory(); - }); - - if (instances.length === 0) { - spinner.info(); - console.log(` ${info('Instances'.padEnd(22))} No account profiles`); - this.results.addCheck('Instances', 'success', 'No account profiles'); - return; - } - - spinner.succeed(); - console.log(` ${ok('Instances'.padEnd(22))} ${instances.length} account profiles`); - this.results.addCheck('Instances', 'success', `${instances.length} account profiles`); - } - - /** - * Check 7: Delegation system - */ - private checkDelegation(): void { - const spinner = ora('Checking delegation').start(); - - // Check if delegation commands exist in ~/.ccs/.claude/commands/ - const ccsClaudeCommandsDir = path.join(this.ccsDir, '.claude', 'commands'); - const hasCcsCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs.md')); - const hasContinueCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs', 'continue.md')); - - if (!hasCcsCommand || !hasContinueCommand) { - spinner.warn(); - console.log(` ${warn('Delegation'.padEnd(22))} Not installed`); - this.results.addCheck( - 'Delegation', - 'warning', - 'Delegation commands not found', - 'Install with: npm install -g @kaitranntt/ccs --force', - { status: 'WARN', info: 'Not installed' } - ); - return; - } - - // Check profile validity using DelegationValidator - const { DelegationValidator } = require('../utils/delegation-validator'); - const readyProfiles: string[] = []; - - for (const profile of ['glm', 'kimi']) { - const validation = DelegationValidator.validate(profile); - if (validation.valid) { - readyProfiles.push(profile); - } - } - - if (readyProfiles.length === 0) { - spinner.warn(); - console.log(` ${warn('Delegation'.padEnd(22))} No profiles ready`); - this.results.addCheck( - 'Delegation', - 'warning', - 'Delegation installed but no profiles configured', - 'Configure profiles with valid API keys (not placeholders)', - { status: 'WARN', info: 'No profiles ready' } - ); - return; - } - - spinner.succeed(); - console.log( - ` ${ok('Delegation'.padEnd(22))} ${readyProfiles.length} profiles ready (${readyProfiles.join(', ')})` - ); - this.results.addCheck( - 'Delegation', - 'success', - `${readyProfiles.length} profile(s) ready: ${readyProfiles.join(', ')}`, - undefined, - { status: 'OK', info: `${readyProfiles.length} profiles ready` } - ); - } - - /** - * Check 8: File permissions - */ - private checkPermissions(): void { - const spinner = ora('Checking permissions').start(); - const testFile = path.join(this.ccsDir, '.permission-test'); - - try { - fs.writeFileSync(testFile, 'test', 'utf8'); - fs.unlinkSync(testFile); - spinner.succeed(); - console.log(` ${ok('Permissions'.padEnd(22))} Write access verified`); - this.results.addCheck('Permissions', 'success', undefined, undefined, { - status: 'OK', - info: 'Write access verified', - }); - } catch (_e) { - spinner.fail(); - console.log(` ${fail('Permissions'.padEnd(22))} Cannot write to ~/.ccs/`); - this.results.addCheck( - 'Permissions', - 'error', - 'Cannot write to ~/.ccs/', - 'Fix: sudo chown -R $USER ~/.ccs ~/.claude && chmod 755 ~/.ccs ~/.claude', - { status: 'ERROR', info: 'Cannot write to ~/.ccs/' } - ); - } - } - - /** - * Check 9: CCS symlinks to ~/.claude/ - */ - private checkCcsSymlinks(): void { - const spinner = ora('Checking CCS symlinks').start(); - - try { - const { ClaudeSymlinkManager } = require('../utils/claude-symlink-manager'); - const manager = new ClaudeSymlinkManager(); - const health = manager.checkHealth(); - - if (health.healthy) { - const itemCount = manager.ccsItems.length; - spinner.succeed(); - console.log(` ${ok('CCS Symlinks'.padEnd(22))} ${itemCount}/${itemCount} items linked`); - this.results.addCheck( - 'CCS Symlinks', - 'success', - 'All CCS items properly symlinked', - undefined, - { - status: 'OK', - info: `${itemCount}/${itemCount} items synced`, - } - ); - } else { - spinner.warn(); - console.log(` ${warn('CCS Symlinks'.padEnd(22))} ${health.issues.length} issues found`); - this.results.addCheck( - 'CCS Symlinks', - 'warning', - health.issues.join(', '), - 'Run: ccs sync', - { status: 'WARN', info: `${health.issues.length} issues` } - ); - } - } catch (e) { - spinner.warn(); - console.log(` ${warn('CCS Symlinks'.padEnd(22))} Could not check`); - this.results.addCheck( - 'CCS Symlinks', - 'warning', - 'Could not check CCS symlinks: ' + (e as Error).message, - 'Run: ccs sync', - { status: 'WARN', info: 'Could not check' } - ); - } - } - - /** - * Check 10: settings.json symlinks - */ - private checkSettingsSymlinks(): void { - const spinner = ora('Checking settings.json symlinks').start(); - const settingsLabel = 'settings.json'; - - try { - const sharedDir = path.join(this.homedir, '.ccs', 'shared'); - const sharedSettings = path.join(sharedDir, 'settings.json'); - const claudeSettings = path.join(this.claudeDir, 'settings.json'); - - // Check shared settings exists and points to ~/.claude/ - if (!fs.existsSync(sharedSettings)) { - spinner.warn(); - console.log(` ${warn(settingsLabel.padEnd(22))} Not found (shared)`); - this.results.addCheck( - 'Settings Symlinks', - 'warning', - 'Shared settings.json not found', - 'Run: ccs sync' - ); - return; - } - - const sharedStats = fs.lstatSync(sharedSettings); - if (!sharedStats.isSymbolicLink()) { - spinner.warn(); - console.log(` ${warn(settingsLabel.padEnd(22))} Not a symlink (shared)`); - this.results.addCheck( - 'Settings Symlinks', - 'warning', - 'Shared settings.json is not a symlink', - 'Run: ccs sync' - ); - return; - } - - const sharedTarget = fs.readlinkSync(sharedSettings); - const resolvedShared = path.resolve(path.dirname(sharedSettings), sharedTarget); - - if (resolvedShared !== claudeSettings) { - spinner.warn(); - console.log(` ${warn(settingsLabel.padEnd(22))} Wrong target (shared)`); - this.results.addCheck( - 'Settings Symlinks', - 'warning', - `Points to ${resolvedShared} instead of ${claudeSettings}`, - 'Run: ccs sync' - ); - return; - } - - // Check each instance - const instancesDir = path.join(this.ccsDir, 'instances'); - if (!fs.existsSync(instancesDir)) { - spinner.succeed(); - console.log(` ${ok(settingsLabel.padEnd(22))} Shared symlink valid`); - this.results.addCheck('Settings Symlinks', 'success', 'Shared symlink valid', undefined, { - status: 'OK', - info: 'Shared symlink valid', - }); - return; - } - - const instances = fs.readdirSync(instancesDir).filter((name) => { - return fs.statSync(path.join(instancesDir, name)).isDirectory(); - }); - - let broken = 0; - for (const instance of instances) { - const instancePath = path.join(instancesDir, instance); - const instanceSettings = path.join(instancePath, 'settings.json'); - - if (!fs.existsSync(instanceSettings)) { - broken++; - continue; - } - - try { - const stats = fs.lstatSync(instanceSettings); - if (!stats.isSymbolicLink()) { - broken++; - continue; - } - - const target = fs.readlinkSync(instanceSettings); - const resolved = path.resolve(path.dirname(instanceSettings), target); - - if (resolved !== sharedSettings) { - broken++; - } - } catch (_err) { - broken++; - } - } - - if (broken > 0) { - spinner.warn(); - console.log(` ${warn(settingsLabel.padEnd(22))} ${broken} broken instance(s)`); - this.results.addCheck( - 'Settings Symlinks', - 'warning', - `${broken} instance(s) have broken symlinks`, - 'Run: ccs sync', - { status: 'WARN', info: `${broken} broken instance(s)` } - ); - } else { - spinner.succeed(); - console.log(` ${ok(settingsLabel.padEnd(22))} ${instances.length} instance(s) valid`); - this.results.addCheck( - 'Settings Symlinks', - 'success', - 'All instance symlinks valid', - undefined, - { - status: 'OK', - info: `${instances.length} instance(s) valid`, - } - ); - } - } catch (err) { - spinner.warn(); - console.log(` ${warn(settingsLabel.padEnd(22))} Check failed`); - this.results.addCheck( - 'Settings Symlinks', - 'warning', - `Failed to check: ${(err as Error).message}`, - 'Run: ccs sync', - { status: 'WARN', info: 'Check failed' } - ); - } - } - - /** - * Check 10.1: Environment detection (OAuth readiness) - * Helps diagnose Windows headless false positives - */ - private checkEnvironment(): void { - const spinner = ora('Checking environment').start(); - const diag = getEnvironmentDiagnostics(); - - // Determine overall environment health - let envStatus: 'OK' | 'WARN' = 'OK'; - let envMessage = 'Browser available'; - - // Check for potential issues - if (diag.detectedHeadless) { - if (diag.platform === 'win32' && diag.ttyStatus === 'undefined') { - // Windows false positive - this is actually a warning - envStatus = 'WARN'; - envMessage = 'Headless detected (may be false positive on Windows)'; - } else if (diag.sshSession) { - envMessage = 'SSH session (headless mode)'; - } else { - envMessage = 'Headless environment'; - } - } - - if (envStatus === 'WARN') { - spinner.warn(); - console.log(` ${warn('Environment'.padEnd(22))} ${envMessage}`); - } else { - spinner.succeed(); - console.log(` ${ok('Environment'.padEnd(22))} ${envMessage}`); - } - - // Show key environment details - console.log(` ${''.padEnd(24)} Platform: ${diag.platformName}`); - if (diag.sshSession) { - console.log(` ${''.padEnd(24)} SSH: Yes (${diag.sshReason})`); - } - if (diag.ttyStatus === 'undefined') { - console.log(` ${''.padEnd(24)} TTY: undefined [!]`); - } - console.log(` ${''.padEnd(24)} Browser: ${diag.browserReason}`); - - this.results.addCheck( - 'Environment', - envStatus === 'OK' ? 'success' : 'warning', - envMessage, - envStatus === 'WARN' ? 'If browser opens correctly, this warning can be ignored' : undefined, - { - status: envStatus, - info: envMessage, - } - ); - } - - /** - * Check 10.2: OAuth callback ports availability - * Pre-flight check for OAuth authentication - */ - private async checkOAuthPorts(): Promise { - const spinner = ora('Checking OAuth callback ports').start(); - const portDiagnostics = await checkAuthCodePorts(); - - // Count issues - const conflicts = portDiagnostics.filter((d) => d.status === 'occupied'); - - if (conflicts.length === 0) { - spinner.succeed(); - console.log(` ${ok('OAuth Ports'.padEnd(22))} All callback ports available`); - this.results.addCheck('OAuth Ports', 'success', undefined, undefined, { - status: 'OK', - info: 'All callback ports available', - }); - } else { - spinner.warn(); - console.log(` ${warn('OAuth Ports'.padEnd(22))} ${conflicts.length} port conflict(s)`); - this.results.addCheck( - 'OAuth Ports', - 'warning', - `${conflicts.length} port conflict(s)`, - 'Close conflicting applications before OAuth', - { status: 'WARN', info: `${conflicts.length} conflict(s)` } - ); - } - - // Show individual port status - for (const diag of portDiagnostics) { - const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1); - const portStr = diag.port !== null ? `(${diag.port})` : ''; - - let statusIcon: string; - switch (diag.status) { - case 'free': - case 'cliproxy': - statusIcon = ok(`${providerName} ${portStr}`.padEnd(20)); - break; - case 'occupied': - statusIcon = warn(`${providerName} ${portStr}`.padEnd(20)); - break; - default: - statusIcon = info(`${providerName} ${portStr}`.padEnd(20)); - } - - console.log(` ${statusIcon} ${diag.message}`); - - if (diag.recommendation && diag.status === 'occupied') { - console.log(` ${''.padEnd(24)} Fix: ${diag.recommendation}`); - } - } - } - - /** - * Check 11: CLIProxy health (OAuth profiles: gemini, codex, agy, qwen) - */ - private async checkCLIProxy(): Promise { - // 1. Binary installed? - const binarySpinner = ora('Checking CLIProxy binary').start(); - - if (isCLIProxyInstalled()) { - const binaryPath = getCLIProxyPath(); - const installedVersion = getInstalledCliproxyVersion(); - binarySpinner.succeed(); - console.log(` ${ok('CLIProxy Binary'.padEnd(22))} v${installedVersion}`); - this.results.addCheck('CLIProxy Binary', 'success', undefined, undefined, { - status: 'OK', - info: `v${installedVersion} (${binaryPath})`, - }); - } else { - binarySpinner.info(); - console.log( - ` ${info('CLIProxy Binary'.padEnd(22))} Not installed (downloads on first use)` - ); - this.results.addCheck( - 'CLIProxy Binary', - 'success', - 'Not installed yet', - 'Run: ccs gemini "test" (will download automatically)', - { status: 'OK', info: 'Not installed (downloads on first use)' } - ); - } - - // 2. Config file exists and is up-to-date? - const configSpinner = ora('Checking CLIProxy config').start(); - const configPath = getConfigPath(); - - if (fs.existsSync(configPath)) { - // Check if config needs regeneration (version mismatch or missing features) - if (configNeedsRegeneration()) { - configSpinner.warn(); - console.log( - ` ${warn('CLIProxy Config'.padEnd(22))} Outdated config, upgrading to v${CLIPROXY_CONFIG_VERSION}...` - ); - - // Regenerate config with new features - regenerateConfig(); - - console.log( - ` ${ok('CLIProxy Config'.padEnd(22))} Upgraded to v${CLIPROXY_CONFIG_VERSION}` - ); - this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, { - status: 'OK', - info: `Upgraded to v${CLIPROXY_CONFIG_VERSION}`, - }); - } else { - configSpinner.succeed(); - console.log( - ` ${ok('CLIProxy Config'.padEnd(22))} cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})` - ); - this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, { - status: 'OK', - info: `cliproxy/config.yaml (v${CLIPROXY_CONFIG_VERSION})`, - }); - } - } else { - configSpinner.info(); - console.log(` ${info('CLIProxy Config'.padEnd(22))} Not created (on first use)`); - this.results.addCheck('CLIProxy Config', 'success', 'Not created yet', undefined, { - status: 'OK', - info: 'Generated on first use', - }); - } - - // 3. OAuth status for each provider - const authStatuses = getAllAuthStatus(); - for (const status of authStatuses) { - const authSpinner = ora(`Checking ${status.provider} auth`).start(); - const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1); - - if (status.authenticated) { - const lastAuth = status.lastAuth ? ` (${status.lastAuth.toLocaleDateString()})` : ''; - authSpinner.succeed(); - console.log(` ${ok(`${providerName} Auth`.padEnd(22))} Authenticated${lastAuth}`); - this.results.addCheck(`${providerName} Auth`, 'success', undefined, undefined, { - status: 'OK', - info: `Authenticated${lastAuth}`, - }); - } else { - authSpinner.info(); - console.log(` ${info(`${providerName} Auth`.padEnd(22))} Not authenticated`); - this.results.addCheck( - `${providerName} Auth`, - 'success', - 'Not authenticated', - `Run: ccs ${status.provider} --auth`, - { status: 'OK', info: 'Not authenticated (run ccs to login)' } - ); - } - } - - // 4. Port status (check if CLIProxy or other process) - const portSpinner = ora(`Checking port ${CLIPROXY_DEFAULT_PORT}`).start(); - const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT); - - if (!portProcess) { - // Port is free - portSpinner.info(); - console.log( - ` ${info('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} free (proxy not running)` - ); - this.results.addCheck('CLIProxy Port', 'success', undefined, undefined, { - status: 'OK', - info: `Port ${CLIPROXY_DEFAULT_PORT} free`, - }); - } else if (isCLIProxyProcess(portProcess)) { - // CLIProxy is running (expected) - portSpinner.succeed(); - console.log(` ${ok('CLIProxy Port'.padEnd(22))} CLIProxy running (PID ${portProcess.pid})`); - this.results.addCheck('CLIProxy Port', 'success', undefined, undefined, { - status: 'OK', - info: `CLIProxy running (PID ${portProcess.pid})`, - }); - } else { - // Port conflict - different process - portSpinner.warn(); - console.log( - ` ${warn('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} occupied by ${portProcess.processName}` - ); - this.results.addCheck( - 'CLIProxy Port', - 'warning', - `Port ${CLIPROXY_DEFAULT_PORT} occupied by ${portProcess.processName} (PID ${portProcess.pid})`, - `Kill process: kill ${portProcess.pid} (or restart conflicting application)`, - { status: 'WARN', info: `Occupied by ${portProcess.processName}` } - ); - } - } - /** * Show health check report */ @@ -1119,134 +170,9 @@ class Doctor { /** * Fix detected issues (--fix flag) - * Fixes: - * 1. Zombie CLIProxy processes blocking ports - * 2. Outdated CLIProxy config files - * 3. Shared symlinks broken by Claude CLI's atomic writes - * 4. OAuth callback ports blocked by CLIProxy */ async fixIssues(): Promise { - console.log(''); - console.log(header('AUTO-FIX MODE')); - console.log(''); - console.log(info(`Platform: ${getPlatformName()}`)); - console.log(''); - - let fixed = 0; - - // Fix 1: Kill zombie CLIProxy processes - const zombieSpinner = ora('Checking for zombie CLIProxy processes').start(); - try { - // Check main CLIProxy port - const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT); - if (portProcess && isCLIProxyProcess(portProcess)) { - zombieSpinner.text = 'Killing zombie CLIProxy process...'; - const killed = killProcessOnPort(CLIPROXY_DEFAULT_PORT, true); - if (killed) { - zombieSpinner.succeed( - `${ok('Fixed')} Killed zombie CLIProxy on port ${CLIPROXY_DEFAULT_PORT}` - ); - fixed++; - } else { - zombieSpinner.warn(`${warn('Partial')} CLIProxy detected but could not kill`); - } - } else if (portProcess) { - zombieSpinner.info( - `${info('Info')} Port ${CLIPROXY_DEFAULT_PORT} used by ${portProcess.processName} (not CLIProxy)` - ); - } else { - zombieSpinner.succeed(`${ok('OK')} No zombie CLIProxy processes found`); - } - } catch (err) { - zombieSpinner.fail(`${fail('Error')} Could not check processes: ${(err as Error).message}`); - } - - // Fix 2: Kill CLIProxy processes on OAuth callback ports - const oauthPorts = [8085, 1455, 51121]; // Gemini, Codex, Agy - for (const port of oauthPorts) { - const oauthSpinner = ora(`Checking OAuth port ${port}`).start(); - try { - const portProcess = await getPortProcess(port); - if (portProcess && isCLIProxyProcess(portProcess)) { - oauthSpinner.text = `Freeing OAuth port ${port}...`; - const killed = killProcessOnPort(port, true); - if (killed) { - oauthSpinner.succeed(`${ok('Fixed')} Freed OAuth port ${port}`); - fixed++; - } else { - oauthSpinner.warn(`${warn('Partial')} CLIProxy on port ${port} but could not kill`); - } - } else if (portProcess) { - oauthSpinner.info( - `${info('Info')} Port ${port} used by ${portProcess.processName} - please close manually` - ); - } else { - oauthSpinner.succeed(`${ok('OK')} OAuth port ${port} is free`); - } - } catch (_err) { - oauthSpinner.succeed(`${ok('OK')} OAuth port ${port} check passed`); - } - } - - // Fix 3: Regenerate outdated CLIProxy config - const configSpinner = ora('Checking CLIProxy config version').start(); - try { - if (configNeedsRegeneration()) { - configSpinner.text = 'Upgrading CLIProxy config...'; - regenerateConfig(); - configSpinner.succeed( - `${ok('Fixed')} Upgraded CLIProxy config to v${CLIPROXY_CONFIG_VERSION}` - ); - fixed++; - } else { - configSpinner.succeed(`${ok('OK')} CLIProxy config is up to date`); - } - } catch (err) { - configSpinner.fail(`${fail('Error')} Could not upgrade config: ${(err as Error).message}`); - } - - // Fix 4: Fix shared symlinks (settings.json broken by Claude CLI toggle thinking, etc.) - const symlinkSpinner = ora('Checking shared settings.json symlink').start(); - const sharedSettings = path.join(this.homedir, '.ccs', 'shared', 'settings.json'); - try { - if (fs.existsSync(sharedSettings)) { - const stats = fs.lstatSync(sharedSettings); - if (!stats.isSymbolicLink()) { - symlinkSpinner.text = 'Restoring shared settings.json symlink...'; - const SharedManagerModule = await import('./shared-manager'); - const SharedManager = SharedManagerModule.default; - const sharedManager = new SharedManager(); - sharedManager.ensureSharedDirectories(); - symlinkSpinner.succeed(`${ok('Fixed')} Restored shared settings.json symlink`); - fixed++; - } else { - symlinkSpinner.succeed(`${ok('OK')} Shared settings.json symlink is valid`); - } - } else { - symlinkSpinner.succeed(`${ok('OK')} Shared settings.json not yet created`); - } - } catch (err) { - symlinkSpinner.fail(`${fail('Error')} Could not fix symlink: ${(err as Error).message}`); - } - - // Summary - console.log(''); - if (fixed > 0) { - console.log(ok(`Auto-fix complete: ${fixed} issue(s) resolved`)); - console.log(''); - console.log(info('Try your command again. If issues persist, run:')); - console.log(` ${color('ccs doctor', 'command')} - for full diagnostics`); - } else { - console.log(ok('No issues found that needed fixing')); - console.log(''); - console.log(info('If you still have issues:')); - console.log(` 1. Run ${color('ccs doctor', 'command')} for diagnostics`); - console.log( - ` 2. Try ${color('ccs --auth --verbose', 'command')} for detailed logs` - ); - console.log(` 3. Restart your terminal/computer`); - } - console.log(''); + await runAutoRepair(); } /** diff --git a/src/management/repair/auto-repair.ts b/src/management/repair/auto-repair.ts new file mode 100644 index 00000000..882bb3b1 --- /dev/null +++ b/src/management/repair/auto-repair.ts @@ -0,0 +1,153 @@ +/** + * Auto-Repair Module - Fix common issues automatically + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ok, warn, fail, info, header, color } from '../../utils/ui'; +import { + CLIPROXY_DEFAULT_PORT, + configNeedsRegeneration, + regenerateConfig, + CLIPROXY_CONFIG_VERSION, +} from '../../cliproxy'; +import { getPortProcess, isCLIProxyProcess } from '../../utils/port-utils'; +import { killProcessOnPort, getPlatformName } from '../../utils/platform-commands'; +import { createSpinner } from '../checks/types'; + +const ora = createSpinner(); + +/** + * Fix detected issues automatically + * Fixes: + * 1. Zombie CLIProxy processes blocking ports + * 2. Outdated CLIProxy config files + * 3. Shared symlinks broken by Claude CLI's atomic writes + * 4. OAuth callback ports blocked by CLIProxy + */ +export async function runAutoRepair(): Promise { + const homedir = os.homedir(); + + console.log(''); + console.log(header('AUTO-FIX MODE')); + console.log(''); + console.log(info(`Platform: ${getPlatformName()}`)); + console.log(''); + + let fixed = 0; + + // Fix 1: Kill zombie CLIProxy processes + const zombieSpinner = ora('Checking for zombie CLIProxy processes').start(); + try { + // Check main CLIProxy port + const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT); + if (portProcess && isCLIProxyProcess(portProcess)) { + zombieSpinner.text = 'Killing zombie CLIProxy process...'; + const killed = killProcessOnPort(CLIPROXY_DEFAULT_PORT, true); + if (killed) { + zombieSpinner.succeed( + `${ok('Fixed')} Killed zombie CLIProxy on port ${CLIPROXY_DEFAULT_PORT}` + ); + fixed++; + } else { + zombieSpinner.warn(`${warn('Partial')} CLIProxy detected but could not kill`); + } + } else if (portProcess) { + zombieSpinner.info( + `${info('Info')} Port ${CLIPROXY_DEFAULT_PORT} used by ${portProcess.processName} (not CLIProxy)` + ); + } else { + zombieSpinner.succeed(`${ok('OK')} No zombie CLIProxy processes found`); + } + } catch (err) { + zombieSpinner.fail(`${fail('Error')} Could not check processes: ${(err as Error).message}`); + } + + // Fix 2: Kill CLIProxy processes on OAuth callback ports + const oauthPorts = [8085, 1455, 51121]; // Gemini, Codex, Agy + for (const port of oauthPorts) { + const oauthSpinner = ora(`Checking OAuth port ${port}`).start(); + try { + const portProcess = await getPortProcess(port); + if (portProcess && isCLIProxyProcess(portProcess)) { + oauthSpinner.text = `Freeing OAuth port ${port}...`; + const killed = killProcessOnPort(port, true); + if (killed) { + oauthSpinner.succeed(`${ok('Fixed')} Freed OAuth port ${port}`); + fixed++; + } else { + oauthSpinner.warn(`${warn('Partial')} CLIProxy on port ${port} but could not kill`); + } + } else if (portProcess) { + oauthSpinner.info( + `${info('Info')} Port ${port} used by ${portProcess.processName} - please close manually` + ); + } else { + oauthSpinner.succeed(`${ok('OK')} OAuth port ${port} is free`); + } + } catch (_err) { + oauthSpinner.succeed(`${ok('OK')} OAuth port ${port} check passed`); + } + } + + // Fix 3: Regenerate outdated CLIProxy config + const configSpinner = ora('Checking CLIProxy config version').start(); + try { + if (configNeedsRegeneration()) { + configSpinner.text = 'Upgrading CLIProxy config...'; + regenerateConfig(); + configSpinner.succeed( + `${ok('Fixed')} Upgraded CLIProxy config to v${CLIPROXY_CONFIG_VERSION}` + ); + fixed++; + } else { + configSpinner.succeed(`${ok('OK')} CLIProxy config is up to date`); + } + } catch (err) { + configSpinner.fail(`${fail('Error')} Could not upgrade config: ${(err as Error).message}`); + } + + // Fix 4: Fix shared symlinks (settings.json broken by Claude CLI toggle thinking, etc.) + const symlinkSpinner = ora('Checking shared settings.json symlink').start(); + const sharedSettings = path.join(homedir, '.ccs', 'shared', 'settings.json'); + try { + if (fs.existsSync(sharedSettings)) { + const stats = fs.lstatSync(sharedSettings); + if (!stats.isSymbolicLink()) { + symlinkSpinner.text = 'Restoring shared settings.json symlink...'; + const SharedManagerModule = await import('../shared-manager'); + const SharedManager = SharedManagerModule.default; + const sharedManager = new SharedManager(); + sharedManager.ensureSharedDirectories(); + symlinkSpinner.succeed(`${ok('Fixed')} Restored shared settings.json symlink`); + fixed++; + } else { + symlinkSpinner.succeed(`${ok('OK')} Shared settings.json symlink is valid`); + } + } else { + symlinkSpinner.succeed(`${ok('OK')} Shared settings.json not yet created`); + } + } catch (err) { + symlinkSpinner.fail(`${fail('Error')} Could not fix symlink: ${(err as Error).message}`); + } + + // Summary + console.log(''); + if (fixed > 0) { + console.log(ok(`Auto-fix complete: ${fixed} issue(s) resolved`)); + console.log(''); + console.log(info('Try your command again. If issues persist, run:')); + console.log(` ${color('ccs doctor', 'command')} - for full diagnostics`); + } else { + console.log(ok('No issues found that needed fixing')); + console.log(''); + console.log(info('If you still have issues:')); + console.log(` 1. Run ${color('ccs doctor', 'command')} for diagnostics`); + console.log( + ` 2. Try ${color('ccs --auth --verbose', 'command')} for detailed logs` + ); + console.log(` 3. Restart your terminal/computer`); + } + console.log(''); +} diff --git a/src/management/repair/index.ts b/src/management/repair/index.ts new file mode 100644 index 00000000..d495a41d --- /dev/null +++ b/src/management/repair/index.ts @@ -0,0 +1,5 @@ +/** + * Repair Module Registry + */ + +export { runAutoRepair } from './auto-repair';