@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,2057 @@
1
+ /**
2
+ * Device X host collectors — Windows implementation.
3
+ *
4
+ * The public data contract is `docs/build-contract.md`: `Metrics`, the six
5
+ * section shapes and `Inventory` are identical to the macOS collector, with
6
+ * `null` where Windows genuinely does not expose a value. The Windows-specific
7
+ * sources are PowerShell/CIM (see `plugin/host/win/*.ps1`). Every call goes
8
+ * through the frozen `./win.js` bridge (encoded command, argv only, bounded
9
+ * buffer, timeout); nothing here invokes a shell or interpolates data into
10
+ * script text.
11
+ *
12
+ * Rules honoured here (same as `collector-darwin.js`):
13
+ * - every data source failure yields null/empty values plus a non-fatal entry
14
+ * in the section's `errors[]`; collectors never throw (unknown section
15
+ * names are the one deliberate exception);
16
+ * - everything returned is JSON-safe: no `undefined`, no NaN/Infinity, no
17
+ * circular references;
18
+ * - slow sections are cached with TTLs, de-duplicated while in flight and can
19
+ * be warmed in the background;
20
+ * - `collectMetrics()` is a fast poll path built from a single PowerShell
21
+ * invocation plus `os.cpus()` deltas for CPU percentages;
22
+ * - CPU%, network and disk byte rates are deltas against the previous sample,
23
+ * exactly like the macOS implementation.
24
+ *
25
+ * @module @cfour-hi/dsh-plugin-devicex/host/collector-win32
26
+ */
27
+ import * as os from 'node:os';
28
+
29
+ import { actionCatalog, actionHistory } from './actions-win32.js';
30
+ import { sleep } from './proc.js';
31
+ import { readScript, runPSJson } from './win.js';
32
+
33
+ /** Host platform for this implementation. */
34
+ export const PLATFORM = 'win32';
35
+
36
+ /** Frozen section identifiers, in UI order. */
37
+ export const SECTIONS = ['hardware', 'system', 'software', 'network', 'security', 'processes'];
38
+
39
+ /** Per-section cache TTLs (ms) — recommended values from docs/windows-contract.md. */
40
+ const SECTION_TTL = {
41
+ hardware: 300_000,
42
+ system: 120_000,
43
+ software: 600_000,
44
+ network: 15_000,
45
+ security: 60_000,
46
+ processes: 30_000,
47
+ };
48
+
49
+ /** Wall-clock timeout per bundled script (PowerShell startup + CIM query). */
50
+ const SCRIPT_TIMEOUT = {
51
+ 'metrics.ps1': 15_000,
52
+ 'hardware.ps1': 30_000,
53
+ 'system.ps1': 25_000,
54
+ 'software.ps1': 45_000,
55
+ 'network.ps1': 30_000,
56
+ 'security.ps1': 30_000,
57
+ 'processes.ps1': 25_000,
58
+ };
59
+
60
+ /**
61
+ * Fast-metrics cache TTL. The macOS collector stays inside the contract's
62
+ * <250 ms poll budget, but one Windows sample needs a PowerShell spawn plus
63
+ * ~20 CIM queries, netsh and per-process owner lookups — routinely far above
64
+ * 250 ms. The UI still polls every 2500 ms, so a 3000 ms TTL serves roughly
65
+ * every other poll from cache and halves the PowerShell load. This is a
66
+ * deliberate, documented deviation from the darwin budget; a <=3 s stale
67
+ * metrics snapshot is acceptable for the panel, and network/disk rates stay
68
+ * meaningful because they are deltas against the previous *sampled* timestamp.
69
+ */
70
+ const METRICS_TTL = 3_000;
71
+
72
+ /* -------------------------------------------------------------------------- */
73
+ /* Small utilities */
74
+ /* -------------------------------------------------------------------------- */
75
+
76
+ /** @param {unknown} value @returns {value is Record<string, any>} */
77
+ function isObject(value) {
78
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
79
+ }
80
+
81
+ /**
82
+ * Coerce to a finite number, else null.
83
+ * @param {unknown} value
84
+ * @returns {number|null}
85
+ */
86
+ function num(value) {
87
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
88
+ if (typeof value === 'string' && value.trim() !== '') {
89
+ const parsed = Number(value);
90
+ return Number.isFinite(parsed) ? parsed : null;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Coerce to a trimmed, non-empty string, else null.
97
+ * @param {unknown} value
98
+ * @returns {string|null}
99
+ */
100
+ function str(value) {
101
+ if (typeof value === 'string') {
102
+ const trimmed = value.trim();
103
+ return trimmed === '' ? null : trimmed;
104
+ }
105
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * Tolerant array normalizer. Windows PowerShell 5.1 `ConvertTo-Json` can
111
+ * collapse a single-element array into a scalar, so arrays emitted by the
112
+ * bundled scripts are normalized through this helper.
113
+ * @param {unknown} value
114
+ * @returns {any[]}
115
+ */
116
+ function asArray(value) {
117
+ if (Array.isArray(value)) return value;
118
+ if (value === null || value === undefined || value === '') return [];
119
+ return [value];
120
+ }
121
+
122
+ /**
123
+ * Coerce a number to a fixed number of decimals (null-safe).
124
+ * @param {unknown} value
125
+ * @param {number} [digits]
126
+ * @returns {number|null}
127
+ */
128
+ function round(value, digits = 1) {
129
+ const parsed = num(value);
130
+ if (parsed === null) return null;
131
+ const factor = 10 ** digits;
132
+ return Math.round(parsed * factor) / factor;
133
+ }
134
+
135
+ /**
136
+ * Clamp a number into [min, max]; non-finite becomes min.
137
+ * @param {unknown} value
138
+ * @param {number} min
139
+ * @param {number} max
140
+ * @returns {number}
141
+ */
142
+ function clamp(value, min, max) {
143
+ const parsed = num(value);
144
+ if (parsed === null) return min;
145
+ return Math.min(max, Math.max(min, parsed));
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 (!isObject(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 (!isObject(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
+ * Tolerant boolean: true/false booleans, "true"/"false" strings, 1/0 numbers.
180
+ * Returns null when the value is not a recognizable boolean.
181
+ * @param {unknown} value
182
+ * @returns {boolean|null}
183
+ */
184
+ function boolOrNull(value) {
185
+ if (typeof value === 'boolean') return value;
186
+ if (typeof value === 'number' && Number.isFinite(value)) {
187
+ if (value === 1) return true;
188
+ if (value === 0) return false;
189
+ return null;
190
+ }
191
+ if (typeof value === 'string') {
192
+ const text = value.trim().toLowerCase();
193
+ if (text === 'true' || text === '1' || text === 'yes' || text === 'on') return true;
194
+ if (text === 'false' || text === '0' || text === 'no' || text === 'off') return false;
195
+ }
196
+ return null;
197
+ }
198
+
199
+ /**
200
+ * Shorten an error message for an `errors[]` entry.
201
+ * @param {unknown} value
202
+ * @param {number} [max]
203
+ * @returns {string}
204
+ */
205
+ function shortErr(value, max = 220) {
206
+ const text = String(value ?? 'unknown error').replace(/\s+/g, ' ').trim();
207
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
208
+ }
209
+
210
+ /**
211
+ * Deep JSON-safe conversion: `undefined`, non-finite numbers and functions
212
+ * become `null`; values are cloned so callers cannot mutate a cache entry.
213
+ * Cycles (which should not exist here) collapse to `null`.
214
+ * @param {unknown} value
215
+ * @param {Set<object>} [seen]
216
+ * @returns {any}
217
+ */
218
+ function jsonSafe(value, seen = new Set()) {
219
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
220
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
221
+ if (typeof value === 'bigint') return Number(value);
222
+ if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol') return null;
223
+ if (typeof value !== 'object') return null;
224
+ if (seen.has(value)) return null;
225
+ seen.add(value);
226
+ if (Array.isArray(value)) {
227
+ const array = value.map((item) => jsonSafe(item, seen));
228
+ seen.delete(value);
229
+ return array;
230
+ }
231
+ if (value instanceof Date) {
232
+ const time = value.getTime();
233
+ seen.delete(value);
234
+ return Number.isFinite(time) ? time : null;
235
+ }
236
+ /** @type {Record<string, unknown>} */
237
+ const object = {};
238
+ for (const [key, item] of Object.entries(value)) {
239
+ object[key] = jsonSafe(item, seen);
240
+ }
241
+ seen.delete(value);
242
+ return object;
243
+ }
244
+
245
+ /** os.hostname() that cannot throw. @returns {string|null} */
246
+ function safeHostname() {
247
+ try {
248
+ return os.hostname();
249
+ } catch {
250
+ return null;
251
+ }
252
+ }
253
+
254
+ /** os.userInfo().username that cannot throw. @returns {string|null} */
255
+ function safeUsername() {
256
+ try {
257
+ return os.userInfo().username;
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ /** Number of logical CPUs that cannot throw. @returns {number} */
264
+ function safeCpuCount() {
265
+ try {
266
+ return os.cpus().length;
267
+ } catch {
268
+ return 0;
269
+ }
270
+ }
271
+
272
+ /** Sum a numeric field across objects, ignoring nulls. */
273
+ function sumField(items, field) {
274
+ let total = null;
275
+ for (const item of items) {
276
+ const value = num(item?.[field]);
277
+ if (value === null) continue;
278
+ total = (total ?? 0) + value;
279
+ }
280
+ return total;
281
+ }
282
+
283
+ /**
284
+ * Normalize the `errors` array of a raw PowerShell document plus an optional
285
+ * transport error. Also produces a populated `errors[]` for empty/failed input.
286
+ * @param {unknown} raw
287
+ * @param {string|null|undefined} transportError
288
+ * @param {string} label
289
+ * @returns {string[]}
290
+ */
291
+ function collectErrors(raw, transportError, label) {
292
+ const errors = [];
293
+ if (transportError) errors.push(`${label}: ${shortErr(transportError)}`);
294
+ if (!isObject(raw)) {
295
+ errors.push(`${label}: PowerShell returned no data`);
296
+ return errors;
297
+ }
298
+ if (Object.keys(raw).length === 0) errors.push(`${label}: PowerShell returned an empty document`);
299
+ for (const item of asArray(raw.errors)) {
300
+ const text = str(item);
301
+ if (text) errors.push(shortErr(text));
302
+ }
303
+ return errors;
304
+ }
305
+
306
+ /* -------------------------------------------------------------------------- */
307
+ /* PowerShell execution seam (injectable for tests) */
308
+ /* -------------------------------------------------------------------------- */
309
+
310
+ /** Default runner: read a bundled script and run it through the frozen bridge. */
311
+ async function defaultRunner(scriptName, options = {}) {
312
+ return runPSJson(readScript(scriptName), {
313
+ timeout: options.timeout ?? SCRIPT_TIMEOUT[scriptName] ?? 20_000,
314
+ });
315
+ }
316
+
317
+ /** @type {(scriptName: string, options?: {timeout?: number}) => Promise<any>} */
318
+ let scriptRunner = defaultRunner;
319
+
320
+ /**
321
+ * Run one bundled collection script. Never throws: a bridge failure becomes
322
+ * `{ok:false, error}`.
323
+ * @param {string} scriptName
324
+ * @returns {Promise<{ok: true, data: any} | {ok: false, error: string}>}
325
+ */
326
+ async function runScript(scriptName) {
327
+ try {
328
+ const result = await scriptRunner(scriptName, { timeout: SCRIPT_TIMEOUT[scriptName] ?? 20_000 });
329
+ if (result && result.ok === true) return { ok: true, data: result.data };
330
+ const error = result && (result.error || result.stderr) ? result.error || result.stderr : 'PowerShell returned no data';
331
+ return { ok: false, error: shortErr(error) };
332
+ } catch (error) {
333
+ return { ok: false, error: shortErr(error instanceof Error ? error.message : error) };
334
+ }
335
+ }
336
+
337
+ /* -------------------------------------------------------------------------- */
338
+ /* Caching */
339
+ /* -------------------------------------------------------------------------- */
340
+
341
+ /** @type {Map<string, {value: any, at: number}>} */
342
+ const sectionCache = new Map();
343
+ /** @type {Map<string, Promise<any>>} */
344
+ const sectionInflight = new Map();
345
+
346
+ /* -------------------------------------------------------------------------- */
347
+ /* Rate tracking (metrics deltas) */
348
+ /* -------------------------------------------------------------------------- */
349
+
350
+ /** @type {Map<string, {at: number, read: number, write: number}>} */
351
+ const rateState = new Map();
352
+
353
+ /**
354
+ * Turn two cumulative byte counters into per-second rates.
355
+ * @param {string} key
356
+ * @param {{read: number, write: number}|null} current
357
+ * @param {number} at
358
+ * @returns {{readBytesPerSec: number, writeBytesPerSec: number}}
359
+ */
360
+ function rateOf(key, current, at) {
361
+ if (!current) return { readBytesPerSec: 0, writeBytesPerSec: 0 };
362
+ const previous = rateState.get(key);
363
+ rateState.set(key, { at, read: current.read, write: current.write });
364
+ if (!previous || at <= previous.at) return { readBytesPerSec: 0, writeBytesPerSec: 0 };
365
+ const dt = (at - previous.at) / 1000;
366
+ if (!(dt > 0)) return { readBytesPerSec: 0, writeBytesPerSec: 0 };
367
+ return {
368
+ readBytesPerSec: Math.max(0, Math.round((current.read - previous.read) / dt)),
369
+ writeBytesPerSec: Math.max(0, Math.round((current.write - previous.write) / dt)),
370
+ };
371
+ }
372
+
373
+ /**
374
+ * Extract a cumulative counter pair from a raw script document.
375
+ * @param {unknown} raw
376
+ * @returns {{read: number, write: number}|null}
377
+ */
378
+ function countersOf(raw) {
379
+ if (!isObject(raw)) return null;
380
+ const read = num(raw.read);
381
+ const write = num(raw.write);
382
+ if (read === null && write === null) return null;
383
+ return { read: read ?? 0, write: write ?? 0 };
384
+ }
385
+
386
+ /* -------------------------------------------------------------------------- */
387
+ /* CPU usage (os.cpus deltas, same algorithm as collector-darwin.js) */
388
+ /* -------------------------------------------------------------------------- */
389
+
390
+ /** Previous `os.cpus()` snapshot for delta-based CPU usage. */
391
+ let cpuSnapshot = null;
392
+ /** Last computed CPU usage, used when two polls land within the same tick. */
393
+ /** @type {{usagePercent: number, perCore: number[]}} */
394
+ let lastCpuUsage = { usagePercent: 0, perCore: [] };
395
+
396
+ /**
397
+ * Whole-machine and per-core CPU usage from successive `os.cpus()` snapshots.
398
+ * @returns {Promise<{usagePercent: number, perCore: number[]}>}
399
+ */
400
+ async function cpuUsage() {
401
+ const takeSnapshot = () =>
402
+ os.cpus().map((cpu) => ({
403
+ idle: cpu.times.idle,
404
+ total: cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq,
405
+ }));
406
+ const compute = (previous, current) => {
407
+ let idleDelta = 0;
408
+ let totalDelta = 0;
409
+ const perCore = current.map((core, index) => {
410
+ const before = previous[index] ?? core;
411
+ const idle = Math.max(0, core.idle - before.idle);
412
+ const total = Math.max(0, core.total - before.total);
413
+ idleDelta += idle;
414
+ totalDelta += total;
415
+ if (total <= 0) return lastCpuUsage.perCore[index] ?? 0;
416
+ return round(clamp(100 * (1 - idle / total), 0, 100), 1);
417
+ });
418
+ const usagePercent =
419
+ totalDelta > 0 ? round(clamp(100 * (1 - idleDelta / totalDelta), 0, 100), 1) ?? 0 : lastCpuUsage.usagePercent;
420
+ lastCpuUsage = { usagePercent, perCore };
421
+ return lastCpuUsage;
422
+ };
423
+ const current = takeSnapshot();
424
+ if (!cpuSnapshot) {
425
+ await sleep(80);
426
+ const sampled = takeSnapshot();
427
+ cpuSnapshot = sampled;
428
+ return compute(current, sampled);
429
+ }
430
+ const previous = cpuSnapshot;
431
+ cpuSnapshot = current;
432
+ return compute(previous, current);
433
+ }
434
+
435
+ /* -------------------------------------------------------------------------- */
436
+ /* Shared value mappers */
437
+ /* -------------------------------------------------------------------------- */
438
+
439
+ /** Map a Win32_Processor.Architecture code to a readable architecture. */
440
+ function mapProcessorArchitecture(value) {
441
+ switch (num(value)) {
442
+ case 0:
443
+ return 'x86';
444
+ case 5:
445
+ return 'arm';
446
+ case 6:
447
+ return 'ia64';
448
+ case 9:
449
+ return 'x64';
450
+ case 12:
451
+ return 'arm64';
452
+ default:
453
+ return null;
454
+ }
455
+ }
456
+
457
+ /** Map a SMBIOS memory type code to a readable generation. */
458
+ function mapMemoryType(value) {
459
+ switch (num(value)) {
460
+ case 20:
461
+ return 'DDR';
462
+ case 21:
463
+ return 'DDR2';
464
+ case 22:
465
+ return 'DDR2 FB-DIMM';
466
+ case 24:
467
+ return 'DDR3';
468
+ case 26:
469
+ return 'DDR4';
470
+ case 27:
471
+ return 'LPDDR';
472
+ case 28:
473
+ return 'LPDDR2';
474
+ case 29:
475
+ return 'LPDDR3';
476
+ case 30:
477
+ return 'LPDDR4';
478
+ case 34:
479
+ return 'DDR5';
480
+ case 35:
481
+ return 'LPDDR5';
482
+ default:
483
+ return null;
484
+ }
485
+ }
486
+
487
+ /** Best-effort GPU vendor from the adapter name. */
488
+ function gpuVendor(name) {
489
+ if (!name) return null;
490
+ if (/nvidia/i.test(name)) return 'NVIDIA';
491
+ if (/amd|radeon/i.test(name)) return 'AMD';
492
+ if (/intel/i.test(name)) return 'Intel';
493
+ if (/apple/i.test(name)) return 'Apple';
494
+ if (/qualcomm|adreno/i.test(name)) return 'Qualcomm';
495
+ if (/microsoft/i.test(name)) return 'Microsoft';
496
+ return null;
497
+ }
498
+
499
+ /**
500
+ * Windows reports Wi-Fi link quality as a percentage, not dBm. The conventional
501
+ * quality% ≈ 2 × (dBm + 100) mapping is used so the UI receives a plausible
502
+ * `rssi`; this is an approximation and is documented as such.
503
+ * @param {unknown} percent
504
+ * @param {unknown} dbm
505
+ * @returns {number|null}
506
+ */
507
+ function rssiFromSignal(percent, dbm) {
508
+ const explicit = num(dbm);
509
+ if (explicit !== null) return Math.round(explicit);
510
+ const quality = num(percent);
511
+ if (quality === null) return null;
512
+ return Math.round(clamp(quality, 0, 100) / 2 - 100);
513
+ }
514
+
515
+ /**
516
+ * Metrics-level Wi-Fi sample: `{ssid, rssi, channel} | null`.
517
+ * @param {unknown} raw
518
+ * @returns {{ssid: string, rssi: number|null, channel: number|null}|null}
519
+ */
520
+ function mapMetricWifi(raw) {
521
+ if (!isObject(raw)) return null;
522
+ const ssid = str(raw.ssid);
523
+ // Windows may redact the SSID behind a location-permission placeholder.
524
+ if (!ssid || /^<.*>$/.test(ssid)) return null;
525
+ return {
526
+ ssid,
527
+ rssi: rssiFromSignal(raw.signalPercent, raw.rssiDbm),
528
+ channel: num(raw.channel),
529
+ };
530
+ }
531
+
532
+ /**
533
+ * Network-section Wi-Fi sample: `{ssid,bssid,rssi,noise,channel,security,txRateMbps} | null`.
534
+ * @param {unknown} raw
535
+ * @returns {{ssid: string, bssid: string|null, rssi: number|null, noise: null,
536
+ * channel: number|null, security: string|null, txRateMbps: number|null}|null}
537
+ */
538
+ function mapNetworkWifi(raw) {
539
+ if (!isObject(raw)) return null;
540
+ const ssid = str(raw.ssid);
541
+ if (!ssid || /^<.*>$/.test(ssid)) return null;
542
+ return {
543
+ ssid,
544
+ bssid: str(raw.bssid),
545
+ rssi: rssiFromSignal(raw.signalPercent, raw.rssiDbm),
546
+ noise: null,
547
+ channel: num(raw.channel),
548
+ security: firstStr(raw, ['authentication', 'security']),
549
+ txRateMbps: num(raw.transmitRateMbps) ?? num(raw.receiveRateMbps),
550
+ };
551
+ }
552
+
553
+ /**
554
+ * Memory pressure estimate from the last metrics sample. Windows has no
555
+ * pressure-level counter, so this is a documented heuristic on available RAM.
556
+ * @param {number|null} totalBytes
557
+ * @param {number|null} freeBytes
558
+ * @param {number|null} availableBytes
559
+ * @returns {'normal'|'warning'|'critical'|null}
560
+ */
561
+ function memoryPressure(totalBytes, freeBytes, availableBytes) {
562
+ const effectiveFree = availableBytes ?? freeBytes;
563
+ if (effectiveFree === null || totalBytes === null || totalBytes <= 0) return null;
564
+ const ratio = effectiveFree / totalBytes;
565
+ if (ratio < 0.05) return 'critical';
566
+ if (ratio < 0.12) return 'warning';
567
+ return 'normal';
568
+ }
569
+
570
+ /**
571
+ * Store the five standard `metrics.battery` fields (present==false → null).
572
+ * @param {unknown} raw
573
+ * @returns {object|null}
574
+ */
575
+ function mapMetricBattery(raw) {
576
+ if (!isObject(raw) || raw.present === false) return null;
577
+ return {
578
+ present: true,
579
+ percent: num(raw.percent),
580
+ charging: boolOrNull(raw.charging) ?? false,
581
+ plugged: boolOrNull(raw.plugged) ?? false,
582
+ powerSource: str(raw.powerSource),
583
+ timeRemainingMin: num(raw.timeRemainingMin),
584
+ temperatureC: round(raw.temperatureC, 1),
585
+ cycleCount: num(raw.cycleCount),
586
+ healthPercent: num(raw.healthPercent),
587
+ };
588
+ }
589
+
590
+ /* -------------------------------------------------------------------------- */
591
+ /* Mapper: metrics.thermal and hardware.drivers */
592
+ /* -------------------------------------------------------------------------- */
593
+
594
+ /** Contract cap for `hardware.drivers` (both platforms). */
595
+ const MAX_DRIVERS = 250;
596
+ /** Contract cap for `hardware.driverProblems` (Windows only). */
597
+ const MAX_DRIVER_PROBLEMS = 50;
598
+
599
+ /** Allowed `metrics.thermal.note` values (frozen). */
600
+ const THERMAL_NOTES = ['cpu-needs-admin', 'wmi-unsupported', 'unavailable'];
601
+
602
+ /**
603
+ * Accept only a physically plausible temperature (a malformed CIM/nvidia-smi
604
+ * value must never become a bogus reading).
605
+ * @param {unknown} value
606
+ * @returns {number|null}
607
+ */
608
+ function plausibleTemperature(value) {
609
+ const parsed = num(value);
610
+ if (parsed === null || parsed < -20 || parsed > 150) return null;
611
+ return round(parsed, 1);
612
+ }
613
+
614
+ /**
615
+ * The contract's null-filled thermal object (fallback metrics / hard failure).
616
+ * @param {'cpu-needs-admin'|'wmi-unsupported'|'unavailable'|null} [note]
617
+ * @returns {{available: false, cpuCelsius: null, gpuCelsius: null,
618
+ * batteryCelsius: null, nvmeCelsius: null, pressure: null, fans: never[],
619
+ * sources: never[], note: 'cpu-needs-admin'|'wmi-unsupported'|'unavailable'|null}}
620
+ */
621
+ function emptyThermal(note = null) {
622
+ return {
623
+ available: false,
624
+ cpuCelsius: null,
625
+ gpuCelsius: null,
626
+ batteryCelsius: null,
627
+ nvmeCelsius: null,
628
+ pressure: null,
629
+ fans: [],
630
+ sources: [],
631
+ note,
632
+ };
633
+ }
634
+
635
+ /**
636
+ * Pure mapper for the `thermal` block emitted by metrics.ps1.
637
+ *
638
+ * Windows sources: `MSAcpi_ThermalZoneTemperature` (WMI thermal zones, the
639
+ * script keeps only plausible zones and reports their max as `cpuCelsius`),
640
+ * optional `nvidia-smi` (GPU temperature + fan percent) and the
641
+ * `Win32_Battery`/`BatteryStatus` block for `batteryCelsius`. There is no
642
+ * Windows thermal-pressure counter, so `pressure` is always null.
643
+ *
644
+ * @param {unknown} raw thermal sub-document
645
+ * @param {object|null} battery mapped `metrics.battery`
646
+ * @returns {object} frozen `metrics.thermal` shape
647
+ */
648
+ function mapThermal(raw, battery) {
649
+ const data = isObject(raw) ? raw : {};
650
+ const fans = asArray(data.fans)
651
+ .filter(isObject)
652
+ .slice(0, 8)
653
+ .map((fan) => ({ name: str(fan.name), rpm: num(fan.rpm) }));
654
+ const cpuCelsius = plausibleTemperature(data.cpuCelsius);
655
+ const gpuCelsius = plausibleTemperature(data.gpuCelsius);
656
+ const batteryCelsius =
657
+ battery && typeof battery.temperatureC === 'number'
658
+ ? plausibleTemperature(battery.temperatureC)
659
+ : plausibleTemperature(data.batteryCelsius);
660
+ const sources = asArray(data.sources)
661
+ .map((value) => str(value))
662
+ .filter((value) => value !== null)
663
+ .slice(0, 12);
664
+ const noteValue = str(data.note);
665
+ const note = noteValue !== null && THERMAL_NOTES.includes(noteValue) ? noteValue : cpuCelsius === null ? 'wmi-unsupported' : null;
666
+ const nvmeCelsius = plausibleTemperature(data.nvmeCelsius);
667
+ const available = [cpuCelsius, gpuCelsius, batteryCelsius, nvmeCelsius].some(
668
+ (value) => typeof value === 'number' && Number.isFinite(value),
669
+ );
670
+ return {
671
+ available,
672
+ cpuCelsius,
673
+ gpuCelsius,
674
+ batteryCelsius,
675
+ nvmeCelsius,
676
+ pressure: null,
677
+ fans,
678
+ sources,
679
+ note,
680
+ };
681
+ }
682
+
683
+ /**
684
+ * Normalise a driver date into the contract's `yyyy-MM-dd` form. Accepts the
685
+ * string already formatted by hardware.ps1 as well as defensive alternatives
686
+ * (ISO timestamps, numeric epoch ms) without ever throwing.
687
+ * @param {unknown} value
688
+ * @returns {string|null}
689
+ */
690
+ function mapDriverDate(value) {
691
+ if (value === null || value === undefined) return null;
692
+ if (typeof value === 'number' && Number.isFinite(value)) {
693
+ const date = new Date(value);
694
+ return Number.isFinite(date.getTime()) ? date.toISOString().slice(0, 10) : null;
695
+ }
696
+ const text = str(value);
697
+ if (text === null) return null;
698
+ const iso = /^(\d{4}-\d{2}-\d{2})/.exec(text);
699
+ if (iso) return iso[1];
700
+ const parsed = Date.parse(text);
701
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString().slice(0, 10) : null;
702
+ }
703
+
704
+ /**
705
+ * Pure mapper for one `Win32_PnPSignedDriver` record.
706
+ * @param {unknown} raw
707
+ * @returns {{name: string, class: string|null, manufacturer: string|null,
708
+ * version: string|null, date: string|null, signer: string|null,
709
+ * inf: string|null, deviceId: string|null, kind: 'kext'|'driverkit'|'pnp',
710
+ * problem: string|null}}
711
+ */
712
+ function mapDriver(raw) {
713
+ const entry = isObject(raw) ? raw : {};
714
+ const name = str(entry.name) ?? str(entry.deviceName) ?? str(entry.DeviceName) ?? 'Unknown device';
715
+ const kind = str(entry.kind);
716
+ return {
717
+ name,
718
+ class: str(entry.class) ?? str(entry.deviceClass),
719
+ manufacturer: str(entry.manufacturer),
720
+ version: str(entry.version) ?? str(entry.driverVersion),
721
+ date: mapDriverDate(entry.date ?? entry.driverDate),
722
+ signer: str(entry.signer) ?? str(entry.driverProviderName),
723
+ inf: str(entry.inf) ?? str(entry.infName),
724
+ deviceId: str(entry.deviceId) ?? str(entry.deviceID),
725
+ kind: kind === 'kext' || kind === 'driverkit' ? kind : 'pnp',
726
+ problem: str(entry.problem),
727
+ };
728
+ }
729
+
730
+ /**
731
+ * Pure mapper for one problem PnP device (`ConfigManagerErrorCode != 0`).
732
+ * @param {unknown} raw
733
+ * @returns {{name: string, code: string, class: string|null}}
734
+ */
735
+ function mapDriverProblem(raw) {
736
+ const entry = isObject(raw) ? raw : {};
737
+ return {
738
+ name: str(entry.name) ?? str(entry.deviceName) ?? 'Unknown device',
739
+ code: str(entry.code) ?? str(entry.configManagerErrorCode) ?? 'unknown',
740
+ class: str(entry.class) ?? str(entry.deviceClass),
741
+ };
742
+ }
743
+
744
+ /**
745
+ * A driver is third-party unless its provider/manufacturer is Microsoft.
746
+ * Empty vendors are not counted as third-party (never invent a value).
747
+ * @param {{signer: string|null, manufacturer: string|null}} driver
748
+ * @returns {boolean}
749
+ */
750
+ function isThirdPartyDriver(driver) {
751
+ const vendor = driver.signer ?? driver.manufacturer;
752
+ return vendor !== null && !/^microsoft/i.test(vendor);
753
+ }
754
+
755
+ /**
756
+ * Build the header-style Windows OS version string, e.g.
757
+ * `Windows 11 23H2 (22631)`.
758
+ * @param {Record<string, any>} rawOs
759
+ * @returns {string|null}
760
+ */
761
+ function windowsOsVersion(rawOs) {
762
+ if (!isObject(rawOs)) return null;
763
+ const caption = str(rawOs.caption);
764
+ const displayVersion = str(rawOs.displayVersion);
765
+ const version = str(rawOs.version);
766
+ const build = str(rawOs.build);
767
+ // Never fabricate a version: without any OS fact the value is genuinely
768
+ // unavailable and the contract allows null.
769
+ if (caption === null && displayVersion === null && version === null && build === null) return null;
770
+ let family = null;
771
+ if (caption) {
772
+ const cleaned = caption.replace(/^Microsoft\s+/i, '').trim();
773
+ const match = /^(Windows\s+\S+)/i.exec(cleaned);
774
+ family = match ? match[1] : cleaned;
775
+ }
776
+ if (!family) family = 'Windows';
777
+ let text = family;
778
+ if (displayVersion) text += ` ${displayVersion}`;
779
+ else if (version) text += ` ${version}`;
780
+ if (build && !text.includes(build)) text += ` (${build})`;
781
+ return text;
782
+ }
783
+
784
+ /* -------------------------------------------------------------------------- */
785
+ /* Mapper: metrics */
786
+ /* -------------------------------------------------------------------------- */
787
+
788
+ /**
789
+ * Pure mapper for `metrics.ps1` output.
790
+ *
791
+ * `context.cpu` carries the `os.cpus()` delta sample; `context.rates` carries
792
+ * the network/disk byte-rate deltas. Both default to safe zero values so the
793
+ * mapper is directly usable by the offline fixture verification.
794
+ *
795
+ * @param {unknown} raw
796
+ * @param {{cpu?: {usagePercent?: number, perCore?: number[]}, rates?: {network?: object, disk?: object}, now?: number}} [context]
797
+ * @returns {object} frozen `Metrics` shape
798
+ */
799
+ function mapMetrics(raw, context = {}) {
800
+ const data = isObject(raw) ? raw : {};
801
+ const now = num(context.now) ?? Date.now();
802
+ const cpuSample = isObject(context.cpu) ? context.cpu : {};
803
+ const rates = isObject(context.rates) ? context.rates : {};
804
+ const diskRates = isObject(rates.disk) ? rates.disk : {};
805
+ const networkRates = isObject(rates.network) ? rates.network : {};
806
+
807
+ const cpuRaw = isObject(data.cpu) ? data.cpu : {};
808
+ const memoryRaw = isObject(data.memory) ? data.memory : {};
809
+ const diskRaw = isObject(data.disk) ? data.disk : {};
810
+ const networkRaw = isObject(data.network) ? data.network : {};
811
+ const processRaw = isObject(data.processes) ? data.processes : {};
812
+ const osRaw = isObject(data.os) ? data.os : {};
813
+ const battery = mapMetricBattery(data.battery);
814
+
815
+ const logicalCores = num(cpuRaw.logicalCores) ?? safeCpuCount() ?? 0;
816
+ const physicalCores = num(cpuRaw.physicalCores) ?? logicalCores;
817
+ const perCore = asArray(cpuSample.perCore)
818
+ .map((value) => round(value, 1))
819
+ .filter((value) => value !== null);
820
+
821
+ const totalBytes = num(memoryRaw.totalBytes) ?? os.totalmem();
822
+ const freeBytes = num(memoryRaw.freeBytes) ?? os.freemem();
823
+ const usedBytes =
824
+ num(memoryRaw.usedBytes) ?? (totalBytes !== null && freeBytes !== null ? Math.max(0, totalBytes - freeBytes) : null);
825
+ const availableBytes = num(memoryRaw.availableBytes);
826
+ const cachedBytes = num(memoryRaw.cacheBytes);
827
+
828
+ const networkCounters = countersOf(networkRaw.counters);
829
+ const bootTimeMs = num(osRaw.lastBootUpTimeMs);
830
+
831
+ const topProcesses = asArray(processRaw.top)
832
+ .filter(isObject)
833
+ .slice(0, 5)
834
+ .map((entry) => {
835
+ const workingSetBytes = num(entry.workingSetBytes);
836
+ const memPercent =
837
+ num(entry.memPercent) ??
838
+ (workingSetBytes !== null && totalBytes !== null && totalBytes > 0
839
+ ? round(clamp((workingSetBytes / totalBytes) * 100, 0, 100), 1)
840
+ : 0);
841
+ return {
842
+ pid: num(entry.pid) ?? 0,
843
+ name: str(entry.name),
844
+ cpu: round(clamp((num(entry.cpuPercent) ?? 0) / Math.max(1, logicalCores), 0, 100), 1) ?? 0,
845
+ memPercent: memPercent ?? 0,
846
+ user: str(entry.user),
847
+ };
848
+ });
849
+
850
+ const hostname = str(data.hostname) ?? safeHostname() ?? 'unknown';
851
+ const osVersion = windowsOsVersion(osRaw);
852
+ const uptimeSec =
853
+ bootTimeMs !== null && bootTimeMs > 0 && now > bootTimeMs
854
+ ? Math.floor((now - bootTimeMs) / 1000)
855
+ : (() => {
856
+ try {
857
+ return Math.floor(os.uptime());
858
+ } catch {
859
+ return null;
860
+ }
861
+ })();
862
+
863
+ return {
864
+ ts: now,
865
+ hostname,
866
+ osVersion,
867
+ uptimeSec,
868
+ cpu: {
869
+ model: str(cpuRaw.name) ?? (safeCpuCount() > 0 ? str(os.cpus()[0]?.model) : null),
870
+ usagePercent: round(cpuSample.usagePercent, 1) ?? 0,
871
+ perCore,
872
+ loadAvg: [], // Windows has no load-average metric; empty (never faked).
873
+ logicalCores,
874
+ physicalCores,
875
+ },
876
+ memory: {
877
+ totalBytes,
878
+ usedBytes,
879
+ appBytes: num(memoryRaw.appBytes),
880
+ wiredBytes: num(memoryRaw.wiredBytes),
881
+ compressedBytes: num(memoryRaw.compressedBytes),
882
+ cachedBytes:
883
+ cachedBytes !== null && totalBytes !== null ? clamp(Math.round(cachedBytes), 0, totalBytes) : null,
884
+ freeBytes,
885
+ swapTotalBytes: num(memoryRaw.pageFileTotalBytes),
886
+ swapUsedBytes: num(memoryRaw.pageFileUsedBytes),
887
+ pressure: memoryPressure(totalBytes, freeBytes, availableBytes),
888
+ },
889
+ disk: {
890
+ totalBytes: num(diskRaw.totalBytes),
891
+ usedBytes: num(diskRaw.usedBytes),
892
+ freeBytes: num(diskRaw.freeBytes),
893
+ readBytesPerSec: num(diskRates.readBytesPerSec) ?? 0,
894
+ writeBytesPerSec: num(diskRates.writeBytesPerSec) ?? 0,
895
+ },
896
+ network: {
897
+ rxBytesPerSec: num(networkRates.readBytesPerSec) ?? 0,
898
+ txBytesPerSec: num(networkRates.writeBytesPerSec) ?? 0,
899
+ totalRxBytes: networkCounters ? networkCounters.read : null,
900
+ totalTxBytes: networkCounters ? networkCounters.write : null,
901
+ wifi: mapMetricWifi(networkRaw.wifi),
902
+ primaryIpv4: asArray(networkRaw.primaryIpv4)
903
+ .map((value) => str(value))
904
+ .filter((value) => value !== null),
905
+ defaultInterface: str(networkRaw.defaultInterface),
906
+ },
907
+ battery,
908
+ thermal: mapThermal(data.thermal, battery),
909
+ processes: {
910
+ total: num(processRaw.total) ?? 0,
911
+ top: topProcesses,
912
+ },
913
+ };
914
+ }
915
+
916
+ /* -------------------------------------------------------------------------- */
917
+ /* Mapper: hardware */
918
+ /* -------------------------------------------------------------------------- */
919
+
920
+ /**
921
+ * Pure mapper for `hardware.ps1` output.
922
+ * @param {unknown} raw
923
+ * @param {string|null} [transportError]
924
+ * @returns {object} frozen `hardware` section shape
925
+ */
926
+ function mapHardware(raw, transportError) {
927
+ const label = 'hardware.ps1';
928
+ const data = isObject(raw) ? raw : {};
929
+ const errors = collectErrors(raw, transportError, label);
930
+ const cpuRaw = isObject(data.cpu) ? data.cpu : {};
931
+ const memoryRaw = isObject(data.memory) ? data.memory : {};
932
+ const physicalRaw = asArray(data.physicalDisks).filter(isObject);
933
+ const diskDrivesRaw = asArray(data.diskDrives).filter(isObject);
934
+ const controllerRaw = asArray(data.videoControllers).filter(isObject);
935
+ const monitorRaw = asArray(data.monitors).filter(isObject);
936
+
937
+ const modules = asArray(memoryRaw.modules)
938
+ .filter(isObject)
939
+ .map((module) => ({
940
+ slot: str(module.deviceLocator) ?? str(module.slot),
941
+ sizeBytes: num(module.capacityBytes) ?? num(module.sizeBytes),
942
+ type: mapMemoryType(module.smbiosType) ?? str(module.type),
943
+ speedMHz: num(module.speedMHz) ?? num(module.configuredSpeedMHz),
944
+ manufacturer: str(module.manufacturer),
945
+ partNumber: str(module.partNumber),
946
+ }));
947
+
948
+ const moduleTotal = sumField(modules, 'sizeBytes');
949
+ const totalBytes = num(memoryRaw.totalBytes) ?? moduleTotal;
950
+ const memory = {
951
+ totalBytes,
952
+ type: modules.length > 0 ? modules[0].type : str(memoryRaw.type),
953
+ speedMHz: modules.length > 0 ? modules[0].speedMHz : num(memoryRaw.speedMHz),
954
+ modules,
955
+ };
956
+
957
+ const gpu = controllerRaw.map((controller) => ({
958
+ name: str(controller.name),
959
+ cores: num(controller.cores),
960
+ vramBytes: num(controller.adapterRamBytes),
961
+ vendor: gpuVendor(str(controller.name)),
962
+ metal: null,
963
+ }));
964
+
965
+ /** @type {Array<{name: string|null, resolution: string|null, refreshHz: number|null, main: boolean, connection: string|null}>} */
966
+ const displays = [];
967
+ for (const controller of controllerRaw) {
968
+ const width = num(controller.currentWidth);
969
+ const height = num(controller.currentHeight);
970
+ if (width === null || height === null || width <= 0 || height <= 0) continue;
971
+ displays.push({
972
+ name: str(controller.name),
973
+ resolution: `${width}x${height}`,
974
+ refreshHz: num(controller.currentRefreshHz),
975
+ main: displays.length === 0,
976
+ connection: null,
977
+ });
978
+ }
979
+ if (displays.length === 0 && monitorRaw.length > 0) {
980
+ monitorRaw.forEach((monitor, index) => {
981
+ displays.push({
982
+ name: str(monitor.name) ?? str(monitor.manufacturer),
983
+ resolution: null,
984
+ refreshHz: null,
985
+ main: index === 0,
986
+ connection: null,
987
+ });
988
+ });
989
+ }
990
+
991
+ const physical =
992
+ physicalRaw.length > 0
993
+ ? physicalRaw.map((disk) => ({
994
+ name: str(disk.name) ?? str(disk.model),
995
+ model: str(disk.model) ?? str(disk.name),
996
+ sizeBytes: num(disk.sizeBytes),
997
+ mediaType: str(disk.mediaType),
998
+ smartStatus: str(disk.smartStatus),
999
+ }))
1000
+ : diskDrivesRaw.map((disk) => ({
1001
+ name: str(disk.model),
1002
+ model: str(disk.model),
1003
+ sizeBytes: num(disk.sizeBytes),
1004
+ mediaType: str(disk.mediaType),
1005
+ smartStatus: null,
1006
+ }));
1007
+
1008
+ const volumes = asArray(data.volumes)
1009
+ .filter(isObject)
1010
+ .map((volume) => {
1011
+ const total = num(volume.totalBytes);
1012
+ const free = num(volume.freeBytes);
1013
+ const driveType = num(volume.driveType);
1014
+ return {
1015
+ name: str(volume.name) ?? str(volume.deviceId),
1016
+ mount: str(volume.deviceId),
1017
+ fs: str(volume.fs),
1018
+ totalBytes: total,
1019
+ usedBytes: total !== null && free !== null ? Math.max(0, total - free) : null,
1020
+ freeBytes: free,
1021
+ removable: driveType === null ? null : driveType === 2,
1022
+ internal: driveType === null ? null : driveType === 3,
1023
+ };
1024
+ });
1025
+
1026
+ let battery = null;
1027
+ if (isObject(data.battery) && data.battery.present !== false) {
1028
+ battery = {
1029
+ percent: num(data.battery.percent),
1030
+ condition: str(data.battery.condition),
1031
+ cycleCount: num(data.battery.cycleCount),
1032
+ designCapacityMah: num(data.battery.designCapacityMah),
1033
+ maxCapacityMah: num(data.battery.maxCapacityMah),
1034
+ temperatureC: round(data.battery.temperatureC, 1),
1035
+ healthPercent: num(data.battery.healthPercent),
1036
+ };
1037
+ }
1038
+
1039
+ const usb = asArray(data.usb)
1040
+ .filter(isObject)
1041
+ .map((device) => ({
1042
+ name: str(device.name),
1043
+ vendor: str(device.vendor),
1044
+ productId: str(device.productId),
1045
+ serial: str(device.serial),
1046
+ speed: str(device.speed),
1047
+ bus: str(device.bus),
1048
+ }));
1049
+
1050
+ const bluetoothControllers = asArray(data.bluetoothControllers).filter(isObject);
1051
+ const bluetoothController =
1052
+ bluetoothControllers.length > 0
1053
+ ? {
1054
+ address: str(bluetoothControllers[0].address),
1055
+ chipset: str(bluetoothControllers[0].chipset) ?? str(bluetoothControllers[0].name),
1056
+ firmware: str(bluetoothControllers[0].firmware),
1057
+ state: str(bluetoothControllers[0].state),
1058
+ }
1059
+ : null;
1060
+ const bluetoothDevices = asArray(data.bluetoothDevices)
1061
+ .filter(isObject)
1062
+ .map((device) => ({
1063
+ name: str(device.name),
1064
+ address: str(device.address),
1065
+ connected: device.connected === false ? false : true,
1066
+ batteryPercent: num(device.batteryPercent),
1067
+ type: str(device.type),
1068
+ }));
1069
+
1070
+ const audio = asArray(data.audio)
1071
+ .filter(isObject)
1072
+ .map((device) => ({
1073
+ name: str(device.name),
1074
+ kind: device.kind === 'input' ? 'input' : 'output',
1075
+ manufacturer: str(device.manufacturer),
1076
+ default: device.default === true,
1077
+ }));
1078
+
1079
+ const cameras = asArray(data.cameras)
1080
+ .filter(isObject)
1081
+ .map((camera) => ({
1082
+ name: str(camera.name),
1083
+ modelId: str(camera.modelId),
1084
+ uniqueId: str(camera.uniqueId),
1085
+ }));
1086
+
1087
+ // `drivers` comes from Win32_PnPSignedDriver (cap 250); `driverProblems` from
1088
+ // Win32_PnPEntity rows whose ConfigManagerErrorCode is non-zero (cap 50).
1089
+ // The summary is derived here so it can never disagree with the arrays;
1090
+ // `truncated` flags that the raw driver list exceeded the cap.
1091
+ const rawDrivers = asArray(data.drivers).filter(isObject);
1092
+ const drivers = rawDrivers.slice(0, MAX_DRIVERS).map(mapDriver);
1093
+ const driverProblems = asArray(data.driverProblems)
1094
+ .filter(isObject)
1095
+ .slice(0, MAX_DRIVER_PROBLEMS)
1096
+ .map(mapDriverProblem);
1097
+ const driverSummary = {
1098
+ total: drivers.length,
1099
+ thirdParty: drivers.filter(isThirdPartyDriver).length,
1100
+ problems: driverProblems.length,
1101
+ truncated: rawDrivers.length > MAX_DRIVERS,
1102
+ };
1103
+
1104
+ return {
1105
+ cpu: {
1106
+ model: str(cpuRaw.name),
1107
+ vendor: str(cpuRaw.manufacturer),
1108
+ physicalCores: num(cpuRaw.physicalCores) ?? safeCpuCount(),
1109
+ logicalCores: num(cpuRaw.logicalCores) ?? safeCpuCount(),
1110
+ speedGHz: (() => {
1111
+ const mhz = num(cpuRaw.maxClockSpeedMHz);
1112
+ return mhz === null ? null : round(mhz / 1000, 2);
1113
+ })(),
1114
+ architecture: mapProcessorArchitecture(cpuRaw.architecture),
1115
+ features: asArray(cpuRaw.features)
1116
+ .map((value) => str(value))
1117
+ .filter((value) => value !== null),
1118
+ },
1119
+ memory,
1120
+ gpu,
1121
+ displays,
1122
+ storage: { physical, volumes },
1123
+ battery,
1124
+ usb,
1125
+ bluetooth: { controller: bluetoothController, devices: bluetoothDevices },
1126
+ audio,
1127
+ cameras,
1128
+ drivers,
1129
+ driverSummary,
1130
+ driverProblems,
1131
+ errors,
1132
+ };
1133
+ }
1134
+
1135
+ /* -------------------------------------------------------------------------- */
1136
+ /* Mapper: system */
1137
+ /* -------------------------------------------------------------------------- */
1138
+
1139
+ /** Map PROCESSOR_ARCHITECTURE to the contract-ish arch string. */
1140
+ function mapHostArchitecture(value) {
1141
+ const text = str(value);
1142
+ if (!text) return null;
1143
+ switch (text.toUpperCase()) {
1144
+ case 'AMD64':
1145
+ return 'x64';
1146
+ case 'ARM64':
1147
+ return 'arm64';
1148
+ case 'X86':
1149
+ return 'x86';
1150
+ case 'IA64':
1151
+ return 'ia64';
1152
+ default:
1153
+ return text.toLowerCase();
1154
+ }
1155
+ }
1156
+
1157
+ /**
1158
+ * Pure mapper for `system.ps1` output.
1159
+ * @param {unknown} raw
1160
+ * @param {string|null} [transportError]
1161
+ * @returns {object} frozen `system` section shape
1162
+ */
1163
+ function mapSystem(raw, transportError) {
1164
+ const label = 'system.ps1';
1165
+ const data = isObject(raw) ? raw : {};
1166
+ const errors = collectErrors(raw, transportError, label);
1167
+ const osRaw = isObject(data.os) ? data.os : {};
1168
+ const deviceRaw = isObject(data.device) ? data.device : {};
1169
+ const bootRaw = isObject(data.boot) ? data.boot : {};
1170
+ const usersRaw = isObject(data.users) ? data.users : {};
1171
+ const currentRaw = isObject(usersRaw.current) ? usersRaw.current : null;
1172
+ const environmentRaw = isObject(data.environment) ? data.environment : {};
1173
+ const localeRaw = isObject(data.locale) ? data.locale : {};
1174
+
1175
+ const hostname = str(deviceRaw.hostname) ?? safeHostname() ?? 'unknown';
1176
+ const computerName = str(deviceRaw.computerName) ?? hostname;
1177
+ const localHostname = str(deviceRaw.localHostname) ?? hostname;
1178
+
1179
+ const caption = str(osRaw.caption);
1180
+ let family = null;
1181
+ if (caption) {
1182
+ const cleaned = caption.replace(/^Microsoft\s+/i, '').trim();
1183
+ const match = /^(Windows\s+\S+)/i.exec(cleaned);
1184
+ family = match ? match[1] : cleaned;
1185
+ }
1186
+
1187
+ const version = str(osRaw.version);
1188
+ const build = str(osRaw.build);
1189
+ const bootTime = num(bootRaw.bootTimeMs);
1190
+ const now = Date.now();
1191
+ let uptimeSec = num(bootRaw.uptimeSec);
1192
+ if (uptimeSec === null) {
1193
+ if (bootTime !== null && bootTime > 0 && now > bootTime) {
1194
+ uptimeSec = Math.floor((now - bootTime) / 1000);
1195
+ } else {
1196
+ try {
1197
+ uptimeSec = Math.floor(os.uptime());
1198
+ } catch {
1199
+ uptimeSec = null;
1200
+ }
1201
+ }
1202
+ }
1203
+
1204
+ const currentName = str(usersRaw.current) ?? str(currentRaw?.name) ?? safeUsername();
1205
+ const adminNames = asArray(usersRaw.admins)
1206
+ .map((value) => str(value))
1207
+ .filter((value) => value !== null);
1208
+ const adminSet = new Set(adminNames.map((value) => value.toLowerCase()));
1209
+
1210
+ const currentFullName = str(currentRaw?.fullName);
1211
+ const currentHome = str(currentRaw?.home) ?? str(environmentRaw.HOME);
1212
+ const user =
1213
+ currentName === null
1214
+ ? null
1215
+ : {
1216
+ name: currentName,
1217
+ fullName: currentFullName,
1218
+ uid: null,
1219
+ gid: null,
1220
+ home: currentHome,
1221
+ shell: null,
1222
+ admin: adminSet.has(currentName.toLowerCase()),
1223
+ };
1224
+
1225
+ const users = asArray(usersRaw.localUsers)
1226
+ .filter(isObject)
1227
+ .map((account) => {
1228
+ const name = str(account.name);
1229
+ return {
1230
+ name,
1231
+ uid: null,
1232
+ gid: null,
1233
+ fullName: str(account.fullName),
1234
+ home: str(account.home),
1235
+ shell: null,
1236
+ admin: name !== null && adminSet.has(name.toLowerCase()),
1237
+ isCurrent: name !== null && currentName !== null && name.toLowerCase() === currentName.toLowerCase(),
1238
+ };
1239
+ })
1240
+ .filter((account) => account.name !== null);
1241
+
1242
+ /** @type {Array<{label: string|null, path: string, scope: 'user'|'system', loaded: boolean, pid: number|null, lastExitStatus: number|null, program: string}>} */
1243
+ const launchAgents = asArray(data.startup)
1244
+ .filter(isObject)
1245
+ .filter((entry) => str(entry.source) === null || str(entry.source) === 'registry')
1246
+ .map((entry) => {
1247
+ const path = str(entry.path) ?? '';
1248
+ return {
1249
+ label: str(entry.label) ?? path,
1250
+ path,
1251
+ scope: /** @type {'user'|'system'} */ (str(entry.scope) === 'user' ? 'user' : 'system'),
1252
+ loaded: entry.loaded !== false,
1253
+ pid: null,
1254
+ lastExitStatus: null,
1255
+ program: str(entry.program) ?? '',
1256
+ };
1257
+ })
1258
+ .filter((entry) => entry.path !== '');
1259
+ launchAgents.sort((left, right) => {
1260
+ if (left.scope !== right.scope) return left.scope === 'user' ? -1 : 1;
1261
+ return (left.label ?? left.path).localeCompare(right.label ?? right.path);
1262
+ });
1263
+
1264
+ const loginItems = asArray(data.loginItems)
1265
+ .map((item) => ({ name: isObject(item) ? str(item.name) : str(item) }))
1266
+ .filter((item) => item.name !== null);
1267
+
1268
+ let timezone = str(localeRaw.timezone);
1269
+ if (timezone === null) {
1270
+ try {
1271
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? null;
1272
+ } catch {
1273
+ timezone = null;
1274
+ }
1275
+ }
1276
+
1277
+ return {
1278
+ os: {
1279
+ name: family ?? 'Windows',
1280
+ version,
1281
+ build,
1282
+ kernel: version ? `Windows NT ${version}` : null,
1283
+ arch: mapHostArchitecture(osRaw.processorArchitecture) ?? process.arch ?? null,
1284
+ release: build,
1285
+ },
1286
+ device: {
1287
+ hostname,
1288
+ computerName,
1289
+ localHostname,
1290
+ model: str(deviceRaw.model) ?? str(deviceRaw.manufacturer),
1291
+ modelId: str(deviceRaw.modelId),
1292
+ chip: str(deviceRaw.chip),
1293
+ serial: str(deviceRaw.serial),
1294
+ hardwareUuid: str(deviceRaw.hardwareUuid),
1295
+ },
1296
+ boot: { bootTime, uptimeSec },
1297
+ user,
1298
+ users,
1299
+ launchAgents,
1300
+ loginItems,
1301
+ environment: {
1302
+ PATH: str(environmentRaw.PATH) ?? str(process.env.PATH),
1303
+ SHELL: str(environmentRaw.SHELL),
1304
+ HOME: str(environmentRaw.HOME) ?? str(process.env.USERPROFILE) ?? str(process.env.HOME),
1305
+ USER: str(environmentRaw.USER) ?? str(process.env.USERNAME) ?? str(process.env.USER),
1306
+ LANG: str(environmentRaw.LANG),
1307
+ TERM: str(environmentRaw.TERM),
1308
+ },
1309
+ locale: {
1310
+ timezone,
1311
+ locale: str(localeRaw.locale),
1312
+ language: str(localeRaw.language),
1313
+ },
1314
+ errors,
1315
+ };
1316
+ }
1317
+
1318
+ /* -------------------------------------------------------------------------- */
1319
+ /* Mapper: software */
1320
+ /* -------------------------------------------------------------------------- */
1321
+
1322
+ /**
1323
+ * Pure mapper for `software.ps1` output.
1324
+ *
1325
+ * Windows sources:
1326
+ * - installed applications from the registry Uninstall keys; `uninstallId` is
1327
+ * `<HIVE>:<full key path>` (the action engine's uninstall input) and
1328
+ * `bundleId` is the registry key name (GUID or product key);
1329
+ * - `source` is `user` for HKCU entries and `other` for HKLM entries (the
1330
+ * client only disables uninstall for `system`, which is reserved for
1331
+ * protected macOS apps);
1332
+ * - `homebrew` is present but unavailable; `winget` is an additive field
1333
+ * detected by the script (cached with the section's 10-minute TTL).
1334
+ *
1335
+ * @param {unknown} raw
1336
+ * @param {string|null} [transportError]
1337
+ * @returns {object} frozen `software` section shape (+ additive `winget`)
1338
+ */
1339
+ function mapSoftware(raw, transportError) {
1340
+ const label = 'software.ps1';
1341
+ const data = isObject(raw) ? raw : {};
1342
+ const errors = collectErrors(raw, transportError, label);
1343
+
1344
+ const applications = asArray(data.applications)
1345
+ .filter(isObject)
1346
+ .map((application) => {
1347
+ const hiveText = str(application.hive)?.toUpperCase() ?? null;
1348
+ const hive = hiveText === 'HKLM' || hiveText === 'HKCU' ? hiveText : null;
1349
+ const keyPathText = str(application.keyPath);
1350
+ // Only build the action-facing uninstallId for a genuine Uninstall key
1351
+ // path; a malformed document must not yield an id the action engine would
1352
+ // reject (or worse, misread).
1353
+ const keyPath =
1354
+ keyPathText !== null &&
1355
+ /^Software\\(WOW6432Node\\)?Microsoft\\Windows\\CurrentVersion\\Uninstall\\/i.test(keyPathText)
1356
+ ? keyPathText
1357
+ : null;
1358
+ const keyName = str(application.keyName);
1359
+ const estimatedSizeKb = num(application.estimatedSizeKB);
1360
+ return {
1361
+ name: str(application.displayName),
1362
+ path: str(application.installLocation) ?? '',
1363
+ version: str(application.version),
1364
+ bundleId: keyName,
1365
+ uninstallId: hive !== null && keyPath !== null ? `${hive}:${keyPath}` : null,
1366
+ source: hive === 'HKCU' ? 'user' : 'other',
1367
+ arch: str(application.arch),
1368
+ lastUsed: null,
1369
+ sizeBytes: estimatedSizeKb === null ? null : Math.round(estimatedSizeKb * 1024),
1370
+ };
1371
+ })
1372
+ .filter((application) => application.name !== null);
1373
+ applications.sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
1374
+
1375
+ const wingetRaw = isObject(data.winget) ? data.winget : {};
1376
+
1377
+ return {
1378
+ applications,
1379
+ totalApplications: applications.length,
1380
+ homebrew: { available: false, prefix: null, installed: [], outdated: [] },
1381
+ winget: {
1382
+ available: wingetRaw.available === true,
1383
+ version: str(wingetRaw.version),
1384
+ },
1385
+ errors,
1386
+ };
1387
+ }
1388
+
1389
+ /* -------------------------------------------------------------------------- */
1390
+ /* Mapper: network */
1391
+ /* -------------------------------------------------------------------------- */
1392
+
1393
+ /**
1394
+ * Classify a Windows adapter for the contract's interface type enum.
1395
+ * @param {Record<string, any>} adapter
1396
+ * @returns {'wifi'|'ethernet'|'loopback'|'bridge'|'other'}
1397
+ */
1398
+ function mapInterfaceType(adapter) {
1399
+ const name = str(adapter.name) ?? '';
1400
+ const description = str(adapter.description) ?? '';
1401
+ const combined = `${name} ${description}`;
1402
+ if (/loopback/i.test(combined)) return 'loopback';
1403
+ const medium = num(adapter.ndisMedium);
1404
+ if (medium === 9 || medium === 1 || medium === 8 || medium === 12) return 'wifi';
1405
+ if (/wi-?fi|wireless|wlan|802\.11/i.test(combined)) return 'wifi';
1406
+ if (/hyper-v|vethernet|bridge|virtual switch/i.test(combined)) return 'bridge';
1407
+ if (medium === 14 || medium === 17 || medium === 18) return 'ethernet';
1408
+ if (/ethernet|802\.3|gbe/i.test(combined)) return 'ethernet';
1409
+ return 'other';
1410
+ }
1411
+
1412
+ /**
1413
+ * Map the WinHTTP/IE proxy settings into the contract's three slots.
1414
+ * @param {unknown} raw
1415
+ * @returns {{http: string|null, https: string|null, socks: string|null}}
1416
+ */
1417
+ function mapProxy(raw) {
1418
+ /** @type {{http: string|null, https: string|null, socks: string|null}} */
1419
+ const result = { http: null, https: null, socks: null };
1420
+ if (!isObject(raw)) return result;
1421
+ const enabled = raw.enable === true || raw.enable === 1 || raw.enable === '1';
1422
+ if (!enabled) return result;
1423
+ const server = str(raw.server);
1424
+ if (!server) return result;
1425
+ if (server.includes('=')) {
1426
+ for (const part of server.split(';')) {
1427
+ const separator = part.indexOf('=');
1428
+ if (separator === -1) continue;
1429
+ const key = part.slice(0, separator).trim().toLowerCase();
1430
+ const value = str(part.slice(separator + 1));
1431
+ if (!value) continue;
1432
+ if (key === 'http') result.http = value;
1433
+ else if (key === 'https') result.https = value;
1434
+ else if (key === 'socks' || key === 'socks5') result.socks = value;
1435
+ }
1436
+ } else {
1437
+ result.http = server;
1438
+ result.https = server;
1439
+ }
1440
+ return result;
1441
+ }
1442
+
1443
+ /**
1444
+ * Pure mapper for `network.ps1` output.
1445
+ * @param {unknown} raw
1446
+ * @param {string|null} [transportError]
1447
+ * @returns {object} frozen `network` section shape
1448
+ */
1449
+ function mapNetwork(raw, transportError) {
1450
+ const label = 'network.ps1';
1451
+ const data = isObject(raw) ? raw : {};
1452
+ const errors = collectErrors(raw, transportError, label);
1453
+ const routeRaw = isObject(data.defaultRoute) ? data.defaultRoute : null;
1454
+ const defaultInterface = str(routeRaw?.interface);
1455
+
1456
+ /** @type {Array<{name: string, type: string, up: boolean, ipv4: string[], ipv6: string[], mac: string|null, mtu: number|null, primary: boolean}>} */
1457
+ const interfaces = [];
1458
+ const seenNames = new Set();
1459
+ for (const adapter of asArray(data.adapters).filter(isObject)) {
1460
+ const name = str(adapter.name);
1461
+ if (!name || seenNames.has(name)) continue;
1462
+ seenNames.add(name);
1463
+ const ipv4 = asArray(adapter.ipv4)
1464
+ .map((value) => str(value))
1465
+ .filter((value) => value !== null);
1466
+ const ipv6 = asArray(adapter.ipv6)
1467
+ .map((value) => str(value))
1468
+ .filter((value) => value !== null);
1469
+ const type = mapInterfaceType(adapter);
1470
+ const status = (str(adapter.status) ?? '').toLowerCase();
1471
+ const up = status === 'up' || status === 'connected' || adapter.up === true;
1472
+ const isPrimary = defaultInterface !== null && name === defaultInterface;
1473
+ if (type === 'loopback') {
1474
+ // network.ps1 intentionally drops 127.0.0.1/::1 from the address maps;
1475
+ // the loopback interface still needs its well-known addresses so the UI
1476
+ // receives a complete interface card.
1477
+ if (ipv4.length === 0) ipv4.push('127.0.0.1');
1478
+ if (ipv6.length === 0) ipv6.push('::1');
1479
+ }
1480
+ if (type !== 'loopback' && !isPrimary && !up && ipv4.length === 0 && ipv6.length === 0) continue;
1481
+ let mtu = num(adapter.mtu);
1482
+ // The Windows loopback adapter reports a sentinel MTU (4294967295); it is
1483
+ // not a meaningful value, so report null instead of a bogus number.
1484
+ if (mtu !== null && mtu > 1_000_000) mtu = null;
1485
+ interfaces.push({
1486
+ name,
1487
+ type,
1488
+ up,
1489
+ ipv4,
1490
+ ipv6,
1491
+ mac: str(adapter.mac),
1492
+ mtu,
1493
+ primary: isPrimary,
1494
+ });
1495
+ }
1496
+ if (!interfaces.some((entry) => entry.type === 'loopback')) {
1497
+ interfaces.unshift({
1498
+ name: 'Loopback',
1499
+ type: 'loopback',
1500
+ up: true,
1501
+ ipv4: ['127.0.0.1'],
1502
+ ipv6: ['::1'],
1503
+ mac: null,
1504
+ mtu: null,
1505
+ primary: false,
1506
+ });
1507
+ }
1508
+ interfaces.sort((left, right) => {
1509
+ if (left.primary !== right.primary) return left.primary ? -1 : 1;
1510
+ if (left.type === 'loopback' && right.type !== 'loopback') return 1;
1511
+ if (right.type === 'loopback' && left.type !== 'loopback') return -1;
1512
+ return left.name.localeCompare(right.name);
1513
+ });
1514
+
1515
+ const dnsRaw = isObject(data.dns) ? data.dns : {};
1516
+ const dnsServers = [];
1517
+ for (const server of asArray(dnsRaw.servers)) {
1518
+ const text = str(server);
1519
+ if (text && !dnsServers.includes(text)) dnsServers.push(text);
1520
+ }
1521
+ const searchDomains = [];
1522
+ for (const domain of asArray(dnsRaw.searchDomains)) {
1523
+ const text = str(domain);
1524
+ if (text && !searchDomains.includes(text)) searchDomains.push(text);
1525
+ }
1526
+
1527
+ /** @type {Array<{proto: string, address: string, port: number, pid: number|null, process: string}>} */
1528
+ const listeningPorts = [];
1529
+ const pushListeners = (items, proto) => {
1530
+ for (const listener of items) {
1531
+ if (!isObject(listener)) continue;
1532
+ const port = num(listener.port);
1533
+ if (port === null || port <= 0) continue;
1534
+ listeningPorts.push({
1535
+ proto,
1536
+ address: str(listener.address) ?? '*',
1537
+ port,
1538
+ pid: num(listener.pid),
1539
+ process: str(listener.process) ?? '',
1540
+ });
1541
+ }
1542
+ };
1543
+ pushListeners(asArray(data.tcpListeners), 'tcp');
1544
+ pushListeners(asArray(data.udpListeners), 'udp');
1545
+ listeningPorts.sort((left, right) => left.port - right.port || left.proto.localeCompare(right.proto));
1546
+ listeningPorts.splice(500);
1547
+
1548
+ return {
1549
+ interfaces,
1550
+ wifi: mapNetworkWifi(data.wifi),
1551
+ defaultRoute:
1552
+ routeRaw && (str(routeRaw.gateway) !== null || str(routeRaw.interface) !== null)
1553
+ ? { gateway: str(routeRaw.gateway), interface: str(routeRaw.interface) }
1554
+ : null,
1555
+ dns: { servers: dnsServers.slice(0, 12), searchDomains: searchDomains.slice(0, 12) },
1556
+ proxy: mapProxy(data.proxy),
1557
+ listeningPorts,
1558
+ establishedCount: num(data.establishedCount) ?? 0,
1559
+ errors,
1560
+ };
1561
+ }
1562
+
1563
+ /* -------------------------------------------------------------------------- */
1564
+ /* Mapper: security */
1565
+ /* -------------------------------------------------------------------------- */
1566
+
1567
+ /** Normalize one `{enabled, raw}` status block. */
1568
+ function statusFlag(raw) {
1569
+ const value = isObject(raw) ? raw : {};
1570
+ return { enabled: boolOrNull(value.enabled), raw: str(value.raw) };
1571
+ }
1572
+
1573
+ /**
1574
+ * Pure mapper for `security.ps1` output.
1575
+ *
1576
+ * Windows mapping: BitLocker → fileVault, Secure Boot → sip, Defender real-time
1577
+ * protection → gatekeeper, Windows Firewall profiles → firewall, Remote Desktop
1578
+ * → remoteLogin, Winlogon auto-logon → autoLogin. TCC has no Windows analogue,
1579
+ * so the documented fixed `{readable:false, ...}` payload is returned.
1580
+ *
1581
+ * @param {unknown} raw
1582
+ * @param {string|null} [transportError]
1583
+ * @returns {object} frozen `security` section shape
1584
+ */
1585
+ function mapSecurity(raw, transportError) {
1586
+ const label = 'security.ps1';
1587
+ const data = isObject(raw) ? raw : {};
1588
+ const errors = collectErrors(raw, transportError, label);
1589
+ const usersRaw = isObject(data.users) ? data.users : {};
1590
+ const firewallRaw = isObject(data.firewall) ? data.firewall : {};
1591
+
1592
+ const admins = asArray(usersRaw.admins)
1593
+ .map((value) => str(value))
1594
+ .filter((value) => value !== null);
1595
+
1596
+ return {
1597
+ fileVault: statusFlag(data.fileVault),
1598
+ sip: statusFlag(data.sip),
1599
+ gatekeeper: statusFlag(data.gatekeeper),
1600
+ firewall: {
1601
+ ...statusFlag(firewallRaw),
1602
+ stealthMode: boolOrNull(firewallRaw.stealthMode),
1603
+ },
1604
+ tcc: {
1605
+ readable: false,
1606
+ reason: 'Windows does not expose a per-app consent store',
1607
+ grants: [],
1608
+ },
1609
+ users: {
1610
+ current: str(usersRaw.current) ?? safeUsername(),
1611
+ admins,
1612
+ total: num(usersRaw.total),
1613
+ },
1614
+ remoteLogin: boolOrNull(data.remoteLogin),
1615
+ autoLogin: str(data.autoLogin),
1616
+ errors,
1617
+ };
1618
+ }
1619
+
1620
+ /* -------------------------------------------------------------------------- */
1621
+ /* Mapper: processes */
1622
+ /* -------------------------------------------------------------------------- */
1623
+
1624
+ /**
1625
+ * Pure mapper for `processes.ps1` output.
1626
+ *
1627
+ * Windows perf counters report CPU across all logical processors; the raw value
1628
+ * is divided by the logical core count to get the Task-Manager-style 0..100
1629
+ * percent used by the rest of the contract.
1630
+ *
1631
+ * @param {unknown} raw
1632
+ * @param {string|null} [transportError]
1633
+ * @returns {object} frozen `processes` section shape
1634
+ */
1635
+ function mapProcesses(raw, transportError) {
1636
+ const label = 'processes.ps1';
1637
+ const data = isObject(raw) ? raw : {};
1638
+ const errors = collectErrors(raw, transportError, label);
1639
+ const cores = num(data.logicalCores) ?? safeCpuCount();
1640
+ const totalMemoryBytes = num(data.totalMemoryBytes) ?? (() => {
1641
+ try {
1642
+ return os.totalmem();
1643
+ } catch {
1644
+ return null;
1645
+ }
1646
+ })();
1647
+
1648
+ const table = asArray(data.processes)
1649
+ .filter(isObject)
1650
+ .map((process) => {
1651
+ const pid = num(process.pid);
1652
+ const workingSetBytes = num(process.workingSetBytes) ?? num(process.privateWorkingSetBytes) ?? num(process.rssBytes);
1653
+ const memPercent =
1654
+ num(process.memPercent) ??
1655
+ (workingSetBytes !== null && totalMemoryBytes !== null && totalMemoryBytes > 0
1656
+ ? round(clamp((workingSetBytes / totalMemoryBytes) * 100, 0, 100), 1)
1657
+ : null);
1658
+ const creationDateMs = num(process.creationDateMs);
1659
+ let elapsed = num(process.elapsed);
1660
+ if (elapsed === null && creationDateMs !== null) {
1661
+ const seconds = Math.floor((Date.now() - creationDateMs) / 1000);
1662
+ elapsed = seconds >= 0 ? seconds : null;
1663
+ }
1664
+ return {
1665
+ pid: pid ?? 0,
1666
+ ppid: num(process.ppid),
1667
+ user: str(process.user),
1668
+ cpu: round(clamp((num(process.cpuPercent) ?? 0) / Math.max(1, cores || 1), 0, 100), 1) ?? 0,
1669
+ memPercent: memPercent ?? 0,
1670
+ rssBytes: workingSetBytes,
1671
+ state: str(process.state),
1672
+ elapsed,
1673
+ command: str(process.command) ?? str(process.name) ?? '',
1674
+ };
1675
+ })
1676
+ .filter((process) => process.pid > 0);
1677
+
1678
+ const sorted = [...table].sort((left, right) => {
1679
+ if (right.cpu !== left.cpu) return right.cpu - left.cpu;
1680
+ return right.memPercent - left.memPercent;
1681
+ });
1682
+ const list = sorted.slice(0, 200);
1683
+
1684
+ /** @type {Map<string, number>} */
1685
+ const counts = new Map();
1686
+ for (const process of table) {
1687
+ const user = process.user ?? 'unknown';
1688
+ counts.set(user, (counts.get(user) ?? 0) + 1);
1689
+ }
1690
+ const byUser = [...counts.entries()]
1691
+ .map(([user, count]) => ({ user, count }))
1692
+ .sort((left, right) => right.count - left.count);
1693
+
1694
+ return {
1695
+ total: num(data.total) ?? table.length,
1696
+ list,
1697
+ byUser,
1698
+ errors,
1699
+ };
1700
+ }
1701
+
1702
+ /* -------------------------------------------------------------------------- */
1703
+ /* Section builders (PowerShell → cached contract shapes) */
1704
+ /* -------------------------------------------------------------------------- */
1705
+
1706
+ /**
1707
+ * Run a collection script and map its JSON document.
1708
+ * @param {string} scriptName
1709
+ * @param {(raw: any, error?: string|null) => object} mapper
1710
+ * @returns {Promise<object>}
1711
+ */
1712
+ async function collectWith(scriptName, mapper) {
1713
+ const result = await runScript(scriptName);
1714
+ if (!result.ok) return mapper(null, result.error);
1715
+ try {
1716
+ return mapper(result.data);
1717
+ } catch (error) {
1718
+ // A mapper bug must still yield a contract-shaped, failing section.
1719
+ return mapper(null, `mapper failure: ${error instanceof Error ? error.message : String(error)}`);
1720
+ }
1721
+ }
1722
+
1723
+ async function buildHardware() {
1724
+ return collectWith('hardware.ps1', mapHardware);
1725
+ }
1726
+
1727
+ async function buildSystem() {
1728
+ return collectWith('system.ps1', mapSystem);
1729
+ }
1730
+
1731
+ async function buildSoftware() {
1732
+ return collectWith('software.ps1', mapSoftware);
1733
+ }
1734
+
1735
+ async function buildNetwork() {
1736
+ return collectWith('network.ps1', mapNetwork);
1737
+ }
1738
+
1739
+ async function buildSecurity() {
1740
+ return collectWith('security.ps1', mapSecurity);
1741
+ }
1742
+
1743
+ async function buildProcesses() {
1744
+ return collectWith('processes.ps1', mapProcesses);
1745
+ }
1746
+
1747
+ const BUILDERS = {
1748
+ hardware: buildHardware,
1749
+ system: buildSystem,
1750
+ software: buildSoftware,
1751
+ network: buildNetwork,
1752
+ security: buildSecurity,
1753
+ processes: buildProcesses,
1754
+ };
1755
+
1756
+ /* -------------------------------------------------------------------------- */
1757
+ /* Metrics */
1758
+ /* -------------------------------------------------------------------------- */
1759
+
1760
+ /** @type {object|null} */
1761
+ let lastMetrics = null;
1762
+ let lastMetricsAt = 0;
1763
+ /** @type {Promise<object>|null} */
1764
+ let metricsPending = null;
1765
+
1766
+ /**
1767
+ * Best-effort zeroed metrics object (the tick path must never throw).
1768
+ * @returns {object}
1769
+ */
1770
+ function fallbackMetrics() {
1771
+ return {
1772
+ ts: Date.now(),
1773
+ hostname: safeHostname() ?? 'unknown',
1774
+ osVersion: null,
1775
+ uptimeSec: (() => {
1776
+ try {
1777
+ return Math.floor(os.uptime());
1778
+ } catch {
1779
+ return null;
1780
+ }
1781
+ })(),
1782
+ cpu: {
1783
+ model: null,
1784
+ usagePercent: 0,
1785
+ perCore: [],
1786
+ loadAvg: [],
1787
+ logicalCores: safeCpuCount(),
1788
+ physicalCores: safeCpuCount(),
1789
+ },
1790
+ memory: {
1791
+ totalBytes: (() => {
1792
+ try {
1793
+ return os.totalmem();
1794
+ } catch {
1795
+ return null;
1796
+ }
1797
+ })(),
1798
+ usedBytes: null,
1799
+ appBytes: null,
1800
+ wiredBytes: null,
1801
+ compressedBytes: null,
1802
+ cachedBytes: null,
1803
+ freeBytes: (() => {
1804
+ try {
1805
+ return os.freemem();
1806
+ } catch {
1807
+ return null;
1808
+ }
1809
+ })(),
1810
+ swapTotalBytes: null,
1811
+ swapUsedBytes: null,
1812
+ pressure: null,
1813
+ },
1814
+ disk: { totalBytes: null, usedBytes: null, freeBytes: null, readBytesPerSec: 0, writeBytesPerSec: 0 },
1815
+ network: {
1816
+ rxBytesPerSec: 0,
1817
+ txBytesPerSec: 0,
1818
+ totalRxBytes: null,
1819
+ totalTxBytes: null,
1820
+ wifi: null,
1821
+ primaryIpv4: [],
1822
+ defaultInterface: null,
1823
+ },
1824
+ battery: null,
1825
+ thermal: emptyThermal(),
1826
+ processes: { total: 0, top: [] },
1827
+ };
1828
+ }
1829
+
1830
+ /**
1831
+ * Build one metrics snapshot: a single PowerShell invocation (OS-specific
1832
+ * facts) in parallel with the cheap `os.cpus()` CPU delta sample.
1833
+ * @returns {Promise<object>}
1834
+ */
1835
+ async function buildMetrics() {
1836
+ const [result, usage] = await Promise.all([runScript('metrics.ps1'), cpuUsage()]);
1837
+ const ts = Date.now();
1838
+ if (!result.ok) {
1839
+ // Keep the last good sample for continuity (documented); otherwise fall
1840
+ // back to the Node-only facts.
1841
+ if (lastMetrics !== null) return { ...lastMetrics, ts };
1842
+ return jsonSafe(mapMetrics(null, { cpu: usage, now: ts }));
1843
+ }
1844
+ const data = isObject(result.data) ? result.data : {};
1845
+ const rates = {
1846
+ network: rateOf('network', countersOf(data.network?.counters), ts),
1847
+ disk: rateOf('disk', countersOf(data.diskIo), ts),
1848
+ };
1849
+ return jsonSafe(mapMetrics(data, { cpu: usage, rates, now: ts }));
1850
+ }
1851
+
1852
+ /* -------------------------------------------------------------------------- */
1853
+ /* Public collectors */
1854
+ /* -------------------------------------------------------------------------- */
1855
+
1856
+ /**
1857
+ * Start slow section collection in the background (fire-and-forget). Two small
1858
+ * waves keep the number of simultaneous PowerShell processes low; metrics are
1859
+ * primed last so the first UI poll still gets a fresh sample.
1860
+ * @returns {void}
1861
+ */
1862
+ export function warmup() {
1863
+ const prime = async () => {
1864
+ for (const group of [['system', 'network', 'processes'], ['hardware', 'security', 'software']]) {
1865
+ await Promise.all(group.map((section) => collectSection(section).catch(() => {})));
1866
+ }
1867
+ await collectMetrics().catch(() => {});
1868
+ };
1869
+ Promise.resolve()
1870
+ .then(prime)
1871
+ .catch(() => {});
1872
+ }
1873
+
1874
+ /**
1875
+ * One metrics snapshot for the 2500 ms UI poll. Never throws.
1876
+ * @returns {Promise<object>}
1877
+ */
1878
+ export async function collectMetrics() {
1879
+ const now = Date.now();
1880
+ if (lastMetrics !== null && now - lastMetricsAt < METRICS_TTL) return lastMetrics;
1881
+ if (metricsPending !== null) return metricsPending;
1882
+ const task = (async () => {
1883
+ try {
1884
+ const value = await buildMetrics();
1885
+ if (value && typeof value === 'object') {
1886
+ lastMetrics = value;
1887
+ lastMetricsAt = Date.now();
1888
+ return value;
1889
+ }
1890
+ return fallbackMetrics();
1891
+ } catch {
1892
+ return fallbackMetrics();
1893
+ }
1894
+ })().finally(() => {
1895
+ if (metricsPending === task) metricsPending = null;
1896
+ });
1897
+ metricsPending = task;
1898
+ return task;
1899
+ }
1900
+
1901
+ /**
1902
+ * Build (or serve from cache) one section.
1903
+ *
1904
+ * Unknown section names reject; every PowerShell/data failure is captured in
1905
+ * the returned section's `errors[]` instead of throwing.
1906
+ *
1907
+ * @param {string} section
1908
+ * @param {{fresh?: boolean}} [options]
1909
+ * @returns {Promise<object>}
1910
+ */
1911
+ export async function collectSection(section, options = {}) {
1912
+ if (!SECTIONS.includes(section)) {
1913
+ throw new Error(`Unknown section: ${String(section)}`);
1914
+ }
1915
+ const fresh = options?.fresh === true;
1916
+ const ttl = SECTION_TTL[section] ?? 30_000;
1917
+ const now = Date.now();
1918
+ const cached = sectionCache.get(section);
1919
+ if (!fresh && cached && now - cached.at < ttl) return cached.value;
1920
+ const pending = sectionInflight.get(section);
1921
+ if (pending) return pending;
1922
+
1923
+ const builder = BUILDERS[section];
1924
+ const task = (async () => {
1925
+ let value;
1926
+ try {
1927
+ value = await builder();
1928
+ } catch (error) {
1929
+ value = { errors: [shortErr(error instanceof Error ? error.message : error)] };
1930
+ }
1931
+ const safe = jsonSafe(value);
1932
+ sectionCache.set(section, { value: safe, at: Date.now() });
1933
+ return safe;
1934
+ })().finally(() => {
1935
+ if (sectionInflight.get(section) === task) sectionInflight.delete(section);
1936
+ });
1937
+
1938
+ sectionInflight.set(section, task);
1939
+ return task;
1940
+ }
1941
+
1942
+ /**
1943
+ * Full inventory for the panel's initial load and the agent tool.
1944
+ * @param {{sections?: string[], fresh?: boolean}} [options]
1945
+ * @returns {Promise<object>}
1946
+ */
1947
+ export async function collectInventory(options = {}) {
1948
+ const sectionFilter = Array.isArray(options?.sections) ? options.sections : null;
1949
+ const requested = sectionFilter !== null
1950
+ ? SECTIONS.filter((section) => sectionFilter.includes(section))
1951
+ : [...SECTIONS];
1952
+ const fresh = options?.fresh === true;
1953
+ const errors = [];
1954
+
1955
+ const [metrics, ...sectionValues] = await Promise.all([
1956
+ collectMetrics(),
1957
+ ...requested.map((section) => collectSection(section, { fresh })),
1958
+ ]);
1959
+ /** @type {Record<string, object>} */
1960
+ const sections = {};
1961
+ requested.forEach((section, index) => {
1962
+ sections[section] = sectionValues[index];
1963
+ });
1964
+ if (!metrics || typeof metrics !== 'object') errors.push('metrics unavailable');
1965
+
1966
+ let catalog = [];
1967
+ let history = [];
1968
+ try {
1969
+ catalog = asArray(actionCatalog());
1970
+ } catch (error) {
1971
+ errors.push(`action catalog: ${shortErr(error instanceof Error ? error.message : error)}`);
1972
+ }
1973
+ try {
1974
+ history = asArray(actionHistory());
1975
+ } catch (error) {
1976
+ errors.push(`action history: ${shortErr(error instanceof Error ? error.message : error)}`);
1977
+ }
1978
+
1979
+ return jsonSafe({
1980
+ generatedAt: Date.now(),
1981
+ platform: PLATFORM,
1982
+ hostname: safeHostname() ?? 'unknown',
1983
+ metrics,
1984
+ sections,
1985
+ actions: { catalog, history },
1986
+ errors,
1987
+ });
1988
+ }
1989
+
1990
+ /**
1991
+ * Drop cached section data (one section or all of them).
1992
+ * `clearCache('metrics')` drops the fast-metrics cache as well.
1993
+ * @param {string} [section]
1994
+ * @returns {void}
1995
+ */
1996
+ export function clearCache(section) {
1997
+ if (section === undefined || section === null || section === '') {
1998
+ sectionCache.clear();
1999
+ lastMetrics = null;
2000
+ lastMetricsAt = 0;
2001
+ return;
2002
+ }
2003
+ if (section === 'metrics') {
2004
+ lastMetrics = null;
2005
+ lastMetricsAt = 0;
2006
+ return;
2007
+ }
2008
+ sectionCache.delete(String(section));
2009
+ }
2010
+
2011
+ /* -------------------------------------------------------------------------- */
2012
+ /* Test seam */
2013
+ /* -------------------------------------------------------------------------- */
2014
+
2015
+ /**
2016
+ * Reset all module-level state (caches, deltas, metrics) and restore the real
2017
+ * PowerShell runner. Intended for the offline verification harness only.
2018
+ * @returns {void}
2019
+ */
2020
+ function resetInternalState() {
2021
+ sectionCache.clear();
2022
+ sectionInflight.clear();
2023
+ rateState.clear();
2024
+ cpuSnapshot = null;
2025
+ lastCpuUsage = { usagePercent: 0, perCore: [] };
2026
+ lastMetrics = null;
2027
+ lastMetricsAt = 0;
2028
+ metricsPending = null;
2029
+ scriptRunner = defaultRunner;
2030
+ }
2031
+
2032
+ /**
2033
+ * Stable pure helpers used by `scripts/verify-windows.mjs` and the offline
2034
+ * fixture harness. `setRunner` is the optional test seam for the PowerShell
2035
+ * bridge: it makes the caching/warmup logic testable on any platform.
2036
+ */
2037
+ export const __test = {
2038
+ asArray,
2039
+ mapMetrics,
2040
+ mapHardware,
2041
+ mapSystem,
2042
+ mapSoftware,
2043
+ mapNetwork,
2044
+ mapSecurity,
2045
+ mapProcesses,
2046
+ cpuUsage,
2047
+ SECTION_TTL,
2048
+ setRunner(fn) {
2049
+ scriptRunner = typeof fn === 'function' ? fn : defaultRunner;
2050
+ },
2051
+ resetRunner() {
2052
+ scriptRunner = defaultRunner;
2053
+ },
2054
+ reset() {
2055
+ resetInternalState();
2056
+ },
2057
+ };