|
| 1 | +// Diagnostic that runs the same checks the server runs at boot, so contributors |
| 2 | +// know whether their setup is broken BEFORE `npm start` and a request stack |
| 3 | +// trace. Prints PASS/FAIL with the fix command for each failure. |
| 4 | +// |
| 5 | +// Exits 0 on all-green, 1 if any check failed. Designed to be safe to run |
| 6 | +// against an already-running server (the port check just notes "in use" — it |
| 7 | +// doesn't try to bind). |
| 8 | + |
| 9 | +import 'dotenv/config'; |
| 10 | +import net from 'node:net'; |
| 11 | +import { existsSync, readFileSync } from 'node:fs'; |
| 12 | +import { fileURLToPath } from 'node:url'; |
| 13 | +import { dirname, join } from 'node:path'; |
| 14 | + |
| 15 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 16 | +const ROOT = join(__dirname, '..'); |
| 17 | + |
| 18 | +const checks = []; |
| 19 | +const ok = (name, detail = '') => checks.push({ name, status: 'PASS', detail }); |
| 20 | +const fail = (name, detail, fix) => checks.push({ name, status: 'FAIL', detail, fix }); |
| 21 | +const warn = (name, detail, fix = '') => checks.push({ name, status: 'WARN', detail, fix }); |
| 22 | + |
| 23 | +// ─── 1. Node version ────────────────────────────────────────────────────────── |
| 24 | +{ |
| 25 | + const major = Number(process.versions.node.split('.')[0]); |
| 26 | + if (major >= 20) ok('Node version', `v${process.versions.node}`); |
| 27 | + else fail('Node version', `v${process.versions.node} — requires v20+`, 'Install Node 20+ from https://nodejs.org'); |
| 28 | +} |
| 29 | + |
| 30 | +// ─── 2. node_modules present ───────────────────────────────────────────────── |
| 31 | +{ |
| 32 | + if (existsSync(join(ROOT, 'node_modules'))) ok('Dependencies installed'); |
| 33 | + else fail('Dependencies installed', 'node_modules/ missing', 'Run: npm install'); |
| 34 | +} |
| 35 | + |
| 36 | +// ─── 3. .env present (warn only — demo mode works without it) ──────────────── |
| 37 | +{ |
| 38 | + if (existsSync(join(ROOT, '.env'))) ok('.env file present'); |
| 39 | + else warn('.env file', 'not found — server will only run in DEMO mode', 'cp .env.example .env && edit MNEMONIC'); |
| 40 | +} |
| 41 | + |
| 42 | +// ─── 4. MNEMONIC validity (only if set) ────────────────────────────────────── |
| 43 | +{ |
| 44 | + const m = (process.env.MNEMONIC || '').trim(); |
| 45 | + if (!m) { |
| 46 | + warn('MNEMONIC', 'not set — only DEMO mode will work', 'Add MNEMONIC=... to .env (12 or 24 words)'); |
| 47 | + } else { |
| 48 | + const words = m.split(/\s+/).filter(Boolean); |
| 49 | + if (words.length !== 12 && words.length !== 24) { |
| 50 | + fail('MNEMONIC', `${words.length} words — must be 12 or 24`, 'Fix MNEMONIC in .env'); |
| 51 | + } else { |
| 52 | + try { |
| 53 | + const { DirectSecp256k1HdWallet } = await import('@cosmjs/proto-signing'); |
| 54 | + const wallet = await DirectSecp256k1HdWallet.fromMnemonic(m, { prefix: 'sent' }); |
| 55 | + const [acc] = await wallet.getAccounts(); |
| 56 | + ok('MNEMONIC', `derives ${acc.address}`); |
| 57 | + } catch (err) { |
| 58 | + fail('MNEMONIC', `failed to derive: ${err.message}`, 'Verify MNEMONIC is a valid bech32 cosmos mnemonic'); |
| 59 | + } |
| 60 | + } |
| 61 | + } |
| 62 | +} |
| 63 | + |
| 64 | +// ─── 5. DEMO_ADDR validity (only if set) ───────────────────────────────────── |
| 65 | +{ |
| 66 | + const d = (process.env.DEMO_ADDR || '').trim(); |
| 67 | + if (d) { |
| 68 | + try { |
| 69 | + const { fromBech32 } = await import('@cosmjs/encoding'); |
| 70 | + const { prefix } = fromBech32(d); |
| 71 | + if (prefix !== 'sent') fail('DEMO_ADDR', `prefix ${prefix} — must be sent`, 'Use a sent1... address'); |
| 72 | + else ok('DEMO_ADDR', d); |
| 73 | + } catch (err) { |
| 74 | + fail('DEMO_ADDR', `invalid bech32: ${err.message}`, 'Check DEMO_ADDR for typos'); |
| 75 | + } |
| 76 | + } |
| 77 | +} |
| 78 | + |
| 79 | +// ─── 6. Port availability ──────────────────────────────────────────────────── |
| 80 | +{ |
| 81 | + const port = Number(process.env.PORT) || 3003; |
| 82 | + const inUse = await new Promise((resolve) => { |
| 83 | + const s = net.createServer(); |
| 84 | + s.once('error', (err) => resolve(err.code === 'EADDRINUSE')); |
| 85 | + s.once('listening', () => s.close(() => resolve(false))); |
| 86 | + s.listen(port, '127.0.0.1'); |
| 87 | + }); |
| 88 | + if (inUse) warn(`Port ${port}`, 'in use — another process is bound (might be your own server)', `Stop the other process or set PORT in .env`); |
| 89 | + else ok(`Port ${port}`, 'free'); |
| 90 | +} |
| 91 | + |
| 92 | +// ─── 7. RPC reachability (probe 3 endpoints) ───────────────────────────────── |
| 93 | +{ |
| 94 | + const endpoints = [ |
| 95 | + 'https://rpc.sentinel.co', |
| 96 | + 'https://sentinel-rpc.publicnode.com', |
| 97 | + 'https://sentinel-rpc.polkachu.com', |
| 98 | + ]; |
| 99 | + const results = await Promise.all(endpoints.map(async (url) => { |
| 100 | + try { |
| 101 | + const ctrl = new AbortController(); |
| 102 | + const t = setTimeout(() => ctrl.abort(), 4000); |
| 103 | + const r = await fetch(`${url}/status`, { signal: ctrl.signal }); |
| 104 | + clearTimeout(t); |
| 105 | + if (!r.ok) return { url, ok: false, detail: `HTTP ${r.status}` }; |
| 106 | + const j = await r.json(); |
| 107 | + const h = j?.result?.sync_info?.latest_block_height; |
| 108 | + return { url, ok: !!h, detail: h ? `height ${h}` : 'no height' }; |
| 109 | + } catch (err) { |
| 110 | + return { url, ok: false, detail: err.message }; |
| 111 | + } |
| 112 | + })); |
| 113 | + const reachable = results.filter(r => r.ok).length; |
| 114 | + if (reachable >= 2) ok('Sentinel RPC', `${reachable}/${endpoints.length} reachable`); |
| 115 | + else if (reachable === 1) warn('Sentinel RPC', `only ${reachable}/${endpoints.length} reachable — failover thin`); |
| 116 | + else fail('Sentinel RPC', `0/${endpoints.length} reachable — chain queries will fail`, 'Check internet connection / firewall'); |
| 117 | +} |
| 118 | + |
| 119 | +// ─── 8. Privy config (only if any var is set — warn on partial) ────────────── |
| 120 | +{ |
| 121 | + const id = process.env.PRIVY_APP_ID; |
| 122 | + const secret = process.env.PRIVY_APP_SECRET; |
| 123 | + const client = process.env.PRIVY_CLIENT_ID; |
| 124 | + const set = [id, secret, client].filter(Boolean).length; |
| 125 | + if (set === 0) ok('Privy', 'disabled (optional)'); |
| 126 | + else if (set === 3) ok('Privy', 'all three vars set'); |
| 127 | + else warn('Privy', `${set}/3 vars set — login card will fail to send codes`, 'Set PRIVY_APP_ID, PRIVY_APP_SECRET, PRIVY_CLIENT_ID together (or unset all)'); |
| 128 | +} |
| 129 | + |
| 130 | +// ─── Print report ──────────────────────────────────────────────────────────── |
| 131 | +const symbol = { PASS: '✓', WARN: '!', FAIL: '✗' }; |
| 132 | +const pad = (s, n) => s + ' '.repeat(Math.max(0, n - s.length)); |
| 133 | + |
| 134 | +console.log('\nPlan Manager doctor\n'); |
| 135 | +let failed = 0; |
| 136 | +for (const c of checks) { |
| 137 | + console.log(` ${symbol[c.status]} ${pad(c.name, 22)} ${c.detail}`); |
| 138 | + if (c.fix) console.log(` fix: ${c.fix}`); |
| 139 | + if (c.status === 'FAIL') failed++; |
| 140 | +} |
| 141 | +console.log(); |
| 142 | +if (failed === 0) { |
| 143 | + console.log('All checks passed.'); |
| 144 | + process.exit(0); |
| 145 | +} else { |
| 146 | + console.log(`${failed} check(s) failed.`); |
| 147 | + process.exit(1); |
| 148 | +} |
0 commit comments