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