pyre-cli 10.1.0 → 10.3.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.
Files changed (46) hide show
  1. package/README.md +211 -494
  2. package/dist/{checkCmd-RKW6QGDR.js → checkCmd-WQAHAUWO.js} +1 -1
  3. package/dist/{chunk-GHRDW646.js → chunk-23YWCI3O.js} +375 -135
  4. package/dist/{service-Q74BBILO.js → chunk-KFPTD266.js} +43 -3
  5. package/dist/{doctor-GYHFTNZQ.js → chunk-KVUMOA6C.js} +7 -6
  6. package/dist/{chunk-53O2RGSL.js → chunk-QM5L43EK.js} +19 -2
  7. package/dist/{chunk-6UHZP4QP.js → chunk-XTOOEKLM.js} +24 -12
  8. package/dist/{collectors-H43PN4PS.js → collectors-JDN5Y7EV.js} +1 -1
  9. package/dist/{completions-JKYEJNGR.js → completions-ZVTNE52B.js} +15 -11
  10. package/dist/{config-GYGJXU2Q.js → config-J4YMADEF.js} +3 -1
  11. package/dist/doctor-LHFW4UJP.js +10 -0
  12. package/dist/firebaseSync-ON3RZ6KX.js +91 -0
  13. package/dist/index.js +4245 -736
  14. package/dist/monitors/platform/windows-ui.ps1 +42 -0
  15. package/dist/{netUsageCmd-WKBTW2YG.js → netUsageCmd-67IBZZYN.js} +2 -2
  16. package/dist/{pipeCmd-7REDBLBU.js → pipeCmd-2N4JPKYE.js} +1 -1
  17. package/dist/{prometheusCmd-PL64BLN5.js → prometheusCmd-LEUM5WCZ.js} +1 -1
  18. package/dist/serveCmd-L7LVJOZN.js +353 -0
  19. package/dist/service-7GIX3YNC.js +16 -0
  20. package/dist/syncCmd-42IRF633.js +246 -0
  21. package/dist/{topoCmd-DU2HHZNH.js → topoCmd-DZ3YS5IL.js} +1 -1
  22. package/dist/{watchdogCmd-UF53JPZB.js → watchdogCmd-6GR4UN6B.js} +7 -7
  23. package/dist/{wizard-TD7BGX6O.js → wizard-JQMQWFAW.js} +1 -1
  24. package/package.json +4 -3
  25. package/parity.md +2 -0
  26. package/src/completions.ts +15 -11
  27. package/src/easy_features.test.ts +149 -0
  28. package/src/firebaseSync.ts +157 -0
  29. package/src/formatters/html_template.ts +3086 -598
  30. package/src/formatters/render.ts +25 -12
  31. package/src/index.ts +518 -24
  32. package/src/live/input.ts +501 -148
  33. package/src/live/render.ts +163 -18
  34. package/src/live/state.ts +52 -2
  35. package/src/live/types.ts +1 -1
  36. package/src/monitors/collectors.ts +3 -10
  37. package/src/monitors/platform/windows-ui.ps1 +42 -0
  38. package/src/monitors/platform/windows.ts +442 -147
  39. package/src/monitors/types.ts +8 -0
  40. package/src/serveCmd.ts +17 -2
  41. package/src/service.ts +49 -0
  42. package/src/state/config.ts +48 -0
  43. package/src/syncCmd.ts +159 -11
  44. package/src/web/dashboard.ts +205 -0
  45. package/dist/serveCmd-2H5VLF3P.js +0 -153
  46. package/dist/syncCmd-A2W3QB3S.js +0 -126
package/src/index.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  import fs from 'node:fs';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
+ import crypto from 'node:crypto';
13
+ import { fileURLToPath } from 'node:url';
12
14
  import readline from 'node:readline';
13
15
  import { Command } from 'commander';
14
16
  import chalk from 'chalk';
@@ -21,7 +23,9 @@ import { runDoctor, printDoctorReport } from './doctor.js';
21
23
  import { generateZshCompletions, generateBashCompletions, generateFishCompletions, generatePowerShellCompletions } from './completions.js';
22
24
  import { runHistoryCommand } from './historyCmd.js';
23
25
  import { runDiffCommand } from './diffCmd.js';
24
- import { readConfig, CONFIG_FILE } from './state/config.js';
26
+ import { readConfig, writeConfig, resetConfig, CONFIG_FILE } from './state/config.js';
27
+ import { getServiceStatus, installService, uninstallService } from './service.js';
28
+ import { sendTestAlert } from './alertChannels.js';
25
29
  import { generateXbarPlugin } from './xbar.js';
26
30
  import { runFleetCommand } from './fleet.js';
27
31
 
@@ -80,6 +84,9 @@ program
80
84
  .option('--install', 'Install pyre web as a background launchd agent')
81
85
  .option('--uninstall', 'Uninstall pyre web launchd agent')
82
86
  .option('--port <port>', 'Port number for web server mode', '3000')
