blockyard 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +929 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +191 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +41 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1577 -0
  9. package/docs/ARCHITECTURE.md +1394 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +847 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +205 -0
  15. package/docs/INSTALL.md +547 -0
  16. package/docs/MEASUREMENTS.md +1401 -0
  17. package/docs/RULES.md +681 -0
  18. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  19. package/docs/SECURITY-AUDIT.md +258 -0
  20. package/docs/SECURITY.md +212 -0
  21. package/docs/TROUBLESHOOTING.md +332 -0
  22. package/docs/USER-GUIDE.md +1262 -0
  23. package/package.json +53 -5
  24. package/public/404.html +9 -0
  25. package/public/css/app.css +2009 -0
  26. package/public/donate-qr.png +0 -0
  27. package/public/index.html +1085 -0
  28. package/public/js/about.js +112 -0
  29. package/public/js/agents.js +1141 -0
  30. package/public/js/app.js +1386 -0
  31. package/public/js/arkanoid.js +806 -0
  32. package/public/js/blockanoid.js +347 -0
  33. package/public/js/blockout.js +347 -0
  34. package/public/js/blockpack.js +428 -0
  35. package/public/js/blockscene3d.js +2830 -0
  36. package/public/js/breakout.js +224 -0
  37. package/public/js/charts.js +635 -0
  38. package/public/js/depthchart.js +315 -0
  39. package/public/js/details3d.js +4342 -0
  40. package/public/js/doom.js +31 -0
  41. package/public/js/dosaudio.js +48 -0
  42. package/public/js/dosgame.js +389 -0
  43. package/public/js/dosio.js +186 -0
  44. package/public/js/dospc.js +1353 -0
  45. package/public/js/dosworker.js +196 -0
  46. package/public/js/explorer.js +405 -0
  47. package/public/js/feepalette.js +149 -0
  48. package/public/js/fmt.js +162 -0
  49. package/public/js/goggles.js +886 -0
  50. package/public/js/kiosk.js +41 -0
  51. package/public/js/login.js +88 -0
  52. package/public/js/markets.js +395 -0
  53. package/public/js/mining.js +1416 -0
  54. package/public/js/panels.js +970 -0
  55. package/public/js/pricechart.js +189 -0
  56. package/public/js/quake.js +20 -0
  57. package/public/js/settings.js +1096 -0
  58. package/public/js/soundcard.js +459 -0
  59. package/public/js/tetris.js +226 -0
  60. package/public/js/tetrust.js +356 -0
  61. package/public/js/tetsound.js +175 -0
  62. package/public/js/theme.js +235 -0
  63. package/public/js/wolf3d.js +22 -0
  64. package/public/js/x86.js +1978 -0
  65. package/public/login.html +33 -0
  66. package/scripts/blockfile-measure.js +156 -0
  67. package/scripts/browser-check.mjs +286 -0
  68. package/scripts/check.js +173 -0
  69. package/scripts/decode-check.js +81 -0
  70. package/scripts/doc-counts.js +109 -0
  71. package/scripts/donate-qr.py +23 -0
  72. package/scripts/dos-bench.js +56 -0
  73. package/scripts/fake-node.js +534 -0
  74. package/scripts/index-bench.js +216 -0
  75. package/scripts/index-benchmark.js +117 -0
  76. package/scripts/index-build.js +40 -0
  77. package/scripts/live-render-check.mjs +89 -0
  78. package/scripts/manage-users.js +132 -0
  79. package/scripts/motion-check.mjs +138 -0
  80. package/scripts/pool-map.js +157 -0
  81. package/scripts/setup.js +432 -0
  82. package/scripts/shots.mjs +278 -0
  83. package/scripts/smoke.sh +327 -0
  84. package/scripts/tls.js +31 -0
  85. package/scripts/ui.js +174 -0
  86. package/server/auth/sessions.js +221 -0
  87. package/server/auth/users.js +243 -0
  88. package/server/chain/blockfile.js +234 -0
  89. package/server/chain/index/build.js +210 -0
  90. package/server/chain/index/heights.js +36 -0
  91. package/server/chain/index/live.js +276 -0
  92. package/server/chain/index/rows.js +145 -0
  93. package/server/chain/index/store.js +154 -0
  94. package/server/chain/index/worker.js +109 -0
  95. package/server/chain/tx.js +310 -0
  96. package/server/collect/gbt.js +229 -0
  97. package/server/collect/logparse.js +765 -0
  98. package/server/collect/logtail.js +189 -0
  99. package/server/collect/markets.js +333 -0
  100. package/server/collect/mining.js +333 -0
  101. package/server/collect/monitor.js +2545 -0
  102. package/server/collect/network.js +295 -0
  103. package/server/collect/nextblock.js +275 -0
  104. package/server/collect/sync.js +386 -0
  105. package/server/config.js +644 -0
  106. package/server/http/api.js +1319 -0
  107. package/server/http/explorer.js +418 -0
  108. package/server/http/games.js +77 -0
  109. package/server/http/server.js +420 -0
  110. package/server/http/sse.js +176 -0
  111. package/server/http/static.js +212 -0
  112. package/server/main.js +673 -0
  113. package/server/netinfo.js +253 -0
  114. package/server/rpc/allowlist.js +130 -0
  115. package/server/rpc/client.js +414 -0
  116. package/server/store/audit.js +148 -0
  117. package/server/store/history.js +220 -0
  118. package/server/store/ledger.js +290 -0
  119. package/server/store/ring.js +173 -0
  120. package/server/tls/selfsigned.js +160 -0
  121. package/server/util/fmt.js +29 -0
  122. package/systemd/blockyard.service +102 -0
