pyre-cli 9.5.2 → 10.0.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 (86) hide show
  1. package/README.md +73 -16
  2. package/dist/alertChannels-TPSQIJ7S.js +8 -0
  3. package/dist/{blender-7UT5J7IP.js → blender-FGH3ASEQ.js} +2 -2
  4. package/dist/brewHealth-P2GW5Y5C.js +176 -0
  5. package/dist/buildCmd-CDPZ2ZFA.js +38 -0
  6. package/dist/{checkCmd-T7O4KXXA.js → checkCmd-RKW6QGDR.js} +9 -11
  7. package/dist/{chunk-CXL4GYGQ.js → chunk-53O2RGSL.js} +44 -17
  8. package/dist/{chunk-3TE7MJLS.js → chunk-6UHZP4QP.js} +36 -10
  9. package/dist/chunk-GHRDW646.js +2506 -0
  10. package/dist/{prometheus-LFYHF6VO.js → chunk-HT4VQ4OH.js} +1 -0
  11. package/dist/chunk-M73HCQD2.js +95 -0
  12. package/dist/chunk-MZO5QKUR.js +32 -0
  13. package/dist/{chunk-25I533OF.js → chunk-VIACN2RU.js} +1 -1
  14. package/dist/chunk-Z2YV5HKV.js +87 -0
  15. package/dist/chunk-ZLVIVFSO.js +110 -0
  16. package/dist/{collectors-JB4OGUEF.js → collectors-H43PN4PS.js} +6 -3
  17. package/dist/{completions-VQMHQCXE.js → completions-JKYEJNGR.js} +34 -1
  18. package/dist/{config-KS5ZZ3VN.js → config-GYGJXU2Q.js} +9 -1
  19. package/dist/doctor-GYHFTNZQ.js +387 -0
  20. package/dist/explainCmd-MF6PIMLW.js +222 -0
  21. package/dist/extensions-XJGHG7X3.js +134 -0
  22. package/dist/index.js +460 -88
  23. package/dist/ioreport.py +72 -0
  24. package/dist/netUsageCmd-WKBTW2YG.js +90 -0
  25. package/dist/{pipeCmd-TYLJHQ5B.js → pipeCmd-7REDBLBU.js} +4 -3
  26. package/dist/profileProcCmd-N4H6MCBT.js +107 -0
  27. package/dist/prometheus-5BL5PVLC.js +6 -0
  28. package/dist/prometheusCmd-PL64BLN5.js +93 -0
  29. package/dist/replayCmd-Z3PYXY7V.js +164 -0
  30. package/dist/run-HOGQDDTA.js +8 -0
  31. package/dist/serveCmd-2H5VLF3P.js +153 -0
  32. package/dist/service-Q74BBILO.js +181 -0
  33. package/dist/smartCmd-ODWKSFNG.js +166 -0
  34. package/dist/syncCmd-A2W3QB3S.js +126 -0
  35. package/dist/topoCmd-DU2HHZNH.js +148 -0
  36. package/dist/watchdogCmd-UF53JPZB.js +161 -0
  37. package/dist/{wizard-Z3RGA3VB.js → wizard-TD7BGX6O.js} +1 -1
  38. package/install.ps1 +29 -0
  39. package/install.sh +10 -9
  40. package/package.json +7 -3
  41. package/src/ai/models.ts +138 -0
  42. package/src/alertChannels.ts +119 -0
  43. package/src/brewHealth.ts +157 -40
  44. package/src/buildCmd.ts +48 -0
  45. package/src/checkCmd.ts +5 -5
  46. package/src/completions.ts +35 -0
  47. package/src/doctor.ts +235 -7
  48. package/src/easy_features.test.ts +40 -0
  49. package/src/explainCmd.ts +261 -0
  50. package/src/extensions.ts +71 -7
  51. package/src/formatters/render.ts +34 -10
  52. package/src/formatters/types.ts +2 -0
  53. package/src/index.ts +251 -36
  54. package/src/live/input.ts +60 -0
  55. package/src/live/render.ts +67 -0
  56. package/src/live/state.ts +5 -0
  57. package/src/live/types.ts +1 -1
  58. package/src/monitors/batteryPredictor.ts +143 -0
  59. package/src/monitors/buildTracker.ts +121 -0
  60. package/src/monitors/collectors.ts +342 -178
  61. package/src/monitors/ioreport.py +72 -0
  62. package/src/monitors/platform/linux.ts +756 -0
  63. package/src/monitors/platform/windows.ts +519 -0
  64. package/src/monitors/run.ts +27 -3
  65. package/src/monitors/smc.ts +21 -1
  66. package/src/monitors/types.ts +15 -0
  67. package/src/netUsageCmd.ts +121 -0
  68. package/src/platform.test.ts +99 -0
  69. package/src/plugins/index.ts +143 -0
  70. package/src/profileProcCmd.ts +133 -0
  71. package/src/prometheusCmd.ts +107 -0
  72. package/src/replayCmd.ts +186 -0
  73. package/src/serveCmd.ts +180 -0
  74. package/src/service.ts +125 -7
  75. package/src/smartCmd.ts +205 -0
  76. package/src/state/config.ts +58 -17
  77. package/src/syncCmd.ts +166 -0
  78. package/src/topoCmd.ts +176 -0
  79. package/src/watchdogCmd.ts +198 -0
  80. package/src/xbar.ts +22 -11
  81. package/dist/brewHealth-F2BXF5M6.js +0 -74
  82. package/dist/chunk-FH5WDJBF.js +0 -15
  83. package/dist/chunk-N4WJVF5M.js +0 -1184
  84. package/dist/doctor-UV7TWIHX.js +0 -184
  85. package/dist/extensions-DVA5YLGH.js +0 -74
  86. package/dist/service-WTS3NBQU.js +0 -73
