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 checkDeprecatedConfigKeys: Check = async (ctx) => { if (!existsSync(ctx.configPath)) { return { status: 'skip', label: 'Config deprecated keys', detail: '(no config file)' }; } try { const raw = readFileSync(ctx.configPath, 'utf-8'); const parsed = parse(raw) as any; const tailscaleOnly = Boolean(parsed?.server && typeof parsed.server === 'object' && 'tailscale_only' in parsed.server); if (tailscaleOnly) { return { status: 'warn', label: 'Config deprecated keys', detail: 'server.tailscale_only is deprecated/ignored; use server.tailscale.* + server.localhost instead', }; } return { status: 'pass', label: 'Config deprecated keys' }; } catch { return { status: 'skip', label: 'Config deprecated keys', detail: '(could not read/parse config)' }; } }; 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' }; } type AuthMode = 'auto' | 'api_key' | 'oauth'; const getEffectiveAuthMode = (cfg: Record): AuthMode => { const mode = cfg.auth_mode; if (mode === 'auto' || mode === 'api_key' || mode === 'oauth') { return mode; } const useOauth = Boolean(cfg.use_oauth); if (useOauth) { return 'oauth'; } return 'auto'; }; const loadAuthStore = (): Record => { const home = process.env.HOME ?? homedir(); const authFile = resolve(home, '.config/flynn/auth.json'); try { const raw = readFileSync(authFile, 'utf-8'); const parsed = JSON.parse(raw) as unknown; return (parsed && typeof parsed === 'object') ? parsed as Record : {}; } catch { return {}; } }; const store = loadAuthStore(); const storeOpenAIOAuthPresent = (): boolean => { const openai = store.openai as unknown; if (!openai || typeof openai !== 'object') { return false; } const o = openai as Record; // Legacy: auth.json.openai = { access_token, refresh_token, ... } if (typeof o.access_token === 'string' && typeof o.refresh_token === 'string') { return true; } const oauth = o.oauth; return Boolean( oauth && typeof oauth === 'object' && typeof (oauth as any).access_token === 'string' && typeof (oauth as any).refresh_token === 'string', ); }; const storeOpenAIApiKeyPresent = (): boolean => { const openai = store.openai as unknown; if (!openai || typeof openai !== 'object') { return false; } const o = openai as Record; const apiKey = o.api_key; return Boolean( apiKey && typeof apiKey === 'object' && typeof (apiKey as any).api_key === 'string' && (apiKey as any).api_key.length > 0, ); }; const storeAnthropicApiKeyPresent = (): boolean => { const anthropic = store.anthropic as any; return Boolean(anthropic && typeof anthropic.api_key === 'string' && anthropic.api_key.length > 0); }; const storeAnthropicAuthTokenPresent = (): boolean => { const anthropic = store.anthropic as any; return Boolean(anthropic && typeof anthropic.auth_token === 'string' && anthropic.auth_token.length > 0); }; const formatSources = (sources: { config: boolean; env: boolean; store: boolean }): string => { const parts: string[] = []; if (sources.config) {parts.push('config');} if (sources.env) {parts.push('env');} if (sources.store) {parts.push('store');} return parts.length > 0 ? parts.join('+') : 'missing'; }; const checkTierAuth = (tier: string, cfg: Record): { status: 'pass' | 'warn' | 'fail'; detail?: string } => { const provider = String(cfg.provider ?? ''); const modelName = String(cfg.model ?? ''); const authMode = getEffectiveAuthMode(cfg); if (provider === 'openai') { if (authMode === 'oauth') { const ok = storeOpenAIOAuthPresent(); if (!ok) { const status = tier === 'default' ? 'fail' : 'warn'; return { status, detail: `${tier}: openai/${modelName} (auth_mode=oauth, oauth=missing — run flynn openai-auth)` }; } return { status: 'pass', detail: `${tier}: openai/${modelName} (auth_mode=oauth, oauth=store)` }; } const sources = { config: typeof cfg.api_key === 'string' && (cfg.api_key as string).length > 0, env: typeof process.env.OPENAI_API_KEY === 'string' && process.env.OPENAI_API_KEY.length > 0, store: storeOpenAIApiKeyPresent(), }; const ok = sources.config || sources.env || sources.store; if (!ok) { const status = (authMode === 'api_key' && tier === 'default') ? 'fail' : 'warn'; const hint = authMode === 'api_key' ? 'Set OPENAI_API_KEY or run flynn openai-key' : 'Set OPENAI_API_KEY (or run flynn openai-key) or run flynn openai-auth'; return { status, detail: `${tier}: openai/${modelName} (auth_mode=${authMode}, api_key=${formatSources(sources)} — ${hint})` }; } return { status: 'pass', detail: `${tier}: openai/${modelName} (auth_mode=${authMode}, api_key=${formatSources(sources)})` }; } if (provider === 'anthropic') { if (authMode === 'oauth') { const sources = { config: typeof cfg.auth_token === 'string' && (cfg.auth_token as string).length > 0, env: typeof process.env.ANTHROPIC_AUTH_TOKEN === 'string' && process.env.ANTHROPIC_AUTH_TOKEN.length > 0, store: storeAnthropicAuthTokenPresent(), }; const ok = sources.config || sources.env || sources.store; if (!ok) { const status = tier === 'default' ? 'fail' : 'warn'; return { status, detail: `${tier}: anthropic/${modelName} (auth_mode=oauth, auth_token=${formatSources(sources)} — set ANTHROPIC_AUTH_TOKEN or run flynn anthropic-auth --token)` }; } return { status: 'pass', detail: `${tier}: anthropic/${modelName} (auth_mode=oauth, auth_token=${formatSources(sources)})` }; } const sources = { config: typeof cfg.api_key === 'string' && (cfg.api_key as string).length > 0, env: typeof process.env.ANTHROPIC_API_KEY === 'string' && process.env.ANTHROPIC_API_KEY.length > 0, store: storeAnthropicApiKeyPresent(), }; const ok = sources.config || sources.env || sources.store; if (!ok) { const status = (authMode === 'api_key' && tier === 'default') ? 'fail' : 'warn'; const hint = authMode === 'api_key' ? 'Set ANTHROPIC_API_KEY or run flynn anthropic-auth' : 'Set ANTHROPIC_API_KEY (or run flynn anthropic-auth) or set ANTHROPIC_AUTH_TOKEN (or run flynn anthropic-auth --token)'; return { status, detail: `${tier}: anthropic/${modelName} (auth_mode=${authMode}, api_key=${formatSources(sources)} — ${hint})` }; } return { status: 'pass', detail: `${tier}: anthropic/${modelName} (auth_mode=${authMode}, api_key=${formatSources(sources)})` }; } // Providers with API-key style auth (no auth store integration yet) const needsKey = ['gemini', 'openrouter', 'vercel', 'xai', 'github']; if (needsKey.includes(provider)) { const envVarMap: Record = { gemini: 'GEMINI_API_KEY', openrouter: 'OPENROUTER_API_KEY', vercel: 'AI_GATEWAY_API_KEY', xai: 'XAI_API_KEY', github: 'GITHUB_TOKEN', }; const envVar = envVarMap[provider]; const sources = { config: typeof cfg.api_key === 'string' && (cfg.api_key as string).length > 0, env: Boolean(envVar && process.env[envVar] && process.env[envVar]!.length > 0), store: false, }; const ok = sources.config || sources.env; if (!ok) { const status = tier === 'default' ? 'warn' : 'warn'; const hint = envVar ? `set ${envVar} or provide api_key in config` : 'provide api_key in config'; return { status, detail: `${tier}: ${provider}/${modelName} (api_key=${formatSources(sources)} — ${hint})` }; } return { status: 'pass', detail: `${tier}: ${provider}/${modelName} (api_key=${formatSources(sources)})` }; } return { status: 'pass', detail: `${tier}: ${provider}/${modelName}` }; }; const tierEntries: Array<{ tier: string; cfg?: Record }> = [ { tier: 'default', cfg: models.default as unknown as Record }, { tier: 'fast', cfg: models.fast as unknown as Record | undefined }, { tier: 'complex', cfg: models.complex as unknown as Record | undefined }, { tier: 'local', cfg: models.local as unknown as Record | undefined }, ]; const details: string[] = []; let hasFail = false; let hasWarn = false; for (const { tier, cfg } of tierEntries) { if (!cfg) {continue;} const r = checkTierAuth(tier, cfg); if (r.detail) {details.push(r.detail);} if (r.status === 'fail') {hasFail = true;} if (r.status === 'warn') {hasWarn = true;} } // Build a summary of the model stack details.push(`fallback: [${models.fallback_chain.join(', ')}]`); const status: CheckResult['status'] = hasFail ? 'fail' : hasWarn ? 'warn' : 'pass'; return { status, label: 'Model connectivity', detail: details.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}` }; } let googleProjectId: string | undefined; try { const creds = JSON.parse(readFileSync(credentialsPath, 'utf-8')) as Record; const installed = (creds.installed as Record | undefined) ?? (creds.web as Record | undefined); const projectId = installed?.project_id; if (typeof projectId === 'string' && projectId.trim()) { googleProjectId = projectId.trim(); } } catch { // Ignore JSON parse errors; doctor will still validate token and output. } 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' }; } const modes: string[] = []; const warnings: string[] = []; const topicRaw = (gmail.pubsub_topic ?? process.env.FLYNN_GMAIL_PUBSUB_TOPIC ?? '').trim(); const pushEnabled = Boolean(topicRaw) && !gmail.disable_push; if (pushEnabled) { modes.push('push'); if (topicRaw.includes('/')) { const ok = /^projects\/[^/]+\/topics\/[^/]+$/.test(topicRaw); if (!ok) { warnings.push('pubsub_topic format invalid (expected projects//topics/)'); } } else if (!googleProjectId) { warnings.push('pubsub_topic shorthand requires project_id in Gmail credentials'); } if (ctx.config.server?.tailscale?.serve) { warnings.push('push requires a public HTTPS endpoint; Tailscale Serve is typically tailnet-only'); } } else if (gmail.disable_push && topicRaw) { warnings.push('push disabled (disable_push=true)'); } const subRaw = (gmail.pubsub_subscription_id ?? '').trim(); if (subRaw) { modes.push('pull'); if (subRaw.includes('/')) { const ok = /^projects\/[^/]+\/subscriptions\/[^/]+$/.test(subRaw); if (!ok) { warnings.push('pubsub_subscription_id format invalid (expected projects//subscriptions/)'); } } else if (!googleProjectId) { warnings.push('pubsub_subscription_id shorthand requires project_id in Gmail credentials'); } } modes.push('poll'); const detail = `(${modes.join(' + ')} -> ${gmail.output.channel}/${gmail.output.peer})`; const withWarnings = warnings.length > 0 ? `${detail} — ${warnings.join('; ')}` : detail; return { status: warnings.length > 0 ? 'warn' : 'pass', label: 'Gmail configured', detail: withWarnings }; }; const allChecks: Check[] = [ checkConfigExists, checkOverlayExists, checkConfigParses, checkDeprecatedConfigKeys, 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); }); }