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.
- package/README.md +73 -16
- package/dist/alertChannels-TPSQIJ7S.js +8 -0
- package/dist/{blender-7UT5J7IP.js → blender-FGH3ASEQ.js} +2 -2
- package/dist/brewHealth-P2GW5Y5C.js +176 -0
- package/dist/buildCmd-CDPZ2ZFA.js +38 -0
- package/dist/{checkCmd-T7O4KXXA.js → checkCmd-RKW6QGDR.js} +9 -11
- package/dist/{chunk-CXL4GYGQ.js → chunk-53O2RGSL.js} +44 -17
- package/dist/{chunk-3TE7MJLS.js → chunk-6UHZP4QP.js} +36 -10
- package/dist/chunk-GHRDW646.js +2506 -0
- package/dist/{prometheus-LFYHF6VO.js → chunk-HT4VQ4OH.js} +1 -0
- package/dist/chunk-M73HCQD2.js +95 -0
- package/dist/chunk-MZO5QKUR.js +32 -0
- package/dist/{chunk-25I533OF.js → chunk-VIACN2RU.js} +1 -1
- package/dist/chunk-Z2YV5HKV.js +87 -0
- package/dist/chunk-ZLVIVFSO.js +110 -0
- package/dist/{collectors-JB4OGUEF.js → collectors-H43PN4PS.js} +6 -3
- package/dist/{completions-VQMHQCXE.js → completions-JKYEJNGR.js} +34 -1
- package/dist/{config-KS5ZZ3VN.js → config-GYGJXU2Q.js} +9 -1
- package/dist/doctor-GYHFTNZQ.js +387 -0
- package/dist/explainCmd-MF6PIMLW.js +222 -0
- package/dist/extensions-XJGHG7X3.js +134 -0
- package/dist/index.js +460 -88
- package/dist/ioreport.py +72 -0
- package/dist/netUsageCmd-WKBTW2YG.js +90 -0
- package/dist/{pipeCmd-TYLJHQ5B.js → pipeCmd-7REDBLBU.js} +4 -3
- package/dist/profileProcCmd-N4H6MCBT.js +107 -0
- package/dist/prometheus-5BL5PVLC.js +6 -0
- package/dist/prometheusCmd-PL64BLN5.js +93 -0
- package/dist/replayCmd-Z3PYXY7V.js +164 -0
- package/dist/run-HOGQDDTA.js +8 -0
- package/dist/serveCmd-2H5VLF3P.js +153 -0
- package/dist/service-Q74BBILO.js +181 -0
- package/dist/smartCmd-ODWKSFNG.js +166 -0
- package/dist/syncCmd-A2W3QB3S.js +126 -0
- package/dist/topoCmd-DU2HHZNH.js +148 -0
- package/dist/watchdogCmd-UF53JPZB.js +161 -0
- package/dist/{wizard-Z3RGA3VB.js → wizard-TD7BGX6O.js} +1 -1
- package/install.ps1 +29 -0
- package/install.sh +10 -9
- package/package.json +7 -3
- package/src/ai/models.ts +138 -0
- package/src/alertChannels.ts +119 -0
- package/src/brewHealth.ts +157 -40
- package/src/buildCmd.ts +48 -0
- package/src/checkCmd.ts +5 -5
- package/src/completions.ts +35 -0
- package/src/doctor.ts +235 -7
- package/src/easy_features.test.ts +40 -0
- package/src/explainCmd.ts +261 -0
- package/src/extensions.ts +71 -7
- package/src/formatters/render.ts +34 -10
- package/src/formatters/types.ts +2 -0
- package/src/index.ts +251 -36
- package/src/live/input.ts +60 -0
- package/src/live/render.ts +67 -0
- package/src/live/state.ts +5 -0
- package/src/live/types.ts +1 -1
- package/src/monitors/batteryPredictor.ts +143 -0
- package/src/monitors/buildTracker.ts +121 -0
- package/src/monitors/collectors.ts +342 -178
- package/src/monitors/ioreport.py +72 -0
- package/src/monitors/platform/linux.ts +756 -0
- package/src/monitors/platform/windows.ts +519 -0
- package/src/monitors/run.ts +27 -3
- package/src/monitors/smc.ts +21 -1
- package/src/monitors/types.ts +15 -0
- package/src/netUsageCmd.ts +121 -0
- package/src/platform.test.ts +99 -0
- package/src/plugins/index.ts +143 -0
- package/src/profileProcCmd.ts +133 -0
- package/src/prometheusCmd.ts +107 -0
- package/src/replayCmd.ts +186 -0
- package/src/serveCmd.ts +180 -0
- package/src/service.ts +125 -7
- package/src/smartCmd.ts +205 -0
- package/src/state/config.ts +58 -17
- package/src/syncCmd.ts +166 -0
- package/src/topoCmd.ts +176 -0
- package/src/watchdogCmd.ts +198 -0
- package/src/xbar.ts +22 -11
- package/dist/brewHealth-F2BXF5M6.js +0 -74
- package/dist/chunk-FH5WDJBF.js +0 -15
- package/dist/chunk-N4WJVF5M.js +0 -1184
- package/dist/doctor-UV7TWIHX.js +0 -184
- package/dist/extensions-DVA5YLGH.js +0 -74
- package/dist/service-WTS3NBQU.js +0 -73
package/src/smartCmd.ts
ADDED
|
@@ -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
|
+
}
|
package/src/state/config.ts
CHANGED
|
@@ -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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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(
|
|
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
|
-
|
|
139
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
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
|
|
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
|
+
}
|