Files
flynn/src/cli/doctor.ts
T

690 lines
26 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';
import {
checkMinioExtractorStatus,
getMinioExtractorInstallHints,
summarizeMinioExtractorStatus,
} from './minioExtractors.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>;
type UnknownRecord = Record<string, unknown>;
const asRecord = (value: unknown): UnknownRecord | undefined => (
value && typeof value === 'object' ? value as UnknownRecord : undefined
);
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 = asRecord(parse(raw));
const server = asRecord(parsed?.server);
const tailscaleOnly = Boolean(server && 'tailscale_only' in 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<string, unknown>): 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<string, unknown> => {
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<string, unknown> : {};
} 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<string, unknown>;
// 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;
const oauthRecord = asRecord(oauth);
return Boolean(
oauthRecord
&& typeof oauthRecord.access_token === 'string'
&& typeof oauthRecord.refresh_token === 'string',
);
};
const storeOpenAIApiKeyPresent = (): boolean => {
const openai = store.openai as unknown;
if (!openai || typeof openai !== 'object') {
return false;
}
const o = openai as Record<string, unknown>;
const apiKey = o.api_key;
const apiKeyRecord = asRecord(apiKey);
return Boolean(
typeof apiKeyRecord?.api_key === 'string'
&& apiKeyRecord.api_key.length > 0,
);
};
const storeAnthropicApiKeyPresent = (): boolean => {
const anthropic = asRecord(store.anthropic);
return Boolean(typeof anthropic?.api_key === 'string' && anthropic.api_key.length > 0);
};
const storeAnthropicAuthTokenPresent = (): boolean => {
const anthropic = asRecord(store.anthropic);
return Boolean(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<string, unknown>): { 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', 'minimax', 'moonshot', 'github'];
if (needsKey.includes(provider)) {
const envVarMap: Record<string, string> = {
gemini: 'GEMINI_API_KEY',
openrouter: 'OPENROUTER_API_KEY',
vercel: 'AI_GATEWAY_API_KEY',
xai: 'XAI_API_KEY',
minimax: 'MINIMAX_API_KEY',
moonshot: 'MOONSHOT_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 && typeof process.env[envVar] === 'string' && 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<string, unknown> }> = [
{ tier: 'default', cfg: models.default as unknown as Record<string, unknown> },
{ tier: 'fast', cfg: models.fast as unknown as Record<string, unknown> | undefined },
{ tier: 'complex', cfg: models.complex as unknown as Record<string, unknown> | undefined },
{ tier: 'local', cfg: models.local as unknown as Record<string, unknown> | 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' };
}
const mentionMode = ctx.config.telegram.require_mention ? 'mention-gated groups' : 'all allowed group messages';
return {
status: 'pass',
label: 'Telegram bot configured',
detail: `(${ctx.config.telegram.allowed_chat_ids.length} allowed chat(s), mode: ${mentionMode})`,
};
};
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) };
}
};
function toRegistrySource(value: string): { type: 'file'; path: string } | { type: 'url'; url: string } | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith('https://')) {
return { type: 'url', url: trimmed };
}
if (trimmed.startsWith('http://')) {
return null;
}
return { type: 'file', path: trimmed };
}
const checkSkillsRegistry: Check = async (ctx) => {
if (!ctx.config) {
return { status: 'skip', label: 'Skills registry', detail: '(config invalid)' };
}
const configured = ctx.config.skills.registry_source?.trim() || process.env.FLYNN_SKILLS_REGISTRY_SOURCE?.trim() || '';
if (!configured) {
return {
status: 'warn',
label: 'Skills registry',
detail: 'registry discovery unconfigured (set skills.registry_source or FLYNN_SKILLS_REGISTRY_SOURCE)',
};
}
const source = toRegistrySource(configured);
if (!source) {
return {
status: 'fail',
label: 'Skills registry',
detail: `invalid registry source '${configured}' (use local path or https:// URL)`,
};
}
try {
const { loadSkillRegistryCatalog } = await import('../skills/index.js');
const catalog = await loadSkillRegistryCatalog(source);
const sourceLabel = source.type === 'file' ? source.path : source.url;
return {
status: 'pass',
label: 'Skills registry',
detail: `loaded ${catalog.skills.length} entr${catalog.skills.length === 1 ? 'y' : 'ies'} from ${sourceLabel}`,
};
} catch (err) {
return {
status: 'fail',
label: 'Skills registry',
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<string, unknown>;
const installed = (creds.installed as Record<string, unknown> | undefined) ?? (creds.web as Record<string, unknown> | 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/<project>/topics/<topic>)');
}
} 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/<project>/subscriptions/<sub>)');
}
} 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 checkMinioExtractors: Check = async (ctx) => {
if (!ctx.config) {
return { status: 'skip', label: 'MinIO ingest extractors', detail: '(config invalid)' };
}
const status = await checkMinioExtractorStatus(ctx.config as unknown as Record<string, unknown>);
if (!status.minioEnabled) {
return { status: 'skip', label: 'MinIO ingest extractors', detail: '(backup.minio not enabled)' };
}
const summary = summarizeMinioExtractorStatus(status);
if (status.missingRequirements.length > 0) {
const installHints = await getMinioExtractorInstallHints(status);
const hint = installHints.length > 0 ? `; hint: ${installHints[0]}` : '';
return {
status: 'warn',
label: 'MinIO ingest extractors',
detail: `${summary} — install missing extractors for PDF/DOCX ingestion${hint}`,
};
}
return { status: 'pass', label: 'MinIO ingest extractors', detail: summary };
};
const allChecks: Check[] = [
checkConfigExists,
checkOverlayExists,
checkConfigParses,
checkDeprecatedConfigKeys,
checkConfigValidates,
checkEnvVars,
checkDataDir,
checkSessionDb,
checkModelConnectivity,
checkTelegram,
checkGmail,
checkMinioExtractors,
checkMcpServers,
checkSkills,
checkSkillsRegistry,
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 computeDoctorExitCode(results: CheckResult[], strict: boolean): number {
const failCount = results.filter((r) => r.status === 'fail').length;
const warnCount = results.filter((r) => r.status === 'warn').length;
if (failCount > 0) {
return 1;
}
if (strict && warnCount > 0) {
return 1;
}
return 0;
}
export function registerDoctorCommand(program: Command): void {
program
.command('doctor')
.description('Validate configuration and check system health')
.option('-c, --config <path>', 'Config file path')
.option('--strict', 'Treat warnings as failures')
.action(async (opts: { config?: string; strict?: boolean }) => {
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`);
if (opts.strict && counts.warn > 0) {
console.log('Strict mode enabled: warnings are treated as failures.');
}
process.exit(computeDoctorExitCode(results, Boolean(opts.strict)));
});
}