@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,119 @@
1
+ # Device X - Windows installed-software inventory (registry Uninstall keys).
2
+ #
3
+ # Emits exactly one JSON document on stdout (no other stdout text).
4
+ # Read-only: no admin rights, no network calls, no external modules.
5
+ # `winget --version` only runs when the executable is present (no install, no
6
+ # source agreement prompt) and never touches the network.
7
+ $ErrorActionPreference = 'Stop'
8
+
9
+ $errors = @()
10
+
11
+ function Invoke-NativeOem([scriptblock]$action) {
12
+ # Native console tools (netsh/tasklist/manage-bde/winget) write OEM/ANSI bytes
13
+ # on a non-English Windows. The bridge sets the console to UTF-8 for the JSON
14
+ # document; temporarily decode native output with the OEM code page so SSIDs,
15
+ # user names and device names are not mojibake, then restore in finally.
16
+ $originalEncoding = [Console]::OutputEncoding
17
+ $oemCodePage = 437
18
+ try {
19
+ $cultureCodePage = [System.Globalization.CultureInfo]::CurrentCulture.TextInfo.OEMCodePage
20
+ if ($cultureCodePage -gt 0) { $oemCodePage = $cultureCodePage }
21
+ [Console]::OutputEncoding = [System.Text.Encoding]::GetEncoding($oemCodePage)
22
+ return & $action
23
+ } finally {
24
+ [Console]::OutputEncoding = $originalEncoding
25
+ }
26
+ }
27
+
28
+ $uninstallRoots = @(
29
+ [pscustomobject]@{ hive = 'HKLM'; path = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall'; arch = 'x64' },
30
+ [pscustomobject]@{ hive = 'HKLM'; path = 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'; arch = 'x86' },
31
+ [pscustomobject]@{ hive = 'HKCU'; path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall'; arch = 'x64' }
32
+ )
33
+
34
+ $applications = @()
35
+ foreach ($root in $uninstallRoots) {
36
+ try {
37
+ $uninstallKeys = @(Get-ChildItem -Path $root.path -ErrorAction Stop)
38
+ } catch {
39
+ $message = $_.Exception.Message
40
+ if ($message -notmatch 'Cannot find path' -and $message -notmatch 'does not exist') {
41
+ $errors += ('registry ' + $root.path + ': ' + $message)
42
+ }
43
+ continue
44
+ }
45
+ $relativeRoot = $root.path -replace '^(HKLM|HKCU):\\', ''
46
+ foreach ($uninstallKey in $uninstallKeys) {
47
+ $keyName = [string]$uninstallKey.PSChildName
48
+ $keyPath = $relativeRoot + '\' + $keyName
49
+ $properties = $null
50
+ try {
51
+ $properties = Get-ItemProperty -Path $uninstallKey.PSPath -ErrorAction Stop
52
+ } catch {
53
+ continue
54
+ }
55
+ if ($null -eq $properties) { continue }
56
+ $displayName = [string]$properties.DisplayName
57
+ if (-not $displayName.Trim()) { continue }
58
+ if ($null -ne $properties.SystemComponent -and [int]$properties.SystemComponent -eq 1) { continue }
59
+ if ($null -ne $properties.ParentKeyName -and [string]$properties.ParentKeyName) { continue }
60
+ $estimatedSizeKb = $null
61
+ if ($null -ne $properties.EstimatedSize) {
62
+ try { $estimatedSizeKb = [long]$properties.EstimatedSize } catch { $estimatedSizeKb = $null }
63
+ }
64
+ $applications += [ordered]@{
65
+ displayName = $displayName.Trim()
66
+ version = [string]$properties.DisplayVersion
67
+ publisher = [string]$properties.Publisher
68
+ installLocation = [string]$properties.InstallLocation
69
+ estimatedSizeKB = $estimatedSizeKb
70
+ keyName = $keyName
71
+ keyPath = $keyPath
72
+ hive = $root.hive
73
+ arch = $root.arch
74
+ uninstallString = [string]$properties.UninstallString
75
+ quietUninstallString = [string]$properties.QuietUninstallString
76
+ }
77
+ }
78
+ }
79
+
80
+ # ---------------------------------------------------------- winget presence --
81
+ $winget = [ordered]@{ available = $false; version = $null }
82
+ $wingetExe = $null
83
+ try {
84
+ $resolved = Get-Command -Name 'winget.exe' -CommandType Application -ErrorAction SilentlyContinue
85
+ if ($null -ne $resolved) { $wingetExe = [string]$resolved.Source }
86
+ } catch { $wingetExe = $null }
87
+ if (-not $wingetExe) {
88
+ try {
89
+ $resolved = Get-Command -Name 'winget' -CommandType Application -ErrorAction SilentlyContinue
90
+ if ($null -ne $resolved) { $wingetExe = [string]$resolved.Source }
91
+ } catch { $wingetExe = $null }
92
+ }
93
+ if ($wingetExe) {
94
+ try {
95
+ $versionLines = @(Invoke-NativeOem { & $wingetExe --version 2>$null })
96
+ $versionText = $null
97
+ foreach ($line in $versionLines) {
98
+ $text = [string]$line
99
+ if ($text.Trim()) {
100
+ if ($text -match '(\d+\.\d+[0-9A-Za-z.\-]*)') { $versionText = $matches[1]; break }
101
+ }
102
+ }
103
+ $winget.available = $true
104
+ if ($versionText) { $winget.version = $versionText }
105
+ } catch {
106
+ $winget.available = $true
107
+ $errors += ('winget --version: ' + $_.Exception.Message)
108
+ }
109
+ }
110
+
111
+ # -------------------------------------------------------------------- emit --
112
+ $result = [ordered]@{
113
+ generatedAt = ([DateTimeOffset]::UtcNow).ToUnixTimeMilliseconds()
114
+ applications = $applications
115
+ winget = $winget
116
+ errors = $errors
117
+ }
118
+
119
+ $result | ConvertTo-Json -Depth 8 -Compress
@@ -0,0 +1,279 @@
1
+ # Device X - Windows system inventory (OS, device, users, startup, locale).
2
+ #
3
+ # Emits exactly one JSON document on stdout (no other stdout text).
4
+ # Read-only: no admin rights, no network calls, no external modules.
5
+ $ErrorActionPreference = 'Stop'
6
+
7
+ $errors = @()
8
+
9
+ function To-EpochMs($value) {
10
+ if ($null -eq $value) { return $null }
11
+ try { return ([DateTimeOffset]$value).ToUnixTimeMilliseconds() } catch { return $null }
12
+ }
13
+
14
+ function To-LongOrNull($value) {
15
+ if ($null -eq $value) { return $null }
16
+ try { return [long]$value } catch { return $null }
17
+ }
18
+
19
+ $hostName = $env:COMPUTERNAME
20
+ if (-not $hostName) {
21
+ try { $hostName = [System.Net.Dns]::GetHostName() } catch { $hostName = $null }
22
+ }
23
+
24
+ # ---------------------------------------------------------------------- OS --
25
+ $osInfo = $null
26
+ $bootInfo = $null
27
+ try {
28
+ $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
29
+ $displayVersion = $null
30
+ try {
31
+ $winVersionKey = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction Stop
32
+ if ($winVersionKey.DisplayVersion) { $displayVersion = [string]$winVersionKey.DisplayVersion }
33
+ } catch { $displayVersion = $null }
34
+ $bootTimeMs = To-EpochMs $os.LastBootUpTime
35
+ $uptimeSec = $null
36
+ if ($null -ne $bootTimeMs -and $bootTimeMs -gt 0) {
37
+ $nowMs = ([DateTimeOffset]::UtcNow).ToUnixTimeMilliseconds()
38
+ $uptimeSec = [long][math]::Floor(($nowMs - $bootTimeMs) / 1000)
39
+ }
40
+ $osInfo = [ordered]@{
41
+ caption = [string]$os.Caption
42
+ displayVersion = $displayVersion
43
+ version = [string]$os.Version
44
+ build = [string]$os.BuildNumber
45
+ installDateMs = To-EpochMs $os.InstallDate
46
+ lastBootUpTimeMs = $bootTimeMs
47
+ osArchitecture = [string]$os.OSArchitecture
48
+ processorArchitecture = $env:PROCESSOR_ARCHITECTURE
49
+ }
50
+ $bootInfo = [ordered]@{ bootTimeMs = $bootTimeMs; uptimeSec = $uptimeSec }
51
+ } catch { $errors += ('Win32_OperatingSystem: ' + $_.Exception.Message) }
52
+
53
+ # ------------------------------------------------------------ OS/device facts --
54
+ $processorName = $null
55
+ try {
56
+ $processors = @(Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop)
57
+ if ($processors.Count -gt 0) { $processorName = [string]$processors[0].Name }
58
+ } catch { $processorName = $null }
59
+
60
+ $device = $null
61
+ try {
62
+ $computerSystem = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
63
+ $bios = Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop
64
+ $product = Get-CimInstance -ClassName Win32_ComputerSystemProduct -ErrorAction Stop
65
+ $modelId = $null
66
+ if ($computerSystem.SystemSKUNumber) { $modelId = [string]$computerSystem.SystemSKUNumber }
67
+ elseif ($product.Version) { $modelId = [string]$product.Version }
68
+ $device = [ordered]@{
69
+ hostname = $hostName
70
+ computerName = [string]$computerSystem.Name
71
+ localHostname = $hostName
72
+ manufacturer = [string]$computerSystem.Manufacturer
73
+ model = [string]$computerSystem.Model
74
+ modelId = $modelId
75
+ chip = $processorName
76
+ serial = [string]$bios.SerialNumber
77
+ hardwareUuid = [string]$product.UUID
78
+ }
79
+ } catch { $errors += ('Win32_ComputerSystem/BIOS: ' + $_.Exception.Message) }
80
+
81
+ # -------------------------------------------------------------------- users --
82
+ $profiles = @{}
83
+ try {
84
+ $profileKeys = @(Get-ChildItem -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList' -ErrorAction Stop)
85
+ foreach ($profileKey in $profileKeys) {
86
+ $sid = [string]$profileKey.PSChildName
87
+ try {
88
+ $profileProperties = Get-ItemProperty -Path $profileKey.PSPath -Name 'ProfileImagePath' -ErrorAction Stop
89
+ if ($profileProperties.ProfileImagePath) { $profiles[$sid] = [string]$profileProperties.ProfileImagePath }
90
+ } catch { }
91
+ }
92
+ } catch { $profiles = @{} }
93
+
94
+ $localUsers = @()
95
+ try {
96
+ $accounts = @(Get-LocalUser -ErrorAction Stop)
97
+ foreach ($account in $accounts) {
98
+ $sid = $null
99
+ if ($account.SID) { $sid = [string]$account.SID.Value }
100
+ $homePath = $null
101
+ if ($sid -and $profiles.ContainsKey($sid)) { $homePath = $profiles[$sid] }
102
+ $localUsers += [ordered]@{
103
+ name = [string]$account.Name
104
+ fullName = [string]$account.FullName
105
+ enabled = [bool]$account.Enabled
106
+ sid = $sid
107
+ home = $homePath
108
+ }
109
+ }
110
+ } catch {
111
+ $errors += ('Get-LocalUser: ' + $_.Exception.Message)
112
+ try {
113
+ $fallbackAccounts = @(Get-CimInstance -ClassName Win32_UserAccount -Filter 'LocalAccount = True' -ErrorAction Stop)
114
+ foreach ($account in $fallbackAccounts) {
115
+ $sid = [string]$account.SID
116
+ $homePath = $null
117
+ if ($sid -and $profiles.ContainsKey($sid)) { $homePath = $profiles[$sid] }
118
+ $localUsers += [ordered]@{
119
+ name = [string]$account.Name
120
+ fullName = [string]$account.FullName
121
+ enabled = (-not [bool]$account.Disabled)
122
+ sid = $sid
123
+ home = $homePath
124
+ }
125
+ }
126
+ } catch { $errors += ('Win32_UserAccount: ' + $_.Exception.Message) }
127
+ }
128
+
129
+ $admins = @()
130
+ try {
131
+ $adminGroup = $null
132
+ try { $adminGroup = Get-LocalGroup -SID 'S-1-5-32-544' -ErrorAction Stop } catch { $adminGroup = $null }
133
+ if ($null -eq $adminGroup) {
134
+ # The group display name is localized; resolve it by the well-known
135
+ # Administrators SID instead of the English name.
136
+ $adminGroup = Get-LocalGroup -ErrorAction Stop | Where-Object { $_.SID.Value -eq 'S-1-5-32-544' } | Select-Object -First 1
137
+ }
138
+ if ($null -eq $adminGroup) { throw 'Could not resolve the local Administrators group (S-1-5-32-544).' }
139
+ $members = @(Get-LocalGroupMember -Group $adminGroup -ErrorAction Stop)
140
+ foreach ($member in $members) {
141
+ $memberName = [string]$member.Name
142
+ if ($memberName -match '\\') {
143
+ $parts = $memberName -split '\\'
144
+ $memberName = [string]$parts[$parts.Count - 1]
145
+ }
146
+ if ($memberName) { $admins += $memberName }
147
+ }
148
+ } catch { $errors += ('local administrators: ' + $_.Exception.Message) }
149
+
150
+ $currentUser = $null
151
+ if ($env:USERNAME) {
152
+ $currentFullName = $null
153
+ foreach ($account in $localUsers) {
154
+ if ([string]$account.name -ieq [string]$env:USERNAME) { $currentFullName = [string]$account.fullName; break }
155
+ }
156
+ $isAdmin = $false
157
+ foreach ($adminName in $admins) {
158
+ if ([string]$adminName -ieq [string]$env:USERNAME) { $isAdmin = $true; break }
159
+ }
160
+ $currentUser = [ordered]@{
161
+ name = [string]$env:USERNAME
162
+ fullName = $currentFullName
163
+ home = [string]$env:USERPROFILE
164
+ admin = $isAdmin
165
+ }
166
+ }
167
+
168
+ # ---------------------------------------------------------------- startup --
169
+ $startup = @()
170
+ $runKeys = @(
171
+ [pscustomobject]@{ hive = 'HKCU'; path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' },
172
+ [pscustomobject]@{ hive = 'HKLM'; path = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' },
173
+ # The 32-bit registry view needs its own pseudo-hive so the action engine
174
+ # (system.launchAgent) can re-enable the entry in the same key it came from;
175
+ # disabling startup:HKLM:<name> would otherwise target the 64-bit Run key.
176
+ [pscustomobject]@{ hive = 'HKLM32'; path = 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Run' }
177
+ )
178
+ foreach ($runKey in $runKeys) {
179
+ try {
180
+ $properties = Get-ItemProperty -Path $runKey.path -ErrorAction Stop
181
+ $scope = 'system'
182
+ if ($runKey.hive -eq 'HKCU') { $scope = 'user' }
183
+ # HKLM and HKLM32 are both machine scope; only the pseudo-path differs.
184
+ foreach ($property in $properties.PSObject.Properties) {
185
+ $valueName = [string]$property.Name
186
+ if ($valueName -like 'PS*') { continue }
187
+ $startup += [ordered]@{
188
+ label = $valueName
189
+ path = ('startup:' + $runKey.hive + ':' + $valueName)
190
+ scope = $scope
191
+ loaded = $true
192
+ program = [string]$property.Value
193
+ hive = $runKey.hive
194
+ location = 'Software\Microsoft\Windows\CurrentVersion\Run'
195
+ source = 'registry'
196
+ }
197
+ }
198
+ } catch {
199
+ $message = $_.Exception.Message
200
+ if ($message -notmatch 'Cannot find path' -and $message -notmatch 'does not exist') {
201
+ $errors += ('registry ' + $runKey.path + ': ' + $message)
202
+ }
203
+ }
204
+ }
205
+
206
+ # Entries disabled by the DeviceX action engine (see system.launchAgent). The
207
+ # action side stores one subkey per hive under
208
+ # `HKCU:\Software\DeviceX\DisabledStartup\<HIVE>`, value name -> command.
209
+ foreach ($disabledHive in @('HKCU', 'HKLM', 'HKLM32')) {
210
+ $disabledKeyPath = 'HKCU:\Software\DeviceX\DisabledStartup\' + $disabledHive
211
+ try {
212
+ $disabledProperties = Get-ItemProperty -Path $disabledKeyPath -ErrorAction Stop
213
+ if ($null -eq $disabledProperties) { continue }
214
+ $disabledScope = 'system'
215
+ if ($disabledHive -eq 'HKCU') { $disabledScope = 'user' }
216
+ foreach ($property in $disabledProperties.PSObject.Properties) {
217
+ $valueName = [string]$property.Name
218
+ if ($valueName -like 'PS*' -or -not $valueName) { continue }
219
+ $startup += [ordered]@{
220
+ label = $valueName
221
+ path = ('startup:' + $disabledHive + ':' + $valueName)
222
+ scope = $disabledScope
223
+ loaded = $false
224
+ program = [string]$property.Value
225
+ hive = $disabledHive
226
+ location = 'Software\Microsoft\Windows\CurrentVersion\Run'
227
+ source = 'registry'
228
+ }
229
+ }
230
+ } catch { }
231
+ }
232
+
233
+ $loginItems = @()
234
+ try {
235
+ $startupCommands = @(Get-CimInstance -ClassName Win32_StartupCommand -ErrorAction Stop)
236
+ foreach ($startupCommand in $startupCommands) {
237
+ $location = [string]$startupCommand.Location
238
+ if ($location -match '^(HKCU|HKLM)') { continue }
239
+ $loginItems += [ordered]@{
240
+ name = [string]$startupCommand.Name
241
+ command = [string]$startupCommand.Command
242
+ }
243
+ }
244
+ } catch { $errors += ('Win32_StartupCommand: ' + $_.Exception.Message) }
245
+
246
+ # ------------------------------------------------------ environment/locale --
247
+ $environment = [ordered]@{
248
+ PATH = [string]$env:Path
249
+ SHELL = $null
250
+ HOME = [string]$env:USERPROFILE
251
+ USER = [string]$env:USERNAME
252
+ LANG = $env:LANG
253
+ TERM = $env:TERM
254
+ }
255
+
256
+ $locale = [ordered]@{ timezone = $null; locale = $null; language = $null }
257
+ try { $locale.timezone = [string](Get-TimeZone).Id } catch { $locale.timezone = $null }
258
+ try { $locale.locale = [string](Get-Culture).Name } catch { $locale.locale = $null }
259
+ try { $locale.language = [string](Get-UICulture).Name } catch { $locale.language = $null }
260
+
261
+ # -------------------------------------------------------------------- emit --
262
+ $result = [ordered]@{
263
+ generatedAt = ([DateTimeOffset]::UtcNow).ToUnixTimeMilliseconds()
264
+ os = $osInfo
265
+ device = $device
266
+ boot = $bootInfo
267
+ users = [ordered]@{
268
+ current = $currentUser
269
+ localUsers = $localUsers
270
+ admins = $admins
271
+ }
272
+ startup = $startup
273
+ loginItems = $loginItems
274
+ environment = $environment
275
+ locale = $locale
276
+ errors = $errors
277
+ }
278
+
279
+ $result | ConvertTo-Json -Depth 8 -Compress
package/host/win.js ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Device X — PowerShell bridge for the Windows host half.
3
+ *
4
+ * Every Windows collection and action goes through this module so one place
5
+ * guarantees: a resolved interpreter (Windows PowerShell 5.1 preferred, then
6
+ * pwsh 7), stdin-streamed scripts (`-Command -`; no shell, no quoting
7
+ * injection, and no 32,767-character command-line limit), UTF-8 console
8
+ * output, a wall-clock timeout, a bounded buffer, and JSON parsing that never
9
+ * throws.
10
+ *
11
+ * Parameters reach scripts through environment variables (`DEVICEX_ARG_*`)
12
+ * rather than string interpolation; callers use {@link psString} only for
13
+ * constant option values.
14
+ *
15
+ * @module @cfour-hi/dsh-plugin-devicex/host/win
16
+ */
17
+ import { readFileSync } from 'node:fs';
18
+ import { run } from './proc.js';
19
+
20
+ /** Default wall-clock timeout for one PowerShell call. */
21
+ export const DEFAULT_TIMEOUT = 20_000;
22
+ /** Default stdout/stderr buffer (CIM JSON is well below this). */
23
+ export const DEFAULT_MAX_BUFFER = 16 * 1024 * 1024;
24
+
25
+ /** Prefix every script with UTF-8 console settings and a quiet progress bar. */
26
+ const OUTPUT_PREFIX =
27
+ "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;" +
28
+ "$OutputEncoding=[System.Text.Encoding]::UTF8;" +
29
+ "$ProgressPreference='SilentlyContinue';";
30
+
31
+ /** Interpreter candidates, in preference order.
32
+ *
33
+ * Windows PowerShell 5.1 comes first: the collectors/actions use inbox Windows
34
+ * modules (`Get-LocalUser`, `Get-BitLockerVolume`, `Get-MpComputerStatus`,
35
+ * `Get-PnpDevice`, `Get-PhysicalDisk`) that are not guaranteed under pwsh 7.
36
+ * Set DEVICEX_PWSH to override (useful on hosts where only pwsh 7 exists).
37
+ */
38
+ /** @type {string[]} */
39
+ export const PS_CANDIDATES = /** @type {string[]} */ ([
40
+ process.env.DEVICEX_PWSH,
41
+ 'powershell.exe',
42
+ 'pwsh.exe',
43
+ 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
44
+ ].filter((candidate) => typeof candidate === 'string' && candidate.length > 0));
45
+
46
+ /** One resolved interpreter per Host process lifetime. */
47
+ let resolution;
48
+
49
+ /**
50
+ * Resolve the first working PowerShell interpreter.
51
+ * @returns {Promise<{command: string, major: number|null}|null>}
52
+ */
53
+ export function resolveInterpreter() {
54
+ resolution ??= (async () => {
55
+ for (const candidate of PS_CANDIDATES) {
56
+ const probe = await run(
57
+ candidate,
58
+ ['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'],
59
+ { timeout: 8_000, maxBuffer: 64 * 1024 },
60
+ );
61
+ if (probe.ok) {
62
+ const major = Number.parseInt(probe.stdout.trim(), 10);
63
+ return { command: candidate, major: Number.isFinite(major) ? major : null };
64
+ }
65
+ }
66
+ return null;
67
+ })();
68
+ return resolution;
69
+ }
70
+
71
+ /**
72
+ * Build the interpreter argv for one streamed script.
73
+ *
74
+ * The script is written to stdin (`-Command -`) instead of being encoded into
75
+ * argv: Windows caps a command line at 32,767 characters and the bundled
76
+ * collectors are larger than that once UTF-16/base64 encoded.
77
+ * @returns {string[]}
78
+ */
79
+ export function commandArguments() {
80
+ return ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', '-'];
81
+ }
82
+
83
+ /**
84
+ * Quote one value as a PowerShell single-quoted literal.
85
+ * @param {string} value
86
+ * @returns {string}
87
+ */
88
+ export function psString(value) {
89
+ return `'${String(value).replaceAll("'", "''")}'`;
90
+ }
91
+
92
+ /**
93
+ * Run a PowerShell script with no implicit shell.
94
+ * @param {string} script PowerShell source.
95
+ * @param {object} [options]
96
+ * @param {number} [options.timeout] milliseconds.
97
+ * @param {number} [options.maxBuffer] bytes.
98
+ * @param {Record<string,string>} [options.env] extra environment variables.
99
+ * @returns {Promise<{ok: boolean, stdout: string, stderr: string, code: number|null,
100
+ * signal: string|null, timedOut: boolean, error: string|null}>}
101
+ */
102
+ export async function runPS(script, options = {}) {
103
+ const interpreter = await resolveInterpreter();
104
+ if (interpreter === null) {
105
+ return {
106
+ ok: false,
107
+ stdout: '',
108
+ stderr: 'no PowerShell interpreter found (pwsh.exe / powershell.exe)',
109
+ code: null,
110
+ signal: null,
111
+ timedOut: false,
112
+ error: 'PowerShell is not available on this host',
113
+ };
114
+ }
115
+ return run(interpreter.command, commandArguments(), {
116
+ timeout: options.timeout ?? DEFAULT_TIMEOUT,
117
+ maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER,
118
+ env: options.env === undefined ? undefined : { ...process.env, ...options.env },
119
+ input: `${OUTPUT_PREFIX}${script}`,
120
+ });
121
+ }
122
+
123
+ /** Strip a UTF-8 BOM and surrounding whitespace. */
124
+ function trimOutput(text) {
125
+ return String(text ?? '').replace(/^\uFEFF/, '').trim();
126
+ }
127
+
128
+ /**
129
+ * Run a script that ends by writing one JSON document, and parse it.
130
+ * A failed run, empty output, or malformed JSON becomes `{ ok: false }` with
131
+ * a short diagnostic supplied by the script (or from stderr); it never throws.
132
+ * @param {string} script
133
+ * @param {object} [options] same as {@link runPS}.
134
+ * @returns {Promise<{ok: true, data: any, raw: string} | {ok: false, error: string, raw: string, stderr: string}>}
135
+ */
136
+ export async function runPSJson(script, options = {}) {
137
+ const result = await runPS(script, options);
138
+ const raw = trimOutput(result.stdout);
139
+ if (raw === '') {
140
+ const detail = trimOutput(result.stderr) || result.error || 'PowerShell returned no output';
141
+ return { ok: false, error: short(detail), raw, stderr: trimOutput(result.stderr) };
142
+ }
143
+ try {
144
+ return { ok: true, data: JSON.parse(raw), raw };
145
+ } catch (error) {
146
+ return {
147
+ ok: false,
148
+ error: `PowerShell JSON was not parseable: ${short(error instanceof Error ? error.message : String(error))}`,
149
+ raw,
150
+ stderr: trimOutput(result.stderr),
151
+ };
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Read one bundled PowerShell script from `plugin/host/win/`.
157
+ * Scripts are cached after first use.
158
+ * @param {string} name file name inside `host/win/`.
159
+ * @returns {string}
160
+ */
161
+ const scriptCache = new Map();
162
+ export function readScript(name) {
163
+ if (!scriptCache.has(name)) {
164
+ const url = new URL(`./win/${name}`, import.meta.url);
165
+ scriptCache.set(name, readFileSync(url, 'utf8'));
166
+ }
167
+ return scriptCache.get(name);
168
+ }
169
+
170
+ /** Shorten a diagnostic to a single useful line. */
171
+ function short(text) {
172
+ const line = String(text ?? '')
173
+ .split(/\r?\n/)
174
+ .map((entry) => entry.trim())
175
+ .filter(Boolean)
176
+ .slice(0, 2)
177
+ .join(' ');
178
+ return line.length > 400 ? `${line.slice(0, 400)}…` : line || 'unknown PowerShell failure';
179
+ }
180
+
181
+ /** Test seam: stable pure helpers used by the offline verification script. */
182
+ export const __test = {
183
+ commandArguments,
184
+ psString,
185
+ trimOutput,
186
+ OUTPUT_PREFIX,
187
+ };
package/icon.svg ADDED
@@ -0,0 +1,18 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Device X">
2
+ <defs>
3
+ <linearGradient id="dx-body" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#2f6bff"/>
5
+ <stop offset="1" stop-color="#7a4dff"/>
6
+ </linearGradient>
7
+ <linearGradient id="dx-orbit" x1="0" y1="0" x2="1" y2="1">
8
+ <stop offset="0" stop-color="#63e6ff"/>
9
+ <stop offset="1" stop-color="#9d7bff"/>
10
+ </linearGradient>
11
+ </defs>
12
+ <rect x="13" y="17" width="38" height="30" rx="7" fill="url(#dx-body)"/>
13
+ <rect x="19" y="23" width="26" height="18" rx="3" fill="#0b1024" opacity="0.82"/>
14
+ <path d="M19 28h26M19 33h18M19 38h22" stroke="#63e6ff" stroke-width="1.6" stroke-linecap="round" opacity="0.9"/>
15
+ <path d="M22 47v6M32 47v6M42 47v6M17 22v-5M32 22V14M47 22v-5" stroke="#c9d6ff" stroke-width="2.4" stroke-linecap="round"/>
16
+ <ellipse cx="32" cy="32" rx="29" ry="11" fill="none" stroke="url(#dx-orbit)" stroke-width="2" transform="rotate(-24 32 32)" opacity="0.95"/>
17
+ <circle cx="52.5" cy="21.5" r="3.1" fill="#63e6ff"/>
18
+ </svg>
package/index.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Device X host half.
3
+ *
4
+ * Owns the device data collectors, the whitelisted device action engine, the
5
+ * browser HTTP API consumed by `client.js`, and the two agent tools. The whole
6
+ * surface is one implementation: the UI routes and the tools call the same
7
+ * functions.
8
+ *
9
+ * @module @cfour-hi/dsh-plugin-devicex
10
+ */
11
+ import {
12
+ SECTIONS,
13
+ PLATFORM,
14
+ warmup,
15
+ collectMetrics,
16
+ collectSection,
17
+ collectInventory,
18
+ } from './host/collector.js';
19
+ import { GROUPS, actionCatalog, actionHistory, runAction } from './host/actions.js';
20
+ import { scanCleanup } from './host/cleanup.js';
21
+ import { runBenchmark } from './host/benchmark.js';
22
+ import { registerRoutes } from './host/transport.js';
23
+ import { registerTools } from './host/tool.js';
24
+
25
+ /** Cordis function-plugin name. */
26
+ export const name = 'devicex';
27
+
28
+ /**
29
+ * The route carrier, its trust fence, and the tool registry. All are hard
30
+ * dependencies: without them the plugin has nothing to serve.
31
+ */
32
+ export const inject = ['webServer', 'connection', 'tools'];
33
+
34
+ /** The one shared operation surface both callers use. */
35
+ /** @type {import('../types/contracts.js').PluginOperations} */
36
+ const operations = {
37
+ SECTIONS,
38
+ PLATFORM,
39
+ GROUPS,
40
+ collectMetrics,
41
+ collectSection,
42
+ collectInventory,
43
+ actionCatalog,
44
+ actionHistory,
45
+ runAction,
46
+ scanCleanup,
47
+ runBenchmark,
48
+ };
49
+
50
+ /**
51
+ * Register the browser API and the agent tools, keeping the slow collectors
52
+ * warm so the first panel view is fast.
53
+ * @param {import('../types/contracts.js').PluginContext} ctx - the plugin's Cordis context.
54
+ */
55
+ export function apply(ctx) {
56
+ warmup();
57
+ ctx.effect(
58
+ () => registerRoutes(ctx, operations),
59
+ 'devicex: browser API routes',
60
+ );
61
+ ctx.effect(
62
+ () => registerTools(ctx, operations),
63
+ 'devicex: agent tools',
64
+ );
65
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Device X",
4
+ "description": "See everything about this device — hardware, system, software, network, security, and processes — and control what can be changed."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Device X 设备总览",
4
+ "description": "一览本机硬件、系统、软件、网络、安全与进程,并直接控制一切可修改项。"
5
+ }
6
+ }