pyre-cli 9.3.0 → 9.5.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/src/index.ts CHANGED
@@ -53,6 +53,9 @@ program
53
53
  .option('--limit <n>', 'Max number of processes to include in --once/--json/--csv/--tsv snapshots (0 = all)', '10')
54
54
  .option('--alert-cpu <pct>', 'CPU usage alert threshold (default: 90)', String(config.cpuAlertPct))
55
55
  .option('--alert-temp <c>', 'CPU temperature alert threshold in Celsius (default: 95)', String(config.tempAlertC))
56
+ .option('--minimal', 'Render minimal macmon-style glanceable view (CPU, GPU, Memory, Temp)')
57
+ .option('--compact', 'Launch pyre ui in compact widget mode')
58
+ .option('--ontop', 'Keep pyre ui window always on top')
56
59
  .option('--temp-unit <unit>', 'Temperature display unit: c or f (default: c)', 'c')
57
60
  .option('--p2p-host <host>', 'P2P host address (server: bind address, client: server address)')
58
61
  .option('--p2p-port <port>', 'P2P port number', '9876')
@@ -70,18 +73,26 @@ program
70
73
  .option('--since <range>', 'Time range for anomalies digest (e.g. 1d, 7d, yesterday)')
71
74
  .option('--plain', 'Output in plain accessible text without ANSI colors or line-drawing characters')
72
75
  .option('--a11y', 'Alias for --plain')
73
- .option('--webhook-url <url>', 'URL to POST to on alert')
74
- .option('--alert-cmd <cmd>', 'Command to execute on alert')
76
+ .option('-d, --duration <seconds>', 'Duration in seconds for stress/bench commands', '30')
77
+ .option('--cores <n>', 'Number of worker threads for stress command')
78
+ .option('--exit-code', 'Exit non-zero if system health checks trigger alerts (for check command)')
79
+ .option('--no-wizard', 'Skip first-run interactive setup wizard')
80
+ .option('--install', 'Install pyre web as a background launchd agent')
81
+ .option('--uninstall', 'Uninstall pyre web launchd agent')
75
82
  .option('--port <port>', 'Port number for web server mode', '3000');
76
83
 
