pyre-cli 9.5.1 → 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 +84 -14
- package/dist/alertChannels-TPSQIJ7S.js +8 -0
- package/dist/blender-FGH3ASEQ.js +7 -0
- package/dist/brewHealth-P2GW5Y5C.js +176 -0
- package/dist/buildCmd-CDPZ2ZFA.js +38 -0
- package/dist/{checkCmd-EWFNLBWE.js → checkCmd-RKW6QGDR.js} +10 -11
- package/dist/{chunk-RVGKEJXT.js → chunk-53O2RGSL.js} +47 -17
- package/dist/{chunk-MOAEGMFP.js → chunk-6UHZP4QP.js} +204 -46
- 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-VIACN2RU.js +133 -0
- package/dist/chunk-Z2YV5HKV.js +87 -0
- package/dist/chunk-ZLVIVFSO.js +110 -0
- package/dist/{collectors-WD6VKH6M.js → collectors-H43PN4PS.js} +8 -2
- package/dist/{completions-VQMHQCXE.js → completions-JKYEJNGR.js} +34 -1
- package/dist/{config-4ZLF4HTS.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 +841 -206
- package/dist/ioreport.py +72 -0
- package/dist/netUsageCmd-WKBTW2YG.js +90 -0
- package/dist/{pipeCmd-7THXEGDH.js → pipeCmd-7REDBLBU.js} +5 -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-GVIAHIVV.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/anomalies.ts +114 -60
- 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/index.ts +1 -1
- package/src/formatters/output.ts +100 -13
- package/src/formatters/render.ts +274 -38
- package/src/formatters/themes.ts +8 -0
- package/src/formatters/types.ts +6 -1
- package/src/index.ts +298 -56
- package/src/live/input.ts +122 -4
- package/src/live/render.ts +98 -0
- package/src/live/state.ts +70 -52
- package/src/live/types.ts +2 -2
- package/src/monitors/batteryPredictor.ts +143 -0
- package/src/monitors/blender.ts +144 -0
- package/src/monitors/buildTracker.ts +121 -0
- package/src/monitors/collectors.ts +558 -147
- package/src/monitors/dns_cache.ts +48 -0
- package/src/monitors/index.ts +3 -2
- 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 +112 -14
- 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 +64 -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-HGRBBPYX.js +0 -937
- package/dist/doctor-L6FZYA7F.js +0 -184
- package/dist/extensions-DVA5YLGH.js +0 -74
- package/dist/service-WTS3NBQU.js +0 -73
- /package/dist/{chunk-SG26Y3WF.js → chunk-IM3IQMMT.js} +0 -0
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,23 @@ 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;
|
|
58
|
+
dockerModeConfirmed?: boolean;
|
|
44
59
|
visiblePanels?: {
|
|
45
60
|
cpu?: boolean;
|
|
46
61
|
mem?: boolean;
|
|
@@ -53,6 +68,8 @@ export interface PyreConfig {
|
|
|
53
68
|
tasks?: boolean;
|
|
54
69
|
disk?: boolean;
|
|
55
70
|
process?: boolean;
|
|
71
|
+
containers?: boolean;
|
|
72
|
+
blender?: boolean;
|
|
56
73
|
tree?: boolean;
|
|
57
74
|
};
|
|
58
75
|
panelLayout?: string[];
|
|
@@ -83,6 +100,15 @@ export const DEFAULT_CONFIG: Required<PyreConfig> = {
|
|
|
83
100
|
splashAnimation: 'classic',
|
|
84
101
|
webhookUrl: '',
|
|
85
102
|
alertCmd: '',
|
|
103
|
+
slackAlertUrl: '',
|
|
104
|
+
discordAlertUrl: '',
|
|
105
|
+
pushoverToken: '',
|
|
106
|
+
pushoverUser: '',
|
|
107
|
+
ntfyUrl: '',
|
|
108
|
+
aiBackend: 'builtin',
|
|
109
|
+
aiModel: 'expert-rules-v1',
|
|
110
|
+
aiApiKey: '',
|
|
111
|
+
dockerModeConfirmed: false,
|
|
86
112
|
visiblePanels: {
|
|
87
113
|
cpu: true,
|
|
88
114
|
mem: true,
|
|
@@ -95,15 +121,34 @@ export const DEFAULT_CONFIG: Required<PyreConfig> = {
|
|
|
95
121
|
tasks: true,
|
|
96
122
|
disk: true,
|
|
97
123
|
process: true,
|
|
124
|
+
containers: true,
|
|
125
|
+
blender: true,
|
|
98
126
|
tree: false,
|
|
99
127
|
},
|
|
100
128
|
panelLayout: ['mem', 'disk', 'net'],
|
|
101
129
|
};
|
|
102
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
|
+
|
|
103
147
|
export function readConfig(): Required<PyreConfig> {
|
|
104
148
|
try {
|
|
105
|
-
|
|
106
|
-
|
|
149
|
+
const file = getConfigFile();
|
|
150
|
+
if (!fs.existsSync(file)) return { ...DEFAULT_CONFIG };
|
|
151
|
+
const raw = fs.readFileSync(file, 'utf-8');
|
|
107
152
|
const parsed = JSON.parse(raw) as Partial<PyreConfig>;
|
|
108
153
|
return deepMerge(DEFAULT_CONFIG, parsed);
|
|
109
154
|
} catch {
|
|
@@ -113,32 +158,34 @@ export function readConfig(): Required<PyreConfig> {
|
|
|
113
158
|
|
|
114
159
|
export function writeConfig(config: Partial<PyreConfig>): void {
|
|
115
160
|
try {
|
|
116
|
-
|
|
117
|
-
|
|
161
|
+
const dir = getConfigDir();
|
|
162
|
+
const file = getConfigFile();
|
|
163
|
+
if (!fs.existsSync(dir)) {
|
|
164
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
118
165
|
}
|
|
119
166
|
const merged = deepMerge(readConfig(), config);
|
|
120
|
-
fs.writeFileSync(
|
|
167
|
+
fs.writeFileSync(file, JSON.stringify(merged, null, 2) + '\n');
|
|
121
168
|
} catch {
|
|
122
169
|
// ignore write errors
|
|
123
170
|
}
|
|
124
171
|
}
|
|
125
172
|
|
|
126
|
-
const PROFILES_DIR = path.join(CONFIG_DIR, 'profiles');
|
|
127
|
-
|
|
128
173
|
export function saveProfile(name: string): void {
|
|
129
174
|
if (!name || name.includes('/') || name.includes('\\')) {
|
|
130
175
|
throw new Error('Invalid profile name.');
|
|
131
176
|
}
|
|
132
|
-
|
|
133
|
-
|
|
177
|
+
const profilesDir = getProfilesDir();
|
|
178
|
+
if (!fs.existsSync(profilesDir)) {
|
|
179
|
+
fs.mkdirSync(profilesDir, { recursive: true });
|
|
134
180
|
}
|
|
135
181
|
const currentConfig = readConfig();
|
|
136
|
-
const profileFile = path.join(
|
|
182
|
+
const profileFile = path.join(profilesDir, `${name}.json`);
|
|
137
183
|
fs.writeFileSync(profileFile, JSON.stringify(currentConfig, null, 2) + '\n');
|
|
138
184
|
}
|
|
139
185
|
|
|
140
186
|
export function loadProfile(name: string): void {
|
|
141
|
-
const
|
|
187
|
+
const profilesDir = getProfilesDir();
|
|
188
|
+
const profileFile = path.join(profilesDir, `${name}.json`);
|
|
142
189
|
if (!fs.existsSync(profileFile)) {
|
|
143
190
|
throw new Error(`Profile '${name}' does not exist.`);
|
|
144
191
|
}
|
|
@@ -149,8 +196,9 @@ export function loadProfile(name: string): void {
|
|
|
149
196
|
}
|
|
150
197
|
|
|
151
198
|
export function listProfiles(): string[] {
|
|
152
|
-
|
|
153
|
-
|
|
199
|
+
const profilesDir = getProfilesDir();
|
|
200
|
+
if (!fs.existsSync(profilesDir)) return [];
|
|
201
|
+
const files = fs.readdirSync(profilesDir);
|
|
154
202
|
return files.filter(f => f.endsWith('.json')).map(f => f.replace(/\.json$/, ''));
|
|
155
203
|
}
|
|
156
204
|
|
|
@@ -169,7 +217,6 @@ function deepMerge(target: any, source: any): any {
|
|
|
169
217
|
}
|
|
170
218
|
|
|
171
219
|
export function getConfigPath(): string {
|
|
172
|
-
return
|
|
220
|
+
return getConfigFile();
|
|
173
221
|
}
|
|
174
222
|
|
|
175
|
-
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
|
+
}
|