- --fix 先显示所有 fix 方案,再 readline 询问用户确认 - 拒绝直接执行,需用户输入 y 才继续 - DoctorService.fix() 支持 toolchain.* 工具通过 apt 安装 - 支持 display (ImageMagick) 安装 - fix 后自动重跑 diagnostics 显示更新状态 - 输出按 category 分组显示 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
345 lines
13 KiB
TypeScript
Executable File
345 lines
13 KiB
TypeScript
Executable File
/**
|
|
* 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<DoctorReport> {
|
|
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<string, string> = { 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<DoctorCheck> {
|
|
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<DoctorCheck[]> {
|
|
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
|
|
}
|
|
}
|