87
+ .option('--host <host>', 'Host address to bind web/UI server (e.g. 127.0.0.1 or 0.0.0.0)')
88
+ .option('--lan', 'Expose web/UI server to local network (Accessible on same Wi-Fi / LAN)')
89
+ .option('--no-lan', 'Do not expose server to local network (bind to localhost only)')
83
90
  .option('--prometheus-port <port>', 'Port number for Prometheus exporter (default: 9090)', '9090')
84
91
  .option('--speed <n>', 'Playback speed multiplier for pyre replay (default: 1)', '1')
85
92
  .option('--llm-backend <backend>', 'AI backend: builtin (local zero-setup), ollama, or openai (default: builtin)', 'builtin')
@@ -103,7 +110,7 @@ program.addHelpText('after', `
103
110
  check One-line plain-English health summary
104
111
  pipe Continuous newline-delimited JSON stream for scripting
105
112
  stress Synthetic CPU/GPU load generator
106
- ui Launch the live UI dashboard window
113
+ ui, launch ui Launch the native Activity Monitor UI dashboard window
107
114
  web Serve an auto-refreshing live web portal
108
115
  ssh <host> Stream live stats from a remote host over SSH
109
116
  fleet <host1> [host2]... Multi-host live dashboard monitoring multiple hosts
@@ -196,7 +203,7 @@ async function runServerCommand(): Promise<void> {
196
203
  }
197
204
 
198
205
  const port = 9876;
199
- const password = 'mysecret';
206
+ const password = crypto.randomBytes(12).toString('hex');
200
207
 
201
208
  console.log(chalk.bold(`\n pyre server`));
202
209
  console.log(chalk.dim(` Detected IP: ${ip}`));
@@ -527,11 +534,16 @@ async function main() {
527
534
  return;
528
535
  }
529
536
 
530
- if (cmd === 'ui') {
537
+ if (cmd === 'ui' || (cmd === 'launch' && (!program.args[1] || program.args[1] === 'ui'))) {
531
538
  await runUiCommand();
532
539
  return;
533
540
  }
534
541
 
542
+ if (cmd === 'launch' && program.args[1] === 'web') {
543
+ await runWebCommand();
544
+ return;
545
+ }
546
+
535
547
  if (cmd === 'bench') {
536
548
  const benchCmd = program.args.slice(1).join(' ');
537
549
  if (!benchCmd) {
@@ -953,10 +965,76 @@ async function runSshCommand(host: string): Promise<void> {
953
965
  });
954
966
  }
955
967
 
956
- async function runWebCommand(): Promise<number> {
968
+ async function runWebCommand(bindHost?: string): Promise<number> {
957
969
  const http = await import('node:http');
958
970
  const port = parseInt(opts.port || process.env.PORT || '3000', 10) || (opts.port === '0' ? 0 : 3000);
959
971
 
972
+ const exportDir = opts.exportDir || './pyre-exports';
973
+ const webLogSession = {
974
+ recording: false,
975
+ filePath: '',
976
+ fileName: '',
977
+ stream: null as any,
978
+ samples: 0,
979
+ startedAt: 0,
980
+ timer: null as any,
981
+ };
982
+
983
+ function writeCsvSample(data: any) {
984
+ if (!webLogSession.recording || !webLogSession.stream) return;
985
+ try {
986
+ const temp = data.cpu?.temperature ?? data.thermal?.temperatures?.cpu_die ?? '';
987
+ const rxPackets = data.network?.rxPackets ?? 0;
988
+ const txPackets = data.network?.txPackets ?? 0;
989
+ const connections = data.network?.connections ?? 0;
990
+ webLogSession.stream.write(
991
+ `${data.timestamp || new Date().toISOString()},${data.cpu?.usage ?? 0},${data.memory?.usagePercent ?? 0},${temp},${data.network?.rxBytes ?? 0},${data.network?.txBytes ?? 0},${rxPackets},${txPackets},${connections},${data.thermal?.state ?? 'Nominal'}\n`
992
+ );
993
+ webLogSession.samples++;
994
+ } catch {
995
+ // ignore write error
996
+ }
997
+ }
998
+
999
+ function startWebLogging() {
1000
+ if (webLogSession.recording) return;
1001
+ if (!fs.existsSync(exportDir)) {
1002
+ fs.mkdirSync(exportDir, { recursive: true });
1003
+ }
1004
+ const tsStr = new Date().toISOString().replace(/[:.]/g, '-');
1005
+ webLogSession.fileName = `pyre-log-${tsStr}.csv`;
1006
+ webLogSession.filePath = path.join(exportDir, webLogSession.fileName);
1007
+ webLogSession.stream = fs.createWriteStream(webLogSession.filePath, { flags: 'a' });
1008
+ webLogSession.stream.write('timestamp,cpu_usage,mem_usage_percent,temp_c,net_rx_bytes,net_tx_bytes,net_rx_packets,net_tx_packets,connections,thermal_state\n');
1009
+ webLogSession.recording = true;
1010
+ webLogSession.samples = 0;
1011
+ webLogSession.startedAt = Date.now();
1012
+
1013
+ if (webLogSession.timer) clearInterval(webLogSession.timer);
1014
+ webLogSession.timer = setInterval(async () => {
1015
+ if (!webLogSession.recording) return;
1016
+ try {
1017
+ const d = await collectAll({ detailed: true });
1018
+ writeCsvSample(d);
1019
+ } catch {
1020
+ // ignore
1021
+ }
1022
+ }, 2000);
1023
+ }
1024
+
1025
+ function stopWebLogging() {
1026
+ if (!webLogSession.recording) return;
1027
+ webLogSession.recording = false;
1028
+ if (webLogSession.timer) {
1029
+ clearInterval(webLogSession.timer);
1030
+ webLogSession.timer = null;
1031
+ }
1032
+ if (webLogSession.stream) {
1033
+ webLogSession.stream.end();
1034
+ webLogSession.stream = null;
1035
+ }
1036
+ }
1037
+
960
1038
  const server = http.createServer(async (req, res) => {
961
1039
  const url = req.url || '/';
962
1040
 
@@ -988,6 +1066,9 @@ async function runWebCommand(): Promise<number> {
988
1066
  const sendStats = async () => {
989
1067
  try {
990
1068
  const data = await collectAll({ detailed: true });
1069
+ if (webLogSession.recording && !webLogSession.timer) {
1070
+ writeCsvSample(data);
1071
+ }
991
1072
  res.write(`data: ${JSON.stringify(data)}\n\n`);
992
1073
  } catch {
993
1074
  // ignore stream collect error
@@ -1023,6 +1104,245 @@ async function runWebCommand(): Promise<number> {
1023
1104
  'Access-Control-Allow-Methods': 'GET, OPTIONS',
1024
1105
  });
1025
1106
  res.end(JSON.stringify(data, null, 2));
1107
+ } else if (url.startsWith('/api/logging/status')) {
1108
+ let fileSize = 0;
1109
+ if (webLogSession.filePath && fs.existsSync(webLogSession.filePath)) {
1110
+ try { fileSize = fs.statSync(webLogSession.filePath).size; } catch {}
1111
+ }
1112
+ res.writeHead(200, {
1113
+ 'Content-Type': 'application/json',
1114
+ 'Access-Control-Allow-Origin': '*',
1115
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
1116
+ });
1117
+ res.end(JSON.stringify({
1118
+ recording: webLogSession.recording,
1119
+ file: webLogSession.fileName,
1120
+ path: webLogSession.filePath,
1121
+ samples: webLogSession.samples,
1122
+ durationSec: webLogSession.recording ? Math.round((Date.now() - webLogSession.startedAt) / 1000) : 0,
1123
+ sizeBytes: fileSize,
1124
+ exportDir,
1125
+ }));
1126
+ } else if (url.startsWith('/api/logging/start')) {
1127
+ startWebLogging();
1128
+ res.writeHead(200, {
1129
+ 'Content-Type': 'application/json',
1130
+ 'Access-Control-Allow-Origin': '*',
1131
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
1132
+ });
1133
+ res.end(JSON.stringify({
1134
+ success: true,
1135
+ recording: true,
1136
+ file: webLogSession.fileName,
1137
+ path: webLogSession.filePath,
1138
+ }));
1139
+ } else if (url.startsWith('/api/logging/stop')) {
1140
+ const summary = {
1141
+ success: true,
1142
+ recording: false,
1143
+ file: webLogSession.fileName,
1144
+ path: webLogSession.filePath,
1145
+ samples: webLogSession.samples,
1146
+ durationSec: webLogSession.startedAt ? Math.round((Date.now() - webLogSession.startedAt) / 1000) : 0,
1147
+ };
1148
+ stopWebLogging();
1149
+ res.writeHead(200, {
1150
+ 'Content-Type': 'application/json',
1151
+ 'Access-Control-Allow-Origin': '*',
1152
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
1153
+ });
1154
+ res.end(JSON.stringify(summary));
1155
+ } else if (url.startsWith('/api/logging/history')) {
1156
+ res.writeHead(200, {
1157
+ 'Content-Type': 'application/json',
1158
+ 'Access-Control-Allow-Origin': '*',
1159
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
1160
+ });
1161
+ const files: any[] = [];
1162
+ if (fs.existsSync(exportDir)) {
1163
+ try {
1164
+ const logFiles = fs.readdirSync(exportDir)
1165
+ .filter(f => f.startsWith('pyre-log-') && f.endsWith('.csv'))
1166
+ .sort().reverse();
1167
+ for (const lf of logFiles) {
1168
+ const p = path.join(exportDir, lf);
1169
+ const st = fs.statSync(p);
1170
+ files.push({
1171
+ name: lf,
1172
+ sizeBytes: st.size,
1173
+ mtime: st.mtime.toISOString(),
1174
+ });
1175
+ }
1176
+ } catch {}
1177
+ }
1178
+ res.end(JSON.stringify({ exportDir, files }));
1179
+ } else if (url.startsWith('/api/logging/download')) {
1180
+ const parsedUrl = new URL(url, 'http://localhost');
1181
+ const filename = path.basename(parsedUrl.searchParams.get('file') || '');
1182
+ const filePath = path.join(exportDir, filename);
1183
+ if (!filename || !fs.existsSync(filePath)) {
1184
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
1185
+ res.end('File not found');
1186
+ return;
1187
+ }
1188
+ res.writeHead(200, {
1189
+ 'Content-Type': 'text/csv',
1190
+ 'Content-Disposition': `attachment; filename="${filename}"`,
1191
+ 'Access-Control-Allow-Origin': '*',
1192
+ });
1193
+ fs.createReadStream(filePath).pipe(res);
1194
+ } else if (url.startsWith('/api/export')) {
1195
+ const parsedUrl = new URL(url, 'http://localhost');
1196
+ const fmt = (parsedUrl.searchParams.get('format') || 'json').toLowerCase();
1197
+ const data = await collectAll({ detailed: true });
1198
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
1199
+ let body: string;
1200
+ let ctype = 'text/plain';
1201
+ let ext = 'txt';
1202
+ if (fmt === 'csv') {
1203
+ body = formatCsv(data);
1204
+ ctype = 'text/csv';
1205
+ ext = 'csv';
1206
+ } else if (fmt === 'tsv') {
1207
+ body = formatTsv(data);
1208
+ ctype = 'text/tab-separated-values';
1209
+ ext = 'tsv';
1210
+ } else if (fmt === 'md' || fmt === 'markdown') {
1211
+ body = formatMarkdown(data);
1212
+ ctype = 'text/markdown';
1213
+ ext = 'md';
1214
+ } else if (fmt === 'html') {
1215
+ body = formatHtml(data);
1216
+ ctype = 'text/html';
1217
+ ext = 'html';
1218
+ } else {
1219
+ body = formatJson(data);
1220
+ ctype = 'application/json';
1221
+ ext = 'json';
1222
+ }
1223
+ res.writeHead(200, {
1224
+ 'Content-Type': `${ctype}; charset=utf-8`,
1225
+ 'Content-Disposition': `attachment; filename="pyre-snapshot-${ts}.${ext}"`,
1226
+ 'Access-Control-Allow-Origin': '*',
1227
+ });
1228
+ res.end(body);
1229
+ } else if (url.startsWith('/api/kill')) {
1230
+ const parsedUrl = new URL(url, 'http://localhost');
1231
+ const pidStr = parsedUrl.searchParams.get('pid');
1232
+ const signal = (parsedUrl.searchParams.get('signal') || 'SIGTERM').toUpperCase();
1233
+ const pid = parseInt(pidStr || '', 10);
1234
+ res.writeHead(200, {
1235
+ 'Content-Type': 'application/json',
1236
+ 'Access-Control-Allow-Origin': '*',
1237
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
1238
+ });
1239
+ if (!pid || isNaN(pid)) {
1240
+ res.end(JSON.stringify({ success: false, error: 'Invalid PID' }));
1241
+ return;
1242
+ }
1243
+ try {
1244
+ process.kill(pid, signal as NodeJS.Signals);
1245
+ res.end(JSON.stringify({ success: true, pid, signal }));
1246
+ } catch (err: any) {
1247
+ res.end(JSON.stringify({ success: false, error: err.message }));
1248
+ }
1249
+ } else if (url === '/api/config' || url.startsWith('/api/config?')) {
1250
+ res.writeHead(200, {
1251
+ 'Content-Type': 'application/json',
1252
+ 'Access-Control-Allow-Origin': '*',
1253
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
1254
+ 'Access-Control-Allow-Headers': 'Content-Type',
1255
+ });
1256
+ if (req.method === 'POST') {
1257
+ let raw = '';
1258
+ req.on('data', chunk => { raw += chunk; });
1259
+ req.on('end', () => {
1260
+ try {
1261
+ const patch = raw ? JSON.parse(raw) : {};
1262
+ writeConfig(patch);
1263
+ res.end(JSON.stringify({ success: true, config: readConfig(), configFile: CONFIG_FILE }));
1264
+ } catch (e: any) {
1265
+ res.end(JSON.stringify({ success: false, error: e.message }));
1266
+ }
1267
+ });
1268
+ return;
1269
+ }
1270
+ res.end(JSON.stringify({ success: true, config: readConfig(), configFile: CONFIG_FILE }));
1271
+ } else if (url === '/api/config/reset') {
1272
+ res.writeHead(200, {
1273
+ 'Content-Type': 'application/json',
1274
+ 'Access-Control-Allow-Origin': '*',
1275
+ });
1276
+ resetConfig();
1277
+ res.end(JSON.stringify({ success: true, config: readConfig() }));
1278
+ } else if (url === '/api/service/status') {
1279
+ res.writeHead(200, {
1280
+ 'Content-Type': 'application/json',
1281
+ 'Access-Control-Allow-Origin': '*',
1282
+ });
1283
+ res.end(JSON.stringify(getServiceStatus()));
1284
+ } else if (url === '/api/service/toggle') {
1285
+ res.writeHead(200, {
1286
+ 'Content-Type': 'application/json',
1287
+ 'Access-Control-Allow-Origin': '*',
1288
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
1289
+ 'Access-Control-Allow-Headers': 'Content-Type',
1290
+ });
1291
+ let raw = '';
1292
+ req.on('data', chunk => { raw += chunk; });
1293
+ req.on('end', () => {
1294
+ try {
1295
+ const body = raw ? JSON.parse(raw) : {};
1296
+ const enable = body.enabled === true;
1297
+ if (enable) {
1298
+ installService(body.port || 3000);
1299
+ } else {
1300
+ uninstallService();
1301
+ }
1302
+ res.end(JSON.stringify({ success: true, ...getServiceStatus() }));
1303
+ } catch (e: any) {
1304
+ res.end(JSON.stringify({ success: false, error: e.message, ...getServiceStatus() }));
1305
+ }
1306
+ });
1307
+ return;
1308
+ } else if (url === '/api/alerts/test') {
1309
+ res.writeHead(200, {
1310
+ 'Content-Type': 'application/json',
1311
+ 'Access-Control-Allow-Origin': '*',
1312
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
1313
+ 'Access-Control-Allow-Headers': 'Content-Type',
1314
+ });
1315
+ let raw = '';
1316
+ req.on('data', chunk => { raw += chunk; });
1317
+ req.on('end', async () => {
1318
+ try {
1319
+ const body = raw ? JSON.parse(raw) : {};
1320
+ const cfg = readConfig();
1321
+ const channelConfig = {
1322
+ slackUrl: body.slackAlertUrl || cfg.slackAlertUrl || undefined,
1323
+ discordUrl: body.discordAlertUrl || cfg.discordAlertUrl || undefined,
1324
+ pushoverToken: body.pushoverToken || cfg.pushoverToken || undefined,
1325
+ pushoverUser: body.pushoverUser || cfg.pushoverUser || undefined,
1326
+ ntfyUrl: body.ntfyUrl || cfg.ntfyUrl || undefined,
1327
+ };
1328
+ await sendTestAlert(channelConfig);
1329
+ res.end(JSON.stringify({ success: true, message: 'Test alert dispatched' }));
1330
+ } catch (e: any) {
1331
+ res.end(JSON.stringify({ success: false, error: e.message }));
1332
+ }
1333
+ });
1334
+ return;
1335
+ } else if (url === '/api/doctor') {
1336
+ res.writeHead(200, {
1337
+ 'Content-Type': 'application/json',
1338
+ 'Access-Control-Allow-Origin': '*',
1339
+ });
1340
+ try {
1341
+ const results = await runDoctor();
1342
+ res.end(JSON.stringify({ success: true, results }));
1343
+ } catch (e: any) {
1344
+ res.end(JSON.stringify({ success: false, error: e.message }));
1345
+ }
1026
1346
  } else {
1027
1347
  res.writeHead(404);
1028
1348
  res.end('Not found');
@@ -1041,24 +1361,34 @@ async function runWebCommand(): Promise<number> {
1041
1361
  }
1042
1362
  });
1043
1363
 
1364
+ const host = bindHost || opts.host || (opts.lan === true ? '0.0.0.0' : (opts.lan === false || opts.noLan) ? '127.0.0.1' : '0.0.0.0');
1044
1365
  const ip = detectLocalIP() || 'localhost';
1366
+ const isLan = host === '0.0.0.0' || (!['127.0.0.1', 'localhost', '::1'].includes(host));
1045
1367
 
1046
1368
  return new Promise((resolve) => {
1047
- server.listen(port, '0.0.0.0', () => {
1369
+ server.listen(port, host, () => {
1048
1370
  const address = server.address();
1049
1371
  const actualPort = typeof address === 'object' && address ? address.port : port;
1050
1372
 
1051
1373
  console.log(chalk.bold(`\n pyre web server running on port ${actualPort}`));
1052
1374
  console.log(chalk.dim(` Local URL: http://localhost:${actualPort}/`));
1053
- console.log(chalk.hex('#ff6a39').bold(` Network URL: http://${ip}:${actualPort}/ (Accessible on same Wi-Fi / LAN)`));
1054
- console.log(chalk.dim(` API: http://${ip}:${actualPort}/api`));
1055
- console.log(chalk.dim(` Metrics: http://${ip}:${actualPort}/metrics`));
1056
- console.log(chalk.dim(` SSE Stream: http://${ip}:${actualPort}/api/stream`));
1375
+ if (isLan) {
1376
+ console.log(chalk.hex('#ff6a39').bold(` Network URL: http://${ip}:${actualPort}/ (Accessible on same Wi-Fi / LAN)`));
1377
+ console.log(chalk.dim(` API: http://${ip}:${actualPort}/api`));
1378
+ console.log(chalk.dim(` Metrics: http://${ip}:${actualPort}/metrics`));
1379
+ console.log(chalk.dim(` SSE Stream: http://${ip}:${actualPort}/api/stream`));
1380
+ } else {
1381
+ console.log(chalk.dim(` Network: Disabled (localhost only for security)`));
1382
+ console.log(chalk.dim(` API: http://localhost:${actualPort}/api`));
1383
+ console.log(chalk.dim(` Metrics: http://localhost:${actualPort}/metrics`));
1384
+ console.log(chalk.dim(` SSE Stream: http://localhost:${actualPort}/api/stream`));
1385
+ }
1057
1386
 
1058
1387
  resolve(actualPort);
1059
1388
  });
