pyre-cli 9.4.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,13 +73,20 @@ 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
@@ -175,8 +185,42 @@ async function runServerCommand(): Promise<void> {
175
185
  }
176
186
 
177
187
  async function main() {
188
+ const { runSetupWizardIfNeeded } = await import('./wizard.js');
189
+ await runSetupWizardIfNeeded(!opts.wizard);
190
+
178
191
  const cmd = program.args[0];
179
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
+
180
224
  if (cmd === 'anomalies') {
181
225
  const { runAnomaliesDigest } = await import('./anomaliesCmd.js');
182
226
  runAnomaliesDigest({ since: opts.since || '7d', dir: opts.exportDir });
@@ -261,6 +305,17 @@ async function main() {
261
305
  }
262
306
 
263
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
+ }
264
319
  await runWebCommand();
265
320
  return;
266
321
  }
@@ -400,7 +455,7 @@ async function main() {
400
455
  else if (opts.md) output = formatMarkdown(data);
401
456
  else if (opts.csv) output = formatCsv(data);
402
457
  else if (opts.tsv) output = formatTsv(data);
403
- 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 } });
404
459
 
405
460
  console.log(output);
406
461
 
@@ -697,6 +752,15 @@ async function runWebCommand(): Promise<number> {
697
752
  return;
698
753
  }
699
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
+
700
764
  // 3. Real-time Server-Sent Events (SSE) stream endpoint
701
765
  if (url === '/api/stream') {
702
766
  res.writeHead(200, {
@@ -773,6 +837,7 @@ async function runWebCommand(): Promise<number> {
773
837
  console.log(chalk.dim(` Local URL: http://localhost:${actualPort}/`));
774
838
  console.log(chalk.hex('#ff6a39').bold(` Network URL: http://${ip}:${actualPort}/ (Accessible on same Wi-Fi / LAN)`));
775
839
  console.log(chalk.dim(` API: http://${ip}:${actualPort}/api`));
840
+ console.log(chalk.dim(` Metrics: http://${ip}:${actualPort}/metrics`));
776
841
  console.log(chalk.dim(` SSE Stream: http://${ip}:${actualPort}/api/stream`));
777
842
 
778
843
  resolve(actualPort);
@@ -807,9 +872,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
807
872
 
808
873
  func applicationDidFinishLaunching(_ n: Notification) {
809
874
  // Window
875
+ let isCompact = ${!!opts.compact}
876
+ let isAlwaysOnTop = ${!!opts.ontop}
810
877
  let screen = NSScreen.main?.visibleFrame ?? NSRect(x:0, y:0, width:1280, height:800)
811
- let W = min(1180.0, screen.width - 80)
812
- 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)
813
880
  let rect = NSRect(x: screen.minX + (screen.width - W) / 2,
814
881
  y: screen.minY + (screen.height - H) / 2,
815
882
  width: W, height: H)
@@ -817,8 +884,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
817
884
  window = NSWindow(contentRect: rect,
818
885
  styleMask: [.titled, .closable, .miniaturizable, .resizable],
819
886
  backing: .buffered, defer: false)
820
- window.title = "Pyre"
821
- 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
+ }
822
892
  window.appearance = NSAppearance(named: .darkAqua)
823
893
  window.backgroundColor = NSColor(white: 0, alpha: 1)
824
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
+ }