dsh-winrm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/LICENSE +73 -0
  2. package/README.md +112 -0
  3. package/README.zh.md +119 -0
  4. package/RELEASING.md +75 -0
  5. package/cordis.patch.yml +8 -0
  6. package/lib/client.js +2076 -0
  7. package/lib/index.js +2458 -0
  8. package/lib/invariant.js +6 -0
  9. package/lib/types/client/api.d.ts +59 -0
  10. package/lib/types/client/index.d.ts +38 -0
  11. package/lib/types/client/locales.d.ts +231 -0
  12. package/lib/types/client/panel/ConsoleTab.d.ts +11 -0
  13. package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
  14. package/lib/types/client/panel/HostsTab.d.ts +9 -0
  15. package/lib/types/client/panel/ProcessesTab.d.ts +7 -0
  16. package/lib/types/client/panel/ServicesTab.d.ts +7 -0
  17. package/lib/types/client/panel/TransferTab.d.ts +7 -0
  18. package/lib/types/client/panel/WinrmPanel.d.ts +12 -0
  19. package/lib/types/client/panel/WinrmPanelIcon.d.ts +8 -0
  20. package/lib/types/client/panel/helpers.d.ts +16 -0
  21. package/lib/types/engine/client.d.ts +49 -0
  22. package/lib/types/engine/console.d.ts +23 -0
  23. package/lib/types/engine.d.ts +58 -0
  24. package/lib/types/index.d.ts +44 -0
  25. package/lib/types/invariant.d.ts +3 -0
  26. package/lib/types/loopback.d.ts +24 -0
  27. package/lib/types/mount-once.d.ts +24 -0
  28. package/lib/types/powershell.d.ts +73 -0
  29. package/lib/types/protocol.d.ts +176 -0
  30. package/lib/types/routes.d.ts +26 -0
  31. package/lib/types/smb.d.ts +31 -0
  32. package/lib/types/store.d.ts +44 -0
  33. package/lib/types/tools.d.ts +20 -0
  34. package/package.json +111 -0
  35. package/scripts/enable-winrm.ps1 +0 -0
  36. package/scripts/postbuild.mjs +40 -0
  37. package/scripts/validate-manifest.mjs +18 -0
  38. package/src/client/api.ts +345 -0
  39. package/src/client/css-modules.d.ts +5 -0
  40. package/src/client/index.ts +82 -0
  41. package/src/client/locales.ts +243 -0
  42. package/src/client/panel/ConsoleTab.tsx +142 -0
  43. package/src/client/panel/HostFormDialog.tsx +142 -0
  44. package/src/client/panel/HostsTab.tsx +153 -0
  45. package/src/client/panel/ProcessesTab.tsx +119 -0
  46. package/src/client/panel/ServicesTab.tsx +123 -0
  47. package/src/client/panel/TransferTab.tsx +161 -0
  48. package/src/client/panel/WinrmPanel.tsx +85 -0
  49. package/src/client/panel/WinrmPanelIcon.tsx +27 -0
  50. package/src/client/panel/helpers.ts +33 -0
  51. package/src/client/panel/panel.module.css +1000 -0
  52. package/src/engine/client.ts +291 -0
  53. package/src/engine/console.ts +62 -0
  54. package/src/engine.ts +331 -0
  55. package/src/index.ts +142 -0
  56. package/src/invariant.ts +4 -0
  57. package/src/loopback.ts +63 -0
  58. package/src/mount-once.ts +48 -0
  59. package/src/powershell.ts +208 -0
  60. package/src/protocol.ts +178 -0
  61. package/src/routes.ts +490 -0
  62. package/src/smb.ts +129 -0
  63. package/src/store.ts +232 -0
  64. package/src/tools.ts +387 -0
