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
@@ -0,0 +1,291 @@
1
+ /**
2
+ * WinRM transport bridge.
3
+ *
4
+ * Runs each PowerShell payload over a native Node.js WinRM client
5
+ * (winrm-client) — no Python/pywinrm subprocess. The script rides the
6
+ * UTF-8 base64 envelope (marker + base64 of UTF-8 output) so Chinese and
7
+ * any codepage output survives the WinRM transport losslessly; the envelope
8
+ * is base64-encoded with `-EncodedCommand`, keeping the command line a
9
+ * single ASCII-safe line that WinRS/cmd.exe cannot mangle.
10
+ *
11
+ * Auth is chosen per attempt (HTTPS: Basic first, then NTLM; HTTP: NTLM
12
+ * first, then Basic) with fallback, so a host that only enables one scheme
13
+ * still connects. Credentials are passed to the library in-process — they
14
+ * never appear in a child-process argument list.
15
+ */
16
+
17
+ import { Command, Shell } from 'winrm-client'
18
+ import type { WinHostEntry } from '../protocol.ts'
19
+ import { parseEnvelope, powershellCommandLine, psFileSize, psReadChunk, psWriteChunk, stripClixml, winrmPowerShellScript } from '../powershell.ts'
20
+
21
+ /** Connection parameters for one target (transport-agnostic projection of a host entry). */
22
+ export interface WinRMParams {
23
+ host: string
24
+ port: number
25
+ path: string
26
+ username: string
27
+ password: string
28
+ useHttps?: boolean
29
+ rejectUnauthorized?: boolean
30
+ }
31
+
32
+ /** winrm-client auth schemes (mirrors its `AuthMethod`). */
33
+ type WinrmAuth = 'basic' | 'ntlm'
34
+
35
+ /** Extra wall-clock budget so connection setup does not eat the command's timeout window. */
36
+ const SETUP_GRACE_MS = 5_000
37
+
38
+ /** Upper bound on the best-effort shell-delete round trip after a timed-out call. */
39
+ const DELETE_TIMEOUT_MS = 10_000
40
+
41
+ /** Sentinel: the command exceeded its deadline (distinct from auth/transport errors). */
42
+ class WinrmTimedOut extends Error {
43
+ constructor() {
44
+ super('WinRM request timed out')
45
+ this.name = 'WinrmTimedOut'
46
+ }
47
+ }
48
+
49
+ /** Small cancellable delay used to bound the shell-delete cleanup. */
50
+ function sleep(ms: number): Promise<void> {
51
+ return new Promise(resolve => { setTimeout(resolve, ms) })
52
+ }
53
+
54
+ /**
55
+ * Race a promise against a deadline; the loser's later settlement is
56
+ * ignored (Promise.race attaches a handler to both), so a hung WinRM call
57
+ * cannot surface as an unhandled rejection.
58
+ * @param promise - the operation to bound.
59
+ * @param ms - milliseconds before rejecting with {@link WinrmTimedOut}.
60
+ * @returns the operation result, or rejects with WinrmTimedOut on deadline.
61
+ */
62
+ async function raceDeadline<T>(promise: Promise<T>, ms: number): Promise<T> {
63
+ let timer: NodeJS.Timeout | undefined
64
+ const timeout = new Promise<never>((_resolve, reject) => {
65
+ timer = setTimeout(() => { reject(new WinrmTimedOut()) }, ms)
66
+ })
67
+ try {
68
+ return await Promise.race([promise, timeout])
69
+ } finally {
70
+ if (timer !== undefined) clearTimeout(timer)
71
+ }
72
+ }
73
+
74
+ /** Build winrm-client's base params for one auth scheme. */
75
+ function baseParams(conn: WinRMParams, authMethod: WinrmAuth) {
76
+ return {
77
+ host: conn.host,
78
+ port: conn.port,
79
+ path: conn.path,
80
+ username: conn.username,
81
+ password: conn.password,
82
+ authMethod,
83
+ useHttps: conn.useHttps ?? false,
84
+ rejectUnauthorized: conn.rejectUnauthorized ?? true,
85
+ }
86
+ }
87
+
88
+ /**
89
+ * One shell lifecycle: create → run the encoded script → receive until the
90
+ * command completes → delete. Bounded by an overall deadline; on timeout the
91
+ * delete is fired without blocking the caller.
92
+ * @param conn - connection parameters.
93
+ * @param envelope - the wrapped UTF-8 envelope script (already prepared).
94
+ * @param timeoutMs - command execution budget measured from just after Execute.
95
+ * @param authMethod - the auth scheme for this attempt.
96
+ * @returns stdout/stderr captured from the WinRS streams.
97
+ * @throws WinrmTimedOut when the deadline passes; other errors are auth/transport failures.
98
+ */
99
+ async function attemptOnce(
100
+ conn: WinRMParams,
101
+ envelope: string,
102
+ timeoutMs: number,
103
+ authMethod: WinrmAuth,
104
+ ): Promise<{ stdout: string; stderr: string }> {
105
+ const base = baseParams(conn, authMethod)
106
+ const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS
107
+ const state = { shellId: undefined as string | undefined, timedOut: false, settled: false }
108
+
109
+ const inner = (async (): Promise<{ stdout: string; stderr: string }> => {
110
+ const shellId = await Shell.doCreateShell(base)
111
+ state.shellId = shellId
112
+ // The deadline can fire while the shell is still being created, in which
113
+ // case this attempt has already settled and its `finally` ran without a
114
+ // shell id: abandon the request and remove the shell here so a slow
115
+ // handshake cannot leak it or run the command after the timeout.
116
+ if (state.settled) {
117
+ void Shell.doDeleteShell({ ...base, shellId }).catch(() => undefined)
118
+ throw new WinrmTimedOut()
119
+ }
120
+ const command = powershellCommandLine(envelope)
121
+ const commandId = await Command.doExecuteCommand({ ...base, shellId, command })
122
+ const receive = { ...base, shellId, commandId }
123
+ const commandDeadline = Date.now() + timeoutMs
124
+ let stdout = ''
125
+ let stderr = ''
126
+ for (;;) {
127
+ if (state.settled || Date.now() >= commandDeadline) throw new WinrmTimedOut()
128
+ const chunk = await Command.doReceiveOutputNonBlocking(receive)
129
+ stdout += chunk.output
130
+ stderr += chunk.stderr
131
+ if (chunk.isComplete) return { stdout, stderr }
132
+ }
133
+ })()
134
+
135
+ try {
136
+ return await raceDeadline(inner, hardDeadline - Date.now())
137
+ } catch (error) {
138
+ if (error instanceof WinrmTimedOut || Date.now() >= hardDeadline) {
139
+ state.timedOut = true
140
+ throw error instanceof WinrmTimedOut ? error : new WinrmTimedOut()
141
+ }
142
+ throw error
143
+ } finally {
144
+ state.settled = true
145
+ const shellId = state.shellId
146
+ if (shellId !== undefined) {
147
+ const deletion = Shell.doDeleteShell({ ...base, shellId }).catch(() => undefined)
148
+ if (state.timedOut) void deletion
149
+ else await Promise.race([deletion, sleep(DELETE_TIMEOUT_MS)])
150
+ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Run one envelope over WinRM, trying each auth scheme in order until one
156
+ * succeeds. A timeout aborts immediately (retrying auth would waste the
157
+ * remaining budget on a reachable-but-slow host).
158
+ * @param conn - connection parameters.
159
+ * @param envelope - the wrapped UTF-8 envelope script.
160
+ * @param timeoutMs - command execution budget.
161
+ * @returns captured stdout/stderr.
162
+ * @throws WinrmTimedOut on deadline; the last auth/transport error when every scheme fails.
163
+ */
164
+ async function runWinrm(
165
+ conn: WinRMParams,
166
+ envelope: string,
167
+ timeoutMs: number,
168
+ ): Promise<{ stdout: string; stderr: string }> {
169
+ const candidates: WinrmAuth[] = conn.useHttps ? ['basic', 'ntlm'] : ['ntlm', 'basic']
170
+ let lastError: unknown
171
+ for (const authMethod of candidates) {
172
+ try {
173
+ return await attemptOnce(conn, envelope, timeoutMs, authMethod)
174
+ } catch (error) {
175
+ if (error instanceof WinrmTimedOut) throw error
176
+ lastError = error
177
+ }
178
+ }
179
+ throw lastError instanceof Error ? lastError : new Error('all WinRM authentication methods failed')
180
+ }
181
+
182
+ export async function runScript(
183
+ conn: WinRMParams,
184
+ script: string,
185
+ options: { timeoutMs?: number; onChunk?: (chunk: { output: string; stderr: string }) => void } = {},
186
+ ): Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean; durationMs: number }> {
187
+ const started = Date.now()
188
+ const timeoutMs = options.timeoutMs ?? 60_000
189
+ const envelope = winrmPowerShellScript(script)
190
+
191
+ let outcome: { stdout: string; stderr: string }
192
+ try {
193
+ outcome = await runWinrm(conn, envelope, timeoutMs)
194
+ } catch (error) {
195
+ if (error instanceof WinrmTimedOut) {
196
+ return { stdout: '', stderr: '', exitCode: null, timedOut: true, durationMs: Date.now() - started }
197
+ }
198
+ throw error
199
+ }
200
+
201
+ const raw = outcome.stdout
202
+ const parsed = parseEnvelope(raw)
203
+ // The envelope is the wrapper's completion signal, and it always prints —
204
+ // even for output that is empty. Its absence therefore means the script
205
+ // never ran to completion (a parse error, or an aborted shell), which is a
206
+ // failure; the raw stream is still returned as evidence.
207
+ const exitCode = parsed !== null ? parsed.exitCode : 1
208
+ // A script that reached the envelope leaves only the host's CLIXML records
209
+ // on stderr; an aborted one keeps its stderr untouched for diagnosis.
210
+ const stderr = parsed !== null ? stripClixml(outcome.stderr) : outcome.stderr
211
+ options.onChunk?.({ output: raw, stderr })
212
+ return {
213
+ stdout: parsed !== null ? parsed.text : raw,
214
+ stderr,
215
+ exitCode,
216
+ timedOut: false,
217
+ durationMs: Date.now() - started,
218
+ }
219
+ }
220
+
221
+ export function connOf(entry: WinHostEntry): WinRMParams {
222
+ return {
223
+ host: entry.host,
224
+ port: entry.port,
225
+ path: '/wsman',
226
+ username: entry.user,
227
+ password: entry.auth.password ?? '',
228
+ useHttps: entry.transport === 'https',
229
+ rejectUnauthorized: entry.rejectUnauthorized ?? true,
230
+ }
231
+ }
232
+
233
+ export async function testConnection(conn: WinRMParams): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
234
+ const started = Date.now()
235
+ try {
236
+ const result = await runScript(conn, 'Write-Output ok', { timeoutMs: 30_000 })
237
+ return { ok: result.exitCode === 0 && result.stdout.trim() !== '', latencyMs: Date.now() - started }
238
+ } catch (error) {
239
+ return { ok: false, latencyMs: Date.now() - started, error: error instanceof Error ? error.message : String(error) }
240
+ }
241
+ }
242
+
243
+ export const TRANSFER_CHUNK = 48 * 1024
244
+
245
+ export async function remoteFileSize(conn: WinRMParams, remotePath: string, timeoutMs = 60_000): Promise<number> {
246
+ const result = await runScript(conn, psFileSize(remotePath), { timeoutMs })
247
+ const value = Number.parseInt(result.stdout.trim(), 10)
248
+ return Number.isFinite(value) ? value : 0
249
+ }
250
+
251
+ export async function downloadChunks(
252
+ conn: WinRMParams,
253
+ remotePath: string,
254
+ onChunk: (b64: string, bytes: number) => void,
255
+ onProgress?: (transferred: number, total: number) => void,
256
+ timeoutMs = 60_000,
257
+ ): Promise<number> {
258
+ const total = await remoteFileSize(conn, remotePath, timeoutMs)
259
+ if (total === 0) return 0
260
+ let offset = 0
261
+ for (;;) {
262
+ const result = await runScript(conn, psReadChunk(remotePath, offset, TRANSFER_CHUNK), { timeoutMs })
263
+ const b64 = result.stdout.trim()
264
+ const bytes = Buffer.from(b64, 'base64').length
265
+ if (bytes === 0) break
266
+ onChunk(b64, bytes)
267
+ offset += bytes
268
+ onProgress?.(offset, total)
269
+ if (offset >= total || bytes < TRANSFER_CHUNK) break
270
+ }
271
+ return offset
272
+ }
273
+
274
+ export async function uploadBuffer(
275
+ conn: WinRMParams,
276
+ remotePath: string,
277
+ data: Buffer,
278
+ onProgress?: (transferred: number, total: number) => void,
279
+ timeoutMs = 60_000,
280
+ ): Promise<number> {
281
+ let written = 0
282
+ let first = true
283
+ for (let offset = 0; offset < data.length; offset += TRANSFER_CHUNK) {
284
+ const chunk = data.subarray(offset, Math.min(offset + TRANSFER_CHUNK, data.length))
285
+ await runScript(conn, psWriteChunk(remotePath, chunk.toString('base64'), !first), { timeoutMs })
286
+ written += chunk.length
287
+ first = false
288
+ onProgress?.(written, data.length)
289
+ }
290
+ return written
291
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * WebSocket PowerShell console backed by winrm-client one-shot commands.
3
+ *
4
+ * The native Node WinRM client executes each submitted command through the
5
+ * same UTF-8 envelope as winrm_exec; the WebSocket remains open while
6
+ * commands are serialized. This is intentionally a command console rather
7
+ * than a persistent remote process, so every line is authenticated and
8
+ * isolated.
9
+ */
10
+
11
+ import type { WinRMParams } from './client.ts'
12
+ import { runScript } from './client.ts'
13
+
14
+ export class ConsoleSession {
15
+ private conn: WinRMParams
16
+ private running = false
17
+ private exited = false
18
+ private queue: Promise<void> = Promise.resolve()
19
+ onData: ((data: string) => void) | undefined
20
+ onExit: ((code: number | null, error?: string) => void) | undefined
21
+
22
+ constructor(conn: WinRMParams) {
23
+ this.conn = conn
24
+ }
25
+
26
+ async start(): Promise<void> {
27
+ this.running = true
28
+ }
29
+
30
+ send(data: string): void {
31
+ const command = data.replace(/\r?\n/g, '').trim()
32
+ if (!this.running || command === '') return
33
+ if (command.toLowerCase() === 'exit') {
34
+ this.close()
35
+ return
36
+ }
37
+ this.queue = this.queue.then(async () => {
38
+ if (!this.running) return
39
+ try {
40
+ const result = await runScript(this.conn, command, { timeoutMs: 120_000 })
41
+ if (result.stdout !== '') this.onData?.(result.stdout + (result.stdout.endsWith('\n') ? '' : '\n'))
42
+ if (result.stderr !== '') this.onData?.(result.stderr + (result.stderr.endsWith('\n') ? '' : '\n'))
43
+ if (result.timedOut) this.onData?.('[timed out]\n')
44
+ } catch (error) {
45
+ this.onData?.('[error] ' + (error instanceof Error ? error.message : String(error)) + '\n')
46
+ }
47
+ })
48
+ }
49
+
50
+ close(): void {
51
+ if (!this.running) return
52
+ this.running = false
53
+ this.exited = true
54
+ }
55
+
56
+ private finish(code: number | null, error?: string): void {
57
+ if (this.exited) return
58
+ this.exited = true
59
+ this.running = false
60
+ this.onExit?.(code, error)
61
+ }
62
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,331 @@
1
+ /**
2
+ * The WinRM engine facade: host lookup, PowerShell exec (UTF-8 envelope),
3
+ * service & process management, directory listing, base64-chunked file
4
+ * transfer, cluster execution and the streaming console. The heavy lifting
5
+ * lives in engine/ (client, console); this class composes them behind one
6
+ * WinRmEngine instance per plugin apply.
7
+ */
8
+
9
+ import type { ClusterResult, ExecResult, ProcessInfo, RemoteDirEntry, ServiceInfo, TestResult, TransferProgress, WinHostSummary } from './protocol.ts'
10
+ import type { HostStore } from './store.ts'
11
+ import { connOf, downloadChunks, runScript, testConnection, uploadBuffer } from './engine/client.ts'
12
+ import { downloadSmb, uploadSmb, type TransferChannel } from './smb.ts'
13
+ import { ConsoleSession } from './engine/console.ts'
14
+ import { psKillProcess, psListDir, psListProcesses, psListServices, psServiceAction } from './powershell.ts'
15
+
16
+ /** JSON-safe parse of the envelope output into a typed value. */
17
+ function parseJson<T>(text: string): T {
18
+ const trimmed = text.trim()
19
+ if (trimmed === '' || trimmed === 'null') return [] as unknown as T
20
+ return JSON.parse(trimmed) as T
21
+ }
22
+
23
+ /** A tiny per-alias in-flight limiter so GUI bursts do not pile up sockets. */
24
+ class AliasGate {
25
+ private queues = new Map<string, Promise<void>>()
26
+
27
+ async run<T>(alias: string, fn: () => Promise<T>): Promise<T> {
28
+ const prev = this.queues.get(alias) ?? Promise.resolve()
29
+ const next = prev.then(() => fn(), () => fn())
30
+ // Keep the chain alive even when fn rejects (the caller observes the rejection).
31
+ this.queues.set(alias, next.then(() => undefined, () => undefined))
32
+ try {
33
+ return await next
34
+ } finally {
35
+ if (this.queues.get(alias) === next.then(() => undefined, () => undefined)) {
36
+ // (The identity check above is intentionally approximate; the queue
37
+ // entry is replaced per run, so stale entries are naturally dropped.)
38
+ }
39
+ void prev
40
+ }
41
+ }
42
+ }
43
+
44
+ /** The engine. One instance per plugin apply; dispose() clears nothing pooled (WinRM is per-call HTTP). */
45
+ export class WinRmEngine {
46
+ readonly store: HostStore
47
+ private gate = new AliasGate()
48
+
49
+ constructor(store: HostStore) {
50
+ this.store = store
51
+ }
52
+
53
+ // ---------------------------------------------------------------- config
54
+
55
+ /** Secret-free host list (filtered by the optional query). */
56
+ list(query?: string): WinHostSummary[] {
57
+ const needle = query?.trim().toLowerCase()
58
+ return this.store.list()
59
+ .filter(entry => needle === undefined || needle === ''
60
+ || entry.alias.toLowerCase().includes(needle)
61
+ || (entry.description ?? '').toLowerCase().includes(needle)
62
+ || entry.host.toLowerCase().includes(needle)
63
+ || entry.tags.some(tag => tag.toLowerCase().includes(needle)))
64
+ .map(entry => this.store.summarize(entry))
65
+ }
66
+
67
+ /** One host summary by alias. */
68
+ find(alias: string): WinHostSummary | undefined {
69
+ const entry = this.store.find(alias)
70
+ return entry === undefined ? undefined : this.store.summarize(entry)
71
+ }
72
+
73
+ // ------------------------------------------------------------------ exec
74
+
75
+ /** Run one PowerShell command on `alias`. */
76
+ async exec(alias: string, command: string, timeoutMs?: number): Promise<ExecResult> {
77
+ const entry = this.store.find(alias)
78
+ if (entry === undefined) {
79
+ return { success: false, exitCode: null, timedOut: false, stdout: '', stderr: '', durationMs: 0, error: `alias '${alias}' not found` }
80
+ }
81
+ return this.gate.run(alias, async () => {
82
+ try {
83
+ const result = await runScript(connOf(entry), command, { timeoutMs: timeoutMs ?? 60_000 })
84
+ return {
85
+ success: result.exitCode === 0,
86
+ exitCode: result.exitCode,
87
+ timedOut: result.timedOut,
88
+ stdout: result.stdout,
89
+ stderr: result.stderr,
90
+ durationMs: result.durationMs,
91
+ }
92
+ } catch (error) {
93
+ return { success: false, exitCode: null, timedOut: false, stdout: '', stderr: '', durationMs: 0, error: error instanceof Error ? error.message : String(error) }
94
+ }
95
+ })
96
+ }
97
+
98
+ // --------------------------------------------------------------- cluster
99
+
100
+ /** Run one command against many hosts concurrently. */
101
+ async cluster(options: {
102
+ command: string
103
+ aliases?: string[]
104
+ environment?: string
105
+ tags?: string[]
106
+ timeoutMs?: number
107
+ maxWorkers?: number
108
+ }): Promise<ClusterResult[]> {
109
+ const hosts = this.store.list()
110
+ let matched = hosts
111
+ if (options.aliases !== undefined && options.aliases.length > 0) {
112
+ const set = new Set(options.aliases)
113
+ matched = matched.filter(entry => set.has(entry.alias))
114
+ }
115
+ if (options.environment !== undefined && options.environment !== '') {
116
+ matched = matched.filter(entry => entry.environment === options.environment)
117
+ }
118
+ if (options.tags !== undefined && options.tags.length > 0) {
119
+ matched = matched.filter(entry => options.tags!.every(tag => entry.tags.includes(tag)))
120
+ }
121
+ const maxWorkers = Math.max(1, Math.min(options.maxWorkers ?? 8, 32))
122
+ const results: ClusterResult[] = []
123
+ let cursor = 0
124
+ const worker = async (): Promise<void> => {
125
+ for (;;) {
126
+ const index = cursor++
127
+ const entry = matched[index]
128
+ if (entry === undefined) return
129
+ const started = Date.now()
130
+ const outcome = await this.exec(entry.alias, options.command, options.timeoutMs)
131
+ results.push({
132
+ alias: entry.alias,
133
+ ok: outcome.success,
134
+ exitCode: outcome.exitCode,
135
+ timedOut: outcome.timedOut,
136
+ stdout: outcome.stdout,
137
+ stderr: outcome.stderr,
138
+ durationMs: outcome.durationMs,
139
+ ...(outcome.error !== undefined ? { error: outcome.error } : {}),
140
+ })
141
+ void started
142
+ }
143
+ }
144
+ await Promise.all(Array.from({ length: Math.min(maxWorkers, matched.length) }, () => worker()))
145
+ return results
146
+ }
147
+
148
+ // -------------------------------------------------------------- services
149
+
150
+ /** Full service table. */
151
+ async listServices(alias: string): Promise<ServiceInfo[]> {
152
+ const entry = this.store.find(alias)
153
+ if (entry === undefined) throw new Error(`alias '${alias}' not found`)
154
+ return this.gate.run(alias, async () => {
155
+ const result = await runScript(connOf(entry), psListServices(), { timeoutMs: 60_000 })
156
+ if (result.exitCode !== 0) throw new Error(result.stdout || 'failed to list services')
157
+ const items = parseJson<Array<{ name?: string; displayName?: string; status?: string; startMode?: string; startName?: string }>>(result.stdout)
158
+ return items.map(item => ({
159
+ name: item.name ?? '',
160
+ displayName: item.displayName ?? '',
161
+ status: item.status ?? '',
162
+ startMode: item.startMode ?? '',
163
+ startName: item.startName ?? '',
164
+ }))
165
+ })
166
+ }
167
+
168
+ /** One service action, returning the refreshed service row. */
169
+ async serviceAction(alias: string, name: string, action: 'start' | 'stop' | 'restart' | 'set-auto' | 'set-manual' | 'set-disabled'): Promise<ServiceInfo> {
170
+ const entry = this.store.find(alias)
171
+ if (entry === undefined) throw new Error(`alias '${alias}' not found`)
172
+ return this.gate.run(alias, async () => {
173
+ const result = await runScript(connOf(entry), psServiceAction(name, action), { timeoutMs: 60_000 })
174
+ if (result.exitCode !== 0) throw new Error(result.stdout || `service action '${action}' failed`)
175
+ const item = parseJson<Array<{ name?: string; displayName?: string; status?: string; startMode?: string; startName?: string }>>(result.stdout)[0] ?? {}
176
+ return {
177
+ name: item.name ?? name,
178
+ displayName: item.displayName ?? '',
179
+ status: item.status ?? '',
180
+ startMode: item.startMode ?? '',
181
+ startName: item.startName ?? '',
182
+ }
183
+ })
184
+ }
185
+
186
+ // ------------------------------------------------------------- processes
187
+
188
+ /** Full process table. */
189
+ async listProcesses(alias: string): Promise<ProcessInfo[]> {
190
+ const entry = this.store.find(alias)
191
+ if (entry === undefined) throw new Error(`alias '${alias}' not found`)
192
+ return this.gate.run(alias, async () => {
193
+ const result = await runScript(connOf(entry), psListProcesses(), { timeoutMs: 60_000 })
194
+ if (result.exitCode !== 0) throw new Error(result.stdout || 'failed to list processes')
195
+ const items = parseJson<Array<{ id?: number; name?: string; cpu?: number | null; memMB?: number; startTime?: string | null; path?: string | null }>>(result.stdout)
196
+ return items.map(item => ({
197
+ id: item.id ?? 0,
198
+ name: item.name ?? '',
199
+ ...(item.cpu !== undefined && item.cpu !== null ? { cpu: item.cpu } : {}),
200
+ ...(item.memMB !== undefined && item.memMB !== null ? { memMB: item.memMB } : {}),
201
+ ...(item.startTime !== undefined && item.startTime !== null ? { startTime: item.startTime } : {}),
202
+ ...(item.path !== undefined && item.path !== null ? { path: item.path } : {}),
203
+ }))
204
+ })
205
+ }
206
+
207
+ /** Kill one process by id. */
208
+ async killProcess(alias: string, id: number): Promise<void> {
209
+ const entry = this.store.find(alias)
210
+ if (entry === undefined) throw new Error(`alias '${alias}' not found`)
211
+ return this.gate.run(alias, async () => {
212
+ const result = await runScript(connOf(entry), psKillProcess(id), { timeoutMs: 30_000 })
213
+ if (result.exitCode !== 0) throw new Error(result.stdout || 'failed to kill process')
214
+ })
215
+ }
216
+
217
+ // -------------------------------------------------------------------- ls
218
+
219
+ /** List a remote directory. */
220
+ async ls(alias: string, dir: string): Promise<RemoteDirEntry[]> {
221
+ const entry = this.store.find(alias)
222
+ if (entry === undefined) throw new Error(`alias '${alias}' not found`)
223
+ return this.gate.run(alias, async () => {
224
+ const result = await runScript(connOf(entry), psListDir(dir), { timeoutMs: 60_000 })
225
+ if (result.exitCode !== 0) throw new Error(result.stdout || `failed to list '${dir}'`)
226
+ const items = parseJson<Array<{ name?: string; type?: string; size?: number; mtimeMs?: number }>>(result.stdout)
227
+ return items.map(item => ({
228
+ name: item.name ?? '',
229
+ type: (item.type === 'dir' || item.type === 'file' ? item.type : 'other') as RemoteDirEntry['type'],
230
+ size: item.size ?? 0,
231
+ mtimeMs: item.mtimeMs ?? 0,
232
+ }))
233
+ })
234
+ }
235
+
236
+ // -------------------------------------------------------------- transfer
237
+
238
+ /** Upload one local file to a remote path. Defaults to SMB with WinRM fallback. */
239
+ async upload(alias: string, localPath: string, remotePath: string, onProgress?: (progress: TransferProgress) => void, channel: TransferChannel = 'auto'): Promise<{ bytes: number; channel?: TransferChannel }> {
240
+ const entry = this.store.find(alias)
241
+ if (entry === undefined) throw new Error(`alias '${alias}' not found`)
242
+ const { readFileSync } = await import('node:fs')
243
+ const data = readFileSync(localPath)
244
+ return this.gate.run(alias, async () => {
245
+ onProgress?.({ phase: 'connecting', file: remotePath, transferred: 0, total: data.length, percent: 0 })
246
+ if (channel !== 'winrm') {
247
+ try {
248
+ const outcome = await uploadSmb(entry, localPath, remotePath)
249
+ onProgress?.({ phase: 'done', file: remotePath, transferred: outcome.bytes, total: outcome.bytes, percent: 100 })
250
+ return { bytes: outcome.bytes, channel: outcome.channel }
251
+ } catch (error) {
252
+ if (channel === 'smb') throw error
253
+ onProgress?.({ phase: 'connecting', file: remotePath, transferred: 0, total: data.length, percent: 0 })
254
+ }
255
+ }
256
+ const bytes = await uploadBuffer(connOf(entry), remotePath, data, (transferred, total) => {
257
+ onProgress?.({ phase: 'transferring', file: remotePath, transferred, total, percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0 })
258
+ })
259
+ onProgress?.({ phase: 'done', file: remotePath, transferred: bytes, total: bytes, percent: 100 })
260
+ return { bytes, channel: 'winrm' }
261
+ })
262
+ }
263
+
264
+ /** Download one remote file to a local path. Defaults to SMB with WinRM fallback. */
265
+ async download(alias: string, remotePath: string, localPath: string, onProgress?: (progress: TransferProgress) => void, channel: TransferChannel = 'auto'): Promise<{ bytes: number; channel?: TransferChannel }> {
266
+ const entry = this.store.find(alias)
267
+ if (entry === undefined) throw new Error(`alias '${alias}' not found`)
268
+ return this.gate.run(alias, async () => {
269
+ onProgress?.({ phase: 'connecting', file: remotePath, transferred: 0, total: 0, percent: 0 })
270
+ if (channel !== 'winrm') {
271
+ try {
272
+ const outcome = await downloadSmb(entry, remotePath, localPath)
273
+ onProgress?.({ phase: 'done', file: remotePath, transferred: outcome.bytes, total: outcome.bytes, percent: 100 })
274
+ return { bytes: outcome.bytes, channel: outcome.channel }
275
+ } catch (error) {
276
+ if (channel === 'smb') throw error
277
+ onProgress?.({ phase: 'connecting', file: remotePath, transferred: 0, total: 0, percent: 0 })
278
+ }
279
+ }
280
+ const { createWriteStream } = await import('node:fs')
281
+ const sink = createWriteStream(localPath, { mode: 0o600 })
282
+ let bytes = 0
283
+ await new Promise<void>((resolve, reject) => {
284
+ sink.on('error', reject)
285
+ void downloadChunks(
286
+ connOf(entry),
287
+ remotePath,
288
+ (b64) => {
289
+ const buffer = Buffer.from(b64, 'base64')
290
+ bytes += buffer.length
291
+ sink.write(buffer)
292
+ },
293
+ (transferred, total) => onProgress?.({ phase: 'transferring', file: remotePath, transferred, total, percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0 }),
294
+ ).then(
295
+ () => sink.end(resolve),
296
+ (error) => { sink.destroy(); reject(error) },
297
+ )
298
+ })
299
+ onProgress?.({ phase: 'done', file: remotePath, transferred: bytes, total: bytes, percent: 100 })
300
+ return { bytes, channel: 'winrm' }
301
+ })
302
+ }
303
+
304
+ // --------------------------------------------------------------- console
305
+
306
+ /** Open a streaming PowerShell console session (standalone connection). */
307
+ async openConsole(alias: string): Promise<ConsoleSession> {
308
+ const entry = this.store.find(alias)
309
+ if (entry === undefined) throw new Error(`alias '${alias}' not found`)
310
+ const session = new ConsoleSession(connOf(entry))
311
+ await session.start()
312
+ return session
313
+ }
314
+
315
+ // ------------------------------------------------------------------ misc
316
+
317
+ /** Probe connectivity. */
318
+ async test(alias: string): Promise<TestResult> {
319
+ const entry = this.store.find(alias)
320
+ if (entry === undefined) return { ok: false, error: `alias '${alias}' not found` }
321
+ return this.gate.run(alias, async () => {
322
+ const result = await testConnection(connOf(entry))
323
+ return result
324
+ })
325
+ }
326
+
327
+ /** No pooled resources to close (WinRM is per-call HTTP). */
328
+ dispose(): void {
329
+ // Nothing to tear down: every operation owns its shell lifecycle.
330
+ }
331
+ }