Files
flynn/src/cli/doctor.ts
T
2026-02-12 17:05:04 -08:00

327 lines
12 KiB
TypeScript

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<CheckResult>;
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<string, string> = {
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<CheckResult[]> {
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 <path>', '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);
});
}