refactor(management): modularize doctor health checks

- 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)
This commit is contained in:
kaitranntt
2025-12-19 11:47:55 -05:00
parent 22dbfd91c5
commit 0eb2030dc2
12 changed files with 1411 additions and 1097 deletions
+199
View File
@@ -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 <profile> to login)' }
);
}
}
}
}
/**
* Check CLIProxy port status
*/
export class CLIProxyPortChecker implements IHealthChecker {
name = 'CLIProxy Port';
async run(results: HealthCheck): Promise<void> {
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<void> {
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);
}
+168
View File
@@ -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);
}
+76
View File
@@ -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);
}
+51
View File
@@ -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';
+76
View File
@@ -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<void> {
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<void> {
const checker = new OAuthPortsChecker();
await checker.run(results);
}
+190
View File
@@ -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);
}
+218
View File
@@ -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);
}
+138
View File
@@ -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<void> {
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<string>((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<void> {
const cliChecker = new ClaudeCliChecker();
const dirChecker = new CcsDirectoryChecker();
await cliChecker.run(results);
dirChecker.run(results);
}
+114
View File
@@ -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<string, HealthCheckDetails> = {};
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> | 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: '',
}),
};
};
}
}
+23 -1097
View File
File diff suppressed because it is too large Load Diff
+153
View File
@@ -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<void> {
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 <provider> --auth --verbose', 'command')} for detailed logs`
);
console.log(` 3. Restart your terminal/computer`);
}
console.log('');
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Repair Module Registry
*/
export { runAutoRepair } from './auto-repair';