/** * DoctorService - Diagnostic and repair service * DD §16.1. Self-bootstrap before capability checks. * INV-4: dependency installs originate here. * * @module packages/runtime/src/doctor/DoctorService */ import { existsSync, accessSync, constants, mkdirSync } from 'fs' import { join } from 'path' import { execFileSync } from 'child_process' export interface DoctorCheck { name: string category: 'self_bootstrap' | 'capability' | 'project' | 'runtime' | 'toolchain' | 'display' | 'network' | 'provider' passed: boolean message: string fixable: boolean fix?: string } export interface DoctorReport { checks: DoctorCheck[] all_passed: boolean bootstrap_passed: boolean fixable_count: number } export class DoctorService { private project_root: string private capability_registry?: any constructor(project_root: string, capability_registry?: any) { this.project_root = project_root this.capability_registry = capability_registry } /** * Run all diagnostic checks. */ async run_diagnostics(scope: 'all' | 'self_bootstrap' | 'capability' = 'all'): Promise { const checks: DoctorCheck[] = [] // Self-bootstrap checks (always run first) checks.push(this.check_bun()) checks.push(this.check_sqlite()) checks.push(this.check_shell()) checks.push(this.check_air_writability()) const bootstrap_passed = checks.every(c => c.passed) if (!bootstrap_passed) { return { checks, all_passed: false, bootstrap_passed, fixable_count: checks.filter(c => c.fixable).length } } if (scope === 'self_bootstrap') { return { checks, all_passed: bootstrap_passed, bootstrap_passed, fixable_count: 0 } } // Capability checks checks.push(this.check_git()) checks.push(this.check_node()) checks.push(this.check_project_structure()) // INV-4: Capability registry health — verify capability dependencies if (this.capability_registry) { checks.push(this.check_capability_deps()) } // FR-018/§6.12: toolchain / display / network / provider checks if (scope === 'all') { checks.push(...this.check_cpp_toolchain()) checks.push(this.check_display()) checks.push(await this.check_network()) checks.push(...await this.check_provider()) } const all_passed = checks.every(c => c.passed) return { checks, all_passed, bootstrap_passed: true, fixable_count: checks.filter(c => c.fixable).length } } /** * Attempt to fix an issue. * INV-4: dependency installs originate here. */ async fix(check_name: string): Promise<{ ok: boolean; message: string }> { // Implement self-repair logic per DD §16.1 switch (check_name) { case 'bun': { return { ok: false, message: 'Bun installation requires manual setup. Run: curl -fsSL https://bun.sh/install | bash' } } case 'git': { return { ok: false, message: 'Git installation requires manual setup. Run: apt install git (Debian/Ubuntu)' } } case 'node': { return { ok: false, message: 'Node.js installation requires manual setup. Run: https://nodejs.org' } } case 'air_writability': { try { const air_dir = join(this.project_root, '.air') if (!existsSync(air_dir)) { mkdirSync(air_dir, { recursive: true }) } mkdirSync(join(air_dir, 'shared'), { recursive: true }) mkdirSync(join(air_dir, 'local'), { recursive: true }) mkdirSync(join(air_dir, 'sessions'), { recursive: true }) mkdirSync(join(air_dir, 'logs'), { recursive: true }) return { ok: true, message: 'Created .air directory structure' } } catch (e) { return { ok: false, message: `Failed to create .air directory: ${e}` } } } case 'project_structure': { return { ok: false, message: 'Run air init to create project structure' } } case 'display': { try { execFileSync('sudo', ['apt', 'install', '-y', 'imagemagick'], { stdio: 'pipe', timeout: 60000 }) return { ok: true, message: 'ImageMagick installed' } } catch (e: any) { return { ok: false, message: `ImageMagick install failed: ${e.message}` } } } default: // Toolchain fix: try apt install if (check_name.startsWith('toolchain.')) { const pkg = check_name.replace('toolchain.', '') const pkgMap: Record = { cmake: 'cmake', ninja: 'ninja-build', cppcheck: 'cppcheck', clangd: 'clangd', 'g++': 'g++' } const aptPkg = pkgMap[pkg] || pkg try { execFileSync('sudo', ['apt', 'install', '-y', aptPkg], { stdio: 'pipe', timeout: 120000 }) return { ok: true, message: `${pkg} installed via apt` } } catch (e: any) { return { ok: false, message: `Failed to install ${pkg}: ${e.message}` } } } return { ok: false, message: `Fix for ${check_name} not implemented` } } } private check_bun(): DoctorCheck { // Search bun in common paths, not just PATH const candidates = [ 'bun', // PATH `${process.env.HOME || '/root'}/.bun/bin/bun`, '/usr/local/bin/bun', '/usr/bin/bun', ] for (const bun of candidates) { try { const version = execFileSync(bun, ['--version'], { stdio: 'pipe', timeout: 5000 }).toString().trim() return { name: 'bun', category: 'self_bootstrap', passed: true, message: `Bun ${version} found`, fixable: false } } catch { /* try next */ } } return { name: 'bun', category: 'self_bootstrap', passed: false, message: 'Bun not found', fixable: true, fix: 'Install Bun: curl -fsSL https://bun.sh/install | bash' } } private check_sqlite(): DoctorCheck { return { name: 'sqlite', category: 'self_bootstrap', passed: true, message: 'SQLite via Bun built-in', fixable: false } } private check_shell(): DoctorCheck { return { name: 'shell', category: 'self_bootstrap', passed: true, message: 'Shell available', fixable: false } } private check_air_writability(): DoctorCheck { const air_dir = join(this.project_root, '.air') try { if (!existsSync(air_dir)) { return { name: 'air_writability', category: 'self_bootstrap', passed: false, message: '.air directory does not exist', fixable: true, fix: 'Run project.initialize()' } } accessSync(air_dir, constants.W_OK) return { name: 'air_writability', category: 'self_bootstrap', passed: true, message: '.air directory is writable', fixable: false } } catch { return { name: 'air_writability', category: 'self_bootstrap', passed: false, message: '.air directory is not writable', fixable: true } } } private check_git(): DoctorCheck { try { execFileSync('git', ['--version'], { stdio: 'pipe', timeout: 5000 }) return { name: 'git', category: 'capability', passed: true, message: 'Git available', fixable: false } } catch { return { name: 'git', category: 'capability', passed: false, message: 'Git not found', fixable: true, fix: 'Install Git: apt install git' } } } private check_node(): DoctorCheck { try { const version = execFileSync('node', ['--version'], { stdio: 'pipe', timeout: 5000 }).toString().trim() return { name: 'node', category: 'capability', passed: true, message: `Node.js ${version} available`, fixable: false } } catch { return { name: 'node', category: 'capability', passed: false, message: 'Node.js not found', fixable: true, fix: 'Install Node.js: https://nodejs.org' } } } private check_project_structure(): DoctorCheck { const required = ['package.json', 'tsconfig.json'] const missing: string[] = [] for (const file of required) { if (!existsSync(join(this.project_root, file))) { missing.push(file) } } if (missing.length > 0) { return { name: 'project_structure', category: 'project', passed: false, message: `Missing: ${missing.join(', ')}`, fixable: true, fix: 'Run air init to create project structure' } } return { name: 'project_structure', category: 'project', passed: true, message: 'Project structure valid', fixable: false } } /** * INV-4: Check capability registry health — verify capability dependencies * are installed and accessible. Bridges CapabilityRegistry → DoctorService. */ private check_capability_deps(): DoctorCheck { try { const capabilities = this.capability_registry?.list?.() || [] if (capabilities.length === 0) { return { name: 'capability_deps', category: 'capability', passed: true, message: 'No capabilities registered — nothing to check', fixable: false } } const missing_deps: string[] = [] for (const cap of capabilities) { const entry = this.capability_registry?.get?.(cap.id) || cap const deps = entry?.manifest?.dependencies || [] for (const dep of deps) { try { const { execFileSync } = require('child_process') execFileSync('which', [dep], { stdio: 'pipe', timeout: 3000 }) } catch { missing_deps.push(`${cap.name || cap.id}:${dep}`) } } } if (missing_deps.length > 0) { return { name: 'capability_deps', category: 'capability', passed: false, message: `Missing capability dependencies: ${missing_deps.join(', ')}`, fixable: true, fix: 'Install missing tools: apt install ' + missing_deps.map(d => d.split(':')[1]).join(' ') } } return { name: 'capability_deps', category: 'capability', passed: true, message: `All ${capabilities.length} capabilities healthy`, fixable: false } } catch (e: any) { return { name: 'capability_deps', category: 'capability', passed: false, message: `Capability check failed: ${e.message}`, fixable: true } } } // ===== FR-018/§6.12: 5 new categories ===== private check_cpp_toolchain(): DoctorCheck[] { const tools = ['cmake', 'ninja', 'cppcheck', 'clangd', 'g++'] const reports: DoctorCheck[] = [] for (const t of tools) { try { const v = execFileSync('which', [t], { stdio: 'pipe', timeout: 3000 }).toString().trim() reports.push({ name: `toolchain.${t}`, category: 'toolchain', passed: true, message: `${t} found at ${v}`, fixable: false }) } catch { reports.push({ name: `toolchain.${t}`, category: 'toolchain', passed: false, message: `${t} not found`, fixable: true, fix: `apt install ${t === 'cmake' ? 'cmake' : t === 'ninja' ? 'ninja-build' : t}` }) } } return reports } private check_display(): DoctorCheck { const display = process.env.DISPLAY const wayland = process.env.WAYLAND_DISPLAY if (!display && !wayland) { return { name: 'display', category: 'display', passed: false, message: 'No DISPLAY/WAYLAND_DISPLAY (gui.screenshot will fail)', fixable: false } } try { execFileSync('which', ['import'], { stdio: 'pipe' }) return { name: 'display', category: 'display', passed: true, message: `Display ${display || wayland} + ImageMagick available`, fixable: false } } catch { return { name: 'display', category: 'display', passed: false, message: 'ImageMagick not installed', fixable: true, fix: 'apt install imagemagick' } } } private async check_network(): Promise { try { const r = await fetch('https://1.1.1.1', { method: 'HEAD', signal: AbortSignal.timeout(3000) }) return { name: 'network.internet', category: 'network', passed: r.ok || r.status > 0, message: `HTTP ${r.status}`, fixable: false } } catch (e: any) { return { name: 'network.internet', category: 'network', passed: false, message: e.message, fixable: false } } } private async check_provider(): Promise { const reports: DoctorCheck[] = [] const apiKey = process.env.AIRCODING_API_KEY || process.env.OPENAI_API_KEY const baseUrl = process.env.OPENAI_BASE_URL || process.env.AIRCODING_API_URL const model = process.env.AIRCODING_MODEL reports.push({ name: 'provider.api_key', category: 'provider', passed: Boolean(apiKey), message: apiKey ? `API key set (${apiKey.slice(0, 7)}...)` : 'No API key set', fixable: false, }) reports.push({ name: 'provider.base_url', category: 'provider', passed: Boolean(baseUrl), message: baseUrl ? `Base URL: ${baseUrl}` : 'No base URL set', fixable: false, }) reports.push({ name: 'provider.model', category: 'provider', passed: Boolean(model), message: model || 'No model set', fixable: false, }) if (apiKey && baseUrl) { try { const r = await fetch(`${baseUrl.replace(/\/$/, '')}/v1/models`, { method: 'GET', headers: { 'Authorization': `Bearer ${apiKey}` }, signal: AbortSignal.timeout(5000), }) reports.push({ name: 'provider.connectivity', category: 'provider', passed: r.ok || r.status > 0, message: `HTTP ${r.status}`, fixable: false, }) } catch (e: any) { reports.push({ name: 'provider.connectivity', category: 'provider', passed: false, message: e.message, fixable: false, }) } } return reports } }