package/lib/index.js ADDED
@@ -0,0 +1,2458 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { Command, Shell } from "winrm-client";
3
+ import { spawn } from "node:child_process";
4
+ import { copyFile, readFile, stat, unlink } from "node:fs/promises";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import { closeSync, createReadStream, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
7
+ import { basename, dirname, join, resolve } from "node:path";
8
+ import { homedir, tmpdir } from "node:os";
9
+ import { WebSocket, WebSocketServer } from "ws";
10
+ import { defineTool } from "@deepseek-ai/dsh-tools";
11
+ //#region src/powershell.ts
12
+ /** PS single-quoted literal (doubles embedded quotes). */
13
+ function sq(value) {
14
+ return "'" + value.replace(/'/g, "''") + "'";
15
+ }
16
+ /** Base64 of a UTF-16LE PowerShell script, for -EncodedCommand. */
17
+ function encodeCommand(script) {
18
+ return Buffer.from(script, "utf16le").toString("base64");
19
+ }
20
+ /**
21
+ * The UTF-8 WinRM session script: runs `userScript` inside a script block,
22
+ * merges stderr, prints the exit-code marker and base64 of UTF-8 output.
23
+ */
24
+ function wrapEnvelope(userScript) {
25
+ return [
26
+ "$ErrorActionPreference = 'Continue'",
27
+ "$__out = & { " + userScript + " } 2>&1 | Out-String -Width 4096",
28
+ "$__code = 0",
29
+ "if ($null -ne $LASTEXITCODE) { $__code = $LASTEXITCODE }",
30
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
31
+ "'__DSH_WINRM__' + [string]$__code",
32
+ "[System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$__out))",
33
+ ""
34
+ ].join("\r\n");
35
+ }
36
+ /** One-shot script body for the PowerShell session already created by WinRM. */
37
+ function winrmPowerShellScript(userScript) {
38
+ return wrapEnvelope(userScript);
39
+ }
40
+ /** The full powershell.exe command line (ASCII-safe) for a script body. */
41
+ function powershellCommandLine(script) {
42
+ return "powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand " + encodeCommand(script);
43
+ }
44
+ /** JSON: full service table (CIM). */
45
+ function psListServices() {
46
+ return [
47
+ "$__s = Get-CimInstance Win32_Service | Select-Object Name, DisplayName, State, StartMode, StartName | ForEach-Object {",
48
+ " [PSCustomObject]@{ name=$_.Name; displayName=$_.DisplayName; status=[string]$_.State; startMode=[string]$_.StartMode; startName=[string]$_.StartName }",
49
+ "}",
50
+ "ConvertTo-Json -InputObject $__s -Compress -Depth 3",
51
+ ""
52
+ ].join("\r\n");
53
+ }
54
+ /** JSON: one service after an action. */
55
+ function psServiceAction(name, action) {
56
+ const statements = [];
57
+ if (action === "start") statements.push("Start-Service -Name " + sq(name) + " -ErrorAction Stop");
58
+ if (action === "stop") statements.push("Stop-Service -Name " + sq(name) + " -Force -ErrorAction Stop");
59
+ if (action === "restart") statements.push("Restart-Service -Name " + sq(name) + " -Force -ErrorAction Stop");
60
+ if (action === "set-auto") statements.push("Set-Service -Name " + sq(name) + " -StartupType Automatic -ErrorAction Stop");
61
+ if (action === "set-manual") statements.push("Set-Service -Name " + sq(name) + " -StartupType Manual -ErrorAction Stop");
62
+ if (action === "set-disabled") statements.push("Set-Service -Name " + sq(name) + " -StartupType Disabled -ErrorAction Stop");
63
+ statements.push("$__s = Get-CimInstance Win32_Service -Filter " + sq("Name=" + name.replace(/'/g, "''")) + " | Select-Object Name, DisplayName, State, StartMode, StartName");
64
+ statements.push("ConvertTo-Json -InputObject $__s -Compress -Depth 3");
65
+ return statements.join("\r\n");
66
+ }
67
+ /** JSON: process table. */
68
+ function psListProcesses() {
69
+ return [
70
+ "$__p = Get-Process | Sort-Object Id | Select-Object Id, ProcessName, CPU, WS, StartTime, Path | ForEach-Object {",
71
+ " [PSCustomObject]@{ id=$_.Id; name=$_.ProcessName; cpu=if($null -eq $_.CPU){$null}else{[math]::Round([double]$_.CPU,1)}; memMB=[math]::Round($_.WS/1MB,1); startTime=if($_.StartTime){$_.StartTime.ToString('yyyy-MM-dd HH:mm:ss')}else{$null}; path=[string]$_.Path }",
72
+ "}",
73
+ "ConvertTo-Json -InputObject $__p -Compress -Depth 3",
74
+ ""
75
+ ].join("\r\n");
76
+ }
77
+ /** Kill one process by id. */
78
+ function psKillProcess(id) {
79
+ return "Stop-Process -Id " + String(id) + " -Force -ErrorAction Stop; \"killed \" + " + String(id);
80
+ }
81
+ /** JSON: directory listing. */
82
+ function psListDir(dir) {
83
+ return [
84
+ "$__d = " + sq(dir),
85
+ "$__items = Get-ChildItem -LiteralPath $__d -Force -ErrorAction Stop | Select-Object Name, PSIsContainer, Length, LastWriteTime | ForEach-Object {",
86
+ " [PSCustomObject]@{ name=[string]$_.Name; type=if($_.PSIsContainer){'dir'}elseif(-not $_.PSIsContainer -and $_.Length -ge 0){'file'}else{'other'}; size=if($_.PSIsContainer){0}else{[long]$_.Length}; mtimeMs=[long](([DateTimeOffset]$_.LastWriteTime).ToUnixTimeMilliseconds()) }",
87
+ "}",
88
+ "ConvertTo-Json -InputObject $__items -Compress -Depth 3",
89
+ ""
90
+ ].join("\r\n");
91
+ }
92
+ /** Plain: remote file size in bytes (0 when missing). */
93
+ function psFileSize(p) {
94
+ return [
95
+ "$__f = Get-Item -LiteralPath " + sq(p) + " -ErrorAction SilentlyContinue",
96
+ "if ($null -eq $__f) { \"0\" } elseif ($__f.PSIsContainer) { \"0\" } else { [string]$__f.Length }",
97
+ ""
98
+ ].join("\r\n");
99
+ }
100
+ /** Plain: base64 of up to `count` bytes at `offset` (download chunk). */
101
+ function psReadChunk(p, offset, count) {
102
+ return [
103
+ "$__p = " + sq(p),
104
+ "$__fs = [System.IO.File]::OpenRead($__p)",
105
+ "try {",
106
+ " $__fs.Position = [long]" + String(offset),
107
+ " $__buf = New-Object byte[] " + String(count),
108
+ " $__n = $__fs.Read($__buf, 0, $__buf.Length)",
109
+ " [System.Convert]::ToBase64String($__buf, 0, $__n)",
110
+ "} finally { $__fs.Dispose() }",
111
+ ""
112
+ ].join("\r\n");
113
+ }
114
+ /**
115
+ * Plain: append `b64` bytes to `p` (Create on first chunk, Append after),
116
+ * prints the number of bytes written.
117
+ */
118
+ function psWriteChunk(p, b64, append) {
119
+ return [
120
+ "$__p = " + sq(p),
121
+ "$__d = [System.IO.Path]::GetDirectoryName($__p)",
122
+ "if (-not [string]::IsNullOrEmpty($__d)) { [System.IO.Directory]::CreateDirectory($__d) | Out-Null }",
123
+ "$__b = [System.Convert]::FromBase64String(" + sq(b64) + ")",
124
+ "$__mode = " + (append ? "[System.IO.FileMode]::Append" : "[System.IO.FileMode]::Create"),
125
+ "$__fs = New-Object System.IO.FileStream($__p, $__mode, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read)",
126
+ "try { $__fs.Write($__b, 0, $__b.Length) } finally { $__fs.Dispose() }",
127
+ "[string]$__b.Length",
128
+ ""
129
+ ].join("\r\n");
130
+ }
131
+ /**
132
+ * Parse the envelope output: { exitCode, text } or null when absent.
133
+ *
134
+ * The base64 body is optional and the separating newline may be missing: a
135
+ * command that printed nothing emits the marker alone (an empty string has
136
+ * no base64 body), and a transport that trims each response drops the
137
+ * trailing newline. The exit code is signed because `$LASTEXITCODE` can be
138
+ * negative.
139
+ */
140
+ function parseEnvelope(output) {
141
+ const match = /^__DSH_WINRM__(-?\d+)(?:\r?\n([\s\S]*))?$/.exec(output);
142
+ if (match === null) return null;
143
+ const exitCode = Number.parseInt(match[1], 10);
144
+ const payload = (match[2] ?? "").trim();
145
+ try {
146
+ return {
147
+ exitCode,
148
+ text: Buffer.from(payload, "base64").toString("utf8")
149
+ };
150
+ } catch {
151
+ return {
152
+ exitCode,
153
+ text: payload
154
+ };
155
+ }
156
+ }
157
+ /** The `#< CLIXML` stream header PowerShell writes once before its serialized records. */
158
+ const CLIXML_HEADER = /^#< CLIXML\r?$/gm;
159
+ /** One serialized record block (`<Objs …>…</Objs>`) holding host progress/verbose records. */
160
+ const CLIXML_RECORDS = /<Objs\b[^>]*>[\s\S]*?<\/Objs>\r?\n?/g;
161
+ /**
162
+ * Remove PowerShell's CLIXML host records from the WinRS stderr stream.
163
+ *
164
+ * PowerShell marks its stderr stream with a `#< CLIXML` header line and
165
+ * serializes the remote host's own progress/verbose records into `<Objs>`
166
+ * blocks. Both are removed here. Anything else on the stream is genuine
167
+ * process-level stderr and is preserved verbatim — text written between the
168
+ * header and a record block (for example `[Console]::Error`) is real output,
169
+ * not part of the CLIXML payload.
170
+ *
171
+ * The script's own error output is already merged into stdout by the
172
+ * envelope, so this only removes transport-level noise.
173
+ * @param stderr - raw stderr text captured from the WinRS stream.
174
+ * @returns the stderr text without CLIXML host records.
175
+ */
176
+ function stripClixml(stderr) {
177
+ return stderr.replace(CLIXML_HEADER, "").replace(CLIXML_RECORDS, "").trim();
178
+ }
179
+ //#endregion
180
+ //#region src/engine/client.ts
181
+ /**
182
+ * WinRM transport bridge.
183
+ *
184
+ * Runs each PowerShell payload over a native Node.js WinRM client
185
+ * (winrm-client) — no Python/pywinrm subprocess. The script rides the
186
+ * UTF-8 base64 envelope (marker + base64 of UTF-8 output) so Chinese and
187
+ * any codepage output survives the WinRM transport losslessly; the envelope
188
+ * is base64-encoded with `-EncodedCommand`, keeping the command line a
189
+ * single ASCII-safe line that WinRS/cmd.exe cannot mangle.
190
+ *
191
+ * Auth is chosen per attempt (HTTPS: Basic first, then NTLM; HTTP: NTLM
192
+ * first, then Basic) with fallback, so a host that only enables one scheme
193
+ * still connects. Credentials are passed to the library in-process — they
194
+ * never appear in a child-process argument list.
195
+ */
196
+ /** Extra wall-clock budget so connection setup does not eat the command's timeout window. */
197
+ const SETUP_GRACE_MS = 5e3;
198
+ /** Upper bound on the best-effort shell-delete round trip after a timed-out call. */
199
+ const DELETE_TIMEOUT_MS = 1e4;
200
+ /** Sentinel: the command exceeded its deadline (distinct from auth/transport errors). */
201
+ var WinrmTimedOut = class extends Error {
202
+ constructor() {
203
+ super("WinRM request timed out");
204
+ this.name = "WinrmTimedOut";
205
+ }
206
+ };
207
+ /** Small cancellable delay used to bound the shell-delete cleanup. */
208
+ function sleep(ms) {
209
+ return new Promise((resolve) => {
210
+ setTimeout(resolve, ms);
211
+ });
212
+ }
213
+ /**
214
+ * Race a promise against a deadline; the loser's later settlement is
215
+ * ignored (Promise.race attaches a handler to both), so a hung WinRM call
216
+ * cannot surface as an unhandled rejection.
217
+ * @param promise - the operation to bound.
218
+ * @param ms - milliseconds before rejecting with {@link WinrmTimedOut}.
219
+ * @returns the operation result, or rejects with WinrmTimedOut on deadline.
220
+ */
221
+ async function raceDeadline(promise, ms) {
222
+ let timer;
223
+ const timeout = new Promise((_resolve, reject) => {
224
+ timer = setTimeout(() => {
225
+ reject(new WinrmTimedOut());
226
+ }, ms);
227
+ });
228
+ try {
229
+ return await Promise.race([promise, timeout]);
230
+ } finally {
231
+ if (timer !== void 0) clearTimeout(timer);
232
+ }
233
+ }
234
+ /** Build winrm-client's base params for one auth scheme. */
235
+ function baseParams(conn, authMethod) {
236
+ return {
237
+ host: conn.host,
238
+ port: conn.port,
239
+ path: conn.path,
240
+ username: conn.username,
241
+ password: conn.password,
242
+ authMethod,
243
+ useHttps: conn.useHttps ?? false,
244
+ rejectUnauthorized: conn.rejectUnauthorized ?? true
245
+ };
246
+ }
247
+ /**
248
+ * One shell lifecycle: create → run the encoded script → receive until the
249
+ * command completes → delete. Bounded by an overall deadline; on timeout the
250
+ * delete is fired without blocking the caller.
251
+ * @param conn - connection parameters.
252
+ * @param envelope - the wrapped UTF-8 envelope script (already prepared).
253
+ * @param timeoutMs - command execution budget measured from just after Execute.
254
+ * @param authMethod - the auth scheme for this attempt.
255
+ * @returns stdout/stderr captured from the WinRS streams.
256
+ * @throws WinrmTimedOut when the deadline passes; other errors are auth/transport failures.
257
+ */
258
+ async function attemptOnce(conn, envelope, timeoutMs, authMethod) {
259
+ const base = baseParams(conn, authMethod);
260
+ const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS;
261
+ const state = {
262
+ shellId: void 0,
263
+ timedOut: false,
264
+ settled: false
265
+ };
266
+ const inner = (async () => {
267
+ const shellId = await Shell.doCreateShell(base);
268
+ state.shellId = shellId;
269
+ if (state.settled) {
270
+ Shell.doDeleteShell({
271
+ ...base,
272
+ shellId
273
+ }).catch(() => void 0);
274
+ throw new WinrmTimedOut();
275
+ }
276
+ const command = powershellCommandLine(envelope);
277
+ const commandId = await Command.doExecuteCommand({
278
+ ...base,
279
+ shellId,
280
+ command
281
+ });
282
+ const receive = {
283
+ ...base,
284
+ shellId,
285
+ commandId
286
+ };
287
+ const commandDeadline = Date.now() + timeoutMs;
288
+ let stdout = "";
289
+ let stderr = "";
290
+ for (;;) {
291
+ if (state.settled || Date.now() >= commandDeadline) throw new WinrmTimedOut();
292
+ const chunk = await Command.doReceiveOutputNonBlocking(receive);
293
+ stdout += chunk.output;
294
+ stderr += chunk.stderr;
295
+ if (chunk.isComplete) return {
296
+ stdout,
297
+ stderr
298
+ };
299
+ }
300
+ })();
301
+ try {
302
+ return await raceDeadline(inner, hardDeadline - Date.now());
303
+ } catch (error) {
304
+ if (error instanceof WinrmTimedOut || Date.now() >= hardDeadline) {
305
+ state.timedOut = true;
306
+ throw error instanceof WinrmTimedOut ? error : new WinrmTimedOut();
307
+ }
308
+ throw error;
309
+ } finally {
310
+ state.settled = true;
311
+ const shellId = state.shellId;
312
+ if (shellId !== void 0) {
313
+ const deletion = Shell.doDeleteShell({
314
+ ...base,
315
+ shellId
316
+ }).catch(() => void 0);
317
+ if (state.timedOut);
318
+ else await Promise.race([deletion, sleep(DELETE_TIMEOUT_MS)]);
319
+ }
320
+ }
321
+ }
322
+ /**
323
+ * Run one envelope over WinRM, trying each auth scheme in order until one
324
+ * succeeds. A timeout aborts immediately (retrying auth would waste the
325
+ * remaining budget on a reachable-but-slow host).
326
+ * @param conn - connection parameters.
327
+ * @param envelope - the wrapped UTF-8 envelope script.
328
+ * @param timeoutMs - command execution budget.
329
+ * @returns captured stdout/stderr.
330
+ * @throws WinrmTimedOut on deadline; the last auth/transport error when every scheme fails.
331
+ */
332
+ async function runWinrm(conn, envelope, timeoutMs) {
333
+ const candidates = conn.useHttps ? ["basic", "ntlm"] : ["ntlm", "basic"];
334
+ let lastError;
335
+ for (const authMethod of candidates) try {
336
+ return await attemptOnce(conn, envelope, timeoutMs, authMethod);
337
+ } catch (error) {
338
+ if (error instanceof WinrmTimedOut) throw error;
339
+ lastError = error;
340
+ }
341
+ throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error("all WinRM authentication methods failed");
342
+ }
343
+ async function runScript(conn, script, options = {}) {
344
+ const started = Date.now();
345
+ const timeoutMs = options.timeoutMs ?? 6e4;
346
+ const envelope = winrmPowerShellScript(script);
347
+ let outcome;
348
+ try {
349
+ outcome = await runWinrm(conn, envelope, timeoutMs);
350
+ } catch (error) {
351
+ if (error instanceof WinrmTimedOut) return {
352
+ stdout: "",
353
+ stderr: "",
354
+ exitCode: null,
355
+ timedOut: true,
356
+ durationMs: Date.now() - started
357
+ };
358
+ throw error;
359
+ }
360
+ const raw = outcome.stdout;
361
+ const parsed = parseEnvelope(raw);
362
+ const exitCode = parsed !== null ? parsed.exitCode : 1;
363
+ const stderr = parsed !== null ? stripClixml(outcome.stderr) : outcome.stderr;
364
+ options.onChunk?.({
365
+ output: raw,
366
+ stderr
367
+ });
368
+ return {
369
+ stdout: parsed !== null ? parsed.text : raw,
370
+ stderr,
371
+ exitCode,
372
+ timedOut: false,
373
+ durationMs: Date.now() - started
374
+ };
375
+ }
376
+ function connOf(entry) {
377
+ return {
378
+ host: entry.host,
379
+ port: entry.port,
380
+ path: "/wsman",
381
+ username: entry.user,
382
+ password: entry.auth.password ?? "",
383
+ useHttps: entry.transport === "https",
384
+ rejectUnauthorized: entry.rejectUnauthorized ?? true
385
+ };
386
+ }
387
+ async function testConnection(conn) {
388
+ const started = Date.now();
389
+ try {
390
+ const result = await runScript(conn, "Write-Output ok", { timeoutMs: 3e4 });
391
+ return {
392
+ ok: result.exitCode === 0 && result.stdout.trim() !== "",
393
+ latencyMs: Date.now() - started
394
+ };
395
+ } catch (error) {
396
+ return {
397
+ ok: false,
398
+ latencyMs: Date.now() - started,
399
+ error: error instanceof Error ? error.message : String(error)
400
+ };
401
+ }
402
+ }
403
+ const TRANSFER_CHUNK = 48 * 1024;
404
+ async function remoteFileSize(conn, remotePath, timeoutMs = 6e4) {
405
+ const result = await runScript(conn, psFileSize(remotePath), { timeoutMs });
406
+ const value = Number.parseInt(result.stdout.trim(), 10);
407
+ return Number.isFinite(value) ? value : 0;
408
+ }
409
+ async function downloadChunks(conn, remotePath, onChunk, onProgress, timeoutMs = 6e4) {
410
+ const total = await remoteFileSize(conn, remotePath, timeoutMs);
411
+ if (total === 0) return 0;
412
+ let offset = 0;
413
+ for (;;) {
414
+ const b64 = (await runScript(conn, psReadChunk(remotePath, offset, TRANSFER_CHUNK), { timeoutMs })).stdout.trim();
415
+ const bytes = Buffer.from(b64, "base64").length;
416
+ if (bytes === 0) break;
417
+ onChunk(b64, bytes);
418
+ offset += bytes;
419
+ onProgress?.(offset, total);
420
+ if (offset >= total || bytes < 49152) break;
421
+ }
422
+ return offset;
423
+ }
424
+ async function uploadBuffer(conn, remotePath, data, onProgress, timeoutMs = 6e4) {
425
+ let written = 0;
426
+ let first = true;
427
+ for (let offset = 0; offset < data.length; offset += TRANSFER_CHUNK) {
428
+ const chunk = data.subarray(offset, Math.min(offset + TRANSFER_CHUNK, data.length));
429
+ await runScript(conn, psWriteChunk(remotePath, chunk.toString("base64"), !first), { timeoutMs });
430
+ written += chunk.length;
431
+ first = false;
432
+ onProgress?.(written, data.length);
433
+ }
434
+ return written;
435
+ }
436
+ //#endregion
437
+ //#region src/smb.ts
438
+ /**
439
+ * SMB transfer bridge.
440
+ *
441
+ * The WinRM base64 chunk bridge is reliable for small files but breaks for
442
+ * large artifacts (the WinRM/HTTP envelope rejects large payloads), so whole
443
+ * files ride the remote admin share. This module mounts the remote host's
444
+ * admin share with `net use` (password passed via stdin, never argv), copies
445
+ * whole files with node:fs, then verifies the SHA-256 on both ends.
446
+ */
447
+ /**
448
+ * Convert a Windows drive path to its UNC admin-share form.
449
+ * D:\\dir\\file -> \\\\host\\D$\\dir\\file
450
+ */
451
+ function adminSharePath(host, remotePath) {
452
+ const absolute = remotePath.replace(/[\\/]+$/, "");
453
+ const match = /^([A-Za-z]):[\\/](.*)$/.exec(absolute);
454
+ if (match === null) throw new Error("SMB requires an absolute remote path like D:\\dir\\file");
455
+ const drive = match[1];
456
+ const tail = match[2];
457
+ return "\\\\" + host + "\\" + drive + "$" + (tail === "" ? "" : "\\" + tail);
458
+ }
459
+ /** Run one cmd.exe process; returns output and exit code. */
460
+ function runCmd(args, input, timeoutMs = 6e4) {
461
+ return new Promise((resolve) => {
462
+ const child = spawn("cmd.exe", ["/c", ...args], {
463
+ windowsHide: true,
464
+ stdio: [
465
+ "pipe",
466
+ "pipe",
467
+ "pipe"
468
+ ]
469
+ });
470
+ const stdout = [];
471
+ const stderr = [];
472
+ let settled = false;
473
+ const timer = setTimeout(() => {
474
+ if (settled) return;
475
+ try {
476
+ child.kill();
477
+ } catch {}
478
+ settled = true;
479
+ resolve({
480
+ code: 1,
481
+ stdout: "",
482
+ stderr: "SMB net use timed out"
483
+ });
484
+ }, timeoutMs);
485
+ child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
486
+ child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
487
+ child.on("error", (error) => {
488
+ if (settled) return;
489
+ clearTimeout(timer);
490
+ settled = true;
491
+ resolve({
492
+ code: 1,
493
+ stdout: "",
494
+ stderr: error instanceof Error ? error.message : String(error)
495
+ });
496
+ });
497
+ child.on("close", (code) => {
498
+ if (settled) return;
499
+ clearTimeout(timer);
500
+ settled = true;
501
+ resolve({
502
+ code: code ?? 1,
503
+ stdout: Buffer.concat(stdout).toString("utf8"),
504
+ stderr: Buffer.concat(stderr).toString("utf8")
505
+ });
506
+ });
507
+ if (input !== void 0) child.stdin.end(input);
508
+ else child.stdin.end();
509
+ });
510
+ }
511
+ /** SHA-256 of a local file. */
512
+ async function sha256File(localPath) {
513
+ return createHash("sha256").update(await readFile(localPath)).digest("hex").toUpperCase();
514
+ }
515
+ /** SHA-256 of a remote file through the UNC path. */
516
+ async function remoteSha256(host, remotePath) {
517
+ const { execFile } = await import("node:child_process");
518
+ const { promisify } = await import("node:util");
519
+ const { stdout } = await promisify(execFile)("certutil.exe", [
520
+ "-hashfile",
521
+ adminSharePath(host, remotePath),
522
+ "SHA256"
523
+ ]);
524
+ const match = /([0-9A-Fa-f]{64})/.exec(stdout);
525
+ if (match === null) throw new Error("SMB remote hash verification failed: " + stdout);
526
+ return match[1].toUpperCase();
527
+ }
528
+ /**
529
+ * Copy one local file to a remote Windows host over SMB, verifying the
530
+ * remote SHA-256 matches the local source.
531
+ */
532
+ async function uploadSmb(entry, localPath, remotePath, timeoutMs = 3e5) {
533
+ const unc = adminSharePath(entry.host, remotePath);
534
+ const mount = await runCmd([
535
+ "net",
536
+ "use",
537
+ "\\\\" + entry.host + "\\IPC$",
538
+ entry.auth.password ?? "",
539
+ "/user:" + entry.user
540
+ ], void 0, timeoutMs);
541
+ if (mount.code !== 0) throw new Error("SMB net use failed: " + (mount.stderr || mount.stdout));
542
+ try {
543
+ const { mkdir } = await import("node:fs/promises");
544
+ const { dirname } = await import("node:path");
545
+ await mkdir(dirname(unc), { recursive: true });
546
+ await copyFile(localPath, unc);
547
+ const local = await stat(localPath);
548
+ const localHash = await sha256File(localPath);
549
+ const remoteHash = await remoteSha256(entry.host, remotePath);
550
+ if (remoteHash !== localHash) throw new Error("SMB upload hash mismatch: local " + localHash + ", remote " + remoteHash);
551
+ return {
552
+ bytes: local.size,
553
+ channel: "smb"
554
+ };
555
+ } finally {
556
+ await runCmd([
557
+ "net",
558
+ "use",
559
+ "\\\\" + entry.host + "\\IPC$",
560
+ "/delete",
561
+ "/y"
562
+ ], void 0, 3e4);
563
+ }
564
+ }
565
+ /**
566
+ * Copy one remote Windows file to a local path over SMB and verify the local
567
+ * SHA-256 matches the remote source.
568
+ */
569
+ async function downloadSmb(entry, remotePath, localPath, timeoutMs = 3e5) {
570
+ const unc = adminSharePath(entry.host, remotePath);
571
+ const mount = await runCmd([
572
+ "net",
573
+ "use",
574
+ "\\\\" + entry.host + "\\IPC$",
575
+ entry.auth.password ?? "",
576
+ "/user:" + entry.user
577
+ ], void 0, timeoutMs);
578
+ if (mount.code !== 0) throw new Error("SMB net use failed: " + (mount.stderr || mount.stdout));
579
+ try {
580
+ await copyFile(unc, localPath);
581
+ const local = await stat(localPath);
582
+ const localHash = await sha256File(localPath);
583
+ const remoteHash = await remoteSha256(entry.host, remotePath);
584
+ if (remoteHash !== localHash) throw new Error("SMB download hash mismatch: remote " + remoteHash + ", local " + localHash);
585
+ return {
586
+ bytes: local.size,
587
+ channel: "smb"
588
+ };
589
+ } finally {
590
+ await runCmd([
591
+ "net",
592
+ "use",
593
+ "\\\\" + entry.host + "\\IPC$",
594
+ "/delete",
595
+ "/y"
596
+ ], void 0, 3e4);
597
+ }
598
+ }
599
+ //#endregion
600
+ //#region src/engine/console.ts
601
+ var ConsoleSession = class {
602
+ conn;
603
+ running = false;
604
+ exited = false;
605
+ queue = Promise.resolve();
606
+ onData;
607
+ onExit;
608
+ constructor(conn) {
609
+ this.conn = conn;
610
+ }
611
+ async start() {
612
+ this.running = true;
613
+ }
614
+ send(data) {
615
+ const command = data.replace(/\r?\n/g, "").trim();
616
+ if (!this.running || command === "") return;
617
+ if (command.toLowerCase() === "exit") {
618
+ this.close();
619
+ return;
620
+ }
621
+ this.queue = this.queue.then(async () => {
622
+ if (!this.running) return;
623
+ try {
624
+ const result = await runScript(this.conn, command, { timeoutMs: 12e4 });
625
+ if (result.stdout !== "") this.onData?.(result.stdout + (result.stdout.endsWith("\n") ? "" : "\n"));
626
+ if (result.stderr !== "") this.onData?.(result.stderr + (result.stderr.endsWith("\n") ? "" : "\n"));
627
+ if (result.timedOut) this.onData?.("[timed out]\n");
628
+ } catch (error) {
629
+ this.onData?.("[error] " + (error instanceof Error ? error.message : String(error)) + "\n");
630
+ }
631
+ });
632
+ }
633
+ close() {
634
+ if (!this.running) return;
635
+ this.running = false;
636
+ this.exited = true;
637
+ }
638
+ finish(code, error) {
639
+ if (this.exited) return;
640
+ this.exited = true;
641
+ this.running = false;
642
+ this.onExit?.(code, error);
643
+ }
644
+ };
645
+ //#endregion
646
+ //#region src/engine.ts
647
+ /** JSON-safe parse of the envelope output into a typed value. */
648
+ function parseJson(text) {
649
+ const trimmed = text.trim();
650
+ if (trimmed === "" || trimmed === "null") return [];
651
+ return JSON.parse(trimmed);
652
+ }
653
+ /** A tiny per-alias in-flight limiter so GUI bursts do not pile up sockets. */
654
+ var AliasGate = class {
655
+ queues = /* @__PURE__ */ new Map();
656
+ async run(alias, fn) {
657
+ const next = (this.queues.get(alias) ?? Promise.resolve()).then(() => fn(), () => fn());
658
+ this.queues.set(alias, next.then(() => void 0, () => void 0));
659
+ try {
660
+ return await next;
661
+ } finally {
662
+ if (this.queues.get(alias) === next.then(() => void 0, () => void 0)) {}
663
+ }
664
+ }
665
+ };
666
+ /** The engine. One instance per plugin apply; dispose() clears nothing pooled (WinRM is per-call HTTP). */
667
+ var WinRmEngine = class {
668
+ store;
669
+ gate = new AliasGate();
670
+ constructor(store) {
671
+ this.store = store;
672
+ }
673
+ /** Secret-free host list (filtered by the optional query). */
674
+ list(query) {
675
+ const needle = query?.trim().toLowerCase();
676
+ return this.store.list().filter((entry) => needle === void 0 || needle === "" || entry.alias.toLowerCase().includes(needle) || (entry.description ?? "").toLowerCase().includes(needle) || entry.host.toLowerCase().includes(needle) || entry.tags.some((tag) => tag.toLowerCase().includes(needle))).map((entry) => this.store.summarize(entry));
677
+ }
678
+ /** One host summary by alias. */
679
+ find(alias) {
680
+ const entry = this.store.find(alias);
681
+ return entry === void 0 ? void 0 : this.store.summarize(entry);
682
+ }
683
+ /** Run one PowerShell command on `alias`. */
684
+ async exec(alias, command, timeoutMs) {
685
+ const entry = this.store.find(alias);
686
+ if (entry === void 0) return {
687
+ success: false,
688
+ exitCode: null,
689
+ timedOut: false,
690
+ stdout: "",
691
+ stderr: "",
692
+ durationMs: 0,
693
+ error: `alias '${alias}' not found`
694
+ };
695
+ return this.gate.run(alias, async () => {
696
+ try {
697
+ const result = await runScript(connOf(entry), command, { timeoutMs: timeoutMs ?? 6e4 });
698
+ return {
699
+ success: result.exitCode === 0,
700
+ exitCode: result.exitCode,
701
+ timedOut: result.timedOut,
702
+ stdout: result.stdout,
703
+ stderr: result.stderr,
704
+ durationMs: result.durationMs
705
+ };
706
+ } catch (error) {
707
+ return {
708
+ success: false,
709
+ exitCode: null,
710
+ timedOut: false,
711
+ stdout: "",
712
+ stderr: "",
713
+ durationMs: 0,
714
+ error: error instanceof Error ? error.message : String(error)
715
+ };
716
+ }
717
+ });
718
+ }
719
+ /** Run one command against many hosts concurrently. */
720
+ async cluster(options) {
721
+ let matched = this.store.list();
722
+ if (options.aliases !== void 0 && options.aliases.length > 0) {
723
+ const set = new Set(options.aliases);
724
+ matched = matched.filter((entry) => set.has(entry.alias));
725
+ }
726
+ if (options.environment !== void 0 && options.environment !== "") matched = matched.filter((entry) => entry.environment === options.environment);
727
+ if (options.tags !== void 0 && options.tags.length > 0) matched = matched.filter((entry) => options.tags.every((tag) => entry.tags.includes(tag)));
728
+ const maxWorkers = Math.max(1, Math.min(options.maxWorkers ?? 8, 32));
729
+ const results = [];
730
+ let cursor = 0;
731
+ const worker = async () => {
732
+ for (;;) {
733
+ const index = cursor++;
734
+ const entry = matched[index];
735
+ if (entry === void 0) return;
736
+ const outcome = await this.exec(entry.alias, options.command, options.timeoutMs);
737
+ results.push({
738
+ alias: entry.alias,
739
+ ok: outcome.success,
740
+ exitCode: outcome.exitCode,
741
+ timedOut: outcome.timedOut,
742
+ stdout: outcome.stdout,
743
+ stderr: outcome.stderr,
744
+ durationMs: outcome.durationMs,
745
+ ...outcome.error !== void 0 ? { error: outcome.error } : {}
746
+ });
747
+ }
748
+ };
749
+ await Promise.all(Array.from({ length: Math.min(maxWorkers, matched.length) }, () => worker()));
750
+ return results;
751
+ }
752
+ /** Full service table. */
753
+ async listServices(alias) {
754
+ const entry = this.store.find(alias);
755
+ if (entry === void 0) throw new Error(`alias '${alias}' not found`);
756
+ return this.gate.run(alias, async () => {
757
+ const result = await runScript(connOf(entry), psListServices(), { timeoutMs: 6e4 });
758
+ if (result.exitCode !== 0) throw new Error(result.stdout || "failed to list services");
759
+ return parseJson(result.stdout).map((item) => ({
760
+ name: item.name ?? "",
761
+ displayName: item.displayName ?? "",
762
+ status: item.status ?? "",
763
+ startMode: item.startMode ?? "",
764
+ startName: item.startName ?? ""
765
+ }));
766
+ });
767
+ }
768
+ /** One service action, returning the refreshed service row. */
769
+ async serviceAction(alias, name, action) {
770
+ const entry = this.store.find(alias);
771
+ if (entry === void 0) throw new Error(`alias '${alias}' not found`);
772
+ return this.gate.run(alias, async () => {
773
+ const result = await runScript(connOf(entry), psServiceAction(name, action), { timeoutMs: 6e4 });
774
+ if (result.exitCode !== 0) throw new Error(result.stdout || `service action '${action}' failed`);
775
+ const item = parseJson(result.stdout)[0] ?? {};
776
+ return {
777
+ name: item.name ?? name,
778
+ displayName: item.displayName ?? "",
779
+ status: item.status ?? "",
780
+ startMode: item.startMode ?? "",
781
+ startName: item.startName ?? ""
782
+ };
783
+ });
784
+ }
785
+ /** Full process table. */
786
+ async listProcesses(alias) {
787
+ const entry = this.store.find(alias);
788
+ if (entry === void 0) throw new Error(`alias '${alias}' not found`);
789
+ return this.gate.run(alias, async () => {
790
+ const result = await runScript(connOf(entry), psListProcesses(), { timeoutMs: 6e4 });
791
+ if (result.exitCode !== 0) throw new Error(result.stdout || "failed to list processes");
792
+ return parseJson(result.stdout).map((item) => ({
793
+ id: item.id ?? 0,
794
+ name: item.name ?? "",
795
+ ...item.cpu !== void 0 && item.cpu !== null ? { cpu: item.cpu } : {},
796
+ ...item.memMB !== void 0 && item.memMB !== null ? { memMB: item.memMB } : {},
797
+ ...item.startTime !== void 0 && item.startTime !== null ? { startTime: item.startTime } : {},
798
+ ...item.path !== void 0 && item.path !== null ? { path: item.path } : {}
799
+ }));
800
+ });
801
+ }
802
+ /** Kill one process by id. */
803
+ async killProcess(alias, id) {
804
+ const entry = this.store.find(alias);
805
+ if (entry === void 0) throw new Error(`alias '${alias}' not found`);
806
+ return this.gate.run(alias, async () => {
807
+ const result = await runScript(connOf(entry), psKillProcess(id), { timeoutMs: 3e4 });
808
+ if (result.exitCode !== 0) throw new Error(result.stdout || "failed to kill process");
809
+ });
810
+ }
811
+ /** List a remote directory. */
812
+ async ls(alias, dir) {
813
+ const entry = this.store.find(alias);
814
+ if (entry === void 0) throw new Error(`alias '${alias}' not found`);
815
+ return this.gate.run(alias, async () => {
816
+ const result = await runScript(connOf(entry), psListDir(dir), { timeoutMs: 6e4 });
817
+ if (result.exitCode !== 0) throw new Error(result.stdout || `failed to list '${dir}'`);
818
+ return parseJson(result.stdout).map((item) => ({
819
+ name: item.name ?? "",
820
+ type: item.type === "dir" || item.type === "file" ? item.type : "other",
821
+ size: item.size ?? 0,
822
+ mtimeMs: item.mtimeMs ?? 0
823
+ }));
824
+ });
825
+ }
826
+ /** Upload one local file to a remote path. Defaults to SMB with WinRM fallback. */
827
+ async upload(alias, localPath, remotePath, onProgress, channel = "auto") {
828
+ const entry = this.store.find(alias);
829
+ if (entry === void 0) throw new Error(`alias '${alias}' not found`);
830
+ const { readFileSync } = await import("node:fs");
831
+ const data = readFileSync(localPath);
832
+ return this.gate.run(alias, async () => {
833
+ onProgress?.({
834
+ phase: "connecting",
835
+ file: remotePath,
836
+ transferred: 0,
837
+ total: data.length,
838
+ percent: 0
839
+ });
840
+ if (channel !== "winrm") try {
841
+ const outcome = await uploadSmb(entry, localPath, remotePath);
842
+ onProgress?.({
843
+ phase: "done",
844
+ file: remotePath,
845
+ transferred: outcome.bytes,
846
+ total: outcome.bytes,
847
+ percent: 100
848
+ });
849
+ return {
850
+ bytes: outcome.bytes,
851
+ channel: outcome.channel
852
+ };
853
+ } catch (error) {
854
+ if (channel === "smb") throw error;
855
+ onProgress?.({
856
+ phase: "connecting",
857
+ file: remotePath,
858
+ transferred: 0,
859
+ total: data.length,
860
+ percent: 0
861
+ });
862
+ }
863
+ const bytes = await uploadBuffer(connOf(entry), remotePath, data, (transferred, total) => {
864
+ onProgress?.({
865
+ phase: "transferring",
866
+ file: remotePath,
867
+ transferred,
868
+ total,
869
+ percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0
870
+ });
871
+ });
872
+ onProgress?.({
873
+ phase: "done",
874
+ file: remotePath,
875
+ transferred: bytes,
876
+ total: bytes,
877
+ percent: 100
878
+ });
879
+ return {
880
+ bytes,
881
+ channel: "winrm"
882
+ };
883
+ });
884
+ }
885
+ /** Download one remote file to a local path. Defaults to SMB with WinRM fallback. */
886
+ async download(alias, remotePath, localPath, onProgress, channel = "auto") {
887
+ const entry = this.store.find(alias);
888
+ if (entry === void 0) throw new Error(`alias '${alias}' not found`);
889
+ return this.gate.run(alias, async () => {
890
+ onProgress?.({
891
+ phase: "connecting",
892
+ file: remotePath,
893
+ transferred: 0,
894
+ total: 0,
895
+ percent: 0
896
+ });
897
+ if (channel !== "winrm") try {
898
+ const outcome = await downloadSmb(entry, remotePath, localPath);
899
+ onProgress?.({
900
+ phase: "done",
901
+ file: remotePath,
902
+ transferred: outcome.bytes,
903
+ total: outcome.bytes,
904
+ percent: 100
905
+ });
906
+ return {
907
+ bytes: outcome.bytes,
908
+ channel: outcome.channel
909
+ };
910
+ } catch (error) {
911
+ if (channel === "smb") throw error;
912
+ onProgress?.({
913
+ phase: "connecting",
914
+ file: remotePath,
915
+ transferred: 0,
916
+ total: 0,
917
+ percent: 0
918
+ });
919
+ }
920
+ const { createWriteStream } = await import("node:fs");
921
+ const sink = createWriteStream(localPath, { mode: 384 });
922
+ let bytes = 0;
923
+ await new Promise((resolve, reject) => {
924
+ sink.on("error", reject);
925
+ downloadChunks(connOf(entry), remotePath, (b64) => {
926
+ const buffer = Buffer.from(b64, "base64");
927
+ bytes += buffer.length;
928
+ sink.write(buffer);
929
+ }, (transferred, total) => onProgress?.({
930
+ phase: "transferring",
931
+ file: remotePath,
932
+ transferred,
933
+ total,
934
+ percent: total > 0 ? Math.round(transferred / total * 1e3) / 10 : 0
935
+ })).then(() => sink.end(resolve), (error) => {
936
+ sink.destroy();
937
+ reject(error);
938
+ });
939
+ });
940
+ onProgress?.({
941
+ phase: "done",
942
+ file: remotePath,
943
+ transferred: bytes,
944
+ total: bytes,
945
+ percent: 100
946
+ });
947
+ return {
948
+ bytes,
949
+ channel: "winrm"
950
+ };
951
+ });
952
+ }
953
+ /** Open a streaming PowerShell console session (standalone connection). */
954
+ async openConsole(alias) {
955
+ const entry = this.store.find(alias);
956
+ if (entry === void 0) throw new Error(`alias '${alias}' not found`);
957
+ const session = new ConsoleSession(connOf(entry));
958
+ await session.start();
959
+ return session;
960
+ }
961
+ /** Probe connectivity. */
962
+ async test(alias) {
963
+ const entry = this.store.find(alias);
964
+ if (entry === void 0) return {
965
+ ok: false,
966
+ error: `alias '${alias}' not found`
967
+ };
968
+ return this.gate.run(alias, async () => {
969
+ return await testConnection(connOf(entry));
970
+ });
971
+ }
972
+ /** No pooled resources to close (WinRM is per-call HTTP). */
973
+ dispose() {}
974
+ };
975
+ //#endregion
976
+ //#region src/loopback.ts
977
+ /** IPv4 127/8 predicate (four decimal octets, first == 127). */
978
+ function isIPv4Loopback(v4) {
979
+ const parts = v4.split(".");
980
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
981
+ }
982
+ /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
983
+ function isLoopbackAddress(address) {
984
+ if (address === void 0) return false;
985
+ const normalized = address.toLowerCase();
986
+ if (normalized === "::1") return true;
987
+ if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
988
+ return isIPv4Loopback(normalized);
989
+ }
990
+ /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
991
+ function isLoopbackHostname(hostname) {
992
+ if (hostname === "localhost" || hostname === "[::1]") return true;
993
+ return isIPv4Loopback(hostname);
994
+ }
995
+ /**
996
+ * Request-level trust fence: a loopback socket address AND a loopback Host
997
+ * header, plus browser same-origin markers. The socket address is
998
+ * authoritative; X-Forwarded-For is never trusted.
999
+ */
1000
+ function isLoopbackRequest(request) {
1001
+ if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
1002
+ const host = request.headers.host;
1003
+ if (typeof host !== "string") return false;
1004
+ let hostUrl;
1005
+ try {
1006
+ hostUrl = new URL("http://" + host);
1007
+ } catch {
1008
+ return false;
1009
+ }
1010
+ if (!isLoopbackHostname(hostUrl.hostname)) return false;
1011
+ if (request.headers["sec-fetch-site"] === "cross-site") return false;
1012
+ const origin = request.headers.origin;
1013
+ if (origin === void 0) return true;
1014
+ try {
1015
+ return new URL(origin).host === hostUrl.host;
1016
+ } catch {
1017
+ return false;
1018
+ }
1019
+ }
1020
+ //#endregion
1021
+ //#region src/protocol.ts
1022
+ const WINRM_API = {
1023
+ hosts: "/api/dsh-winrm/hosts",
1024
+ test: "/api/dsh-winrm/test",
1025
+ exec: "/api/dsh-winrm/exec",
1026
+ cluster: "/api/dsh-winrm/cluster",
1027
+ services: "/api/dsh-winrm/services",
1028
+ processes: "/api/dsh-winrm/processes",
1029
+ ls: "/api/dsh-winrm/ls",
1030
+ upload: "/api/dsh-winrm/upload",
1031
+ download: "/api/dsh-winrm/download",
1032
+ console: "/api/dsh-winrm/console"
1033
+ };
1034
+ //#endregion
1035
+ //#region src/routes.ts
1036
+ /**
1037
+ * The /api/dsh-winrm route family: host CRUD, exec, cluster, services,
1038
+ * processes, remote listing, NDJSON upload progress, binary download, and
1039
+ * the WebSocket PowerShell console upgrade. Every route carries a
1040
+ * loopback-only trust fence (plus browser same-origin markers) — these
1041
+ * endpoints execute commands on remote machines, so LAN-exposed dsh web
1042
+ * deployments must not serve them.
1043
+ */
1044
+ /** Cap on JSON request bodies. */
1045
+ const MAX_JSON_BODY_BYTES = 64 * 1024;
1046
+ /** Cap on declared upload bodies (staged to disk before chunking out). */
1047
+ const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024;
1048
+ /** One noServer WebSocket server for console upgrades. */
1049
+ const consoleWss = new WebSocketServer({ noServer: true });
1050
+ /** One JSON response. */
1051
+ function writeJson(res, status, body) {
1052
+ const payload = JSON.stringify(body);
1053
+ res.writeHead(status, {
1054
+ "content-type": "application/json; charset=utf-8",
1055
+ "referrer-policy": "no-referrer"
1056
+ });
1057
+ res.end(payload);
1058
+ }
1059
+ /** Read a JSON request body (undefined when too large or unparseable). */
1060
+ async function readJsonBody(req) {
1061
+ const chunks = [];
1062
+ let size = 0;
1063
+ for await (const chunk of req) {
1064
+ const buffer = chunk;
1065
+ size += buffer.length;
1066
+ if (size > MAX_JSON_BODY_BYTES) return void 0;
1067
+ chunks.push(buffer);
1068
+ }
1069
+ try {
1070
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1071
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
1072
+ } catch {
1073
+ return;
1074
+ }
1075
+ }
1076
+ /** URL query helper (first value, decoded). */
1077
+ function queryParam(url, name) {
1078
+ const value = url.searchParams.get(name);
1079
+ return value === null ? void 0 : value;
1080
+ }
1081
+ /**
1082
+ * Build every /api/dsh-winrm route plus the console upgrade.
1083
+ * @param deps - store, engine, staging dir.
1084
+ */
1085
+ function makeRoutes(deps) {
1086
+ const { store, engine } = deps;
1087
+ const staging = deps.stagingDir ?? join(tmpdir(), "dsh-winrm-uploads");
1088
+ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES;
1089
+ mkdirSync(staging, {
1090
+ recursive: true,
1091
+ mode: 448
1092
+ });
1093
+ /** Guard helper: fence + method check. */
1094
+ const guard = (req, res, method) => {
1095
+ if (!isLoopbackRequest(req)) {
1096
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
1097
+ return false;
1098
+ }
1099
+ if (req.method !== method) {
1100
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
1101
+ return false;
1102
+ }
1103
+ return true;
1104
+ };
1105
+ return {
1106
+ routes: [
1107
+ {
1108
+ kind: "exact",
1109
+ path: WINRM_API.hosts,
1110
+ handler: async (req, res) => {
1111
+ const method = req.method ?? "GET";
1112
+ if (!isLoopbackRequest(req)) {
1113
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
1114
+ return;
1115
+ }
1116
+ const url = new URL(req.url ?? "/", "http://localhost");
1117
+ if (method === "GET") {
1118
+ writeJson(res, 200, { hosts: engine.list(queryParam(url, "query")) });
1119
+ return;
1120
+ }
1121
+ if (method === "POST") {
1122
+ const body = await readJsonBody(req);
1123
+ if (body === void 0) {
1124
+ writeJson(res, 400, { error: "invalid JSON body" });
1125
+ return;
1126
+ }
1127
+ try {
1128
+ const entry = store.create(body);
1129
+ writeJson(res, 201, { host: store.summarize(entry) });
1130
+ } catch (error) {
1131
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1132
+ }
1133
+ return;
1134
+ }
1135
+ if (method !== "PATCH" && method !== "DELETE") {
1136
+ writeJson(res, 405, { error: `method not allowed: ${method}` });
1137
+ return;
1138
+ }
1139
+ const alias = queryParam(url, "alias");
1140
+ if (alias === void 0 || alias === "") {
1141
+ writeJson(res, 400, { error: "alias query parameter is required" });
1142
+ return;
1143
+ }
1144
+ if (method === "PATCH") {
1145
+ const body = await readJsonBody(req);
1146
+ if (body === void 0) {
1147
+ writeJson(res, 400, { error: "invalid JSON body" });
1148
+ return;
1149
+ }
1150
+ try {
1151
+ const entry = store.update(alias, body);
1152
+ writeJson(res, 200, { host: store.summarize(entry) });
1153
+ } catch (error) {
1154
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1155
+ }
1156
+ return;
1157
+ }
1158
+ if (method === "DELETE") {
1159
+ try {
1160
+ store.delete(alias);
1161
+ writeJson(res, 200, { ok: true });
1162
+ } catch (error) {
1163
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1164
+ }
1165
+ return;
1166
+ }
1167
+ writeJson(res, 405, { error: `method not allowed: ${method}` });
1168
+ }
1169
+ },
1170
+ {
1171
+ kind: "exact",
1172
+ path: WINRM_API.test,
1173
+ handler: async (req, res) => {
1174
+ if (!guard(req, res, "POST")) return;
1175
+ const body = await readJsonBody(req);
1176
+ const alias = typeof body?.alias === "string" ? body.alias : "";
1177
+ if (alias === "") {
1178
+ writeJson(res, 400, { error: "alias is required" });
1179
+ return;
1180
+ }
1181
+ try {
1182
+ writeJson(res, 200, { result: await engine.test(alias) });
1183
+ } catch (error) {
1184
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
1185
+ }
1186
+ }
1187
+ },
1188
+ {
1189
+ kind: "exact",
1190
+ path: WINRM_API.exec,
1191
+ handler: async (req, res) => {
1192
+ if (!guard(req, res, "POST")) return;
1193
+ const body = await readJsonBody(req);
1194
+ const alias = typeof body?.alias === "string" ? body.alias : "";
1195
+ const command = typeof body?.command === "string" ? body.command : "";
1196
+ if (alias === "" || command === "") {
1197
+ writeJson(res, 400, { error: "alias and command are required" });
1198
+ return;
1199
+ }
1200
+ const timeoutMs = typeof body?.timeoutMs === "number" ? body.timeoutMs : void 0;
1201
+ try {
1202
+ writeJson(res, 200, { result: await engine.exec(alias, command, timeoutMs) });
1203
+ } catch (error) {
1204
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
1205
+ }
1206
+ }
1207
+ },
1208
+ {
1209
+ kind: "exact",
1210
+ path: WINRM_API.cluster,
1211
+ handler: async (req, res) => {
1212
+ if (!guard(req, res, "POST")) return;
1213
+ const body = await readJsonBody(req);
1214
+ const command = typeof body?.command === "string" ? body.command : "";
1215
+ if (command === "") {
1216
+ writeJson(res, 400, { error: "command is required" });
1217
+ return;
1218
+ }
1219
+ const aliases = Array.isArray(body?.aliases) ? body.aliases.filter((x) => typeof x === "string") : void 0;
1220
+ const tags = Array.isArray(body?.tags) ? body.tags.filter((x) => typeof x === "string") : void 0;
1221
+ const environment = typeof body?.environment === "string" ? body.environment : void 0;
1222
+ const timeoutMs = typeof body?.timeoutMs === "number" ? body.timeoutMs : void 0;
1223
+ const maxWorkers = typeof body?.maxWorkers === "number" ? body.maxWorkers : void 0;
1224
+ try {
1225
+ writeJson(res, 200, { results: await engine.cluster({
1226
+ command,
1227
+ aliases,
1228
+ environment,
1229
+ tags,
1230
+ timeoutMs,
1231
+ maxWorkers
1232
+ }) });
1233
+ } catch (error) {
1234
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
1235
+ }
1236
+ }
1237
+ },
1238
+ {
1239
+ kind: "exact",
1240
+ path: WINRM_API.services,
1241
+ handler: async (req, res) => {
1242
+ if (!guard(req, res, "POST")) return;
1243
+ const body = await readJsonBody(req);
1244
+ const alias = typeof body?.alias === "string" ? body.alias : "";
1245
+ const name = typeof body?.name === "string" ? body.name : "";
1246
+ const action = typeof body?.action === "string" ? body.action : "list";
1247
+ if (alias === "") {
1248
+ writeJson(res, 400, { error: "alias is required" });
1249
+ return;
1250
+ }
1251
+ try {
1252
+ if (action === "list" || name === "") writeJson(res, 200, { services: await engine.listServices(alias) });
1253
+ else writeJson(res, 200, { service: await engine.serviceAction(alias, name, action) });
1254
+ } catch (error) {
1255
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1256
+ }
1257
+ }
1258
+ },
1259
+ {
1260
+ kind: "exact",
1261
+ path: WINRM_API.processes,
1262
+ handler: async (req, res) => {
1263
+ if (!guard(req, res, "POST")) return;
1264
+ const body = await readJsonBody(req);
1265
+ const alias = typeof body?.alias === "string" ? body.alias : "";
1266
+ const action = typeof body?.action === "string" ? body.action : "list";
1267
+ const id = typeof body?.id === "number" ? body.id : void 0;
1268
+ if (alias === "") {
1269
+ writeJson(res, 400, { error: "alias is required" });
1270
+ return;
1271
+ }
1272
+ try {
1273
+ if (action === "kill" && id !== void 0) {
1274
+ await engine.killProcess(alias, id);
1275
+ writeJson(res, 200, { killed: id });
1276
+ } else writeJson(res, 200, { processes: await engine.listProcesses(alias) });
1277
+ } catch (error) {
1278
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1279
+ }
1280
+ }
1281
+ },
1282
+ {
1283
+ kind: "exact",
1284
+ path: WINRM_API.ls,
1285
+ handler: async (req, res) => {
1286
+ if (!guard(req, res, "GET")) return;
1287
+ const url = new URL(req.url ?? "/", "http://localhost");
1288
+ const alias = queryParam(url, "alias");
1289
+ const dir = queryParam(url, "path") ?? "C:\\";
1290
+ if (alias === void 0 || alias === "") {
1291
+ writeJson(res, 400, { error: "alias query parameter is required" });
1292
+ return;
1293
+ }
1294
+ try {
1295
+ writeJson(res, 200, { entries: await engine.ls(alias, dir) });
1296
+ } catch (error) {
1297
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1298
+ }
1299
+ }
1300
+ },
1301
+ {
1302
+ kind: "exact",
1303
+ path: WINRM_API.upload,
1304
+ handler: async (req, res) => {
1305
+ if (!guard(req, res, "POST")) return;
1306
+ const url = new URL(req.url ?? "/", "http://localhost");
1307
+ const alias = queryParam(url, "alias");
1308
+ const remotePath = queryParam(url, "remotePath");
1309
+ if (alias === void 0 || remotePath === void 0) {
1310
+ writeJson(res, 400, { error: "alias and remotePath query parameters are required" });
1311
+ return;
1312
+ }
1313
+ const channel = queryParam(url, "channel") ?? "auto";
1314
+ const declared = Number(req.headers["content-length"]);
1315
+ if (Number.isFinite(declared) && declared > maxUploadBytes) {
1316
+ writeJson(res, 413, { error: "upload body too large" });
1317
+ return;
1318
+ }
1319
+ res.writeHead(200, {
1320
+ "content-type": "application/x-ndjson; charset=utf-8",
1321
+ "cache-control": "no-cache",
1322
+ "referrer-policy": "no-referrer"
1323
+ });
1324
+ const emit = (line) => {
1325
+ try {
1326
+ res.write(JSON.stringify(line) + "\n");
1327
+ } catch {}
1328
+ };
1329
+ const tmp = join(staging, `upload-${randomBytes(6).toString("hex")}`);
1330
+ const sink = createWriteStream(tmp, { mode: 384 });
1331
+ let settled = false;
1332
+ const fail = (error) => {
1333
+ if (settled) return;
1334
+ settled = true;
1335
+ emit({
1336
+ type: "result",
1337
+ ok: false,
1338
+ error: error instanceof Error ? error.message : String(error)
1339
+ });
1340
+ const cleanup = () => {
1341
+ unlink(tmp).catch(() => void 0).finally(() => {
1342
+ try {
1343
+ res.end();
1344
+ } catch {}
1345
+ });
1346
+ };
1347
+ if (sink.destroyed) cleanup();
1348
+ else {
1349
+ sink.once("close", cleanup);
1350
+ try {
1351
+ sink.destroy();
1352
+ } catch {
1353
+ cleanup();
1354
+ }
1355
+ }
1356
+ };
1357
+ const done = () => {
1358
+ if (settled) return;
1359
+ settled = true;
1360
+ try {
1361
+ res.end();
1362
+ } catch {}
1363
+ };
1364
+ sink.on("error", (error) => fail(error));
1365
+ req.on("error", (error) => fail(error));
1366
+ req.on("aborted", () => fail("upload aborted by the client"));
1367
+ res.on("error", () => fail("response stream closed"));
1368
+ res.on("close", () => {
1369
+ if (!res.writableEnded) fail("connection closed");
1370
+ });
1371
+ let received = 0;
1372
+ let capped = false;
1373
+ req.on("data", (chunk) => {
1374
+ received += chunk.byteLength;
1375
+ if (received > maxUploadBytes && !capped) {
1376
+ capped = true;
1377
+ fail("upload body too large");
1378
+ res.on("finish", () => {
1379
+ try {
1380
+ req.destroy();
1381
+ } catch {}
1382
+ });
1383
+ req.resume();
1384
+ }
1385
+ });
1386
+ req.pipe(sink);
1387
+ sink.on("finish", async () => {
1388
+ if (settled) return;
1389
+ emit({
1390
+ type: "progress",
1391
+ progress: {
1392
+ phase: "connecting",
1393
+ file: remotePath,
1394
+ transferred: 0,
1395
+ total: 0,
1396
+ percent: 0
1397
+ }
1398
+ });
1399
+ try {
1400
+ const outcome = await engine.upload(alias, tmp, remotePath, (progress) => emit({
1401
+ type: "progress",
1402
+ progress
1403
+ }), channel);
1404
+ emit({
1405
+ type: "result",
1406
+ ok: true,
1407
+ transferredBytes: outcome.bytes
1408
+ });
1409
+ } catch (error) {
1410
+ emit({
1411
+ type: "result",
1412
+ ok: false,
1413
+ error: error instanceof Error ? error.message : String(error)
1414
+ });
1415
+ } finally {
1416
+ await unlink(tmp).catch(() => void 0);
1417
+ done();
1418
+ }
1419
+ });
1420
+ }
1421
+ },
1422
+ {
1423
+ kind: "exact",
1424
+ path: WINRM_API.download,
1425
+ handler: async (req, res) => {
1426
+ if (!guard(req, res, "GET")) return;
1427
+ const url = new URL(req.url ?? "/", "http://localhost");
1428
+ const alias = queryParam(url, "alias");
1429
+ const remotePath = queryParam(url, "remotePath");
1430
+ if (alias === void 0 || remotePath === void 0) {
1431
+ writeJson(res, 400, { error: "alias and remotePath query parameters are required" });
1432
+ return;
1433
+ }
1434
+ const channel = queryParam(url, "channel") ?? "auto";
1435
+ const tmp = join(staging, `download-${randomBytes(6).toString("hex")}`);
1436
+ try {
1437
+ closeSync(openSync(tmp, "w", 384));
1438
+ const outcome = await engine.download(alias, remotePath, tmp, void 0, channel);
1439
+ res.writeHead(200, {
1440
+ "content-type": "application/octet-stream",
1441
+ "content-length": String(outcome.bytes),
1442
+ "content-disposition": `attachment; filename="${basename(remotePath).replace(/"/g, "")}"`,
1443
+ "referrer-policy": "no-referrer"
1444
+ });
1445
+ await new Promise((resolve, reject) => {
1446
+ const source = createReadStream(tmp);
1447
+ source.on("error", reject);
1448
+ res.on("error", reject);
1449
+ source.pipe(res);
1450
+ source.on("end", resolve);
1451
+ });
1452
+ } catch (error) {
1453
+ if (!res.headersSent) writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1454
+ else res.destroy();
1455
+ } finally {
1456
+ await unlink(tmp).catch(() => void 0);
1457
+ }
1458
+ }
1459
+ }
1460
+ ],
1461
+ upgrade: {
1462
+ path: WINRM_API.console,
1463
+ handler: (req, socket, head) => {
1464
+ if (!isLoopbackRequest(req)) {
1465
+ socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
1466
+ socket.destroy();
1467
+ return;
1468
+ }
1469
+ const alias = queryParam(new URL(req.url ?? "/", "http://localhost"), "alias");
1470
+ if (alias === void 0) {
1471
+ socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
1472
+ socket.destroy();
1473
+ return;
1474
+ }
1475
+ consoleWss.handleUpgrade(req, socket, head, (ws) => {
1476
+ let session;
1477
+ let closed = false;
1478
+ const sendFrame = (frame) => {
1479
+ if (closed || ws.readyState !== WebSocket.OPEN) return;
1480
+ ws.send(JSON.stringify(frame));
1481
+ };
1482
+ const closeSession = () => {
1483
+ const opened = session;
1484
+ session = void 0;
1485
+ if (opened !== void 0) opened.close();
1486
+ };
1487
+ engine.openConsole(alias).then((opened) => {
1488
+ if (ws.readyState !== WebSocket.OPEN) {
1489
+ opened.close();
1490
+ return;
1491
+ }
1492
+ session = opened;
1493
+ sendFrame({
1494
+ type: "ready",
1495
+ alias
1496
+ });
1497
+ opened.onData = (data) => sendFrame({
1498
+ type: "output",
1499
+ data
1500
+ });
1501
+ opened.onExit = (code, error) => {
1502
+ sendFrame({
1503
+ type: "exit",
1504
+ code,
1505
+ error
1506
+ });
1507
+ closed = true;
1508
+ try {
1509
+ ws.close(1e3);
1510
+ } catch {}
1511
+ };
1512
+ }).catch((error) => {
1513
+ sendFrame({
1514
+ type: "exit",
1515
+ code: null,
1516
+ error: error instanceof Error ? error.message : String(error)
1517
+ });
1518
+ closed = true;
1519
+ try {
1520
+ ws.close(1e3);
1521
+ } catch {}
1522
+ });
1523
+ ws.on("message", (data) => {
1524
+ let frame;
1525
+ try {
1526
+ frame = JSON.parse(String(data));
1527
+ } catch {
1528
+ return;
1529
+ }
1530
+ if (frame.type === "input") session?.send(frame.data);
1531
+ });
1532
+ ws.on("close", () => {
1533
+ closed = true;
1534
+ closeSession();
1535
+ });
1536
+ ws.on("error", () => {
1537
+ closed = true;
1538
+ closeSession();
1539
+ });
1540
+ });
1541
+ }
1542
+ }
1543
+ };
1544
+ }
1545
+ //#endregion
1546
+ //#region src/store.ts
1547
+ /**
1548
+ * Host config store: one JSON file (~/.dsh/dsh-winrm.json) holding every
1549
+ * Windows host entry, written atomically (tmp + rename). Secrets (passwords)
1550
+ * live in this user-owned file in plaintext — the same trust model as
1551
+ * dsh-ssh; document it, never log it.
1552
+ */
1553
+ /** File format version. */
1554
+ const FORMAT_VERSION = 1;
1555
+ /** Store file location: <home>/.dsh/dsh-winrm.json. */
1556
+ function storePath() {
1557
+ return join(homedir(), ".dsh", "dsh-winrm.json");
1558
+ }
1559
+ /** Validate the wire shape of a host payload; returns a message or undefined. */
1560
+ function validateHostPayload(payload) {
1561
+ if (typeof payload !== "object" || payload === null) return "body must be a JSON object";
1562
+ const p = payload;
1563
+ if (typeof p.host !== "string" || p.host.trim() === "") return "host is required";
1564
+ if (typeof p.user !== "string" || p.user.trim() === "") return "user is required";
1565
+ const auth = p.auth;
1566
+ if (auth !== void 0) {
1567
+ if (typeof auth !== "object" || auth === null) return "auth must be an object";
1568
+ if (auth.kind !== "password") return "auth.kind must be password";
1569
+ if (auth.password !== void 0 && typeof auth.password !== "string") return "auth.password must be a string when provided";
1570
+ }
1571
+ if (p.port !== void 0 && (typeof p.port !== "number" || !Number.isInteger(p.port) || p.port < 1 || p.port > 65535)) return "port must be an integer in 1..65535";
1572
+ if (p.transport !== void 0 && p.transport !== "http" && p.transport !== "https") return "transport must be http or https";
1573
+ if (p.rejectUnauthorized !== void 0 && typeof p.rejectUnauthorized !== "boolean") return "rejectUnauthorized must be a boolean";
1574
+ if (p.tags !== void 0 && (!Array.isArray(p.tags) || p.tags.some((x) => typeof x !== "string"))) return "tags must be an array of strings";
1575
+ }
1576
+ /** Alias grammar: letters/digits plus dots, hyphens, underscores (IP/domain aliases included). */
1577
+ const ALIAS_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
1578
+ /** Validate an alias for creation. */
1579
+ function validateAlias(alias) {
1580
+ if (!ALIAS_RE.test(alias)) return "alias must be letters, digits, dots, hyphens or underscores";
1581
+ }
1582
+ /**
1583
+ * The host store. Pure file I/O — no cordis dependency, unit-testable.
1584
+ */
1585
+ var HostStore = class {
1586
+ /** The JSON file path. */
1587
+ path;
1588
+ /**
1589
+ * @param path - store file path (defaults to the standard location).
1590
+ */
1591
+ constructor(path) {
1592
+ this.path = resolve(path ?? storePath());
1593
+ }
1594
+ /** Load all entries (empty store when the file is absent). */
1595
+ list() {
1596
+ return this.load().hosts;
1597
+ }
1598
+ /** Find one entry by alias. */
1599
+ find(alias) {
1600
+ return this.list().find((entry) => entry.alias === alias);
1601
+ }
1602
+ /** Secret-free projection for the browser and agent surfaces. */
1603
+ summarize(entry) {
1604
+ return {
1605
+ alias: entry.alias,
1606
+ host: entry.host,
1607
+ port: entry.port,
1608
+ user: entry.user,
1609
+ auth: entry.auth.kind,
1610
+ transport: entry.transport,
1611
+ ...entry.rejectUnauthorized !== void 0 ? { rejectUnauthorized: entry.rejectUnauthorized } : {},
1612
+ ...entry.description !== void 0 ? { description: entry.description } : {},
1613
+ ...entry.environment !== void 0 ? { environment: entry.environment } : {},
1614
+ tags: [...entry.tags],
1615
+ ...entry.location !== void 0 ? { location: entry.location } : {},
1616
+ createdAt: entry.createdAt,
1617
+ updatedAt: entry.updatedAt
1618
+ };
1619
+ }
1620
+ /** Create one entry. Throws on alias collision or invalid payload. */
1621
+ create(payload) {
1622
+ const alias = payload.alias?.trim();
1623
+ if (!alias) throw new Error("alias is required");
1624
+ const aliasError = validateAlias(alias);
1625
+ if (aliasError !== void 0) throw new Error(aliasError);
1626
+ const bodyError = validateHostPayload(payload);
1627
+ if (bodyError !== void 0) throw new Error(bodyError);
1628
+ if (payload.auth === void 0) throw new Error("auth is required");
1629
+ const file = this.load();
1630
+ if (file.hosts.some((entry) => entry.alias === alias)) throw new Error(`alias '${alias}' already exists`);
1631
+ const now = Date.now();
1632
+ const entry = {
1633
+ alias,
1634
+ host: payload.host.trim(),
1635
+ port: payload.port ?? 5985,
1636
+ user: payload.user.trim(),
1637
+ auth: {
1638
+ kind: "password",
1639
+ password: payload.auth.password
1640
+ },
1641
+ transport: payload.transport ?? "http",
1642
+ ...payload.rejectUnauthorized !== void 0 ? { rejectUnauthorized: payload.rejectUnauthorized } : {},
1643
+ description: payload.description?.trim() || void 0,
1644
+ environment: payload.environment?.trim() || void 0,
1645
+ tags: [...payload.tags ?? []].map((tag) => tag.trim()).filter((tag) => tag !== ""),
1646
+ location: payload.location?.trim() || void 0,
1647
+ createdAt: now,
1648
+ updatedAt: now
1649
+ };
1650
+ file.hosts.push(entry);
1651
+ this.save(file);
1652
+ return entry;
1653
+ }
1654
+ /** Update the fields present in `patch`; unknown aliases throw. */
1655
+ update(alias, patch) {
1656
+ const file = this.load();
1657
+ const entry = file.hosts.find((candidate) => candidate.alias === alias);
1658
+ if (entry === void 0) throw new Error(`alias '${alias}' not found`);
1659
+ if (patch.host !== void 0 && (typeof patch.host !== "string" || patch.host.trim() === "")) throw new Error("host is required");
1660
+ if (patch.user !== void 0 && (typeof patch.user !== "string" || patch.user.trim() === "")) throw new Error("user is required");
1661
+ if (patch.port !== void 0 && (typeof patch.port !== "number" || !Number.isInteger(patch.port) || patch.port < 1 || patch.port > 65535)) throw new Error("port must be an integer in 1..65535");
1662
+ if (patch.transport !== void 0 && patch.transport !== "http" && patch.transport !== "https") throw new Error("transport must be http or https");
1663
+ if (patch.tags !== void 0 && (!Array.isArray(patch.tags) || patch.tags.some((x) => typeof x !== "string"))) throw new Error("tags must be an array of strings");
1664
+ if (patch.host !== void 0) entry.host = patch.host.trim();
1665
+ if (patch.port !== void 0) entry.port = patch.port;
1666
+ if (patch.user !== void 0) entry.user = patch.user.trim();
1667
+ if (patch.auth !== void 0) {
1668
+ const auth = patch.auth;
1669
+ if (auth.kind !== "password") throw new Error("auth.kind must be password");
1670
+ entry.auth = {
1671
+ kind: "password",
1672
+ password: auth.password
1673
+ };
1674
+ }
1675
+ if (patch.transport !== void 0) entry.transport = patch.transport;
1676
+ if (patch.rejectUnauthorized !== void 0) entry.rejectUnauthorized = patch.rejectUnauthorized;
1677
+ if (patch.description !== void 0) entry.description = patch.description.trim() || void 0;
1678
+ if (patch.environment !== void 0) entry.environment = patch.environment.trim() || void 0;
1679
+ if (patch.tags !== void 0) entry.tags = [...patch.tags].map((tag) => tag.trim()).filter((tag) => tag !== "");
1680
+ if (patch.location !== void 0) entry.location = patch.location.trim() || void 0;
1681
+ entry.updatedAt = Date.now();
1682
+ this.save(file);
1683
+ return entry;
1684
+ }
1685
+ /** Remove one entry. */
1686
+ delete(alias) {
1687
+ const file = this.load();
1688
+ const index = file.hosts.findIndex((candidate) => candidate.alias === alias);
1689
+ if (index < 0) throw new Error(`alias '${alias}' not found`);
1690
+ file.hosts.splice(index, 1);
1691
+ this.save(file);
1692
+ }
1693
+ /**
1694
+ * Last parsed store keyed by file identity. list/find ride every acquire
1695
+ * and GUI refresh; re-reading and re-parsing the whole file each call is
1696
+ * wasted work when the file has not changed. Any save invalidates.
1697
+ */
1698
+ cache;
1699
+ load() {
1700
+ let stats;
1701
+ try {
1702
+ stats = statSync(this.path);
1703
+ } catch {
1704
+ this.cache = void 0;
1705
+ return {
1706
+ version: FORMAT_VERSION,
1707
+ hosts: []
1708
+ };
1709
+ }
1710
+ if (this.cache !== void 0 && this.cache.mtimeMs === stats.mtimeMs && this.cache.size === stats.size) return this.cache.file;
1711
+ try {
1712
+ const parsed = JSON.parse(readFileSync(this.path, "utf8"));
1713
+ if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.hosts)) throw new Error("store file shape invalid");
1714
+ this.cache = {
1715
+ mtimeMs: stats.mtimeMs,
1716
+ size: stats.size,
1717
+ file: parsed
1718
+ };
1719
+ return parsed;
1720
+ } catch {
1721
+ this.cache = void 0;
1722
+ try {
1723
+ renameSync(this.path, `${this.path}.corrupt-${Date.now()}`);
1724
+ } catch {}
1725
+ return {
1726
+ version: FORMAT_VERSION,
1727
+ hosts: []
1728
+ };
1729
+ }
1730
+ }
1731
+ save(file) {
1732
+ const dir = dirname(this.path);
1733
+ if (!existsSync(dir)) mkdirSync(dir, {
1734
+ recursive: true,
1735
+ mode: 448
1736
+ });
1737
+ const tmp = this.path + ".tmp";
1738
+ writeFileSync(tmp, JSON.stringify(file, null, 2) + "\n", {
1739
+ encoding: "utf8",
1740
+ mode: 384
1741
+ });
1742
+ renameSync(tmp, this.path);
1743
+ this.cache = void 0;
1744
+ }
1745
+ };
1746
+ //#endregion
1747
+ //#region src/tools.ts
1748
+ /**
1749
+ * Agent tools: the DSH-native counterpart of the GUI — every tool talks to
1750
+ * the same engine the web UI uses, so a host configured in the GUI is
1751
+ * immediately operable by any agent, and vice versa.
1752
+ */
1753
+ /** One text content block (the only render shape these tools emit). */
1754
+ function text(value) {
1755
+ return [{
1756
+ type: "text",
1757
+ text: value
1758
+ }];
1759
+ }
1760
+ /** Host table render shared by list surfaces. */
1761
+ function renderHosts(hosts) {
1762
+ if (hosts.length === 0) return "no hosts configured";
1763
+ return [
1764
+ "alias | host | port | user | transport | environment | tags | description",
1765
+ "--- | --- | --- | --- | --- | --- | --- | ---",
1766
+ ...hosts.map((host) => [
1767
+ host.alias,
1768
+ host.host,
1769
+ String(host.port),
1770
+ host.user,
1771
+ host.transport,
1772
+ host.environment ?? "-",
1773
+ host.tags.length > 0 ? host.tags.join(",") : "-",
1774
+ host.description ?? ""
1775
+ ].join(" | "))
1776
+ ].join("\n");
1777
+ }
1778
+ /** Render one exec result (mirrors the bash-tool exit-code convention). */
1779
+ function renderExec(result) {
1780
+ const parts = [result.timedOut ? "[timed out]" : `[exit code: ${result.exitCode ?? "null"}]`];
1781
+ if (result.stdout !== "") parts.push("stdout:\n" + result.stdout);
1782
+ if (result.stderr !== "") parts.push("stderr:\n" + result.stderr);
1783
+ if (result.error !== void 0) parts.push("error: " + result.error);
1784
+ parts.push(`duration: ${result.durationMs} ms`);
1785
+ return parts.join("\n");
1786
+ }
1787
+ /** Render cluster outcomes compactly. */
1788
+ function renderCluster(results) {
1789
+ if (results.length === 0) return "no hosts matched";
1790
+ return results.map((result) => {
1791
+ const status = result.ok ? "ok" : result.timedOut === true ? "timed out" : "failed";
1792
+ const tail = result.error !== void 0 ? " (" + result.error + ")" : "";
1793
+ return `${result.alias}: ${status} [exit code: ${result.exitCode ?? "null"}]${tail}`;
1794
+ }).join("\n");
1795
+ }
1796
+ /** The host-list tool. */
1797
+ function winrmListTool(engine) {
1798
+ return defineTool({
1799
+ name: "winrm_list",
1800
+ description: "List configured Windows hosts (alias, host, port, user, transport, environment, tags, description). Use winrm_exec etc. with the alias. Triggers: Windows server, WinRM, PowerShell remote, check server/status, deploy to Windows.",
1801
+ parameters: { query: {
1802
+ type: "string",
1803
+ description: "Optional fuzzy match against alias, description, host, and tags."
1804
+ } },
1805
+ output: {
1806
+ schema: {
1807
+ type: "object",
1808
+ additionalProperties: false,
1809
+ properties: { hosts: {
1810
+ type: "array",
1811
+ required: true,
1812
+ items: {
1813
+ type: "object",
1814
+ additionalProperties: false,
1815
+ properties: {
1816
+ alias: {
1817
+ type: "string",
1818
+ required: true
1819
+ },
1820
+ host: {
1821
+ type: "string",
1822
+ required: true
1823
+ },
1824
+ port: {
1825
+ type: "integer",
1826
+ required: true
1827
+ },
1828
+ user: {
1829
+ type: "string",
1830
+ required: true
1831
+ },
1832
+ auth: {
1833
+ type: "string",
1834
+ enum: ["password"],
1835
+ required: true
1836
+ },
1837
+ transport: {
1838
+ type: "string",
1839
+ enum: ["http", "https"],
1840
+ required: true
1841
+ },
1842
+ description: { type: "string" },
1843
+ environment: { type: "string" },
1844
+ tags: {
1845
+ type: "array",
1846
+ items: { type: "string" },
1847
+ required: true
1848
+ },
1849
+ location: { type: "string" },
1850
+ createdAt: {
1851
+ type: "integer",
1852
+ required: true
1853
+ },
1854
+ updatedAt: {
1855
+ type: "integer",
1856
+ required: true
1857
+ }
1858
+ }
1859
+ }
1860
+ } }
1861
+ },
1862
+ render: (_args, value) => text(renderHosts(value.hosts ?? []))
1863
+ },
1864
+ async execute(args) {
1865
+ return { hosts: engine.list(args.query) };
1866
+ }
1867
+ });
1868
+ }
1869
+ /** The command-execution tool (PowerShell). */
1870
+ function winrmExecTool(engine) {
1871
+ return defineTool({
1872
+ name: "winrm_exec",
1873
+ description: "Execute a PowerShell command on a configured Windows host by alias. The command runs under PowerShell with UTF-8 output; prefer combining independent read-only queries into one command. Triggers: run command on Windows server, PowerShell remote, check server/status, service control, view logs, any remote Windows operation.",
1874
+ parameters: {
1875
+ alias: {
1876
+ type: "string",
1877
+ required: true,
1878
+ description: "Host alias from winrm_list."
1879
+ },
1880
+ command: {
1881
+ type: "string",
1882
+ required: true,
1883
+ description: "The PowerShell command to run remotely (script body, no powershell.exe wrapper needed)."
1884
+ },
1885
+ timeoutMs: {
1886
+ type: "integer",
1887
+ description: "Timeout in milliseconds (default 60000)."
1888
+ }
1889
+ },
1890
+ output: {
1891
+ schema: {
1892
+ type: "object",
1893
+ additionalProperties: false,
1894
+ properties: {
1895
+ success: {
1896
+ type: "boolean",
1897
+ required: true
1898
+ },
1899
+ exitCode: {
1900
+ oneOf: [{ type: "integer" }, { type: "null" }],
1901
+ required: true
1902
+ },
1903
+ timedOut: {
1904
+ type: "boolean",
1905
+ required: true
1906
+ },
1907
+ stdout: {
1908
+ type: "string",
1909
+ required: true
1910
+ },
1911
+ stderr: {
1912
+ type: "string",
1913
+ required: true
1914
+ },
1915
+ durationMs: {
1916
+ type: "integer",
1917
+ required: true
1918
+ },
1919
+ error: { type: "string" }
1920
+ }
1921
+ },
1922
+ render: (_args, value) => text(renderExec(value))
1923
+ },
1924
+ async execute(args) {
1925
+ return engine.exec(args.alias, args.command, args.timeoutMs);
1926
+ }
1927
+ });
1928
+ }
1929
+ /** The service-management tool. */
1930
+ function winrmServiceTool(engine) {
1931
+ return defineTool({
1932
+ name: "winrm_service",
1933
+ description: "Manage Windows services on a configured host: list all services, or start/stop/restart one and change its startup type. Triggers: service control, start/stop/restart Windows service, check service status, set service startup type.",
1934
+ parameters: {
1935
+ alias: {
1936
+ type: "string",
1937
+ required: true,
1938
+ description: "Host alias from winrm_list."
1939
+ },
1940
+ name: {
1941
+ type: "string",
1942
+ description: "Service name (required for actions; omitted lists all services)."
1943
+ },
1944
+ action: {
1945
+ type: "string",
1946
+ enum: [
1947
+ "list",
1948
+ "start",
1949
+ "stop",
1950
+ "restart",
1951
+ "set-auto",
1952
+ "set-manual",
1953
+ "set-disabled"
1954
+ ],
1955
+ description: "list (default) / start / stop / restart / set-auto / set-manual / set-disabled."
1956
+ }
1957
+ },
1958
+ output: {
1959
+ schema: {
1960
+ type: "object",
1961
+ additionalProperties: false,
1962
+ properties: {
1963
+ services: {
1964
+ type: "array",
1965
+ items: {
1966
+ type: "object",
1967
+ additionalProperties: false,
1968
+ properties: {
1969
+ name: {
1970
+ type: "string",
1971
+ required: true
1972
+ },
1973
+ displayName: {
1974
+ type: "string",
1975
+ required: true
1976
+ },
1977
+ status: {
1978
+ type: "string",
1979
+ required: true
1980
+ },
1981
+ startMode: {
1982
+ type: "string",
1983
+ required: true
1984
+ },
1985
+ startName: { type: "string" }
1986
+ }
1987
+ }
1988
+ },
1989
+ service: {
1990
+ type: "object",
1991
+ additionalProperties: false,
1992
+ properties: {
1993
+ name: {
1994
+ type: "string",
1995
+ required: true
1996
+ },
1997
+ displayName: {
1998
+ type: "string",
1999
+ required: true
2000
+ },
2001
+ status: {
2002
+ type: "string",
2003
+ required: true
2004
+ },
2005
+ startMode: {
2006
+ type: "string",
2007
+ required: true
2008
+ },
2009
+ startName: { type: "string" }
2010
+ }
2011
+ },
2012
+ error: { type: "string" }
2013
+ }
2014
+ },
2015
+ render: (_args, value) => {
2016
+ if (value.error !== void 0) return text("service error: " + value.error);
2017
+ if (value.services !== void 0) {
2018
+ if (value.services.length === 0) return text("no services");
2019
+ return text([
2020
+ "name | displayName | status | startMode",
2021
+ "--- | --- | --- | ---",
2022
+ ...value.services.map((s) => `${s.name} | ${s.displayName} | ${s.status} | ${s.startMode}`)
2023
+ ].join("\n"));
2024
+ }
2025
+ if (value.service !== void 0) {
2026
+ const s = value.service;
2027
+ return text(`service ${s.name}: ${s.status} (startMode ${s.startMode}, displayName ${s.displayName})`);
2028
+ }
2029
+ return text("no result");
2030
+ }
2031
+ },
2032
+ async execute(args) {
2033
+ try {
2034
+ if (args.name === void 0 || args.action === void 0 || args.action === "list") return { services: await engine.listServices(args.alias) };
2035
+ return { service: await engine.serviceAction(args.alias, args.name, args.action) };
2036
+ } catch (error) {
2037
+ return { error: error instanceof Error ? error.message : String(error) };
2038
+ }
2039
+ }
2040
+ });
2041
+ }
2042
+ /** The process-management tool. */
2043
+ function winrmProcessTool(engine) {
2044
+ return defineTool({
2045
+ name: "winrm_process",
2046
+ description: "Manage processes on a configured Windows host: list all processes, or kill one by id. Triggers: process list, kill process, check what is running on a Windows server.",
2047
+ parameters: {
2048
+ alias: {
2049
+ type: "string",
2050
+ required: true,
2051
+ description: "Host alias from winrm_list."
2052
+ },
2053
+ id: {
2054
+ type: "integer",
2055
+ description: "Process id (required for kill; omitted lists processes)."
2056
+ },
2057
+ action: {
2058
+ type: "string",
2059
+ enum: ["list", "kill"],
2060
+ description: "list (default) / kill."
2061
+ }
2062
+ },
2063
+ output: {
2064
+ schema: {
2065
+ type: "object",
2066
+ additionalProperties: false,
2067
+ properties: {
2068
+ processes: {
2069
+ type: "array",
2070
+ items: {
2071
+ type: "object",
2072
+ additionalProperties: false,
2073
+ properties: {
2074
+ id: {
2075
+ type: "integer",
2076
+ required: true
2077
+ },
2078
+ name: {
2079
+ type: "string",
2080
+ required: true
2081
+ },
2082
+ cpu: { type: "number" },
2083
+ memMB: { type: "number" },
2084
+ startTime: { type: "string" },
2085
+ path: { type: "string" }
2086
+ }
2087
+ }
2088
+ },
2089
+ killed: { type: "integer" },
2090
+ error: { type: "string" }
2091
+ }
2092
+ },
2093
+ render: (_args, value) => {
2094
+ if (value.error !== void 0) return text("process error: " + value.error);
2095
+ if (value.processes !== void 0) {
2096
+ if (value.processes.length === 0) return text("no processes");
2097
+ return text([
2098
+ "id | name | cpu | memMB | startTime",
2099
+ "--- | --- | --- | --- | ---",
2100
+ ...value.processes.map((p) => `${p.id} | ${p.name} | ${p.cpu ?? ""} | ${p.memMB ?? ""} | ${p.startTime ?? ""}`)
2101
+ ].join("\n"));
2102
+ }
2103
+ return text(`killed process ${value.killed ?? 0}`);
2104
+ }
2105
+ },
2106
+ async execute(args) {
2107
+ try {
2108
+ if (args.action === "kill" && args.id !== void 0) {
2109
+ await engine.killProcess(args.alias, args.id);
2110
+ return { killed: args.id };
2111
+ }
2112
+ return { processes: await engine.listProcesses(args.alias) };
2113
+ } catch (error) {
2114
+ return { error: error instanceof Error ? error.message : String(error) };
2115
+ }
2116
+ }
2117
+ });
2118
+ }
2119
+ /** The upload tool. */
2120
+ function winrmUploadTool(engine) {
2121
+ return defineTool({
2122
+ name: "winrm_upload",
2123
+ description: "Upload a local file to a configured Windows host (SMB admin share preferred, base64 WinRM chunk fallback). The local path is on THIS machine (the dsh host). Triggers: upload file to Windows server, deploy artifact, copy config to Windows server.",
2124
+ parameters: {
2125
+ alias: {
2126
+ type: "string",
2127
+ required: true,
2128
+ description: "Host alias from winrm_list."
2129
+ },
2130
+ localPath: {
2131
+ type: "string",
2132
+ required: true,
2133
+ description: "Absolute local file path on this machine."
2134
+ },
2135
+ remotePath: {
2136
+ type: "string",
2137
+ required: true,
2138
+ description: "Destination path on the remote host, e.g. C:\\temp\\file.txt (parent dirs are created)."
2139
+ },
2140
+ channel: {
2141
+ type: "string",
2142
+ enum: [
2143
+ "auto",
2144
+ "smb",
2145
+ "winrm"
2146
+ ],
2147
+ description: "Transfer channel: smb (admin share), winrm (base64 chunks), or auto (default, SMB first with WinRM fallback)."
2148
+ }
2149
+ },
2150
+ output: {
2151
+ schema: {
2152
+ type: "object",
2153
+ additionalProperties: false,
2154
+ properties: {
2155
+ ok: {
2156
+ type: "boolean",
2157
+ required: true
2158
+ },
2159
+ transferredBytes: { type: "integer" },
2160
+ channel: {
2161
+ type: "string",
2162
+ enum: ["smb", "winrm"]
2163
+ },
2164
+ error: { type: "string" }
2165
+ }
2166
+ },
2167
+ render: (_args, value) => text(value.ok ? `uploaded ${value.transferredBytes ?? 0} bytes via ${value.channel ?? "unknown"}` : `upload failed: ${value.error ?? "unknown error"}`)
2168
+ },
2169
+ async execute(args) {
2170
+ try {
2171
+ const outcome = await engine.upload(args.alias, args.localPath, args.remotePath, void 0, args.channel ?? "auto");
2172
+ const usedChannel = outcome.channel === "smb" ? "smb" : "winrm";
2173
+ return {
2174
+ ok: true,
2175
+ transferredBytes: outcome.bytes,
2176
+ channel: usedChannel
2177
+ };
2178
+ } catch (error) {
2179
+ return {
2180
+ ok: false,
2181
+ error: error instanceof Error ? error.message : String(error)
2182
+ };
2183
+ }
2184
+ }
2185
+ });
2186
+ }
2187
+ /** The download tool. */
2188
+ function winrmDownloadTool(engine) {
2189
+ return defineTool({
2190
+ name: "winrm_download",
2191
+ description: "Download a remote FILE from a configured Windows host to a local path on this machine (SMB admin share preferred, base64 WinRM chunk fallback). Directory download is not supported — download files individually. Triggers: download file from Windows server, fetch remote log/artifact.",
2192
+ parameters: {
2193
+ alias: {
2194
+ type: "string",
2195
+ required: true,
2196
+ description: "Host alias from winrm_list."
2197
+ },
2198
+ remotePath: {
2199
+ type: "string",
2200
+ required: true,
2201
+ description: "Remote file path, e.g. C:\\temp\\file.txt."
2202
+ },
2203
+ localPath: {
2204
+ type: "string",
2205
+ required: true,
2206
+ description: "Absolute destination path on this machine."
2207
+ },
2208
+ channel: {
2209
+ type: "string",
2210
+ enum: [
2211
+ "auto",
2212
+ "smb",
2213
+ "winrm"
2214
+ ],
2215
+ description: "Transfer channel: smb (admin share), winrm (base64 chunks), or auto (default, SMB first with WinRM fallback)."
2216
+ }
2217
+ },
2218
+ output: {
2219
+ schema: {
2220
+ type: "object",
2221
+ additionalProperties: false,
2222
+ properties: {
2223
+ ok: {
2224
+ type: "boolean",
2225
+ required: true
2226
+ },
2227
+ bytes: { type: "integer" },
2228
+ channel: {
2229
+ type: "string",
2230
+ enum: ["smb", "winrm"]
2231
+ },
2232
+ error: { type: "string" }
2233
+ }
2234
+ },
2235
+ render: (_args, value) => text(value.ok ? `downloaded ${value.bytes ?? 0} bytes via ${value.channel ?? "unknown"}` : `download failed: ${value.error ?? "unknown error"}`)
2236
+ },
2237
+ async execute(args) {
2238
+ try {
2239
+ const outcome = await engine.download(args.alias, args.remotePath, args.localPath, void 0, args.channel ?? "auto");
2240
+ const usedChannel = outcome.channel === "smb" ? "smb" : "winrm";
2241
+ return {
2242
+ ok: true,
2243
+ bytes: outcome.bytes,
2244
+ channel: usedChannel
2245
+ };
2246
+ } catch (error) {
2247
+ return {
2248
+ ok: false,
2249
+ error: error instanceof Error ? error.message : String(error)
2250
+ };
2251
+ }
2252
+ }
2253
+ });
2254
+ }
2255
+ /** The cluster tool. */
2256
+ function winrmClusterTool(engine) {
2257
+ return defineTool({
2258
+ name: "winrm_cluster",
2259
+ description: "Run one PowerShell command concurrently across many Windows hosts (all hosts, or filtered by aliases / environment / tags). Triggers: run on all Windows servers, batch operation, production servers, cluster command.",
2260
+ parameters: {
2261
+ command: {
2262
+ type: "string",
2263
+ required: true,
2264
+ description: "The PowerShell command to run on every matched host."
2265
+ },
2266
+ aliases: {
2267
+ type: "array",
2268
+ items: { type: "string" },
2269
+ description: "Explicit alias list; when absent every configured host matches."
2270
+ },
2271
+ environment: {
2272
+ type: "string",
2273
+ description: "Only hosts with this environment label."
2274
+ },
2275
+ tags: {
2276
+ type: "array",
2277
+ items: { type: "string" },
2278
+ description: "Only hosts carrying ALL these tags."
2279
+ },
2280
+ timeoutMs: {
2281
+ type: "integer",
2282
+ description: "Per-host timeout in milliseconds."
2283
+ },
2284
+ maxWorkers: {
2285
+ type: "integer",
2286
+ description: "Concurrency cap (default 8)."
2287
+ }
2288
+ },
2289
+ output: {
2290
+ schema: {
2291
+ type: "object",
2292
+ additionalProperties: false,
2293
+ properties: { results: {
2294
+ type: "array",
2295
+ required: true,
2296
+ items: {
2297
+ type: "object",
2298
+ additionalProperties: false,
2299
+ properties: {
2300
+ alias: {
2301
+ type: "string",
2302
+ required: true
2303
+ },
2304
+ ok: {
2305
+ type: "boolean",
2306
+ required: true
2307
+ },
2308
+ exitCode: { oneOf: [{ type: "integer" }, { type: "null" }] },
2309
+ timedOut: { type: "boolean" },
2310
+ stdout: { type: "string" },
2311
+ stderr: { type: "string" },
2312
+ durationMs: { type: "integer" },
2313
+ error: { type: "string" }
2314
+ }
2315
+ }
2316
+ } }
2317
+ },
2318
+ render: (_args, value) => text(renderCluster(value.results ?? []))
2319
+ },
2320
+ async execute(args) {
2321
+ return { results: await engine.cluster(args) };
2322
+ }
2323
+ });
2324
+ }
2325
+ //#endregion
2326
+ //#region src/mount-once.ts
2327
+ /**
2328
+ * Host single-instance guard shared by the plugin family. The family bundle
2329
+ * (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
2330
+ * the loader accepts a standalone install of the same package side by side;
2331
+ * without this guard the second instance would still re-register the same
2332
+ * webserver routes, tools, settings namespaces, and system-prompt sections
2333
+ * and fail the boot. mountOnce makes the second host apply a no-op for the
2334
+ * lifetime of the first instance (the browser half is already deduped by
2335
+ * package name in the client module host).
2336
+ *
2337
+ * The registry rides a global symbol so two module instances of the same
2338
+ * package (npm copy vs repository link) still share one verdict. cordis
2339
+ * `ctx.effect` runs its callback immediately and treats the callback's
2340
+ * return value as the fiber disposer, so the unmarker is returned, not run.
2341
+ */
2342
+ const MOUNTED = Symbol.for("dsh-web-ui.mounted-plugins");
2343
+ function mountedSet() {
2344
+ const registry = globalThis;
2345
+ return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
2346
+ }
2347
+ /**
2348
+ * Wrap a cordis plugin apply so the package runs at most once per process.
2349
+ * The first mount registers normally and unmarks when its fiber disposes;
2350
+ * any later mount of the same package name is a no-op.
2351
+ * @param packageName - npm package identity shared by every install source.
2352
+ * @param fn - the original plugin apply.
2353
+ * @returns an apply of the same shape.
2354
+ */
2355
+ function mountOnce(packageName, fn) {
2356
+ return ((...args) => {
2357
+ const mounted = mountedSet();
2358
+ if (mounted.has(packageName)) return;
2359
+ mounted.add(packageName);
2360
+ args[0]?.effect?.(() => () => {
2361
+ mounted.delete(packageName);
2362
+ });
2363
+ return fn(...args);
2364
+ });
2365
+ }
2366
+ //#endregion
2367
+ //#region src/index.ts
2368
+ /** Stable cordis plugin name. */
2369
+ const name = "winrm";
2370
+ /** Services required before the WinRM surfaces can mount. */
2371
+ const inject = [
2372
+ "webServer",
2373
+ "tools",
2374
+ "systemPrompt"
2375
+ ];
2376
+ const Config = z.object({
2377
+ announceToAgent: z.boolean().default(true).volatile(),
2378
+ enabled: z.boolean().default(true).volatile()
2379
+ });
2380
+ /** Schema default, re-read for hand-built test contexts. */
2381
+ const DEFAULT_ANNOUNCE = true;
2382
+ /** Schema default for the master switch. */
2383
+ const DEFAULT_ENABLED = true;
2384
+ /** Order of the announcement section within the tool-guidance band. */
2385
+ const SECTION_ORDER = 151;
2386
+ /** Model-facing announcement: plugin presence, capabilities, and limits. */
2387
+ const WINRM_GUIDANCE = "本机已安装 dsh-winrm 插件(DSH 远程 Windows 运维,WinRM/PowerShell Remoting):侧边栏「Windows」入口;仿照 dsh-ssh 插件开发。能力:主机配置存 ~/.dsh/dsh-winrm.json(GUI 配置后 agent 方可使用);winrm_list 列出主机、winrm_exec 执行 PowerShell 命令(UTF-8 信封,中文不乱码)、winrm_service 服务管理(start/stop/restart/启动类型)、winrm_process 进程管理(list/kill)、winrm_upload/winrm_download 文件传输(base64 分块,无 SMB 依赖)、winrm_cluster 集群并发执行;Web 控制台走 WebSocket。认证:通过原生 Node.js WinRM 客户端(winrm-client)的 NTLM/Negotiate 与 Basic 实现,兼容本地账户与域账户;传输支持 http(5985)/https(5986)。前提:目标机需启用 WinRM(scripts/enable-winrm.ps1),本机无需 Python —— WinRM 走纯 Node 依赖。HTTP 仅建议受信内网使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出可能含敏感信息;传输/执行消耗真实远程资源,先确认再操作。用户提到「Windows 服务器 / WinRM / PowerShell 远程 / 服务管理 / 进程管理」时即指本插件,请据此协作。";
2388
+ /**
2389
+ * Mount the WinRM engine, routes, tools, and announcement.
2390
+ * @param ctx - host plugin context carrying webServer/tools/systemPrompt.
2391
+ * @param config - resolved plugin config (volatile fields read via `.get()`).
2392
+ */
2393
+ const apply = mountOnce("dsh-winrm", applyImpl);
2394
+ function applyImpl(ctx, config) {
2395
+ const resolve = () => ({
2396
+ announceToAgent: config?.announceToAgent?.get() ?? DEFAULT_ANNOUNCE,
2397
+ enabled: config?.enabled?.get() ?? DEFAULT_ENABLED
2398
+ });
2399
+ const store = new HostStore();
2400
+ const engine = new WinRmEngine(store);
2401
+ const { routes, upgrade } = makeRoutes({
2402
+ store,
2403
+ engine
2404
+ });
2405
+ const tools = [
2406
+ winrmListTool(engine),
2407
+ winrmExecTool(engine),
2408
+ winrmServiceTool(engine),
2409
+ winrmProcessTool(engine),
2410
+ winrmUploadTool(engine),
2411
+ winrmDownloadTool(engine),
2412
+ winrmClusterTool(engine)
2413
+ ];
2414
+ let disposeSection;
2415
+ let disposeRoutes;
2416
+ let disposeTools;
2417
+ const registerRoutes = () => {
2418
+ const disposers = routes.map((route) => ctx.webServer.register(route));
2419
+ const upgradeDisposer = ctx.webServer.registerUpgrade(upgrade);
2420
+ return () => {
2421
+ for (const dispose of disposers) dispose();
2422
+ upgradeDisposer();
2423
+ };
2424
+ };
2425
+ const registerTools = () => {
2426
+ const disposers = tools.map((tool) => ctx.tools.register(tool));
2427
+ return () => {
2428
+ for (const dispose of disposers) dispose();
2429
+ };
2430
+ };
2431
+ const sync = () => {
2432
+ disposeSection?.();
2433
+ disposeSection = void 0;
2434
+ disposeRoutes?.();
2435
+ disposeRoutes = void 0;
2436
+ disposeTools?.();
2437
+ disposeTools = void 0;
2438
+ const value = resolve();
2439
+ if (!value.enabled) return;
2440
+ if (value.announceToAgent) disposeSection = ctx.systemPrompt.section({
2441
+ name: "plugin:dsh-winrm",
2442
+ order: SECTION_ORDER,
2443
+ text: WINRM_GUIDANCE
2444
+ });
2445
+ disposeRoutes = registerRoutes();
2446
+ disposeTools = registerTools();
2447
+ };
2448
+ ctx.on("loader/volatile-update", sync);
2449
+ ctx.effect(() => () => {
2450
+ disposeSection?.();
2451
+ disposeRoutes?.();
2452
+ disposeTools?.();
2453
+ engine.dispose();
2454
+ }, "dsh-winrm: teardown");
2455
+ sync();
2456
+ }
2457
+ //#endregion
2458
+ export { Config, WINRM_GUIDANCE, apply, inject, name };