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