1060
1389
 
1061
1390
  process.once('SIGINT', () => {
1391
+ stopWebLogging();
1062
1392
  server.close();
1063
1393
  process.exit(0);
1064
1394
  });
@@ -1078,19 +1408,147 @@ async function openBrowserUrl(url: string): Promise<void> {
1078
1408
  }
1079
1409
 
1080
1410
  async function runUiCommand(): Promise<void> {
1411
+ let bindHost = '127.0.0.1';
1412
+
1413
+ if (opts.lan === true || opts.host === '0.0.0.0') {
1414
+ bindHost = '0.0.0.0';
1415
+ } else if (opts.lan === false || opts.noLan || opts.host === '127.0.0.1' || opts.host === 'localhost') {
1416
+ bindHost = '127.0.0.1';
1417
+ } else if (opts.host) {
1418
+ bindHost = opts.host;
1419
+ } else if (process.stdin.isTTY && process.stdout.isTTY) {
1420
+ const ip = detectLocalIP();
1421
+ console.log(chalk.bold('\n pyre ui'));
1422
+ console.log(chalk.yellow(' ⚠️ Security Notice: Exposing pyre allows any device on your Wi-Fi / LAN'));
1423
+ console.log(chalk.yellow(' to inspect system telemetry, export logs, and control processes.'));
1424
+ const netHint = ip ? ` (${ip})` : '';
1425
+
1426
+ const rl = readline.createInterface({
1427
+ input: process.stdin,
1428
+ output: process.stdout,
1429
+ });
1430
+ try {
1431
+ const answer = await new Promise<string>((resolve) => {
1432
+ rl.question(chalk.hex('#ff6a39').bold(` Expose to Network URL (Accessible on same Wi-Fi / LAN)${netHint}? [y/N]: `), (ans) => {
1433
+ resolve(ans.trim().toLowerCase());
1434
+ });
1435
+ rl.once('close', () => resolve(''));
1436
+ });
1437
+ if (answer === 'y' || answer === 'yes') {
1438
+ bindHost = '0.0.0.0';
1439
+ } else {
1440
+ bindHost = '127.0.0.1';
1441
+ }
1442
+ } finally {
1443
+ rl.close();
1444
+ }
1445
+ } else {
1446
+ bindHost = '127.0.0.1';
1447
+ }
1448
+
1081
1449
  // Use a random ephemeral port to avoid collisions
1082
1450
  opts.port = '0';
1083
- const port = await runWebCommand();
1451
+ const port = await runWebCommand(bindHost);
1084
1452
  const url = `http://localhost:${port}`;
1085
1453
 
1086
1454
  if (process.platform === 'darwin') {
1087
1455
  const { run } = await import('./monitors/run.js');
1088
1456
  const hasSwift = (await run('which swift 2>/dev/null', '')).trim().length > 0;
1089
1457
  if (hasSwift) {
1458
+ const isCompact = !!opts.compact;
1459
+ const isAlwaysOnTop = !!opts.ontop;
1460
+ const widthExpr = isCompact ? '420.0' : 'min(1180.0, screen.width - 80)';
1461
+ const heightExpr = isCompact ? '280.0' : 'min(820.0, screen.height - 80)';
1462
+ const windowTitle = isCompact ? 'Pyre Widget' : 'Pyre';
1463
+ const minSizeExpr = isCompact ? 'NSSize(width: 320, height: 200)' : 'NSSize(width: 860, height: 560)';
1464
+
1090
1465
  const swiftScript = `
1091
1466
  import Cocoa
1092
1467
  import WebKit
1093
1468
 
1469
+ ProcessInfo.processInfo.processName = "Pyre"
1470
+
1471
+ func makePyreIcon() -> NSImage? {
1472
+ let size = NSSize(width: 512, height: 512)
1473
+ let img = NSImage(size: size)
1474
+ img.lockFocus()
1475
+
1476
+ // 1. Apple-style squircle background
1477
+ let pad: CGFloat = 36.0
1478
+ let bgRect = NSRect(x: pad, y: pad, width: size.width - pad * 2, height: size.height - pad * 2)
1479
+ let squircle = NSBezierPath(roundedRect: bgRect, xRadius: 104, yRadius: 104)
1480
+
1481
+ // Dark obsidian/slate gradient (matching official Pyre liquid-glass design)
1482
+ if let bgGrad = NSGradient(colors: [
1483
+ NSColor(red: 0.114, green: 0.133, blue: 0.176, alpha: 1.0),
1484
+ NSColor(red: 0.039, green: 0.043, blue: 0.059, alpha: 1.0)
1485
+ ]) {
1486
+ bgGrad.draw(in: squircle, angle: 270)
1487
+ }
1488
+
1489
+ // Subtle inner rim highlight
1490
+ NSColor(white: 1.0, alpha: 0.12).setStroke()
1491
+ squircle.lineWidth = 2.5
1492
+ squircle.stroke()
1493
+
1494
+ // 2. Official Pyre Flame Vector Path (exact SVG bezier curves from Pyre design)
1495
+ func pt(_ x: CGFloat, _ y: CGFloat) -> NSPoint {
1496
+ let s: CGFloat = 3.65
1497
+ let cx: CGFloat = 256.0
1498
+ let cy: CGFloat = 256.0
1499
+ let px = cx + (x - 50.0) * s
1500
+ let py = cy - (y - 49.0) * s
1501
+ return NSPoint(x: px, y: py)
1502
+ }
1503
+
1504
+ let flame = NSBezierPath()
1505
+ flame.windingRule = .evenOdd
1506
+
1507
+ // Outer flame contour
1508
+ flame.move(to: pt(50, 5))
1509
+ flame.curve(to: pt(79, 60), controlPoint1: pt(53, 22), controlPoint2: pt(79, 35))
1510
+ flame.curve(to: pt(50, 93), controlPoint1: pt(79, 79), controlPoint2: pt(67, 93))
1511
+ flame.curve(to: pt(21, 60), controlPoint1: pt(33, 93), controlPoint2: pt(21, 79))
1512
+ flame.curve(to: pt(33, 32), controlPoint1: pt(21, 48), controlPoint2: pt(27, 40))
1513
+ flame.curve(to: pt(45, 47), controlPoint1: pt(35, 42), controlPoint2: pt(40, 47))
1514
+ flame.curve(to: pt(50, 5), controlPoint1: pt(42, 30), controlPoint2: pt(44, 16))
1515
+ flame.close()
1516
+
1517
+ // Inner flame cutout (even-odd winding rule cuts this out)
1518
+ flame.move(to: pt(50, 50))
1519
+ flame.curve(to: pt(64, 74), controlPoint1: pt(58, 60), controlPoint2: pt(64, 67))
1520
+ flame.curve(to: pt(50, 87), controlPoint1: pt(64, 82), controlPoint2: pt(58, 87))
1521
+ flame.curve(to: pt(36, 74), controlPoint1: pt(42, 87), controlPoint2: pt(36, 82))
1522
+ flame.curve(to: pt(50, 50), controlPoint1: pt(36, 67), controlPoint2: pt(42, 60))
1523
+ flame.close()
1524
+
1525
+ // Drop shadow under flame
1526
+ let ctx = NSGraphicsContext.current
1527
+ ctx?.saveGraphicsState()
1528
+ let shadow = NSShadow()
1529
+ shadow.shadowColor = NSColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 0.45)
1530
+ shadow.shadowOffset = NSSize(width: 0, height: -8)
1531
+ shadow.shadowBlurRadius = 16
1532
+ shadow.set()
1533
+
1534
+ // Silver-white gradient fill for the flame mark
1535
+ if let flameGrad = NSGradient(colors: [
1536
+ NSColor(white: 1.0, alpha: 1.0),
1537
+ NSColor(red: 0.60, green: 0.64, blue: 0.72, alpha: 1.0)
1538
+ ]) {
1539
+ flameGrad.draw(in: flame, angle: 270)
1540
+ }
1541
+ ctx?.restoreGraphicsState()
1542
+
1543
+ // Top glint highlight
1544
+ let glintRect = NSRect(x: 64, y: 468, width: size.width - 128, height: 1.5)
1545
+ NSColor(white: 1.0, alpha: 0.22).setFill()
1546
+ NSBezierPath(roundedRect: glintRect, xRadius: 1, yRadius: 1).fill()
1547
+
1548
+ img.unlockFocus()
1549
+ return img
1550
+ }
1551
+
1094
1552
  // WKWebView subclass that allows the window to be dragged from any pixel
1095
1553
  class DraggableWebView: WKWebView {
1096
1554
  override var mouseDownCanMoveWindow: Bool { return true }
@@ -1103,12 +1561,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
1103
1561
  let serverPort = ${port}
1104
1562
 
1105
1563
  func applicationDidFinishLaunching(_ n: Notification) {
1564
+ // Main Application Menu
1565
+ let mainMenu = NSMenu()
1566
+ let appMenuItem = NSMenuItem()
1567
+ mainMenu.addItem(appMenuItem)
1568
+ let appMenu = NSMenu(title: "Pyre")
1569
+ appMenuItem.submenu = appMenu
1570
+ appMenu.addItem(withTitle: "About Pyre", action: nil, keyEquivalent: "")
1571
+ appMenu.addItem(NSMenuItem.separator())
1572
+ appMenu.addItem(withTitle: "Quit Pyre", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
1573
+ NSApp.mainMenu = mainMenu
1574
+
1106
1575
  // Window
1107
- let isCompact = ${!!opts.compact}
1108
- let isAlwaysOnTop = ${!!opts.ontop}
1109
1576
  let screen = NSScreen.main?.visibleFrame ?? NSRect(x:0, y:0, width:1280, height:800)
1110
- let W = isCompact ? 420.0 : min(1180.0, screen.width - 80)
1111
- let H = isCompact ? 280.0 : min(820.0, screen.height - 80)
1577
+ let W = ${widthExpr}
1578
+ let H = ${heightExpr}
1112
1579
  let rect = NSRect(x: screen.minX + (screen.width - W) / 2,
1113
1580
  y: screen.minY + (screen.height - H) / 2,
1114
1581
  width: W, height: H)
@@ -1116,12 +1583,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
1116
1583
  window = NSWindow(contentRect: rect,
1117
1584
  styleMask: [.titled, .closable, .miniaturizable, .resizable],
1118
1585
  backing: .buffered, defer: false)
1119
- window.title = isCompact ? "Pyre Widget" : "Pyre"
1120
- window.minSize = isCompact ? NSSize(width: 320, height: 200) : NSSize(width: 800, height: 600)
1121
- if isAlwaysOnTop {
1122
- window.level = .floating
1123
- }
1124
- window.appearance = NSAppearance(named: .darkAqua)
1586
+ window.title = "${windowTitle}"
1587
+ window.minSize = ${minSizeExpr}
1588
+ ${isAlwaysOnTop ? ' window.level = .floating\n' : ''} window.appearance = NSAppearance(named: .darkAqua)
1125
1589
  window.backgroundColor = NSColor(white: 0, alpha: 1)
1126
1590
  window.isReleasedWhenClosed = false
1127
1591
  window.delegate = self
@@ -1135,12 +1599,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
1135
1599
 
1136
1600
  // Load the dashboard HTML from the local server
1137
1601
  var req = URLRequest(url: URL(string: "http://localhost:\\(serverPort)/")!)
1138
- req.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
1602
+ req.cachePolicy = .reloadIgnoringLocalCacheData
1139
1603
  webView.load(req)
1140
1604
 
1141
1605
  window.contentView?.addSubview(webView)
1142
1606
  window.makeKeyAndOrderFront(nil)
1143
1607
  NSApp.activate(ignoringOtherApps: true)
1608
+ let icon = makePyreIcon()
1609
+ NSApp.applicationIconImage = icon
1610
+ NSApp.dockTile.display()
1144
1611
 
1145
1612
  // Start native data timer (URLSession has full network access — no sandbox issues)
1146
1613
  // We wait 1.5 s for the page to load, then start pushing data every 2 s.
@@ -1195,7 +1662,7 @@ app.run()
1195
1662
 
1196
1663
  const { spawn } = await import('node:child_process');
1197
1664
  console.log(chalk.cyan(`\n Launching native UI window...`));
1198
- const ui = spawn('swift', [scriptPath], { stdio: 'inherit' });
1665
+ const ui = spawn('swift', ['-suppress-warnings', scriptPath], { stdio: 'inherit' });
1199
1666
 
1200
1667
  ui.on('close', () => {
1201
1668
  console.log(chalk.dim('UI window closed, shutting down server...'));
@@ -1210,7 +1677,34 @@ app.run()
1210
1677
  }
1211
1678
  }
1212
1679
 
1213
- // Cross-platform browser / app-mode launch for Linux, Windows, or macOS without Swift
1680
+ if (process.platform === 'win32') {
1681
+ const scriptPath = path.join(
1682
+ path.dirname(fileURLToPath(import.meta.url)),
1683
+ 'monitors',
1684
+ 'platform',
1685
+ 'windows-ui.ps1'
1686
+ );
1687
+ const { spawn } = await import('node:child_process');
1688
+ console.log(chalk.cyan(`\n Launching native UI window...`));
1689
+ const ui = spawn(
1690
+ 'powershell.exe',
1691
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, '-Port', String(port)],
1692
+ { stdio: 'inherit' }
1693
+ );
1694
+
1695
+ ui.on('close', () => {
1696
+ console.log(chalk.dim('UI window closed, shutting down server...'));
1697
+ process.exit(0);
1698
+ });
1699
+
1700
+ process.once('SIGINT', () => {
1701
+ ui.kill();
1702
+ process.exit(0);
1703
+ });
1704
+ return;
1705
+ }
1706
+
1707
+ // Cross-platform browser / app-mode launch for Linux, or macOS without Swift
1214
1708
  console.log(chalk.cyan(`\n Opening live UI dashboard at ${url}...`));
1215
1709
  await openBrowserUrl(url);
1216
1710
  console.log(chalk.dim(' Press Ctrl+C to stop server.\n'));