From 43985c703fe556cad238d6dfe61311f89966857b Mon Sep 17 00:00:00 2001 From: Colin Date: Wed, 18 Feb 2026 21:18:14 -0500 Subject: [PATCH] feat: add `quorum doctor` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validates the user's Quorum setup in one command: - Config file exists and parses correctly (YAML validation) - Node.js version compatibility (requires ≥20) - Quorum version (current vs latest on npm) - Each configured provider: lightweight API probe to verify auth Output uses ✅/❌/⚠️ icons with a summary line. Exit code 1 if any errors, 0 otherwise. --- src/cli/doctor.test.ts | 139 +++++++++++++++++++++++++++++ src/cli/doctor.ts | 192 +++++++++++++++++++++++++++++++++++++++++ src/cli/index.ts | 2 + 3 files changed, 333 insertions(+) create mode 100644 src/cli/doctor.test.ts create mode 100644 src/cli/doctor.ts diff --git a/src/cli/doctor.test.ts b/src/cli/doctor.test.ts new file mode 100644 index 0000000..a2fd542 --- /dev/null +++ b/src/cli/doctor.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock modules before importing the module under test +vi.mock('../config.js', () => ({ + loadConfig: vi.fn(), + CONFIG_PATH: '/home/test/.quorum/config.yaml', +})); + +vi.mock('../providers/base.js', () => ({ + createProvider: vi.fn(), +})); + +import { runDoctor } from './doctor.js'; +import { loadConfig } from '../config.js'; +import { createProvider } from '../providers/base.js'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; + +// Mock existsSync for config file check +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { ...actual, existsSync: vi.fn(actual.existsSync), readFileSync: actual.readFileSync }; +}); + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises'); + return { ...actual, readFile: vi.fn(actual.readFile) }; +}); + +// Mock fetch for npm version check +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +beforeEach(() => { + vi.clearAllMocks(); + // Default: config file exists + (existsSync as any).mockImplementation((path: string) => { + if (path === '/home/test/.quorum/config.yaml') return true; + const { existsSync: real } = vi.importActual('node:fs') as any; + return real(path); + }); + // Mock readFile for config path to return valid YAML + (readFile as any).mockImplementation(async (path: string, enc?: string) => { + if (path === '/home/test/.quorum/config.yaml') { + return 'providers:\n - name: test\n provider: openai\n model: gpt-4o\n'; + } + const actual = await vi.importActual('node:fs/promises'); + return actual.readFile(path, enc as any); + }); + // Default: npm returns current version + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ version: '0.0.0' }), // will differ from actual + }); +}); + +describe('doctor', () => { + it('returns 0 when all checks pass', async () => { + (loadConfig as any).mockResolvedValue({ + providers: [{ name: 'test-provider', provider: 'openai', model: 'gpt-4o' }], + }); + (createProvider as any).mockResolvedValue({ + generate: vi.fn().mockResolvedValue('ok'), + }); + + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const code = await runDoctor(); + spy.mockRestore(); + + // May be 0 or non-zero depending on version mismatch (warn, not error) + // Provider check should pass + expect(code).toBe(0); + }); + + it('returns 1 when config file is missing', async () => { + (existsSync as any).mockImplementation((path: string) => { + if (path === '/home/test/.quorum/config.yaml') return false; + const { existsSync: real } = vi.importActual('node:fs') as any; + return real(path); + }); + + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const code = await runDoctor(); + spy.mockRestore(); + + expect(code).toBe(1); + }); + + it('returns 1 when a provider fails auth', async () => { + (loadConfig as any).mockResolvedValue({ + providers: [{ name: 'bad-provider', provider: 'openai', model: 'gpt-4o' }], + }); + (createProvider as any).mockRejectedValue( + Object.assign(new Error('Unauthorized'), { status: 401 }), + ); + + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const code = await runDoctor(); + spy.mockRestore(); + + expect(code).toBe(1); + }); + + it('returns 1 when provider connection is refused', async () => { + (loadConfig as any).mockResolvedValue({ + providers: [{ name: 'ollama', provider: 'ollama', model: 'llama3' }], + }); + (createProvider as any).mockRejectedValue( + Object.assign(new Error('fetch failed: ECONNREFUSED'), { code: 'ECONNREFUSED' }), + ); + + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const code = await runDoctor(); + spy.mockRestore(); + + expect(code).toBe(1); + }); + + it('handles multiple providers with mixed results', async () => { + (loadConfig as any).mockResolvedValue({ + providers: [ + { name: 'good', provider: 'openai', model: 'gpt-4o' }, + { name: 'bad', provider: 'deepseek', model: 'deepseek-chat' }, + ], + }); + (createProvider as any).mockImplementation(async (config: any) => { + if (config.name === 'good') { + return { generate: vi.fn().mockResolvedValue('ok') }; + } + throw Object.assign(new Error('402 Payment Required'), { status: 402 }); + }); + + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const code = await runDoctor(); + spy.mockRestore(); + + expect(code).toBe(1); + }); +}); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts new file mode 100644 index 0000000..7f0001f --- /dev/null +++ b/src/cli/doctor.ts @@ -0,0 +1,192 @@ +import type { Command } from 'commander'; +import pc from 'picocolors'; +import { existsSync, readFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { parse } from 'yaml'; +import { loadConfig, CONFIG_PATH } from '../config.js'; +import { createProvider } from '../providers/base.js'; +import type { ProviderConfig } from '../types.js'; + +// ── Types ────────────────────────────────────────────────────────────────── + +type Status = 'ok' | 'warn' | 'error'; + +interface CheckResult { + status: Status; + label: string; + detail: string; +} + +// ── Symbols ──────────────────────────────────────────────────────────────── + +function icon(s: Status): string { + switch (s) { + case 'ok': + return pc.green('✅'); + case 'warn': + return pc.yellow('⚠️'); + case 'error': + return pc.red('❌'); + } +} + +// ── Individual checks ────────────────────────────────────────────────────── + +async function checkConfig(): Promise { + const label = 'Config'; + const path = CONFIG_PATH; + + if (!existsSync(path)) { + return { status: 'error', label, detail: `${path} not found — run \`quorum init\`` }; + } + + try { + const raw = await readFile(path, 'utf-8'); + const parsed = parse(raw); + if (!parsed || !Array.isArray(parsed.providers)) { + return { status: 'error', label, detail: `${path} missing 'providers' array` }; + } + return { status: 'ok', label, detail: `${tildefy(path)} found and valid` }; + } catch (e: any) { + return { status: 'error', label, detail: `${tildefy(path)} parse error: ${e.message}` }; + } +} + +function checkNodeVersion(): CheckResult { + const label = 'Node.js'; + const major = parseInt(process.versions.node.split('.')[0], 10); + const version = `v${process.versions.node}`; + if (major >= 20) { + return { status: 'ok', label, detail: `${version} (requires ≥20)` }; + } + return { status: 'error', label, detail: `${version} — requires ≥20, please upgrade` }; +} + +async function checkQuorumVersion(): Promise { + const label = 'Quorum'; + const pkgPath = new URL('../../package.json', import.meta.url); + const currentVersion = JSON.parse(readFileSync(pkgPath, 'utf-8')).version as string; + + try { + const res = await fetch('https://registry.npmjs.org/quorum-ai/latest', { + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) { + return { status: 'warn', label, detail: `v${currentVersion} (couldn't check latest)` }; + } + const data = (await res.json()) as { version: string }; + const latest = data.version; + if (currentVersion === latest) { + return { status: 'ok', label, detail: `v${currentVersion} (latest)` }; + } + return { + status: 'warn', + label, + detail: `v${currentVersion} — update available: v${latest}`, + }; + } catch { + return { status: 'warn', label, detail: `v${currentVersion} (couldn't check latest)` }; + } +} + +async function checkProvider(config: ProviderConfig): Promise { + const label = config.name; + const start = Date.now(); + + try { + const adapter = await createProvider(config); + const result = await adapter.generate('Say "ok".', 'Respond with only the word ok.'); + const elapsed = Date.now() - start; + if (result && result.length > 0) { + return { status: 'ok', label, detail: `${config.model} — authenticated, ${elapsed}ms` }; + } + return { status: 'warn', label, detail: `${config.model} — empty response, ${elapsed}ms` }; + } catch (e: any) { + const detail = diagnoseError(e, config); + return { status: 'error', label, detail: `${config.model} — ${detail}` }; + } +} + +function diagnoseError(e: any, config: ProviderConfig): string { + const msg = e.message || String(e); + const status = e.status || e.statusCode; + + if (status === 401 || msg.includes('401')) return '401 Unauthorized — check API key'; + if (status === 402 || msg.includes('402')) return '402 Insufficient Balance'; + if (status === 403 || msg.includes('403')) return '403 Forbidden — check permissions'; + if (status === 429 || msg.includes('429')) return '429 Rate Limited — try again later'; + if (msg.includes('ECONNREFUSED')) return `connection refused (is ${config.provider} running?)`; + if (msg.includes('ENOTFOUND')) return 'DNS resolution failed — check network'; + if (msg.includes('ETIMEDOUT') || msg.includes('timed out')) return 'request timed out'; + if (msg.includes('fetch failed')) return 'network error — check connectivity'; + + // Truncate long messages + return msg.length > 100 ? msg.slice(0, 100) + '…' : msg; +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function tildefy(path: string): string { + const home = homedir(); + return path.startsWith(home) ? '~' + path.slice(home.length) : path; +} + +function pad(s: string, len: number): string { + return s.length >= len ? s : s + ' '.repeat(len - s.length); +} + +// ── Main ─────────────────────────────────────────────────────────────────── + +export async function runDoctor(): Promise { + const results: CheckResult[] = []; + + // System checks + const [configResult, versionResult] = await Promise.all([checkConfig(), checkQuorumVersion()]); + const nodeResult = checkNodeVersion(); + + results.push(configResult, nodeResult, versionResult); + + // Provider checks (only if config is valid) + if (configResult.status !== 'error') { + const config = await loadConfig(); + if (config.providers.length > 0) { + const providerResults = await Promise.all(config.providers.map(checkProvider)); + results.push(...providerResults); + } + } + + // Print results + const maxLabel = Math.max(...results.map((r) => r.label.length)); + console.log(''); + for (const r of results) { + console.log(`${icon(r.status)} ${pad(r.label, maxLabel + 2)}${r.detail}`); + } + + // Summary + const ok = results.filter((r) => r.status === 'ok').length; + const warns = results.filter((r) => r.status === 'warn').length; + const errors = results.filter((r) => r.status === 'error').length; + + console.log(''); + const parts: string[] = []; + if (ok > 0) parts.push(pc.green(`${ok} healthy`)); + if (errors > 0) parts.push(pc.red(`${errors} error${errors > 1 ? 's' : ''}`)); + if (warns > 0) parts.push(pc.yellow(`${warns} warning${warns > 1 ? 's' : ''}`)); + console.log(parts.join(', ')); + console.log(''); + + return errors > 0 ? 1 : 0; +} + +// ── CLI registration ─────────────────────────────────────────────────────── + +export function registerDoctorCommand(program: Command): void { + program + .command('doctor') + .description('Check your Quorum setup — config, providers, connectivity') + .action(async () => { + const exitCode = await runDoctor(); + process.exit(exitCode); + }); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index e692981..358af0d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -37,6 +37,7 @@ import { registerAuthCommand } from './auth.js'; import { registerSessionCommands } from './session.js'; import { registerAnalysisCommands } from './analysis.js'; import { registerGovernanceCommands } from './governance.js'; +import { registerDoctorCommand } from './doctor.js'; const program = new Command(); @@ -52,6 +53,7 @@ registerAuthCommand(program); registerSessionCommands(program); registerAnalysisCommands(program); registerGovernanceCommands(program); +registerDoctorCommand(program); // Ensure clean exit after any command (prevents event-loop hangs from dangling handles) program.hook('postAction', (_thisCommand, actionCommand) => {