From c1f30ae80076e12d1ed536d992c3dc8fad8248ca Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 19 Dec 2025 12:35:13 -0500 Subject: [PATCH] refactor(web-server): modularize health-service into health/ directory - extract system, environment, config, profile, symlink checks - extract cliproxy, oauth, websearch checks and types - slim health-service.ts from 901 to 146 lines (84% reduction) - add barrel export at health/index.ts --- src/web-server/health-service.ts | 808 +------------------- src/web-server/health/cliproxy-checks.ts | 135 ++++ src/web-server/health/config-checks.ts | 155 ++++ src/web-server/health/environment-checks.ts | 39 + src/web-server/health/index.ts | 35 + src/web-server/health/oauth-checks.ts | 33 + src/web-server/health/profile-checks.ts | 140 ++++ src/web-server/health/symlink-checks.ts | 160 ++++ src/web-server/health/system-checks.ts | 106 +++ src/web-server/health/types.ts | 36 + src/web-server/health/websearch-checks.ts | 53 ++ 11 files changed, 926 insertions(+), 774 deletions(-) create mode 100644 src/web-server/health/cliproxy-checks.ts create mode 100644 src/web-server/health/config-checks.ts create mode 100644 src/web-server/health/environment-checks.ts create mode 100644 src/web-server/health/index.ts create mode 100644 src/web-server/health/oauth-checks.ts create mode 100644 src/web-server/health/profile-checks.ts create mode 100644 src/web-server/health/symlink-checks.ts create mode 100644 src/web-server/health/system-checks.ts create mode 100644 src/web-server/health/types.ts create mode 100644 src/web-server/health/websearch-checks.ts diff --git a/src/web-server/health-service.ts b/src/web-server/health-service.ts index 8403dcaa..25696d85 100644 --- a/src/web-server/health-service.ts +++ b/src/web-server/health-service.ts @@ -1,60 +1,43 @@ /** - * Health Check Service (Phase 06) + * Health Check Service * - * Runs comprehensive health checks for CCS dashboard matching `ccs doctor` output. + * Orchestrates comprehensive health checks for CCS dashboard matching `ccs doctor` output. * Groups: System, Environment, Configuration, Profiles & Delegation, System Health, CLIProxy, OAuth Readiness */ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { execSync } from 'child_process'; import { getCcsDir, getConfigPath } from '../utils/config-manager'; -import { - isCLIProxyInstalled, - getInstalledCliproxyVersion, - getCLIProxyPath, - getConfigPath as getCliproxyConfigPath, - getAllAuthStatus, - CLIPROXY_DEFAULT_PORT, -} from '../cliproxy'; -import { getClaudeCliInfo } from '../utils/claude-detector'; -import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; import packageJson from '../../package.json'; -import { getEnvironmentDiagnostics } from '../management/environment-diagnostics'; -import { checkAuthCodePorts } from '../management/oauth-port-diagnostics'; -import { getWebSearchCliProviders, hasAnyWebSearchCli } from '../utils/websearch-manager'; -export interface HealthCheck { - id: string; - name: string; - status: 'ok' | 'warning' | 'error' | 'info'; - message: string; - details?: string; - fix?: string; - fixable?: boolean; -} +// Import all check functions from modular components +import { + type HealthCheck, + type HealthGroup, + type HealthReport, + checkClaudeCli, + checkCcsDirectory, + checkPermissions, + checkCcsSymlinks, + checkSettingsSymlinks, + checkEnvironment, + checkConfigFile, + checkSettingsFiles, + checkClaudeSettings, + checkProfiles, + checkInstances, + checkDelegation, + checkCliproxyBinary, + checkCliproxyConfig, + checkOAuthProviders, + checkCliproxyPort, + checkOAuthPortsForDashboard, + checkWebSearchClis, +} from './health'; -export interface HealthGroup { - id: string; - name: string; - icon: string; - checks: HealthCheck[]; -} - -export interface HealthReport { - timestamp: number; - version: string; - groups: HealthGroup[]; - checks: HealthCheck[]; // Flat list for backward compatibility - summary: { - total: number; - passed: number; - warnings: number; - errors: number; - info: number; - }; -} +// Re-export types for external consumers +export type { HealthCheck, HealthGroup, HealthReport }; /** * Run all health checks and return report @@ -73,7 +56,7 @@ export async function runHealthChecks(): Promise { systemChecks.push(checkCcsDirectory(ccsDir)); groups.push({ id: 'system', name: 'System', icon: 'Monitor', checks: systemChecks }); - // Group 2: Environment (OAuth readiness diagnostics) + // Group 2: Environment const envChecks: HealthCheck[] = []; envChecks.push(checkEnvironment()); groups.push({ id: 'environment', name: 'Environment', icon: 'Laptop', checks: envChecks }); @@ -107,12 +90,7 @@ export async function runHealthChecks(): Promise { healthChecks.push(checkPermissions(ccsDir)); healthChecks.push(checkCcsSymlinks()); healthChecks.push(checkSettingsSymlinks(homedir, ccsDir, claudeDir)); - groups.push({ - id: 'system-health', - name: 'System Health', - icon: 'Shield', - checks: healthChecks, - }); + groups.push({ id: 'system-health', name: 'System Health', icon: 'Shield', checks: healthChecks }); // Group 6: CLIProxy const cliproxyChecks: HealthCheck[] = []; @@ -120,14 +98,9 @@ export async function runHealthChecks(): Promise { cliproxyChecks.push(checkCliproxyConfig()); cliproxyChecks.push(...checkOAuthProviders()); cliproxyChecks.push(await checkCliproxyPort()); - groups.push({ - id: 'cliproxy', - name: 'CLIProxy (OAuth)', - icon: 'Zap', - checks: cliproxyChecks, - }); + groups.push({ id: 'cliproxy', name: 'CLIProxy (OAuth)', icon: 'Zap', checks: cliproxyChecks }); - // Group 7: OAuth Readiness (port availability) + // Group 7: OAuth Readiness const oauthReadinessChecks: HealthCheck[] = []; oauthReadinessChecks.push(...(await checkOAuthPortsForDashboard())); groups.push({ @@ -140,12 +113,7 @@ export async function runHealthChecks(): Promise { // Group 8: WebSearch CLI Providers const websearchChecks: HealthCheck[] = []; websearchChecks.push(...checkWebSearchClis()); - groups.push({ - id: 'websearch', - name: 'WebSearch', - icon: 'Search', - checks: websearchChecks, - }); + groups.push({ id: 'websearch', name: 'WebSearch', icon: 'Search', checks: websearchChecks }); // Flatten all checks for backward compatibility const allChecks = groups.flatMap((g) => g.checks); @@ -159,715 +127,7 @@ export async function runHealthChecks(): Promise { info: allChecks.filter((c) => c.status === 'info').length, }; - return { - timestamp: Date.now(), - version, - groups, - checks: allChecks, - summary, - }; -} - -// Check 1: Claude CLI -async function checkClaudeCli(): Promise { - const cliInfo = getClaudeCliInfo(); - - if (!cliInfo) { - return { - id: 'claude-cli', - name: 'Claude CLI', - status: 'error', - message: 'Not found in PATH', - fix: 'Install: npm install -g @anthropic-ai/claude-code', - }; - } - - try { - const version = execSync('claude --version', { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); - - const versionMatch = version.match(/(\d+\.\d+\.\d+)/); - const versionStr = versionMatch ? versionMatch[1] : 'unknown'; - - return { - id: 'claude-cli', - name: 'Claude CLI', - status: 'ok', - message: `v${versionStr}`, - details: cliInfo.path, - }; - } catch { - return { - id: 'claude-cli', - name: 'Claude CLI', - status: 'error', - message: 'Not working', - details: cliInfo.path, - fix: 'Reinstall Claude CLI', - }; - } -} - -// Check 2: CCS Directory -function checkCcsDirectory(ccsDir: string): HealthCheck { - if (fs.existsSync(ccsDir)) { - return { - id: 'ccs-dir', - name: 'CCS Directory', - status: 'ok', - message: 'Exists', - details: '~/.ccs/', - }; - } - - return { - id: 'ccs-dir', - name: 'CCS Directory', - status: 'error', - message: 'Not found', - details: ccsDir, - fix: 'Run: npm install -g @kaitranntt/ccs --force', - fixable: true, - }; -} - -// Check 3: Config file -function checkConfigFile(): HealthCheck { - const configPath = getConfigPath(); - - if (!fs.existsSync(configPath)) { - return { - id: 'config-file', - name: 'config.json', - status: 'warning', - message: 'Not found', - details: configPath, - fixable: true, - }; - } - - try { - const content = fs.readFileSync(configPath, 'utf8'); - JSON.parse(content); - return { - id: 'config-file', - name: 'config.json', - status: 'ok', - message: 'Valid', - details: configPath, - }; - } catch { - return { - id: 'config-file', - name: 'config.json', - status: 'error', - message: 'Invalid JSON', - details: configPath, - }; - } -} - -// Check 4: Settings files (glm, kimi) -function checkSettingsFiles(ccsDir: string): HealthCheck[] { - const checks: HealthCheck[] = []; - const files = [ - { name: 'glm.settings.json', profile: 'glm' }, - { name: 'kimi.settings.json', profile: 'kimi' }, - ]; - - const { DelegationValidator } = require('../utils/delegation-validator'); - - for (const file of files) { - const filePath = path.join(ccsDir, file.name); - - if (!fs.existsSync(filePath)) { - checks.push({ - id: `settings-${file.profile}`, - name: file.name, - status: 'info', - message: 'Not configured', - details: filePath, - }); - continue; - } - - try { - const content = fs.readFileSync(filePath, 'utf8'); - JSON.parse(content); - - const validation = DelegationValidator.validate(file.profile); - - if (validation.valid) { - checks.push({ - id: `settings-${file.profile}`, - name: file.name, - status: 'ok', - message: 'Key configured', - details: filePath, - }); - } else if (validation.error && validation.error.includes('placeholder')) { - checks.push({ - id: `settings-${file.profile}`, - name: file.name, - status: 'warning', - message: 'Placeholder key', - details: filePath, - }); - } else { - checks.push({ - id: `settings-${file.profile}`, - name: file.name, - status: 'ok', - message: 'Valid JSON', - details: filePath, - }); - } - } catch { - checks.push({ - id: `settings-${file.profile}`, - name: file.name, - status: 'error', - message: 'Invalid JSON', - details: filePath, - }); - } - } - - return checks; -} - -// Check 5: Claude settings -function checkClaudeSettings(claudeDir: string): HealthCheck { - const settingsPath = path.join(claudeDir, 'settings.json'); - - if (!fs.existsSync(settingsPath)) { - return { - id: 'claude-settings', - name: '~/.claude/settings.json', - status: 'warning', - message: 'Not found', - fix: 'Run: claude /login', - }; - } - - try { - const content = fs.readFileSync(settingsPath, 'utf8'); - JSON.parse(content); - return { - id: 'claude-settings', - name: '~/.claude/settings.json', - status: 'ok', - message: 'Valid', - }; - } catch { - return { - id: 'claude-settings', - name: '~/.claude/settings.json', - status: 'warning', - message: 'Invalid JSON', - fix: 'Run: claude /login', - }; - } -} - -// Check 6: Profiles -function checkProfiles(ccsDir: string): HealthCheck { - const configPath = path.join(ccsDir, 'config.json'); - - if (!fs.existsSync(configPath)) { - return { - id: 'profiles', - name: 'Profiles', - status: 'info', - message: 'config.json not found', - }; - } - - try { - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - - if (!config.profiles || typeof config.profiles !== 'object') { - return { - id: 'profiles', - name: 'Profiles', - status: 'error', - message: 'Missing profiles object', - fix: 'Run: npm install -g @kaitranntt/ccs --force', - }; - } - - const profileCount = Object.keys(config.profiles).length; - const profileNames = Object.keys(config.profiles).join(', '); - - return { - id: 'profiles', - name: 'Profiles', - status: 'ok', - message: `${profileCount} configured`, - details: profileNames.length > 40 ? profileNames.substring(0, 37) + '...' : profileNames, - }; - } catch (e) { - return { - id: 'profiles', - name: 'Profiles', - status: 'error', - message: (e as Error).message, - }; - } -} - -// Check 7: Instances -function checkInstances(ccsDir: string): HealthCheck { - const instancesDir = path.join(ccsDir, 'instances'); - - if (!fs.existsSync(instancesDir)) { - return { - id: 'instances', - name: 'Instances', - status: 'ok', - message: 'No account profiles', - }; - } - - const instances = fs.readdirSync(instancesDir).filter((name) => { - return fs.statSync(path.join(instancesDir, name)).isDirectory(); - }); - - if (instances.length === 0) { - return { - id: 'instances', - name: 'Instances', - status: 'ok', - message: 'No account profiles', - }; - } - - return { - id: 'instances', - name: 'Instances', - status: 'ok', - message: `${instances.length} account profile${instances.length !== 1 ? 's' : ''}`, - }; -} - -// Check 8: Delegation -function checkDelegation(ccsDir: string): HealthCheck { - const ccsClaudeCommandsDir = path.join(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) { - return { - id: 'delegation', - name: 'Delegation', - status: 'warning', - message: 'Not installed', - fix: 'Run: npm install -g @kaitranntt/ccs --force', - }; - } - - 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) { - return { - id: 'delegation', - name: 'Delegation', - status: 'warning', - message: 'No profiles ready', - fix: 'Configure profiles with valid API keys', - }; - } - - return { - id: 'delegation', - name: 'Delegation', - status: 'ok', - message: `${readyProfiles.length} profiles ready`, - details: readyProfiles.join(', '), - }; -} - -// Check 9: Permissions -function checkPermissions(ccsDir: string): HealthCheck { - const testFile = path.join(ccsDir, '.permission-test'); - - try { - fs.writeFileSync(testFile, 'test', 'utf8'); - fs.unlinkSync(testFile); - return { - id: 'permissions', - name: 'Permissions', - status: 'ok', - message: 'Write access verified', - }; - } catch { - return { - id: 'permissions', - name: 'Permissions', - status: 'error', - message: 'Cannot write to ~/.ccs/', - fix: 'sudo chown -R $USER ~/.ccs ~/.claude && chmod 755 ~/.ccs ~/.claude', - }; - } -} - -// Check 10: CCS Symlinks -function checkCcsSymlinks(): HealthCheck { - try { - const { ClaudeSymlinkManager } = require('../utils/claude-symlink-manager'); - const manager = new ClaudeSymlinkManager(); - const health = manager.checkHealth(); - - if (health.healthy) { - const itemCount = manager.ccsItems.length; - return { - id: 'ccs-symlinks', - name: 'CCS Symlinks', - status: 'ok', - message: `${itemCount}/${itemCount} items linked`, - }; - } - - return { - id: 'ccs-symlinks', - name: 'CCS Symlinks', - status: 'warning', - message: `${health.issues.length} issues found`, - fix: 'Run: ccs sync', - }; - } catch (e) { - return { - id: 'ccs-symlinks', - name: 'CCS Symlinks', - status: 'warning', - message: 'Could not check', - details: (e as Error).message, - fix: 'Run: ccs sync', - }; - } -} - -// Check 11: Settings Symlinks -function checkSettingsSymlinks(homedir: string, ccsDir: string, claudeDir: string): HealthCheck { - try { - const sharedDir = path.join(homedir, '.ccs', 'shared'); - const sharedSettings = path.join(sharedDir, 'settings.json'); - const claudeSettings = path.join(claudeDir, 'settings.json'); - - if (!fs.existsSync(sharedSettings)) { - return { - id: 'settings-symlinks', - name: 'settings.json', - status: 'warning', - message: 'Shared not found', - fix: 'Run: ccs sync', - }; - } - - const sharedStats = fs.lstatSync(sharedSettings); - if (!sharedStats.isSymbolicLink()) { - return { - id: 'settings-symlinks', - name: 'settings.json', - status: 'warning', - message: 'Not a symlink', - fix: 'Run: ccs sync', - }; - } - - const sharedTarget = fs.readlinkSync(sharedSettings); - const resolvedShared = path.resolve(path.dirname(sharedSettings), sharedTarget); - - if (resolvedShared !== claudeSettings) { - return { - id: 'settings-symlinks', - name: 'settings.json', - status: 'warning', - message: 'Wrong target', - fix: 'Run: ccs sync', - }; - } - - // Check instances - const instancesDir = path.join(ccsDir, 'instances'); - if (!fs.existsSync(instancesDir)) { - return { - id: 'settings-symlinks', - name: 'settings.json', - status: 'ok', - message: 'Shared symlink valid', - }; - } - - const instances = fs.readdirSync(instancesDir).filter((name) => { - return fs.statSync(path.join(instancesDir, name)).isDirectory(); - }); - - let broken = 0; - for (const instance of instances) { - const instanceSettings = path.join(instancesDir, instance, '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 { - broken++; - } - } - - if (broken > 0) { - return { - id: 'settings-symlinks', - name: 'settings.json', - status: 'warning', - message: `${broken} broken instance(s)`, - fix: 'Run: ccs sync', - }; - } - - return { - id: 'settings-symlinks', - name: 'settings.json', - status: 'ok', - message: `${instances.length} instance(s) valid`, - }; - } catch (e) { - return { - id: 'settings-symlinks', - name: 'settings.json', - status: 'warning', - message: 'Check failed', - details: (e as Error).message, - fix: 'Run: ccs sync', - }; - } -} - -// Check 12: CLIProxy Binary -function checkCliproxyBinary(): HealthCheck { - if (isCLIProxyInstalled()) { - const version = getInstalledCliproxyVersion(); - const binaryPath = getCLIProxyPath(); - return { - id: 'cliproxy-binary', - name: 'CLIProxy Binary', - status: 'ok', - message: `v${version}`, - details: binaryPath, - }; - } - - return { - id: 'cliproxy-binary', - name: 'CLIProxy Binary', - status: 'info', - message: 'Not installed', - details: 'Downloads on first use', - }; -} - -// Check 13: CLIProxy Config -function checkCliproxyConfig(): HealthCheck { - const configPath = getCliproxyConfigPath(); - - if (fs.existsSync(configPath)) { - return { - id: 'cliproxy-config', - name: 'CLIProxy Config', - status: 'ok', - message: 'cliproxy/config.yaml', - }; - } - - return { - id: 'cliproxy-config', - name: 'CLIProxy Config', - status: 'info', - message: 'Not created', - details: 'Generated on first use', - }; -} - -// Check 14: OAuth Providers -function checkOAuthProviders(): HealthCheck[] { - const authStatuses = getAllAuthStatus(); - const checks: HealthCheck[] = []; - - for (const status of authStatuses) { - const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1); - - if (status.authenticated) { - const lastAuth = status.lastAuth ? status.lastAuth.toLocaleDateString() : ''; - checks.push({ - id: `oauth-${status.provider}`, - name: `${providerName} Auth`, - status: 'ok', - message: 'Authenticated', - details: lastAuth, - }); - } else { - checks.push({ - id: `oauth-${status.provider}`, - name: `${providerName} Auth`, - status: 'info', - message: 'Not authenticated', - fix: `Run: ccs ${status.provider} --auth`, - }); - } - } - - return checks; -} - -// Check 15: CLIProxy Port -async function checkCliproxyPort(): Promise { - const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT); - - if (!portProcess) { - return { - id: 'cliproxy-port', - name: 'CLIProxy Port', - status: 'info', - message: `${CLIPROXY_DEFAULT_PORT} free`, - details: 'Proxy not running', - }; - } - - if (isCLIProxyProcess(portProcess)) { - return { - id: 'cliproxy-port', - name: 'CLIProxy Port', - status: 'ok', - message: 'CLIProxy running', - details: `PID ${portProcess.pid}`, - }; - } - - return { - id: 'cliproxy-port', - name: 'CLIProxy Port', - status: 'warning', - message: `Occupied by ${portProcess.processName}`, - details: `PID ${portProcess.pid}`, - fix: `Kill process: kill ${portProcess.pid}`, - }; -} - -// Check 16: Environment (platform, SSH, TTY, browser capability) -function checkEnvironment(): HealthCheck { - const diag = getEnvironmentDiagnostics(); - - let status: 'ok' | 'warning' | 'info' = 'ok'; - let message = 'Browser available'; - - if (diag.detectedHeadless) { - if (diag.platform === 'win32' && diag.ttyStatus === 'undefined') { - status = 'warning'; - message = 'Possible headless false positive (Windows)'; - } else if (diag.sshSession) { - status = 'info'; - message = 'SSH session (headless mode)'; - } else { - status = 'info'; - message = 'Headless environment'; - } - } - - return { - id: 'environment', - name: 'Environment', - status, - message, - details: `${diag.platformName} | SSH: ${diag.sshSession ? 'Yes' : 'No'} | Browser: ${diag.browserReason}`, - }; -} - -// Check 17: OAuth Ports (port availability for Gemini, Codex, Agy) -async function checkOAuthPortsForDashboard(): Promise { - const portDiagnostics = await checkAuthCodePorts(); - - return portDiagnostics.map((diag) => { - const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1); - const portStr = diag.port ? ` (${diag.port})` : ''; - - let status: 'ok' | 'warning' | 'info' = 'ok'; - if (diag.status === 'occupied') status = 'warning'; - if (diag.status === 'not_applicable') status = 'info'; - - return { - id: `oauth-port-${diag.provider}`, - name: `${providerName}${portStr}`, - status, - message: diag.message, - details: diag.process ? `PID ${diag.process.pid}` : undefined, - fix: diag.recommendation || undefined, - }; - }); -} - -// Check 18: WebSearch CLI Providers (Gemini CLI, Grok CLI) -function checkWebSearchClis(): HealthCheck[] { - const providers = getWebSearchCliProviders(); - const checks: HealthCheck[] = []; - - for (const provider of providers) { - if (provider.installed) { - const freeTag = provider.freeTier ? ' (FREE)' : ''; - checks.push({ - id: `websearch-${provider.id}`, - name: provider.name, - status: 'ok', - message: `v${provider.version || 'unknown'}${freeTag}`, - details: provider.description, - }); - } else { - const keyNote = provider.requiresApiKey ? ` (needs ${provider.apiKeyEnvVar})` : ' (FREE)'; - checks.push({ - id: `websearch-${provider.id}`, - name: provider.name, - status: 'info', - message: `Not installed${keyNote}`, - fix: provider.installCommand, - details: provider.description, - }); - } - } - - // Add summary check if no providers installed - if (!hasAnyWebSearchCli()) { - checks.push({ - id: 'websearch-summary', - name: 'WebSearch Status', - status: 'warning', - message: 'No CLI tools installed', - fix: 'npm install -g @google/gemini-cli (FREE)', - details: 'Install a WebSearch CLI for real-time web access', - }); - } - - return checks; + return { timestamp: Date.now(), version, groups, checks: allChecks, summary }; } /** diff --git a/src/web-server/health/cliproxy-checks.ts b/src/web-server/health/cliproxy-checks.ts new file mode 100644 index 00000000..80f33e8e --- /dev/null +++ b/src/web-server/health/cliproxy-checks.ts @@ -0,0 +1,135 @@ +/** + * CLIProxy Health Checks + * + * Check CLIProxy binary, config, OAuth providers, and port. + */ + +import * as fs from 'fs'; +import { + isCLIProxyInstalled, + getInstalledCliproxyVersion, + getCLIProxyPath, + getConfigPath as getCliproxyConfigPath, + getAllAuthStatus, + CLIPROXY_DEFAULT_PORT, +} from '../../cliproxy'; +import { getPortProcess, isCLIProxyProcess } from '../../utils/port-utils'; +import type { HealthCheck } from './types'; + +/** + * Check CLIProxy binary installation + */ +export function checkCliproxyBinary(): HealthCheck { + if (isCLIProxyInstalled()) { + const version = getInstalledCliproxyVersion(); + const binaryPath = getCLIProxyPath(); + return { + id: 'cliproxy-binary', + name: 'CLIProxy Binary', + status: 'ok', + message: `v${version}`, + details: binaryPath, + }; + } + + return { + id: 'cliproxy-binary', + name: 'CLIProxy Binary', + status: 'info', + message: 'Not installed', + details: 'Downloads on first use', + }; +} + +/** + * Check CLIProxy config file + */ +export function checkCliproxyConfig(): HealthCheck { + const configPath = getCliproxyConfigPath(); + + if (fs.existsSync(configPath)) { + return { + id: 'cliproxy-config', + name: 'CLIProxy Config', + status: 'ok', + message: 'cliproxy/config.yaml', + }; + } + + return { + id: 'cliproxy-config', + name: 'CLIProxy Config', + status: 'info', + message: 'Not created', + details: 'Generated on first use', + }; +} + +/** + * Check OAuth providers authentication status + */ +export function checkOAuthProviders(): HealthCheck[] { + const authStatuses = getAllAuthStatus(); + const checks: HealthCheck[] = []; + + for (const status of authStatuses) { + const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1); + + if (status.authenticated) { + const lastAuth = status.lastAuth ? status.lastAuth.toLocaleDateString() : ''; + checks.push({ + id: `oauth-${status.provider}`, + name: `${providerName} Auth`, + status: 'ok', + message: 'Authenticated', + details: lastAuth, + }); + } else { + checks.push({ + id: `oauth-${status.provider}`, + name: `${providerName} Auth`, + status: 'info', + message: 'Not authenticated', + fix: `Run: ccs ${status.provider} --auth`, + }); + } + } + + return checks; +} + +/** + * Check CLIProxy port status + */ +export async function checkCliproxyPort(): Promise { + const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT); + + if (!portProcess) { + return { + id: 'cliproxy-port', + name: 'CLIProxy Port', + status: 'info', + message: `${CLIPROXY_DEFAULT_PORT} free`, + details: 'Proxy not running', + }; + } + + if (isCLIProxyProcess(portProcess)) { + return { + id: 'cliproxy-port', + name: 'CLIProxy Port', + status: 'ok', + message: 'CLIProxy running', + details: `PID ${portProcess.pid}`, + }; + } + + return { + id: 'cliproxy-port', + name: 'CLIProxy Port', + status: 'warning', + message: `Occupied by ${portProcess.processName}`, + details: `PID ${portProcess.pid}`, + fix: `Kill process: kill ${portProcess.pid}`, + }; +} diff --git a/src/web-server/health/config-checks.ts b/src/web-server/health/config-checks.ts new file mode 100644 index 00000000..30cc881c --- /dev/null +++ b/src/web-server/health/config-checks.ts @@ -0,0 +1,155 @@ +/** + * Configuration Health Checks + * + * Check config.json, settings files, and Claude settings. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getConfigPath } from '../../utils/config-manager'; +import type { HealthCheck } from './types'; + +/** + * Check config.json file + */ +export function checkConfigFile(): HealthCheck { + const configPath = getConfigPath(); + + if (!fs.existsSync(configPath)) { + return { + id: 'config-file', + name: 'config.json', + status: 'warning', + message: 'Not found', + details: configPath, + fixable: true, + }; + } + + try { + const content = fs.readFileSync(configPath, 'utf8'); + JSON.parse(content); + return { + id: 'config-file', + name: 'config.json', + status: 'ok', + message: 'Valid', + details: configPath, + }; + } catch { + return { + id: 'config-file', + name: 'config.json', + status: 'error', + message: 'Invalid JSON', + details: configPath, + }; + } +} + +/** + * Check settings files (glm, kimi) + */ +export function checkSettingsFiles(ccsDir: string): HealthCheck[] { + const checks: HealthCheck[] = []; + const files = [ + { name: 'glm.settings.json', profile: 'glm' }, + { name: 'kimi.settings.json', profile: 'kimi' }, + ]; + + const { DelegationValidator } = require('../../utils/delegation-validator'); + + for (const file of files) { + const filePath = path.join(ccsDir, file.name); + + if (!fs.existsSync(filePath)) { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'info', + message: 'Not configured', + details: filePath, + }); + continue; + } + + try { + const content = fs.readFileSync(filePath, 'utf8'); + JSON.parse(content); + + const validation = DelegationValidator.validate(file.profile); + + if (validation.valid) { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'ok', + message: 'Key configured', + details: filePath, + }); + } else if (validation.error && validation.error.includes('placeholder')) { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'warning', + message: 'Placeholder key', + details: filePath, + }); + } else { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'ok', + message: 'Valid JSON', + details: filePath, + }); + } + } catch { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'error', + message: 'Invalid JSON', + details: filePath, + }); + } + } + + return checks; +} + +/** + * Check Claude settings file + */ +export function checkClaudeSettings(claudeDir: string): HealthCheck { + const settingsPath = path.join(claudeDir, 'settings.json'); + + if (!fs.existsSync(settingsPath)) { + return { + id: 'claude-settings', + name: '~/.claude/settings.json', + status: 'warning', + message: 'Not found', + fix: 'Run: claude /login', + }; + } + + try { + const content = fs.readFileSync(settingsPath, 'utf8'); + JSON.parse(content); + return { + id: 'claude-settings', + name: '~/.claude/settings.json', + status: 'ok', + message: 'Valid', + }; + } catch { + return { + id: 'claude-settings', + name: '~/.claude/settings.json', + status: 'warning', + message: 'Invalid JSON', + fix: 'Run: claude /login', + }; + } +} diff --git a/src/web-server/health/environment-checks.ts b/src/web-server/health/environment-checks.ts new file mode 100644 index 00000000..fa081baa --- /dev/null +++ b/src/web-server/health/environment-checks.ts @@ -0,0 +1,39 @@ +/** + * Environment Health Checks + * + * Check platform, SSH, TTY, and browser capability. + */ + +import { getEnvironmentDiagnostics } from '../../management/environment-diagnostics'; +import type { HealthCheck } from './types'; + +/** + * Check environment (platform, SSH, TTY, browser capability) + */ +export function checkEnvironment(): HealthCheck { + const diag = getEnvironmentDiagnostics(); + + let status: 'ok' | 'warning' | 'info' = 'ok'; + let message = 'Browser available'; + + if (diag.detectedHeadless) { + if (diag.platform === 'win32' && diag.ttyStatus === 'undefined') { + status = 'warning'; + message = 'Possible headless false positive (Windows)'; + } else if (diag.sshSession) { + status = 'info'; + message = 'SSH session (headless mode)'; + } else { + status = 'info'; + message = 'Headless environment'; + } + } + + return { + id: 'environment', + name: 'Environment', + status, + message, + details: `${diag.platformName} | SSH: ${diag.sshSession ? 'Yes' : 'No'} | Browser: ${diag.browserReason}`, + }; +} diff --git a/src/web-server/health/index.ts b/src/web-server/health/index.ts new file mode 100644 index 00000000..8a38f8dd --- /dev/null +++ b/src/web-server/health/index.ts @@ -0,0 +1,35 @@ +/** + * Health Check Modules - Barrel Export + */ + +// Types +export type { HealthCheck, HealthGroup, HealthReport } from './types'; + +// System checks +export { checkClaudeCli, checkCcsDirectory, checkPermissions } from './system-checks'; + +// Symlink checks +export { checkCcsSymlinks, checkSettingsSymlinks } from './symlink-checks'; + +// Environment checks +export { checkEnvironment } from './environment-checks'; + +// Config checks +export { checkConfigFile, checkSettingsFiles, checkClaudeSettings } from './config-checks'; + +// Profile checks +export { checkProfiles, checkInstances, checkDelegation } from './profile-checks'; + +// CLIProxy checks +export { + checkCliproxyBinary, + checkCliproxyConfig, + checkOAuthProviders, + checkCliproxyPort, +} from './cliproxy-checks'; + +// OAuth checks +export { checkOAuthPortsForDashboard } from './oauth-checks'; + +// WebSearch checks +export { checkWebSearchClis } from './websearch-checks'; diff --git a/src/web-server/health/oauth-checks.ts b/src/web-server/health/oauth-checks.ts new file mode 100644 index 00000000..2a128b7b --- /dev/null +++ b/src/web-server/health/oauth-checks.ts @@ -0,0 +1,33 @@ +/** + * OAuth Readiness Health Checks + * + * Check OAuth port availability for providers. + */ + +import { checkAuthCodePorts } from '../../management/oauth-port-diagnostics'; +import type { HealthCheck } from './types'; + +/** + * Check OAuth ports for dashboard (Gemini, Codex, Agy) + */ +export async function checkOAuthPortsForDashboard(): Promise { + const portDiagnostics = await checkAuthCodePorts(); + + return portDiagnostics.map((diag) => { + const providerName = diag.provider.charAt(0).toUpperCase() + diag.provider.slice(1); + const portStr = diag.port ? ` (${diag.port})` : ''; + + let status: 'ok' | 'warning' | 'info' = 'ok'; + if (diag.status === 'occupied') status = 'warning'; + if (diag.status === 'not_applicable') status = 'info'; + + return { + id: `oauth-port-${diag.provider}`, + name: `${providerName}${portStr}`, + status, + message: diag.message, + details: diag.process ? `PID ${diag.process.pid}` : undefined, + fix: diag.recommendation || undefined, + }; + }); +} diff --git a/src/web-server/health/profile-checks.ts b/src/web-server/health/profile-checks.ts new file mode 100644 index 00000000..4c2a30e5 --- /dev/null +++ b/src/web-server/health/profile-checks.ts @@ -0,0 +1,140 @@ +/** + * Profile Health Checks + * + * Check profiles, instances, and delegation. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { HealthCheck } from './types'; + +/** + * Check profiles configuration + */ +export function checkProfiles(ccsDir: string): HealthCheck { + const configPath = path.join(ccsDir, 'config.json'); + + if (!fs.existsSync(configPath)) { + return { + id: 'profiles', + name: 'Profiles', + status: 'info', + message: 'config.json not found', + }; + } + + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + + if (!config.profiles || typeof config.profiles !== 'object') { + return { + id: 'profiles', + name: 'Profiles', + status: 'error', + message: 'Missing profiles object', + fix: 'Run: npm install -g @kaitranntt/ccs --force', + }; + } + + const profileCount = Object.keys(config.profiles).length; + const profileNames = Object.keys(config.profiles).join(', '); + + return { + id: 'profiles', + name: 'Profiles', + status: 'ok', + message: `${profileCount} configured`, + details: profileNames.length > 40 ? profileNames.substring(0, 37) + '...' : profileNames, + }; + } catch (e) { + return { + id: 'profiles', + name: 'Profiles', + status: 'error', + message: (e as Error).message, + }; + } +} + +/** + * Check instances (account profiles) + */ +export function checkInstances(ccsDir: string): HealthCheck { + const instancesDir = path.join(ccsDir, 'instances'); + + if (!fs.existsSync(instancesDir)) { + return { + id: 'instances', + name: 'Instances', + status: 'ok', + message: 'No account profiles', + }; + } + + const instances = fs.readdirSync(instancesDir).filter((name) => { + return fs.statSync(path.join(instancesDir, name)).isDirectory(); + }); + + if (instances.length === 0) { + return { + id: 'instances', + name: 'Instances', + status: 'ok', + message: 'No account profiles', + }; + } + + return { + id: 'instances', + name: 'Instances', + status: 'ok', + message: `${instances.length} account profile${instances.length !== 1 ? 's' : ''}`, + }; +} + +/** + * Check delegation setup + */ +export function checkDelegation(ccsDir: string): HealthCheck { + const ccsClaudeCommandsDir = path.join(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) { + return { + id: 'delegation', + name: 'Delegation', + status: 'warning', + message: 'Not installed', + fix: 'Run: npm install -g @kaitranntt/ccs --force', + }; + } + + 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) { + return { + id: 'delegation', + name: 'Delegation', + status: 'warning', + message: 'No profiles ready', + fix: 'Configure profiles with valid API keys', + }; + } + + return { + id: 'delegation', + name: 'Delegation', + status: 'ok', + message: `${readyProfiles.length} profiles ready`, + details: readyProfiles.join(', '), + }; +} diff --git a/src/web-server/health/symlink-checks.ts b/src/web-server/health/symlink-checks.ts new file mode 100644 index 00000000..a5e4e0aa --- /dev/null +++ b/src/web-server/health/symlink-checks.ts @@ -0,0 +1,160 @@ +/** + * Symlink Health Checks + * + * Check CCS symlinks and settings symlinks. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { HealthCheck } from './types'; + +/** + * Check CCS symlinks health + */ +export function checkCcsSymlinks(): HealthCheck { + try { + const { ClaudeSymlinkManager } = require('../../utils/claude-symlink-manager'); + const manager = new ClaudeSymlinkManager(); + const health = manager.checkHealth(); + + if (health.healthy) { + const itemCount = manager.ccsItems.length; + return { + id: 'ccs-symlinks', + name: 'CCS Symlinks', + status: 'ok', + message: `${itemCount}/${itemCount} items linked`, + }; + } + + return { + id: 'ccs-symlinks', + name: 'CCS Symlinks', + status: 'warning', + message: `${health.issues.length} issues found`, + fix: 'Run: ccs sync', + }; + } catch (e) { + return { + id: 'ccs-symlinks', + name: 'CCS Symlinks', + status: 'warning', + message: 'Could not check', + details: (e as Error).message, + fix: 'Run: ccs sync', + }; + } +} + +/** + * Check settings symlinks + */ +export function checkSettingsSymlinks( + homedir: string, + ccsDir: string, + claudeDir: string +): HealthCheck { + try { + const sharedDir = `${homedir}/.ccs/shared`; + const sharedSettings = `${sharedDir}/settings.json`; + const claudeSettings = `${claudeDir}/settings.json`; + + if (!fs.existsSync(sharedSettings)) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: 'Shared not found', + fix: 'Run: ccs sync', + }; + } + + const sharedStats = fs.lstatSync(sharedSettings); + if (!sharedStats.isSymbolicLink()) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: 'Not a symlink', + fix: 'Run: ccs sync', + }; + } + + const sharedTarget = fs.readlinkSync(sharedSettings); + const resolvedShared = path.resolve(path.dirname(sharedSettings), sharedTarget); + + if (resolvedShared !== claudeSettings) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: 'Wrong target', + fix: 'Run: ccs sync', + }; + } + + // Check instances + const instancesDir = `${ccsDir}/instances`; + if (!fs.existsSync(instancesDir)) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'ok', + message: 'Shared symlink valid', + }; + } + + const instances = fs.readdirSync(instancesDir).filter((name) => { + return fs.statSync(`${instancesDir}/${name}`).isDirectory(); + }); + + let broken = 0; + for (const instance of instances) { + const instanceSettings = `${instancesDir}/${instance}/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 { + broken++; + } + } + + if (broken > 0) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: `${broken} broken instance(s)`, + fix: 'Run: ccs sync', + }; + } + + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'ok', + message: `${instances.length} instance(s) valid`, + }; + } catch (e) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: 'Check failed', + details: (e as Error).message, + fix: 'Run: ccs sync', + }; + } +} diff --git a/src/web-server/health/system-checks.ts b/src/web-server/health/system-checks.ts new file mode 100644 index 00000000..c9ed979a --- /dev/null +++ b/src/web-server/health/system-checks.ts @@ -0,0 +1,106 @@ +/** + * System Health Checks + * + * Checks for Claude CLI, CCS directory, and permissions. + */ + +import * as fs from 'fs'; +import { execSync } from 'child_process'; +import { getClaudeCliInfo } from '../../utils/claude-detector'; +import type { HealthCheck } from './types'; + +/** + * Check Claude CLI installation and version + */ +export async function checkClaudeCli(): Promise { + const cliInfo = getClaudeCliInfo(); + + if (!cliInfo) { + return { + id: 'claude-cli', + name: 'Claude CLI', + status: 'error', + message: 'Not found in PATH', + fix: 'Install: npm install -g @anthropic-ai/claude-code', + }; + } + + try { + const version = execSync('claude --version', { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + const versionMatch = version.match(/(\d+\.\d+\.\d+)/); + const versionStr = versionMatch ? versionMatch[1] : 'unknown'; + + return { + id: 'claude-cli', + name: 'Claude CLI', + status: 'ok', + message: `v${versionStr}`, + details: cliInfo.path, + }; + } catch { + return { + id: 'claude-cli', + name: 'Claude CLI', + status: 'error', + message: 'Not working', + details: cliInfo.path, + fix: 'Reinstall Claude CLI', + }; + } +} + +/** + * Check CCS directory existence + */ +export function checkCcsDirectory(ccsDir: string): HealthCheck { + if (fs.existsSync(ccsDir)) { + return { + id: 'ccs-dir', + name: 'CCS Directory', + status: 'ok', + message: 'Exists', + details: '~/.ccs/', + }; + } + + return { + id: 'ccs-dir', + name: 'CCS Directory', + status: 'error', + message: 'Not found', + details: ccsDir, + fix: 'Run: npm install -g @kaitranntt/ccs --force', + fixable: true, + }; +} + +/** + * Check permissions on CCS directory + */ +export function checkPermissions(ccsDir: string): HealthCheck { + const testFile = `${ccsDir}/.permission-test`; + + try { + fs.writeFileSync(testFile, 'test', 'utf8'); + fs.unlinkSync(testFile); + return { + id: 'permissions', + name: 'Permissions', + status: 'ok', + message: 'Write access verified', + }; + } catch { + return { + id: 'permissions', + name: 'Permissions', + status: 'error', + message: 'Cannot write to ~/.ccs/', + fix: 'sudo chown -R $USER ~/.ccs ~/.claude && chmod 755 ~/.ccs ~/.claude', + }; + } +} diff --git a/src/web-server/health/types.ts b/src/web-server/health/types.ts new file mode 100644 index 00000000..96d018c1 --- /dev/null +++ b/src/web-server/health/types.ts @@ -0,0 +1,36 @@ +/** + * Health Check Types + * + * Core interfaces for the health check system. + */ + +export interface HealthCheck { + id: string; + name: string; + status: 'ok' | 'warning' | 'error' | 'info'; + message: string; + details?: string; + fix?: string; + fixable?: boolean; +} + +export interface HealthGroup { + id: string; + name: string; + icon: string; + checks: HealthCheck[]; +} + +export interface HealthReport { + timestamp: number; + version: string; + groups: HealthGroup[]; + checks: HealthCheck[]; // Flat list for backward compatibility + summary: { + total: number; + passed: number; + warnings: number; + errors: number; + info: number; + }; +} diff --git a/src/web-server/health/websearch-checks.ts b/src/web-server/health/websearch-checks.ts new file mode 100644 index 00000000..89a7c2cb --- /dev/null +++ b/src/web-server/health/websearch-checks.ts @@ -0,0 +1,53 @@ +/** + * WebSearch CLI Health Checks + * + * Check WebSearch CLI providers (Gemini CLI, Grok CLI). + */ + +import { getWebSearchCliProviders, hasAnyWebSearchCli } from '../../utils/websearch-manager'; +import type { HealthCheck } from './types'; + +/** + * Check WebSearch CLI providers + */ +export function checkWebSearchClis(): HealthCheck[] { + const providers = getWebSearchCliProviders(); + const checks: HealthCheck[] = []; + + for (const provider of providers) { + if (provider.installed) { + const freeTag = provider.freeTier ? ' (FREE)' : ''; + checks.push({ + id: `websearch-${provider.id}`, + name: provider.name, + status: 'ok', + message: `v${provider.version || 'unknown'}${freeTag}`, + details: provider.description, + }); + } else { + const keyNote = provider.requiresApiKey ? ` (needs ${provider.apiKeyEnvVar})` : ' (FREE)'; + checks.push({ + id: `websearch-${provider.id}`, + name: provider.name, + status: 'info', + message: `Not installed${keyNote}`, + fix: provider.installCommand, + details: provider.description, + }); + } + } + + // Add summary check if no providers installed + if (!hasAnyWebSearchCli()) { + checks.push({ + id: 'websearch-summary', + name: 'WebSearch Status', + status: 'warning', + message: 'No CLI tools installed', + fix: 'npm install -g @google/gemini-cli (FREE)', + details: 'Install a WebSearch CLI for real-time web access', + }); + } + + return checks; +}