UNPKG

spaps

Version:

Sweet Potato Authentication & Payment Service CLI - Docker Compose orchestrator for local Python/FastAPI SPAPS server with built-in admin middleware

367 lines (348 loc) • 12.7 kB
const fs = require('fs'); const path = require('path'); const os = require('os'); const net = require('net'); const chalk = require('chalk'); const { getServerStatus } = require('./ai-helper'); const { DEFAULT_PORT } = require('./config'); const { listDomains } = require('./domains'); const { buildAuthDoctorChecks, fetchAuthMethods, resolveDiagnosticOrigin, } = require('./auth/surface'); function checkNodeVersion() { const version = process.versions.node || '0.0.0'; const major = parseInt(version.split('.')[0], 10) || 0; const ok = major >= 16; return { check: 'node_version', success: ok, details: { version, requirement: '>=16' }, fix: ok ? null : 'Upgrade Node.js to v18+ (recommended)' }; } async function checkPort(port, serverUrl = null) { // If server is running, we consider port check OK const status = await getServerStatus({ port, serverUrl }); if (status.running) { return { check: 'port', success: true, details: { port, running: true, url: status.url }, fix: null }; } if (serverUrl) { return { check: 'port', success: false, details: { port, running: false, url: status.url, error: status.error || null }, fix: `Check SPAPS_API_URL or --server-url (${status.url})`, }; } // Otherwise ensure port is free to bind const free = await new Promise((resolve) => { const tester = net.createServer() .once('error', () => resolve(false)) .once('listening', () => tester.once('close', () => resolve(true)).close()) .listen(port, '127.0.0.1'); }); return { check: 'port', success: free, details: { port, running: false, free }, fix: free ? null : `Use a different port: npx spaps local --port ${port + 1}` }; } function checkEnvFile() { const envPath = path.resolve(process.cwd(), '.env.local'); const exists = fs.existsSync(envPath); let hasApiUrl = false; if (exists) { try { const content = fs.readFileSync(envPath, 'utf8'); hasApiUrl = /SPAPS_API_URL\s*=/.test(content); } catch {} } return { check: 'env_file', success: exists && hasApiUrl, details: { path: envPath, exists, hasApiUrl }, fix: exists ? (hasApiUrl ? null : 'Add SPAPS_API_URL to .env.local (http://localhost:3301)') : 'Run: npx spaps init' }; } function checkWritePermissions() { const dir = path.resolve(process.cwd(), '.spaps'); try { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); const tmp = path.join(dir, '_doctor.tmp'); fs.writeFileSync(tmp, 'ok'); fs.unlinkSync(tmp); return { check: 'write_permissions', success: true, details: { dir }, fix: null }; } catch (e) { return { check: 'write_permissions', success: false, details: { dir, error: e.message }, fix: `Make directory writable: chmod -R u+rw ${dir}` }; } } function checkSDKInstalled() { try { require.resolve('spaps-sdk', { paths: [process.cwd()] }); return { check: 'sdk_installed', success: true, details: { package: 'spaps-sdk' }, fix: null }; } catch { return { check: 'sdk_installed', success: false, details: { package: 'spaps-sdk' }, fix: 'npm install spaps-sdk' }; } } function checkStripeMode(stripeModeOpt) { const mode = (stripeModeOpt || (process.env.USE_REAL_STRIPE === 'false' ? 'mock' : 'real')).toLowerCase(); const needsKey = mode === 'real'; const hasKey = Boolean(process.env.STRIPE_SECRET_KEY); const ok = mode === 'mock' || (mode === 'real' && hasKey); return { check: 'stripe_mode', success: ok, details: { mode, needsKey, hasKey }, fix: ok ? null : (mode === 'real' ? 'Set STRIPE_SECRET_KEY or run with --stripe mock' : null) }; } function checkEnvTest() { const envPath = path.resolve(process.cwd(), '.env.test'); if (!fs.existsSync(envPath)) { return { check: 'env_test', success: false, details: { path: envPath, exists: false }, fix: 'Create .env.test with SPAPS_API_URL=http://localhost:3301 (no real network keys)' }; } try { const content = fs.readFileSync(envPath, 'utf8'); const hasLocalUrl = /SPAPS_API_URL\s*=\s*http:\/\/localhost:\d+/.test(content); const hasApiKey = /SPAPS_API_KEY\s*=\s*\S+/.test(content); const warns = []; if (!hasLocalUrl) warns.push('SPAPS_API_URL should point to localhost'); if (hasApiKey) warns.push('SPAPS_API_KEY should not be set in tests'); return { check: 'env_test', success: hasLocalUrl && !hasApiKey, details: { path: envPath, hasLocalUrl, hasApiKey }, fix: warns.length ? warns.join(' | ') : null }; } catch (e) { return { check: 'env_test', success: false, details: { error: e.message }, fix: 'Ensure .env.test is readable' }; } } async function checkNextJsPort() { const defaultNextPort = 3000; const inUse = await new Promise((resolve) => { const tester = net.createServer() .once('error', () => resolve(true)) .once('listening', () => tester.once('close', () => resolve(false)).close()) .listen(defaultNextPort, '127.0.0.1'); }); return { check: 'next_port', success: true, details: { port: defaultNextPort, inUse, note: inUse ? 'Next.js likely running (good)' : 'Port free' }, fix: null }; } async function checkWebhook(port) { const status = await getServerStatus(port); if (!status.running) { return { check: 'webhook', success: false, details: { running: false }, fix: `Start server: npx spaps local --port ${port} --stripe mock` }; } try { const http = require('http'); const payload = JSON.stringify({ id: 'evt_doctor_' + Date.now(), type: 'checkout.session.completed', data: { object: { id: 'cs_doctor_' + Date.now() } } }); const ok = await new Promise((resolve) => { const req = http.request({ hostname: 'localhost', port, path: '/api/stripe/webhooks', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } }, (res) => { resolve(res.statusCode >= 200 && res.statusCode < 300); }); req.on('error', () => resolve(false)); req.write(payload); req.end(); }); return { check: 'webhook', success: ok, details: { path: '/api/stripe/webhooks' }, fix: ok ? null : 'Use --stripe mock or ensure webhook handler is reachable' }; } catch (e) { return { check: 'webhook', success: false, details: { error: e.message }, fix: 'Use --stripe mock or ensure server is running' }; } } async function probeDomain(port, domain) { const httpMod = require('http'); const { method, path: probePath, ok_on_404 = false, ok_on_auth_error = false } = domain.probe; return new Promise((resolve) => { const req = httpMod.request( { hostname: 'localhost', port, path: probePath, method, timeout: 2000 }, (res) => { // Drain so the socket can close cleanly. res.on('data', () => {}); res.on('end', () => { const code = res.statusCode || 0; const is2xx = code >= 200 && code < 300; const is404 = code === 404; const isAuth = code === 401 || code === 403; const mounted = is2xx || (ok_on_404 && is404) || (ok_on_auth_error && isAuth); resolve({ check: `domain_${domain.key}_mounted`, success: mounted, details: { probe: `${method} ${probePath}`, status: code, domain: domain.key }, fix: mounted ? null : `Probe ${method} ${probePath} returned ${code}; confirm the ${domain.label} router is mounted on the running server.`, }); }); } ); req.on('error', (err) => { resolve({ check: `domain_${domain.key}_mounted`, success: false, details: { probe: `${method} ${probePath}`, error: err.message, domain: domain.key }, fix: `Server unreachable while probing ${method} ${probePath}. Start the local stack and retry.`, }); }); req.on('timeout', () => { req.destroy(new Error('probe timeout')); }); req.end(); }); } async function checkDomainMounts(port) { const status = await getServerStatus(port); if (!status.running) { return listDomains().map((d) => ({ check: `domain_${d.key}_mounted`, success: false, details: { probe: `${d.probe.method} ${d.probe.path}`, running: false, domain: d.key }, fix: `Start server: npx spaps local --port ${port}`, })); } return Promise.all(listDomains().map((d) => probeDomain(port, d))); } async function checkAuthSurface({ port = DEFAULT_PORT, serverUrl = null, origin = null, cwd = process.cwd(), env = process.env, } = {}) { const runtime = await getServerStatus({ port, serverUrl }); if (!runtime.running) { return [ { check: 'auth_methods', success: false, details: { running: false, url: runtime.url, error: runtime.error || null }, fix: `Start the SPAPS server or pass --server-url to a reachable server before checking /api/auth/methods.`, }, ]; } const resolvedOrigin = resolveDiagnosticOrigin({ origin }); try { const discovery = await fetchAuthMethods({ port, serverUrl: runtime.url, origin: resolvedOrigin, cwd, env, }); const checks = await buildAuthDoctorChecks({ methods: discovery.methods, runtime, origin: resolvedOrigin, }); const matrix = checks.find((check) => check.check === 'auth_methods'); if (matrix) { matrix.details = { ...matrix.details, server_url: discovery.serverUrl, origin: resolvedOrigin, api_key_source: discovery.apiKeySource, }; } return checks; } catch (err) { const status = err.status || null; return [ { check: 'auth_methods', success: false, details: { server_url: runtime.url, origin: resolvedOrigin, status, code: err.code || 'AUTH_METHODS_FAILED', error: err.message || String(err), }, fix: status === 401 || status === 403 ? 'Set SPAPS_API_KEY and SPAPS_ORIGIN (or pass --origin) for the application whose auth methods you are diagnosing.' : 'Ensure GET /api/auth/methods is mounted and reachable on the configured SPAPS server.', }, ]; } } function formatHuman(results) { const ok = results.every(r => r.success); console.log(chalk.yellow('\nšŸ  SPAPS Doctor\n')); results.forEach(r => { const icon = r.success ? chalk.green('āœ”') : chalk.red('āœ–'); console.log(`${icon} ${r.check} ${chalk.gray(JSON.stringify(r.details))}`); if (r.check === 'auth_methods' && Array.isArray(r.details?.methods)) { r.details.methods.forEach((method) => { const state = method.enabled ? chalk.green('enabled') : chalk.gray('disabled'); console.log(` ${method.method}: ${state}`); }); } if (!r.success && r.fix) console.log(chalk.cyan(` fix: ${r.fix}`)); }); console.log(); console.log(ok ? chalk.green('All checks passed!') : chalk.red('Some checks failed. See fixes above.')); } async function runDoctor({ port = DEFAULT_PORT, serverUrl = null, stripe = null, json = false, origin = null, } = {}) { const results = []; results.push(checkNodeVersion()); results.push(await checkPort(port, serverUrl)); // Warn if using 3000 which often collides with Next.js if (port === 3000) { results.push({ check: 'spaps_port_vs_next', success: false, details: { spaps_port: port, suggestion: 'Use 3301 for SPAPS to avoid Next.js conflicts' }, fix: 'Run: npx spaps local --port 3301' }); } else { results.push({ check: 'spaps_port_vs_next', success: true, details: { spaps_port: port }, fix: null }); } results.push(checkEnvFile()); results.push(checkWritePermissions()); results.push(checkSDKInstalled()); results.push(checkStripeMode(stripe)); results.push(checkEnvTest()); results.push(await checkNextJsPort()); results.push(await checkWebhook(port)); const domainResults = await checkDomainMounts(port); results.push(...domainResults); const authResults = await checkAuthSurface({ port, serverUrl, origin }); results.push(...authResults); const ok = results.every(r => r.success); const payload = { success: ok, results, next_steps: ok ? [] : ['Apply suggested fixes and re-run: npx spaps doctor --json'] }; if (json) { console.log(JSON.stringify(payload, null, 2)); } else { formatHuman(results); } return payload; } module.exports = { runDoctor, checkDomainMounts, checkAuthSurface };