pyre-cli 1.3.0 → 2.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.
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared shell-execution helper for the monitors module.
3
+ *
4
+ * Wraps `child_process.execSync` with a timeout and a
5
+ * fallback return value so that a failing command never
6
+ * crashes the entire collection pipeline.
7
+ */
8
+
9
+ async function run(cmd: string, fallback: string = ''): Promise<string> {
10
+ try {
11
+ const { execSync } = await import('node:child_process');
12
+ return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }).trim();
13
+ } catch {
14
+ return fallback;
15
+ }
16
+ }
17
+
18
+ export { run };
@@ -0,0 +1,107 @@
1
+ /**
2
+ * SMC and powermetrics sensor reader.
3
+ *
4
+ * Modern macOS requires `powermetrics` (root) to read CPU die
5
+ * temperature, GPU die temperature, SMC die temperature, and
6
+ * power draw (CPU/GPU/combined watts). This module wraps that
7
+ * call with a short-lived cache so that multiple collectors can
8
+ * reuse the same data within a single tick without invoking
9
+ * `powermetrics` repeatedly.
10
+ *
11
+ * It also provides the `parseSuffix` helper used when decoding
12
+ * `vm.swapusage` output.
13
+ */
14
+ import { run } from './run.js';
15
+
16
+ export interface SmcMetrics {
17
+ temps: Record<string, number>;
18
+ power: { cpu?: number; gpu?: number; combined?: number };
19
+ freq: Record<string, number>;
20
+ }
21
+
22
+ let smcCache: { data: SmcMetrics; ts: number } | null = null;
23
+
24
+ /**
25
+ * Query `powermetrics` for SMC temperatures, power draw, and
26
+ * CPU frequency. Results are cached for 1.5 s so that
27
+ * concurrent collectors (CPU temp, power, thermal) share a
28
+ * single invocation.
29
+ *
30
+ * Falls back to an empty object on any failure (missing
31
+ * sudoers entry, unavailable binary, etc.) so the rest of the
32
+ * application degrades gracefully.
33
+ */
34
+ export async function getSmcMetrics(): Promise<SmcMetrics> {
35
+ const now = Date.now();
36
+ if (smcCache && now - smcCache.ts < 1500) return smcCache.data;
37
+
38
+ const result: SmcMetrics = { temps: {}, power: {}, freq: {} };
39
+ try {
40
+ // `sudo -n` (non-interactive) so we never hang waiting on a
41
+ // password prompt that can't be answered from a piped child
42
+ // process; if the user hasn't set up passwordless sudo for
43
+ // powermetrics, this just fails fast and we fall back to the
44
+ // thermal-state string instead of a temperature.
45
+ const pm = (
46
+ await run(
47
+ 'sudo -n powermetrics --samplers smc,cpu_power -n 1 --format text 2>/dev/null',
48
+ ''
49
+ )
50
+ ).trim();
51
+
52
+ if (pm) {
53
+ const cpuTemp = pm.match(/CPU die temperature:\s*([\d.]+)/i);
54
+ if (cpuTemp) result.temps['cpu_die'] = parseFloat(cpuTemp[1]);
55
+
56
+ const gpuTemp = pm.match(/GPU die temperature:\s*([\d.]+)/i);
57
+ if (gpuTemp) result.temps['gpu_die'] = parseFloat(gpuTemp[1]);
58
+
59
+ const smcTemp = pm.match(/SMC die temperature:\s*([\d.]+)/i);
60
+ if (smcTemp) result.temps['smc_die'] = parseFloat(smcTemp[1]);
61
+
62
+ const cpuPower = pm.match(/CPU Power:\s*([\d.]+)\s*mW/i);
63
+ if (cpuPower) result.power.cpu = parseFloat(cpuPower[1]) / 1000;
64
+
65
+ const gpuPower = pm.match(/GPU Power:\s*([\d.]+)\s*mW/i);
66
+ if (gpuPower) result.power.gpu = parseFloat(gpuPower[1]) / 1000;
67
+
68
+ const combinedPower = pm.match(/Combined Power \(CPU \+ GPU.*?\):\s*([\d.]+)\s*mW/i);
69
+ if (combinedPower) result.power.combined = parseFloat(combinedPower[1]) / 1000;
70
+
71
+ // Apple Silicon reports active clock speed per core cluster
72
+ // rather than a single number — grab whichever cluster(s)
73
+ // this chip has.
74
+ const eFreq = pm.match(/E-Cluster HW active frequency:\s*([\d.]+)/i);
75
+ if (eFreq) result.freq['e_cluster'] = parseFloat(eFreq[1]);
76
+
77
+ const pFreq = pm.match(/P-Cluster HW active frequency:\s*([\d.]+)/i);
78
+ if (pFreq) result.freq['p_cluster'] = parseFloat(pFreq[1]);
79
+
80
+ // Some single-cluster/older chips report just "CPU HW active frequency".
81
+ const cpuFreq = pm.match(/CPU HW active frequency:\s*([\d.]+)/i);
82
+ if (cpuFreq) result.freq['cpu'] = parseFloat(cpuFreq[1]);
83
+ }
84
+ } catch {
85
+ // powermetrics unavailable/unauthorized — leave temps/power/freq empty,
86
+ // the rest of the app degrades gracefully (thermal state string still
87
+ // shows, and CPU frequency falls back to 0/"unavailable").
88
+ }
89
+
90
+ smcCache = { data: result, ts: now };
91
+ return result;
92
+ }
93
+
94
+ /**
95
+ * Parse a size suffix (K, M, G) from `vm.swapusage` output into
96
+ * its byte multiplier. Case-insensitive; defaults to 1 (no
97
+ * suffix).
98
+ */
99
+ export function parseSuffix(s: string): number {
100
+ return s.toUpperCase() === 'K'
101
+ ? 1024
102
+ : s.toUpperCase() === 'M'
103
+ ? 1024 * 1024
104
+ : s.toUpperCase() === 'G'
105
+ ? 1024 * 1024 * 1024
106
+ : 1;
107
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Core data types for system metric collection.
3
+ *
4
+ * Every interface in this file represents a distinct category of
5
+ * system information gathered by the monitors module. The
6
+ * {@link StatsData} aggregate ties them together for downstream
7
+ * formatting and export.
8
+ */
9
+
10
+ export interface StatsData {
11
+ header: { title: string; hostname: string; os: string; uptime: string };
12
+ cpu: CpuData;
13
+ gpu: GpuData | null;
14
+ memory: MemoryData;
15
+ disk: DiskData[];
16
+ battery: BatteryData | null;
17
+ thermal: ThermalData;
18
+ network: NetworkData;
19
+ processes: ProcessData[];
20
+ power: PowerData | null;
21
+ timestamp: string;
22
+ packets: PacketData | null;
23
+ tasks: TaskData[];
24
+ }
25
+
26
+ export interface GpuData {
27
+ model: string;
28
+ memory: number;
29
+ utilization: number;
30
+ temperature?: number;
31
+ processes: number;
32
+ }
33
+
34
+ export interface PowerData {
35
+ cpuWatts?: number;
36
+ gpuWatts?: number;
37
+ combinedWatts?: number;
38
+ }
39
+
40
+ export interface PacketData {
41
+ totalPackets: number;
42
+ rxPackets: number;
43
+ txPackets: number;
44
+ rxRate: number;
45
+ txRate: number;
46
+ connections: number;
47
+ topProcesses: { pid: number; command: string; rxBytes: number; txBytes: number }[];
48
+ /** Every process with an open network connection (unlike topProcesses, not truncated). */
49
+ allProcesses: { pid: number; command: string; rxBytes: number; txBytes: number; rxPackets?: number; txPackets?: number }[];
50
+ interfaces: { iface: string; rxPackets: number; txPackets: number; rxBytes: number; txBytes: number }[];
51
+ }
52
+
53
+ export interface TaskData {
54
+ pid: number;
55
+ user: string;
56
+ cpu: number;
57
+ mem: number;
58
+ command: string;
59
+ state: string;
60
+ runtime: number;
61
+ }
62
+
63
+ export interface CpuData {
64
+ brand: string;
65
+ cores: number;
66
+ physicalCores: number;
67
+ frequency: number;
68
+ usage: number;
69
+ loadAvg: number[];
70
+ temperature?: number;
71
+ }
72
+
73
+ export interface MemoryData {
74
+ total: number;
75
+ used: number;
76
+ free: number;
77
+ swapTotal: number;
78
+ swapUsed: number;
79
+ swapFree: number;
80
+ pageSize: number;
81
+ usagePercent: number;
82
+ }
83
+
84
+ export interface ThermalData {
85
+ state: string;
86
+ detail?: string;
87
+ /** Normalized 0-3 scale (nominal/fair/serious/critical) derived from `state`, for graphing/coloring */
88
+ pressureLevel: number;
89
+ temperatures?: Record<string, number | null>;
90
+ error?: string;
91
+ }
92
+
93
+ export interface BatteryData {
94
+ level: number;
95
+ state: string;
96
+ timeRemaining: string;
97
+ health: string;
98
+ powerSource: string;
99
+ cycles?: number;
100
+ condition?: string;
101
+ maxCapacityPercent?: number;
102
+ estimatedTimeToEmpty?: string;
103
+ dischargeRatePerHour?: number;
104
+ powerWatts?: number;
105
+ }
106
+
107
+ export interface DiskData {
108
+ filesystem: string;
109
+ size: string;
110
+ used: string;
111
+ available: string;
112
+ capacity: string;
113
+ mountpoint: string;
114
+ }
115
+
116
+ export interface NetworkData {
117
+ interface: string;
118
+ ip: string;
119
+ rxBytes: number;
120
+ txBytes: number;
121
+ rxPackets: number;
122
+ txPackets: number;
123
+ rxPacketsPerSec?: number;
124
+ txPacketsPerSec?: number;
125
+ connections?: number;
126
+ topProcesses?: NetworkProcess[];
127
+ }
128
+
129
+ export interface NetworkProcess {
130
+ pid: number;
131
+ command: string;
132
+ rxBytes: number;
133
+ txBytes: number;
134
+ rxPackets: number;
135
+ txPackets: number;
136
+ }
137
+
138
+ export interface ProcessData {
139
+ pid: number;
140
+ ppid: number;
141
+ user: string;
142
+ cpu: number;
143
+ mem: number;
144
+ command: string;
145
+ state: string;
146
+ threads: number;
147
+ runtime: number;
148
+ }
@@ -0,0 +1,197 @@
1
+ import net from 'node:net';
2
+ import chalk from 'chalk';
3
+ import { encodeAuth, encodePing, encodePong, encodeDisconnect } from './protocol.js';
4
+ import type { P2PClientOptions } from './types.js';
5
+ import type { StatsData } from '../monitors/types.js';
6
+
7
+ interface ReadState {
8
+ buffer: Buffer;
9
+ expectingHeader: boolean;
10
+ messageLength: number;
11
+ }
12
+
13
+ export class P2PClient {
14
+ private socket: net.Socket | null = null;
15
+ private options: P2PClientOptions;
16
+ private running = false;
17
+ private readState: ReadState = { buffer: Buffer.alloc(0), expectingHeader: true, messageLength: 0 };
18
+ private onDataCallback: ((data: StatsData) => void) | null = null;
19
+ private onStatusCallback: ((msg: string) => void) | null = null;
20
+ private reconnectTimer: NodeJS.Timeout | null = null;
21
+ private pingHandle: NodeJS.Timeout | null = null;
22
+
23
+ constructor(options: P2PClientOptions) {
24
+ this.options = options;
25
+ }
26
+
27
+ setOnData(cb: (data: StatsData) => void): void {
28
+ this.onDataCallback = cb;
29
+ }
30
+
31
+ setOnStatus(cb: (msg: string) => void): void {
32
+ this.onStatusCallback = cb;
33
+ }
34
+
35
+ async connect(): Promise<void> {
36
+ return new Promise((resolve, reject) => {
37
+ this.socket = new net.Socket();
38
+
39
+ this.socket.on('data', (data) => this.onData(data));
40
+ this.socket.on('close', () => this.onClose());
41
+ this.socket.on('error', (err) => this.onError(err));
42
+ this.socket.on('timeout', () => this.onTimeout());
43
+
44
+ this.socket.setTimeout(10000);
45
+
46
+ this.socket.connect(this.options.port, this.options.host, () => {
47
+ this.running = true;
48
+ this.socket?.setTimeout(0);
49
+ this.socket?.write(encodeAuth(this.options.password));
50
+ this.status('Connecting...');
51
+ resolve();
52
+ });
53
+
54
+ this.socket.once('error', (err) => {
55
+ reject(err);
56
+ });
57
+ });
58
+ }
59
+
60
+ disconnect(): void {
61
+ this.running = false;
62
+ if (this.reconnectTimer) {
63
+ clearTimeout(this.reconnectTimer);
64
+ this.reconnectTimer = null;
65
+ }
66
+ if (this.pingHandle) {
67
+ clearInterval(this.pingHandle);
68
+ this.pingHandle = null;
69
+ }
70
+ if (this.socket) {
71
+ try {
72
+ this.socket.write(encodeDisconnect('Client closing'));
73
+ } catch {
74
+ // ignore
75
+ }
76
+ this.socket.destroy();
77
+ this.socket = null;
78
+ }
79
+ this.status('Disconnected');
80
+ }
81
+
82
+ private onData(data: Buffer): void {
83
+ this.readState.buffer = Buffer.concat([this.readState.buffer, data]);
84
+
85
+ while (this.readState.buffer.length > 0) {
86
+ if (this.readState.expectingHeader) {
87
+ if (this.readState.buffer.length >= 4) {
88
+ this.readState.messageLength = this.readState.buffer.readUInt32BE(0);
89
+ this.readState.buffer = this.readState.buffer.subarray(4);
90
+ this.readState.expectingHeader = false;
91
+ } else {
92
+ break;
93
+ }
94
+ }
95
+
96
+ if (!this.readState.expectingHeader) {
97
+ if (this.readState.buffer.length >= this.readState.messageLength) {
98
+ const body = this.readState.buffer.subarray(0, this.readState.messageLength);
99
+ this.readState.buffer = this.readState.buffer.subarray(this.readState.messageLength);
100
+ this.readState.expectingHeader = true;
101
+ this.handleMessage(body);
102
+ } else {
103
+ break;
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ private handleMessage(body: Buffer): void {
110
+ try {
111
+ const msg = JSON.parse(body.toString('utf-8'));
112
+
113
+ switch (msg.kind) {
114
+ case 'auth-ok':
115
+ this.status('Authenticated — receiving live data');
116
+ this.startPing();
117
+ break;
118
+
119
+ case 'auth-fail':
120
+ this.status(`Authentication failed: ${(msg.payload as { reason: string }).reason}`);
121
+ this.disconnect();
122
+ break;
123
+
124
+ case 'data':
125
+ if (this.onDataCallback && msg.payload?.stats) {
126
+ this.onDataCallback(msg.payload.stats as StatsData);
127
+ }
128
+ break;
129
+
130
+ case 'ping':
131
+ if (this.socket && !this.socket.destroyed) {
132
+ const ts = (msg.payload as { ts: number }).ts;
133
+ this.socket.write(encodePong(ts));
134
+ }
135
+ break;
136
+
137
+ case 'pong':
138
+ break;
139
+
140
+ case 'disconnect':
141
+ this.status(`Server disconnected: ${(msg.payload as { reason: string }).reason}`);
142
+ this.disconnect();
143
+ break;
144
+
145
+ default:
146
+ break;
147
+ }
148
+ } catch {
149
+ // ignore malformed messages
150
+ }
151
+ }
152
+
153
+ private startPing(): void {
154
+ this.pingHandle = setInterval(() => {
155
+ if (this.socket && !this.socket.destroyed) {
156
+ this.socket.write(encodePing());
157
+ }
158
+ }, 15000);
159
+ }
160
+
161
+ private onClose(): void {
162
+ if (this.running) {
163
+ this.status('Connection closed — reconnecting in 3s');
164
+ this.reconnectTimer = setTimeout(() => {
165
+ if (this.running) this.connect().catch(() => {});
166
+ }, 3000);
167
+ }
168
+ }
169
+
170
+ private onError(err: Error): void {
171
+ const code = (err as any).code;
172
+ if (code === 'ECONNREFUSED') {
173
+ this.status('Connection refused — is the server running?');
174
+ } else if (code === 'ETIMEDOUT') {
175
+ this.status('Connection timed out');
176
+ } else {
177
+ this.status(`Connection error: ${err.message}`);
178
+ }
179
+ }
180
+
181
+ private onTimeout(): void {
182
+ this.status('Connection timed out');
183
+ this.disconnect();
184
+ }
185
+
186
+ private status(msg: string): void {
187
+ if (this.onStatusCallback) {
188
+ this.onStatusCallback(msg);
189
+ }
190
+ }
191
+ }
192
+
193
+ export async function connectP2PClient(options: P2PClientOptions): Promise<P2PClient> {
194
+ const client = new P2PClient(options);
195
+ await client.connect();
196
+ return client;
197
+ }
@@ -0,0 +1,3 @@
1
+ export { P2PServer, startP2PServer } from './server.js';
2
+ export { P2PClient, connectP2PClient } from './client.js';
3
+ export type { P2PServerOptions, P2PClientOptions, P2PConnectionInfo } from './types.js';
@@ -0,0 +1,71 @@
1
+ import type { P2PMessage, P2PMessageKind, P2PPayload } from './types.js';
2
+ import type { StatsData } from '../monitors/types.js';
3
+
4
+ const HEADER_SIZE = 4;
5
+ const MAX_MESSAGE_SIZE = 16 * 1024 * 1024;
6
+
7
+ function buildMessage(kind: P2PMessageKind, payload: P2PPayload): Buffer {
8
+ const envelope: P2PMessage = { kind, payload };
9
+ const body = Buffer.from(JSON.stringify(envelope), 'utf-8');
10
+ const header = Buffer.alloc(HEADER_SIZE);
11
+ header.writeUInt32BE(body.length, 0);
12
+ return Buffer.concat([header, body]);
13
+ }
14
+
15
+ export function encodeAuth(password: string): Buffer {
16
+ return buildMessage('auth', { password });
17
+ }
18
+
19
+ export function encodeAuthOk(): Buffer {
20
+ return buildMessage('auth-ok', { ok: true });
21
+ }
22
+
23
+ export function encodeAuthFail(reason: string): Buffer {
24
+ return buildMessage('auth-fail', { ok: false, reason });
25
+ }
26
+
27
+ export function encodeData(stats: StatsData): Buffer {
28
+ return buildMessage('data', { stats });
29
+ }
30
+
31
+ export function encodePing(): Buffer {
32
+ return buildMessage('ping', { ts: Date.now() });
33
+ }
34
+
35
+ export function encodePong(ts: number): Buffer {
36
+ return buildMessage('pong', { ts });
37
+ }
38
+
39
+ export function encodeDisconnect(reason: string): Buffer {
40
+ return buildMessage('disconnect', { reason });
41
+ }
42
+
43
+ export function decodeMessage(buf: Buffer): P2PMessage {
44
+ const str = buf.toString('utf-8');
45
+ const msg: P2PMessage = JSON.parse(str);
46
+ return msg;
47
+ }
48
+
49
+ export function readMessage(stream: { read(n: number): Buffer | null }): Buffer | null {
50
+ const header = stream.read(HEADER_SIZE);
51
+ if (!header || header.length < HEADER_SIZE) return null;
52
+ const length = header.readUInt32BE(0);
53
+ if (length > MAX_MESSAGE_SIZE) {
54
+ throw new Error(`Message too large: ${length} bytes`);
55
+ }
56
+ const body = stream.read(length);
57
+ if (!body || body.length < length) return null;
58
+ return body;
59
+ }
60
+
61
+ export function readAll(stream: { read(n: number): Buffer | null }, total: number): Buffer {
62
+ const chunks: Buffer[] = [];
63
+ let remaining = total;
64
+ while (remaining > 0) {
65
+ const chunk = stream.read(remaining);
66
+ if (!chunk) break;
67
+ chunks.push(chunk);
68
+ remaining -= chunk.length;
69
+ }
70
+ return Buffer.concat(chunks);
71
+ }