@cfour-hi/dsh-plugin-devicex 0.0.0-stage → 1.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.
@@ -0,0 +1,2995 @@
1
+ /**
2
+ * Device X — host-side data collectors.
3
+ *
4
+ * Plain Node ESM (runs inside the DSH Host process). No harness imports: only
5
+ * `node:*` and one sibling module (`./actions.js`, for the action catalog that
6
+ * belongs to the inventory payload).
7
+ *
8
+ * Rules honoured here:
9
+ * - every macOS command goes through `execFile` (argv only), never a shell;
10
+ * - a failed command yields null/empty values plus a non-fatal string in the
11
+ * section's `errors[]`; collectors never throw (unknown section names are
12
+ * the one deliberate exception);
13
+ * - everything returned is JSON-safe: no `undefined`, no NaN/Infinity, no
14
+ * circular references;
15
+ * - slow sections are cached with TTLs and can be warmed in the background;
16
+ * - `collectMetrics()` is a fast poll path (<250 ms) built from cheap parallel
17
+ * commands plus deltas against the previous poll.
18
+ *
19
+ * @module @cfour-hi/dsh-plugin-devicex/host/collector
20
+ */
21
+ import * as fs from 'node:fs';
22
+ import * as fsp from 'node:fs/promises';
23
+ import * as os from 'node:os';
24
+ import * as path from 'node:path';
25
+
26
+ import { actionCatalog, actionHistory } from './actions-darwin.js';
27
+ import { lines, run, runJson, sleep } from './proc.js';
28
+
29
+ /** Host platform (`darwin` on the target machine). */
30
+ export const PLATFORM = process.platform;
31
+
32
+ /** Frozen section identifiers, in UI order. */
33
+ export const SECTIONS = ['hardware', 'system', 'software', 'network', 'security', 'processes'];
34
+
35
+ /* -------------------------------------------------------------------------- */
36
+ /* Command paths (absolute, so a minimal Host PATH cannot break collection) */
37
+ /* -------------------------------------------------------------------------- */
38
+
39
+ const SYSTEM_PROFILER = '/usr/sbin/system_profiler';
40
+ const SYSCTL = '/usr/sbin/sysctl';
41
+ const SW_VERS = '/usr/bin/sw_vers';
42
+ const UNAME = '/usr/bin/uname';
43
+ const SCUTIL = '/usr/sbin/scutil';
44
+ const IOREG = '/usr/sbin/ioreg';
45
+ const VM_STAT = '/usr/bin/vm_stat';
46
+ const PMSET = '/usr/bin/pmset';
47
+ const IF_CONFIG = '/sbin/ifconfig';
48
+ const ROUTE = '/sbin/route';
49
+ const NETSTAT = '/usr/sbin/netstat';
50
+ const PS = '/bin/ps';
51
+ const DF = '/bin/df';
52
+ const NETWORKSETUP = '/usr/sbin/networksetup';
53
+ const LSOF = '/usr/sbin/lsof';
54
+ const DSCL = '/usr/bin/dscl';
55
+ const LAUNCHCTL = '/bin/launchctl';
56
+ const OSASCRIPT = '/usr/bin/osascript';
57
+ const DEFAULTS = '/usr/bin/defaults';
58
+ const PLUTIL = '/usr/bin/plutil';
59
+ const MDLS = '/usr/bin/mdls';
60
+ const FD_SETUP = '/usr/bin/fdesetup';
61
+ const CSRUTIL = '/usr/bin/csrutil';
62
+ const SPCTL = '/usr/sbin/spctl';
63
+ const SOCKETFILTERFW = '/usr/libexec/ApplicationFirewall/socketfilterfw';
64
+ const SYSTEMSETUP = '/usr/sbin/systemsetup';
65
+ const SQLITE3 = '/usr/bin/sqlite3';
66
+ const BREW_CANDIDATES = ['/opt/homebrew/bin/brew', '/usr/local/bin/brew'];
67
+
68
+ /**
69
+ * Environment for commands whose text output can contain process names:
70
+ * macOS `ps` meta-escapes non-ASCII under the C locale, so these run with a
71
+ * UTF-8 locale. Everything else keeps the deterministic C locale.
72
+ * @returns {NodeJS.ProcessEnv}
73
+ */
74
+ function utf8Env() {
75
+ return { ...process.env, LC_ALL: 'en_US.UTF-8', LANG: 'en_US.UTF-8' };
76
+ }
77
+
78
+ const HARDWARE_PROFILER_TYPES = [
79
+ 'SPHardwareDataType',
80
+ 'SPMemoryDataType',
81
+ 'SPStorageDataType',
82
+ 'SPUSBDataType',
83
+ 'SPBluetoothDataType',
84
+ 'SPAudioDataType',
85
+ 'SPCameraDataType',
86
+ 'SPDisplaysDataType',
87
+ 'SPPowerDataType',
88
+ 'SPExtensionsDataType',
89
+ ];
90
+
91
+ /* -------------------------------------------------------------------------- */
92
+ /* Small utilities */
93
+ /* -------------------------------------------------------------------------- */
94
+
95
+ /**
96
+ * Coerce to a finite number, else null.
97
+ * @param {unknown} value
98
+ * @returns {number|null}
99
+ */
100
+ function num(value) {
101
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
102
+ if (typeof value === 'string' && value.trim() !== '') {
103
+ const parsed = Number(value);
104
+ return Number.isFinite(parsed) ? parsed : null;
105
+ }
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * Coerce to a trimmed, non-empty string, else null.
111
+ * @param {unknown} value
112
+ * @returns {string|null}
113
+ */
114
+ function str(value) {
115
+ if (typeof value === 'string') {
116
+ const trimmed = value.trim();
117
+ return trimmed === '' ? null : trimmed;
118
+ }
119
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * Coerce a number to a fixed number of decimals (null-safe).
125
+ * @param {unknown} value
126
+ * @param {number} [digits]
127
+ * @returns {number|null}
128
+ */
129
+ function round(value, digits = 1) {
130
+ const parsed = num(value);
131
+ if (parsed === null) return null;
132
+ const factor = 10 ** digits;
133
+ return Math.round(parsed * factor) / factor;
134
+ }
135
+
136
+ /**
137
+ * Clamp a number into [min, max].
138
+ * @param {number} value
139
+ * @param {number} min
140
+ * @param {number} max
141
+ * @returns {number}
142
+ */
143
+ function clamp(value, min, max) {
144
+ if (!Number.isFinite(value)) return min;
145
+ return Math.min(max, Math.max(min, value));
146
+ }
147
+
148
+ /**
149
+ * First numeric value among the given keys of an object.
150
+ * @param {Record<string, unknown>} object
151
+ * @param {string[]} keys
152
+ * @returns {number|null}
153
+ */
154
+ function firstNum(object, keys) {
155
+ if (!object || typeof object !== 'object') return null;
156
+ for (const key of keys) {
157
+ const parsed = num(object[key]);
158
+ if (parsed !== null) return parsed;
159
+ }
160
+ return null;
161
+ }
162
+
163
+ /**
164
+ * First string value among the given keys of an object.
165
+ * @param {Record<string, unknown>} object
166
+ * @param {string[]} keys
167
+ * @returns {string|null}
168
+ */
169
+ function firstStr(object, keys) {
170
+ if (!object || typeof object !== 'object') return null;
171
+ for (const key of keys) {
172
+ const parsed = str(object[key]);
173
+ if (parsed !== null) return parsed;
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /**
179
+ * Convert a human byte string ("16 GB", "500.3 GB (500277792768 Bytes)") to
180
+ * bytes. Returns null when nothing parseable is found.
181
+ * @param {unknown} text
182
+ * @returns {number|null}
183
+ */
184
+ function bytesFromText(text) {
185
+ const source = str(text);
186
+ if (!source) return null;
187
+ // Prefer an explicit exact "(N Bytes)" figure when present.
188
+ const exact = /\((\d+)\s*bytes?\)/i.exec(source);
189
+ if (exact) return Number(exact[1]);
190
+ const match = /([\d.]+)\s*(bytes?|kb|mb|gb|tb)/i.exec(source);
191
+ if (!match) {
192
+ const plain = /^(\d+)$/.exec(source);
193
+ return plain ? Number(plain[1]) : null;
194
+ }
195
+ const value = Number(match[1]);
196
+ if (!Number.isFinite(value)) return null;
197
+ const unit = match[2].toLowerCase();
198
+ const factor =
199
+ unit === 'tb' ? 1024 ** 4 : unit === 'gb' ? 1024 ** 3 : unit === 'mb' ? 1024 ** 2 : unit === 'kb' ? 1024 : 1;
200
+ return Math.round(value * factor);
201
+ }
202
+
203
+ /**
204
+ * Shorten an error message for an `errors[]` entry.
205
+ * @param {unknown} value
206
+ * @param {number} [max]
207
+ * @returns {string}
208
+ */
209
+ function shortErr(value, max = 220) {
210
+ const text = String(value ?? 'unknown error').replace(/\s+/g, ' ').trim();
211
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
212
+ }
213
+
214
+ /**
215
+ * Deep JSON-safe conversion: `undefined`, non-finite numbers and functions
216
+ * become `null`; values are cloned so callers cannot mutate a cache entry.
217
+ * Cycles (which should not exist here) collapse to `null`.
218
+ * @param {unknown} value
219
+ * @param {Set<object>} [seen]
220
+ * @returns {any}
221
+ */
222
+ function jsonSafe(value, seen = new Set()) {
223
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
224
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
225
+ if (typeof value === 'bigint') return Number(value);
226
+ if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol') return null;
227
+ if (typeof value !== 'object') return null;
228
+ if (seen.has(value)) return null;
229
+ seen.add(value);
230
+ if (Array.isArray(value)) {
231
+ const array = value.map((item) => jsonSafe(item, seen));
232
+ seen.delete(value);
233
+ return array;
234
+ }
235
+ if (value instanceof Date) {
236
+ const time = value.getTime();
237
+ seen.delete(value);
238
+ return Number.isFinite(time) ? time : null;
239
+ }
240
+ /** @type {Record<string, unknown>} */
241
+ const object = {};
242
+ for (const [key, item] of Object.entries(value)) {
243
+ object[key] = jsonSafe(item, seen);
244
+ }
245
+ seen.delete(value);
246
+ return object;
247
+ }
248
+
249
+ /* -------------------------------------------------------------------------- */
250
+ /* Caching */
251
+ /* -------------------------------------------------------------------------- */
252
+
253
+ /** Per-section TTLs (ms). */
254
+ const SECTION_TTL = {
255
+ hardware: 300_000,
256
+ system: 60_000,
257
+ software: 300_000,
258
+ network: 15_000,
259
+ security: 120_000,
260
+ processes: 4_000,
261
+ };
262
+
263
+ /** @type {Map<string, {value: any, at: number}>} */
264
+ const sectionCache = new Map();
265
+ /** @type {Map<string, Promise<any>>} */
266
+ const sectionInflight = new Map();
267
+
268
+ /** General async memo store: `{value, at, pending}`. @type {Map<string, {value:any, at:number, pending:Promise<any>|null}>} */
269
+ const memoStore = new Map();
270
+
271
+ /**
272
+ * Async memo with TTL and in-flight de-duplication. On failure the previous
273
+ * value (or null) is returned and the timestamp is refreshed.
274
+ *
275
+ * @param {string} key
276
+ * @param {number} ttl
277
+ * @param {() => Promise<any>} loader
278
+ * @returns {Promise<any>}
279
+ */
280
+ function memo(key, ttl, loader) {
281
+ const now = Date.now();
282
+ let record = memoStore.get(key);
283
+ if (!record) {
284
+ record = { value: null, at: 0, pending: null };
285
+ memoStore.set(key, record);
286
+ }
287
+ if (record.pending) return record.pending;
288
+ if (now - record.at < ttl) return Promise.resolve(record.value);
289
+ const pending = Promise.resolve()
290
+ .then(loader)
291
+ .then((value) => {
292
+ record.value = value;
293
+ return value;
294
+ })
295
+ .catch(() => record.value)
296
+ .finally(() => {
297
+ record.at = Date.now();
298
+ record.pending = null;
299
+ });
300
+ record.pending = pending;
301
+ return pending;
302
+ }
303
+
304
+ /**
305
+ * Non-blocking cache: returns the last value immediately and refreshes it in
306
+ * the background when stale. Used by `collectMetrics()` for slow samples
307
+ * (Wi-Fi) so a poll never waits on `system_profiler`.
308
+ *
309
+ * @param {string} key
310
+ * @param {number} ttl
311
+ * @param {() => Promise<any>} loader
312
+ * @returns {any}
313
+ */
314
+ function fastValue(key, ttl, loader) {
315
+ const now = Date.now();
316
+ let record = memoStore.get(key);
317
+ if (!record) {
318
+ record = { value: null, at: 0, pending: null };
319
+ memoStore.set(key, record);
320
+ }
321
+ if (now - record.at < ttl) return record.value;
322
+ if (!record.pending) {
323
+ record.pending = Promise.resolve()
324
+ .then(loader)
325
+ .then((value) => {
326
+ record.value = value;
327
+ return value;
328
+ })
329
+ .catch(() => record.value)
330
+ .finally(() => {
331
+ record.at = Date.now();
332
+ record.pending = null;
333
+ });
334
+ }
335
+ return record.value;
336
+ }
337
+
338
+ /* -------------------------------------------------------------------------- */
339
+ /* Rate tracking (metrics deltas) */
340
+ /* -------------------------------------------------------------------------- */
341
+
342
+ /** @type {Map<string, {at: number, read: number, write: number}>} */
343
+ const rateState = new Map();
344
+
345
+ /**
346
+ * Have two cumulative counters at different times → bytes/second.
347
+ * @param {string} key
348
+ * @param {{read: number, write: number}} current
349
+ * @param {number} at
350
+ * @returns {{readBytesPerSec: number, writeBytesPerSec: number}}
351
+ */
352
+ function rateOf(key, current, at) {
353
+ const previous = rateState.get(key);
354
+ rateState.set(key, { at, read: current.read, write: current.write });
355
+ if (!previous || at <= previous.at) return { readBytesPerSec: 0, writeBytesPerSec: 0 };
356
+ const dt = (at - previous.at) / 1000;
357
+ if (!(dt > 0)) return { readBytesPerSec: 0, writeBytesPerSec: 0 };
358
+ return {
359
+ readBytesPerSec: Math.max(0, Math.round((current.read - previous.read) / dt)),
360
+ writeBytesPerSec: Math.max(0, Math.round((current.write - previous.write) / dt)),
361
+ };
362
+ }
363
+
364
+ /* -------------------------------------------------------------------------- */
365
+ /* Parsers */
366
+ /* -------------------------------------------------------------------------- */
367
+
368
+ /**
369
+ * @typedef {{pageSize: number, free: number, active: number, inactive: number,
370
+ * speculative: number, wired: number, purgeable: number, fileBacked: number,
371
+ * anonymous: number, compressed: number}} VmStat
372
+ */
373
+
374
+ /**
375
+ * Parse `vm_stat` into page counts.
376
+ * @param {string} text
377
+ * @returns {VmStat}
378
+ */
379
+ function parseVmStat(text) {
380
+ const result = {
381
+ pageSize: 4096,
382
+ free: 0,
383
+ active: 0,
384
+ inactive: 0,
385
+ speculative: 0,
386
+ wired: 0,
387
+ purgeable: 0,
388
+ fileBacked: 0,
389
+ anonymous: 0,
390
+ compressed: 0,
391
+ };
392
+ const pageSize = /page size of (\d+) bytes/.exec(text);
393
+ if (pageSize) result.pageSize = Number(pageSize[1]);
394
+ const read = (label, key) => {
395
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
396
+ const match = new RegExp(`^${escaped}:\\s+(\\d+)\\.`, 'm').exec(text);
397
+ if (match) result[key] = Number(match[1]);
398
+ };
399
+ read('Pages free', 'free');
400
+ read('Pages active', 'active');
401
+ read('Pages inactive', 'inactive');
402
+ read('Pages speculative', 'speculative');
403
+ read('Pages wired down', 'wired');
404
+ read('Pages purgeable', 'purgeable');
405
+ read('File-backed pages', 'fileBacked');
406
+ read('Anonymous pages', 'anonymous');
407
+ read('Pages occupied by compressor', 'compressed');
408
+ return result;
409
+ }
410
+
411
+ /**
412
+ * Parse `sysctl -n vm.swapusage` ("total = 12288.00M used = ...").
413
+ * @param {string} text
414
+ * @returns {{total: number, used: number}}
415
+ */
416
+ function parseSwap(text) {
417
+ const total = /total\s*=\s*([\d.]+)([MG])/.exec(text);
418
+ const used = /used\s*=\s*([\d.]+)([MG])/.exec(text);
419
+ const toBytes = (match) => {
420
+ if (!match) return 0;
421
+ const value = Number(match[1]);
422
+ if (!Number.isFinite(value)) return 0;
423
+ return Math.round(value * (match[2] === 'G' ? 1024 ** 3 : 1024 ** 2));
424
+ };
425
+ return { total: toBytes(total), used: toBytes(used) };
426
+ }
427
+
428
+ /**
429
+ * Parse `pmset -g batt`.
430
+ * @param {string} text
431
+ * @returns {{present: boolean, percent: number|null, charging: boolean,
432
+ * plugged: boolean, powerSource: string|null, timeRemainingMin: number|null,
433
+ * batteryState: string|null}}
434
+ */
435
+ function parsePmsetBattery(text) {
436
+ const sourceMatch = /Now drawing from '([^']+)'/.exec(text);
437
+ const line = /^\s*-\s*InternalBattery-\d+.*$/m.exec(text);
438
+ /** @type {{present: boolean, percent: number|null, charging: boolean, plugged: boolean, powerSource: string|null, timeRemainingMin: number|null, batteryState: string|null}} */
439
+ const result = {
440
+ present: Boolean(line),
441
+ percent: null,
442
+ charging: false,
443
+ plugged: false,
444
+ powerSource: sourceMatch ? sourceMatch[1] : null,
445
+ timeRemainingMin: null,
446
+ batteryState: null,
447
+ };
448
+ if (!line) return result;
449
+ const percent = /(\d+)%/.exec(line[0]);
450
+ if (percent) result.percent = Number(percent[1]);
451
+ const state = /;\s*([^;]+?)\s*;/.exec(line[0]);
452
+ if (state) result.batteryState = state[1].trim();
453
+ const pmsetState = result.batteryState ?? '';
454
+ // `discharging` contains the substring `charging`; match whole states instead.
455
+ result.charging = (pmsetState === 'charging' || pmsetState === 'finishing charge') && !/discharg/.test(pmsetState);
456
+ result.plugged = (result.powerSource ?? '').toLowerCase().includes('ac');
457
+ const remaining = /(\d+):(\d{2})\s*remaining/.exec(line[0]);
458
+ if (remaining) {
459
+ const minutes = Number(remaining[1]) * 60 + Number(remaining[2]);
460
+ result.timeRemainingMin = minutes > 0 && minutes < 6000 ? minutes : null;
461
+ }
462
+ if (/charged/.test(result.batteryState ?? '')) result.timeRemainingMin = null;
463
+ return result;
464
+ }
465
+
466
+ /**
467
+ * Parse the interesting top-level keys of `ioreg -rn AppleSmartBattery`.
468
+ * Only line-anchored matches are used so the nested `BatteryData` dictionary
469
+ * cannot shadow the top-level figures.
470
+ * @param {string} text
471
+ * @returns {{present: boolean, currentCapacity: number|null,
472
+ * maxCapacity: number|null, rawMaxCapacity: number|null,
473
+ * designCapacity: number|null, temperatureC: number|null,
474
+ * cycleCount: number|null, charging: boolean|null, external: boolean|null,
475
+ * timeRemaining: number|null}}
476
+ */
477
+ function parseIoregBattery(text) {
478
+ const read = (key) => {
479
+ const match = new RegExp(`^\\s+"${key}" = (\\d+)`, 'm').exec(text);
480
+ return match ? Number(match[1]) : null;
481
+ };
482
+ const readBool = (key) => {
483
+ const match = new RegExp(`^\\s+"${key}" = (Yes|No|true|false)`, 'm').exec(text);
484
+ if (!match) return null;
485
+ return match[1] === 'Yes' || match[1] === 'true';
486
+ };
487
+ const temperature = read('Temperature');
488
+ const installed = readBool('BatteryInstalled');
489
+ return {
490
+ present: installed === true,
491
+ currentCapacity: read('CurrentCapacity'),
492
+ maxCapacity: read('MaxCapacity'),
493
+ rawMaxCapacity: read('AppleRawMaxCapacity'),
494
+ designCapacity: read('DesignCapacity'),
495
+ temperatureC: temperature === null ? null : round(temperature / 100, 1),
496
+ cycleCount: read('CycleCount'),
497
+ charging: readBool('IsCharging'),
498
+ external: readBool('ExternalConnected'),
499
+ timeRemaining: read('TimeRemaining'),
500
+ };
501
+ }
502
+
503
+ /**
504
+ * Compute battery health percentage from raw max / design capacity.
505
+ * @param {ReturnType<typeof parseIoregBattery>} battery
506
+ * @returns {number|null}
507
+ */
508
+ function batteryHealth(battery) {
509
+ if (battery.rawMaxCapacity === null || battery.designCapacity === null || battery.designCapacity <= 0) return null;
510
+ return clamp(Math.round((battery.rawMaxCapacity / battery.designCapacity) * 100), 0, 100);
511
+ }
512
+
513
+ /**
514
+ * Parse cumulative disk byte counters from `ioreg -c IOBlockStorageDriver -r -w0`.
515
+ * @param {string} text
516
+ * @returns {{read: number, write: number}}
517
+ */
518
+ function parseIoregDisk(text) {
519
+ let read = 0;
520
+ let write = 0;
521
+ const readMatches = text.matchAll(/"Bytes \(Read\)"=(\d+)/g);
522
+ for (const match of readMatches) read += Number(match[1]) || 0;
523
+ const writeMatches = text.matchAll(/"Bytes \(Write\)"=(\d+)/g);
524
+ for (const match of writeMatches) write += Number(match[1]) || 0;
525
+ return { read, write };
526
+ }
527
+
528
+ /**
529
+ * Sum cumulative network bytes from `netstat -ib` link rows (loopback excluded).
530
+ * @param {string} text
531
+ * @returns {{read: number, write: number}}
532
+ */
533
+ function parseNetstatBytes(text) {
534
+ let read = 0;
535
+ let write = 0;
536
+ for (const line of String(text ?? '').split(/\r?\n/)) {
537
+ if (!line.trim() || /^Name\s/.test(line)) continue;
538
+ const match = /^(\S+)\s+(\d+)\s+(<Link#\d+>)\s*(.*)$/.exec(line);
539
+ if (!match) continue;
540
+ const name = match[1];
541
+ if (name === 'lo0') continue;
542
+ let rest = match[4].trim().split(/\s+/);
543
+ if (rest.length && /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i.test(rest[0])) rest = rest.slice(1);
544
+ if (rest.length < 7) continue;
545
+ const ibytes = Number(rest[2]);
546
+ const obytes = Number(rest[5]);
547
+ if (Number.isFinite(ibytes)) read += ibytes;
548
+ if (Number.isFinite(obytes)) write += obytes;
549
+ }
550
+ return { read, write };
551
+ }
552
+
553
+ /**
554
+ * Parse `df -kP <path>` for one filesystem.
555
+ * @param {string} text
556
+ * @returns {{totalBytes: number, usedBytes: number, freeBytes: number}|null}
557
+ */
558
+ function parseDf(text) {
559
+ const rows = lines(text);
560
+ if (rows.length < 2) return null;
561
+ const parts = rows[rows.length - 1].trim().split(/\s+/);
562
+ if (parts.length < 4) return null;
563
+ const totalKb = Number(parts[1]);
564
+ const availKb = Number(parts[3]);
565
+ if (!Number.isFinite(totalKb) || !Number.isFinite(availKb)) return null;
566
+ const totalBytes = totalKb * 1024;
567
+ const freeBytes = availKb * 1024;
568
+ return { totalBytes, usedBytes: Math.max(0, totalBytes - freeBytes), freeBytes };
569
+ }
570
+
571
+ /**
572
+ * @typedef {{name: string, up: boolean, mtu: number, mac: string|null,
573
+ * ipv4: string[], ipv6: string[], status: string|null}} Iface
574
+ */
575
+
576
+ /**
577
+ * Parse `ifconfig -a`.
578
+ * @param {string} text
579
+ * @returns {Iface[]}
580
+ */
581
+ function parseIfconfig(text) {
582
+ /** @type {Iface[]} */
583
+ const result = [];
584
+ /** @type {Iface|null} */
585
+ let current = null;
586
+ for (const rawLine of String(text ?? '').split(/\r?\n/)) {
587
+ if (rawLine.trim() === '') continue;
588
+ if (!/^\s/.test(rawLine)) {
589
+ const header = /^([A-Za-z0-9_.-]+):\s+flags=([0-9a-f]+)(?:<([^>]*)>)?\s+mtu\s+(\d+)/.exec(rawLine);
590
+ if (header) {
591
+ current = {
592
+ name: header[1],
593
+ up: (header[3] ?? '').split(',').includes('UP'),
594
+ mtu: Number(header[4]),
595
+ mac: null,
596
+ ipv4: [],
597
+ ipv6: [],
598
+ status: null,
599
+ };
600
+ result.push(current);
601
+ } else {
602
+ current = null;
603
+ }
604
+ continue;
605
+ }
606
+ if (!current) continue;
607
+ const line = rawLine.trim();
608
+ const ipv4 = /^inet\s+(\d{1,3}(?:\.\d{1,3}){3})(?:\s+netmask\s+\S+)?/.exec(line);
609
+ if (ipv4) {
610
+ current.ipv4.push(ipv4[1]);
611
+ continue;
612
+ }
613
+ const ipv6 = /^inet6\s+([0-9a-fA-F:]+)(?:%[A-Za-z0-9]+)?(?:\s+prefixlen\s+\d+)?/.exec(line);
614
+ if (ipv6) {
615
+ const address = ipv6[1];
616
+ if (address !== '::1') current.ipv6.push(address);
617
+ continue;
618
+ }
619
+ const mac = /^ether\s+([0-9a-fA-F:]{17})/.exec(line);
620
+ if (mac) {
621
+ current.mac = mac[1].toLowerCase();
622
+ continue;
623
+ }
624
+ const status = /^status:\s+(\S+)/.exec(line);
625
+ if (status) current.status = status[1];
626
+ }
627
+ return result;
628
+ }
629
+
630
+ /**
631
+ * Parse `networksetup -listallhardwareports` into device → hardware port name.
632
+ * @param {string} text
633
+ * @returns {Map<string, string>}
634
+ */
635
+ function parseHardwarePorts(text) {
636
+ /** @type {Map<string, string>} */
637
+ const map = new Map();
638
+ let port = null;
639
+ for (const rawLine of String(text ?? '').split(/\r?\n/)) {
640
+ const line = rawLine.trim();
641
+ if (!line) continue;
642
+ const portMatch = /^Hardware Port:\s*(.+)$/.exec(line);
643
+ if (portMatch) {
644
+ port = portMatch[1].trim();
645
+ continue;
646
+ }
647
+ const deviceMatch = /^Device:\s*(\S+)$/.exec(line);
648
+ if (deviceMatch && port) {
649
+ map.set(deviceMatch[1], port);
650
+ }
651
+ }
652
+ return map;
653
+ }
654
+
655
+ /**
656
+ * Classify an interface for the network section.
657
+ * @param {Iface} iface
658
+ * @param {Map<string, string>} hardwarePorts
659
+ * @returns {'wifi'|'ethernet'|'loopback'|'bridge'|'other'}
660
+ */
661
+ function interfaceType(iface, hardwarePorts) {
662
+ if (iface.name === 'lo0' || iface.name.startsWith('lo')) return 'loopback';
663
+ if (iface.name.startsWith('bridge')) return 'bridge';
664
+ const port = hardwarePorts.get(iface.name);
665
+ if (port) {
666
+ if (/wi-?fi|airport/i.test(port)) return 'wifi';
667
+ if (/ethernet|thunderbolt|lan|usb/i.test(port)) return 'ethernet';
668
+ }
669
+ if (/^en\d+$/.test(iface.name)) return 'ethernet';
670
+ return 'other';
671
+ }
672
+
673
+ /**
674
+ * Parse `route -n get default`.
675
+ * @param {string} text
676
+ * @returns {{gateway: string|null, interface: string|null}|null}
677
+ */
678
+ function parseDefaultRoute(text) {
679
+ const gateway = /gateway:\s*(\S+)/.exec(text);
680
+ const iface = /interface:\s*(\S+)/.exec(text);
681
+ if (!gateway && !iface) return null;
682
+ return { gateway: gateway ? gateway[1] : null, interface: iface ? iface[1] : null };
683
+ }
684
+
685
+ /**
686
+ * Parse `scutil --dns`.
687
+ * @param {string} text
688
+ * @returns {{servers: string[], searchDomains: string[]}}
689
+ */
690
+ function parseDns(text) {
691
+ /** @type {string[]} */
692
+ const servers = [];
693
+ /** @type {string[]} */
694
+ const searchDomains = [];
695
+ const serverMatches = String(text ?? '').matchAll(/nameserver\[\d+]\s*:\s*(\S+)/g);
696
+ for (const match of serverMatches) {
697
+ if (!servers.includes(match[1])) servers.push(match[1]);
698
+ }
699
+ const domainMatches = String(text ?? '').matchAll(/search domain\[\d+]\s*:\s*(\S+)/g);
700
+ for (const match of domainMatches) {
701
+ if (!searchDomains.includes(match[1])) searchDomains.push(match[1]);
702
+ }
703
+ return { servers: servers.slice(0, 8), searchDomains: searchDomains.slice(0, 8) };
704
+ }
705
+
706
+ /**
707
+ * Parse `scutil --proxy`'s dictionary dump.
708
+ * @param {string} text
709
+ * @returns {Record<string, string|number>}
710
+ */
711
+ function parseScutilDictionary(text) {
712
+ /** @type {Record<string, string|number>} */
713
+ const result = {};
714
+ for (const rawLine of String(text ?? '').split(/\r?\n/)) {
715
+ const match = /^\s*([A-Za-z][A-Za-z0-9]*)\s*:\s*(.*?)\s*$/.exec(rawLine);
716
+ if (!match) continue;
717
+ const value = match[2];
718
+ if (value === '') continue;
719
+ result[match[1]] = /^-?\d+$/.test(value) ? Number(value) : value;
720
+ }
721
+ return result;
722
+ }
723
+
724
+ /**
725
+ * Turn raw scutil proxy keys into the contract's three proxy slots.
726
+ * @param {Record<string, string|number>} config
727
+ * @returns {{http: string|null, https: string|null, socks: string|null}}
728
+ */
729
+ function parseProxy(config) {
730
+ const slot = (enableKey, hostKey, portKey) => {
731
+ const enabled = Number(config[enableKey] ?? 0) === 1;
732
+ const host = str(config[hostKey]);
733
+ if (!enabled || !host) return null;
734
+ const port = num(config[portKey]);
735
+ return port ? `${host}:${port}` : host;
736
+ };
737
+ return {
738
+ http: slot('HTTPEnable', 'HTTPProxy', 'HTTPPort'),
739
+ https: slot('HTTPSEnable', 'HTTPSProxy', 'HTTPSPort'),
740
+ socks: slot('SOCKSEnable', 'SOCKSProxy', 'SOCKSPort'),
741
+ };
742
+ }
743
+
744
+ /**
745
+ * Parse `lsof -nP -iTCP -sTCP:LISTEN` rows.
746
+ * @param {string} text
747
+ * @returns {Array<{proto: string, address: string, port: number,
748
+ * pid: number|null, process: string}>}
749
+ */
750
+ function parseLsofListeners(text) {
751
+ /** @type {Array<{proto: string, address: string, port: number, pid: number|null, process: string}>} */
752
+ const result = [];
753
+ for (const rawLine of String(text ?? '').split(/\r?\n/)) {
754
+ const line = rawLine.trim();
755
+ if (!line || /^COMMAND\s/.test(line)) continue;
756
+ const parts = line.split(/\s+/);
757
+ if (parts.length < 9) continue;
758
+ const process = parts[0].replace(/\\x([0-9a-fA-F]{2})/g, (_m, hex) => String.fromCharCode(parseInt(hex, 16)));
759
+ const pid = num(parts[1]);
760
+ const nameField = parts[8];
761
+ const at = nameField.lastIndexOf(':');
762
+ if (at === -1) continue;
763
+ const address = nameField.slice(0, at).replace(/^\[/, '').replace(/\]$/, '');
764
+ const port = num(nameField.slice(at + 1));
765
+ if (port === null) continue;
766
+ result.push({ proto: 'tcp', address: address || '*', port, pid, process });
767
+ }
768
+ return result;
769
+ }
770
+
771
+ /**
772
+ * Parse `ps -axo pid=,ppid=,user=,%cpu=,%mem=,rss=,state=,etime=,command=`.
773
+ * @param {string} text
774
+ * @returns {Array<{pid: number, ppid: number|null, user: string|null,
775
+ * cpu: number, memPercent: number, rssBytes: number|null, state: string|null,
776
+ * elapsed: number|null, command: string}>}
777
+ */
778
+ function parsePs(text) {
779
+ /** @type {ReturnType<typeof parsePs>} */
780
+ const result = [];
781
+ for (const rawLine of String(text ?? '').split(/\r?\n/)) {
782
+ const line = rawLine.trimEnd();
783
+ if (!line.trim()) continue;
784
+ const match =
785
+ /^\s*(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/.exec(line);
786
+ if (!match) continue;
787
+ const rss = Number(match[6]);
788
+ result.push({
789
+ pid: Number(match[1]),
790
+ ppid: Number(match[2]),
791
+ user: match[3] || null,
792
+ cpu: round(Number(match[4]), 1) ?? 0,
793
+ memPercent: round(Number(match[5]), 1) ?? 0,
794
+ rssBytes: Number.isFinite(rss) ? rss * 1024 : null,
795
+ state: match[7] || null,
796
+ elapsed: parseElapsed(match[8]),
797
+ command: match[9] ? match[9].trim() : '',
798
+ });
799
+ }
800
+ return result;
801
+ }
802
+
803
+ /**
804
+ * Convert a `ps` ELAPSED field ("08-07:05:32", "16:01", "02:03") to seconds.
805
+ * @param {string} value
806
+ * @returns {number|null}
807
+ */
808
+ function parseElapsed(value) {
809
+ if (!value) return null;
810
+ let days = 0;
811
+ let rest = value;
812
+ const dayMatch = /^(\d+)-(.+)$/.exec(value);
813
+ if (dayMatch) {
814
+ days = Number(dayMatch[1]);
815
+ rest = dayMatch[2];
816
+ }
817
+ const parts = rest.split(':').map(Number);
818
+ if (parts.some((part) => !Number.isFinite(part))) return null;
819
+ let hours = 0;
820
+ let minutes = 0;
821
+ let seconds = 0;
822
+ if (parts.length === 3) [hours, minutes, seconds] = parts;
823
+ else if (parts.length === 2) [minutes, seconds] = parts;
824
+ else [seconds] = parts;
825
+ return days * 86_400 + hours * 3_600 + minutes * 60 + seconds;
826
+ }
827
+
828
+ /**
829
+ * Parse `launchctl list` into label → {pid, status}.
830
+ * @param {string} text
831
+ * @returns {Map<string, {pid: number|null, status: number|null}>}
832
+ */
833
+ function parseLaunchctlList(text) {
834
+ /** @type {Map<string, {pid: number|null, status: number|null}>} */
835
+ const map = new Map();
836
+ for (const rawLine of String(text ?? '').split(/\r?\n/)) {
837
+ const parts = rawLine.trim().split('\t');
838
+ if (parts.length < 3) continue;
839
+ const label = parts.slice(2).join('\t').trim();
840
+ if (!label || label === 'Label') continue;
841
+ map.set(label, { pid: num(parts[0]), status: num(parts[1]) });
842
+ }
843
+ return map;
844
+ }
845
+
846
+ /**
847
+ * Decode the five XML entities needed for plist strings.
848
+ * @param {string} value
849
+ * @returns {string}
850
+ */
851
+ function decodeXml(value) {
852
+ return value
853
+ .replace(/&lt;/g, '<')
854
+ .replace(/&gt;/g, '>')
855
+ .replace(/&quot;/g, '"')
856
+ .replace(/&apos;/g, "'")
857
+ .replace(/&amp;/g, '&');
858
+ }
859
+
860
+ /**
861
+ * Extract Label/Program/ProgramArguments/Disabled from an XML plist.
862
+ * @param {string} text
863
+ * @returns {{Label: string|null, Program: string|null,
864
+ * ProgramArguments: string[], Disabled: boolean|null}}
865
+ */
866
+ function parseXmlPlist(text) {
867
+ const value = (key) => {
868
+ const match = new RegExp(`<key>${key}</key>\\s*<string>([\\s\\S]*?)</string>`).exec(text);
869
+ return match ? decodeXml(match[1]).trim() : null;
870
+ };
871
+ const bool = (key) => {
872
+ const match = new RegExp(`<key>${key}</key>\\s*<(true|false)\\s*/>`).exec(text);
873
+ return match ? match[1] === 'true' : null;
874
+ };
875
+ let programArguments = [];
876
+ const argsBlock = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(text);
877
+ if (argsBlock) {
878
+ programArguments = [...argsBlock[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((match) =>
879
+ decodeXml(match[1]),
880
+ );
881
+ }
882
+ return { Label: value('Label'), Program: value('Program'), ProgramArguments: programArguments, Disabled: bool('Disabled') };
883
+ }
884
+
885
+ /**
886
+ * Read and parse one launchd plist (XML, JSON or binary).
887
+ * @param {string} file
888
+ * @returns {Promise<{Label: string|null, Program: string|null,
889
+ * ProgramArguments: string[], Disabled: boolean|null}|null>}
890
+ */
891
+ async function parsePlistFile(file) {
892
+ const buffer = await fsp.readFile(file);
893
+ if (buffer.subarray(0, 6).toString('latin1') === 'bplist') {
894
+ const converted = await runJson(PLUTIL, ['-convert', 'json', '-o', '-', file], { timeout: 5_000 });
895
+ if (!converted.ok || !converted.data || typeof converted.data !== 'object') return null;
896
+ const data = converted.data;
897
+ return {
898
+ Label: str(data.Label),
899
+ Program: str(data.Program),
900
+ ProgramArguments: Array.isArray(data.ProgramArguments) ? data.ProgramArguments.map((item) => String(item)) : [],
901
+ Disabled: typeof data.Disabled === 'boolean' ? data.Disabled : null,
902
+ };
903
+ }
904
+ const text = buffer.toString('utf8');
905
+ if (text.trim().startsWith('{')) {
906
+ try {
907
+ const data = JSON.parse(text);
908
+ return {
909
+ Label: str(data.Label),
910
+ Program: str(data.Program),
911
+ ProgramArguments: Array.isArray(data.ProgramArguments) ? data.ProgramArguments.map((item) => String(item)) : [],
912
+ Disabled: typeof data.Disabled === 'boolean' ? data.Disabled : null,
913
+ };
914
+ } catch {
915
+ return null;
916
+ }
917
+ }
918
+ return parseXmlPlist(text);
919
+ }
920
+
921
+ /**
922
+ * Parse the first `system_profiler -json SPAirPortDataType` network info.
923
+ * @param {any} spData parsed system profiler JSON
924
+ * @returns {{ssid: string|null, bssid: string|null, rssi: number|null,
925
+ * noise: number|null, channel: number|null, security: string|null,
926
+ * txRateMbps: number|null}|null}
927
+ */
928
+ function parseAirportWifi(spData) {
929
+ const interfaces = spData?.SPAirPortDataType?.[0]?.spairport_airport_interfaces;
930
+ if (!Array.isArray(interfaces)) return null;
931
+ for (const iface of interfaces) {
932
+ const info = iface?.spairport_current_network_information;
933
+ if (!info || typeof info !== 'object') continue;
934
+ const channelText = str(info.spairport_network_channel) ?? '';
935
+ const channelMatch = /(\d+)/.exec(channelText);
936
+ const rate = num(info.spairport_network_rate);
937
+ return {
938
+ ssid: str(info._name) ?? str(info.spairport_network_ssid),
939
+ bssid: firstStr(info, ['spairport_network_bssid', 'spairport_network_BSSID']),
940
+ rssi: firstNum(info, ['spairport_network_rssi', 'spairport_rssi']),
941
+ noise: firstNum(info, ['spairport_network_noise', 'spairport_noise']),
942
+ channel: channelMatch ? Number(channelMatch[1]) : null,
943
+ security: firstStr(info, ['spairport_security_mode', 'spairport_network_security']),
944
+ txRateMbps: rate,
945
+ };
946
+ }
947
+ return null;
948
+ }
949
+
950
+ /* -------------------------------------------------------------------------- */
951
+ /* Shared command-backed facts */
952
+ /* -------------------------------------------------------------------------- */
953
+
954
+ /**
955
+ * Static OS/hardware facts used by metrics and the system section.
956
+ * @returns {Promise<{hostname: string, os: {name: string, version: string|null,
957
+ * build: string|null, kernel: string|null, arch: string, release: string|null},
958
+ * model: string|null, modelId: string|null, chip: string|null,
959
+ * serial: string|null, hardwareUuid: string|null, physicalCores: number|null,
960
+ * logicalCores: number, totalMemoryBytes: number, cpuModel: string|null}>}
961
+ */
962
+ function staticFacts() {
963
+ return memo('staticFacts', 300_000, async () => {
964
+ const [swRes, unameRes, sysctlRes, hwRes] = await Promise.all([
965
+ run(SW_VERS),
966
+ run(UNAME, ['-a']),
967
+ run(SYSCTL, ['-n', 'hw.physicalcpu', 'hw.logicalcpu', 'hw.memsize']),
968
+ runJson(SYSTEM_PROFILER, ['-json', 'SPHardwareDataType'], { timeout: 20_000 }),
969
+ ]);
970
+ /** @type {Record<string, string>} */
971
+ const sw = {};
972
+ for (const line of lines(swRes.stdout)) {
973
+ const match = /^([^:]+):\s*(.*)$/.exec(line);
974
+ if (match) sw[match[1].trim()] = match[2].trim();
975
+ }
976
+ const unameText = unameRes.stdout.trim();
977
+ const unameParts = unameText.split(/\s+/);
978
+ const kernelRelease = unameParts[2] ?? null;
979
+ const sysctlValues = lines(sysctlRes.stdout).map((line) => Number(line));
980
+ const physicalCores = num(sysctlValues[0]);
981
+ const logicalCores = num(sysctlValues[1]) ?? os.cpus().length;
982
+ const totalMemoryBytes = num(sysctlValues[2]) ?? os.totalmem();
983
+ const hw = hwRes.ok ? hwRes.data?.SPHardwareDataType?.[0] ?? null : null;
984
+ const cpus = os.cpus();
985
+ return {
986
+ hostname: os.hostname(),
987
+ os: {
988
+ name: sw.ProductName ?? 'macOS',
989
+ version: sw.ProductVersion ?? null,
990
+ build: sw.BuildVersion ?? null,
991
+ kernel: kernelRelease ? `Darwin ${kernelRelease}` : null,
992
+ arch: process.arch,
993
+ release: kernelRelease,
994
+ },
995
+ model: hw ? str(hw.machine_name) : null,
996
+ modelId: hw ? str(hw.machine_model) : null,
997
+ chip: hw ? str(hw.chip_type) : null,
998
+ serial: hw ? str(hw.serial_number) : null,
999
+ hardwareUuid: hw ? str(hw.platform_UUID) : null,
1000
+ physicalCores: physicalCores ?? cpus.length,
1001
+ logicalCores: logicalCores ?? cpus.length,
1002
+ totalMemoryBytes: totalMemoryBytes ?? os.totalmem(),
1003
+ cpuModel: cpus.length ? str(cpus[0].model) : null,
1004
+ };
1005
+ });
1006
+ }
1007
+
1008
+ /**
1009
+ * Real (non-service) local users plus the admin membership set.
1010
+ * @returns {Promise<{users: Array<{name: string, uid: number, gid: number|null,
1011
+ * fullName: string|null, home: string|null, shell: string|null,
1012
+ * admin: boolean, isCurrent: boolean}>, adminNames: string[],
1013
+ * currentName: string}>}
1014
+ */
1015
+ function userFacts() {
1016
+ return memo('userFacts', 120_000, async () => {
1017
+ const currentName = os.userInfo().username;
1018
+ const [listRes, adminRes] = await Promise.all([
1019
+ run(DSCL, ['.', '-list', '/Users', 'UniqueID']),
1020
+ run(DSCL, ['.', '-read', '/Groups/admin', 'GroupMembership']),
1021
+ ]);
1022
+ /** @type {string[]} */
1023
+ const adminNames = [];
1024
+ const adminMatch = /GroupMembership:\s*(.*)/.exec(adminRes.stdout);
1025
+ if (adminMatch) {
1026
+ adminNames.push(...adminMatch[1].trim().split(/\s+/).filter(Boolean));
1027
+ }
1028
+ /** @type {Array<{name: string, uid: number}>} */
1029
+ const candidates = [];
1030
+ for (const line of lines(listRes.stdout)) {
1031
+ const parts = line.trim().split(/\s+/);
1032
+ if (parts.length < 2) continue;
1033
+ const uid = Number(parts[1]);
1034
+ const name = parts[0];
1035
+ if (!Number.isFinite(uid) || uid < 500) continue;
1036
+ if (name.startsWith('_')) continue;
1037
+ candidates.push({ name, uid });
1038
+ }
1039
+ const users = await Promise.all(
1040
+ candidates.map(async (candidate) => {
1041
+ const detail = await run(DSCL, [
1042
+ '.',
1043
+ '-read',
1044
+ `/Users/${candidate.name}`,
1045
+ 'RealName',
1046
+ 'NFSHomeDirectory',
1047
+ 'UserShell',
1048
+ 'PrimaryGroupID',
1049
+ ]);
1050
+ /** @type {Record<string, string>} */
1051
+ const attributes = {};
1052
+ let pendingKey = null;
1053
+ for (const rawLine of String(detail.stdout ?? '').split(/\r?\n/)) {
1054
+ if (!rawLine.trim()) continue;
1055
+ const keyed = /^([A-Za-z][A-Za-z0-9]*):\s*(.*)$/.exec(rawLine);
1056
+ if (keyed) {
1057
+ if (keyed[2].trim() !== '') {
1058
+ attributes[keyed[1]] = keyed[2].trim();
1059
+ pendingKey = null;
1060
+ } else {
1061
+ pendingKey = keyed[1];
1062
+ }
1063
+ continue;
1064
+ }
1065
+ if (pendingKey) {
1066
+ attributes[pendingKey] = rawLine.trim();
1067
+ pendingKey = null;
1068
+ }
1069
+ }
1070
+ return {
1071
+ name: candidate.name,
1072
+ uid: candidate.uid,
1073
+ gid: num(attributes.PrimaryGroupID),
1074
+ fullName: str(attributes.RealName),
1075
+ home: str(attributes.NFSHomeDirectory),
1076
+ shell: str(attributes.UserShell),
1077
+ admin: adminNames.includes(candidate.name),
1078
+ isCurrent: candidate.name === currentName,
1079
+ };
1080
+ }),
1081
+ );
1082
+ return { users, adminNames, currentName };
1083
+ });
1084
+ }
1085
+
1086
+ /**
1087
+ * Cached `ifconfig -a` parse.
1088
+ * @returns {Promise<Iface[]>}
1089
+ */
1090
+ function interfaces() {
1091
+ return memo('interfaces', 5_000, async () => {
1092
+ const result = await run(IF_CONFIG, ['-a']);
1093
+ return parseIfconfig(result.stdout);
1094
+ });
1095
+ }
1096
+
1097
+ /**
1098
+ * Cached `route -n get default` parse.
1099
+ * @returns {Promise<{gateway: string|null, interface: string|null}|null>}
1100
+ */
1101
+ function defaultRoute() {
1102
+ return memo('defaultRoute', 5_000, async () => {
1103
+ const result = await run(ROUTE, ['-n', 'get', 'default']);
1104
+ return parseDefaultRoute(result.stdout);
1105
+ });
1106
+ }
1107
+
1108
+ /**
1109
+ * Cached `df` of the data volume.
1110
+ * @returns {Promise<{totalBytes: number, usedBytes: number, freeBytes: number}|null>}
1111
+ */
1112
+ function diskUsage() {
1113
+ return memo('diskUsage', 5_000, async () => {
1114
+ let result = await run(DF, ['-kP', '/System/Volumes/Data']);
1115
+ if (!result.ok) result = await run(DF, ['-kP', '/']);
1116
+ return parseDf(result.stdout);
1117
+ });
1118
+ }
1119
+
1120
+ /**
1121
+ * Cached process table (shared by metrics and the processes section).
1122
+ * @returns {Promise<ReturnType<typeof parsePs>>}
1123
+ */
1124
+ function processTable() {
1125
+ return memo('processTable', 2_500, async () => {
1126
+ const result = await run(
1127
+ PS,
1128
+ ['-axo', 'pid=,ppid=,user=,%cpu=,%mem=,rss=,state=,etime=,command='],
1129
+ { timeout: 10_000, maxBuffer: 8 * 1024 * 1024, env: utf8Env() },
1130
+ );
1131
+ if (!result.ok) throw new Error(result.error ?? 'ps failed');
1132
+ return parsePs(result.stdout);
1133
+ });
1134
+ }
1135
+
1136
+ /**
1137
+ * Cached Wi-Fi sample for the metrics poll (refreshed in the background).
1138
+ * @returns {{ssid: string|null, bssid: string|null, rssi: number|null,
1139
+ * noise: number|null, channel: number|null, security: string|null,
1140
+ * txRateMbps: number|null}|null}
1141
+ */
1142
+ function wifiMetricSample() {
1143
+ return fastValue('wifiSample', 60_000, async () => {
1144
+ const result = await runJson(SYSTEM_PROFILER, ['-json', 'SPAirPortDataType'], { timeout: 20_000 });
1145
+ if (!result.ok) return null;
1146
+ return parseAirportWifi(result.data);
1147
+ });
1148
+ }
1149
+
1150
+ /**
1151
+ * Humanise a system_profiler GPU vendor token.
1152
+ * @param {string|null} vendor
1153
+ * @returns {string|null}
1154
+ */
1155
+ function humaniseGpuVendor(vendor) {
1156
+ if (!vendor) return null;
1157
+ if (/sppci_vendor_Apple/i.test(vendor)) return 'Apple';
1158
+ const known = vendor.replace(/^sppci_vendor_/i, '');
1159
+ return known || null;
1160
+ }
1161
+
1162
+ /**
1163
+ * Resolve a display connection kind from the profiler fields.
1164
+ * @param {string|null} connectionType
1165
+ * @param {string|null} displayType
1166
+ * @param {any} screen
1167
+ * @returns {string|null}
1168
+ */
1169
+ function displayConnection(connectionType, displayType, screen) {
1170
+ if (connectionType && /internal|builtin/i.test(connectionType)) return 'built-in';
1171
+ if (connectionType && /(hdmi|displayport|thunderbolt|dvi|vga|external)/i.test(connectionType)) return 'external';
1172
+ if (displayType && /built-?in|retina/i.test(displayType)) return 'built-in';
1173
+ if (screen && (screen.spdisplays_main === 'spdisplays_yes' || screen._spdisplays_pixels)) return 'external';
1174
+ return null;
1175
+ }
1176
+
1177
+ /* -------------------------------------------------------------------------- */
1178
+ /* Section: hardware */
1179
+ /* -------------------------------------------------------------------------- */
1180
+
1181
+ /** Contract cap for `hardware.drivers` (both platforms). */
1182
+ const MAX_DRIVERS = 250;
1183
+ /** Contract cap for `hardware.driverProblems` (Windows only; empty on macOS). */
1184
+ const MAX_DRIVER_PROBLEMS = 50;
1185
+
1186
+ /**
1187
+ * Keep only the calendar date part of an ISO timestamp (the driver contract
1188
+ * uses `yyyy-MM-dd`; a full timestamp is not a deviation worth exposing).
1189
+ * @param {unknown} value
1190
+ * @returns {string|null}
1191
+ */
1192
+ function isoDateOnly(value) {
1193
+ const text = str(value);
1194
+ if (!text) return null;
1195
+ const match = /^(\d{4}-\d{2}-\d{2})/.exec(text);
1196
+ return match ? match[1] : null;
1197
+ }
1198
+
1199
+ /**
1200
+ * Vendor shown in `hardware.drivers[].manufacturer`.
1201
+ *
1202
+ * Apple's own extensions are identified by bundle id / install location; a
1203
+ * third-party extension is either the organisation named in its signing
1204
+ * subject or, when that cannot be parsed, the literal `Third-party` (never a
1205
+ * fabricated company name).
1206
+ * @param {string|null} bundleId
1207
+ * @param {string} extensionPath
1208
+ * @param {string|null} signedBy
1209
+ * @returns {string}
1210
+ */
1211
+ function darwinDriverVendor(bundleId, extensionPath, signedBy) {
1212
+ if (
1213
+ (bundleId !== null && bundleId.startsWith('com.apple.')) ||
1214
+ /\/System\/Library\//.test(extensionPath) ||
1215
+ /TeamID:\s*@apple/.test(signedBy ?? '')
1216
+ ) {
1217
+ return 'Apple';
1218
+ }
1219
+ const subject =
1220
+ /Subject:\s*(?:Developer ID Application|Developer ID Installer|Apple Development|Mac Developer):\s*([^,]+)/.exec(
1221
+ signedBy ?? '',
1222
+ );
1223
+ if (subject) {
1224
+ const vendor = subject[1].replace(/\s*\([A-Z0-9]+\)\s*$/, '').trim();
1225
+ if (vendor) return vendor;
1226
+ }
1227
+ return 'Third-party';
1228
+ }
1229
+
1230
+ /**
1231
+ * Descending version comparison for the driver ordering (`31.0.15` before
1232
+ * `2.4.0`; purely numeric segments so Apple's `1` and `2.4.0` sort sanely).
1233
+ * @param {string|null} left
1234
+ * @param {string|null} right
1235
+ * @returns {number}
1236
+ */
1237
+ function compareDriverVersions(left, right) {
1238
+ const segments = (value) =>
1239
+ str(value) === null
1240
+ ? []
1241
+ : String(value)
1242
+ .split(/[^0-9]+/)
1243
+ .filter((part) => part !== '')
1244
+ .map((part) => Number(part));
1245
+ const a = segments(left);
1246
+ const b = segments(right);
1247
+ const length = Math.max(a.length, b.length);
1248
+ for (let index = 0; index < length; index += 1) {
1249
+ const diff = (b[index] ?? 0) - (a[index] ?? 0);
1250
+ if (diff !== 0) return diff;
1251
+ }
1252
+ return 0;
1253
+ }
1254
+
1255
+ /**
1256
+ * Map `SPExtensionsDataType` records to the frozen `hardware.drivers` shape.
1257
+ * `kind` is `driverkit` for DriverKit/system extensions and `kext` otherwise.
1258
+ *
1259
+ * The ordering keeps the most useful rows when the 250-entry cap bites:
1260
+ * third-party extensions first, then loaded ones, then version descending.
1261
+ * `truncated` reports whether the raw list exceeded {@link MAX_DRIVERS}.
1262
+ * @param {unknown} entries
1263
+ * @returns {{drivers: Array<{name: string, class: string|null,
1264
+ * manufacturer: string|null, version: string|null, date: string|null,
1265
+ * signer: string|null, inf: string|null, deviceId: string|null,
1266
+ * kind: 'kext'|'driverkit'|'pnp', problem: string|null}>,
1267
+ * truncated: boolean}}
1268
+ */
1269
+ function parseDarwinDrivers(entries) {
1270
+ /** @type {Array<{thirdParty: boolean, loaded: boolean, driver: any}>} */
1271
+ const records = [];
1272
+ for (const entry of Array.isArray(entries) ? entries : []) {
1273
+ if (!entry || typeof entry !== 'object') continue;
1274
+ const bundleId = str(entry.spext_bundleid);
1275
+ const name = str(entry._name) ?? bundleId;
1276
+ if (name === null) continue;
1277
+ const extensionPath = str(entry.spext_path) ?? '';
1278
+ const signedBy = str(entry.spext_signed_by);
1279
+ const manufacturer = darwinDriverVendor(bundleId, extensionPath, signedBy);
1280
+ records.push({
1281
+ thirdParty: manufacturer !== 'Apple',
1282
+ loaded: str(entry.spext_loaded) === 'spext_yes',
1283
+ driver: {
1284
+ name,
1285
+ class: null,
1286
+ manufacturer,
1287
+ version: str(entry.spext_version) ?? str(entry.version),
1288
+ date: isoDateOnly(entry.spext_lastModified),
1289
+ signer: signedBy,
1290
+ inf: null,
1291
+ // macOS has no PnP instance id; the extension's bundle identifier is
1292
+ // the stable identity we can report honestly.
1293
+ deviceId: bundleId,
1294
+ kind: /\.dext\/?$|DriverExtensions|SystemExtensions/i.test(extensionPath) ? 'driverkit' : 'kext',
1295
+ problem: null,
1296
+ },
1297
+ });
1298
+ }
1299
+ records.sort((left, right) => {
1300
+ if (left.thirdParty !== right.thirdParty) return left.thirdParty ? -1 : 1;
1301
+ if (left.loaded !== right.loaded) return left.loaded ? -1 : 1;
1302
+ const versionDiff = compareDriverVersions(left.driver.version, right.driver.version);
1303
+ if (versionDiff !== 0) return versionDiff;
1304
+ return (left.driver.name ?? '').localeCompare(right.driver.name ?? '');
1305
+ });
1306
+ const truncated = records.length > MAX_DRIVERS;
1307
+ return { drivers: records.slice(0, MAX_DRIVERS).map((record) => record.driver), truncated };
1308
+ }
1309
+
1310
+ /**
1311
+ * Derive the driver summary from the returned (capped) arrays.
1312
+ * @param {Array<{manufacturer: string|null}>} drivers
1313
+ * @param {Array<object>} problems
1314
+ * @param {boolean} [truncated]
1315
+ * @returns {{total: number, thirdParty: number, problems: number, truncated: boolean}}
1316
+ */
1317
+ function driverSummaryOf(drivers, problems, truncated = false) {
1318
+ return {
1319
+ total: drivers.length,
1320
+ thirdParty: drivers.filter((driver) => driver.manufacturer !== 'Apple').length,
1321
+ problems: problems.length,
1322
+ truncated,
1323
+ };
1324
+ }
1325
+
1326
+ /**
1327
+ * @returns {Promise<object>} the frozen hardware section shape.
1328
+ */
1329
+ async function buildHardware() {
1330
+ const errors = [];
1331
+ const [profiler, sysctlRes, ioregBattery] = await Promise.all([
1332
+ runJson(SYSTEM_PROFILER, ['-json', ...HARDWARE_PROFILER_TYPES], { timeout: 30_000, maxBuffer: 24 * 1024 * 1024 }),
1333
+ run(SYSCTL, ['-n', 'hw.physicalcpu', 'hw.logicalcpu', 'hw.memsize']),
1334
+ run(IOREG, ['-rn', 'AppleSmartBattery'], { timeout: 5_000 }),
1335
+ ]);
1336
+ const sp = profiler.ok && profiler.data && typeof profiler.data === 'object' ? profiler.data : {};
1337
+ if (!profiler.ok) errors.push(`system_profiler hardware: ${shortErr(profiler.error)}`);
1338
+
1339
+ const sysctlValues = lines(sysctlRes.stdout).map((line) => Number(line));
1340
+ const cpus = os.cpus();
1341
+ const physicalCores = num(sysctlValues[0]) ?? cpus.length;
1342
+ const logicalCores = num(sysctlValues[1]) ?? cpus.length;
1343
+ const totalMemoryBytes = num(sysctlValues[2]) ?? os.totalmem();
1344
+ const hw = sp.SPHardwareDataType?.[0] ?? {};
1345
+ const memoryInfo = sp.SPMemoryDataType?.[0] ?? {};
1346
+
1347
+ const cpu = {
1348
+ model: str(hw.chip_type) ?? str(cpus.length ? cpus[0].model : null),
1349
+ vendor: 'Apple',
1350
+ physicalCores,
1351
+ logicalCores,
1352
+ speedGHz: cpus.length && cpus[0].speed ? round(cpus[0].speed / 1000, 1) : null,
1353
+ architecture: process.arch,
1354
+ features: [],
1355
+ };
1356
+
1357
+ const modules = [];
1358
+ const memoryType = str(memoryInfo.dimm_type);
1359
+ modules.push({
1360
+ slot: memoryType ? 'Unified Memory' : 'Memory',
1361
+ sizeBytes: totalMemoryBytes,
1362
+ type: memoryType,
1363
+ speedMHz: num(memoryInfo.dimm_speed) ? Number(memoryInfo.dimm_speed) : null,
1364
+ manufacturer: str(memoryInfo.dimm_manufacturer),
1365
+ partNumber: str(memoryInfo.dimm_part_number),
1366
+ });
1367
+ const memory = {
1368
+ totalBytes: totalMemoryBytes,
1369
+ type: memoryType,
1370
+ speedMHz: modules[0].speedMHz,
1371
+ modules,
1372
+ };
1373
+
1374
+ /** @type {Array<{name: string|null, cores: number|null, vramBytes: number|null, vendor: string|null, metal: string|null}>} */
1375
+ const gpu = [];
1376
+ /** @type {Array<{name: string|null, resolution: string|null, refreshHz: number|null, main: boolean, connection: string|null}>} */
1377
+ const displays = [];
1378
+ for (const card of Array.isArray(sp.SPDisplaysDataType) ? sp.SPDisplaysDataType : []) {
1379
+ if (!card || typeof card !== 'object') continue;
1380
+ gpu.push({
1381
+ name: firstStr(card, ['sppci_model', '_name']),
1382
+ cores: num(card.sppci_cores),
1383
+ vramBytes: bytesFromText(card.sppci_vram) ?? bytesFromText(card.spdisplays_vram),
1384
+ vendor: humaniseGpuVendor(firstStr(card, ['spdisplays_vendor', 'sppci_vendor'])),
1385
+ metal: str(card.spdisplays_mtlgpufamilysupport),
1386
+ });
1387
+ const screens = Array.isArray(card.spdisplays_ndrvs) ? card.spdisplays_ndrvs : [];
1388
+ for (const screen of screens) {
1389
+ if (!screen || typeof screen !== 'object') continue;
1390
+ const resolutionRaw = firstStr(screen, ['_spdisplays_pixels', 'spdisplays_resolution']);
1391
+ const resolution = resolutionRaw ? resolutionRaw.replace(/\s*@.*$/, '').replace(/\s*[x×]\s*/i, 'x') : null;
1392
+ const refresh = /@\s*([\d.]+)\s*Hz/i.exec(firstStr(screen, ['spdisplays_resolution']) ?? '');
1393
+ const connectionType = str(screen.spdisplays_connection_type);
1394
+ displays.push({
1395
+ name: str(screen._name),
1396
+ resolution,
1397
+ refreshHz: refresh ? Number(refresh[1]) : null,
1398
+ main: screen.spdisplays_main === 'spdisplays_yes',
1399
+ connection: displayConnection(connectionType, str(screen.spdisplays_display_type), screen),
1400
+ });
1401
+ }
1402
+ }
1403
+
1404
+ /** @type {Map<string, {name: string|null, model: string|null, sizeBytes: number|null, mediaType: string|null, smartStatus: string|null}>} */
1405
+ const physicalDrives = new Map();
1406
+ /** @type {Array<{name: string|null, mount: string|null, fs: string|null, totalBytes: number|null, usedBytes: number|null, freeBytes: number|null, removable: boolean|null, internal: boolean|null}>} */
1407
+ const volumes = [];
1408
+ for (const volume of Array.isArray(sp.SPStorageDataType) ? sp.SPStorageDataType : []) {
1409
+ if (!volume || typeof volume !== 'object') continue;
1410
+ const drive = volume.physical_drive ?? {};
1411
+ const deviceName = str(drive.device_name);
1412
+ const sizeBytes = num(volume.size_in_bytes);
1413
+ const freeBytes = num(volume.free_space_in_bytes);
1414
+ const isInternal = drive.is_internal_disk === 'yes' ? true : drive.is_internal_disk === 'no' ? false : null;
1415
+ volumes.push({
1416
+ name: str(volume._name),
1417
+ mount: str(volume.mount_point),
1418
+ fs: str(volume.file_system),
1419
+ totalBytes: sizeBytes,
1420
+ usedBytes: sizeBytes !== null && freeBytes !== null ? Math.max(0, sizeBytes - freeBytes) : null,
1421
+ freeBytes,
1422
+ removable: isInternal === null ? null : !isInternal,
1423
+ internal: isInternal,
1424
+ });
1425
+ if (deviceName) {
1426
+ const existing = physicalDrives.get(deviceName);
1427
+ const candidate = {
1428
+ name: str(drive.device_name),
1429
+ model: str(drive.media_name) ?? deviceName,
1430
+ sizeBytes,
1431
+ mediaType: str(drive.medium_type),
1432
+ smartStatus: str(drive.smart_status),
1433
+ };
1434
+ if (!existing || (sizeBytes ?? 0) > (existing.sizeBytes ?? 0)) physicalDrives.set(deviceName, candidate);
1435
+ }
1436
+ }
1437
+
1438
+ let battery = null;
1439
+ const powerInfo = Array.isArray(sp.SPPowerDataType)
1440
+ ? sp.SPPowerDataType.find((entry) => entry && entry._name === 'spbattery_information')
1441
+ : null;
1442
+ const ioreg = parseIoregBattery(ioregBattery.stdout);
1443
+ if (powerInfo || hasBattery(ioreg)) {
1444
+ const charge = powerInfo?.sppower_battery_charge_info ?? {};
1445
+ const health = powerInfo?.sppower_battery_health_info ?? {};
1446
+ const percent = num(charge.sppower_battery_state_of_charge) ?? ioreg.currentCapacity;
1447
+ battery = {
1448
+ percent,
1449
+ condition: str(health.sppower_battery_health),
1450
+ cycleCount: num(health.sppower_battery_cycle_count) ?? ioreg.cycleCount,
1451
+ designCapacityMah: ioreg.designCapacity,
1452
+ maxCapacityMah: ioreg.rawMaxCapacity,
1453
+ temperatureC: ioreg.temperatureC,
1454
+ healthPercent: batteryHealth(ioreg),
1455
+ };
1456
+ }
1457
+
1458
+ /** @type {Array<{name: string|null, vendor: string|null, productId: string|null, serial: string|null, speed: string|null, bus: string|null}>} */
1459
+ const usb = [];
1460
+ /** @type {Array<{name: string|null, address: string|null, connected: boolean, batteryPercent: number|null, type: string|null}>} */
1461
+ const bluetoothDevices = [];
1462
+ let bluetoothController = null;
1463
+ for (const root of Array.isArray(sp.SPUSBDataType) ? sp.SPUSBDataType : []) {
1464
+ if (!root || typeof root !== 'object') continue;
1465
+ const busName = str(root.host_controller) ? str(root._name) : null;
1466
+ flattenUsb([root], busName, usb);
1467
+ }
1468
+ const btEntry = Array.isArray(sp.SPBluetoothDataType) ? sp.SPBluetoothDataType[0] : null;
1469
+ if (btEntry && typeof btEntry === 'object') {
1470
+ const controller = btEntry.controller_properties ?? {};
1471
+ bluetoothController = {
1472
+ address: str(controller.controller_address),
1473
+ chipset: str(controller.controller_chipset),
1474
+ firmware: str(controller.controller_firmwareVersion),
1475
+ state: mapBluetoothState(str(controller.controller_state)),
1476
+ };
1477
+ bluetoothDevices.push(...parseBluetoothGroup(btEntry.device_connected, true));
1478
+ bluetoothDevices.push(...parseBluetoothGroup(btEntry.device_not_connected, false));
1479
+ }
1480
+
1481
+ /** @type {Array<{name: string|null, kind: 'output'|'input', manufacturer: string|null, default: boolean}>} */
1482
+ const audio = [];
1483
+ const audioItems = Array.isArray(sp.SPAudioDataType) ? sp.SPAudioDataType[0]?._items ?? [] : [];
1484
+ for (const item of Array.isArray(audioItems) ? audioItems : []) {
1485
+ if (!item || typeof item !== 'object') continue;
1486
+ const isOutput = item.coreaudio_device_output !== undefined || item.coreaudio_output_source !== undefined;
1487
+ const isInput = item.coreaudio_device_input !== undefined || item.coreaudio_input_source !== undefined;
1488
+ const isDefault =
1489
+ item.coreaudio_default_audio_output_device === 'spaudio_yes' ||
1490
+ item.coreaudio_default_audio_system_device === 'spaudio_yes' ||
1491
+ item.coreaudio_default_audio_input_device === 'spaudio_yes';
1492
+ audio.push({
1493
+ name: str(item._name),
1494
+ kind: isOutput ? 'output' : 'input',
1495
+ manufacturer: str(item.coreaudio_device_manufacturer),
1496
+ default: isDefault && (isOutput || isInput),
1497
+ });
1498
+ }
1499
+
1500
+ /** @type {Array<{name: string|null, modelId: string|null, uniqueId: string|null}>} */
1501
+ const cameras = [];
1502
+ for (const camera of Array.isArray(sp.SPCameraDataType) ? sp.SPCameraDataType : []) {
1503
+ if (!camera || typeof camera !== 'object') continue;
1504
+ cameras.push({
1505
+ name: str(camera._name),
1506
+ modelId: str(camera['spcamera_model-id']),
1507
+ uniqueId: str(camera['spcamera_unique-id']),
1508
+ });
1509
+ }
1510
+
1511
+ // Installed kernel- and DriverKit extensions (SPExtensionsDataType). macOS
1512
+ // does not expose per-device ConfigManagerErrorCode, so `problem` stays null
1513
+ // and `driverProblems` is empty; matching Windows keeps the section shape.
1514
+ const parsedDrivers = parseDarwinDrivers(sp.SPExtensionsDataType);
1515
+ const drivers = parsedDrivers.drivers;
1516
+ /** @type {Array<{name: string, code: string, class: string|null}>} */
1517
+ const driverProblems = [];
1518
+
1519
+ return {
1520
+ cpu,
1521
+ memory,
1522
+ gpu,
1523
+ displays,
1524
+ storage: { physical: [...physicalDrives.values()], volumes },
1525
+ battery,
1526
+ usb,
1527
+ bluetooth: { controller: bluetoothController, devices: bluetoothDevices },
1528
+ audio,
1529
+ cameras,
1530
+ drivers,
1531
+ driverSummary: driverSummaryOf(drivers, driverProblems, parsedDrivers.truncated),
1532
+ driverProblems,
1533
+ errors,
1534
+ };
1535
+ }
1536
+
1537
+ /**
1538
+ * True when a battery is physically present.
1539
+ * @param {ReturnType<typeof parseIoregBattery>} battery
1540
+ * @returns {boolean}
1541
+ */
1542
+ function hasBattery(battery) {
1543
+ return battery.present === true;
1544
+ }
1545
+
1546
+ /**
1547
+ * Flatten the nested `SPUSBDataType` tree.
1548
+ * @param {any[]} nodes
1549
+ * @param {string|null} bus
1550
+ * @param {any[]} out
1551
+ * @returns {void}
1552
+ */
1553
+ function flattenUsb(nodes, bus, out) {
1554
+ for (const node of Array.isArray(nodes) ? nodes : []) {
1555
+ if (!node || typeof node !== 'object') continue;
1556
+ const children = node._items;
1557
+ const childBus = bus ?? (typeof node._name === 'string' ? node._name : null);
1558
+ const hasIdentity = Boolean(node.product_id ?? node.vendor_id ?? node.serial_num);
1559
+ if (hasIdentity) {
1560
+ out.push({
1561
+ name: str(node._name),
1562
+ vendor: str(node.manufacturer) ?? str(node.vendor_id),
1563
+ productId: str(node.product_id),
1564
+ serial: str(node.serial_num),
1565
+ speed: mapUsbSpeed(str(node.device_speed)),
1566
+ bus,
1567
+ });
1568
+ }
1569
+ if (Array.isArray(children)) flattenUsb(children, childBus, out);
1570
+ }
1571
+ }
1572
+
1573
+ /**
1574
+ * @param {string|null} speed
1575
+ * @returns {string|null}
1576
+ */
1577
+ function mapUsbSpeed(speed) {
1578
+ switch (speed) {
1579
+ case 'low_speed':
1580
+ return '1.5 Mbps';
1581
+ case 'full_speed':
1582
+ return '12 Mbps';
1583
+ case 'high_speed':
1584
+ return '480 Mbps';
1585
+ case 'super_speed':
1586
+ return '5 Gbps';
1587
+ case 'super_speed_plus':
1588
+ return '10 Gbps';
1589
+ default:
1590
+ return speed;
1591
+ }
1592
+ }
1593
+
1594
+ /**
1595
+ * @param {string|null} state
1596
+ * @returns {string|null}
1597
+ */
1598
+ function mapBluetoothState(state) {
1599
+ if (!state) return null;
1600
+ if (/attrib_on|on$/i.test(state)) return 'on';
1601
+ if (/attrib_off|off$/i.test(state)) return 'off';
1602
+ return state;
1603
+ }
1604
+
1605
+ /**
1606
+ * @param {any} group
1607
+ * @param {boolean} connected
1608
+ * @returns {Array<{name: string|null, address: string|null, connected: boolean,
1609
+ * batteryPercent: number|null, type: string|null}>}
1610
+ */
1611
+ function parseBluetoothGroup(group, connected) {
1612
+ /** @type {Array<{name: string|null, address: string|null, connected: boolean, batteryPercent: number|null, type: string|null}>} */
1613
+ const out = [];
1614
+ for (const entry of Array.isArray(group) ? group : []) {
1615
+ if (!entry || typeof entry !== 'object') continue;
1616
+ for (const [name, info] of Object.entries(entry)) {
1617
+ if (!info || typeof info !== 'object') continue;
1618
+ out.push({
1619
+ name: str(name),
1620
+ address: str(info.device_address),
1621
+ connected,
1622
+ batteryPercent: firstNum(info, [
1623
+ 'device_batteryLevel',
1624
+ 'device_batteryPercent',
1625
+ 'device_batteryLevelCase',
1626
+ 'device_batteryLevelLeft',
1627
+ 'device_batteryLevelRight',
1628
+ ]),
1629
+ type: firstStr(info, ['device_minorType', 'device_type', 'device_majorType']),
1630
+ });
1631
+ }
1632
+ }
1633
+ return out;
1634
+ }
1635
+
1636
+ /* -------------------------------------------------------------------------- */
1637
+ /* Section: system */
1638
+ /* -------------------------------------------------------------------------- */
1639
+
1640
+ /**
1641
+ * @returns {Promise<object>} the frozen system section shape.
1642
+ */
1643
+ async function buildSystem() {
1644
+ const errors = [];
1645
+ const [facts, users, launchAgents, loginItems, localeInfo, envInfo, bootRes] = await Promise.all([
1646
+ staticFacts(),
1647
+ userFacts(),
1648
+ collectLaunchAgents(errors),
1649
+ collectLoginItems(errors),
1650
+ collectLocale(errors),
1651
+ Promise.resolve(collectEnvironment()),
1652
+ run(SYSCTL, ['-n', 'kern.boottime'], { timeout: 5_000 }),
1653
+ ]);
1654
+
1655
+ const [computerRes, localHostRes, hostRes] = await Promise.all([
1656
+ run(SCUTIL, ['--get', 'ComputerName'], { timeout: 5_000 }),
1657
+ run(SCUTIL, ['--get', 'LocalHostName'], { timeout: 5_000 }),
1658
+ run(SCUTIL, ['--get', 'HostName'], { timeout: 5_000 }),
1659
+ ]);
1660
+
1661
+ const bootMatch = /sec = (\d+)/.exec(bootRes.stdout);
1662
+ const bootTime = bootMatch ? Number(bootMatch[1]) * 1000 : null;
1663
+ const currentUser = users.users.find((user) => user.isCurrent) ?? null;
1664
+
1665
+ return {
1666
+ os: {
1667
+ name: facts.os.name,
1668
+ version: facts.os.version,
1669
+ build: facts.os.build,
1670
+ kernel: facts.os.kernel,
1671
+ arch: facts.os.arch,
1672
+ release: facts.os.release,
1673
+ },
1674
+ device: {
1675
+ hostname: facts.hostname,
1676
+ computerName: str(computerRes.stdout) ?? facts.model,
1677
+ localHostname: str(localHostRes.stdout),
1678
+ model: facts.model,
1679
+ modelId: facts.modelId,
1680
+ chip: facts.chip,
1681
+ serial: facts.serial,
1682
+ hardwareUuid: facts.hardwareUuid,
1683
+ },
1684
+ boot: {
1685
+ bootTime,
1686
+ uptimeSec: Math.floor(os.uptime()),
1687
+ },
1688
+ user: currentUser
1689
+ ? {
1690
+ name: currentUser.name,
1691
+ fullName: currentUser.fullName,
1692
+ uid: currentUser.uid,
1693
+ gid: currentUser.gid,
1694
+ home: currentUser.home,
1695
+ shell: currentUser.shell,
1696
+ admin: currentUser.admin,
1697
+ }
1698
+ : null,
1699
+ users: users.users,
1700
+ launchAgents,
1701
+ loginItems,
1702
+ environment: envInfo,
1703
+ locale: localeInfo,
1704
+ errors,
1705
+ };
1706
+ }
1707
+
1708
+ /**
1709
+ * Enumerate launch agents/daemons from the user and system domains.
1710
+ * @param {string[]} errors
1711
+ * @returns {Promise<Array<{label: string|null, path: string, scope: 'user'|'system',
1712
+ * loaded: boolean, pid: number|null, lastExitStatus: number|null,
1713
+ * program: string}>>}
1714
+ */
1715
+ async function collectLaunchAgents(errors) {
1716
+ const home = os.homedir();
1717
+ const directories = [
1718
+ { dir: path.join(home, 'Library', 'LaunchAgents'), scope: 'user' },
1719
+ { dir: '/Library/LaunchAgents', scope: 'system' },
1720
+ { dir: '/Library/LaunchDaemons', scope: 'system' },
1721
+ ];
1722
+
1723
+ /** @type {string[]} */
1724
+ const plistPaths = [];
1725
+ for (const { dir } of directories) {
1726
+ try {
1727
+ const entries = await fsp.readdir(dir);
1728
+ for (const entry of entries) {
1729
+ if (entry.endsWith('.plist')) plistPaths.push(path.join(dir, entry));
1730
+ }
1731
+ } catch {
1732
+ /* missing directory is normal */
1733
+ }
1734
+ }
1735
+
1736
+ const [listRes] = await Promise.all([run(LAUNCHCTL, ['list'], { timeout: 10_000 })]);
1737
+ const loadedMap = listRes.ok ? parseLaunchctlList(listRes.stdout) : new Map();
1738
+ if (!listRes.ok) errors.push(`launchctl list: ${shortErr(listRes.error)}`);
1739
+
1740
+ const agents = await Promise.all(
1741
+ plistPaths.map(async (plistPath) => {
1742
+ const scope = /** @type {'user'|'system'} */ (plistPath.startsWith(home) ? 'user' : 'system');
1743
+ let parsed = null;
1744
+ try {
1745
+ parsed = await parsePlistFile(plistPath);
1746
+ } catch (err) {
1747
+ errors.push(`launch agent ${path.basename(plistPath)}: ${shortErr(err instanceof Error ? err.message : err)}`);
1748
+ }
1749
+ const label = parsed?.Label ?? null;
1750
+ const program =
1751
+ parsed?.Program ?? (parsed?.ProgramArguments?.length ? String(parsed.ProgramArguments[0]) : null);
1752
+ const entry = label ? loadedMap.get(label) : undefined;
1753
+ let loaded = Boolean(entry);
1754
+ let pid = entry?.pid ?? null;
1755
+ let lastExitStatus = entry?.status ?? null;
1756
+
1757
+ if (scope === 'system' && label && plistPath.startsWith('/Library/LaunchDaemons')) {
1758
+ const printed = await run(LAUNCHCTL, ['print', `system/${label}`], { timeout: 5_000 });
1759
+ if (printed.ok) {
1760
+ loaded = true;
1761
+ const pidMatch = /^\s*pid = (\d+)/m.exec(printed.stdout);
1762
+ if (pidMatch) pid = Number(pidMatch[1]);
1763
+ const exitMatch = /last exit code = (\d+)/.exec(printed.stdout);
1764
+ if (exitMatch) lastExitStatus = Number(exitMatch[1]);
1765
+ } else if (!entry) {
1766
+ loaded = false;
1767
+ }
1768
+ }
1769
+
1770
+ return {
1771
+ label,
1772
+ path: plistPath,
1773
+ scope,
1774
+ loaded,
1775
+ pid,
1776
+ lastExitStatus,
1777
+ program: program ?? '',
1778
+ };
1779
+ }),
1780
+ );
1781
+
1782
+ agents.sort((left, right) => {
1783
+ if (left.scope !== right.scope) return left.scope === 'user' ? -1 : 1;
1784
+ return (left.label ?? left.path).localeCompare(right.label ?? right.path);
1785
+ });
1786
+ return agents;
1787
+ }
1788
+
1789
+ /**
1790
+ * Login items through System Events (best effort; needs automation permission).
1791
+ * @param {string[]} errors
1792
+ * @returns {Promise<Array<{name: string}>>}
1793
+ */
1794
+ async function collectLoginItems(errors) {
1795
+ const result = await run(
1796
+ OSASCRIPT,
1797
+ ['-e', 'tell application "System Events" to get the name of every login item'],
1798
+ { timeout: 5_000 },
1799
+ );
1800
+ if (!result.ok) {
1801
+ errors.push(`login items: ${shortErr(result.stderr || result.error)}`);
1802
+ return [];
1803
+ }
1804
+ const text = result.stdout.trim();
1805
+ if (!text) return [];
1806
+ return text
1807
+ .split(/,\s*/)
1808
+ .map((name) => name.trim())
1809
+ .filter(Boolean)
1810
+ .map((name) => ({ name }));
1811
+ }
1812
+
1813
+ /**
1814
+ * Locale/timezone facts.
1815
+ * @param {string[]} errors
1816
+ * @returns {Promise<{timezone: string|null, locale: string|null, language: string|null}>}
1817
+ */
1818
+ async function collectLocale(errors) {
1819
+ const [localeRes, languagesRes] = await Promise.all([
1820
+ run(DEFAULTS, ['read', '-g', 'AppleLocale'], { timeout: 5_000 }),
1821
+ run(DEFAULTS, ['read', '-g', 'AppleLanguages'], { timeout: 5_000 }),
1822
+ ]);
1823
+ const locale = str(localeRes.stdout);
1824
+ if (!localeRes.ok) errors.push(`AppleLocale: ${shortErr(localeRes.error)}`);
1825
+ let language = null;
1826
+ const quoted = /"([^"]+)"/.exec(languagesRes.stdout);
1827
+ if (quoted) language = quoted[1];
1828
+ if (!language) {
1829
+ const envLang = str(process.env.LANG);
1830
+ if (envLang) language = envLang.split('.')[0];
1831
+ }
1832
+ let timezone = null;
1833
+ try {
1834
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? null;
1835
+ } catch {
1836
+ timezone = null;
1837
+ }
1838
+ return { timezone, locale, language };
1839
+ }
1840
+
1841
+ /**
1842
+ * The whitelisted environment table (only these keys leave the process).
1843
+ * @returns {{PATH: string|null, SHELL: string|null, HOME: string|null,
1844
+ * USER: string|null, LANG: string|null, TERM: string|null}}
1845
+ */
1846
+ function collectEnvironment() {
1847
+ return {
1848
+ PATH: str(process.env.PATH),
1849
+ SHELL: str(process.env.SHELL),
1850
+ HOME: str(process.env.HOME),
1851
+ USER: str(process.env.USER),
1852
+ LANG: str(process.env.LANG),
1853
+ TERM: str(process.env.TERM),
1854
+ };
1855
+ }
1856
+
1857
+ /* -------------------------------------------------------------------------- */
1858
+ /* Section: software */
1859
+ /* -------------------------------------------------------------------------- */
1860
+
1861
+ /**
1862
+ * Parse one `mdls` batch output and attach facts to the matching paths.
1863
+ * @param {string} stdout
1864
+ * @param {string[]} paths
1865
+ * @param {Map<string, {bundleId: string|null, sizeBytes: number|null, lastUsed: number|null}>} target
1866
+ * @returns {void}
1867
+ */
1868
+ function mergeMdlsBatch(stdout, paths, target) {
1869
+ /** @type {Array<{bundleId: string|null, sizeBytes: number|null, lastUsed: number|null}>} */
1870
+ const records = [];
1871
+ /** @type {{bundleId: string|null, sizeBytes: number|null, lastUsed: number|null}|null} */
1872
+ let current = null;
1873
+ for (const rawLine of String(stdout ?? '').split(/\r?\n/)) {
1874
+ const line = rawLine.trim();
1875
+ if (!line) continue;
1876
+ const match = /^kMDItem([A-Za-z]+)\s*=\s*(.*)$/.exec(line);
1877
+ if (!match) continue;
1878
+ const attribute = match[1];
1879
+ const rawValue = match[2].trim();
1880
+ const quoted = /^"(.*)"$/.exec(rawValue);
1881
+ const value = quoted ? quoted[1] : rawValue;
1882
+ const isNull = value === '(null)' || value === 'null' || value === '';
1883
+ if (attribute === 'CFBundleIdentifier') {
1884
+ current = { bundleId: isNull ? null : value, sizeBytes: null, lastUsed: null };
1885
+ records.push(current);
1886
+ continue;
1887
+ }
1888
+ if (!current) continue;
1889
+ if (attribute === 'FSSize') {
1890
+ const size = num(value);
1891
+ current.sizeBytes = size !== null ? size : null;
1892
+ continue;
1893
+ }
1894
+ if (attribute === 'LastUsedDate') {
1895
+ current.lastUsed = isNull ? null : parseMdlsDate(value);
1896
+ }
1897
+ }
1898
+ for (let index = 0; index < paths.length; index += 1) {
1899
+ const record = records[index];
1900
+ if (record) target.set(paths[index], record);
1901
+ }
1902
+ }
1903
+
1904
+ /**
1905
+ * Parse the Spotlight date format ("2026-10-16 10:17:05 +0000").
1906
+ * @param {string} value
1907
+ * @returns {number|null}
1908
+ */
1909
+ function parseMdlsDate(value) {
1910
+ const match = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ([+-])(\d{2})(\d{2})$/.exec(value.trim());
1911
+ if (!match) {
1912
+ const parsed = Date.parse(value);
1913
+ return Number.isFinite(parsed) ? parsed : null;
1914
+ }
1915
+ const iso = `${match[1]}T${match[2]}${match[3]}${match[4]}:${match[5]}`;
1916
+ const parsed = Date.parse(iso);
1917
+ return Number.isFinite(parsed) ? parsed : null;
1918
+ }
1919
+
1920
+ /**
1921
+ * Map a Spotlight/`system_profiler` architecture kind to a readable string.
1922
+ * @param {unknown} kind
1923
+ * @returns {string|null}
1924
+ */
1925
+ function mapArchitecture(kind) {
1926
+ switch (kind) {
1927
+ case 'arch_arm_i64':
1928
+ return 'universal';
1929
+ case 'arch_arm':
1930
+ return 'arm64';
1931
+ case 'arch_i64':
1932
+ return 'x86_64';
1933
+ case 'arch_ios':
1934
+ return 'ios';
1935
+ case 'arch_other':
1936
+ return 'other';
1937
+ default:
1938
+ return str(kind);
1939
+ }
1940
+ }
1941
+
1942
+ /**
1943
+ * Classify an app install location.
1944
+ * @param {string|null} appPath
1945
+ * @returns {'system'|'user'|'other'}
1946
+ */
1947
+ function appSource(appPath) {
1948
+ if (!appPath) return 'other';
1949
+ const home = os.homedir();
1950
+ if (appPath.startsWith('/System/') || appPath.startsWith('/Library/Apple/')) return 'system';
1951
+ if (appPath.startsWith('/Applications/') || appPath.startsWith(`${home}/Applications/`)) return 'user';
1952
+ return 'other';
1953
+ }
1954
+
1955
+ /**
1956
+ * @returns {Promise<object>} the frozen software section shape.
1957
+ */
1958
+ async function buildSoftware() {
1959
+ const errors = [];
1960
+ const homebrewPromise = collectHomebrew(errors);
1961
+ const profiler = await runJson(SYSTEM_PROFILER, ['-json', 'SPApplicationsDataType'], {
1962
+ timeout: 60_000,
1963
+ maxBuffer: 24 * 1024 * 1024,
1964
+ });
1965
+ /** @type {Array<{name: string|null, path: string|null, version: string|null, bundleId: string|null, source: 'system'|'user'|'other', arch: string|null, lastUsed: number|null, sizeBytes: number|null}>} */
1966
+ const applications = [];
1967
+ /** @type {Array<any>} */
1968
+ const rawApps = profiler.ok && Array.isArray(profiler.data?.SPApplicationsDataType) ? profiler.data.SPApplicationsDataType : [];
1969
+ if (!profiler.ok) errors.push(`system_profiler applications: ${shortErr(profiler.error)}`);
1970
+
1971
+ /** @type {Map<string, {bundleId: string|null, sizeBytes: number|null, lastUsed: number|null}>} */
1972
+ const metadata = new Map();
1973
+ const appPaths = rawApps.map((app) => str(app?.path)).filter((value) => typeof value === 'string');
1974
+ const chunks = [];
1975
+ for (let index = 0; index < appPaths.length; index += 60) chunks.push(appPaths.slice(index, index + 60));
1976
+ const mdlsResults = await Promise.all(
1977
+ chunks.map((chunk) =>
1978
+ run(
1979
+ MDLS,
1980
+ ['-name', 'kMDItemCFBundleIdentifier', '-name', 'kMDItemFSSize', '-name', 'kMDItemLastUsedDate', ...chunk],
1981
+ { timeout: 20_000, maxBuffer: 8 * 1024 * 1024 },
1982
+ ),
1983
+ ),
1984
+ );
1985
+ mdlsResults.forEach((result, index) => {
1986
+ if (!result.ok) {
1987
+ errors.push(`mdls applications (batch ${index + 1}): ${shortErr(result.error)}`);
1988
+ return;
1989
+ }
1990
+ mergeMdlsBatch(result.stdout, chunks[index], metadata);
1991
+ });
1992
+
1993
+ for (const app of rawApps) {
1994
+ if (!app || typeof app !== 'object') continue;
1995
+ const appPath = str(app.path);
1996
+ const lookup = appPath ? metadata.get(appPath) : undefined;
1997
+ applications.push({
1998
+ name: str(app._name),
1999
+ path: appPath,
2000
+ version: str(app.version),
2001
+ bundleId: lookup?.bundleId ?? null,
2002
+ source: appSource(appPath),
2003
+ arch: mapArchitecture(app.arch_kind),
2004
+ lastUsed: lookup?.lastUsed ?? null,
2005
+ sizeBytes: lookup?.sizeBytes ?? null,
2006
+ });
2007
+ }
2008
+ applications.sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
2009
+
2010
+ const homebrew = await homebrewPromise;
2011
+
2012
+ return {
2013
+ applications,
2014
+ totalApplications: applications.length,
2015
+ homebrew,
2016
+ errors,
2017
+ };
2018
+ }
2019
+
2020
+ /**
2021
+ * Homebrew installed / outdated facts (cached by the software section TTL).
2022
+ * @param {string[]} errors
2023
+ * @returns {Promise<{available: boolean, prefix: string|null,
2024
+ * installed: Array<{name: string, version: string|null}>, outdated: string[]}>}
2025
+ */
2026
+ async function collectHomebrew(errors) {
2027
+ let brewPath = null;
2028
+ for (const candidate of BREW_CANDIDATES) {
2029
+ try {
2030
+ if (fs.existsSync(candidate)) {
2031
+ brewPath = candidate;
2032
+ break;
2033
+ }
2034
+ } catch {
2035
+ /* keep looking */
2036
+ }
2037
+ }
2038
+ if (!brewPath) {
2039
+ return { available: false, prefix: null, installed: [], outdated: [] };
2040
+ }
2041
+ const prefix = path.dirname(path.dirname(brewPath));
2042
+ const [listRes, outdatedRes] = await Promise.all([
2043
+ run(brewPath, ['list', '--versions'], { timeout: 60_000, maxBuffer: 8 * 1024 * 1024 }),
2044
+ runJson(brewPath, ['outdated', '--json=v2'], { timeout: 60_000, maxBuffer: 8 * 1024 * 1024 }),
2045
+ ]);
2046
+ /** @type {Array<{name: string, version: string|null}>} */
2047
+ const installed = [];
2048
+ if (!listRes.ok) {
2049
+ errors.push(`brew list: ${shortErr(listRes.error)}`);
2050
+ } else {
2051
+ for (const line of lines(listRes.stdout)) {
2052
+ const parts = line.trim().split(/\s+/);
2053
+ if (!parts.length) continue;
2054
+ const name = parts[0];
2055
+ const version = parts.length > 1 ? parts.slice(1).join(', ') : null;
2056
+ installed.push({ name, version });
2057
+ }
2058
+ installed.sort((left, right) => left.name.localeCompare(right.name));
2059
+ }
2060
+ /** @type {string[]} */
2061
+ const outdated = [];
2062
+ if (!outdatedRes.ok) {
2063
+ errors.push(`brew outdated: ${shortErr(outdatedRes.error)}`);
2064
+ } else if (outdatedRes.data && Array.isArray(outdatedRes.data.formulae)) {
2065
+ for (const formula of outdatedRes.data.formulae) {
2066
+ const name = str(formula?.name);
2067
+ if (name) outdated.push(name);
2068
+ }
2069
+ outdated.sort((left, right) => left.localeCompare(right));
2070
+ }
2071
+ return { available: true, prefix, installed, outdated };
2072
+ }
2073
+
2074
+ /* -------------------------------------------------------------------------- */
2075
+ /* Section: network */
2076
+ /* -------------------------------------------------------------------------- */
2077
+
2078
+ /**
2079
+ * @returns {Promise<object>} the frozen network section shape.
2080
+ */
2081
+ async function buildNetwork() {
2082
+ const errors = [];
2083
+ const [ifaceListRaw, portsRes, routeValue, dnsRes, proxyRes, lsofRes, netstatRes, airportRes] = await Promise.all([
2084
+ interfaces(),
2085
+ run(NETWORKSETUP, ['-listallhardwareports'], { timeout: 10_000 }),
2086
+ defaultRoute(),
2087
+ run(SCUTIL, ['--dns'], { timeout: 10_000 }),
2088
+ run(SCUTIL, ['--proxy'], { timeout: 10_000 }),
2089
+ run(LSOF, ['-nP', '-iTCP', '-sTCP:LISTEN'], { timeout: 15_000, maxBuffer: 8 * 1024 * 1024 }),
2090
+ run(NETSTAT, ['-an', '-p', 'tcp'], { timeout: 10_000, maxBuffer: 8 * 1024 * 1024 }),
2091
+ runJson(SYSTEM_PROFILER, ['-json', 'SPAirPortDataType'], { timeout: 20_000 }),
2092
+ ]);
2093
+ if (!portsRes.ok) errors.push(`networksetup hardware ports: ${shortErr(portsRes.error)}`);
2094
+ const hardwarePorts = parseHardwarePorts(portsRes.stdout);
2095
+ const ifaceList = Array.isArray(ifaceListRaw) ? ifaceListRaw : [];
2096
+
2097
+ const defaultInterface = routeValue?.interface ?? null;
2098
+ const selected = ifaceList.filter((iface) => {
2099
+ if (iface.name === 'lo0') return true;
2100
+ if (iface.name === defaultInterface) return true;
2101
+ if (!iface.up) return false;
2102
+ if (iface.ipv4.length) return true;
2103
+ if (iface.ipv6.some((address) => !/^fe80:/i.test(address))) return true;
2104
+ if (iface.status === 'active') return true;
2105
+ // Keep the Wi-Fi port when it is turned off: "Wi-Fi off" is a state, not
2106
+ // a missing interface. Other dead Thunderbolt stubs are dropped.
2107
+ const port = hardwarePorts.get(iface.name);
2108
+ return Boolean(port) && /wi-?fi|airport/i.test(String(port));
2109
+ });
2110
+
2111
+ const interfacesSection = selected.map((iface) => ({
2112
+ name: iface.name,
2113
+ type: interfaceType(iface, hardwarePorts),
2114
+ up: iface.up,
2115
+ ipv4: iface.ipv4,
2116
+ ipv6: iface.ipv6,
2117
+ mac: iface.mac,
2118
+ mtu: iface.mtu,
2119
+ primary: iface.name === defaultInterface,
2120
+ }));
2121
+
2122
+ if (!airportRes.ok) errors.push(`system_profiler airport: ${shortErr(airportRes.error)}`);
2123
+ const wifi = airportRes.ok ? parseAirportWifi(airportRes.data) : null;
2124
+
2125
+ /** @type {{servers: string[], searchDomains: string[]}} */
2126
+ let dns = { servers: [], searchDomains: [] };
2127
+ if (dnsRes.ok) {
2128
+ dns = parseDns(dnsRes.stdout);
2129
+ } else {
2130
+ errors.push(`scutil --dns: ${shortErr(dnsRes.error)}`);
2131
+ }
2132
+
2133
+ /** @type {{http: string|null, https: string|null, socks: string|null}} */
2134
+ let proxy = { http: null, https: null, socks: null };
2135
+ if (proxyRes.ok) {
2136
+ proxy = parseProxy(parseScutilDictionary(proxyRes.stdout));
2137
+ } else {
2138
+ errors.push(`scutil --proxy: ${shortErr(proxyRes.error)}`);
2139
+ }
2140
+
2141
+ /** @type {Array<{proto: string, address: string, port: number, pid: number|null, process: string}>} */
2142
+ let listeningPorts = [];
2143
+ if (lsofRes.ok) {
2144
+ listeningPorts = parseLsofListeners(lsofRes.stdout);
2145
+ const uniquePids = [...new Set(listeningPorts.map((entry) => entry.pid).filter((pid) => pid !== null))];
2146
+ if (uniquePids.length) {
2147
+ const nameRes = await run(PS, ['-p', uniquePids.join(','), '-o', 'pid=,command='], {
2148
+ timeout: 10_000,
2149
+ maxBuffer: 4 * 1024 * 1024,
2150
+ env: utf8Env(),
2151
+ });
2152
+ if (nameRes.ok) {
2153
+ /** @type {Map<number, string>} */
2154
+ const names = new Map();
2155
+ for (const line of lines(nameRes.stdout)) {
2156
+ const match = /^\s*(\d+)\s+(.*)$/.exec(line);
2157
+ if (match) {
2158
+ const procName = commandName(String(match[2] ?? '').trim());
2159
+ if (procName !== null) names.set(Number(match[1]), procName);
2160
+ }
2161
+ }
2162
+ listeningPorts = listeningPorts.map((entry) => ({
2163
+ ...entry,
2164
+ process: entry.pid !== null ? names.get(entry.pid) ?? entry.process : entry.process,
2165
+ }));
2166
+ }
2167
+ }
2168
+ listeningPorts.sort((left, right) => left.port - right.port);
2169
+ } else {
2170
+ errors.push(`lsof listening ports: ${shortErr(lsofRes.error)}`);
2171
+ }
2172
+
2173
+ let establishedCount = 0;
2174
+ if (netstatRes.ok) {
2175
+ establishedCount = String(netstatRes.stdout)
2176
+ .split(/\r?\n/)
2177
+ .filter((line) => /\bESTABLISHED\b/.test(line)).length;
2178
+ } else {
2179
+ errors.push(`netstat connections: ${shortErr(netstatRes.error)}`);
2180
+ }
2181
+
2182
+ return {
2183
+ interfaces: interfacesSection,
2184
+ wifi,
2185
+ defaultRoute: routeValue,
2186
+ dns,
2187
+ proxy,
2188
+ listeningPorts,
2189
+ establishedCount,
2190
+ errors,
2191
+ };
2192
+ }
2193
+
2194
+ /* -------------------------------------------------------------------------- */
2195
+ /* Section: security */
2196
+ /* -------------------------------------------------------------------------- */
2197
+
2198
+ /**
2199
+ * @returns {Promise<object>} the frozen security section shape.
2200
+ */
2201
+ async function buildSecurity() {
2202
+ const errors = [];
2203
+ const [vaultRes, sipRes, gatekeeperRes, firewallRes, stealthRes, remoteRes, autoLoginRes, users] = await Promise.all([
2204
+ run(FD_SETUP, ['status'], { timeout: 10_000 }),
2205
+ run(CSRUTIL, ['status'], { timeout: 10_000 }),
2206
+ run(SPCTL, ['--status'], { timeout: 10_000 }),
2207
+ run(SOCKETFILTERFW, ['--getglobalstate'], { timeout: 10_000 }),
2208
+ run(SOCKETFILTERFW, ['--getstealthmode'], { timeout: 10_000 }),
2209
+ run(SYSTEMSETUP, ['-getremotelogin'], { timeout: 10_000 }),
2210
+ run(DEFAULTS, ['read', '/Library/Preferences/com.apple.loginwindow', 'autoLoginUser'], { timeout: 5_000 }),
2211
+ userFacts(),
2212
+ ]);
2213
+
2214
+ const fileVault = parseStatusFlag(vaultRes, errors, 'FileVault', /FileVault is (On|Off)\./i);
2215
+ const sip = parseStatusFlag(sipRes, errors, 'SIP', /System Integrity Protection status:\s*(enabled|disabled)/i);
2216
+ const gatekeeper = parseStatusFlag(gatekeeperRes, errors, 'Gatekeeper', /assessments (enabled|disabled)/i);
2217
+ const firewall = parseStatusFlag(firewallRes, errors, 'Firewall', /Firewall is (enabled|disabled)/i);
2218
+ let stealthMode = null;
2219
+ if (stealthRes.ok) {
2220
+ const match = /stealth mode is (on|off)/i.exec(stealthRes.stdout);
2221
+ if (match) stealthMode = match[1].toLowerCase() === 'on';
2222
+ } else {
2223
+ errors.push(`firewall stealth: ${shortErr(stealthRes.error)}`);
2224
+ }
2225
+
2226
+ let remoteLogin = null;
2227
+ if (remoteRes.ok) {
2228
+ const match = /Remote Login:\s*(On|Off)/i.exec(remoteRes.stdout);
2229
+ if (match) remoteLogin = match[1].toLowerCase() === 'on';
2230
+ } else {
2231
+ errors.push(`remote login: ${shortErr(remoteRes.stderr || remoteRes.error)}`);
2232
+ }
2233
+
2234
+ const autoLogin = autoLoginRes.ok ? str(autoLoginRes.stdout) : null;
2235
+
2236
+ const tcc = await readTccGrants(errors);
2237
+
2238
+ const currentName = os.userInfo().username;
2239
+ const realUserNames = new Set(users.users.map((user) => user.name));
2240
+ const admins = users.adminNames.filter((name) => realUserNames.has(name));
2241
+
2242
+ return {
2243
+ fileVault,
2244
+ sip,
2245
+ gatekeeper,
2246
+ firewall: { ...firewall, stealthMode },
2247
+ tcc,
2248
+ users: { current: currentName, admins, total: users.users.length },
2249
+ remoteLogin,
2250
+ autoLogin,
2251
+ errors,
2252
+ };
2253
+ }
2254
+
2255
+ /**
2256
+ * Parse one "feature is on/off" command result.
2257
+ * @param {Awaited<ReturnType<typeof run>>} result
2258
+ * @param {string[]} errors
2259
+ * @param {string} label
2260
+ * @param {RegExp} pattern
2261
+ * @returns {{enabled: boolean|null, raw: string|null}}
2262
+ */
2263
+ function parseStatusFlag(result, errors, label, pattern) {
2264
+ const raw = str(result.stdout);
2265
+ if (!result.ok) {
2266
+ errors.push(`${label}: ${shortErr(result.stderr || result.error)}`);
2267
+ return { enabled: null, raw };
2268
+ }
2269
+ const match = pattern.exec(result.stdout);
2270
+ if (!match) return { enabled: null, raw };
2271
+ return { enabled: match[1].toLowerCase() === 'enabled' || match[1].toLowerCase() === 'on', raw };
2272
+ }
2273
+
2274
+ /**
2275
+ * Read TCC grants when the user's TCC database happens to be readable.
2276
+ * @param {string[]} errors
2277
+ * @returns {Promise<{readable: boolean, reason: string|null,
2278
+ * grants: Array<{service: string, client: string}>}>}
2279
+ */
2280
+ async function readTccGrants(errors) {
2281
+ const database = path.join(os.homedir(), 'Library', 'Application Support', 'com.apple.TCC', 'TCC.db');
2282
+ let readable = false;
2283
+ try {
2284
+ await fsp.access(database, fs.constants.R_OK);
2285
+ readable = true;
2286
+ } catch {
2287
+ readable = false;
2288
+ }
2289
+ if (!readable) {
2290
+ return {
2291
+ readable: false,
2292
+ reason:
2293
+ 'macOS protects the TCC privacy database; reading it requires Full Disk Access for the Host process.',
2294
+ grants: [],
2295
+ };
2296
+ }
2297
+ const query = await run(SQLITE3, ['-readonly', database, 'SELECT service, client FROM access WHERE auth_value = 2 LIMIT 500'], {
2298
+ timeout: 10_000,
2299
+ maxBuffer: 4 * 1024 * 1024,
2300
+ });
2301
+ if (!query.ok) {
2302
+ errors.push(`TCC query: ${shortErr(query.stderr || query.error)}`);
2303
+ return { readable: true, reason: null, grants: [] };
2304
+ }
2305
+ /** @type {Array<{service: string, client: string}>} */
2306
+ const grants = [];
2307
+ for (const line of lines(query.stdout)) {
2308
+ const separator = line.indexOf('|');
2309
+ if (separator === -1) continue;
2310
+ grants.push({
2311
+ service: line.slice(0, separator),
2312
+ client: line.slice(separator + 1),
2313
+ });
2314
+ }
2315
+ return { readable: true, reason: null, grants };
2316
+ }
2317
+
2318
+ /* -------------------------------------------------------------------------- */
2319
+ /* Section: processes */
2320
+ /* -------------------------------------------------------------------------- */
2321
+
2322
+ /**
2323
+ * @returns {Promise<object>} the frozen processes section shape.
2324
+ */
2325
+ async function buildProcesses() {
2326
+ const errors = [];
2327
+ let table = [];
2328
+ try {
2329
+ const rows = await processTable();
2330
+ table = Array.isArray(rows) ? rows : [];
2331
+ } catch (err) {
2332
+ errors.push(`ps: ${shortErr(err instanceof Error ? err.message : err)}`);
2333
+ }
2334
+ const sorted = [...table].sort((left, right) => {
2335
+ if (right.cpu !== left.cpu) return right.cpu - left.cpu;
2336
+ return right.memPercent - left.memPercent;
2337
+ });
2338
+ const list = sorted.slice(0, 200);
2339
+ /** @type {Map<string, number>} */
2340
+ const counts = new Map();
2341
+ for (const entry of table) {
2342
+ const user = entry.user ?? 'unknown';
2343
+ counts.set(user, (counts.get(user) ?? 0) + 1);
2344
+ }
2345
+ const byUser = [...counts.entries()]
2346
+ .map(([user, count]) => ({ user, count }))
2347
+ .sort((left, right) => right.count - left.count);
2348
+ return { total: table.length, list, byUser, errors };
2349
+ }
2350
+
2351
+ /* -------------------------------------------------------------------------- */
2352
+ /* Metrics */
2353
+ /* -------------------------------------------------------------------------- */
2354
+
2355
+ /** Previous `os.cpus()` snapshot for delta-based CPU usage. */
2356
+ let cpuSnapshot = null;
2357
+ /** Last computed CPU usage, used when two polls land within the same tick. */
2358
+ /** @type {{usagePercent: number, perCore: number[]}} */
2359
+ let lastCpuUsage = { usagePercent: 0, perCore: [] };
2360
+
2361
+ /**
2362
+ * Whole-machine and per-core CPU usage from successive `os.cpus()` snapshots.
2363
+ * @returns {Promise<{usagePercent: number, perCore: number[]}>}
2364
+ */
2365
+ async function cpuUsage() {
2366
+ const takeSnapshot = () =>
2367
+ os.cpus().map((cpu) => ({
2368
+ idle: cpu.times.idle,
2369
+ total: cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq,
2370
+ }));
2371
+ const compute = (previous, current) => {
2372
+ let idleDelta = 0;
2373
+ let totalDelta = 0;
2374
+ const perCore = current.map((core, index) => {
2375
+ const before = previous[index] ?? core;
2376
+ const idle = Math.max(0, core.idle - before.idle);
2377
+ const total = Math.max(0, core.total - before.total);
2378
+ idleDelta += idle;
2379
+ totalDelta += total;
2380
+ if (total <= 0) return lastCpuUsage.perCore[index] ?? 0;
2381
+ return round(clamp(100 * (1 - idle / total), 0, 100), 1);
2382
+ });
2383
+ const usagePercent =
2384
+ totalDelta > 0 ? round(clamp(100 * (1 - idleDelta / totalDelta), 0, 100), 1) ?? 0 : lastCpuUsage.usagePercent;
2385
+ lastCpuUsage = { usagePercent, perCore };
2386
+ return lastCpuUsage;
2387
+ };
2388
+ const current = takeSnapshot();
2389
+ if (!cpuSnapshot) {
2390
+ await sleep(80);
2391
+ const sampled = takeSnapshot();
2392
+ cpuSnapshot = sampled;
2393
+ return compute(current, sampled);
2394
+ }
2395
+ const previous = cpuSnapshot;
2396
+ cpuSnapshot = current;
2397
+ return compute(previous, current);
2398
+ }
2399
+
2400
+ /* -------------------------------------------------------------------------- */
2401
+ /* Metrics: thermal sensors */
2402
+ /* -------------------------------------------------------------------------- */
2403
+
2404
+ /**
2405
+ * TTL for the whole thermal probe. The UI polls metrics every 2500 ms; the
2406
+ * pressure probe spawns `osascript`, so it must not run on every poll. A 5 s
2407
+ * cache refreshes roughly every other poll.
2408
+ */
2409
+ const THERMAL_TTL = 5_000;
2410
+
2411
+ /**
2412
+ * Optional third-party CPU-temperature helpers, in preference order. They are
2413
+ * invoked argv-only with a short timeout; when none is installed the CPU value
2414
+ * stays null and the note explains why (`cpu-needs-admin`).
2415
+ */
2416
+ const SMC_HELPERS = [
2417
+ { name: 'osx-cpu-temp', args: [] },
2418
+ { name: 'smctemp', args: ['-c'] },
2419
+ { name: 'istats', args: ['cpu', 'temp', '--value-only'] },
2420
+ ];
2421
+
2422
+ /** Extra install locations searched besides `$PATH`. */
2423
+ const HELPER_DIRS = ['/opt/homebrew/bin', '/usr/local/bin'];
2424
+ const POWERMETRICS = '/usr/bin/powermetrics';
2425
+ /** `NSProcessInfo.thermalState` enum, in order. */
2426
+ const THERMAL_PRESSURE_STATES = ['nominal', 'fair', 'serious', 'critical'];
2427
+
2428
+ /**
2429
+ * Resolve an installed helper to an absolute path (execFile argv only).
2430
+ * @param {string} name
2431
+ * @returns {string|null}
2432
+ */
2433
+ function resolveExecutable(name) {
2434
+ const dirs = [...String(process.env.PATH ?? '').split(path.delimiter), ...HELPER_DIRS].filter(Boolean);
2435
+ for (const dir of new Set(dirs)) {
2436
+ const candidate = path.join(dir, name);
2437
+ try {
2438
+ if (fs.existsSync(candidate)) return candidate;
2439
+ } catch {
2440
+ /* keep looking */
2441
+ }
2442
+ }
2443
+ return null;
2444
+ }
2445
+
2446
+ /**
2447
+ * Accept only a physically plausible temperature (guards a garbage sample).
2448
+ * @param {unknown} value
2449
+ * @returns {number|null}
2450
+ */
2451
+ function plausibleCelsius(value) {
2452
+ const parsed = num(value);
2453
+ if (parsed === null || parsed < -20 || parsed > 150) return null;
2454
+ return round(parsed, 1);
2455
+ }
2456
+
2457
+ /**
2458
+ * The contract's null-filled thermal object (used by fallbackMetrics and when
2459
+ * the probe cannot produce anything at all).
2460
+ * @param {'cpu-needs-admin'|'wmi-unsupported'|'unavailable'|null} [note]
2461
+ * @returns {{available: false, cpuCelsius: null, gpuCelsius: null,
2462
+ * batteryCelsius: null, nvmeCelsius: null, pressure: null, fans: never[],
2463
+ * sources: never[], note: 'cpu-needs-admin'|'wmi-unsupported'|'unavailable'|null}}
2464
+ */
2465
+ function emptyThermal(note = null) {
2466
+ return {
2467
+ available: false,
2468
+ cpuCelsius: null,
2469
+ gpuCelsius: null,
2470
+ batteryCelsius: null,
2471
+ nvmeCelsius: null,
2472
+ pressure: null,
2473
+ fans: [],
2474
+ sources: [],
2475
+ note,
2476
+ };
2477
+ }
2478
+
2479
+ /**
2480
+ * True when the Host process runs as root (needed for `powermetrics`).
2481
+ * @returns {boolean}
2482
+ */
2483
+ function isRootUser() {
2484
+ try {
2485
+ return typeof process.getuid === 'function' && process.getuid() === 0;
2486
+ } catch {
2487
+ return false;
2488
+ }
2489
+ }
2490
+
2491
+ /**
2492
+ * Thermal pressure from `NSProcessInfo.thermalState` via JavaScript for
2493
+ * Automation (no admin, no helper). Maps 0/1/2/3 → nominal/fair/serious/
2494
+ * critical; any failure or out-of-range value is null.
2495
+ * @returns {Promise<'nominal'|'fair'|'serious'|'critical'|null>}
2496
+ */
2497
+ async function readThermalPressure() {
2498
+ const script = 'ObjC.import("Foundation"); ObjC.unwrap($.NSProcessInfo.processInfo.thermalState)';
2499
+ const result = await run(OSASCRIPT, ['-l', 'JavaScript', '-e', script], { timeout: 1_500 });
2500
+ if (!result.ok) return null;
2501
+ const level = num(lines(result.stdout)[0]);
2502
+ if (level === null || !Number.isInteger(level) || level < 0 || level >= THERMAL_PRESSURE_STATES.length) return null;
2503
+ return /** @type {'nominal'|'fair'|'serious'|'critical'} */ (THERMAL_PRESSURE_STATES[level]);
2504
+ }
2505
+
2506
+ /**
2507
+ * Run one optional temperature helper (bounded) and parse its first number.
2508
+ * @param {string} executable absolute path
2509
+ * @param {string[]} args
2510
+ * @returns {Promise<number|null>}
2511
+ */
2512
+ async function readHelperCelsius(executable, args) {
2513
+ const result = await run(executable, args, { timeout: 2_000, maxBuffer: 256 * 1024 });
2514
+ if (!result.ok) return null;
2515
+ const match = /(-?\d+(?:\.\d+)?)/.exec(result.stdout);
2516
+ return match ? plausibleCelsius(match[1]) : null;
2517
+ }
2518
+
2519
+ /**
2520
+ * CPU/GPU die temperatures from `powermetrics` — only when the Host is root;
2521
+ * `sudo` would need a password and cannot be used from a service. Never throws.
2522
+ * @returns {Promise<{cpuCelsius: number|null, gpuCelsius: number|null}|null>}
2523
+ */
2524
+ async function readPowermetricsTemps() {
2525
+ if (!isRootUser()) return null;
2526
+ const attempts = [
2527
+ ['--samplers', 'smc', '-i', '1', '-n', '1'],
2528
+ ['--samplers', 'thermal', '-i', '1', '-n', '1'],
2529
+ ];
2530
+ for (const args of attempts) {
2531
+ const result = await run(POWERMETRICS, args, { timeout: 4_000, maxBuffer: 1024 * 1024 });
2532
+ if (!result.ok) continue;
2533
+ const cpuMatch = /(?:CPU(?:\s+die)?\s+temperature)\s*:\s*(-?\d+(?:\.\d+)?)/i.exec(result.stdout);
2534
+ const gpuMatch = /(?:GPU(?:\s+die)?\s+temperature)\s*:\s*(-?\d+(?:\.\d+)?)/i.exec(result.stdout);
2535
+ const cpuCelsius = cpuMatch ? plausibleCelsius(cpuMatch[1]) : null;
2536
+ const gpuCelsius = gpuMatch ? plausibleCelsius(gpuMatch[1]) : null;
2537
+ if (cpuCelsius !== null || gpuCelsius !== null) return { cpuCelsius, gpuCelsius };
2538
+ }
2539
+ return null;
2540
+ }
2541
+
2542
+ /**
2543
+ * The full `metrics.thermal` probe, cached for {@link THERMAL_TTL}.
2544
+ *
2545
+ * `batteryCelsius` reuses the AppleSmartBattery sample the metrics path already
2546
+ * reads (the parser returns the top-level `Temperature` value scaled to °C).
2547
+ * `nvmeCelsius` stays null: on macOS the NVMe temperature is not readable
2548
+ * without root and `ioreg -c AppleEmbeddedNVMeTemperatureSensor` does not expose
2549
+ * a usable temperature, so no source is claimed. `fans` is empty on macOS: the
2550
+ * only fan source (`powermetrics`) is root-only and reports RPM unreliably.
2551
+ *
2552
+ * @param {ReturnType<typeof parseIoregBattery>|null} battery
2553
+ * @returns {Promise<object>} frozen thermal shape
2554
+ */
2555
+ async function thermalMetrics(battery) {
2556
+ const cached = await memo('thermal', THERMAL_TTL, async () => {
2557
+ /** @type {string[]} */
2558
+ const sources = [];
2559
+ /** @type {Array<{name: string|null, rpm: number|null}>} */
2560
+ const fans = [];
2561
+ const pressure = await readThermalPressure().catch(() => null);
2562
+ if (pressure !== null) sources.push('nsprocessinfo');
2563
+
2564
+ const batteryCelsius =
2565
+ battery && typeof battery.temperatureC === 'number' ? plausibleCelsius(battery.temperatureC) : null;
2566
+ if (batteryCelsius !== null) sources.push('ioreg-applesmartbattery');
2567
+
2568
+ let cpuCelsius = null;
2569
+ let gpuCelsius = null;
2570
+ for (const helper of SMC_HELPERS) {
2571
+ const executable = resolveExecutable(helper.name);
2572
+ if (executable === null) continue;
2573
+ const value = await readHelperCelsius(executable, helper.args).catch(() => null);
2574
+ if (value !== null) {
2575
+ cpuCelsius = value;
2576
+ sources.push(helper.name);
2577
+ break;
2578
+ }
2579
+ }
2580
+
2581
+ const rootTemps = await readPowermetricsTemps().catch(() => null);
2582
+ if (rootTemps !== null) {
2583
+ if (cpuCelsius === null && rootTemps.cpuCelsius !== null) cpuCelsius = rootTemps.cpuCelsius;
2584
+ if (rootTemps.gpuCelsius !== null) gpuCelsius = rootTemps.gpuCelsius;
2585
+ if (cpuCelsius !== null || gpuCelsius !== null) sources.push('powermetrics');
2586
+ }
2587
+
2588
+ const note = cpuCelsius === null && gpuCelsius === null ? (isRootUser() ? 'unavailable' : 'cpu-needs-admin') : null;
2589
+ const available = [cpuCelsius, gpuCelsius, batteryCelsius].some(
2590
+ (value) => typeof value === 'number' && Number.isFinite(value),
2591
+ );
2592
+ return {
2593
+ available,
2594
+ cpuCelsius,
2595
+ gpuCelsius,
2596
+ batteryCelsius,
2597
+ nvmeCelsius: null,
2598
+ pressure,
2599
+ fans,
2600
+ sources,
2601
+ note,
2602
+ };
2603
+ });
2604
+ if (cached === null || typeof cached !== 'object') return emptyThermal('unavailable');
2605
+ const safe = jsonSafe(cached);
2606
+ return safe && typeof safe === 'object' ? safe : emptyThermal('unavailable');
2607
+ }
2608
+
2609
+ /**
2610
+ * Build one metrics snapshot. Cheap commands only; slow values come from
2611
+ * caches and are refreshed in the background.
2612
+ * @returns {Promise<object>}
2613
+ */
2614
+ async function buildMetrics() {
2615
+ const [vmRes, swapRes, pressureRes, batteryRes, ioregBatteryRes, netstatRes, ioregDiskRes, usage, facts, processes, disk, routeValue, ifaceList] =
2616
+ await Promise.all([
2617
+ run(VM_STAT, [], { timeout: 5_000 }),
2618
+ run(SYSCTL, ['-n', 'vm.swapusage'], { timeout: 5_000 }),
2619
+ run(SYSCTL, ['-n', 'kern.memorystatus_vm_pressure_level'], { timeout: 5_000 }),
2620
+ run(PMSET, ['-g', 'batt'], { timeout: 5_000 }),
2621
+ run(IOREG, ['-rn', 'AppleSmartBattery'], { timeout: 5_000 }),
2622
+ run(NETSTAT, ['-inb'], { timeout: 5_000, maxBuffer: 4 * 1024 * 1024 }),
2623
+ run(IOREG, ['-c', 'IOBlockStorageDriver', '-r', '-w0'], { timeout: 5_000, maxBuffer: 4 * 1024 * 1024 }),
2624
+ cpuUsage(),
2625
+ staticFacts(),
2626
+ processTable().then((rows) => (Array.isArray(rows) ? rows : [])).catch(() => []),
2627
+ diskUsage(),
2628
+ defaultRoute(),
2629
+ interfaces().then((rows) => (Array.isArray(rows) ? rows : [])).catch(() => []),
2630
+ ]);
2631
+
2632
+ const ts = Date.now();
2633
+ const vm = parseVmStat(vmRes.stdout);
2634
+ const pageSize = vm.pageSize;
2635
+ const memoryTotal = facts.totalMemoryBytes ?? os.totalmem();
2636
+ const freeBytes = vm.free * pageSize;
2637
+ const wiredBytes = vm.wired * pageSize;
2638
+ const compressedBytes = vm.compressed * pageSize;
2639
+ const purgeableBytes = vm.purgeable * pageSize;
2640
+ const cachedBytes = Math.min(memoryTotal, vm.fileBacked * pageSize + purgeableBytes);
2641
+ const appBytes = Math.max(0, vm.anonymous * pageSize - purgeableBytes);
2642
+ const usedBytes = clamp(memoryTotal - freeBytes - cachedBytes, 0, memoryTotal);
2643
+ const swap = parseSwap(swapRes.stdout);
2644
+ const pressureLevel = num(lines(pressureRes.stdout)[0]);
2645
+ const memoryPressure =
2646
+ pressureLevel === null ? null : pressureLevel >= 4 ? 'critical' : pressureLevel >= 2 ? 'warning' : 'normal';
2647
+
2648
+ const batteryPmset = parsePmsetBattery(batteryRes.stdout);
2649
+ const batteryIoreg = parseIoregBattery(ioregBatteryRes.stdout);
2650
+ let battery = null;
2651
+ if (batteryPmset.present || hasBattery(batteryIoreg)) {
2652
+ const timeRemaining = batteryIoreg.timeRemaining;
2653
+ battery = {
2654
+ present: true,
2655
+ percent: batteryPmset.percent ?? batteryIoreg.currentCapacity,
2656
+ charging: batteryIoreg.charging ?? batteryPmset.charging,
2657
+ plugged: batteryIoreg.external ?? batteryPmset.plugged,
2658
+ powerSource: batteryPmset.powerSource,
2659
+ timeRemainingMin: timeRemaining === null || timeRemaining <= 0 || timeRemaining > 6000 ? null : Math.round(timeRemaining),
2660
+ temperatureC: batteryIoreg.temperatureC,
2661
+ cycleCount: batteryIoreg.cycleCount,
2662
+ healthPercent: batteryHealth(batteryIoreg),
2663
+ };
2664
+ }
2665
+
2666
+ const thermal = await thermalMetrics(batteryIoreg);
2667
+
2668
+ const netBytes = parseNetstatBytes(netstatRes.stdout);
2669
+ const netRates = rateOf('network', netBytes, ts);
2670
+ const diskBytes = parseIoregDisk(ioregDiskRes.stdout);
2671
+ const diskRates = rateOf('disk', diskBytes, ts);
2672
+
2673
+ const defaultInterface = routeValue?.interface ?? null;
2674
+ const primaryIface = defaultInterface ? ifaceList.find((iface) => iface.name === defaultInterface) : null;
2675
+ const wifi = wifiMetricSample();
2676
+
2677
+ const topProcesses = [...processes]
2678
+ .sort((left, right) => {
2679
+ if (right.cpu !== left.cpu) return right.cpu - left.cpu;
2680
+ return right.memPercent - left.memPercent;
2681
+ })
2682
+ .slice(0, 5)
2683
+ .map((entry) => ({
2684
+ pid: entry.pid,
2685
+ name: commandName(entry.command),
2686
+ cpu: entry.cpu,
2687
+ memPercent: entry.memPercent,
2688
+ user: entry.user,
2689
+ }));
2690
+
2691
+ return {
2692
+ ts,
2693
+ hostname: facts.hostname,
2694
+ osVersion: facts.os.version
2695
+ ? `${facts.os.name} ${facts.os.version}${facts.os.build ? ` (${facts.os.build})` : ''}`
2696
+ : facts.os.name,
2697
+ uptimeSec: Math.floor(os.uptime()),
2698
+ cpu: {
2699
+ model: facts.cpuModel,
2700
+ usagePercent: usage.usagePercent,
2701
+ perCore: usage.perCore,
2702
+ loadAvg: os.loadavg().map((value) => round(value, 2)),
2703
+ logicalCores: facts.logicalCores,
2704
+ physicalCores: facts.physicalCores,
2705
+ },
2706
+ memory: {
2707
+ totalBytes: memoryTotal,
2708
+ usedBytes,
2709
+ appBytes,
2710
+ wiredBytes,
2711
+ compressedBytes,
2712
+ cachedBytes,
2713
+ freeBytes,
2714
+ swapTotalBytes: swap.total,
2715
+ swapUsedBytes: swap.used,
2716
+ pressure: memoryPressure,
2717
+ },
2718
+ disk: {
2719
+ totalBytes: disk?.totalBytes ?? null,
2720
+ usedBytes: disk?.usedBytes ?? null,
2721
+ freeBytes: disk?.freeBytes ?? null,
2722
+ readBytesPerSec: diskRates.readBytesPerSec,
2723
+ writeBytesPerSec: diskRates.writeBytesPerSec,
2724
+ },
2725
+ network: {
2726
+ rxBytesPerSec: netRates.readBytesPerSec,
2727
+ txBytesPerSec: netRates.writeBytesPerSec,
2728
+ totalRxBytes: netBytes.read,
2729
+ totalTxBytes: netBytes.write,
2730
+ wifi,
2731
+ primaryIpv4: primaryIface ? [...primaryIface.ipv4] : [],
2732
+ defaultInterface,
2733
+ },
2734
+ battery,
2735
+ thermal,
2736
+ processes: {
2737
+ total: processes.length,
2738
+ top: topProcesses,
2739
+ },
2740
+ };
2741
+ }
2742
+
2743
+ /**
2744
+ * Display name for a process command line.
2745
+ * @param {string} command
2746
+ * @returns {string|null}
2747
+ */
2748
+ function commandName(command) {
2749
+ const text = str(command);
2750
+ if (!text) return null;
2751
+ // App bundles put the real binary after `.../Contents/MacOS/`, and both the
2752
+ // bundle name and the binary name may contain spaces.
2753
+ const macOsIndex = text.lastIndexOf('/MacOS/');
2754
+ if (macOsIndex !== -1) {
2755
+ const rest = text.slice(macOsIndex + '/MacOS/'.length);
2756
+ const flagIndex = rest.indexOf(' --');
2757
+ const binary = flagIndex === -1 ? rest : rest.slice(0, flagIndex);
2758
+ return binary.trim() || null;
2759
+ }
2760
+ const first = text.split(/\s+/)[0];
2761
+ return path.basename(first);
2762
+ }
2763
+
2764
+ /**
2765
+ * Best-effort zeroed metrics object (the tick path must never throw).
2766
+ * @returns {object}
2767
+ */
2768
+ function fallbackMetrics() {
2769
+ return {
2770
+ ts: Date.now(),
2771
+ hostname: safeHostname(),
2772
+ osVersion: null,
2773
+ uptimeSec: Math.floor(os.uptime()),
2774
+ cpu: {
2775
+ model: null,
2776
+ usagePercent: 0,
2777
+ perCore: [],
2778
+ loadAvg: os.loadavg().map((value) => round(value, 2)),
2779
+ logicalCores: os.cpus().length,
2780
+ physicalCores: os.cpus().length,
2781
+ },
2782
+ memory: {
2783
+ totalBytes: os.totalmem(),
2784
+ usedBytes: null,
2785
+ appBytes: null,
2786
+ wiredBytes: null,
2787
+ compressedBytes: null,
2788
+ cachedBytes: null,
2789
+ freeBytes: os.freemem(),
2790
+ swapTotalBytes: null,
2791
+ swapUsedBytes: null,
2792
+ pressure: null,
2793
+ },
2794
+ disk: { totalBytes: null, usedBytes: null, freeBytes: null, readBytesPerSec: 0, writeBytesPerSec: 0 },
2795
+ network: {
2796
+ rxBytesPerSec: 0,
2797
+ txBytesPerSec: 0,
2798
+ totalRxBytes: null,
2799
+ totalTxBytes: null,
2800
+ wifi: null,
2801
+ primaryIpv4: [],
2802
+ defaultInterface: null,
2803
+ },
2804
+ battery: null,
2805
+ thermal: emptyThermal(),
2806
+ processes: { total: 0, top: [] },
2807
+ };
2808
+ }
2809
+
2810
+ /**
2811
+ * os.hostname() that cannot throw.
2812
+ * @returns {string|null}
2813
+ */
2814
+ function safeHostname() {
2815
+ try {
2816
+ return os.hostname();
2817
+ } catch {
2818
+ return null;
2819
+ }
2820
+ }
2821
+
2822
+ /* -------------------------------------------------------------------------- */
2823
+ /* Public collectors */
2824
+ /* -------------------------------------------------------------------------- */
2825
+
2826
+ /**
2827
+ * Start slow section collection in the background. Fire-and-forget by design:
2828
+ * the Host calls this once at plugin start so the first panel view is warm.
2829
+ * @returns {void}
2830
+ */
2831
+ export function warmup() {
2832
+ for (const section of SECTIONS) {
2833
+ collectSection(section).catch(() => {
2834
+ /* cache prime only; failures are returned by collectSection later */
2835
+ });
2836
+ }
2837
+ // Prime the metrics-side Wi-Fi sample and shared facts too.
2838
+ Promise.resolve()
2839
+ .then(() => wifiMetricSample())
2840
+ .catch(() => {});
2841
+ Promise.resolve()
2842
+ .then(() => staticFacts())
2843
+ .catch(() => {});
2844
+ }
2845
+
2846
+ /**
2847
+ * One metrics snapshot for the 2500 ms UI poll. Never throws.
2848
+ * @returns {Promise<object>}
2849
+ */
2850
+ export async function collectMetrics() {
2851
+ try {
2852
+ return jsonSafe(await buildMetrics());
2853
+ } catch {
2854
+ return jsonSafe(fallbackMetrics());
2855
+ }
2856
+ }
2857
+
2858
+ /**
2859
+ * Build (or serve from cache) one section.
2860
+ *
2861
+ * Unknown section names reject; every command failure is captured in the
2862
+ * returned section's `errors[]` instead of throwing.
2863
+ *
2864
+ * @param {string} section
2865
+ * @param {{fresh?: boolean}} [options]
2866
+ * @returns {Promise<object>}
2867
+ */
2868
+ export async function collectSection(section, options = {}) {
2869
+ if (!SECTIONS.includes(section)) {
2870
+ throw new Error(`Unknown section: ${String(section)}`);
2871
+ }
2872
+ const fresh = options?.fresh === true;
2873
+ const ttl = SECTION_TTL[section] ?? 30_000;
2874
+ const now = Date.now();
2875
+ const cached = sectionCache.get(section);
2876
+ if (!fresh && cached && now - cached.at < ttl) return cached.value;
2877
+ const pending = sectionInflight.get(section);
2878
+ if (pending) return pending;
2879
+
2880
+ const task = (async () => {
2881
+ const builders = {
2882
+ hardware: buildHardware,
2883
+ system: buildSystem,
2884
+ software: buildSoftware,
2885
+ network: buildNetwork,
2886
+ security: buildSecurity,
2887
+ processes: buildProcesses,
2888
+ };
2889
+ const builder = builders[section];
2890
+ let value;
2891
+ try {
2892
+ value = await builder();
2893
+ } catch (err) {
2894
+ value = { errors: [shortErr(err instanceof Error ? err.message : err)] };
2895
+ }
2896
+ const safe = jsonSafe(value);
2897
+ sectionCache.set(section, { value: safe, at: Date.now() });
2898
+ return safe;
2899
+ })().finally(() => {
2900
+ if (sectionInflight.get(section) === task) sectionInflight.delete(section);
2901
+ });
2902
+
2903
+ sectionInflight.set(section, task);
2904
+ return task;
2905
+ }
2906
+
2907
+ /**
2908
+ * Full inventory for the panel's initial load / `device_info` overview.
2909
+ * @param {{sections?: string[], fresh?: boolean}} [options]
2910
+ * @returns {Promise<{generatedAt: number, platform: string,
2911
+ * hostname: string|null, metrics: object, sections: Record<string, object>,
2912
+ * actions: {catalog: any[], history: any[]}, errors: string[]}>}
2913
+ */
2914
+ export async function collectInventory(options = {}) {
2915
+ const sectionFilter = Array.isArray(options?.sections) ? options.sections : null;
2916
+ const requested = sectionFilter !== null
2917
+ ? SECTIONS.filter((section) => sectionFilter.includes(section))
2918
+ : [...SECTIONS];
2919
+ const fresh = options?.fresh === true;
2920
+ const errors = [];
2921
+
2922
+ const [metrics, ...sectionValues] = await Promise.all([
2923
+ collectMetrics(),
2924
+ ...requested.map((section) => collectSection(section, { fresh })),
2925
+ ]);
2926
+ /** @type {Record<string, object>} */
2927
+ const sections = {};
2928
+ requested.forEach((section, index) => {
2929
+ sections[section] = sectionValues[index];
2930
+ });
2931
+ if (!metrics || typeof metrics !== 'object') errors.push('metrics unavailable');
2932
+
2933
+ return jsonSafe({
2934
+ generatedAt: Date.now(),
2935
+ platform: PLATFORM,
2936
+ hostname: safeHostname(),
2937
+ metrics,
2938
+ sections,
2939
+ actions: { catalog: actionCatalog(), history: actionHistory() },
2940
+ errors,
2941
+ });
2942
+ }
2943
+
2944
+ /**
2945
+ * Drop cached section data (one section or all of them).
2946
+ * @param {string} [section]
2947
+ * @returns {void}
2948
+ */
2949
+ export function clearCache(section) {
2950
+ if (section) {
2951
+ sectionCache.delete(section);
2952
+ return;
2953
+ }
2954
+ sectionCache.clear();
2955
+ }
2956
+
2957
+ /* -------------------------------------------------------------------------- */
2958
+ /* Test seam (node:test) */
2959
+ /* -------------------------------------------------------------------------- */
2960
+
2961
+ /**
2962
+ * Pure parser/helper surface for `test/` and the offline harnesses: no
2963
+ * process spawn, no cache mutation, no state changes.
2964
+ */
2965
+ export const __test = {
2966
+ num,
2967
+ str,
2968
+ parseVmStat,
2969
+ parseSwap,
2970
+ parsePmsetBattery,
2971
+ parseIoregBattery,
2972
+ batteryHealth,
2973
+ parseIoregDisk,
2974
+ parseNetstatBytes,
2975
+ parseDf,
2976
+ parseIfconfig,
2977
+ parseHardwarePorts,
2978
+ interfaceType,
2979
+ parseDefaultRoute,
2980
+ parseDns,
2981
+ parseScutilDictionary,
2982
+ parseProxy,
2983
+ parseLsofListeners,
2984
+ parsePs,
2985
+ parseElapsed,
2986
+ parseLaunchctlList,
2987
+ decodeXml,
2988
+ parseXmlPlist,
2989
+ parseAirportWifi,
2990
+ mergeMdlsBatch,
2991
+ parseMdlsDate,
2992
+ mapArchitecture,
2993
+ parseDarwinDrivers,
2994
+ driverSummaryOf,
2995
+ };