UNPKG

voyageai-cli

Version:

CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search

638 lines (560 loc) 19.4 kB
'use strict'; const os = require('os'); const fs = require('fs'); const path = require('path'); const pc = require('picocolors'); const ui = require('../lib/ui'); const { getConfigValue, setConfigValue, CONFIG_DIR, CONFIG_PATH } = require('../lib/config'); const { getApiBase } = require('../lib/api'); const { resolveLLMConfig, createLLMProvider } = require('../lib/llm'); /** * vai doctor — Health check command * Validates the entire setup chain: Node version, API key, MongoDB connectivity, * peer dependencies, and configuration. */ const CHECKS = { node: { name: 'Node.js Version', required: true }, apiKey: { name: 'Voyage AI API Key', required: true }, apiConnection: { name: 'Voyage AI API Connection', required: true }, llmKey: { name: 'LLM API Key', required: false }, llmConnection: { name: 'LLM API Connection', required: false }, mongodb: { name: 'MongoDB Connection', required: false }, pdfParse: { name: 'PDF Support (pdf-parse)', required: false }, nano: { name: 'Local Inference (voyage-4-nano)', required: false }, config: { name: 'Configuration Files', required: false }, }; function checkMark(ok) { return ok ? ui.green('✓') : pc.red('✗'); } function warnMark() { return pc.yellow('⚠'); } async function checkNodeVersion() { const version = process.version; const major = parseInt(version.slice(1).split('.')[0], 10); const minVersion = 18; const ok = major >= minVersion; return { ok, message: ok ? `${version} (meets minimum v${minVersion})` : `${version}${pc.red(`requires v${minVersion}+`)}`, hint: ok ? null : 'Upgrade Node.js: https://nodejs.org/', }; } async function checkApiKey() { const key = process.env.VOYAGE_API_KEY || getConfigValue('apiKey'); const masked = key ? `${key.slice(0, 8)}...${key.slice(-4)}` : null; if (!key) { return { ok: false, message: 'Not configured', hint: 'Set via: vai config set api-key YOUR_KEY\n or: export VOYAGE_API_KEY=YOUR_KEY\n Get a key: https://dash.voyageai.com/api-keys', }; } // Check key format (Voyage keys typically start with pa- or similar) const validFormat = key.length > 20; return { ok: true, message: masked, hint: validFormat ? null : 'Key format looks unusual — verify at dash.voyageai.com', }; } async function checkApiConnection() { const key = process.env.VOYAGE_API_KEY || getConfigValue('apiKey'); if (!key) { return { ok: false, message: 'Skipped (no API key)', hint: null, }; } const baseUrl = getApiBase(); try { const https = require('https'); const url = require('url'); const parsed = new URL(`${baseUrl}/embeddings`); const result = await new Promise((resolve, reject) => { const req = https.request({ hostname: parsed.hostname, port: parsed.port || 443, path: parsed.pathname, method: 'POST', headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json', }, timeout: 10000, }, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { // 200 = success, 400 = bad request (but server reachable), 401 = bad key if (res.statusCode === 401) { resolve({ ok: false, status: 401, message: 'Invalid API key' }); } else if (res.statusCode === 400) { // Bad request means server is reachable, key is valid resolve({ ok: true, status: 400, message: 'Connected' }); } else if (res.statusCode === 200) { resolve({ ok: true, status: 200, message: 'Connected' }); } else { resolve({ ok: false, status: res.statusCode, message: `HTTP ${res.statusCode}` }); } }); }); req.on('error', (err) => reject(err)); req.on('timeout', () => { req.destroy(); reject(new Error('Connection timeout')); }); // Send minimal request body req.write(JSON.stringify({ model: 'voyage-4-lite', input: ['test'] })); req.end(); }); return { ok: result.ok, message: result.ok ? `${result.message} (${baseUrl})` : result.message, hint: result.ok ? null : result.status === 401 ? 'API key is invalid — get a new one at dash.voyageai.com' : 'Check your network connection and API base URL', }; } catch (err) { return { ok: false, message: `Connection failed: ${err.message}`, hint: 'Check your network connection or firewall settings', }; } } const LLM_KEY_HINTS = { anthropic: 'Get a key at: https://console.anthropic.com/settings/keys\n Set via: vai config set llm-api-key YOUR_KEY', openai: 'Get a key at: https://platform.openai.com/api-keys\n Set via: vai config set llm-api-key YOUR_KEY', ollama: null, }; async function checkLLMKey() { const config = resolveLLMConfig(); if (!config.provider) { return { ok: null, message: 'Not configured (optional)', hint: 'Set via: vai config set llm-provider anthropic\n Required for vai chat, workflows with LLM steps', }; } // Ollama doesn't need a key if (config.provider === 'ollama') { return { ok: true, message: `${config.provider} (no key required)`, hint: null, }; } if (!config.apiKey) { return { ok: false, message: `${config.provider} — no API key set`, hint: LLM_KEY_HINTS[config.provider] || 'Set via: vai config set llm-api-key YOUR_KEY', }; } const masked = `${config.apiKey.slice(0, 10)}...${config.apiKey.slice(-4)}`; return { ok: true, message: `${config.provider} (${masked})`, hint: null, }; } async function checkLLMConnection() { const config = resolveLLMConfig(); if (!config.provider) { return { ok: null, message: 'Skipped (no LLM provider configured)', hint: null, }; } if (config.provider !== 'ollama' && !config.apiKey) { return { ok: false, message: 'Skipped (no API key)', hint: null, }; } try { const provider = createLLMProvider(); const result = await provider.ping(); if (result.ok) { return { ok: true, message: `Connected (${config.provider}, model: ${result.model || config.model || 'default'})`, hint: null, }; } let errorMsg = result.error || 'Unknown error'; // Try to extract a clean message from JSON error bodies try { const parsed = JSON.parse(errorMsg.replace(/^HTTP \d+:\s*/, '')); if (parsed.error?.message) errorMsg = parsed.error.message; } catch { /* use raw message */ } const isAuthError = errorMsg.includes('401') || errorMsg.includes('authentication') || errorMsg.includes('invalid'); return { ok: false, message: `${config.provider}${errorMsg}`, hint: isAuthError ? (LLM_KEY_HINTS[config.provider] || 'Check your API key') : 'Check your network connection and LLM base URL', }; } catch (err) { return { ok: false, message: `${config.provider}${err.message}`, hint: LLM_KEY_HINTS[config.provider] || 'Check your API key and provider settings', }; } } async function checkMongoDB() { const uri = process.env.MONGODB_URI || getConfigValue('mongoUri'); if (!uri) { return { ok: null, // null = not configured (optional) message: 'Not configured (optional)', hint: 'Set via: vai config set mongo-uri YOUR_URI\n Required for vai store, vai search, vai query', }; } try { const { MongoClient } = require('mongodb'); const client = new MongoClient(uri, { serverSelectionTimeoutMS: 5000, connectTimeoutMS: 5000, }); await client.connect(); await client.db().command({ ping: 1 }); await client.close(); // Mask the URI const masked = uri.replace(/:\/\/[^@]+@/, '://***@').replace(/\?.*$/, ''); return { ok: true, message: `Connected (${masked})`, hint: null, }; } catch (err) { return { ok: false, message: `Connection failed: ${err.message}`, hint: 'Verify your connection string and network access', }; } } async function checkPdfParse() { try { require.resolve('pdf-parse'); return { ok: true, message: 'Installed', hint: null, }; } catch { return { ok: null, // null = not installed (optional) message: 'Not installed (optional)', hint: 'Install for PDF support: npm install pdf-parse', }; } } async function checkConfig() { const homeConfig = path.join(os.homedir(), '.vai', 'config.json'); const projectConfig = path.join(process.cwd(), '.vai.json'); const results = []; // Check home config if (fs.existsSync(homeConfig)) { try { const stat = fs.statSync(homeConfig); const mode = (stat.mode & 0o777).toString(8); const secure = mode === '600'; results.push(`~/.vai/config.json: ${secure ? 'OK' : pc.yellow(`permissions ${mode} (should be 600)`)}`); } catch { results.push('~/.vai/config.json: exists'); } } // Check project config if (fs.existsSync(projectConfig)) { try { JSON.parse(fs.readFileSync(projectConfig, 'utf8')); results.push('.vai.json: OK'); } catch { results.push(`.vai.json: ${pc.red('invalid JSON')}`); } } if (results.length === 0) { return { ok: null, message: 'No config files found', hint: 'Run: vai init (for project config) or vai config set (for user config)', }; } return { ok: true, message: results.join(', '), hint: null, }; } // ── Fix functions (for --fix mode) ── async function fixApiKey() { const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { console.log(pc.cyan('\n Fixing: Voyage AI API Key')); console.log(pc.dim(' Get a key at: https://dash.voyageai.com/api-keys\n')); rl.question(' Enter your Voyage AI API key: ', (key) => { rl.close(); const trimmed = (key || '').trim(); if (!trimmed) { console.log(pc.yellow(' Skipped (no key entered)')); resolve(false); return; } try { setConfigValue('apiKey', trimmed); // Also set in env for the current session so the connection check passes process.env.VOYAGE_API_KEY = trimmed; console.log(ui.green(' ✓ API key saved to ~/.vai/config.json')); resolve(true); } catch (err) { console.log(pc.red(` ✗ Failed to save: ${err.message}`)); resolve(false); } }); }); } async function fixLLMKey() { const readline = require('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const config = resolveLLMConfig(); return new Promise((resolve) => { console.log(pc.cyan('\n Fixing: LLM API Key')); if (config.provider) { const urls = { anthropic: 'https://console.anthropic.com/settings/keys', openai: 'https://platform.openai.com/api-keys' }; if (urls[config.provider]) console.log(pc.dim(` Get a key at: ${urls[config.provider]}\n`)); } rl.question(' Enter your LLM API key: ', (key) => { rl.close(); const trimmed = (key || '').trim(); if (!trimmed) { console.log(pc.yellow(' Skipped (no key entered)')); resolve(false); return; } try { setConfigValue('llmApiKey', trimmed); process.env.VAI_LLM_API_KEY = trimmed; console.log(ui.green(' ✓ LLM API key saved to ~/.vai/config.json')); resolve(true); } catch (err) { console.log(pc.red(` ✗ Failed to save: ${err.message}`)); resolve(false); } }); }); } function fixConfigPermissions() { try { fs.chmodSync(CONFIG_PATH, 0o600); console.log(ui.green(' ✓ Fixed ~/.vai/config.json permissions to 600')); return true; } catch (err) { console.log(pc.red(` ✗ Failed to fix permissions: ${err.message}`)); return false; } } function fixConfigDir() { try { fs.mkdirSync(CONFIG_DIR, { recursive: true }); console.log(ui.green(' ✓ Created ~/.vai/ directory')); return true; } catch (err) { console.log(pc.red(` ✗ Failed to create directory: ${err.message}`)); return false; } } async function fixPdfParse() { const { execSync } = require('child_process'); console.log(pc.cyan('\n Installing pdf-parse...')); try { execSync('npm install pdf-parse', { stdio: 'pipe' }); console.log(ui.green(' ✓ pdf-parse installed')); return true; } catch (err) { console.log(pc.red(` ✗ Failed to install: ${err.message}`)); return false; } } function checkNano() { try { const { checkPython, checkVenv, checkDeps, checkModel } = require('../nano/nano-health'); const python = checkPython(); if (!python.ok) { return { ok: null, message: 'Python 3.10+ not found', hint: 'Run: vai nano setup' }; } const venv = checkVenv(); if (!venv.ok) { return { ok: null, message: 'Not set up', hint: 'Run: vai nano setup (one-time install — enables free local embeddings)' }; } const deps = checkDeps(); if (!deps.ok) { return { ok: null, message: 'Dependencies missing', hint: 'Run: vai nano setup' }; } const model = checkModel(); if (!model.ok) { return { ok: null, message: 'Model not downloaded', hint: 'Run: vai nano setup' }; } return { ok: true, message: 'Ready (free local embeddings via voyage-4-nano)' }; } catch { return { ok: null, message: 'Not set up', hint: 'Run: vai nano setup (one-time install — enables free local embeddings)' }; } } async function runDoctor(options = {}) { const { json, verbose, fix } = options; console.log(pc.bold('\n🩺 Voyage AI CLI Health Check\n')); const results = {}; let hasError = false; let hasWarning = false; const fixable = []; // Run all checks const checks = [ { key: 'node', fn: checkNodeVersion }, { key: 'apiKey', fn: checkApiKey }, { key: 'apiConnection', fn: checkApiConnection }, { key: 'llmKey', fn: checkLLMKey }, { key: 'llmConnection', fn: checkLLMConnection }, { key: 'mongodb', fn: checkMongoDB }, { key: 'pdfParse', fn: checkPdfParse }, { key: 'nano', fn: checkNano }, { key: 'config', fn: checkConfig }, ]; for (const { key, fn } of checks) { const check = CHECKS[key]; const result = await fn(); results[key] = result; // Determine status icon let icon; if (result.ok === true) { icon = checkMark(true); } else if (result.ok === false) { icon = checkMark(false); if (check.required) hasError = true; else hasWarning = true; } else { icon = warnMark(); hasWarning = true; } // Print result console.log(` ${icon} ${pc.bold(check.name)}: ${result.message}`); if (result.hint && (verbose || result.ok === false)) { console.log(` ${pc.dim(result.hint)}`); } // Track fixable issues if (result.ok !== true) { if (key === 'apiKey' && result.ok === false) fixable.push('apiKey'); if (key === 'llmKey' && result.ok === false) fixable.push('llmKey'); if (key === 'pdfParse' && result.ok === null) fixable.push('pdfParse'); if (key === 'config' && result.message && result.message.includes('permissions')) fixable.push('configPerms'); if (key === 'config' && result.ok === null) fixable.push('configDir'); } } // Summary console.log(''); if (hasError) { console.log(pc.red(' ✗ Some required checks failed. Fix the issues above to use vai.\n')); } else if (hasWarning) { console.log(pc.yellow(' ⚠ Some optional features are not configured.\n')); } else { console.log(ui.green(' ✓ All checks passed. vai is ready to use!\n')); } // --fix mode: attempt automatic repairs if (fix && fixable.length > 0) { console.log(pc.bold(' 🔧 Attempting fixes...\n')); let fixed = 0; for (const item of fixable) { if (item === 'configDir') { if (fixConfigDir()) fixed++; } if (item === 'apiKey') { if (await fixApiKey()) fixed++; } if (item === 'llmKey') { if (await fixLLMKey()) fixed++; } if (item === 'configPerms') { if (fixConfigPermissions()) fixed++; } if (item === 'pdfParse') { if (await fixPdfParse()) fixed++; } } console.log(''); if (fixed > 0) { console.log(ui.green(` ✓ Fixed ${fixed} issue${fixed === 1 ? '' : 's'}. Run ${pc.bold('vai doctor')} again to verify.\n`)); } else { console.log(pc.yellow(' No issues were fixed. See hints above for manual steps.\n')); } return 0; } else if (fix && fixable.length === 0 && (hasError || hasWarning)) { console.log(pc.dim(' No auto-fixable issues found. See hints above for manual steps.\n')); } // Suggest --fix if there are fixable issues and not in fix mode if (!fix && fixable.length > 0) { console.log(pc.dim(` Tip: run ${pc.bold('vai doctor --fix')} to attempt automatic repairs\n`)); } // Suggest next steps if (!hasError) { console.log(pc.dim(' Next steps:')); console.log(pc.dim(' vai demo — Interactive walkthrough')); console.log(pc.dim(' vai quickstart — Zero-to-search tutorial')); console.log(pc.dim(' vai explain — Learn key concepts\n')); } if (json) { console.log(JSON.stringify(results, null, 2)); } return hasError ? 1 : 0; } function register(program) { program .command('doctor') .description('Run health checks on your vai setup') .option('--json', 'Output results as JSON') .option('-v, --verbose', 'Show hints for all checks') .option('-f, --fix', 'Attempt to fix issues automatically') .action(async (options) => { const exitCode = await runDoctor(options); if (exitCode !== 0) process.exit(exitCode); }); } /** * Run all checks programmatically and return structured results. * Used by the playground /api/doctor endpoint. */ async function runChecks() { const checks = [ { key: 'node', fn: checkNodeVersion }, { key: 'apiKey', fn: checkApiKey }, { key: 'apiConnection', fn: checkApiConnection }, { key: 'llmKey', fn: checkLLMKey }, { key: 'llmConnection', fn: checkLLMConnection }, { key: 'mongodb', fn: checkMongoDB }, { key: 'pdfParse', fn: checkPdfParse }, { key: 'config', fn: checkConfig }, ]; const results = {}; for (const { key, fn } of checks) { const result = await fn(); // Strip picocolors ANSI codes from messages for JSON output const clean = (s) => typeof s === 'string' ? s.replace(/\x1b\[[0-9;]*m/g, '') : s; results[key] = { name: CHECKS[key].name, required: CHECKS[key].required, ok: result.ok, message: clean(result.message), hint: result.hint ? clean(result.hint) : null, }; } return results; } module.exports = { register, runDoctor, runChecks };