Skip to content

Commit 2cebf32

Browse files
Sentinel-AutonomybuilderSentinel-Autonomybuilder
authored andcommitted
feat: zero-config demo + doctor diagnostic + boot pre-flight
- npm run demo: spawns server with DEMO=true, no env config required - Curated default DEMO_ADDR (mainnet plans 36+41 owner, 47 subs, 731 nodes) - npm run doctor: checks Node version, deps, port, RPC, MNEMONIC, DEMO_ADDR bech32, Privy partial config — PASS/WARN/FAIL with fix lines - Server warns at boot on partial Privy config (1-of-3 vars set) - EADDRINUSE handler prints fix instead of stack trace - CONTRIBUTING.md with setup + repo rules - README quickstart now leads with npm run demo
1 parent 7d37f37 commit 2cebf32

6 files changed

Lines changed: 253 additions & 4 deletions

File tree

CONTRIBUTING.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Contributing
2+
3+
Thanks for poking at Plan Manager. This file is short on purpose — read it once, then read the code.
4+
5+
## Setup
6+
7+
```bash
8+
git clone https://github.com/Sentinel-Bluebuilder/sentinel-plan-manager.git
9+
cd sentinel-plan-manager
10+
npm install
11+
npm run doctor # confirms Node 20+, RPC reachability, port 3003 free
12+
npm run demo # boots read-only on a curated mainnet operator
13+
```
14+
15+
To work against your own wallet instead: `cp .env.example .env`, paste a mnemonic into `MNEMONIC=`, then `npm start`.
16+
17+
## Architecture
18+
19+
- `server.js` — Express backend (~3800 lines). RPC-first via `blue-js-sdk`, LCD fallback. AsyncLocalStorage per-request session model so the server holds no module-level keys.
20+
- `public/index.html` — Vanilla JS SPA. Dark/light theme, no framework.
21+
- `lib/` — chain helpers, session crypto, constants, errors, wallet accessors.
22+
- `cli.js` — same TX paths, terminal-driven.
23+
24+
## Rules of the road
25+
26+
- **RPC-first.** Sentinel LCD plan endpoints return `Not Implemented`. New chain reads must use RPC (`rpcQueryPlan`, `rpcQueryNodesForPlan`, etc.); LCD only as fallback when RPC has no equivalent.
27+
- **No secrets in commits.** `.env`, `.wallet.json`, `privy-wallets.json`, and runtime caches are all in `.gitignore`. Don't bypass.
28+
- **Audit script must stay green.** `node scripts/audit-buttons.mjs` checks every button in `index.html` is wired to a real handler and a real route. Run it before opening a PR.
29+
- **Plan pricing is immutable in Sentinel v3** — don't add a "change price" UI. Create a new plan instead.
30+
- **Mobile is intentionally gated.** Don't make the operator UX responsive — the gate exists because real operator workflows assume desktop.
31+
32+
## Submitting
33+
34+
1. Branch from `master`.
35+
2. Keep diffs small — one feature or fix per PR.
36+
3. Update `PLANS.md` if you add a chain interaction.
37+
4. PR against `Sentinel-Bluebuilder/sentinel-plan-manager`.
38+
39+
If you hit an SDK gap (missing query, type registration miss, etc.), open the SDK PR against `Sentinel-Bluebuilder/blue-js-sdk` and link it from the Plan Manager PR.

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,19 @@ See [PLANS.md](./PLANS.md) for the full breakdown: plan lifecycle, plan-based vs
4949
5050
### Just want to look around?
5151

52-
Boot a read-only demo mounted on any operator address — no wallet, no tokens, no commitment. Every TX-broadcasting endpoint returns 403 so nothing can be signed by accident.
52+
Boot a read-only demo mounted on a real mainnet operator — no wallet, no tokens, no commitment. Every TX-broadcasting endpoint returns 403 so nothing can be signed by accident.
5353

5454
```bash
5555
git clone https://github.com/Sentinel-Bluebuilder/sentinel-plan-manager.git
5656
cd sentinel-plan-manager
5757
npm install
58-
DEMO=true DEMO_ADDR=sent1...operator-address... npm start
58+
npm run demo
5959
# → http://localhost:3003 with a demo banner across the top
6060
```
6161

62-
Pick any plan-creating operator address from a chain explorer, paste it into `DEMO_ADDR`, and the dashboard renders that operator's plans, nodes, and subscribers.
62+
`npm run demo` mounts a curated default operator (47 active subscribers, 731 linked nodes) so the dashboard renders fully populated out of the box. To inspect a different operator, set `DEMO_ADDR=sent1...` from any chain explorer and re-run.
63+
64+
Stuck? Run `npm run doctor` for an environment health check.
6365

