spaps
Version:
Sweet Potato Authentication & Payment Service CLI - Zero-config local development with built-in admin middleware and permission utilities
218 lines (203 loc) ⢠7.82 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');
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) {
// If server is running, we consider port check OK
const status = await getServerStatus(port);
if (status.running) {
return {
check: 'port',
success: true,
details: { port, running: true, url: status.url },
fix: null
};
}
// 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:3300)') : '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:3300 (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' };
}
}
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.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, stripe = null, json = false } = {}) {
const results = [];
results.push(checkNodeVersion());
results.push(await checkPort(port));
// 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 3300 for SPAPS to avoid Next.js conflicts' },
fix: 'Run: npx spaps local --port 3300'
});
} 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 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 };