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
JavaScript
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 };