6466
### Run your own — Option A: clone
6567

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
"scripts": {
1010
"start": "node server.js",
1111
"cli": "node cli.js",
12+
"demo": "node scripts/demo.mjs",
13+
"doctor": "node scripts/doctor.mjs",
1214
"build:vendor": "node scripts/bundle-vendor.mjs",
1315
"postinstall": "node scripts/bundle-vendor.mjs",
1416
"test:e2e": "node scripts/test-e2e.mjs",

scripts/demo.mjs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Cross-platform `npm run demo` launcher. Sets DEMO=true and spawns server.js
2+
// inheriting stdio so logs stream to the terminal. Forwards SIGINT so Ctrl+C
3+
// in the parent shuts the server cleanly. Curated DEMO_ADDR default lives in
4+
// server.js — no env config required.
5+
6+
import { spawn } from 'node:child_process';
7+
import { fileURLToPath } from 'node:url';
8+
import { dirname, join } from 'node:path';
9+
10+
const __dirname = dirname(fileURLToPath(import.meta.url));
11+
const serverPath = join(__dirname, '..', 'server.js');
12+
13+
const child = spawn(process.execPath, [serverPath], {
14+
stdio: 'inherit',
15+
env: { ...process.env, DEMO: 'true' },
16+
});
17+
18+
const forward = (sig) => () => { try { child.kill(sig); } catch {} };
19+
process.on('SIGINT', forward('SIGINT'));
20+
process.on('SIGTERM', forward('SIGTERM'));
21+
22+
child.on('exit', (code, signal) => {
23+
if (signal) process.kill(process.pid, signal);
24+
else process.exit(code ?? 0);
25+
});

scripts/doctor.mjs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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+
}

server.js

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,13 @@ initSession(DATA_DIR);
7070
// Read-only browse: any visitor sees the UI mounted on a watch-only address
7171
// without supplying a mnemonic. Every TX-broadcasting endpoint returns 403.
7272
// Set DEMO_ADDR to any sent1... operator address you want visitors to view.
73+
// Curated default operator: owns mainnet plans 36 & 41 (47 active subs, 731
74+
// linked nodes at time of writing). Override with env DEMO_ADDR for any other
75+
// sent1... address. Picked so `DEMO=true npm start` works zero-config and
76+
// shows a populated dashboard, not an empty operator with nothing to render.
77+
const DEFAULT_DEMO_ADDR = 'sent1t0xjyflrah5n36rfkpfeuw6pz6vl2g27x2793l';
7378
const DEMO_MODE = String(process.env.DEMO || '').toLowerCase() === 'true';
74-
const DEMO_ADDR = (process.env.DEMO_ADDR || '').trim();
79+
const DEMO_ADDR = (process.env.DEMO_ADDR || '').trim() || (DEMO_MODE ? DEFAULT_DEMO_ADDR : '');
7580
if (DEMO_MODE) {
7681
if (!DEMO_ADDR || !DEMO_ADDR.startsWith('sent1')) {
7782
console.error('[demo] DEMO=true requires DEMO_ADDR=sent1... (operator address to display).');
@@ -90,6 +95,24 @@ if (DEMO_MODE) {
9095
console.log(`[demo] Read-only mode enabled — mounted on ${DEMO_ADDR}. Writes return 403.`);
9196
}
9297

98+
// ─── Boot Pre-flight ──────────────────────────────────────────────────────────
99+
// Warn about partial Privy config at boot — the email login card mounts but
100+
// /api/wallet/privy-login returns 503, leaving users stuck staring at "send
101+
// code did nothing" with no clue why. Catch it here, in the startup log,
102+
// where ops actually look.
103+
{
104+
const privyVars = [
105+
['PRIVY_APP_ID', process.env.PRIVY_APP_ID],
106+
['PRIVY_APP_SECRET', process.env.PRIVY_APP_SECRET],
107+
['PRIVY_CLIENT_ID', process.env.PRIVY_CLIENT_ID],
108+
];
109+
const set = privyVars.filter(([, v]) => v && v.trim());
110+
if (set.length > 0 && set.length < 3) {
111+
const missing = privyVars.filter(([, v]) => !v || !v.trim()).map(([k]) => k).join(', ');
112+
console.warn(`[privy] Partial config: ${set.length}/3 vars set. Missing: ${missing}. Email login will fail until all three are set or all three are empty.`);
113+
}
114+
}
115+
93116
const app = express();
94117
app.use(express.json({ limit: '32kb' }));
95118
// Suppress fingerprinting header.
@@ -3779,6 +3802,16 @@ const server = app.listen(PORT, HOST, () => {
37793802
const displayHost = HOST === '0.0.0.0' ? 'localhost' : HOST;
37803803
console.log(`Plan Manager running on http://${displayHost}:${PORT} (bound to ${HOST})`);
37813804
});
3805+
server.on('error', (err) => {
3806+
if (err.code === 'EADDRINUSE') {
3807+
console.error(`\n[boot] Port ${PORT} is already in use.`);
3808+
console.error(' Another Plan Manager (or unrelated process) is bound to that port.');
3809+
console.error(` Fix: stop the other process, or set PORT=<free port> in .env, then retry.`);
3810+
process.exit(1);
3811+
}
3812+
console.error('[boot] Server failed to start:', err);
3813+
process.exit(1);
3814+
});
37823815

37833816
// ─── Graceful Shutdown ────────────────────────────────────────────────────────
37843817

0 commit comments

Comments
 (0)