import type { Command } from 'commander'; import type { Config } from '../config/index.js'; import { getConfigPath, getDataDir, formatStatus, resolveOverlayPath } from './shared.js'; import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'; import { homedir } from 'os'; import { resolve, join } from 'path'; import { parse } from 'yaml'; import { configSchema } from '../config/schema.js'; export interface CheckResult { status: 'pass' | 'fail' | 'warn' | 'skip'; label: string; detail?: string; } export interface DoctorContext { configPath: string; dataDir: string; config?: Config; } type Check = (ctx: DoctorContext) => Promise; const checkConfigExists: Check = async (ctx) => { if (existsSync(ctx.configPath)) { return { status: 'pass', label: 'Config file exists', detail: `(${ctx.configPath})` }; } return { status: 'fail', label: 'Config file exists', detail: `not found at ${ctx.configPath}` }; }; const checkOverlayExists: Check = async (ctx) => { const overlayPath = resolveOverlayPath(ctx.configPath); if (!overlayPath) { return { status: 'skip', label: 'Config overlay', detail: '(FLYNN_ENV not set)' }; } const env = process.env.FLYNN_ENV; if (existsSync(overlayPath)) { return { status: 'pass', label: 'Config overlay', detail: `(${env}.yaml found)` }; } return { status: 'fail', label: 'Config overlay', detail: `FLYNN_ENV=${env} but ${overlayPath} not found` }; }; const checkConfigParses: Check = async (ctx) => { if (!existsSync(ctx.configPath)) { return { status: 'skip', label: 'Config parses', detail: '(no config file)' }; } try { const raw = readFileSync(ctx.configPath, 'utf-8'); parse(raw); return { status: 'pass', label: 'Config parses', detail: '(valid YAML)' }; } catch (err) { return { status: 'fail', label: 'Config parses', detail: err instanceof Error ? err.message : String(err) }; } }; const checkConfigValidates: Check = async (ctx) => { if (!existsSync(ctx.configPath)) { return { status: 'skip', label: 'Config validates', detail: '(no config file)' }; } try { const raw = readFileSync(ctx.configPath, 'utf-8'); const parsed = parse(raw); if (!parsed || typeof parsed !== 'object') { return { status: 'fail', label: 'Config validates', detail: 'YAML did not produce an object' }; } const config = configSchema.parse(parsed); ctx.config = config; return { status: 'pass', label: 'Config validates', detail: '(schema valid)' }; } catch (err) { return { status: 'fail', label: 'Config validates', detail: err instanceof Error ? err.message : String(err) }; } }; const checkEnvVars: Check = async (ctx) => { if (!existsSync(ctx.configPath)) { return { status: 'skip', label: 'Env vars resolved', detail: '(no config file)' }; } try { const raw = readFileSync(ctx.configPath, 'utf-8'); const envVarPattern = /\$\{([^}]+)\}/g; const unresolved: string[] = []; let match; while ((match = envVarPattern.exec(raw)) !== null) { if (!process.env[match[1]]) { unresolved.push(match[1]); } } if (unresolved.length > 0) { return { status: 'fail', label: 'Env vars resolved', detail: `unset: ${unresolved.join(', ')}` }; } return { status: 'pass', label: 'Env vars resolved' }; } catch { return { status: 'skip', label: 'Env vars resolved', detail: '(could not read config)' }; } }; const checkDataDir: Check = async (ctx) => { try { const { mkdirSync } = await import('fs'); mkdirSync(ctx.dataDir, { recursive: true }); // Test write access const testFile = join(ctx.dataDir, '.doctor-check'); writeFileSync(testFile, 'ok'); unlinkSync(testFile); return { status: 'pass', label: 'Data directory writable', detail: `(${ctx.dataDir})` }; } catch { return { status: 'fail', label: 'Data directory writable', detail: `cannot write to ${ctx.dataDir}` }; } }; const checkSessionDb: Check = async (ctx) => { try { const { mkdirSync } = await import('fs'); mkdirSync(ctx.dataDir, { recursive: true }); const dbPath = resolve(ctx.dataDir, 'sessions.db'); const { SessionStore } = await import('../session/index.js'); const store = new SessionStore(dbPath); store.listSessions(); // Quick query to verify DB works store.close(); return { status: 'pass', label: 'Session DB accessible', detail: '(sessions.db)' }; } catch (err) { return { status: 'fail', label: 'Session DB accessible', detail: err instanceof Error ? err.message : String(err) }; } }; const checkModelConnectivity: Check = async (ctx) => { if (!ctx.config) { return { status: 'skip', label: 'Model connectivity', detail: '(config invalid)' }; } const models = ctx.config.models; const model = models.default; if (!model.model) { return { status: 'fail', label: 'Model connectivity', detail: 'no default model configured' }; } // Check if API key is present for providers that need one const needsKey = ['anthropic', 'openai', 'gemini', 'openrouter']; if (needsKey.includes(model.provider) && !model.api_key && !model.auth_token) { const envVarMap: Record = { anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY', openrouter: 'OPENROUTER_API_KEY', }; const envVar = envVarMap[model.provider]; const hasEnv = envVar && process.env[envVar]; if (!hasEnv) { return { status: 'warn', label: 'Model connectivity', detail: `${model.provider}/${model.model} — no API key or auth token found` }; } } // Build a summary of the model stack const parts = [`default: ${model.provider}/${model.model}`]; if (models.fast) {parts.push(`fast: ${models.fast.provider}/${models.fast.model}`);} if (models.complex) {parts.push(`complex: ${models.complex.provider}/${models.complex.model}`);} if (models.local) {parts.push(`local: ${models.local.provider}/${models.local.model}`);} parts.push(`fallback: [${models.fallback_chain.join(', ')}]`); return { status: 'pass', label: 'Model connectivity', detail: parts.join(', ') }; }; const checkTelegram: Check = async (ctx) => { if (!ctx.config) { return { status: 'skip', label: 'Telegram bot configured', detail: '(config invalid)' }; } if (!ctx.config.telegram) { return { status: 'skip', label: 'Telegram bot configured', detail: '(not configured)' }; } if (!ctx.config.telegram.bot_token || ctx.config.telegram.bot_token.length < 10) { return { status: 'warn', label: 'Telegram bot configured', detail: 'token looks too short' }; } return { status: 'pass', label: 'Telegram bot configured', detail: `(${ctx.config.telegram.allowed_chat_ids.length} allowed chat(s))` }; }; const checkMcpServers: Check = async (ctx) => { if (!ctx.config) { return { status: 'skip', label: 'MCP servers configured', detail: '(config invalid)' }; } const servers = ctx.config.mcp.servers; if (servers.length === 0) { return { status: 'skip', label: 'MCP servers configured', detail: '(none configured)' }; } return { status: 'pass', label: 'MCP servers configured', detail: `(${servers.length} server(s))` }; }; const checkSkills: Check = async (ctx) => { if (!ctx.config) { return { status: 'skip', label: 'Skills loaded', detail: '(config invalid)' }; } try { const skillDirs = { bundled: ctx.config.skills.bundled_dir, managed: ctx.config.skills.managed_dir, workspace: ctx.config.skills.workspace_dir, }; const missingDirs = Object.entries(skillDirs) .filter(([, dir]) => Boolean(dir) && !existsSync(dir as string)) .map(([tier, dir]) => `${tier}:${dir as string}`); const { loadAllSkills } = await import('../skills/index.js'); const skills = loadAllSkills({ bundledDir: skillDirs.bundled, managedDir: skillDirs.managed, workspaceDir: skillDirs.workspace, }); const available = skills.filter((skill) => skill.available).length; const unavailable = skills.length - available; const detailParts = [`${skills.length} skill(s), ${available} available, ${unavailable} unavailable`]; if (missingDirs.length > 0) { detailParts.push(`missing dirs: ${missingDirs.join(', ')}`); return { status: 'warn', label: 'Skills loaded', detail: detailParts.join(' — ') }; } return { status: 'pass', label: 'Skills loaded', detail: detailParts.join(' — ') }; } catch (err) { return { status: 'fail', label: 'Skills loaded', detail: err instanceof Error ? err.message : String(err) }; } }; const checkTailscale: Check = async (ctx) => { if (!ctx.config?.server?.tailscale?.serve) { return { status: 'skip', label: 'Tailscale Serve', detail: '(not enabled)' }; } try { const { isTailscaleAvailable } = await import('../gateway/tailscale.js'); const result = await isTailscaleAvailable(); if (result.available) { return { status: 'pass', label: 'Tailscale Serve', detail: `(v${result.version})` }; } return { status: 'fail', label: 'Tailscale Serve', detail: result.error ?? 'Tailscale not available' }; } catch (err) { return { status: 'fail', label: 'Tailscale Serve', detail: err instanceof Error ? err.message : String(err) }; } }; function expandPath(p: string): string { if (p.startsWith('~/') || p === '~') { return resolve(homedir(), p.slice(2)); } return resolve(p); } const checkGmail: Check = async (ctx) => { if (!ctx.config) { return { status: 'skip', label: 'Gmail configured', detail: '(config invalid)' }; } const gmail = ctx.config.automation.gmail; if (!gmail?.enabled) { return { status: 'skip', label: 'Gmail configured', detail: '(not enabled)' }; } const credentialsPath = expandPath(gmail.credentials_file ?? '~/.config/flynn/gmail-credentials.json'); if (!existsSync(credentialsPath)) { return { status: 'fail', label: 'Gmail configured', detail: `credentials file not found: ${credentialsPath}` }; } const tokenPath = expandPath(gmail.token_file ?? '~/.config/flynn/gmail-token.json'); if (!existsSync(tokenPath)) { return { status: 'warn', label: 'Gmail configured', detail: 'run `flynn gmail-auth` to authenticate' }; } return { status: 'pass', label: 'Gmail configured', detail: `(output: ${gmail.output.channel}/${gmail.output.peer})` }; }; const allChecks: Check[] = [ checkConfigExists, checkOverlayExists, checkConfigParses, checkConfigValidates, checkEnvVars, checkDataDir, checkSessionDb, checkModelConnectivity, checkTelegram, checkGmail, checkMcpServers, checkSkills, checkTailscale, ]; /** Run all doctor checks in order. Exported for testing. */ export async function runChecks(ctx: DoctorContext): Promise { const results: CheckResult[] = []; for (const check of allChecks) { const result = await check(ctx); results.push(result); } return results; } export function registerDoctorCommand(program: Command): void { program .command('doctor') .description('Validate configuration and check system health') .option('-c, --config ', 'Config file path') .action(async (opts: { config?: string }) => { const configPath = opts.config ?? getConfigPath(); const dataDir = getDataDir(); console.log('Flynn Doctor'); console.log('============'); console.log(''); const ctx: DoctorContext = { configPath, dataDir }; const results = await runChecks(ctx); for (const result of results) { console.log(formatStatus(result.status, result.label, result.detail)); } console.log(''); const counts = { pass: results.filter(r => r.status === 'pass').length, fail: results.filter(r => r.status === 'fail').length, warn: results.filter(r => r.status === 'warn').length, skip: results.filter(r => r.status === 'skip').length, }; console.log(`Results: ${counts.pass} passed, ${counts.fail} failed, ${counts.warn} warnings, ${counts.skip} skipped`); process.exit(counts.fail > 0 ? 1 : 0); }); }