pyre-cli 6.0.0 → 6.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pyre-cli",
3
- "version": "6.0.0",
3
+ "version": "6.1.0",
4
4
  "description": "CLI Mac monitoring tool: temps, system stats, live dashboard, packet monitor, battery predictor, export",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -0,0 +1,118 @@
1
+ export function generateZshCompletions(): string {
2
+ return `#compdef pyre
3
+
4
+ _pyre() {
5
+ local -a commands
6
+ commands=(
7
+ 'live:Start interactive live monitoring dashboard'
8
+ 'info:Display static system overview'
9
+ 'doctor:Run system permissions and diagnostic checks'
10
+ 'web:Launch web dashboard server'
11
+ 'config:Show or reset configuration options'
12
+ 'p2p:P2P streaming server or client'
13
+ 'ssh:Stream metrics over SSH'
14
+ 'bench:Benchmark command while logging system stats'
15
+ 'completions:Generate shell completion scripts'
16
+ )
17
+
18
+ _arguments -s \\
19
+ '(-j --json)'{-j,--json}'[Output snapshot as JSON]' \\
20
+ '--html[Output snapshot as HTML]' \\
21
+ '--md[Output snapshot as Markdown]' \\
22
+ '(-c --csv)'{-c,--csv}'[Output snapshot as CSV]' \\
23
+ '(-t --tsv)'{-t,--tsv}'[Output snapshot as TSV]' \\
24
+ '--detailed[Include detailed system info and sensor readings]' \\
25
+ '--theme[Default theme]:theme:(default dracula cyberpunk monochrome nord gruvbox)' \\
26
+ '--interval[Refresh interval in seconds]:seconds:' \\
27
+ '--once[Show a single static snapshot]' \\
28
+ '--out[Write snapshot output to file]:file:_files' \\
29
+ '--export-dir[Directory for snapshot exports and logs]:directory:_files -/' \\
30
+ '--log[Start continuous CSV logging]' \\
31
+ '--tree[Show process tree view]' \\
32
+ '--sort[Sort processes by]:key:(cpu mem pid user command state threads runtime)' \\
33
+ '--packets[Include packet monitor panel]' \\
34
+ '--limit[Max processes to include]:count:' \\
35
+ '--alert-cpu[CPU alert threshold pct]:pct:' \\
36
+ '--alert-temp[CPU alert threshold temp C]:temp:' \\
37
+ '--temp-unit[Temperature display unit]:unit:(c f)' \\
38
+ '*::command:->command'
39
+
40
+ case $state in
41
+ command)
42
+ _describe -t commands 'pyre command' commands
43
+ ;;
44
+ esac
45
+ }
46
+
47
+ _pyre "$@"
48
+ `;
49
+ }
50
+
51
+ export function generateBashCompletions(): string {
52
+ return `# bash completion for pyre
53
+
54
+ _pyre_completions() {
55
+ local cur prev opts commands
56
+ COMPREPLY=()
57
+ cur="\${COMP_WORDS[COMP_CWORD]}"
58
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
59
+ commands="live info doctor web config p2p ssh bench completions"
60
+ opts="--json --html --md --csv --tsv --detailed --theme --interval --once --out --export-dir --log --tree --sort --packets --limit --alert-cpu --alert-temp --temp-unit"
61
+
62
+ case "\${prev}" in
63
+ --theme)
64
+ COMPREPLY=( $(compgen -W "default dracula cyberpunk monochrome nord gruvbox" -- \${cur}) )
65
+ return 0
66
+ ;;
67
+ --sort)
68
+ COMPREPLY=( $(compgen -W "cpu mem pid user command state threads runtime" -- \${cur}) )
69
+ return 0
70
+ ;;
71
+ --temp-unit)
72
+ COMPREPLY=( $(compgen -W "c f" -- \${cur}) )
73
+ return 0
74
+ ;;
75
+ config)
76
+ COMPREPLY=( $(compgen -W "show reset" -- \${cur}) )
77
+ return 0
78
+ ;;
79
+ p2p)
80
+ COMPREPLY=( $(compgen -W "server connect" -- \${cur}) )
81
+ return 0
82
+ ;;
83
+ esac
84
+
85
+ if [[ \${cur} == -* ]] ; then
86
+ COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) )
87
+ return 0
88
+ fi
89
+
90
+ COMPREPLY=( $(compgen -W "\${commands}" -- \${cur}) )
91
+ }
92
+
93
+ complete -F _pyre_completions pyre
94
+ `;
95
+ }
96
+
97
+ export function generateFishCompletions(): string {
98
+ return `# fish completion for pyre
99
+
100
+ complete -c pyre -n "__fish_use_subcommand" -a live -d "Start interactive live monitoring dashboard"
101
+ complete -c pyre -n "__fish_use_subcommand" -a info -d "Display static system overview"
102
+ complete -c pyre -n "__fish_use_subcommand" -a doctor -d "Run system diagnostics"
103
+ complete -c pyre -n "__fish_use_subcommand" -a web -d "Launch web dashboard server"
104
+ complete -c pyre -n "__fish_use_subcommand" -a config -d "Show or reset configuration options"
105
+ complete -c pyre -n "__fish_use_subcommand" -a p2p -d "P2P streaming mode"
106
+ complete -c pyre -n "__fish_use_subcommand" -a completions -d "Generate shell completions"
107
+
108
+ complete -c pyre -l json -s j -d "Output snapshot as JSON"
109
+ complete -c pyre -l html -d "Output snapshot as HTML"
110
+ complete -c pyre -l md -d "Output snapshot as Markdown"
111
+ complete -c pyre -l csv -s c -d "Output snapshot as CSV"
112
+ complete -c pyre -l tsv -s t -d "Output snapshot as TSV"
113
+ complete -c pyre -l detailed -d "Include detailed system info and sensor readings"
114
+ complete -c pyre -l theme -x -a "default dracula cyberpunk monochrome nord gruvbox" -d "Set color theme"
115
+ complete -c pyre -l sort -x -a "cpu mem pid user command state threads runtime" -d "Sort processes by key"
116
+ complete -c pyre -l temp-unit -x -a "c f" -d "Temperature unit"
117
+ `;
118
+ }
package/src/diffCmd.ts ADDED
@@ -0,0 +1,36 @@
1
+ import fs from 'node:fs';
2
+ import chalk from 'chalk';
3
+ import type { StatsData } from './monitors/types.js';
4
+
5
+ export function runDiffCommand(file1: string, file2: string): void {
6
+ console.log(chalk.bold(`\n pyre diff — Snapshot Comparison\n`));
7
+ console.log(chalk.dim(` Snapshot A: ${file1}`));
8
+ console.log(chalk.dim(` Snapshot B: ${file2}\n`));
9
+
10
+ try {
11
+ const dataA: StatsData = JSON.parse(fs.readFileSync(file1, 'utf-8'));
12
+ const dataB: StatsData = JSON.parse(fs.readFileSync(file2, 'utf-8'));
13
+
14
+ const diffNum = (label: string, valA: number, valB: number, unit = '') => {
15
+ const delta = valB - valA;
16
+ const sign = delta > 0 ? '+' : '';
17
+ const color = delta > 0 ? chalk.red : delta < 0 ? chalk.green : chalk.dim;
18
+ console.log(` ${label.padEnd(20)} ${valA.toFixed(1)}${unit} ➔ ${valB.toFixed(1)}${unit} (${color(sign + delta.toFixed(1) + unit)})`);
19
+ };
20
+
21
+ diffNum('CPU Usage', dataA.cpu.usage, dataB.cpu.usage, '%');
22
+ diffNum('Memory Usage', dataA.memory.usagePercent, dataB.memory.usagePercent, '%');
23
+ if (dataA.cpu.temperature && dataB.cpu.temperature) {
24
+ diffNum('CPU Temp', dataA.cpu.temperature, dataB.cpu.temperature, '°C');
25
+ }
26
+ if (dataA.gpu && dataB.gpu) {
27
+ diffNum('GPU Util', dataA.gpu.utilization, dataB.gpu.utilization, '%');
28
+ }
29
+ diffNum('Network RX Packets', dataA.network.rxPackets, dataB.network.rxPackets);
30
+ diffNum('Network TX Packets', dataA.network.txPackets, dataB.network.txPackets);
31
+
32
+ console.log();
33
+ } catch (err: any) {
34
+ console.log(chalk.red(`Failed to compare snapshots: ${err.message}`));
35
+ }
36
+ }
package/src/doctor.ts ADDED
@@ -0,0 +1,123 @@
1
+ import chalk from 'chalk';
2
+ import { run } from './monitors/run.js';
3
+ import { readConfig, CONFIG_FILE } from './state/config.js';
4
+
5
+ export interface DoctorCheck {
6
+ name: string;
7
+ status: 'ok' | 'warn' | 'error';
8
+ message: string;
9
+ details?: string;
10
+ }
11
+
12
+ export async function runDoctor(): Promise<DoctorCheck[]> {
13
+ const checks: DoctorCheck[] = [];
14
+
15
+ // 1. Sudo / Powermetrics Access
16
+ try {
17
+ const pmResult = await run('sudo -n powermetrics --samplers cpu_power -n 1 2>&1', '');
18
+ if (pmResult.includes('Password:') || pmResult.includes('Permission denied') || pmResult.includes('sudo: a password is required')) {
19
+ checks.push({
20
+ name: 'Sudo / Powermetrics',
21
+ status: 'warn',
22
+ message: 'Passwordless sudo for powermetrics is not configured.',
23
+ details: 'Powermetrics allows high-precision GPU & CPU power sampling. Run with sudo or configure passwordless sudo in /etc/sudoers.',
24
+ });
25
+ } else {
26
+ checks.push({
27
+ name: 'Sudo / Powermetrics',
28
+ status: 'ok',
29
+ message: 'Powermetrics access available.',
30
+ });
31
+ }
32
+ } catch {
33
+ checks.push({
34
+ name: 'Sudo / Powermetrics',
35
+ status: 'warn',
36
+ message: 'Powermetrics check failed.',
37
+ });
38
+ }
39
+
40
+ // 2. TCC / System Permissions
41
+ try {
42
+ const psCheck = await run('ps -ax -o pid,user,command', '');
43
+ if (psCheck.length > 0) {
44
+ checks.push({
45
+ name: 'TCC / Process Visibility',
46
+ status: 'ok',
47
+ message: 'Process listing access available.',
48
+ });
49
+ } else {
50
+ checks.push({
51
+ name: 'TCC / Process Visibility',
52
+ status: 'warn',
53
+ message: 'Restricted process visibility.',
54
+ details: 'Grant Full Disk Access or Terminal permissions in System Settings -> Privacy & Security.',
55
+ });
56
+ }
57
+ } catch {
58
+ checks.push({
59
+ name: 'TCC / Process Visibility',
60
+ status: 'error',
61
+ message: 'Unable to list system processes.',
62
+ });
63
+ }
64
+
65
+ // 3. Network Reachability for P2P
66
+ try {
67
+ const netCheck = await run('netstat -rn', '');
68
+ if (netCheck.includes('default') || netCheck.includes('gateway')) {
69
+ checks.push({
70
+ name: 'Network Reachability',
71
+ status: 'ok',
72
+ message: 'Network interface active with default gateway.',
73
+ });
74
+ } else {
75
+ checks.push({
76
+ name: 'Network Reachability',
77
+ status: 'warn',
78
+ message: 'No active network route detected for P2P mode.',
79
+ });
80
+ }
81
+ } catch {
82
+ checks.push({
83
+ name: 'Network Reachability',
84
+ status: 'warn',
85
+ message: 'Could not verify network routes.',
86
+ });
87
+ }
88
+
89
+ // 4. Config file integrity
90
+ try {
91
+ const cfg = readConfig();
92
+ checks.push({
93
+ name: 'Configuration File',
94
+ status: 'ok',
95
+ message: `Config file valid (${CONFIG_FILE}).`,
96
+ });
97
+ } catch (err: any) {
98
+ checks.push({
99
+ name: 'Configuration File',
100
+ status: 'error',
101
+ message: 'Failed to parse config file.',
102
+ details: err.message,
103
+ });
104
+ }
105
+
106
+ return checks;
107
+ }
108
+
109
+ export function printDoctorReport(checks: DoctorCheck[]): void {
110
+ console.log(chalk.bold('\n pyre doctor — System Diagnostics\n'));
111
+
112
+ for (const check of checks) {
113
+ let icon = chalk.green('✔');
114
+ if (check.status === 'warn') icon = chalk.yellow('⚠');
115
+ if (check.status === 'error') icon = chalk.red('✖');
116
+
117
+ console.log(` ${icon} ${chalk.bold(check.name.padEnd(26))} ${check.message}`);
118
+ if (check.details) {
119
+ console.log(chalk.dim(` ↳ ${check.details}`));
120
+ }
121
+ }
122
+ console.log();
123
+ }
@@ -714,7 +714,7 @@ function btopCpuBox(data: StatsData, width: number, theme: ThemeColors, opts: Ta
714
714
  if (data.cpu.coreUsage && data.cpu.coreUsage.length) {
715
715
  const coreColW = Math.floor((rightW - 1) / 2);
716
716
  const half = Math.ceil(data.cpu.coreUsage.length / 2);
717
- for (let i = 0; i < Math.min(2, half); i++) {
717
+ for (let i = 0; i < half; i++) {
718
718
  const u1 = data.cpu.coreUsage[i];
719
719
  const u2 = data.cpu.coreUsage[i + half];
720
720
  const c1BarW = Math.max(3, coreColW - 8);
@@ -763,9 +763,18 @@ function btopLeftColumn(data: StatsData, width: number, theme: ThemeColors, opts
763
763
  for (const d of data.disk.slice(0, 2)) {
764
764
  const capNum = parseInt(d.capacity, 10) || 0;
765
765
  const mountShort = truncatePlain(d.mountpoint, 8);
766
- diskLines.push(`${mountShort} ${d.used}/${d.size} [${gaugeBar(capNum, Math.max(3, contentWidth - 18))}] ${d.capacity}`);
766
+ let ioStr = '';
767
+ if (d.readBytesSec !== undefined || d.writeBytesSec !== undefined) {
768
+ const rStr = formatBytes(d.readBytesSec || 0);
769
+ const wStr = formatBytes(d.writeBytesSec || 0);
770
+ ioStr = ` R:${rStr}/s W:${wStr}/s`;
771
+ }
772
+ diskLines.push(`${mountShort} ${d.used}/${d.size} [${gaugeBar(capNum, Math.max(3, contentWidth - 18 - ioStr.length))}] ${d.capacity}${ioStr}`);
767
773
  }
768
- out.push(...panel('0 disk io', diskLines, width, theme.disk, theme.border, diskLines.length));
774
+ const totalRead = data.disk.reduce((acc, d) => acc + (d.readBytesSec || 0), 0);
775
+ const totalWrite = data.disk.reduce((acc, d) => acc + (d.writeBytesSec || 0), 0);
776
+ const diskHeaderRight = `R:${formatBytes(totalRead)}/s W:${formatBytes(totalWrite)}/s`;
777
+ out.push(...panel('0 disk io', diskLines, width, theme.disk, theme.border, diskLines.length, diskHeaderRight));
769
778
  }
770
779
 
771
780
  // --- Box 3: 7 net ---
@@ -8,7 +8,11 @@
8
8
  */
9
9
  import chalk from 'chalk';
10
10
 
11
- export type ThemeName = 'default' | 'dracula' | 'cyberpunk' | 'monochrome' | 'nord' | 'gruvbox';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ export type ThemeName = string;
12
16
 
13
17
  export interface ThemeColors {
14
18
  border: (s: string) => string;
@@ -24,16 +28,7 @@ export interface ThemeColors {
24
28
  process: (s: string) => string;
25
29
  }
26
30
 
27
- /**
28
- * Built-in colour themes.
29
- *
30
- * - **default** — muted greys and cyan accents, safe for any terminal.
31
- * - **dracula** — the popular Dracula dark palette.
32
- * - **cyberpunk** — high-contrast neon on black.
33
- * - **monochrome** — white-only, useful for colour-blind users or
34
- * terminals without 256-colour support.
35
- */
36
- export const THEMES: Record<ThemeName, ThemeColors> = {
31
+ export const BUILTIN_THEMES: Record<string, ThemeColors> = {
37
32
  default: {
38
33
  border: chalk.hex('#4b5563'),
39
34
  cpu: chalk.hex('#22d3ee'),
@@ -112,4 +107,44 @@ export const THEMES: Record<ThemeName, ThemeColors> = {
112
107
  graphs: chalk.hex('#fb4934'),
113
108
  process: chalk.hex('#ebdbb2'),
114
109
  },
115
- };
110
+ };
111
+
112
+ export function loadCustomThemes(): Record<string, ThemeColors> {
113
+ const themes = { ...BUILTIN_THEMES };
114
+ const themesDir = path.join(os.homedir(), '.config', 'pyre', 'themes');
115
+ try {
116
+ if (fs.existsSync(themesDir)) {
117
+ const files = fs.readdirSync(themesDir);
118
+ for (const file of files) {
119
+ if (file.endsWith('.json')) {
120
+ const name = path.basename(file, '.json');
121
+ try {
122
+ const raw = fs.readFileSync(path.join(themesDir, file), 'utf-8');
123
+ const data = JSON.parse(raw);
124
+ const buildColor = (val?: string) => (val ? (val.startsWith('#') ? chalk.hex(val) : (chalk as any)[val] || chalk.white) : chalk.white);
125
+ themes[name] = {
126
+ border: buildColor(data.border),
127
+ cpu: buildColor(data.cpu),
128
+ mem: buildColor(data.mem),
129
+ gpu: buildColor(data.gpu),
130
+ power: buildColor(data.power),
131
+ battery: buildColor(data.battery),
132
+ thermal: buildColor(data.thermal),
133
+ network: buildColor(data.network),
134
+ disk: buildColor(data.disk),
135
+ graphs: buildColor(data.graphs),
136
+ process: buildColor(data.process),
137
+ };
138
+ } catch {
139
+ // ignore bad theme file
140
+ }
141
+ }
142
+ }
143
+ }
144
+ } catch {
145
+ // ignore directory read error
146
+ }
147
+ return themes;
148
+ }
149
+
150
+ export const THEMES: Record<string, ThemeColors> = loadCustomThemes();
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import chalk from 'chalk';
4
+ import { sparkline } from './sparkline.js';
5
+
6
+ export interface HistoryOptions {
7
+ days?: number;
8
+ dir?: string;
9
+ }
10
+
11
+ export function runHistoryCommand(opts: HistoryOptions = {}): void {
12
+ const days = opts.days || 7;
13
+ const dir = opts.dir || './pyre-exports';
14
+
15
+ console.log(chalk.bold(`\n pyre history — Past ${days} Days Trend\n`));
16
+
17
+ if (!fs.existsSync(dir)) {
18
+ console.log(chalk.yellow(` No logs directory found at ${dir}`));
19
+ return;
20
+ }
21
+
22
+ const files = fs.readdirSync(dir).filter(f => f.startsWith('pyre-log-') && f.endsWith('.csv'));
23
+ if (files.length === 0) {
24
+ console.log(chalk.yellow(` No CSV logs found in ${dir}`));
25
+ return;
26
+ }
27
+
28
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
29
+ const cpuPoints: number[] = [];
30
+ const memPoints: number[] = [];
31
+ const tempPoints: number[] = [];
32
+
33
+ for (const file of files.sort()) {
34
+ const fullPath = path.join(dir, file);
35
+ try {
36
+ const content = fs.readFileSync(fullPath, 'utf-8');
37
+ const lines = content.split('\n').slice(1);
38
+ for (const line of lines) {
39
+ if (!line.trim()) continue;
40
+ const parts = line.split(',');
41
+ if (parts.length >= 4) {
42
+ const tsStr = parts[0];
43
+ const ts = new Date(tsStr).getTime();
44
+ if (!isNaN(ts) && ts >= cutoff) {
45
+ const cpu = parseFloat(parts[1]);
46
+ const mem = parseFloat(parts[2]);
47
+ const temp = parseFloat(parts[3]);
48
+ if (!isNaN(cpu)) cpuPoints.push(cpu);
49
+ if (!isNaN(mem)) memPoints.push(mem);
50
+ if (!isNaN(temp)) tempPoints.push(temp);
51
+ }
52
+ }
53
+ }
54
+ } catch {
55
+ // ignore
56
+ }
57
+ }
58
+
59
+ if (cpuPoints.length === 0) {
60
+ console.log(chalk.yellow(` No historical entries recorded within the last ${days} days.`));
61
+ return;
62
+ }
63
+
64
+ console.log(` Data Points: ${cpuPoints.length} logged snapshots`);
65
+ console.log(` CPU Usage: ${sparkline(cpuPoints, { min: 0, max: 100 })} (${(cpuPoints.reduce((a, b) => a + b, 0) / cpuPoints.length).toFixed(1)}% avg)`);
66
+ console.log(` Memory: ${sparkline(memPoints, { min: 0, max: 100 })} (${(memPoints.reduce((a, b) => a + b, 0) / memPoints.length).toFixed(1)}% avg)`);
67
+ if (tempPoints.length) {
68
+ console.log(` Temperature: ${sparkline(tempPoints)} (${(tempPoints.reduce((a, b) => a + b, 0) / tempPoints.length).toFixed(1)}°C avg)`);
69
+ }
70
+ console.log();
71
+ }
package/src/index.ts CHANGED
@@ -17,8 +17,11 @@ import { formatTable, formatJson, formatCsv, formatTsv, formatHtml, formatMarkdo
17
17
  import { startLive, stopLive } from './live/index.js';
18
18
  import { showSplash } from './splash.js';
19
19
  import { P2PServer, P2PClient } from './p2p/index.js';
20
- import { readConfig } from './state/config.js';
21
- import { CONFIG_FILE } from './state/config.js';
20
+ import { runDoctor, printDoctorReport } from './doctor.js';
21
+ import { generateZshCompletions, generateBashCompletions, generateFishCompletions } from './completions.js';
22
+ import { runHistoryCommand } from './historyCmd.js';
23
+ import { runDiffCommand } from './diffCmd.js';
24
+ import { readConfig, CONFIG_FILE } from './state/config.js';
22
25
 
23
26
  const config = readConfig();
24
27
 
@@ -62,7 +65,10 @@ program
62
65
  .option('--p2p-deny <ips>', 'Comma-separated list of denied IPs')
63
66
  .option('--p2p-audit-log <dir>', 'Directory for P2P audit logs')
64
67
  .option('--p2p-hmac-key <key>', 'HMAC key for message signing (default: derived from password)')
65
- .option('--port <port>', 'Port number for web server mode', '3000');
68
+ .option('--port <port>', 'Port number for web server mode', '3000')
69
+ .option('--web-auth', 'Enable Basic Authentication for web server')
70
+ .option('--web-user <user>', 'Username for web server authentication', 'admin')
71
+ .option('--web-pass <pass>', 'Password for web server authentication', 'pyre');
66
72
 
67
73
  program.parse(process.argv);
68
74
 
@@ -123,8 +129,21 @@ async function runServerCommand(): Promise<void> {
123
129
  async function main() {
124
130
  const cmd = program.args[0];
125
131
 
126
- if (cmd === 'info') {
127
- await runInfoCommand();
132
+ if (cmd === 'history') {
133
+ const daysIdx = program.args.indexOf('--days');
134
+ const days = daysIdx !== -1 ? parseInt(program.args[daysIdx + 1], 10) || 7 : 7;
135
+ runHistoryCommand({ days });
136
+ return;
137
+ }
138
+
139
+ if (cmd === 'diff') {
140
+ const file1 = program.args[1];
141
+ const file2 = program.args[2];
142
+ if (!file1 || !file2) {
143
+ console.log(chalk.red('Usage: pyre diff <snapshot1.json> <snapshot2.json>'));
144
+ process.exit(1);
145
+ }
146
+ runDiffCommand(file1, file2);
128
147
  return;
129
148
  }
130
149
 
@@ -491,9 +510,71 @@ async function runSshCommand(host: string): Promise<void> {
491
510
  async function runWebCommand(): Promise<void> {
492
511
  const http = await import('node:http');
493
512
  const port = parseInt(opts.port || process.env.PORT || '3000', 10) || 3000;
513
+ const authEnabled = !!opts.webAuth;
514
+ const authUser = opts.webUser || 'admin';
515
+ const authPass = opts.webPass || 'pyre';
494
516
 
495
517
  const server = http.createServer(async (req, res) => {
518
+ // 1. Basic Authentication Check
519
+ if (authEnabled) {
520
+ const authHeader = req.headers.authorization;
521
+ if (!authHeader || !authHeader.startsWith('Basic ')) {
522
+ res.writeHead(401, {
523
+ 'WWW-Authenticate': 'Basic realm="pyre Web Dashboard"',
524
+ 'Content-Type': 'text/plain',
525
+ });
526
+ res.end('401 Unauthorized');
527
+ return;
528
+ }
529
+ const credentials = Buffer.from(authHeader.split(' ')[1], 'base64').toString('utf-8');
530
+ const [user, pass] = credentials.split(':');
531
+ if (user !== authUser || pass !== authPass) {
532
+ res.writeHead(401, {
533
+ 'WWW-Authenticate': 'Basic realm="pyre Web Dashboard"',
534
+ 'Content-Type': 'text/plain',
535
+ });
536
+ res.end('401 Unauthorized');
537
+ return;
538
+ }
539
+ }
540
+
496
541
  const url = req.url || '/';
542
+
543
+ // 2. Health check endpoint
544
+ if (url === '/health' || url === '/api/health') {
545
+ res.writeHead(200, { 'Content-Type': 'application/json' });
546
+ res.end(JSON.stringify({ status: 'ok', uptime: process.uptime(), timestamp: new Date().toISOString() }));
547
+ return;
548
+ }
549
+
550
+ // 3. Real-time Server-Sent Events (SSE) stream endpoint
551
+ if (url === '/api/stream') {
552
+ res.writeHead(200, {
553
+ 'Content-Type': 'text/event-stream',
554
+ 'Cache-Control': 'no-cache',
555
+ 'Connection': 'keep-alive',
556
+ 'Access-Control-Allow-Origin': '*',
557
+ });
558
+
559
+ const sendStats = async () => {
560
+ try {
561
+ const data = await collectAll({ detailed: true });
562
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
563
+ } catch {
564
+ // ignore stream collect error
565
+ }
566
+ };
567
+
568
+ sendStats();
569
+ const intervalId = setInterval(sendStats, 2000);
570
+
571
+ req.on('close', () => {
572
+ clearInterval(intervalId);
573
+ });
574
+ return;
575
+ }
576
+
577
+ // 4. Static HTML dashboard & API snapshot endpoints
497
578
  if (url === '/' || url === '/index.html') {
498
579
  res.writeHead(200, { 'Content-Type': 'text/html' });
499
580
  const indexPath = path.join(process.cwd(), 'index.html');
@@ -523,6 +604,10 @@ async function runWebCommand(): Promise<void> {
523
604
  console.log(chalk.bold(`\n pyre web server running on port ${port}`));
524
605
  console.log(chalk.dim(` Dashboard: http://0.0.0.0:${port}/`));
525
606
  console.log(chalk.dim(` API: http://0.0.0.0:${port}/api`));
607
+ console.log(chalk.dim(` SSE Stream: http://0.0.0.0:${port}/api/stream`));
608
+ if (authEnabled) {
609
+ console.log(chalk.yellow(` Authentication: Enabled (${authUser}:${authPass})`));
610
+ }
526
611
  console.log(chalk.dim(' Press Ctrl+C to stop\n'));
527
612
  });
528
613
 
package/src/live/input.ts CHANGED
@@ -189,12 +189,20 @@ function handleInputModeKey(str: string, key: readline.Key) {
189
189
 
190
190
  if (key.name === 'backspace') {
191
191
  state.inputBuffer = state.inputBuffer.slice(0, -1);
192
+ if (state.inputMode === 'filter') {
193
+ state.processFilter = state.inputBuffer;
194
+ invalidateTableCache();
195
+ }
192
196
  render();
193
197
  return;
194
198
  }
195
199
 
196
200
  if (str && str.length === 1 && !key.ctrl && !key.meta) {
197
201
  state.inputBuffer += str;
202
+ if (state.inputMode === 'filter') {
203
+ state.processFilter = state.inputBuffer;
204
+ invalidateTableCache();
205
+ }
198
206
  render();
199
207
  }
200
208
  }