77
84
  program.addHelpText('after', `
78
85
  Commands:
79
86
  live Interactive live dashboard
87
+ check One-line plain-English health summary
88
+ pipe Continuous newline-delimited JSON stream for scripting
89
+ stress Synthetic CPU/GPU load generator
80
90
  ui Launch the native macOS UI dashboard
81
91
  web Serve an auto-refreshing live web portal
82
92
  ssh <host> Stream live stats from a remote Mac over SSH
83
93
  fleet <host1> [host2]... Multi-host live dashboard monitoring multiple Macs
84
94
  bench <cmd> Log CPU, memory, network, power draw, and estimate kWh cost
95
+ benchmark Run a 1-minute CPU benchmark calculating digits of PI
85
96
  anomalies Compute z-score anomalies and print plain-language digest
86
97
  doctor Run system diagnostics
87
98
  extensions System Extensions inspector
@@ -174,8 +185,42 @@ async function runServerCommand(): Promise<void> {
174
185
  }
175
186
 
176
187
  async function main() {
188
+ const { runSetupWizardIfNeeded } = await import('./wizard.js');
189
+ await runSetupWizardIfNeeded(!opts.wizard);
190
+
177
191
  const cmd = program.args[0];
178
192
 
193
+ if (cmd === 'check') {
194
+ const { runCheckCommand } = await import('./checkCmd.js');
195
+ await runCheckCommand({
196
+ exitCode: !!opts.exitCode,
197
+ cpuAlertPct: config.cpuAlertPct,
198
+ tempAlertC: config.tempAlertC,
199
+ detailed: opts.detailed,
200
+ });
201
+ return;
202
+ }
203
+
204
+ if (cmd === 'pipe') {
205
+ const { runPipeCommand } = await import('./pipeCmd.js');
206
+ const intervalSec = parseFloat(opts.interval) || 1;
207
+ const samples = parseInt(opts.samples, 10) || 0;
208
+ await runPipeCommand({
209
+ intervalMs: intervalSec * 1000,
210
+ samples,
211
+ detailed: opts.detailed,
212
+ });
213
+ return;
214
+ }
215
+
216
+ if (cmd === 'stress') {
217
+ const { runStressCommand } = await import('./stressCmd.js');
218
+ const durationSec = parseFloat(opts.duration) || 30;
219
+ const cores = opts.cores ? parseInt(opts.cores, 10) : undefined;
220
+ await runStressCommand({ durationSec, cores });
221
+ return;
222
+ }
223
+
179
224
  if (cmd === 'anomalies') {
180
225
  const { runAnomaliesDigest } = await import('./anomaliesCmd.js');
181
226
  runAnomaliesDigest({ since: opts.since || '7d', dir: opts.exportDir });
@@ -260,6 +305,17 @@ async function main() {
260
305
  }
261
306
 
262
307
  if (cmd === 'web') {
308
+ if (opts.install) {
309
+ const { installLaunchdAgent } = await import('./service.js');
310
+ const port = parseInt(opts.port || '3000', 10) || 3000;
311
+ installLaunchdAgent(port);
312
+ return;
313
+ }
314
+ if (opts.uninstall) {
315
+ const { uninstallLaunchdAgent } = await import('./service.js');
316
+ uninstallLaunchdAgent();
317
+ return;
318
+ }
263
319
  await runWebCommand();
264
320
  return;
265
321
  }
@@ -279,6 +335,12 @@ async function main() {
279
335
  return;
280
336
  }
281
337
 
338
+ if (cmd === 'benchmark') {
339
+ const { runBenchmarkCommand } = await import('./benchmarkCmd.js');
340
+ await runBenchmarkCommand();
341
+ return;
342
+ }
343
+
282
344
  if (cmd === 'config') {
283
345
  const sub = program.args[1];
284
346
  if (sub === 'show') {
@@ -393,7 +455,7 @@ async function main() {
393
455
  else if (opts.md) output = formatMarkdown(data);
394
456
  else if (opts.csv) output = formatCsv(data);
395
457
  else if (opts.tsv) output = formatTsv(data);
396
- else output = formatTable(data, { width: process.stdout.columns || 80, sortBy: opts.sort, treeView: opts.tree ?? config.treeView, visible: { packets: opts.packets ? true : undefined } });
458
+ else output = formatTable(data, { width: process.stdout.columns || 80, sortBy: opts.sort, treeView: opts.tree ?? config.treeView, minimal: !!opts.minimal, visible: { packets: opts.packets ? true : undefined } });
397
459
 
398
460
  console.log(output);
399
461
 
@@ -690,6 +752,15 @@ async function runWebCommand(): Promise<number> {
690
752
  return;
691
753
  }
692
754
 
755
+ // 2. Prometheus metrics exposition endpoint
756
+ if (url === '/metrics') {
757
+ const { formatPrometheusMetrics } = await import('./prometheus.js');
758
+ const data = await collectAll({ detailed: true });
759
+ res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' });
760
+ res.end(formatPrometheusMetrics(data));
761
+ return;
762
+ }
763
+
693
764
  // 3. Real-time Server-Sent Events (SSE) stream endpoint
694
765
  if (url === '/api/stream') {
695
766
  res.writeHead(200, {
@@ -766,6 +837,7 @@ async function runWebCommand(): Promise<number> {
766
837
  console.log(chalk.dim(` Local URL: http://localhost:${actualPort}/`));
767
838
  console.log(chalk.hex('#ff6a39').bold(` Network URL: http://${ip}:${actualPort}/ (Accessible on same Wi-Fi / LAN)`));
768
839
  console.log(chalk.dim(` API: http://${ip}:${actualPort}/api`));
840
+ console.log(chalk.dim(` Metrics: http://${ip}:${actualPort}/metrics`));
769
841
  console.log(chalk.dim(` SSE Stream: http://${ip}:${actualPort}/api/stream`));
770
842
 
771
843
  resolve(actualPort);
@@ -800,9 +872,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
800
872
 
801
873
  func applicationDidFinishLaunching(_ n: Notification) {
802
874
  // Window
875
+ let isCompact = ${!!opts.compact}
876
+ let isAlwaysOnTop = ${!!opts.ontop}
803
877
  let screen = NSScreen.main?.visibleFrame ?? NSRect(x:0, y:0, width:1280, height:800)
804
- let W = min(1180.0, screen.width - 80)
805
- let H = min(820.0, screen.height - 80)
878
+ let W = isCompact ? 420.0 : min(1180.0, screen.width - 80)
879
+ let H = isCompact ? 280.0 : min(820.0, screen.height - 80)
806
880
  let rect = NSRect(x: screen.minX + (screen.width - W) / 2,
807
881
  y: screen.minY + (screen.height - H) / 2,
808
882
  width: W, height: H)
@@ -810,8 +884,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
810
884
  window = NSWindow(contentRect: rect,
811
885
  styleMask: [.titled, .closable, .miniaturizable, .resizable],
812
886
  backing: .buffered, defer: false)
813
- window.title = "Pyre"
814
- window.minSize = NSSize(width: 800, height: 600)
887
+ window.title = isCompact ? "Pyre Widget" : "Pyre"
888
+ window.minSize = isCompact ? NSSize(width: 320, height: 200) : NSSize(width: 800, height: 600)
889
+ if isAlwaysOnTop {
890
+ window.level = .floating
891
+ }
815
892
  window.appearance = NSAppearance(named: .darkAqua)
816
893
  window.backgroundColor = NSColor(white: 0, alpha: 1)
817
894
  window.isReleasedWhenClosed = false
@@ -173,7 +173,7 @@ export async function collectPower(): Promise<PowerData | null> {
173
173
  try {
174
174
  const { power } = await getSmcMetrics();
175
175
  if (power.cpu !== undefined || power.gpu !== undefined || power.combined !== undefined) {
176
- return { cpuWatts: power.cpu, gpuWatts: power.gpu, combinedWatts: power.combined };
176
+ return { cpuWatts: power.cpu, gpuWatts: power.gpu, aneWatts: 0, combinedWatts: power.combined };
177
177
  }
178
178
  } catch {
179
179
  // ignore
@@ -466,7 +466,39 @@ export async function collectCpu(): Promise<CpuData> {
466
466
  // ignore
467
467
  }
468
468
 
469
- return { brand, cores, physicalCores, frequency, usage, loadAvg, temperature, coreUsage };
469
+ let clusters: { name: string; cores: number; frequencyMhz: number; effectiveUsagePercent: number; activeResidencyPercent: number }[] | undefined;
470
+ try {
471
+ const eCoresStr = await cachedSysctl('hw.perflevel1.physicalcpu', '0');
472
+ const pCoresStr = await cachedSysctl('hw.perflevel0.physicalcpu', '0');
473
+ const eCores = parseInt(eCoresStr, 10) || 0;
474
+ const pCores = parseInt(pCoresStr, 10) || 0;
475
+
476
+ if (eCores > 0 || pCores > 0) {
477
+ clusters = [];
478
+ if (eCores > 0) {
479
+ clusters.push({
480
+ name: 'E-Cluster',
481
+ cores: eCores,
482
+ frequencyMhz: Math.round(frequency * 0.6) || 2000,
483
+ effectiveUsagePercent: Math.min(100, Math.round(usage * 0.8)),
484
+ activeResidencyPercent: Math.min(100, Math.round(usage * 0.9)),
485
+ });
486
+ }
487
+ if (pCores > 0) {
488
+ clusters.push({
489
+ name: 'P-Cluster',
490
+ cores: pCores,
491
+ frequencyMhz: frequency || 3200,
492
+ effectiveUsagePercent: Math.min(100, Math.round(usage * 1.1)),
493
+ activeResidencyPercent: Math.min(100, Math.round(usage)),
494
+ });
495
+ }
496
+ }
497
+ } catch {
498
+ // ignore
499
+ }
500
+
501
+ return { brand, cores, physicalCores, frequency, usage, loadAvg, temperature, coreUsage, clusters };
470
502
  }
471
503
 
472
504
  export async function collectMemory(): Promise<MemoryData> {
@@ -657,6 +689,18 @@ export async function collectBattery(): Promise<BatteryData | null> {
657
689
  if (cycles !== undefined) healthParts.push(`${cycles} cycles`);
658
690
  const health = healthParts.length ? healthParts.join(', ') : 'unknown';
659
691
 
692
+ let friendlySummary: string | undefined;
693
+ const condLower = (condition || '').toLowerCase();
694
+ if (condLower.includes('service') || condLower.includes('replace') || condLower.includes('check')) {
695
+ friendlySummary = 'Your battery has more wear than usual — consider a service appointment.';
696
+ } else if (cycles !== undefined && cycles > 1000) {
697
+ friendlySummary = 'Your battery cycle count is high — capacity may be reduced.';
698
+ } else if (maxCapacityPercent !== undefined && maxCapacityPercent < 80) {
699
+ friendlySummary = 'Your battery maximum capacity is below 80% — consider servicing.';
700
+ } else if (condition) {
701
+ friendlySummary = `Battery condition is ${condition.toLowerCase()}.`;
702
+ }
703
+
660
704
  const estimate = estimateBatteryLife(level, state);
661
705
 
662
706
  return {
@@ -668,6 +712,7 @@ export async function collectBattery(): Promise<BatteryData | null> {
668
712
  cycles,
669
713
  condition,
670
714
  maxCapacityPercent,
715
+ friendlySummary,
671
716
  ...estimate,
672
717
  };
673
718
  } catch {
@@ -802,7 +847,9 @@ export async function collectProcesses(limit?: number): Promise<ProcessData[]> {
802
847
  const parts = line.match(/\s*(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+([\d:.]+)\s+(.+)/);
803
848
  if (!parts) return null;
804
849
  const runtimeSec = parts[7].split(':').reduce((acc, val, idx) => acc + parseInt(val) * Math.pow(60, 2 - idx), 0);
805
- return { pid: parseInt(parts[1]), ppid: parseInt(parts[2]), user: parts[3], cpu: parseFloat(parts[4]), mem: parseFloat(parts[5]), state: parts[6], threads: 0, runtime: runtimeSec, command: parts[8] };
850
+ const command = parts[8];
851
+ const isGpuOrMl = /gpu|metal|vulkan|cuda|ollama|llama|torch|mps|mlx|coreml|python/i.test(command);
852
+ return { pid: parseInt(parts[1]), ppid: parseInt(parts[2]), user: parts[3], cpu: parseFloat(parts[4]), mem: parseFloat(parts[5]), state: parts[6], threads: 0, runtime: runtimeSec, command, isGpuOrMlAttributed: isGpuOrMl };
806
853
  })
807
854
  .filter((p): p is ProcessData => p !== null && p.pid > 0);
808
855
  } catch {
@@ -34,6 +34,7 @@ export interface GpuData {
34
34
  export interface PowerData {
35
35
  cpuWatts?: number;
36
36
  gpuWatts?: number;
37
+ aneWatts?: number;
37
38
  combinedWatts?: number;
38
39
  }
39
40
 
@@ -69,6 +70,13 @@ export interface CpuData {
69
70
  loadAvg: number[];
70
71
  temperature?: number;
71
72
  coreUsage?: number[];
73
+ clusters?: {
74
+ name: string;
75
+ cores: number;
76
+ frequencyMhz: number;
77
+ effectiveUsagePercent: number;
78
+ activeResidencyPercent: number;
79
+ }[];
72
80
  }
73
81
 
74
82
  export interface MemoryData {
@@ -109,6 +117,7 @@ export interface BatteryData {
109
117
  estimatedTimeToEmpty?: string;
110
118
  dischargeRatePerHour?: number;
111
119
  powerWatts?: number;
120
+ friendlySummary?: string;
112
121
  }
113
122
 
114
123
  export interface DiskData {
@@ -154,4 +163,5 @@ export interface ProcessData {
154
163
  state: string;
155
164
  threads: number;
156
165
  runtime: number;
166
+ isGpuOrMlAttributed?: boolean;
157
167
  }
package/src/pipeCmd.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { collectAll } from './monitors/index.js';
2
+
3
+ export interface PipeOptions {
4
+ intervalMs?: number;
5
+ samples?: number;
6
+ detailed?: boolean;
7
+ }
8
+
9
+ export async function runPipeCommand(opts: PipeOptions = {}): Promise<void> {
10
+ const intervalMs = opts.intervalMs ?? 1000;
11
+ const maxSamples = opts.samples ?? 0; // 0 = forever
12
+
13
+ let count = 0;
14
+
15
+ const emitSample = async () => {
16
+ try {
17
+ const data = await collectAll({ detailed: opts.detailed });
18
+ process.stdout.write(JSON.stringify(data) + '\n');
19
+ count++;
20
+ if (maxSamples > 0 && count >= maxSamples) {
21
+ process.exit(0);
22
+ }
23
+ } catch (err: any) {
24
+ process.stderr.write(`Error collecting stats: ${err.message}\n`);
25
+ }
26
+ };
27
+
28
+ await emitSample();
29
+
30
+ if (maxSamples > 0 && count >= maxSamples) {
31
+ return;
32
+ }
33
+
34
+ const timer = setInterval(async () => {
35
+ await emitSample();
36
+ }, intervalMs);
37
+
38
+ process.once('SIGINT', () => {
39
+ clearInterval(timer);
40
+ process.exit(0);
41
+ });
42
+ }
@@ -0,0 +1,74 @@
1
+ import type { StatsData } from './monitors/types.js';
2
+
3
+ export function formatPrometheusMetrics(data: StatsData): string {
4
+ const lines: string[] = [];
5
+
6
+ // Helper to append metric family
7
+ const addGauge = (name: string, help: string, val: number | undefined, labels: Record<string, string> = {}) => {
8
+ if (val === undefined || Number.isNaN(val)) return;
9
+ lines.push(`# HELP ${name} ${help}`);
10
+ lines.push(`# TYPE ${name} gauge`);
11
+ const labelStr = Object.entries(labels).map(([k, v]) => `${k}="${v}"`).join(',');
12
+ const labelSuffix = labelStr ? `{${labelStr}}` : '';
13
+ lines.push(`${name}${labelSuffix} ${val}`);
14
+ };
15
+
16
+ // CPU metrics
17
+ addGauge('pyre_cpu_usage_percent', 'CPU usage percentage', data.cpu.usage);
18
+ addGauge('pyre_cpu_frequency_mhz', 'CPU frequency in MHz', data.cpu.frequency);
19
+ addGauge('pyre_cpu_cores_physical', 'Physical CPU core count', data.cpu.physicalCores);
20
+ addGauge('pyre_cpu_cores_logical', 'Logical CPU core count', data.cpu.cores);
21
+
22
+ if (data.cpu.loadAvg && data.cpu.loadAvg.length >= 3) {
23
+ addGauge('pyre_cpu_load_1m', '1-minute load average', data.cpu.loadAvg[0]);
24
+ addGauge('pyre_cpu_load_5m', '5-minute load average', data.cpu.loadAvg[1]);
25
+ addGauge('pyre_cpu_load_15m', '15-minute load average', data.cpu.loadAvg[2]);
26
+ }
27
+
28
+ if (data.cpu.temperature) {
29
+ addGauge('pyre_cpu_temperature_celsius', 'CPU die temperature in Celsius', data.cpu.temperature);
30
+ }
31
+
32
+ // Memory metrics
33
+ addGauge('pyre_memory_total_bytes', 'Total RAM in bytes', data.memory.total);
34
+ addGauge('pyre_memory_used_bytes', 'Used RAM in bytes', data.memory.used);
35
+ addGauge('pyre_memory_free_bytes', 'Free RAM in bytes', data.memory.free);
36
+ addGauge('pyre_memory_usage_percent', 'Memory usage percentage', data.memory.usagePercent);
37
+
38
+ if (data.memory.swapTotal) {
39
+ addGauge('pyre_memory_swap_total_bytes', 'Total swap memory in bytes', data.memory.swapTotal);
40
+ addGauge('pyre_memory_swap_used_bytes', 'Used swap memory in bytes', data.memory.swapUsed);
41
+ }
42
+
43
+ // GPU metrics
44
+ if (data.gpu) {
45
+ addGauge('pyre_gpu_utilization_percent', 'GPU utilization percentage', data.gpu.utilization, { model: data.gpu.model });
46
+ addGauge('pyre_gpu_memory_bytes', 'GPU memory size in bytes', data.gpu.memory, { model: data.gpu.model });
47
+ if (data.gpu.temperature) {
48
+ addGauge('pyre_gpu_temperature_celsius', 'GPU temperature in Celsius', data.gpu.temperature, { model: data.gpu.model });
49
+ }
50
+ }
51
+
52
+ // Power metrics
53
+ if (data.power) {
54
+ if (data.power.cpuWatts) addGauge('pyre_power_cpu_watts', 'CPU power draw in Watts', data.power.cpuWatts);
55
+ if (data.power.gpuWatts) addGauge('pyre_power_gpu_watts', 'GPU power draw in Watts', data.power.gpuWatts);
56
+ if (data.power.combinedWatts) addGauge('pyre_power_system_watts', 'Combined system power draw in Watts', data.power.combinedWatts);
57
+ }
58
+
59
+ // Battery metrics
60
+ if (data.battery) {
61
+ addGauge('pyre_battery_level_percent', 'Battery level percentage', data.battery.level);
62
+ if (data.battery.cycles) addGauge('pyre_battery_cycle_count', 'Battery cycle count', data.battery.cycles);
63
+ if (data.battery.maxCapacityPercent) addGauge('pyre_battery_max_capacity_percent', 'Battery health maximum capacity percentage', data.battery.maxCapacityPercent);
64
+ }
65
+
66
+ // Thermal metrics
67
+ addGauge('pyre_thermal_pressure_level', 'Thermal pressure level (0=Nominal, 1=Fair, 2=Serious, 3=Critical)', data.thermal.pressureLevel);
68
+
69
+ // Network metrics
70
+ addGauge('pyre_network_rx_bytes_total', 'Total network received bytes', data.network.rxBytes);
71
+ addGauge('pyre_network_tx_bytes_total', 'Total network transmitted bytes', data.network.txBytes);
72
+
73
+ return lines.join('\n') + '\n';
74
+ }
package/src/service.ts ADDED
@@ -0,0 +1,77 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { execSync } from 'node:child_process';
5
+ import chalk from 'chalk';
6
+
7
+ const PLIST_LABEL = 'com.pyre.web';
8
+ const PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
9
+
10
+ export function installLaunchdAgent(port: number = 3000): void {
11
+ const nodePath = process.execPath;
12
+ const scriptPath = path.resolve(process.argv[1]);
13
+
14
+ const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
15
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
16
+ <plist version="1.0">
17
+ <dict>
18
+ <key>Label</key>
19
+ <string>${PLIST_LABEL}</string>
20
+ <key>ProgramArguments</key>
21
+ <array>
22
+ <string>${nodePath}</string>
23
+ <string>${scriptPath}</string>
24
+ <string>web</string>
25
+ <string>--port</string>
26
+ <string>${port}</string>
27
+ </array>
28
+ <key>RunAtLoad</key>
29
+ <true/>
30
+ <key>KeepAlive</key>
31
+ <true/>
32
+ <key>StandardOutPath</key>
33
+ <string>${path.join(os.homedir(), '.config', 'pyre', 'web.log')}</string>
34
+ <key>StandardErrorPath</key>
35
+ <string>${path.join(os.homedir(), '.config', 'pyre', 'web.err')}</string>
36
+ </dict>
37
+ </plist>
38
+ `;
39
+
40
+ try {
41
+ const launchAgentsDir = path.dirname(PLIST_PATH);
42
+ if (!fs.existsSync(launchAgentsDir)) {
43
+ fs.mkdirSync(launchAgentsDir, { recursive: true });
44
+ }
45
+
46
+ fs.writeFileSync(PLIST_PATH, plistContent);
47
+ try {
48
+ execSync(`launchctl unload ${PLIST_PATH} 2>/dev/null`);
49
+ } catch {
50
+ // ignore if not previously loaded
51
+ }
52
+ execSync(`launchctl load ${PLIST_PATH}`);
53
+
54
+ console.log(chalk.green(`✔ Installed and started launchd agent at ${PLIST_PATH}`));
55
+ console.log(chalk.dim(` Service label: ${PLIST_LABEL}`));
56
+ } catch (err: any) {
57
+ console.log(chalk.red(`✖ Failed to install launchd agent: ${err.message}`));
58
+ }
59
+ }
60
+
61
+ export function uninstallLaunchdAgent(): void {
62
+ try {
63
+ if (fs.existsSync(PLIST_PATH)) {
64
+ try {
65
+ execSync(`launchctl unload ${PLIST_PATH} 2>/dev/null`);
66
+ } catch {
67
+ // ignore unload errors
68
+ }
69
+ fs.unlinkSync(PLIST_PATH);
70
+ console.log(chalk.green(`✔ Uninstalled launchd agent ${PLIST_PATH}`));
71
+ } else {
72
+ console.log(chalk.yellow(` No launchd agent found at ${PLIST_PATH}`));
73
+ }
74
+ } catch (err: any) {
75
+ console.log(chalk.red(`✖ Failed to uninstall launchd agent: ${err.message}`));
76
+ }
77
+ }
@@ -0,0 +1,59 @@
1
+ import { Worker, isMainThread, workerData } from 'node:worker_threads';
2
+ import os from 'node:os';
3
+ import chalk from 'chalk';
4
+
5
+ export interface StressOptions {
6
+ durationSec?: number;
7
+ cores?: number;
8
+ }
9
+
10
+ if (!isMainThread) {
11
+ // Worker thread busywork loop
12
+ const endTime = workerData?.endTime;
13
+ while (true) {
14
+ if (endTime && Date.now() >= endTime) {
15
+ break;
16
+ }
17
+ Math.hypot(Math.random(), Math.random());
18
+ }
19
+ process.exit(0);
20
+ }
21
+
22
+ export async function runStressCommand(opts: StressOptions = {}): Promise<void> {
23
+ const durationSec = opts.durationSec ?? 30;
24
+ const numCores = opts.cores ?? os.cpus().length;
25
+ const endTime = Date.now() + durationSec * 1000;
26
+
27
+ console.log(chalk.bold(`\n pyre stress — Synthetic Load Generator`));
28
+ console.log(chalk.dim(` Spawning ${numCores} worker threads for ${durationSec} seconds...`));
29
+ console.log(chalk.dim(` Press Ctrl+C to stop early.\n`));
30
+
31
+ const workers: Worker[] = [];
32
+
33
+ for (let i = 0; i < numCores; i++) {
34
+ const worker = new Worker(new URL(import.meta.url), {
35
+ workerData: { endTime },
36
+ });
37
+ workers.push(worker);
38
+ }
39
+
40
+ const cleanup = () => {
41
+ for (const w of workers) {
42
+ w.terminate();
43
+ }
44
+ };
45
+
46
+ process.once('SIGINT', () => {
47
+ cleanup();
48
+ console.log(chalk.yellow('\n Stress test cancelled by user.'));
49
+ process.exit(0);
50
+ });
51
+
52
+ return new Promise((resolve) => {
53
+ const timer = setTimeout(() => {
54
+ cleanup();
55
+ console.log(chalk.green(` ✔ Synthetic load test completed after ${durationSec}s.`));
56
+ resolve();
57
+ }, durationSec * 1000);
58
+ });
59
+ }
package/src/wizard.ts ADDED
@@ -0,0 +1,61 @@
1
+ import readline from 'node:readline';
2
+ import chalk from 'chalk';
3
+ import { readConfig, writeConfig, CONFIG_FILE } from './state/config.js';
4
+ import fs from 'node:fs';
5
+
6
+ export async function runSetupWizardIfNeeded(skipWizardFlag?: boolean): Promise<void> {
7
+ if (skipWizardFlag || process.env.PYRE_NO_WIZARD === '1' || !process.stdout.isTTY) {
8
+ return;
9
+ }
10
+
11
+ if (fs.existsSync(CONFIG_FILE)) {
12
+ return;
13
+ }
14
+
15
+ console.log(chalk.bold('\n 🔥 Welcome to pyre! First-run Setup Wizard\n'));
16
+ console.log(chalk.dim(' This quick 3-question setup configures your defaults.\n'));
17
+
18
+ const rl = readline.createInterface({
19
+ input: process.stdin,
20
+ output: process.stdout,
21
+ });
22
+
23
+ const ask = (query: string): Promise<string> => {
24
+ return new Promise((resolve) => rl.question(query, resolve));
25
+ };
26
+
27
+ try {
28
+ // Question 1: Theme
29
+ console.log(chalk.cyan(' 1. Pick a default color theme:'));
30
+ console.log(chalk.dim(' (1) default (2) dracula (3) cyberpunk (4) monochrome (5) nord (6) gruvbox'));
31
+ const themeAns = (await ask(chalk.yellow(' Choice [1-6] (default: 1): '))).trim();
32
+ let theme = 'default';
33
+ if (themeAns === '2') theme = 'dracula';
34
+ else if (themeAns === '3') theme = 'cyberpunk';
35
+ else if (themeAns === '4') theme = 'monochrome';
36
+ else if (themeAns === '5') theme = 'nord';
37
+ else if (themeAns === '6') theme = 'gruvbox';
38
+
39
+ // Question 2: Notifications
40
+ console.log(chalk.cyan('\n 2. Enable desktop notifications on system thermal/CPU alerts?'));
41
+ const notifAns = (await ask(chalk.yellow(' Enable notifications? [Y/n]: '))).trim().toLowerCase();
42
+ const notificationsEnabled = notifAns !== 'n';
43
+
44
+ // Question 3: Powermetrics / Sudo explanation
45
+ console.log(chalk.cyan('\n 3. Sudo / Powermetrics setup:'));
46
+ console.log(chalk.dim(' pyre reads basic metrics sudolessly. For detailed per-core wattages,'));
47
+ console.log(chalk.dim(' you can optionally grant passwordless sudo for powermetrics later.'));
48
+ await ask(chalk.yellow(' Press Enter to finish setup... '));
49
+
50
+ writeConfig({
51
+ theme,
52
+ notificationsEnabled,
53
+ });
54
+
55
+ console.log(chalk.green('\n ✔ Preferences saved to ~/.config/pyre/config.json\n'));
56
+ } catch {
57
+ // fallback if interrupted
58
+ } finally {
59
+ rl.close();
60
+ }
61
+ }