@@ -0,0 +1,432 @@
1
+ #!/usr/bin/env node
2
+ // SET UP A FRESH INSTALL: ask where the node is, prove the answers work (scripts/check.js: the
3
+ // RPC server answers with the right chain, the credentials are accepted, txindex is on, the block
4
+ // files open, the log is found), write config/local.json, build the address index, and start the
5
+ // monitor -- so a new machine goes from a clone to running and indexing in one sitting.
6
+ //
7
+ // npm run setup # interactive
8
+ // node scripts/setup.js --yes [--rpc-url URL] [--datadir DIR] [--label L] [--rpc-user U --rpc-password P]
9
+ // [--host 127.0.0.1] [--port 21000] [--index-dir DIR] [--workers N]
10
+ // [--build-here | --build-later] [--start] [--force]
11
+ //
12
+ // --yes takes every default without asking (a scripted install); --force replaces an existing
13
+ // config/local.json (a backup is kept either way); --start boots the monitor at the end without
14
+ // asking. The index is built by BlockYard itself, in the background, once it starts (the Overview shows
15
+ // progress and an event says when it is done); --build-here builds it in this terminal instead, and
16
+ // --build-later writes addressIndexBuild: "manual" so nothing builds until you run index-build.js.
17
+ // The written file is mode 0600: it may carry an RPC password. Nothing here touches the
18
+ // node: every call is a read. (operator, 2026-09-14: "Make this npm installer absolutely beautiful")
19
+ import { existsSync, statSync, writeFileSync, copyFileSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
20
+ import os from 'node:os';
21
+ import net from 'node:net';
22
+ import http from 'node:http';
23
+ import https from 'node:https';
24
+ import path from 'node:path';
25
+ import readline from 'node:readline/promises';
26
+ import { stdin, stdout } from 'node:process';
27
+ import { ROOT, loadConfig, resolveCookie } from '../server/config.js';
28
+ import { runChecks, clientFor } from './check.js';
29
+ import { buildIndex, defaultWorkers, rpcPacer } from '../server/chain/index/build.js';
30
+ import { c, banner, step, checkLine, box, spinner, progress, progressLine, fmt, strip, wrapText, cols } from './ui.js';
31
+ import { fileURLToPath } from 'node:url';
32
+
33
+ const argv = process.argv.slice(2);
34
+ const flag = (name) => argv.includes(`--${name}`);
35
+ const arg = (name, def = null) => { const i = argv.indexOf(`--${name}`); return i >= 0 && argv[i + 1] != null && !argv[i + 1].startsWith('--') ? argv[i + 1] : def; };
36
+ const YES = flag('yes');
37
+ const VERSION = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
38
+ const STEPS = 6;
39
+
40
+ // ---------------------------------------------------------------- the answers, and their shape
41
+ /** Where Bitcoin Core keeps its data by default on this platform. */
42
+ export function defaultDatadir(platform = process.platform, home = os.homedir(), env = process.env) {
43
+ // joined with the named platform's own separator, so the answer for a Mac is a Mac path
44
+ // whatever machine asks (the tests ask for all three from one)
45
+ const P = platform === 'win32' ? path.win32 : path.posix;
46
+ if (platform === 'darwin') return P.join(home, 'Library', 'Application Support', 'Bitcoin');
47
+ if (platform === 'win32') return P.join(env.APPDATA ?? P.join(home, 'AppData', 'Roaming'), 'Bitcoin');
48
+ return P.join(home, '.bitcoin');
49
+ }
50
+
51
+ export { defaultWorkers };
52
+
53
+ /** The config/local.json a set of answers produces. */
54
+ export function localConfig(a) {
55
+ const node = { id: 'main', label: a.label, rpcUrl: a.rpcUrl, datadir: a.datadir, chainHint: a.chain ?? 'main' };
56
+ if (a.cookieFile) node.cookieFile = a.cookieFile;
57
+ if (a.rpcUser) { node.rpcUser = a.rpcUser; node.rpcPassword = a.rpcPassword ?? ''; }
58
+ if (a.indexDir) node.addressIndex = a.indexDir;
59
+ if (a.indexBuild === 'manual') node.addressIndexBuild = 'manual'; // the server builds a missing index on start unless told not to
60
+ if (Number.isInteger(a.workers) && a.workers > 0) node.addressIndexWorkers = a.workers; // the server's background build uses the same number
61
+ return { server: { host: a.host, port: Number(a.port) }, nodes: [node] };
62
+ }
63
+
64
+ /** Write it, keeping a dated copy of whatever was there. Refuses an existing file unless `force`. */
65
+ export function writeLocalConfig(file, cfg, { force = false, now = new Date() } = {}) {
66
+ if (existsSync(file)) {
67
+ if (!force) throw new Error(`${file} exists; pass --force (or answer yes) to replace it`);
68
+ const bak = `${file}.bak-${now.toISOString().replace(/[:.]/g, '').slice(0, 15)}`;
69
+ copyFileSync(file, bak);
70
+ }
71
+ mkdirSync(path.dirname(file), { recursive: true });
72
+ writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
73
+ }
74
+
75
+ // ------------------------------------------------------------------------- what an answer must be
76
+ // Each returns { value } or { error }: a bad answer is explained and asked again, never written.
77
+ /** A path as a person would type it: relative to the checkout, or under ~. */
78
+ export function shortPath(p, root = ROOT, home = os.homedir()) {
79
+ const rel = path.relative(root, p);
80
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) return rel;
81
+ return p.startsWith(home + '/') || p.startsWith(home + '\\') ? `~${p.slice(home.length)}` : p;
82
+ }
83
+ export function expand(p) { return p.startsWith('~/') || p === '~' ? path.join(os.homedir(), p.slice(1)) : p; }
84
+ export const validate = {
85
+ rpcUrl(s) {
86
+ let u;
87
+ try { u = new URL(String(s).trim()); } catch { return { error: 'not a URL -- something like http://127.0.0.1:8332' }; }
88
+ if (!/^https?:$/.test(u.protocol)) return { error: `${u.protocol.slice(0, -1)} is not http or https` };
89
+ if (!u.port) u.port = u.protocol === 'https:' ? '443' : '8332';
90
+ return { value: u.toString().replace(/\/$/, '') };
91
+ },
92
+ dir(s) {
93
+ const p = expand(String(s).trim());
94
+ if (!path.isAbsolute(p)) return { error: 'give an absolute path' };
95
+ if (!existsSync(p)) return { error: `${p} does not exist` };
96
+ if (!statSync(p).isDirectory()) return { error: `${p} is not a directory` };
97
+ return { value: p };
98
+ },
99
+ newDir(s) {
100
+ const p = expand(String(s).trim());
101
+ if (!path.isAbsolute(p)) return { error: 'give an absolute path' };
102
+ if (existsSync(p) && !statSync(p).isDirectory()) return { error: `${p} exists and is not a directory` };
103
+ return { value: p };
104
+ },
105
+ port(s) {
106
+ const n = Number(String(s).trim());
107
+ if (!Number.isInteger(n) || n < 1 || n > 65535) return { error: 'a port is a whole number from 1 to 65535' };
108
+ return { value: n };
109
+ },
110
+ host(s) {
111
+ const h = String(s).trim();
112
+ if (h === 'localhost') return { value: '127.0.0.1' };
113
+ if (!net.isIP(h)) return { error: 'an IP address literal: 127.0.0.1 for this machine only, 0.0.0.0 for everyone who can reach it, or one of this machine\'s addresses' };
114
+ return { value: h };
115
+ },
116
+ workers(s) {
117
+ const n = Number(String(s).trim());
118
+ if (!Number.isInteger(n) || n < 1 || n > 64) return { error: 'a whole number of workers, 1 to 64' };
119
+ return { value: n };
120
+ },
121
+ label(s) { const l = String(s).trim(); return l ? { value: l.slice(0, 40) } : { error: 'a label, even a short one' }; },
122
+ };
123
+
124
+ /**
125
+ * THE NODE'S OWN bitcoin.conf (operator, 2026-09-14: "can't you look through the user's .conf and find
126
+ * the rpc values?"): read from the data directory, so the RPC port, the chain, rpcconnect, a
127
+ * rpcuser/rpcpassword pair, rpcauth users, a cookie file the node was told to write elsewhere, and
128
+ * server= / txindex= all arrive as defaults instead of questions. Core's rules: `key=value`, `#`
129
+ * comments, `[main]` / `[test]` / `[signet]` / `[regtest]` sections whose keys apply to that chain
130
+ * only, the chain chosen by testnet=1 / signet=1 / regtest=1 / chain=, and includeconf= pulling in
131
+ * another file relative to the data directory. The last value of a key wins, except rpcauth, which
132
+ * may repeat. Returns { found, file, chain, values, rpcauthUsers }.
133
+ */
134
+ export function readBitcoinConf(datadir, { file = null, depth = 0 } = {}) {
135
+ const conf = file ?? path.join(datadir, 'bitcoin.conf');
136
+ const out = { found: false, file: conf, chain: 'main', values: {}, rpcauthUsers: [] };
137
+ let text;
138
+ try { text = readFileSync(conf, 'utf8'); } catch { return out; }
139
+ out.found = true;
140
+ const top = {}, sections = {};
141
+ const rpcauth = { top: [], sections: {} };
142
+ let section = null;
143
+ const includes = [];
144
+ for (const raw of text.split(/\r?\n/)) {
145
+ const line = raw.replace(/#.*$/, '').trim();
146
+ if (!line) continue;
147
+ const sec = line.match(/^\[([a-z0-9]+)\]$/i);
148
+ if (sec) { section = sec[1].toLowerCase(); continue; }
149
+ const eq = line.indexOf('=');
150
+ if (eq < 0) continue;
151
+ const key = line.slice(0, eq).trim().toLowerCase(), value = line.slice(eq + 1).trim();
152
+ if (key === 'includeconf') { includes.push(value); continue; }
153
+ if (key === 'rpcauth') { (section ? (rpcauth.sections[section] ??= []) : rpcauth.top).push(value); continue; }
154
+ if (section) (sections[section] ??= {})[key] = value; else top[key] = value;
155
+ }
156
+ // the chain: only the top level may choose it
157
+ const chain = top.chain ? { main: 'main', test: 'test', testnet3: 'test', testnet4: 'testnet4', signet: 'signet', regtest: 'regtest' }[top.chain] ?? top.chain
158
+ : top.regtest === '1' ? 'regtest' : top.signet === '1' ? 'signet' : top.testnet4 === '1' ? 'testnet4' : top.testnet === '1' ? 'test' : 'main';
159
+ out.chain = chain;
160
+ const secName = { main: 'main', test: 'test', signet: 'signet', regtest: 'regtest', testnet4: 'testnet4' }[chain];
161
+ // on mainnet, a few keys are only honoured inside [main]; everywhere else the top level applies too
162
+ const MAIN_ONLY = new Set(['rpcport', 'rpcbind', 'port', 'bind', 'wallet', 'addnode', 'connect']);
163
+ const values = {};
164
+ for (const [k, v] of Object.entries(top)) if (!(chain === 'main' && MAIN_ONLY.has(k)) || true) values[k] = v;
165
+ if (chain === 'main') for (const k of MAIN_ONLY) if (k in top && !(k in (sections.main ?? {}))) values[k] = top[k];
166
+ Object.assign(values, sections[secName] ?? {});
167
+ out.values = values;
168
+ out.rpcauthUsers = [...rpcauth.top, ...(rpcauth.sections[secName] ?? [])].map((v) => v.split(':')[0]).filter(Boolean);
169
+ // included files, once, relative to the data directory
170
+ if (depth < 3) for (const inc of includes) {
171
+ const sub = readBitcoinConf(datadir, { file: path.isAbsolute(inc) ? inc : path.join(datadir, inc), depth: depth + 1 });
172
+ if (!sub.found) continue;
173
+ Object.assign(out.values, sub.values);
174
+ out.rpcauthUsers.push(...sub.rpcauthUsers);
175
+ }
176
+ return out;
177
+ }
178
+ export const RPC_PORT = { main: 8332, test: 18332, testnet4: 48332, signet: 38332, regtest: 18443 };
179
+
180
+ /** Is a BlockYard (or anything) already answering on this port? */
181
+ export async function portInUse(host, port) {
182
+ // HTTPS is the default (2026-09-15) and its certificate is self-signed, so the probe tries
183
+ // https first without checking the chain, then plain http
184
+ const h = host === '0.0.0.0' ? '127.0.0.1' : host;
185
+ const probe = (mod, scheme) => new Promise((resolve) => {
186
+ const req = mod.get({ host: h, port, path: '/api/health', timeout: 1500, rejectUnauthorized: false }, (res) => {
187
+ let text = '';
188
+ res.on('data', (d) => { text += d; });
189
+ res.on('end', () => { let j = null; try { j = JSON.parse(text); } catch { /* not ours */ } resolve({ ok: true, scheme, blockyard: j?.version ?? null }); });
190
+ });
191
+ req.on('timeout', () => { req.destroy(new Error('timeout')); });
192
+ req.on('error', (err) => resolve({ ok: false, err }));
193
+ });
194
+ const s = await probe(https, 'https');
195
+ if (s.ok) return { busy: true, blockyard: s.blockyard };
196
+ const p = await probe(http, 'http');
197
+ if (p.ok) return { busy: true, blockyard: p.blockyard };
198
+ const refused = (e) => /ECONNREFUSED/.test(e?.code ?? '') || /ECONNREFUSED/.test(e?.message ?? '') || /timeout/.test(e?.message ?? '');
199
+ return { busy: !(refused(s.err) && refused(p.err)), blockyard: null };
200
+ }
201
+
202
+ // ------------------------------------------------------------------------------------ the flow
203
+ async function main() {
204
+ if (!YES && !stdin.isTTY) { console.error('no terminal to ask on: pass --yes with the --rpc-url/--datadir flags (see the header of scripts/setup.js)'); process.exit(2); }
205
+ const rl = YES ? null : readline.createInterface({ input: stdin, output: stdout });
206
+ const out = (s = '') => stdout.write(`${s}\n`);
207
+ // every line the installer says fits the terminal (operator: 80 columns, "Standard CRT")
208
+ const say = (s) => out(` ${wrapText(s, cols() - 4, 4)}`);
209
+ const q = c.accent('?');
210
+
211
+ /** Ask until the answer validates; --yes takes the default (validated the same way). */
212
+ const ask = async (label, def, check, { secret = false } = {}) => {
213
+ for (;;) {
214
+ let raw;
215
+ if (YES) raw = def;
216
+ else {
217
+ const shown = def != null && def !== '' && !secret ? ` ${c.dim(`(${def})`)}` : '';
218
+ raw = (await rl.question(` ${q} ${label}${shown} ${c.dim('›')} `)).trim();
219
+ if (raw === '') raw = def;
220
+ }
221
+ const r = check ? check(raw ?? '') : { value: raw };
222
+ if (!('error' in r)) return r.value;
223
+ say(`${c.bad('✗')} ${r.error}`);
224
+ if (YES) { out(); say(c.bad('--yes cannot answer that one; pass it as a flag')); process.exit(2); }
225
+ }
226
+ };
227
+ const yes = async (label, def = true) => {
228
+ if (YES) return def;
229
+ const a = (await rl.question(` ${q} ${label} ${c.dim(def ? '(Y/n)' : '(y/N)')} ${c.dim('›')} `)).trim().toLowerCase();
230
+ return a === '' ? def : a.startsWith('y');
231
+ };
232
+
233
+ // the same file loadConfig reads: config/local.json, or BLOCKYARD_CONFIG where the environment names one
234
+ const env = process.env.BLOCKYARD_CONFIG;
235
+ const file = env && !/^(none|off|no|-)$/i.test(env) ? path.resolve(env) : path.join(ROOT, 'config', 'local.json');
236
+ const defaults = loadConfig({ configFile: null });
237
+
238
+ let building = null;
239
+ process.on('SIGINT', () => {
240
+ out(); out();
241
+ if (building) say(c.warn(`stopped. The index in ${building} is unfinished: run the build again (it starts over) before pointing BlockYard at it.`));
242
+ else say(c.dim('stopped; nothing written.'));
243
+ process.exit(130);
244
+ });
245
+
246
+ out(banner(VERSION));
247
+ say(c.dim('BlockYard runs on the machine that runs Bitcoin Core: the explorer\'s'));
248
+ say(c.dim('address index is built from the node\'s block files. Every check below is'));
249
+ say(c.dim('a read; nothing on the node is changed. Enter accepts the value shown.'));
250
+ say(c.dim('Ctrl-C leaves everything as it was.'));
251
+
252
+ // ---------------------------------------------------------------- 1. the node, until it answers
253
+ const a = {};
254
+ let result;
255
+ for (;;) {
256
+ out(step(1, STEPS, 'Your Bitcoin Core node'));
257
+ // the data directory first: its bitcoin.conf answers most of the rest
258
+ a.datadir = await ask('data directory', arg('datadir', defaultDatadir()), validate.dir);
259
+ let conf = readBitcoinConf(a.datadir);
260
+ if (conf.found && conf.values.datadir && conf.values.datadir !== a.datadir && existsSync(conf.values.datadir)) {
261
+ say(`${c.warn('!')} ${shortPath(conf.file)} moves the data directory to ${conf.values.datadir}; using that`);
262
+ a.datadir = conf.values.datadir; conf = readBitcoinConf(a.datadir, { file: conf.file });
263
+ }
264
+ a.chain = conf.chain;
265
+ if (conf.found) {
266
+ const v = conf.values;
267
+ const bits = [`chain ${conf.chain}`, v.rpcport ? `rpcport ${v.rpcport}` : null, v.server === '1' ? 'server=1' : c.warn('no server=1'),
268
+ v.txindex === '1' ? 'txindex=1' : c.warn('no txindex=1'), v.rpcuser ? `rpcuser ${v.rpcuser}` : conf.rpcauthUsers.length ? `rpcauth ${conf.rpcauthUsers.join(', ')}` : 'cookie auth',
269
+ v.prune && v.prune !== '0' ? c.bad(`prune=${v.prune}`) : null].filter(Boolean);
270
+ say(`${c.ok('✓')} read ${c.dim(shortPath(conf.file))}: ${bits.join(c.dim(' · '))}`);
271
+ } else say(c.dim(`no bitcoin.conf under ${a.datadir}: Core's defaults assumed`));
272
+ const host = conf.values.rpcconnect ?? '127.0.0.1';
273
+ const port = conf.values.rpcport ?? RPC_PORT[conf.chain] ?? 8332;
274
+ a.rpcUrl = await ask('RPC URL', arg('rpc-url', `http://${host}:${port}`), validate.rpcUrl);
275
+ a.label = await ask('a label for the node', arg('label', conf.chain === 'main' ? 'Bitcoin Core' : `Bitcoin Core (${conf.chain})`), validate.label);
276
+ a.rpcUser = arg('rpc-user'); a.rpcPassword = arg('rpc-password');
277
+ a.cookieFile = conf.values.rpccookiefile ? (path.isAbsolute(conf.values.rpccookiefile) ? conf.values.rpccookiefile : path.join(a.datadir, conf.values.rpccookiefile)) : null;
278
+ const cookie = resolveCookie({ datadir: a.datadir, chainHint: a.chain, cookieFile: a.cookieFile ?? undefined });
279
+ if (cookie && cookie.source !== 'config') say(`${c.ok('✓')} cookie found: ${c.dim(cookie.source)}`);
280
+ else if (!a.rpcUser && conf.values.rpcuser && conf.values.rpcpassword) {
281
+ a.rpcUser = conf.values.rpcuser; a.rpcPassword = conf.values.rpcpassword;
282
+ say(`${c.ok('✓')} rpcuser/rpcpassword taken from ${shortPath(conf.file)}`);
283
+ } else if (!a.rpcUser) {
284
+ const who = conf.rpcauthUsers[0] ?? '';
285
+ say(`${c.warn('!')} no .cookie readable under ${a.datadir}${who ? `; ${shortPath(conf.file)} has rpcauth for "${who}", whose password is not in the file` : ': a node authenticating with rpcauth needs a user and password'}`);
286
+ a.rpcUser = await ask('rpcUser', who, null);
287
+ if (a.rpcUser) a.rpcPassword = await ask('rpcPassword', '', null, { secret: true });
288
+ }
289
+
290
+ out(step(2, STEPS, 'Checking the node'));
291
+ const node = localConfig({ ...a, host: '127.0.0.1', port: 0 }).nodes[0];
292
+ const spin = spinner(`asking ${a.rpcUrl} …`);
293
+ result = await runChecks(node, { rpc: clientFor(node, defaults) });
294
+ if (result.facts.chain && result.facts.chain !== a.chain) { a.chain = result.facts.chain; node.chainHint = a.chain; result = await runChecks(node, { rpc: clientFor(node, defaults) }); }
295
+ spin.stop();
296
+ for (const ch of result.checks) out(checkLine(ch.status, ch.name, ch.detail));
297
+ out();
298
+ if (result.ok) { say(c.ok(c.bold('everything this needs is there'))); break; }
299
+ if (result.checks.some((ch) => ch.name === 'rpc' && ch.status === 'fail')) {
300
+ say(c.bad(c.bold('the RPC server did not answer')));
301
+ say(c.dim('is the node running, is server=1 in its bitcoin.conf, and is that its RPC port (rpcport)?'));
302
+ if (await yes('try different answers?', !YES)) continue;
303
+ } else {
304
+ say(c.bad(c.bold('something this needs is missing')) + c.dim(' (the ✗ lines say what, and what to do)'));
305
+ if (await yes('write the config anyway?', false)) break;
306
+ }
307
+ out(); say(c.dim('nothing written.')); rl?.close(); process.exit(1);
308
+ }
309
+
310
+ // ------------------------------------------------------------------------ 3. the web interface
311
+ out(step(3, STEPS, 'The web interface'));
312
+ say(c.dim('127.0.0.1 (the default) keeps it to this machine -- reach it from elsewhere over an SSH'));
313
+ say(c.dim('tunnel; a LAN address, or 0.0.0.0, opens it to everyone who can reach the port. Sign-in'));
314
+ say(c.dim('is on either way: the first start prints the admin password once, and it serves HTTPS'));
315
+ say(c.dim('with a certificate it makes for itself -- expect one browser warning per address (docs/SECURITY.md).'));
316
+ a.host = await ask('bind address', arg('host', '127.0.0.1'), validate.host);
317
+ // ANYTHING BUT LOOPBACK IS SAID TWICE (operator, 2026-09-15: "hardened and on 127.0.0.1,
318
+ // unless the user explicitly types 0.0.0.0 in the installer"): typing it is the first time,
319
+ // this question the second -- except when --host named it on the command line, which is as
320
+ // explicit as it gets
321
+ if (!['127.0.0.1', '::1'].includes(a.host) && arg('host') !== a.host) {
322
+ const who = a.host === '0.0.0.0' ? 'everyone who can reach this machine on any interface' : `everyone who can reach ${a.host}`;
323
+ say(`${c.warn('!')} ${who} will get the sign-in page on port ${arg('port', '21000')}; docs/SECURITY.md before opening it further`);
324
+ if (!(await yes(`bind ${a.host} rather than 127.0.0.1?`, false))) { a.host = '127.0.0.1'; say(c.dim('127.0.0.1 then -- an SSH tunnel reaches it from elsewhere')); }
325
+ }
326
+ for (;;) {
327
+ a.port = await ask('port', arg('port', '21000'), validate.port);
328
+ const inUse = await portInUse(a.host, a.port);
329
+ if (!inUse.busy) break;
330
+ say(`${c.warn('!')} ${inUse.blockyard ? `BlockYard ${inUse.blockyard} is already listening on ${a.port}` : `something is already listening on ${a.port}`}`);
331
+ if (YES || await yes('use it anyway?', false)) break;
332
+ }
333
+
334
+ // -------------------------------------------------------------------------- 4. the address index
335
+ out(step(4, STEPS, 'The address index'));
336
+ const gb = result.facts.blockBytes ? Math.round(result.facts.blockBytes / 1e9 * 0.141) : 124;
337
+ say(c.dim('History and balances on the explorer come from an index built from the'));
338
+ say(c.dim(`node's block files: about ${gb} GB on disk, best on a different disk from`));
339
+ say(c.dim('the node\'s. If the block files are on spinning disks, answer 1 worker below:'));
340
+ say(c.dim('parallel readers seek against each other and against the node.'));
341
+ // inside the checkout by default (operator, 2026-09-14, on the Mac: "It should honor the directory
342
+ // it's run out of"): data/ is where this install keeps everything it writes, and it is gitignored
343
+ a.indexDir = await ask('index directory', arg('index-dir', path.join(defaults.store.dir, 'index')), validate.newDir); // data/ of the checkout, or ~/.blockyard/data from the npm command
344
+ const built = existsSync(path.join(a.indexDir, 'manifest.json'));
345
+ if (built) say(`${c.ok('✓')} an index is already built there; the server will follow the chain from it`);
346
+ // the suggestion is the shared-machine number (at most four, half the cores), not a dedicated
347
+ // build's: the node reads the same disk, and the first Mac wrote 16 into its config by pressing Enter
348
+ // four by default (operator, 2026-09-14): the number that shares an NVMe with a node without
349
+ // trouble; fewer only on a machine with fewer cores or less memory, and 1 on spinning disks
350
+ else a.workers = await ask(`build workers ${c.dim('(1 on spinning disks; each needs ~2.5 GB of memory)')}`, arg('workers', String(Math.max(1, Math.min(4, defaultWorkers())))), validate.workers);
351
+
352
+ // ------------------------------------------------------------------------------- 5. written
353
+ out(step(5, STEPS, shortPath(file)));
354
+ const cfg = localConfig(a);
355
+ out(box(JSON.stringify(cfg, (k, v) => (k === 'rpcPassword' ? '••••••••' : v), 2).split('\n').map((l) => c.dim(l)), { title: shortPath(file) }).split('\n').map((l) => ` ${l}`).join('\n'));
356
+ let force = flag('force');
357
+ if (existsSync(file) && !force) force = await yes(`${shortPath(file)} exists -- replace it? (a backup is kept)`, false);
358
+ try { writeLocalConfig(file, cfg, { force }); }
359
+ catch (err) { out(); say(c.bad(err.message)); rl?.close(); process.exit(1); }
360
+ say(`${c.ok('✓')} written ${c.dim('(mode 0600)')}`);
361
+
362
+ // --------------------------------------------------------------------------------- 6. build
363
+ out(step(6, STEPS, 'Building the index'));
364
+ // BACKGROUND BY DEFAULT (operator, 2026-09-14: "Is it possible to run step 6 in the background,
365
+ // and have a status notification in blockyard when the index process is finished?"): BlockYard
366
+ // builds a missing index itself when it starts, shows the progress on the Overview and the
367
+ // address page, and posts an event -- and a toast -- when it is done.
368
+ let how = built ? 'none' : flag('build-here') ? 'here' : flag('build-later') ? 'later' : 'background';
369
+ if (!built && !YES && !flag('build-here') && !flag('build-later')) {
370
+ say(c.dim('BlockYard can build it in the background once it starts: the Overview'));
371
+ say(c.dim('shows the progress and a notification says when it is done (~30 min on'));
372
+ say(c.dim('16 workers, longer on fewer). Or build it here, now.'));
373
+ how = await ask(`build it ${c.dim('(b)')}ackground when BlockYard starts, ${c.dim('(h)')}ere now, or ${c.dim('(l)')}ater by hand`, 'b',
374
+ (v) => ({ b: { value: 'background' }, h: { value: 'here' }, l: { value: 'later' } }[String(v).trim().toLowerCase()[0]] ?? { error: 'b, h or l' }));
375
+ }
376
+ if (how === 'later') { a.indexBuild = 'manual'; writeLocalConfig(file, localConfig(a), { force: true }); }
377
+ if (how === 'here') {
378
+ const node = cfg.nodes[0];
379
+ const rpc = clientFor(node, defaults);
380
+ const started = Date.now();
381
+ let phaseStart = started, phase = null;
382
+ const bar = progress();
383
+ building = a.indexDir;
384
+ try {
385
+ const manifest = await buildIndex({
386
+ rpc, blocksDir: path.join(node.datadir, 'blocks'), out: a.indexDir, workers: a.workers,
387
+ pace: rpcPacer(rpc, { onChange: (held) => bar.done(held ? c.dim(' paused while the node\'s RPC is slow or failing') : c.dim(' resumed')) }),
388
+ onProgress: (p) => {
389
+ if (p.phase !== phase) {
390
+ if (phase) bar.done(strip(progressLine({ phase, done: 1, total: 1, elapsed: (Date.now() - phaseStart) / 1000 })));
391
+ phase = p.phase; phaseStart = Date.now();
392
+ }
393
+ bar.update({ ...p, elapsed: (Date.now() - phaseStart) / 1000 });
394
+ },
395
+ });
396
+ bar.done();
397
+ building = null;
398
+ const mins = (Date.now() - started) / 60000;
399
+ say(`${c.ok('✓')} index built: ${fmt.big(manifest.rows ?? 0)} rows to block ${Number(manifest.tip?.height ?? 0).toLocaleString()} in ${mins.toFixed(1)} min`);
400
+ } catch (err) {
401
+ bar.done();
402
+ building = null;
403
+ say(c.bad(`the build failed: ${err.message}`));
404
+ say(c.dim(`fix the cause and run: node scripts/index-build.js --out ${a.indexDir} --workers ${a.workers}`));
405
+ }
406
+ } else if (how === 'background') say(`${c.ok('✓')} BlockYard will build it when it starts ${c.dim(`(${a.workers} workers; progress on the Overview, a notification when done)`)}`);
407
+ else if (how === 'later') say(c.dim(`later: node scripts/index-build.js --out ${a.indexDir} --workers ${a.workers} (the address page says "not indexed" until then; restart BlockYard after)`));
408
+ else say(c.dim('nothing to build'));
409
+
410
+ // --------------------------------------------------------------------------------- done
411
+ const url = `https://${a.host === '0.0.0.0' ? '127.0.0.1' : a.host}:${a.port}`;
412
+ out();
413
+ out(box([
414
+ `${c.bold('start it')} ${c.accent('npm start')}${!YES ? c.dim(' (or answer yes below)') : ''}`,
415
+ ...(how === 'background' ? [` ${c.dim('the index build starts with it')}`] : []),
416
+ `${c.bold('open it')} ${c.cyan(url)}`,
417
+ `${c.bold('check it')} ${c.accent('npm run check')}${c.dim(' the same checks, any time')}`,
418
+ `${c.bold('keep it up')} ${c.dim('docs/GETTING-STARTED.md §6')}`,
419
+ ], { title: c.bold('BlockYard is set up') }).split('\n').map((l) => ` ${l}`).join('\n'));
420
+ out();
421
+ const start = flag('start') || (!YES && await yes('start BlockYard now, in this terminal? (Ctrl-C stops it)', true));
422
+ rl?.close();
423
+ if (!start) process.exit(0);
424
+ const { boot, banner: serverBanner } = await import('../server/main.js');
425
+ const app = await boot();
426
+ stdout.write(serverBanner(app) + '\n');
427
+ if (app.bootstrap) await app.audit({ type: 'bootstrap-admin', generated: app.bootstrap.generated, ip: 'local' });
428
+ }
429
+
430
+ if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) {
431
+ main().catch((err) => { console.error(c.bad(err.message)); process.exit(1); });
432
+ }