@@ -0,0 +1,205 @@
1
+ /**
2
+ * pyre smart — S.M.A.R.T. disk health inspector.
3
+ *
4
+ * Uses smartctl (from smartmontools, via brew install smartmontools)
5
+ * for detailed SMART data. Falls back to diskutil info when
6
+ * smartctl is not installed.
7
+ */
8
+ import { execFile } from 'node:child_process';
9
+ import { promisify } from 'node:util';
10
+ import chalk from 'chalk';
11
+
12
+ const execFileAsync = promisify(execFile);
13
+
14
+ interface DiskInfo {
15
+ device: string;
16
+ model: string;
17
+ serialNumber: string;
18
+ health: 'PASSED' | 'FAILED' | 'Unknown';
19
+ tempC?: number;
20
+ powerOnHours?: number;
21
+ reallocatedSectors?: number;
22
+ percentUsed?: number;
23
+ bytesRead?: number;
24
+ bytesWritten?: number;
25
+ protocol?: string;
26
+ capacity?: string;
27
+ }
28
+
29
+ async function runCmd(cmd: string, args: string[], timeoutMs = 5000): Promise<string> {
30
+ try {
31
+ const { stdout } = await execFileAsync(cmd, args, { timeout: timeoutMs });
32
+ return stdout.trim();
33
+ } catch {
34
+ return '';
35
+ }
36
+ }
37
+
38
+ async function getPhysicalDisks(): Promise<string[]> {
39
+ // 1. First try smartctl --scan --json on all platforms
40
+ const scanRaw = await runCmd('smartctl', ['--scan', '--json']);
41
+ if (scanRaw) {
42
+ try {
43
+ const parsed = JSON.parse(scanRaw);
44
+ if (Array.isArray(parsed.devices) && parsed.devices.length > 0) {
45
+ return parsed.devices.map((d: any) => d.name).filter(Boolean);
46
+ }
47
+ } catch {
48
+ // ignore
49
+ }
50
+ }
51
+
52
+ // 2. Linux fallback: lsblk
53
+ if (process.platform === 'linux') {
54
+ const lsblk = await runCmd('lsblk', ['-dpno', 'NAME']);
55
+ if (lsblk) {
56
+ const disks = lsblk.split('\n').map(s => s.trim()).filter(d => /^\/dev\/(sd[a-z]|nvme\d+n\d+|vd[a-z])/.test(d));
57
+ if (disks.length > 0) return disks;
58
+ }
59
+ return ['/dev/sda'];
60
+ }
61
+
62
+ // 3. Windows fallback
63
+ if (process.platform === 'win32') {
64
+ return ['/dev/sda', '/dev/csmi0,0'];
65
+ }
66
+
67
+ // 4. macOS fallback: diskutil
68
+ const raw = await runCmd('diskutil', ['list', '-plist']);
69
+ if (!raw) return ['/dev/disk0'];
70
+ const matches = raw.match(/\/dev\/disk\d+(?!s)/g) ?? [];
71
+ return [...new Set(matches)].filter(d => /^\/dev\/disk\d+$/.test(d));
72
+ }
73
+
74
+ async function collectSmartctlDisk(device: string): Promise<DiskInfo | null> {
75
+ const raw = await runCmd('smartctl', ['-a', '--json', device], 6000);
76
+ if (!raw) return null;
77
+ try {
78
+ const j = JSON.parse(raw);
79
+ const model = j.model_name ?? j.device?.name ?? 'Unknown';
80
+ const serial = j.serial_number ?? '';
81
+ const protocol = j.device?.protocol ?? '';
82
+ const passed = j.smart_status?.passed;
83
+ const health: DiskInfo['health'] = passed === true ? 'PASSED' : passed === false ? 'FAILED' : 'Unknown';
84
+
85
+ let tempC: number | undefined;
86
+ if (j.temperature?.current !== undefined) tempC = j.temperature.current;
87
+
88
+ const poh = j.power_on_time?.hours;
89
+
90
+ let reallocated: number | undefined;
91
+ if (Array.isArray(j.ata_smart_attributes?.table)) {
92
+ const attr = j.ata_smart_attributes.table.find((a: any) => a.id === 5);
93
+ if (attr) reallocated = attr.raw?.value ?? 0;
94
+ }
95
+
96
+ let percentUsed: number | undefined;
97
+ if (j.nvme_smart_health_information_log?.percentage_used !== undefined) {
98
+ percentUsed = j.nvme_smart_health_information_log.percentage_used;
99
+ }
100
+
101
+ let bytesRead: number | undefined;
102
+ let bytesWritten: number | undefined;
103
+ if (j.nvme_smart_health_information_log) {
104
+ const unitsRead = j.nvme_smart_health_information_log.data_units_read;
105
+ const unitsWritten = j.nvme_smart_health_information_log.data_units_written;
106
+ if (unitsRead !== undefined) bytesRead = unitsRead * 512000;
107
+ if (unitsWritten !== undefined) bytesWritten = unitsWritten * 512000;
108
+ }
109
+
110
+ const userCap = j.user_capacity?.bytes;
111
+ const capacity = userCap !== undefined
112
+ ? userCap >= 1e12 ? `${(userCap / 1e12).toFixed(1)} TB`
113
+ : userCap >= 1e9 ? `${(userCap / 1e9).toFixed(0)} GB`
114
+ : `${(userCap / 1e6).toFixed(0)} MB`
115
+ : undefined;
116
+
117
+ return { device, model, serialNumber: serial, health, tempC, powerOnHours: poh,
118
+ reallocatedSectors: reallocated, percentUsed, bytesRead, bytesWritten, protocol, capacity };
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ async function collectDiskutilFallback(device: string): Promise<DiskInfo | null> {
125
+ const raw = await runCmd('diskutil', ['info', device]);
126
+ if (!raw) return null;
127
+ const nameMatch = raw.match(/(?:Device \/ Media Name|Volume Name|Media Name):\s*(.+)/i);
128
+ const sizeMatch = raw.match(/Disk Size:\s*(.+?\([^)]+\))/i);
129
+ return {
130
+ device,
131
+ model: nameMatch?.[1]?.trim() || 'Unknown',
132
+ serialNumber: '',
133
+ health: 'Unknown',
134
+ capacity: sizeMatch?.[1]?.trim(),
135
+ };
136
+ }
137
+
138
+ function formatBytes(bytes: number): string {
139
+ if (bytes >= 1e15) return `${(bytes / 1e15).toFixed(1)} PB`;
140
+ if (bytes >= 1e12) return `${(bytes / 1e12).toFixed(1)} TB`;
141
+ if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
142
+ if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(0)} MB`;
143
+ return `${bytes} B`;
144
+ }
145
+
146
+ function healthColor(h: DiskInfo['health']): string {
147
+ if (h === 'PASSED') return chalk.green(h);
148
+ if (h === 'FAILED') return chalk.red.bold(h);
149
+ return chalk.dim(h);
150
+ }
151
+
152
+ export async function runSmartCommand(): Promise<void> {
153
+ console.log(chalk.bold('\n 🔥 pyre — S.M.A.R.T. Disk Health\n'));
154
+
155
+ const smartctlVersion = await runCmd('smartctl', ['--version'], 2000);
156
+ const hasSmartctl = smartctlVersion.includes('smartmontools');
157
+
158
+ if (!hasSmartctl) {
159
+ console.log(chalk.yellow(' ⚠ smartctl not found. Showing basic disk info.'));
160
+ console.log(chalk.dim(' For full SMART data: ') + chalk.cyan('brew install smartmontools') + '\n');
161
+ }
162
+
163
+ const devices = await getPhysicalDisks();
164
+ if (devices.length === 0) {
165
+ console.log(chalk.yellow(' No physical disks found.\n'));
166
+ return;
167
+ }
168
+
169
+ for (const device of devices) {
170
+ let info: DiskInfo | null = null;
171
+ if (hasSmartctl) info = await collectSmartctlDisk(device);
172
+ if (!info) info = await collectDiskutilFallback(device);
173
+ if (!info) continue;
174
+
175
+ console.log(chalk.bold(` ${info.device}`));
176
+ console.log(` Model: ${info.model}`);
177
+ if (info.serialNumber) console.log(` Serial: ${chalk.dim(info.serialNumber)}`);
178
+ if (info.protocol) console.log(` Protocol: ${info.protocol}`);
179
+ if (info.capacity) console.log(` Capacity: ${info.capacity}`);
180
+ console.log(` Health: ${healthColor(info.health)}`);
181
+ if (info.tempC !== undefined) {
182
+ const tc = info.tempC >= 60 ? chalk.red : info.tempC >= 45 ? chalk.yellow : chalk.green;
183
+ console.log(` Temperature: ${tc(`${info.tempC}°C`)}`);
184
+ }
185
+ if (info.powerOnHours !== undefined) {
186
+ const days = Math.floor(info.powerOnHours / 24);
187
+ console.log(` Power-On Hours: ${info.powerOnHours.toLocaleString()} h (${days} days)`);
188
+ }
189
+ if (info.reallocatedSectors !== undefined) {
190
+ const sc = info.reallocatedSectors > 0 ? chalk.yellow : chalk.green;
191
+ console.log(` Reallocated Secs: ${sc(info.reallocatedSectors.toString())}`);
192
+ }
193
+ if (info.percentUsed !== undefined) {
194
+ const wc = info.percentUsed >= 90 ? chalk.red : info.percentUsed >= 50 ? chalk.yellow : chalk.green;
195
+ console.log(` Wear Level: ${wc(`${info.percentUsed}% used`)}`);
196
+ }
197
+ if (info.bytesRead !== undefined) console.log(` Data Read: ${formatBytes(info.bytesRead)}`);
198
+ if (info.bytesWritten !== undefined) console.log(` Data Written: ${formatBytes(info.bytesWritten)}`);
199
+ console.log();
200
+ }
201
+
202
+ if (!hasSmartctl) {
203
+ console.log(chalk.dim(' Install smartmontools for full SMART attributes.\n'));
204
+ }
205
+ }
@@ -13,8 +13,6 @@ import fs from 'node:fs';
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
15
15
 
16
- const CONFIG_DIR = path.join(os.homedir(), '.config', 'pyre');
17
- const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
18
16
 
19
17
  export interface PyreConfig {
20
18
  theme?: string;
@@ -41,6 +39,22 @@ export interface PyreConfig {
41
39
  watchdogMem?: number;
42
40
  webhookUrl?: string;
43
41
  alertCmd?: string;
42
+ /** Slack incoming webhook URL for alert notifications */
43
+ slackAlertUrl?: string;
44
+ /** Discord webhook URL for alert notifications */
45
+ discordAlertUrl?: string;
46
+ /** Pushover application token for alert notifications */
47
+ pushoverToken?: string;
48
+ /** Pushover user key for alert notifications */
49
+ pushoverUser?: string;
50
+ /** ntfy.sh topic URL (e.g. https://ntfy.sh/my-pyre-alerts) for alert notifications */
51
+ ntfyUrl?: string;
52
+ /** AI backend: 'builtin' (heuristic / rule-based), 'ollama', or 'openai' */
53
+ aiBackend?: string;
54
+ /** Selected AI model identifier */
55
+ aiModel?: string;
56
+ /** Optional API key for external cloud providers */
57
+ aiApiKey?: string;
44
58
  dockerModeConfirmed?: boolean;
45
59
  visiblePanels?: {
46
60
  cpu?: boolean;
@@ -86,6 +100,14 @@ export const DEFAULT_CONFIG: Required<PyreConfig> = {
86
100
  splashAnimation: 'classic',
87
101
  webhookUrl: '',
88
102
  alertCmd: '',
103
+ slackAlertUrl: '',
104
+ discordAlertUrl: '',
105
+ pushoverToken: '',
106
+ pushoverUser: '',
107
+ ntfyUrl: '',
108
+ aiBackend: 'builtin',
109
+ aiModel: 'expert-rules-v1',
110
+ aiApiKey: '',
89
111
  dockerModeConfirmed: false,
90
112
  visiblePanels: {
91
113
  cpu: true,
@@ -106,10 +128,27 @@ export const DEFAULT_CONFIG: Required<PyreConfig> = {
106
128
  panelLayout: ['mem', 'disk', 'net'],
107
129
  };
108
130
 
131
+ export function getConfigDir(): string {
132
+ return process.env.PYRE_CONFIG_DIR || path.join(os.homedir(), '.config', 'pyre');
133
+ }
134
+
135
+ export function getConfigFile(): string {
136
+ return path.join(getConfigDir(), 'config.json');
137
+ }
138
+
139
+ export function getProfilesDir(): string {
140
+ return path.join(getConfigDir(), 'profiles');
141
+ }
142
+
143
+ export const CONFIG_DIR = getConfigDir();
144
+ export const CONFIG_FILE = getConfigFile();
145
+ export const PROFILES_DIR = getProfilesDir();
146
+
109
147
  export function readConfig(): Required<PyreConfig> {
110
148
  try {
111
- if (!fs.existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG };
112
- const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
149
+ const file = getConfigFile();
150
+ if (!fs.existsSync(file)) return { ...DEFAULT_CONFIG };
151
+ const raw = fs.readFileSync(file, 'utf-8');
113
152
  const parsed = JSON.parse(raw) as Partial<PyreConfig>;
114
153
  return deepMerge(DEFAULT_CONFIG, parsed);
115
154
  } catch {
@@ -119,32 +158,34 @@ export function readConfig(): Required<PyreConfig> {
119
158
 
120
159
  export function writeConfig(config: Partial<PyreConfig>): void {
121
160
  try {
122
- if (!fs.existsSync(CONFIG_DIR)) {
123
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
161
+ const dir = getConfigDir();
162
+ const file = getConfigFile();
163
+ if (!fs.existsSync(dir)) {
164
+ fs.mkdirSync(dir, { recursive: true });
124
165
  }
125
166
  const merged = deepMerge(readConfig(), config);
126
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n');
167
+ fs.writeFileSync(file, JSON.stringify(merged, null, 2) + '\n');
127
168
  } catch {
128
169
  // ignore write errors
129
170
  }
130
171
  }
131
172
 
132
- const PROFILES_DIR = path.join(CONFIG_DIR, 'profiles');
133
-
134
173
  export function saveProfile(name: string): void {
135
174
  if (!name || name.includes('/') || name.includes('\\')) {
136
175
  throw new Error('Invalid profile name.');
137
176
  }
138
- if (!fs.existsSync(PROFILES_DIR)) {
139
- fs.mkdirSync(PROFILES_DIR, { recursive: true });
177
+ const profilesDir = getProfilesDir();
178
+ if (!fs.existsSync(profilesDir)) {
179
+ fs.mkdirSync(profilesDir, { recursive: true });
140
180
  }
141
181
  const currentConfig = readConfig();
142
- const profileFile = path.join(PROFILES_DIR, `${name}.json`);
182
+ const profileFile = path.join(profilesDir, `${name}.json`);
143
183
  fs.writeFileSync(profileFile, JSON.stringify(currentConfig, null, 2) + '\n');
144
184
  }
145
185
 
146
186
  export function loadProfile(name: string): void {
147
- const profileFile = path.join(PROFILES_DIR, `${name}.json`);
187
+ const profilesDir = getProfilesDir();
188
+ const profileFile = path.join(profilesDir, `${name}.json`);
148
189
  if (!fs.existsSync(profileFile)) {
149
190
  throw new Error(`Profile '${name}' does not exist.`);
150
191
  }
@@ -155,8 +196,9 @@ export function loadProfile(name: string): void {
155
196
  }
156
197
 
157
198
  export function listProfiles(): string[] {
158
- if (!fs.existsSync(PROFILES_DIR)) return [];
159
- const files = fs.readdirSync(PROFILES_DIR);
199
+ const profilesDir = getProfilesDir();
200
+ if (!fs.existsSync(profilesDir)) return [];
201
+ const files = fs.readdirSync(profilesDir);
160
202
  return files.filter(f => f.endsWith('.json')).map(f => f.replace(/\.json$/, ''));
161
203
  }
162
204
 
@@ -175,7 +217,6 @@ function deepMerge(target: any, source: any): any {
175
217
  }
176
218
 
177
219
  export function getConfigPath(): string {
178
- return CONFIG_FILE;
220
+ return getConfigFile();
179
221
  }
180
222
 
181
- export { CONFIG_FILE, PROFILES_DIR };
package/src/syncCmd.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * pyre sync — Cloud Configuration & Anomaly History Sync.
3
+ *
4
+ * Synchronizes configuration profiles (~/.config/pyre/profiles/),
5
+ * alert rules, thresholds, and anomaly history across multiple Macs
6
+ * via iCloud Drive or any user-defined sync folder.
7
+ *
8
+ * Commands:
9
+ * pyre sync status
10
+ * pyre sync push
11
+ * pyre sync pull
12
+ */
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import crypto from 'node:crypto';
17
+ import chalk from 'chalk';
18
+ import { getConfigDir, getConfigFile, getProfilesDir, readConfig, writeConfig } from './state/config.js';
19
+
20
+ export interface SyncOptions {
21
+ syncDir?: string;
22
+ force?: boolean;
23
+ }
24
+
25
+ interface SyncManifest {
26
+ version: number;
27
+ lastUpdated: string;
28
+ sourceHost: string;
29
+ checksum: string;
30
+ profilesCount: number;
31
+ }
32
+
33
+ function getDefaultSyncDir(): string {
34
+ if (process.env.PYRE_SYNC_DIR) return process.env.PYRE_SYNC_DIR;
35
+
36
+ // macOS native iCloud Drive path
37
+ const icloudPath = path.join(os.homedir(), 'Library', 'Mobile Documents', 'com~apple~CloudDocs', 'PyreSync');
38
+ if (fs.existsSync(path.dirname(icloudPath))) {
39
+ return icloudPath;
40
+ }
41
+
42
+ // Fallback to cross-platform standard shared folder
43
+ return path.join(os.homedir(), '.pyre-sync');
44
+ }
45
+
46
+ function computeDirChecksum(dirPath: string): string {
47
+ const hash = crypto.createHash('sha256');
48
+ if (!fs.existsSync(dirPath)) return '';
49
+
50
+ const files = fs.readdirSync(dirPath).sort();
51
+ for (const file of files) {
52
+ const full = path.join(dirPath, file);
53
+ if (fs.statSync(full).isFile()) {
54
+ hash.update(file);
55
+ hash.update(fs.readFileSync(full));
56
+ }
57
+ }
58
+ return hash.digest('hex').slice(0, 16);
59
+ }
60
+
61
+ export async function runSyncCommand(subcommand: string = 'status', opts: SyncOptions = {}): Promise<void> {
62
+ const syncDir = opts.syncDir || getDefaultSyncDir();
63
+ const manifestPath = path.join(syncDir, 'sync-manifest.json');
64
+ const remoteProfilesDir = path.join(syncDir, 'profiles');
65
+ const remoteConfigFile = path.join(syncDir, 'config.json');
66
+
67
+ const localProfilesDir = getProfilesDir();
68
+ const localConfigFile = getConfigFile();
69
+
70
+ console.log(chalk.bold('\n 🔥 pyre sync — Configuration & Profile Cloud Sync\n'));
71
+ console.log(` Sync Target Directory: ${chalk.cyan(syncDir)}`);
72
+
73
+ if (subcommand === 'status' || !subcommand) {
74
+ const hasSyncDir = fs.existsSync(syncDir);
75
+ const hasManifest = fs.existsSync(manifestPath);
76
+
77
+ console.log(` Local Profiles: ${chalk.bold(String(fs.existsSync(localProfilesDir) ? fs.readdirSync(localProfilesDir).filter(f => f.endsWith('.json')).length : 0))}`);
78
+ console.log(` Sync Target Available: ${hasSyncDir ? chalk.green('YES') : chalk.yellow('NO (will be created on push)')}`);
79
+
80
+ if (hasManifest) {
81
+ try {
82
+ const manifest: SyncManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
83
+ console.log(` Remote Last Updated: ${chalk.green(manifest.lastUpdated)} (from ${chalk.bold(manifest.sourceHost)})`);
84
+ console.log(` Remote Profiles Count: ${chalk.bold(String(manifest.profilesCount))}`);
85
+ console.log(` Remote Checksum: ${chalk.dim(manifest.checksum)}`);
86
+ } catch {
87
+ console.log(chalk.yellow(' Remote manifest corrupted.'));
88
+ }
89
+ } else {
90
+ console.log(chalk.dim(' No remote sync bundle detected yet. Run "pyre sync push" to initialize.'));
91
+ }
92
+ console.log();
93
+ return;
94
+ }
95
+
96
+ if (subcommand === 'push') {
97
+ if (!fs.existsSync(syncDir)) {
98
+ fs.mkdirSync(syncDir, { recursive: true });
99
+ }
100
+ if (!fs.existsSync(remoteProfilesDir)) {
101
+ fs.mkdirSync(remoteProfilesDir, { recursive: true });
102
+ }
103
+
104
+ // 1. Sync config.json
105
+ if (fs.existsSync(localConfigFile)) {
106
+ fs.copyFileSync(localConfigFile, remoteConfigFile);
107
+ }
108
+
109
+ // 2. Sync profiles/
110
+ let profileCount = 0;
111
+ if (fs.existsSync(localProfilesDir)) {
112
+ const files = fs.readdirSync(localProfilesDir).filter(f => f.endsWith('.json'));
113
+ profileCount = files.length;
114
+ for (const f of files) {
115
+ fs.copyFileSync(path.join(localProfilesDir, f), path.join(remoteProfilesDir, f));
116
+ }
117
+ }
118
+
119
+ // 3. Write manifest
120
+ const checksum = computeDirChecksum(remoteProfilesDir);
121
+ const manifest: SyncManifest = {
122
+ version: 1,
123
+ lastUpdated: new Date().toISOString(),
124
+ sourceHost: os.hostname(),
125
+ checksum,
126
+ profilesCount: profileCount,
127
+ };
128
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
129
+
130
+ console.log(chalk.green(` ✔ Successfully pushed config and ${profileCount} profile(s) to sync target.`));
131
+ console.log(` Checksum: ${chalk.dim(checksum)} | Timestamp: ${chalk.dim(manifest.lastUpdated)}\n`);
132
+ return;
133
+ }
134
+
135
+ if (subcommand === 'pull') {
136
+ if (!fs.existsSync(manifestPath) || !fs.existsSync(remoteConfigFile)) {
137
+ console.error(chalk.red(` ✖ Error: No sync manifest found at ${syncDir}. Run "pyre sync push" first from another machine.\n`));
138
+ process.exit(1);
139
+ }
140
+
141
+ // 1. Pull config.json
142
+ if (fs.existsSync(remoteConfigFile)) {
143
+ const remoteConfig = JSON.parse(fs.readFileSync(remoteConfigFile, 'utf-8'));
144
+ writeConfig(remoteConfig);
145
+ }
146
+
147
+ // 2. Pull profiles
148
+ let pulledCount = 0;
149
+ if (fs.existsSync(remoteProfilesDir)) {
150
+ if (!fs.existsSync(localProfilesDir)) {
151
+ fs.mkdirSync(localProfilesDir, { recursive: true });
152
+ }
153
+ const files = fs.readdirSync(remoteProfilesDir).filter(f => f.endsWith('.json'));
154
+ pulledCount = files.length;
155
+ for (const f of files) {
156
+ fs.copyFileSync(path.join(remoteProfilesDir, f), path.join(localProfilesDir, f));
157
+ }
158
+ }
159
+
160
+ console.log(chalk.green(` ✔ Successfully pulled latest configuration and ${pulledCount} profile(s).`));
161
+ console.log(chalk.dim(` Applied to ${localConfigFile}\n`));
162
+ return;
163
+ }
164
+
165
+ console.log(chalk.yellow(` Unknown subcommand: "${subcommand}". Available: status, push, pull\n`));
166
+ }
package/src/topoCmd.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * pyre topo — Network Topology Mapper.
3
+ *
4
+ * Visualizes the local network topology, including default gateways,
5
+ * active routing paths, LAN neighbors (via ARP cache), and active remote sockets.
6
+ * Also scans LAN neighbors for running pyre instances (P2P port 9876, REST port 8080/3000).
7
+ */
8
+ import net from 'node:net';
9
+ import os from 'node:os';
10
+ import chalk from 'chalk';
11
+ import { run } from './monitors/run.js';
12
+ import { collectNetwork } from './monitors/collectors.js';
13
+
14
+ export interface TopoOptions {
15
+ scan?: boolean;
16
+ json?: boolean;
17
+ }
18
+
19
+ interface LanPeer {
20
+ ip: string;
21
+ mac: string;
22
+ interface: string;
23
+ isGateway: boolean;
24
+ pyreP2P?: boolean;
25
+ pyreRest?: boolean;
26
+ hostname?: string;
27
+ }
28
+
29
+ async function probePort(ip: string, port: number, timeoutMs = 400): Promise<boolean> {
30
+ return new Promise((resolve) => {
31
+ const socket = new net.Socket();
32
+ socket.setTimeout(timeoutMs);
33
+ socket.once('connect', () => {
34
+ socket.destroy();
35
+ resolve(true);
36
+ });
37
+ socket.once('timeout', () => {
38
+ socket.destroy();
39
+ resolve(false);
40
+ });
41
+ socket.once('error', () => {
42
+ socket.destroy();
43
+ resolve(false);
44
+ });
45
+ socket.connect(port, ip);
46
+ });
47
+ }
48
+
49
+ async function getDefaultGateway(): Promise<string> {
50
+ try {
51
+ const raw = await run("route -n get default 2>/dev/null | grep gateway | awk '{print $2}'", '');
52
+ if (raw.trim()) return raw.trim();
53
+ } catch {}
54
+ try {
55
+ const raw = await run("netstat -nr | grep default | awk '{print $2}' | head -n1", '');
56
+ if (raw.trim()) return raw.trim();
57
+ } catch {}
58
+ return '192.168.1.1';
59
+ }
60
+
61
+ async function getArpNeighbors(gatewayIp: string): Promise<LanPeer[]> {
62
+ const peers: LanPeer[] = [];
63
+ try {
64
+ const raw = await run('arp -a -n 2>/dev/null', '');
65
+ const lines = raw.split('\n');
66
+ for (const line of lines) {
67
+ // macOS format: "? (192.168.1.1) at 0:11:22:33:44:55 on en0 ifscope [ethernet]"
68
+ const match = line.match(/\(([\d.]+)\)\s+at\s+([0-9a-fA-F:]+)\s+on\s+(\S+)/);
69
+ if (match) {
70
+ const ip = match[1];
71
+ const mac = match[2];
72
+ const iface = match[3];
73
+ if (ip === '255.255.255.255' || ip.startsWith('224.') || mac.includes('incomplete')) continue;
74
+ peers.push({
75
+ ip,
76
+ mac,
77
+ interface: iface,
78
+ isGateway: ip === gatewayIp,
79
+ });
80
+ }
81
+ }
82
+ } catch {}
83
+ return peers;
84
+ }
85
+
86
+ export async function runTopoCommand(opts: TopoOptions = {}): Promise<void> {
87
+ const localNet = await collectNetwork();
88
+ const gatewayIp = await getDefaultGateway();
89
+ const lanPeers = await getArpNeighbors(gatewayIp);
90
+ const hostname = os.hostname();
91
+
92
+ // If scan is requested or small peer count, probe common pyre ports
93
+ if (opts.scan || lanPeers.length <= 15) {
94
+ await Promise.all(
95
+ lanPeers.map(async (peer) => {
96
+ const [hasP2P, hasRest] = await Promise.all([
97
+ probePort(peer.ip, 9876, 250),
98
+ probePort(peer.ip, 8080, 250),
99
+ ]);
100
+ peer.pyreP2P = hasP2P;
101
+ peer.pyreRest = hasRest;
102
+ })
103
+ );
104
+ }
105
+
106
+ const establishedConns = (localNet.topRemoteHosts || []).slice(0, 6);
107
+
108
+ if (opts.json) {
109
+ console.log(
110
+ JSON.stringify(
111
+ {
112
+ localNode: {
113
+ hostname,
114
+ ip: localNet.ip,
115
+ interface: localNet.interface,
116
+ },
117
+ gateway: gatewayIp,
118
+ lanPeers,
119
+ activeRemoteConnections: establishedConns,
120
+ },
121
+ null,
122
+ 2
123
+ )
124
+ );
125
+ return;
126
+ }
127
+
128
+ console.log(chalk.bold('\n 🔥 pyre topo — Network Topology Map\n'));
129
+
130
+ // Local Host Header Box
131
+ console.log(` ${chalk.bgCyan.black.bold(' LOCAL HOST ')} ${chalk.bold(hostname)}`);
132
+ console.log(` ├── IP Address: ${chalk.green.bold(localNet.ip || '127.0.0.1')} (${localNet.interface})`);
133
+ console.log(` ├── Active RX / TX: ↓ ${chalk.cyan(localNet.rxBytes ? `${(localNet.rxBytes / 1e6).toFixed(1)} MB` : '0 MB')} ↑ ${chalk.blue(localNet.txBytes ? `${(localNet.txBytes / 1e6).toFixed(1)} MB` : '0 MB')}`);
134
+ console.log(' │');
135
+
136
+ // Gateway Node
137
+ console.log(` ├── ${chalk.yellow.bold('DEFAULT GATEWAY / ROUTER')}`);
138
+ console.log(` │ └── IP: ${chalk.yellow(gatewayIp)} [Route → WAN / Internet]`);
139
+ console.log(' │');
140
+
141
+ // LAN Neighborhood
142
+ console.log(` ├── ${chalk.magenta.bold('LAN NEIGHBORS & PEERS')} (${lanPeers.length} discovered via ARP)`);
143
+ if (lanPeers.length === 0) {
144
+ console.log(` │ └── ${chalk.dim('No neighboring ARP entries currently cached.')}`);
145
+ } else {
146
+ for (let i = 0; i < lanPeers.length; i++) {
147
+ const peer = lanPeers[i];
148
+ const isLast = i === lanPeers.length - 1;
149
+ const prefix = isLast ? ' │ └──' : ' │ ├──';
150
+ const gwBadge = peer.isGateway ? chalk.yellow(' [Gateway]') : '';
151
+ let pyreBadge = '';
152
+ if (peer.pyreP2P) pyreBadge += chalk.hex('#ff5500').bold(' [🔥 pyre P2P :9876]');
153
+ if (peer.pyreRest) pyreBadge += chalk.hex('#00ffcc').bold(' [🔥 pyre REST :8080]');
154
+
155
+ console.log(`${prefix} ${peer.ip.padEnd(16)} ${chalk.dim(peer.mac)}${gwBadge}${pyreBadge}`);
156
+ }
157
+ }
158
+ console.log(' │');
159
+
160
+ // Active External Remote Sockets
161
+ console.log(` └── ${chalk.cyan.bold('TOP ACTIVE REMOTE CONNECTIONS')}`);
162
+ if (establishedConns.length === 0) {
163
+ console.log(` └── ${chalk.dim('No active established sockets detected.')}`);
164
+ } else {
165
+ for (let i = 0; i < establishedConns.length; i++) {
166
+ const conn = establishedConns[i];
167
+ const isLast = i === establishedConns.length - 1;
168
+ const prefix = isLast ? ' └──' : ' ├──';
169
+ const connsBadge = chalk.dim(`(${conn.connectionCount} socket${conn.connectionCount > 1 ? 's' : ''})`);
170
+ console.log(`${prefix} ${chalk.white(conn.host.padEnd(24))} ${chalk.green('ESTABLISHED')} ${connsBadge}`);
171
+ }
172
+ }
173
+
174
+ console.log();
175
+ console.log(chalk.dim(' Tip: Run "pyre p2p server" on any node to allow automatic live cluster streaming.\n'));
176
+ }