aether-hub 1.1.5 → 1.1.7

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.
@@ -1,720 +1,754 @@
1
- #!/usr/bin/env node
2
- /**
3
- * aether-cli doctor - System Requirements Checker
4
- *
5
- * Validates that a validator's hardware meets minimum requirements:
6
- * - CPU: 8+ cores
7
- * - RAM: 32GB+ total, 28GB+ available
8
- * - Disk: 512GB+ SSD with 340GB+ free
9
- * - Network: 100Mbps+ upload/download
10
- * - Firewall: Required ports open
11
- *
12
- * @see docs/MINING_VALIDATOR_TOOLS.md for spec
13
- */
14
-
15
- const { execSync } = require('child_process');
16
- const os = require('os');
17
- const fs = require('fs');
18
- const path = require('path');
19
- const readline = require('readline');
20
-
21
- // ANSI colors for terminal output
22
- const colors = {
23
- reset: '\x1b[0m',
24
- bright: '\x1b[1m',
25
- red: '\x1b[31m',
26
- green: '\x1b[32m',
27
- yellow: '\x1b[33m',
28
- blue: '\x1b[34m',
29
- magenta: '\x1b[35m',
30
- cyan: '\x1b[36m',
31
- };
32
-
33
- // Minimum requirements per tier (from spec)
34
- const TIER_REQUIREMENTS = {
35
- full: {
36
- badge: '[FULL]',
37
- cpu: { minCores: 8 },
38
- ram: { minTotalGB: 32, minAvailableGB: 28 },
39
- disk: { minTotalGB: 512, minFreeGB: 340 },
40
- network: { minSpeedMbps: 100 },
41
- ports: { p2p: 8001, p2pNode: 8002, rpc: 8899, ssh: 22 },
42
- stake: '10,000 AETH',
43
- consensusWeight: '1.0x',
44
- canProduceBlocks: true,
45
- },
46
- lite: {
47
- badge: '[LITE]',
48
- cpu: { minCores: 4 },
49
- ram: { minTotalGB: 8, minAvailableGB: 6 },
50
- disk: { minTotalGB: 100, minFreeGB: 50 },
51
- network: { minSpeedMbps: 25 },
52
- ports: { p2p: 8001, rpc: 8899, ssh: 22 },
53
- stake: '1,000 AETH',
54
- consensusWeight: 'stake/10000 (e.g., 0.1x at 1K AETH)',
55
- canProduceBlocks: false,
56
- },
57
- observer: {
58
- badge: '[OBSERVER]',
59
- cpu: { minCores: 2 },
60
- ram: { minTotalGB: 4, minAvailableGB: 3 },
61
- disk: { minTotalGB: 50, minFreeGB: 25 },
62
- network: { minSpeedMbps: 10 },
63
- ports: { p2p: 8001, ssh: 22 }, // inbound only, no RPC
64
- stake: '0 AETH',
65
- consensusWeight: '0x (relay-only)',
66
- canProduceBlocks: false,
67
- },
68
- };
69
-
70
- // Default to full tier
71
- const DEFAULT_TIER = 'full';
72
-
73
- /**
74
- * Execute shell command and return output
75
- */
76
- function runCommand(cmd, options = {}) {
77
- try {
78
- return execSync(cmd, {
79
- encoding: 'utf-8',
80
- stdio: ['pipe', 'pipe', 'pipe'],
81
- ...options,
82
- }).trim();
83
- } catch (error) {
84
- return options.allowFailure ? null : error.message;
85
- }
86
- }
87
-
88
- /**
89
- * Check CPU specifications
90
- */
91
- function checkCPU(tier = DEFAULT_TIER) {
92
- const reqs = TIER_REQUIREMENTS[tier];
93
- const cpus = os.cpus();
94
- const physicalCores = cpus.length / 2; // Hyperthreading aware
95
- const model = cpus[0].model;
96
- const speed = cpus[0].speed;
97
-
98
- const passed = physicalCores >= reqs.cpu.minCores;
99
-
100
- return {
101
- section: 'CPU',
102
- model,
103
- physicalCores,
104
- logicalCores: cpus.length,
105
- frequency: `${speed} MHz`,
106
- passed,
107
- message: passed
108
- ? `✅ PASS (${physicalCores} cores >= ${reqs.cpu.minCores} required)`
109
- : `❌ FAIL (${physicalCores} cores < ${reqs.cpu.minCores} required)`,
110
- fixable: false,
111
- fixNote: 'CPU upgrade required - hardware limitation',
112
- };
113
- }
114
-
115
- /**
116
- * Check memory specifications
117
- */
118
- function checkMemory(tier = DEFAULT_TIER) {
119
- const reqs = TIER_REQUIREMENTS[tier];
120
- const totalGB = os.totalmem() / (1024 * 1024 * 1024);
121
- const freeGB = os.freemem() / (1024 * 1024 * 1024);
122
- const availableGB = freeGB; // Simplified - in production would check swap too
123
-
124
- const totalPassed = totalGB >= reqs.ram.minTotalGB;
125
- const availablePassed = availableGB >= reqs.ram.minAvailableGB;
126
- const passed = totalPassed && availablePassed;
127
-
128
- return {
129
- section: 'Memory',
130
- total: `${totalGB.toFixed(1)} GB`,
131
- available: `${availableGB.toFixed(1)} GB`,
132
- passed,
133
- message: passed
134
- ? `✅ PASS (${totalGB.toFixed(1)} GB total, ${availableGB.toFixed(1)} GB available)`
135
- : `❌ FAIL (need ${reqs.ram.minTotalGB} GB total, ${reqs.ram.minAvailableGB} GB available)`,
136
- fixable: false,
137
- fixNote: 'RAM upgrade required or close memory-intensive applications',
138
- };
139
- }
140
-
141
- /**
142
- * Check disk specifications
143
- */
144
- function checkDisk(tier = DEFAULT_TIER) {
145
- const reqs = TIER_REQUIREMENTS[tier];
146
- // Get disk info for root partition (works on Linux/Mac)
147
- let diskInfo = { mount: '/', type: 'SSD', total: 0, free: 0 };
148
-
149
- try {
150
- if (process.platform === 'win32') {
151
- // Windows: use PowerShell to get disk info
152
- const output = runCommand('powershell -c "Get-Volume -DriveType Fixed | Where-Object {$_.DriveLetter -eq (Get-Location).Drive.Name} | Select-Object Size,SizeRemaining"', { allowFailure: true });
153
- if (output) {
154
- // Parse PowerShell output (format: Size: 123456789012, SizeRemaining: 98765432100)
155
- const lines = output.split('\n');
156
- for (const line of lines) {
157
- if (line.includes('Size') && !line.includes('SizeRemaining')) {
158
- const sizeMatch = line.match(/(\d+)/);
159
- if (sizeMatch) diskInfo.total = parseInt(sizeMatch[1]) / (1024 * 1024 * 1024);
160
- }
161
- if (line.includes('SizeRemaining')) {
162
- const freeMatch = line.match(/(\d+)/);
163
- if (freeMatch) diskInfo.free = parseInt(freeMatch[1]) / (1024 * 1024 * 1024);
164
- }
165
- }
166
- }
167
- // Fallback: use Get-PSDrive
168
- if (diskInfo.total === 0) {
169
- const psDrive = runCommand('powershell -c "(Get-PSDrive -Name (Get-Location).Drive.Name).Used / 1GB"', { allowFailure: true });
170
- const psFree = runCommand('powershell -c "(Get-PSDrive -Name (Get-Location).Drive.Name).Free / 1GB"', { allowFailure: true });
171
- if (psDrive && psFree) {
172
- diskInfo.free = parseFloat(psFree);
173
- diskInfo.total = parseFloat(psDrive) + parseFloat(psFree);
174
- }
175
- }
176
- } else {
177
- // Linux/Mac: use df
178
- const output = runCommand('df -k / | tail -1');
179
- const parts = output.split(/\s+/);
180
- if (parts.length >= 4) {
181
- diskInfo.total = parseInt(parts[1]) / (1024 * 1024);
182
- diskInfo.free = parseInt(parts[3]) / (1024 * 1024);
183
- }
184
- }
185
- } catch (e) {
186
- // Fallback - mark as unknown
187
- diskInfo.total = 0;
188
- diskInfo.free = 0;
189
- }
190
-
191
- const totalPassed = diskInfo.total >= reqs.disk.minTotalGB;
192
- const freePassed = diskInfo.free >= reqs.disk.minFreeGB;
193
- const passed = totalPassed && freePassed;
194
-
195
- return {
196
- section: 'Disk',
197
- mount: diskInfo.mount,
198
- type: diskInfo.type,
199
- total: `${diskInfo.total.toFixed(0)} GB`,
200
- free: `${diskInfo.free.toFixed(0)} GB`,
201
- passed,
202
- message: passed
203
- ? `✅ PASS (${diskInfo.total.toFixed(0)} GB total, ${diskInfo.free.toFixed(0)} GB free)`
204
- : `❌ FAIL (need ${reqs.disk.minTotalGB} GB total, ${reqs.disk.minFreeGB} GB free)`,
205
- fixable: !totalPassed ? false : true,
206
- fixNote: totalPassed ? 'Free up disk space by removing old files or logs' : 'Larger disk required - hardware limitation',
207
- };
208
- }
209
-
210
- /**
211
- * Check network specifications
212
- */
213
- function checkNetwork(tier = DEFAULT_TIER) {
214
- const reqs = TIER_REQUIREMENTS[tier];
215
- // Try to get public IP
216
- let publicIP = 'Unknown';
217
- try {
218
- publicIP = runCommand('curl -s ifconfig.me', { allowFailure: true }) ||
219
- runCommand('curl -s icanhazip.com', { allowFailure: true }) ||
220
- 'Unknown';
221
- } catch (e) {
222
- publicIP = 'Unknown';
223
- }
224
-
225
- // Network speed test would require external API
226
- // For now, we check interface speed
227
- let downloadSpeed = 'Unknown';
228
- let uploadSpeed = 'Unknown';
229
- let latency = 'Unknown';
230
- let passed = true; // Assume pass if we can't test
231
-
232
- // In production, integrate with speedtest CLI or similar
233
- // For MVP, we'll show interface info
234
- const interfaces = os.networkInterfaces();
235
- const interfaceCount = Object.keys(interfaces).length;
236
-
237
- return {
238
- section: 'Network',
239
- publicIP,
240
- download: downloadSpeed,
241
- upload: uploadSpeed,
242
- latency,
243
- interfaces: interfaceCount,
244
- required: `${reqs.network.minSpeedMbps} Mbps`,
245
- passed,
246
- message: passed
247
- ? `✅ PASS (Network interfaces detected, need ${reqs.network.minSpeedMbps} Mbps)`
248
- : `❌ FAIL`,
249
- fixable: false,
250
- fixNote: 'Network connectivity is system-level',
251
- };
252
- }
253
-
254
- /**
255
- * Check firewall and port availability
256
- */
257
- function checkFirewall(tier = DEFAULT_TIER) {
258
- const reqs = TIER_REQUIREMENTS[tier];
259
- const results = { p2p: false, rpc: false, ssh: false };
260
- const blockedPorts = [];
261
-
262
- try {
263
- if (process.platform === 'linux') {
264
- // Check if ufw is active and ports are open
265
- const ufwStatus = runCommand('ufw status 2>&1', { allowFailure: true });
266
- if (ufwStatus && !ufwStatus.includes('inactive')) {
267
- results.p2p = ufwStatus.includes(`${reqs.ports.p2p}`);
268
- results.ssh = ufwStatus.includes(`${reqs.ports.ssh}`);
269
- // RPC only required for full/lite tiers
270
- if (reqs.ports.rpc) {
271
- results.rpc = ufwStatus.includes(`${reqs.ports.rpc}`);
272
- } else {
273
- results.rpc = true; // observer doesn't need RPC
274
- }
275
-
276
- if (!results.p2p) blockedPorts.push(reqs.ports.p2p);
277
- if (reqs.ports.rpc && !results.rpc) blockedPorts.push(reqs.ports.rpc);
278
- if (!results.ssh) blockedPorts.push(reqs.ports.ssh);
279
- } else {
280
- // If firewall inactive, assume ports are accessible
281
- results.p2p = true;
282
- results.rpc = reqs.ports.rpc ? true : true;
283
- results.ssh = true;
284
- }
285
- } else if (process.platform === 'win32') {
286
- // Windows Firewall check - test each port
287
- const testPort = (port) => {
288
- try {
289
- const result = runCommand(`powershell -c "Get-NetFirewallRule -DisplayName '*Aether*' -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq True }"`, { allowFailure: true });
290
- // Simplified: check if any aether rules exist
291
- if (result && result.includes('Aether')) {
292
- return true;
293
- }
294
- // Try to bind to port to test availability
295
- const bindTest = runCommand(`powershell -c "$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Any, ${port}); $listener.Start(); $listener.Stop()"`, { allowFailure: true });
296
- return bindTest === null || !bindTest.includes('error');
297
- } catch {
298
- return false;
299
- }
300
- };
301
-
302
- results.p2p = testPort(reqs.ports.p2p);
303
- results.ssh = testPort(reqs.ports.ssh);
304
- if (reqs.ports.rpc) {
305
- results.rpc = testPort(reqs.ports.rpc);
306
- } else {
307
- results.rpc = true; // observer doesn't need RPC
308
- }
309
-
310
- if (!results.p2p) blockedPorts.push(reqs.ports.p2p);
311
- if (reqs.ports.rpc && !results.rpc) blockedPorts.push(reqs.ports.rpc);
312
- if (!results.ssh) blockedPorts.push(reqs.ports.ssh);
313
- } else {
314
- // macOS / other - assume pass
315
- results.p2p = true;
316
- results.rpc = reqs.ports.rpc ? true : true;
317
- results.ssh = true;
318
- }
319
- } catch (e) {
320
- // Assume pass if we can't check
321
- results.p2p = true;
322
- results.rpc = reqs.ports.rpc ? true : true;
323
- results.ssh = true;
324
- }
325
-
326
- const allPassed = results.p2p && results.rpc && results.ssh;
327
-
328
- return {
329
- section: 'Firewall',
330
- p2p: results.p2p,
331
- rpc: results.rpc,
332
- ssh: results.ssh,
333
- blockedPorts,
334
- passed: allPassed,
335
- message: allPassed
336
- ? `✅ PASS (All required ports accessible)`
337
- : `❌ FAIL (Ports ${blockedPorts.join(', ')} may be blocked)`,
338
- fixable: blockedPorts.length > 0,
339
- fixNote: blockedPorts.length > 0 ? `Add firewall rules for ports ${blockedPorts.join(', ')}` : '',
340
- };
341
- }
342
-
343
- /**
344
- * Check if validator binary exists
345
- */
346
- function checkValidatorBinary() {
347
- const platform = os.platform();
348
- const isWindows = platform === 'win32';
349
- const binaryName = isWindows ? 'aether-validator.exe' : 'aether-validator';
350
-
351
- // Check the expected location based on repo layout
352
- const workspaceRoot = path.join(__dirname, '..', '..');
353
- const repoPath = path.join(workspaceRoot, 'Jelly-legs-unsteady-workshop');
354
- const binaryPath = path.join(repoPath, 'target', 'debug', binaryName);
355
-
356
- const exists = fs.existsSync(binaryPath);
357
-
358
- return {
359
- section: 'Validator Binary',
360
- path: binaryPath,
361
- exists,
362
- passed: exists,
363
- message: exists
364
- ? `✅ PASS (Binary found at ${binaryPath})`
365
- : `❌ FAIL (Binary not found at ${binaryPath})`,
366
- fixable: true,
367
- fixNote: exists ? '' : 'Run cargo build --bin aether-validator',
368
- };
369
- }
370
-
371
- /**
372
- * Get OS information
373
- */
374
- function getOSInfo() {
375
- const platform = os.platform();
376
- const arch = os.arch();
377
- const release = os.release();
378
-
379
- let osName = platform;
380
- try {
381
- if (platform === 'linux') {
382
- const osRelease = fs.readFileSync('/etc/os-release', 'utf-8');
383
- const nameMatch = osRelease.match(/^NAME="([^"]+)"/m);
384
- const versionMatch = osRelease.match(/^VERSION_ID="([^"]+)"/m);
385
- if (nameMatch) osName = nameMatch[1];
386
- if (versionMatch) osName += ` ${versionMatch[1]}`;
387
- } else if (platform === 'darwin') {
388
- const darwinVersion = runCommand('sw_vers -productVersion', { allowFailure: true });
389
- if (darwinVersion) osName = `macOS ${darwinVersion}`;
390
- } else if (platform === 'win32') {
391
- const winVersion = runCommand('powershell -c "(Get-CimInstance Win32_OperatingSystem).Caption"', { allowFailure: true });
392
- if (winVersion) osName = winVersion;
393
- }
394
- } catch (e) {
395
- // Use defaults
396
- }
397
-
398
- return { platform, arch, release, osName };
399
- }
400
-
401
- /**
402
- * Print section header
403
- */
404
- function printSectionHeader(title) {
405
- console.log(`\n${colors.bright}${colors.cyan}${title}${colors.reset}`);
406
- console.log(`${colors.cyan}${'─'.repeat(60)}${colors.reset}`);
407
- }
408
-
409
- /**
410
- * Print check result
411
- */
412
- function printCheckResult(check) {
413
- console.log(`\n${colors.bright}${check.section}${colors.reset}`);
414
- Object.entries(check).forEach(([key, value]) => {
415
- if (['section', 'passed', 'message', 'fixable', 'fixNote'].includes(key)) return;
416
- console.log(` ${key}: ${value}`);
417
- });
418
- console.log(` ${check.message}`);
419
- }
420
-
421
- /**
422
- * Print ASCII art header
423
- */
424
- function printHeader(tier = DEFAULT_TIER) {
425
- const reqs = TIER_REQUIREMENTS[tier];
426
- const header = `
427
- ${colors.bright}${colors.cyan}
428
- ███╗ ███╗██╗███████╗███████╗██╗ ██████╗ ███╗ ██╗
429
- ████╗ ████║██║██╔════╝██╔════╝██║██╔═══██╗████╗ ██║
430
- ██╔████╔██║██║███████╗███████╗██║██║ ██║██╔██╗ ██║
431
- ██║╚██╔╝██║██║╚════██║╚════██║██║██║ ██║██║╚██╗██║
432
- ██║ ╚═╝ ██║██║███████║███████║██║╚██████╔╝██║ ╚████║
433
- ╚═╝ ╚═╝╚═╝╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═══╝
434
-
435
- ${colors.reset}${colors.bright}Validator System Check${colors.reset}
436
- ${colors.yellow}v1.0.0${colors.reset}
437
- ${new Date().toISOString().split('T')[0]}
438
- ${colors.magenta}${reqs.badge}${colors.reset}
439
- `.trim();
440
- console.log(header);
441
- console.log(`\n${colors.cyan}${'━'.repeat(60)}${colors.reset}`);
442
-
443
- // Print tier summary
444
- console.log(`\n${colors.bright}Tier Requirements:${colors.reset}`);
445
- console.log(` ${colors.cyan}Stake:${colors.reset} ${reqs.stake}`);
446
- console.log(` ${colors.cyan}Consensus Weight:${colors.reset} ${reqs.consensusWeight}`);
447
- console.log(` ${colors.cyan}Block Production:${colors.reset} ${reqs.canProduceBlocks ? '✅ Yes' : '❌ No'}`);
448
- console.log(` ${colors.cyan}CPU:${colors.reset} ${reqs.cpu.minCores}+ cores`);
449
- console.log(` ${colors.cyan}RAM:${colors.reset} ${reqs.ram.minTotalGB}GB+ total, ${reqs.ram.minAvailableGB}GB+ available`);
450
- console.log(` ${colors.cyan}Disk:${colors.reset} ${reqs.disk.minTotalGB}GB+ total, ${reqs.disk.minFreeGB}GB+ free`);
451
- console.log(` ${colors.cyan}Network:${colors.reset} ${reqs.network.minSpeedMbps}+ Mbps`);
452
- console.log(` ${colors.cyan}Ports:${colors.reset} ${Object.values(reqs.ports).join(', ')}`);
453
- console.log(`\n${colors.cyan}${'━'.repeat(60)}${colors.reset}`);
454
- }
455
-
456
- /**
457
- * Print summary
458
- */
459
- function printSummary(results, tier = DEFAULT_TIER) {
460
- const reqs = TIER_REQUIREMENTS[tier];
461
- const allPassed = results.every(r => r.passed);
462
-
463
- console.log(`\n${colors.cyan}${'━'.repeat(60)}${colors.reset}`);
464
- console.log(`\n${colors.bright}SUMMARY:${colors.reset} ${colors.magenta}${reqs.badge}${colors.reset}`);
465
-
466
- if (allPassed) {
467
- console.log(`\n${colors.bright}${colors.green} All checks passed!${colors.reset}`);
468
- console.log(`\n${colors.green}Your system is ready to run an AeTHer ${tier.toUpperCase()} validator.${colors.reset}`);
469
- console.log(`\nNext steps:`);
470
- console.log(` ${colors.bright}aether-cli validator start --tier ${tier}${colors.reset} # Start validating`);
471
- console.log(` ${colors.bright}aether-cli validator status${colors.reset} # Check status`);
472
- console.log(` ${colors.bright}aether-cli help${colors.reset} # View all commands`);
473
- } else {
474
- const failed = results.filter(r => !r.passed);
475
- const fixable = failed.filter(r => r.fixable);
476
-
477
- console.log(`\n${colors.bright}${colors.red}❌ ${failed.length} check(s) failed${colors.reset}`);
478
-
479
- if (fixable.length > 0) {
480
- console.log(`\n${colors.yellow}⚠ ${fixable.length} issue(s) can be auto-fixed:${colors.reset}`);
481
- fixable.forEach(f => {
482
- console.log(` ${colors.yellow}• ${f.section}: ${f.fixNote}${colors.reset}`);
483
- });
484
- }
485
-
486
- const notFixable = failed.filter(r => !r.fixable);
487
- if (notFixable.length > 0) {
488
- console.log(`\n${colors.red}The following issues require manual action:${colors.reset}`);
489
- notFixable.forEach(f => {
490
- console.log(` ${colors.red}• ${f.section}: ${f.fixNote}${colors.reset}`);
491
- });
492
- }
493
- }
494
-
495
- console.log(`\n${colors.cyan}${'━'.repeat(60)}${colors.reset}\n`);
496
- }
497
-
498
- /**
499
- * Generate fix command for a failed check
500
- */
501
- function getFixCommand(check) {
502
- const platform = os.platform();
503
-
504
- switch (check.section) {
505
- case 'Firewall':
506
- if (platform === 'win32') {
507
- const ports = check.blockedPorts || [];
508
- if (ports.length === 0) return null;
509
- const rules = ports.map(port =>
510
- `New-NetFirewallRule -DisplayName "Aether Port ${port}" -Direction Inbound -LocalPort ${port} -Protocol TCP -Action Allow`
511
- ).join('; ');
512
- return `powershell -c "${rules}"`;
513
- } else if (platform === 'linux') {
514
- const ports = check.blockedPorts || [];
515
- if (ports.length === 0) return null;
516
- const rules = ports.map(port => `sudo ufw allow ${port}/tcp`).join(' && ');
517
- return rules;
518
- }
519
- return null;
520
-
521
- case 'Validator Binary':
522
- const workspaceRoot = path.join(__dirname, '..', '..');
523
- const repoPath = path.join(workspaceRoot, 'Jelly-legs-unsteady-workshop');
524
- return `cd "${repoPath}" && cargo build --bin aether-validator`;
525
-
526
- case 'Disk':
527
- // Can't auto-fix disk space, but can suggest cleanup
528
- if (platform === 'win32') {
529
- return 'powershell -c "Get-AppxPackage -AllUsers | Where-Object {$_.InstallLocation -like \'*WindowsApps*\'} | Select-Object Name, PackageFullName"';
530
- } else {
531
- return 'sudo du -sh /* 2>/dev/null | sort -hr | head -20';
532
- }
533
-
534
- default:
535
- return null;
536
- }
537
- }
538
-
539
- /**
540
- * Ask user for confirmation
541
- */
542
- async function askConfirmation(question) {
543
- const rl = readline.createInterface({
544
- input: process.stdin,
545
- output: process.stdout,
546
- });
547
-
548
- return new Promise((resolve) => {
549
- rl.question(`${colors.yellow}${question}${colors.reset} [y/N] `, (answer) => {
550
- rl.close();
551
- resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
552
- });
553
- });
554
- }
555
-
556
- /**
557
- * Apply a fix for a failed check
558
- */
559
- async function applyFix(check) {
560
- const command = getFixCommand(check);
561
-
562
- if (!command) {
563
- console.log(` ${colors.red}✗ No automated fix available for ${check.section}${colors.reset}`);
564
- return false;
565
- }
566
-
567
- console.log(`\n ${colors.cyan}Proposed fix:${colors.reset}`);
568
- console.log(` ${colors.bright}${command}${colors.reset}`);
569
- console.log();
570
-
571
- const confirmed = await askConfirmation(' Apply this fix?');
572
-
573
- if (!confirmed) {
574
- console.log(` ${colors.yellow}Skipped.${colors.reset}`);
575
- return false;
576
- }
577
-
578
- console.log(` ${colors.cyan}Applying fix...${colors.reset}`);
579
-
580
- try {
581
- execSync(command, {
582
- stdio: 'inherit',
583
- shell: true,
584
- cwd: process.cwd(),
585
- });
586
-
587
- console.log(` ${colors.green}✓ Fix applied successfully!${colors.reset}`);
588
-
589
- // Re-run the check to verify
590
- console.log(` ${colors.cyan}Verifying...${colors.reset}`);
591
- let verifyCheck;
592
- switch (check.section) {
593
- case 'Firewall':
594
- verifyCheck = checkFirewall();
595
- break;
596
- case 'Validator Binary':
597
- verifyCheck = checkValidatorBinary();
598
- break;
599
- default:
600
- verifyCheck = check;
601
- }
602
-
603
- if (verifyCheck.passed) {
604
- console.log(` ${colors.green}✓ Verification passed!${colors.reset}`);
605
- return true;
606
- } else {
607
- console.log(` ${colors.yellow}⚠ Fix applied but check still failing. May require manual intervention.${colors.reset}`);
608
- return false;
609
- }
610
- } catch (err) {
611
- console.log(` ${colors.red}✗ Fix failed: ${err.message}${colors.reset}`);
612
- return false;
613
- }
614
- }
615
-
616
- /**
617
- * Interactive fix mode
618
- */
619
- async function interactiveFixMode(results) {
620
- const failed = results.filter(r => !r.passed);
621
- const fixable = failed.filter(r => r.fixable);
622
-
623
- if (fixable.length === 0) {
624
- console.log(`\n${colors.yellow}No auto-fixable issues found.${colors.reset}`);
625
- return;
626
- }
627
-
628
- console.log(`\n${colors.bright}${colors.cyan}Auto-Fix Mode${colors.reset}`);
629
- console.log(`${colors.cyan}${'─'.repeat(60)}${colors.reset}`);
630
- console.log(`\n${colors.yellow}Found ${fixable.length} issue(s) that can be fixed automatically:${colors.reset}\n`);
631
-
632
- for (const check of fixable) {
633
- console.log(`${colors.bright}${check.section}${colors.reset}`);
634
- console.log(` Issue: ${check.fixNote}`);
635
- console.log();
636
-
637
- const fixed = await applyFix(check);
638
-
639
- if (fixed) {
640
- check.passed = true;
641
- check.message = `✅ FIXED (was: ${check.message})`;
642
- }
643
-
644
- console.log();
645
- }
646
-
647
- // Print updated summary
648
- const stillFailed = results.filter(r => !r.passed);
649
- if (stillFailed.length === 0) {
650
- console.log(`\n${colors.bright}${colors.green}🎉 All issues resolved!${colors.reset}`);
651
- console.log(`\n${colors.green}Your system is now ready to run an AeTHer validator.${colors.reset}`);
652
- } else {
653
- console.log(`\n${colors.yellow}⚠ ${stillFailed.length} issue(s) remain unresolved.${colors.reset}`);
654
- }
655
- }
656
-
657
- /**
658
- * Main doctor command
659
- */
660
- async function doctorCommand(options = {}) {
661
- const { autoFix = false, tier = DEFAULT_TIER } = options;
662
-
663
- // Validate tier
664
- if (!TIER_REQUIREMENTS[tier]) {
665
- console.log(`${colors.red}Error: Invalid tier '${tier}'. Valid tiers: full, lite, observer${colors.reset}`);
666
- return 1;
667
- }
668
-
669
- printHeader(tier);
670
- console.log(`\n${colors.bright}Running system checks for ${tier.toUpperCase()} tier...${colors.reset}\n`);
671
-
672
- const results = [
673
- checkCPU(tier),
674
- checkMemory(tier),
675
- checkDisk(tier),
676
- checkNetwork(tier),
677
- checkFirewall(tier),
678
- checkValidatorBinary(),
679
- ];
680
-
681
- results.forEach(printCheckResult);
682
- printSummary(results, tier);
683
-
684
- // If auto-fix mode or user requests it
685
- const failed = results.filter(r => !r.passed);
686
- const fixable = failed.filter(r => r.fixable);
687
-
688
- if (fixable.length > 0) {
689
- if (autoFix) {
690
- console.log(`\n${colors.cyan}Auto-fix mode enabled. Attempting fixes...${colors.reset}\n`);
691
- await interactiveFixMode(results);
692
- } else {
693
- console.log(`\n${colors.cyan}Tip: Run ${colors.bright}aether-cli doctor --fix${colors.reset}${colors.cyan} to auto-fix issues.${colors.reset}\n`);
694
- }
695
- }
696
-
697
- // Return exit code based on results
698
- const allPassed = results.every(r => r.passed);
699
- return allPassed ? 0 : 1;
700
- }
701
-
702
- // Export for use as module
703
- module.exports = { doctorCommand, checkCPU, checkMemory, checkDisk, checkNetwork, checkFirewall, checkValidatorBinary };
704
-
705
- // Run if called directly
706
- if (require.main === module) {
707
- const args = process.argv.slice(2);
708
- const autoFix = args.includes('--fix') || args.includes('-f');
709
-
710
- // Parse --tier flag
711
- let tier = DEFAULT_TIER;
712
- const tierIndex = args.findIndex(arg => arg === '--tier');
713
- if (tierIndex !== -1 && args[tierIndex + 1]) {
714
- tier = args[tierIndex + 1].toLowerCase();
715
- }
716
-
717
- doctorCommand({ autoFix, tier }).then(exitCode => {
718
- process.exit(exitCode);
719
- });
720
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * aether-cli doctor - System Requirements Checker
4
+ *
5
+ * Validates that a validator's hardware meets minimum requirements:
6
+ * - CPU: 8+ cores
7
+ * - RAM: 32GB+ total, 28GB+ available
8
+ * - Disk: 512GB+ SSD with 340GB+ free
9
+ * - Network: 100Mbps+ upload/download
10
+ * - Firewall: Required ports open
11
+ *
12
+ * @see docs/MINING_VALIDATOR_TOOLS.md for spec
13
+ */
14
+
15
+ const { execSync } = require('child_process');
16
+ const os = require('os');
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const readline = require('readline');
20
+
21
+ // ANSI colors for terminal output
22
+ const colors = {
23
+ reset: '\x1b[0m',
24
+ bright: '\x1b[1m',
25
+ red: '\x1b[31m',
26
+ green: '\x1b[32m',
27
+ yellow: '\x1b[33m',
28
+ blue: '\x1b[34m',
29
+ magenta: '\x1b[35m',
30
+ cyan: '\x1b[36m',
31
+ };
32
+
33
+ // Minimum requirements per tier (from spec)
34
+ const TIER_REQUIREMENTS = {
35
+ full: {
36
+ badge: '[FULL]',
37
+ cpu: { minCores: 8 },
38
+ ram: { minTotalGB: 32, minAvailableGB: 28 },
39
+ disk: { minTotalGB: 512, minFreeGB: 340 },
40
+ network: { minSpeedMbps: 100 },
41
+ ports: { p2p: 8001, p2pNode: 8002, rpc: 8899, ssh: 22 },
42
+ stake: '10,000 AETH',
43
+ consensusWeight: '1.0x',
44
+ canProduceBlocks: true,
45
+ },
46
+ lite: {
47
+ badge: '[LITE]',
48
+ cpu: { minCores: 4 },
49
+ ram: { minTotalGB: 8, minAvailableGB: 6 },
50
+ disk: { minTotalGB: 100, minFreeGB: 50 },
51
+ network: { minSpeedMbps: 25 },
52
+ ports: { p2p: 8001, rpc: 8899, ssh: 22 },
53
+ stake: '1,000 AETH',
54
+ consensusWeight: 'stake/10000 (e.g., 0.1x at 1K AETH)',
55
+ canProduceBlocks: false,
56
+ },
57
+ observer: {
58
+ badge: '[OBSERVER]',
59
+ cpu: { minCores: 2 },
60
+ ram: { minTotalGB: 4, minAvailableGB: 3 },
61
+ disk: { minTotalGB: 50, minFreeGB: 25 },
62
+ network: { minSpeedMbps: 10 },
63
+ ports: { p2p: 8001, ssh: 22 }, // inbound only, no RPC
64
+ stake: '0 AETH',
65
+ consensusWeight: '0x (relay-only)',
66
+ canProduceBlocks: false,
67
+ },
68
+ };
69
+
70
+ // Default to full tier
71
+ const DEFAULT_TIER = 'full';
72
+
73
+ /**
74
+ * Execute shell command and return output
75
+ */
76
+ function runCommand(cmd, options = {}) {
77
+ try {
78
+ return execSync(cmd, {
79
+ encoding: 'utf-8',
80
+ stdio: ['pipe', 'pipe', 'pipe'],
81
+ ...options,
82
+ }).trim();
83
+ } catch (error) {
84
+ return options.allowFailure ? null : error.message;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Check CPU specifications
90
+ */
91
+ function checkCPU(tier = DEFAULT_TIER) {
92
+ const reqs = TIER_REQUIREMENTS[tier];
93
+ const cpus = os.cpus();
94
+ const physicalCores = cpus.length / 2; // Hyperthreading aware
95
+ const model = cpus[0].model;
96
+ const speed = cpus[0].speed;
97
+
98
+ const passed = physicalCores >= reqs.cpu.minCores;
99
+
100
+ return {
101
+ section: 'CPU',
102
+ model,
103
+ physicalCores,
104
+ logicalCores: cpus.length,
105
+ frequency: `${speed} MHz`,
106
+ passed,
107
+ message: passed
108
+ ? `✅ PASS (${physicalCores} cores >= ${reqs.cpu.minCores} required)`
109
+ : `❌ FAIL (${physicalCores} cores < ${reqs.cpu.minCores} required)`,
110
+ fixable: false,
111
+ fixNote: 'CPU upgrade required - hardware limitation',
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Check memory specifications
117
+ */
118
+ function checkMemory(tier = DEFAULT_TIER) {
119
+ const reqs = TIER_REQUIREMENTS[tier];
120
+ const totalGB = os.totalmem() / (1024 * 1024 * 1024);
121
+ const freeGB = os.freemem() / (1024 * 1024 * 1024);
122
+ const availableGB = freeGB; // Simplified - in production would check swap too
123
+
124
+ const totalPassed = totalGB >= reqs.ram.minTotalGB;
125
+ const availablePassed = availableGB >= reqs.ram.minAvailableGB;
126
+ const passed = totalPassed && availablePassed;
127
+
128
+ return {
129
+ section: 'Memory',
130
+ total: `${totalGB.toFixed(1)} GB`,
131
+ available: `${availableGB.toFixed(1)} GB`,
132
+ passed,
133
+ message: passed
134
+ ? `✅ PASS (${totalGB.toFixed(1)} GB total, ${availableGB.toFixed(1)} GB available)`
135
+ : `❌ FAIL (need ${reqs.ram.minTotalGB} GB total, ${reqs.ram.minAvailableGB} GB available)`,
136
+ fixable: false,
137
+ fixNote: 'RAM upgrade required or close memory-intensive applications',
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Check disk specifications
143
+ */
144
+ function checkDisk(tier = DEFAULT_TIER) {
145
+ const reqs = TIER_REQUIREMENTS[tier];
146
+ // Get disk info for root partition (works on Linux/Mac)
147
+ let diskInfo = { mount: '/', type: 'SSD', total: 0, free: 0 };
148
+
149
+ try {
150
+ if (process.platform === 'win32') {
151
+ // Windows: use PowerShell to get disk info
152
+ const output = runCommand('powershell -c "Get-Volume -DriveType Fixed | Where-Object {$_.DriveLetter -eq (Get-Location).Drive.Name} | Select-Object Size,SizeRemaining"', { allowFailure: true });
153
+ if (output) {
154
+ // Parse PowerShell output (format: Size: 123456789012, SizeRemaining: 98765432100)
155
+ const lines = output.split('\n');
156
+ for (const line of lines) {
157
+ if (line.includes('Size') && !line.includes('SizeRemaining')) {
158
+ const sizeMatch = line.match(/(\d+)/);
159
+ if (sizeMatch) diskInfo.total = parseInt(sizeMatch[1]) / (1024 * 1024 * 1024);
160
+ }
161
+ if (line.includes('SizeRemaining')) {
162
+ const freeMatch = line.match(/(\d+)/);
163
+ if (freeMatch) diskInfo.free = parseInt(freeMatch[1]) / (1024 * 1024 * 1024);
164
+ }
165
+ }
166
+ }
167
+ // Fallback: use Get-PSDrive
168
+ if (diskInfo.total === 0) {
169
+ const psDrive = runCommand('powershell -c "(Get-PSDrive -Name (Get-Location).Drive.Name).Used / 1GB"', { allowFailure: true });
170
+ const psFree = runCommand('powershell -c "(Get-PSDrive -Name (Get-Location).Drive.Name).Free / 1GB"', { allowFailure: true });
171
+ if (psDrive && psFree) {
172
+ diskInfo.free = parseFloat(psFree);
173
+ diskInfo.total = parseFloat(psDrive) + parseFloat(psFree);
174
+ }
175
+ }
176
+ } else {
177
+ // Linux/Mac: use df
178
+ const output = runCommand('df -k / | tail -1');
179
+ const parts = output.split(/\s+/);
180
+ if (parts.length >= 4) {
181
+ diskInfo.total = parseInt(parts[1]) / (1024 * 1024);
182
+ diskInfo.free = parseInt(parts[3]) / (1024 * 1024);
183
+ }
184
+ }
185
+ } catch (e) {
186
+ // Fallback - mark as unknown
187
+ diskInfo.total = 0;
188
+ diskInfo.free = 0;
189
+ }
190
+
191
+ const totalPassed = diskInfo.total >= reqs.disk.minTotalGB;
192
+ const freePassed = diskInfo.free >= reqs.disk.minFreeGB;
193
+ const passed = totalPassed && freePassed;
194
+
195
+ return {
196
+ section: 'Disk',
197
+ mount: diskInfo.mount,
198
+ type: diskInfo.type,
199
+ total: `${diskInfo.total.toFixed(0)} GB`,
200
+ free: `${diskInfo.free.toFixed(0)} GB`,
201
+ passed,
202
+ message: passed
203
+ ? `✅ PASS (${diskInfo.total.toFixed(0)} GB total, ${diskInfo.free.toFixed(0)} GB free)`
204
+ : `❌ FAIL (need ${reqs.disk.minTotalGB} GB total, ${reqs.disk.minFreeGB} GB free)`,
205
+ fixable: !totalPassed ? false : true,
206
+ fixNote: totalPassed ? 'Free up disk space by removing old files or logs' : 'Larger disk required - hardware limitation',
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Check network specifications
212
+ */
213
+ function checkNetwork(tier = DEFAULT_TIER) {
214
+ const reqs = TIER_REQUIREMENTS[tier];
215
+ // Try to get public IP
216
+ let publicIP = 'Unknown';
217
+ try {
218
+ publicIP = runCommand('curl -s ifconfig.me', { allowFailure: true }) ||
219
+ runCommand('curl -s icanhazip.com', { allowFailure: true }) ||
220
+ 'Unknown';
221
+ } catch (e) {
222
+ publicIP = 'Unknown';
223
+ }
224
+
225
+ // Network speed test would require external API
226
+ // For now, we check interface speed
227
+ let downloadSpeed = 'Unknown';
228
+ let uploadSpeed = 'Unknown';
229
+ let latency = 'Unknown';
230
+ let passed = true; // Assume pass if we can't test
231
+
232
+ // In production, integrate with speedtest CLI or similar
233
+ // For MVP, we'll show interface info
234
+ const interfaces = os.networkInterfaces();
235
+ const interfaceCount = Object.keys(interfaces).length;
236
+
237
+ return {
238
+ section: 'Network',
239
+ publicIP,
240
+ download: downloadSpeed,
241
+ upload: uploadSpeed,
242
+ latency,
243
+ interfaces: interfaceCount,
244
+ required: `${reqs.network.minSpeedMbps} Mbps`,
245
+ passed,
246
+ message: passed
247
+ ? `✅ PASS (Network interfaces detected, need ${reqs.network.minSpeedMbps} Mbps)`
248
+ : `❌ FAIL`,
249
+ fixable: false,
250
+ fixNote: 'Network connectivity is system-level',
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Check firewall and port availability
256
+ */
257
+ function checkFirewall(tier = DEFAULT_TIER) {
258
+ const reqs = TIER_REQUIREMENTS[tier];
259
+ const results = { p2p: false, rpc: false, ssh: false };
260
+ const blockedPorts = [];
261
+
262
+ try {
263
+ if (process.platform === 'linux') {
264
+ // Check if ufw is active and ports are open
265
+ const ufwStatus = runCommand('ufw status 2>&1', { allowFailure: true });
266
+ if (ufwStatus && !ufwStatus.includes('inactive')) {
267
+ results.p2p = ufwStatus.includes(`${reqs.ports.p2p}`);
268
+ results.ssh = ufwStatus.includes(`${reqs.ports.ssh}`);
269
+ // RPC only required for full/lite tiers
270
+ if (reqs.ports.rpc) {
271
+ results.rpc = ufwStatus.includes(`${reqs.ports.rpc}`);
272
+ } else {
273
+ results.rpc = true; // observer doesn't need RPC
274
+ }
275
+
276
+ if (!results.p2p) blockedPorts.push(reqs.ports.p2p);
277
+ if (reqs.ports.rpc && !results.rpc) blockedPorts.push(reqs.ports.rpc);
278
+ if (!results.ssh) blockedPorts.push(reqs.ports.ssh);
279
+ } else {
280
+ // If firewall inactive, assume ports are accessible
281
+ results.p2p = true;
282
+ results.rpc = reqs.ports.rpc ? true : true;
283
+ results.ssh = true;
284
+ }
285
+ } else if (process.platform === 'win32') {
286
+ // Windows Firewall check - test each port
287
+ const testPort = (port) => {
288
+ try {
289
+ const result = runCommand(`powershell -c "Get-NetFirewallRule -DisplayName '*Aether*' -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq True }"`, { allowFailure: true });
290
+ // Simplified: check if any aether rules exist
291
+ if (result && result.includes('Aether')) {
292
+ return true;
293
+ }
294
+ // Try to bind to port to test availability
295
+ const bindTest = runCommand(`powershell -c "$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Any, ${port}); $listener.Start(); $listener.Stop()"`, { allowFailure: true });
296
+ return bindTest === null || !bindTest.includes('error');
297
+ } catch {
298
+ return false;
299
+ }
300
+ };
301
+
302
+ results.p2p = testPort(reqs.ports.p2p);
303
+ results.ssh = testPort(reqs.ports.ssh);
304
+ if (reqs.ports.rpc) {
305
+ results.rpc = testPort(reqs.ports.rpc);
306
+ } else {
307
+ results.rpc = true; // observer doesn't need RPC
308
+ }
309
+
310
+ if (!results.p2p) blockedPorts.push(reqs.ports.p2p);
311
+ if (reqs.ports.rpc && !results.rpc) blockedPorts.push(reqs.ports.rpc);
312
+ if (!results.ssh) blockedPorts.push(reqs.ports.ssh);
313
+ } else {
314
+ // macOS / other - assume pass
315
+ results.p2p = true;
316
+ results.rpc = reqs.ports.rpc ? true : true;
317
+ results.ssh = true;
318
+ }
319
+ } catch (e) {
320
+ // Assume pass if we can't check
321
+ results.p2p = true;
322
+ results.rpc = reqs.ports.rpc ? true : true;
323
+ results.ssh = true;
324
+ }
325
+
326
+ const allPassed = results.p2p && results.rpc && results.ssh;
327
+
328
+ return {
329
+ section: 'Firewall',
330
+ p2p: results.p2p,
331
+ rpc: results.rpc,
332
+ ssh: results.ssh,
333
+ blockedPorts,
334
+ passed: allPassed,
335
+ message: allPassed
336
+ ? `✅ PASS (All required ports accessible)`
337
+ : `❌ FAIL (Ports ${blockedPorts.join(', ')} may be blocked)`,
338
+ fixable: blockedPorts.length > 0,
339
+ fixNote: blockedPorts.length > 0 ? `Add firewall rules for ports ${blockedPorts.join(', ')}` : '',
340
+ };
341
+ }
342
+
343
+ /**
344
+ * Check if validator binary exists
345
+ */
346
+ function checkValidatorBinary() {
347
+ const platform = os.platform();
348
+ const isWindows = platform === 'win32';
349
+ const binaryName = isWindows ? 'aether-validator.exe' : 'aether-validator';
350
+
351
+ // Check the expected location based on repo layout
352
+ // Resolve workspace root walk up from CLI to find the actual repo
353
+ // Look for Jelly-legs-unsteady-workshop that has real workspace markers (src/, .github/)
354
+ // not the nested copy inside the npm package
355
+ const npmPackageDir = path.join(__dirname, '..', '..');
356
+ let workspaceRoot = npmPackageDir;
357
+
358
+ let dir = npmPackageDir;
359
+ for (let i = 0; i < 15; i++) {
360
+ const candidate = path.join(dir, 'Jelly-legs-unsteady-workshop');
361
+ // Must have real workspace markers (Cargo.toml or .github/) to distinguish from npm package nested copy
362
+ if (fs.existsSync(path.join(candidate, 'Cargo.toml')) || fs.existsSync(path.join(candidate, '.github'))) {
363
+ workspaceRoot = candidate;
364
+ break;
365
+ }
366
+ const parent = path.dirname(dir);
367
+ if (parent === dir) break;
368
+ dir = parent;
369
+ }
370
+
371
+ const repoPath = path.join(workspaceRoot, 'Jelly-legs-unsteady-workshop');
372
+ // Prefer release binary, fall back to debug
373
+ const releaseBinary = path.join(repoPath, 'target', 'release', binaryName);
374
+ const debugBinary = path.join(repoPath, 'target', 'debug', binaryName);
375
+ const binaryPath = fs.existsSync(releaseBinary) ? releaseBinary : debugBinary;
376
+
377
+ const exists = fs.existsSync(binaryPath);
378
+
379
+ return {
380
+ section: 'Validator Binary',
381
+ path: binaryPath,
382
+ exists,
383
+ passed: exists,
384
+ message: exists
385
+ ? `✅ PASS (Binary found at ${binaryPath})`
386
+ : `❌ FAIL (Binary not found at ${binaryPath})`,
387
+ fixable: true,
388
+ fixNote: exists ? '' : 'Run cargo build --bin aether-validator',
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Get OS information
394
+ */
395
+ function getOSInfo() {
396
+ const platform = os.platform();
397
+ const arch = os.arch();
398
+ const release = os.release();
399
+
400
+ let osName = platform;
401
+ try {
402
+ if (platform === 'linux') {
403
+ const osRelease = fs.readFileSync('/etc/os-release', 'utf-8');
404
+ const nameMatch = osRelease.match(/^NAME="([^"]+)"/m);
405
+ const versionMatch = osRelease.match(/^VERSION_ID="([^"]+)"/m);
406
+ if (nameMatch) osName = nameMatch[1];
407
+ if (versionMatch) osName += ` ${versionMatch[1]}`;
408
+ } else if (platform === 'darwin') {
409
+ const darwinVersion = runCommand('sw_vers -productVersion', { allowFailure: true });
410
+ if (darwinVersion) osName = `macOS ${darwinVersion}`;
411
+ } else if (platform === 'win32') {
412
+ const winVersion = runCommand('powershell -c "(Get-CimInstance Win32_OperatingSystem).Caption"', { allowFailure: true });
413
+ if (winVersion) osName = winVersion;
414
+ }
415
+ } catch (e) {
416
+ // Use defaults
417
+ }
418
+
419
+ return { platform, arch, release, osName };
420
+ }
421
+
422
+ /**
423
+ * Print section header
424
+ */
425
+ function printSectionHeader(title) {
426
+ console.log(`\n${colors.bright}${colors.cyan}${title}${colors.reset}`);
427
+ console.log(`${colors.cyan}${'─'.repeat(60)}${colors.reset}`);
428
+ }
429
+
430
+ /**
431
+ * Print check result
432
+ */
433
+ function printCheckResult(check) {
434
+ console.log(`\n${colors.bright}${check.section}${colors.reset}`);
435
+ Object.entries(check).forEach(([key, value]) => {
436
+ if (['section', 'passed', 'message', 'fixable', 'fixNote'].includes(key)) return;
437
+ console.log(` ${key}: ${value}`);
438
+ });
439
+ console.log(` ${check.message}`);
440
+ }
441
+
442
+ /**
443
+ * Print ASCII art header
444
+ */
445
+ function printHeader(tier = DEFAULT_TIER) {
446
+ const reqs = TIER_REQUIREMENTS[tier];
447
+ const header = `
448
+ ${colors.bright}${colors.cyan}
449
+ ███╗ ███╗██╗███████╗███████╗██╗ ██████╗ ███╗ ██╗
450
+ ████╗ ████║██║██╔════╝██╔════╝██║██╔═══██╗████╗ ██║
451
+ ██╔████╔██║██║███████╗███████╗██║██║ ██║██╔██╗ ██║
452
+ ██║╚██╔╝██║██║╚════██║╚════██║██║██║ ██║██║╚██╗██║
453
+ ██║ ╚═╝ ██║██║███████║███████║██║╚██████╔╝██║ ╚████║
454
+ ╚═╝ ╚═╝╚═╝╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═══╝
455
+
456
+ ${colors.reset}${colors.bright}Validator System Check${colors.reset}
457
+ ${colors.yellow}v1.0.0${colors.reset}
458
+ ${new Date().toISOString().split('T')[0]}
459
+ ${colors.magenta}${reqs.badge}${colors.reset}
460
+ `.trim();
461
+ console.log(header);
462
+ console.log(`\n${colors.cyan}${'━'.repeat(60)}${colors.reset}`);
463
+
464
+ // Print tier summary
465
+ console.log(`\n${colors.bright}Tier Requirements:${colors.reset}`);
466
+ console.log(` ${colors.cyan}Stake:${colors.reset} ${reqs.stake}`);
467
+ console.log(` ${colors.cyan}Consensus Weight:${colors.reset} ${reqs.consensusWeight}`);
468
+ console.log(` ${colors.cyan}Block Production:${colors.reset} ${reqs.canProduceBlocks ? '✅ Yes' : '❌ No'}`);
469
+ console.log(` ${colors.cyan}CPU:${colors.reset} ${reqs.cpu.minCores}+ cores`);
470
+ console.log(` ${colors.cyan}RAM:${colors.reset} ${reqs.ram.minTotalGB}GB+ total, ${reqs.ram.minAvailableGB}GB+ available`);
471
+ console.log(` ${colors.cyan}Disk:${colors.reset} ${reqs.disk.minTotalGB}GB+ total, ${reqs.disk.minFreeGB}GB+ free`);
472
+ console.log(` ${colors.cyan}Network:${colors.reset} ${reqs.network.minSpeedMbps}+ Mbps`);
473
+ console.log(` ${colors.cyan}Ports:${colors.reset} ${Object.values(reqs.ports).join(', ')}`);
474
+ console.log(`\n${colors.cyan}${'━'.repeat(60)}${colors.reset}`);
475
+ }
476
+
477
+ /**
478
+ * Print summary
479
+ */
480
+ function printSummary(results, tier = DEFAULT_TIER) {
481
+ const reqs = TIER_REQUIREMENTS[tier];
482
+ const allPassed = results.every(r => r.passed);
483
+
484
+ console.log(`\n${colors.cyan}${'━'.repeat(60)}${colors.reset}`);
485
+ console.log(`\n${colors.bright}SUMMARY:${colors.reset} ${colors.magenta}${reqs.badge}${colors.reset}`);
486
+
487
+ if (allPassed) {
488
+ console.log(`\n${colors.bright}${colors.green}✅ All checks passed!${colors.reset}`);
489
+ console.log(`\n${colors.green}Your system is ready to run an AeTHer ${tier.toUpperCase()} validator.${colors.reset}`);
490
+ console.log(`\nNext steps:`);
491
+ console.log(` ${colors.bright}aether-cli validator start --tier ${tier}${colors.reset} # Start validating`);
492
+ console.log(` ${colors.bright}aether-cli validator status${colors.reset} # Check status`);
493
+ console.log(` ${colors.bright}aether-cli help${colors.reset} # View all commands`);
494
+ } else {
495
+ const failed = results.filter(r => !r.passed);
496
+ const fixable = failed.filter(r => r.fixable);
497
+
498
+ console.log(`\n${colors.bright}${colors.red}❌ ${failed.length} check(s) failed${colors.reset}`);
499
+
500
+ if (fixable.length > 0) {
501
+ console.log(`\n${colors.yellow}⚠ ${fixable.length} issue(s) can be auto-fixed:${colors.reset}`);
502
+ fixable.forEach(f => {
503
+ console.log(` ${colors.yellow}• ${f.section}: ${f.fixNote}${colors.reset}`);
504
+ });
505
+ }
506
+
507
+ const notFixable = failed.filter(r => !r.fixable);
508
+ if (notFixable.length > 0) {
509
+ console.log(`\n${colors.red}The following issues require manual action:${colors.reset}`);
510
+ notFixable.forEach(f => {
511
+ console.log(` ${colors.red}• ${f.section}: ${f.fixNote}${colors.reset}`);
512
+ });
513
+ }
514
+ }
515
+
516
+ console.log(`\n${colors.cyan}${'━'.repeat(60)}${colors.reset}\n`);
517
+ }
518
+
519
+ /**
520
+ * Generate fix command for a failed check
521
+ */
522
+ function getFixCommand(check) {
523
+ const platform = os.platform();
524
+
525
+ switch (check.section) {
526
+ case 'Firewall':
527
+ if (platform === 'win32') {
528
+ const ports = check.blockedPorts || [];
529
+ if (ports.length === 0) return null;
530
+ const rules = ports.map(port =>
531
+ `New-NetFirewallRule -DisplayName "Aether Port ${port}" -Direction Inbound -LocalPort ${port} -Protocol TCP -Action Allow`
532
+ ).join('; ');
533
+ return `powershell -c "${rules}"`;
534
+ } else if (platform === 'linux') {
535
+ const ports = check.blockedPorts || [];
536
+ if (ports.length === 0) return null;
537
+ const rules = ports.map(port => `sudo ufw allow ${port}/tcp`).join(' && ');
538
+ return rules;
539
+ }
540
+ return null;
541
+
542
+ case 'Validator Binary': {
543
+ // Use same resolution logic as checkValidatorBinary
544
+ const npmPackageDir = path.join(__dirname, '..', '..');
545
+ let workspaceRoot = npmPackageDir;
546
+ let dir = npmPackageDir;
547
+ for (let i = 0; i < 15; i++) {
548
+ const candidate = path.join(dir, 'Jelly-legs-unsteady-workshop');
549
+ if (fs.existsSync(path.join(candidate, 'src')) || fs.existsSync(path.join(candidate, '.github'))) {
550
+ workspaceRoot = candidate;
551
+ break;
552
+ }
553
+ const parent = path.dirname(dir);
554
+ if (parent === dir) break;
555
+ dir = parent;
556
+ }
557
+ const repoPath = path.join(workspaceRoot, 'Jelly-legs-unsteady-workshop');
558
+ return `cd "${repoPath}" && cargo build --bin aether-validator --release`;
559
+
560
+ case 'Disk':
561
+ // Can't auto-fix disk space, but can suggest cleanup
562
+ if (platform === 'win32') {
563
+ return 'powershell -c "Get-AppxPackage -AllUsers | Where-Object {$_.InstallLocation -like \'*WindowsApps*\'} | Select-Object Name, PackageFullName"';
564
+ } else {
565
+ return 'sudo du -sh /* 2>/dev/null | sort -hr | head -20';
566
+ }
567
+
568
+ default:
569
+ return null;
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Ask user for confirmation
575
+ */
576
+ async function askConfirmation(question) {
577
+ const rl = readline.createInterface({
578
+ input: process.stdin,
579
+ output: process.stdout,
580
+ });
581
+
582
+ return new Promise((resolve) => {
583
+ rl.question(`${colors.yellow}${question}${colors.reset} [y/N] `, (answer) => {
584
+ rl.close();
585
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
586
+ });
587
+ });
588
+ }
589
+
590
+ /**
591
+ * Apply a fix for a failed check
592
+ */
593
+ async function applyFix(check) {
594
+ const command = getFixCommand(check);
595
+
596
+ if (!command) {
597
+ console.log(` ${colors.red}✗ No automated fix available for ${check.section}${colors.reset}`);
598
+ return false;
599
+ }
600
+
601
+ console.log(`\n ${colors.cyan}Proposed fix:${colors.reset}`);
602
+ console.log(` ${colors.bright}${command}${colors.reset}`);
603
+ console.log();
604
+
605
+ const confirmed = await askConfirmation(' Apply this fix?');
606
+
607
+ if (!confirmed) {
608
+ console.log(` ${colors.yellow}Skipped.${colors.reset}`);
609
+ return false;
610
+ }
611
+
612
+ console.log(` ${colors.cyan}Applying fix...${colors.reset}`);
613
+
614
+ try {
615
+ execSync(command, {
616
+ stdio: 'inherit',
617
+ shell: true,
618
+ cwd: process.cwd(),
619
+ });
620
+
621
+ console.log(` ${colors.green}✓ Fix applied successfully!${colors.reset}`);
622
+
623
+ // Re-run the check to verify
624
+ console.log(` ${colors.cyan}Verifying...${colors.reset}`);
625
+ let verifyCheck;
626
+ switch (check.section) {
627
+ case 'Firewall':
628
+ verifyCheck = checkFirewall();
629
+ break;
630
+ case 'Validator Binary':
631
+ verifyCheck = checkValidatorBinary();
632
+ break;
633
+ default:
634
+ verifyCheck = check;
635
+ }
636
+
637
+ if (verifyCheck.passed) {
638
+ console.log(` ${colors.green}✓ Verification passed!${colors.reset}`);
639
+ return true;
640
+ } else {
641
+ console.log(` ${colors.yellow}⚠ Fix applied but check still failing. May require manual intervention.${colors.reset}`);
642
+ return false;
643
+ }
644
+ } catch (err) {
645
+ console.log(` ${colors.red}✗ Fix failed: ${err.message}${colors.reset}`);
646
+ return false;
647
+ }
648
+ }
649
+
650
+ /**
651
+ * Interactive fix mode
652
+ */
653
+ async function interactiveFixMode(results) {
654
+ const failed = results.filter(r => !r.passed);
655
+ const fixable = failed.filter(r => r.fixable);
656
+
657
+ if (fixable.length === 0) {
658
+ console.log(`\n${colors.yellow}No auto-fixable issues found.${colors.reset}`);
659
+ return;
660
+ }
661
+
662
+ console.log(`\n${colors.bright}${colors.cyan}Auto-Fix Mode${colors.reset}`);
663
+ console.log(`${colors.cyan}${'─'.repeat(60)}${colors.reset}`);
664
+ console.log(`\n${colors.yellow}Found ${fixable.length} issue(s) that can be fixed automatically:${colors.reset}\n`);
665
+
666
+ for (const check of fixable) {
667
+ console.log(`${colors.bright}${check.section}${colors.reset}`);
668
+ console.log(` Issue: ${check.fixNote}`);
669
+ console.log();
670
+
671
+ const fixed = await applyFix(check);
672
+
673
+ if (fixed) {
674
+ check.passed = true;
675
+ check.message = `✅ FIXED (was: ${check.message})`;
676
+ }
677
+
678
+ console.log();
679
+ }
680
+
681
+ // Print updated summary
682
+ const stillFailed = results.filter(r => !r.passed);
683
+ if (stillFailed.length === 0) {
684
+ console.log(`\n${colors.bright}${colors.green}🎉 All issues resolved!${colors.reset}`);
685
+ console.log(`\n${colors.green}Your system is now ready to run an AeTHer validator.${colors.reset}`);
686
+ } else {
687
+ console.log(`\n${colors.yellow}⚠ ${stillFailed.length} issue(s) remain unresolved.${colors.reset}`);
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Main doctor command
693
+ */
694
+ async function doctorCommand(options = {}) {
695
+ const { autoFix = false, tier = DEFAULT_TIER } = options;
696
+
697
+ // Validate tier
698
+ if (!TIER_REQUIREMENTS[tier]) {
699
+ console.log(`${colors.red}Error: Invalid tier '${tier}'. Valid tiers: full, lite, observer${colors.reset}`);
700
+ return 1;
701
+ }
702
+
703
+ printHeader(tier);
704
+ console.log(`\n${colors.bright}Running system checks for ${tier.toUpperCase()} tier...${colors.reset}\n`);
705
+
706
+ const results = [
707
+ checkCPU(tier),
708
+ checkMemory(tier),
709
+ checkDisk(tier),
710
+ checkNetwork(tier),
711
+ checkFirewall(tier),
712
+ checkValidatorBinary(),
713
+ ];
714
+
715
+ results.forEach(printCheckResult);
716
+ printSummary(results, tier);
717
+
718
+ // If auto-fix mode or user requests it
719
+ const failed = results.filter(r => !r.passed);
720
+ const fixable = failed.filter(r => r.fixable);
721
+
722
+ if (fixable.length > 0) {
723
+ if (autoFix) {
724
+ console.log(`\n${colors.cyan}Auto-fix mode enabled. Attempting fixes...${colors.reset}\n`);
725
+ await interactiveFixMode(results);
726
+ } else {
727
+ console.log(`\n${colors.cyan}Tip: Run ${colors.bright}aether-cli doctor --fix${colors.reset}${colors.cyan} to auto-fix issues.${colors.reset}\n`);
728
+ }
729
+ }
730
+
731
+ // Return exit code based on results
732
+ const allPassed = results.every(r => r.passed);
733
+ return allPassed ? 0 : 1;
734
+ }
735
+
736
+ // Export for use as module
737
+ module.exports = { doctorCommand, checkCPU, checkMemory, checkDisk, checkNetwork, checkFirewall, checkValidatorBinary };
738
+
739
+ // Run if called directly
740
+ if (require.main === module) {
741
+ const args = process.argv.slice(2);
742
+ const autoFix = args.includes('--fix') || args.includes('-f');
743
+
744
+ // Parse --tier flag
745
+ let tier = DEFAULT_TIER;
746
+ const tierIndex = args.findIndex(arg => arg === '--tier');
747
+ if (tierIndex !== -1 && args[tierIndex + 1]) {
748
+ tier = args[tierIndex + 1].toLowerCase();
749
+ }
750
+
751
+ doctorCommand({ autoFix, tier }).then(exitCode => {
752
+ process.exit(exitCode);
753
+ });
754
+ }