mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
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:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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('');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Repair Module Registry
|
||||
*/
|
||||
|
||||
export { runAutoRepair } from './auto-repair';
|
||||
Reference in New Issue
Block a user