@linxin666/dsh-ssh 0.1.1

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 (52) hide show
  1. package/README.md +75 -0
  2. package/cordis.patch.yml +12 -0
  3. package/lib/client.js +15638 -0
  4. package/lib/index.js +3297 -0
  5. package/lib/invariant.js +6 -0
  6. package/lib/types/client/api.d.ts +72 -0
  7. package/lib/types/client/index.d.ts +37 -0
  8. package/lib/types/client/locales.d.ts +137 -0
  9. package/lib/types/client/mount.d.ts +12 -0
  10. package/lib/types/client/panel/ClusterTab.d.ts +7 -0
  11. package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
  12. package/lib/types/client/panel/HostsTab.d.ts +9 -0
  13. package/lib/types/client/panel/SshPanel.d.ts +13 -0
  14. package/lib/types/client/panel/TerminalTab.d.ts +11 -0
  15. package/lib/types/client/panel/TransferTab.d.ts +7 -0
  16. package/lib/types/client/panel/TunnelsTab.d.ts +7 -0
  17. package/lib/types/client/panel/controller.d.ts +22 -0
  18. package/lib/types/client/panel/helpers.d.ts +14 -0
  19. package/lib/types/client/panel/xterm.css.d.ts +2 -0
  20. package/lib/types/client/sidebar-entry.d.ts +24 -0
  21. package/lib/types/engine.d.ts +135 -0
  22. package/lib/types/index.d.ts +40 -0
  23. package/lib/types/invariant.d.ts +3 -0
  24. package/lib/types/protocol.d.ts +189 -0
  25. package/lib/types/routes.d.ts +29 -0
  26. package/lib/types/store.d.ts +54 -0
  27. package/lib/types/tools.d.ts +18 -0
  28. package/package.json +106 -0
  29. package/src/client/api.ts +363 -0
  30. package/src/client/css-modules.d.ts +5 -0
  31. package/src/client/index.ts +68 -0
  32. package/src/client/locales.ts +284 -0
  33. package/src/client/mount.tsx +83 -0
  34. package/src/client/panel/ClusterTab.tsx +123 -0
  35. package/src/client/panel/HostFormDialog.tsx +221 -0
  36. package/src/client/panel/HostsTab.tsx +166 -0
  37. package/src/client/panel/SshPanel.tsx +77 -0
  38. package/src/client/panel/TerminalTab.tsx +176 -0
  39. package/src/client/panel/TransferTab.tsx +232 -0
  40. package/src/client/panel/TunnelsTab.tsx +177 -0
  41. package/src/client/panel/controller.ts +48 -0
  42. package/src/client/panel/helpers.ts +26 -0
  43. package/src/client/panel/panel.module.css +833 -0
  44. package/src/client/panel/xterm.css.ts +2 -0
  45. package/src/client/sidebar-entry.ts +123 -0
  46. package/src/engine.ts +780 -0
  47. package/src/index.ts +155 -0
  48. package/src/invariant.ts +4 -0
  49. package/src/protocol.ts +188 -0
  50. package/src/routes.ts +540 -0
  51. package/src/store.ts +344 -0
  52. package/src/tools.ts +371 -0
package/src/engine.ts ADDED
@@ -0,0 +1,780 @@
1
+ /**
2
+ * The SSH engine: a per-alias persistent connection pool (ssh2) with
3
+ * multi-hop jump support, command execution, PTY shells, SFTP transfers,
4
+ * local port-forward tunnels and cluster execution — the DSH counterpart of
5
+ * ssh-skill's daemon + scripts, living entirely in the host process.
6
+ */
7
+
8
+ import { createServer, type Server as NetServer } from 'node:net'
9
+ import { existsSync, mkdirSync, readFileSync, statSync, readdirSync } from 'node:fs'
10
+ import { dirname, join, relative, resolve as resolvePath } from 'node:path'
11
+ import { Client, type ClientChannel, type ConnectConfig } from 'ssh2'
12
+ import type { ClusterResult, ExecResult, SshHostEntry, SshHostSummary, TestResult, TransferProgress, TunnelInfo } from './protocol.ts'
13
+ import { expandHome, type HostStore } from './store.ts'
14
+
15
+ /** Default engine knobs. */
16
+ export interface EngineOptions {
17
+ /** Connections idle longer than this are closed (ms). */
18
+ idleTimeoutMs?: number
19
+ /** SSH handshake timeout (ms). */
20
+ connectTimeoutMs?: number
21
+ /** Keepalive ping interval (ms). */
22
+ keepaliveIntervalMs?: number
23
+ /** Cap on captured stdout/stderr bytes per exec (ms). */
24
+ maxOutputBytes?: number
25
+ /** Default exec timeout (ms). */
26
+ defaultExecTimeoutMs?: number
27
+ /** Default cluster concurrency. */
28
+ defaultMaxWorkers?: number
29
+ /** SFTP concurrent channel count for transfers. */
30
+ sftpConcurrency?: number
31
+ }
32
+
33
+ const DEFAULTS: Required<EngineOptions> = {
34
+ idleTimeoutMs: 30 * 60_000,
35
+ connectTimeoutMs: 15_000,
36
+ keepaliveIntervalMs: 15_000,
37
+ maxOutputBytes: 2 * 1024 * 1024,
38
+ defaultExecTimeoutMs: 60_000,
39
+ defaultMaxWorkers: 8,
40
+ sftpConcurrency: 8,
41
+ }
42
+
43
+ /** One pooled connection record. */
44
+ interface PoolRecord {
45
+ client: Client
46
+ /** Jump-chain clients kept alive under the target. */
47
+ hops: Client[]
48
+ idleAt: number
49
+ /** Pinned connections (tunnels) are never swept. */
50
+ pinned: boolean
51
+ broken: boolean
52
+ /** Operations currently running on this connection (sweep guard). */
53
+ inFlight: number
54
+ }
55
+
56
+ /** A live PTY shell session. */
57
+ export interface ShellSession {
58
+ /** Assign to receive remote output. */
59
+ onData?: (data: Buffer) => void
60
+ /** Assign to be notified when the channel closes. */
61
+ onExit?: (code: number | null, error?: string) => void
62
+ /** Write raw input to the shell. */
63
+ send(data: string): void
64
+ /** Resize the remote PTY. */
65
+ resize(cols: number, rows: number): void
66
+ /** Close the session and its channel. */
67
+ close(): void
68
+ /** Pause remote output delivery (transport backpressure). */
69
+ pause(): void
70
+ /** Resume remote output delivery. */
71
+ resume(): void
72
+ }
73
+
74
+ /** One active tunnel record (server + pinned client + live sockets). */
75
+ interface TunnelRecord {
76
+ info: TunnelInfo
77
+ server: NetServer
78
+ alias: string
79
+ sockets: Set<import('node:net').Socket>
80
+ }
81
+
82
+ /** Build the ssh2 connect config for one entry (key read from disk). */
83
+ function buildConnectConfig(entry: SshHostEntry, sock?: ConnectConfig['sock']): ConnectConfig {
84
+ const config: ConnectConfig = {
85
+ host: entry.host,
86
+ port: entry.port,
87
+ username: entry.user,
88
+ readyTimeout: 15_000,
89
+ keepaliveInterval: 15_000,
90
+ keepaliveCountMax: 3,
91
+ }
92
+ if (sock !== undefined) config.sock = sock
93
+ if (entry.auth.kind === 'password') {
94
+ config.password = entry.auth.password
95
+ } else {
96
+ const keyPath = entry.auth.keyPath === undefined ? undefined : expandHome(entry.auth.keyPath)
97
+ if (keyPath === undefined || !existsSync(keyPath)) {
98
+ throw new Error(`private key not found: '${entry.auth.keyPath ?? '(unset)'}'`)
99
+ }
100
+ config.privateKey = readFileSync(keyPath, 'utf8')
101
+ if (entry.auth.passphrase !== undefined && entry.auth.passphrase !== '') {
102
+ config.passphrase = entry.auth.passphrase
103
+ }
104
+ }
105
+ return config
106
+ }
107
+
108
+ /** Connect one ssh2 client (resolve on ready, reject on error/close). */
109
+ function connectClient(config: ConnectConfig): Promise<Client> {
110
+ return new Promise((resolve, reject) => {
111
+ const client = new Client()
112
+ let settled = false
113
+ client.once('ready', () => {
114
+ if (settled) return
115
+ settled = true
116
+ resolve(client)
117
+ })
118
+ client.once('error', (error) => {
119
+ if (settled) return
120
+ settled = true
121
+ reject(error instanceof Error ? error : new Error(String(error)))
122
+ })
123
+ try {
124
+ client.connect(config)
125
+ } catch (error) {
126
+ if (!settled) {
127
+ settled = true
128
+ reject(error instanceof Error ? error : new Error(String(error)))
129
+ }
130
+ }
131
+ })
132
+ }
133
+
134
+ /** Cap captured output at the configured byte budget (marks truncation). */
135
+ function appendOutput(target: { text: string; truncated: boolean }, chunk: Buffer, maxBytes: number): void {
136
+ if (target.truncated) return
137
+ if (target.text.length + chunk.length > maxBytes) {
138
+ let cut = chunk.toString('utf8').slice(0, maxBytes - target.text.length)
139
+ // Never split a surrogate pair at the cut boundary.
140
+ if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1)
141
+ target.text += cut + '…[output truncated]'
142
+ target.truncated = true
143
+ return
144
+ }
145
+ target.text += chunk.toString('utf8')
146
+ }
147
+
148
+ /** Walk a local directory, collecting relative paths of every file. */
149
+ function walkLocalDir(root: string): string[] {
150
+ const files: string[] = []
151
+ const visit = (dir: string): void => {
152
+ for (const name of readdirSync(dir)) {
153
+ const full = join(dir, name)
154
+ const stat = statSync(full)
155
+ if (stat.isDirectory()) visit(full)
156
+ else if (stat.isFile()) files.push(relative(root, full))
157
+ }
158
+ }
159
+ visit(root)
160
+ return files
161
+ }
162
+
163
+ /**
164
+ * The engine. Owns the pool, tunnels, and all operations. One instance per
165
+ * plugin apply; dispose() closes every connection.
166
+ */
167
+ export class SshEngine {
168
+ private readonly store: HostStore
169
+ private readonly opts: Required<EngineOptions>
170
+ private readonly pool = new Map<string, PoolRecord>()
171
+ private readonly tunnels = new Map<string, TunnelRecord>()
172
+ private sweepTimer: NodeJS.Timeout | undefined
173
+ private nextTunnelId = 1
174
+
175
+ /**
176
+ * @param store - the host config store.
177
+ * @param options - engine knobs (defaults applied).
178
+ */
179
+ constructor(store: HostStore, options?: EngineOptions) {
180
+ this.store = store
181
+ this.opts = { ...DEFAULTS, ...options }
182
+ this.sweepTimer = setInterval(() => this.sweep(), Math.max(10_000, this.opts.idleTimeoutMs / 4))
183
+ this.sweepTimer.unref?.()
184
+ }
185
+
186
+ // ---------------------------------------------------------------- config
187
+
188
+ /** Secret-free host list (filtered by the optional query). */
189
+ list(query?: string): SshHostSummary[] {
190
+ const needle = query?.trim().toLowerCase()
191
+ return this.store.list()
192
+ .filter(entry => needle === undefined || needle === ''
193
+ || entry.alias.toLowerCase().includes(needle)
194
+ || (entry.description ?? '').toLowerCase().includes(needle)
195
+ || entry.host.toLowerCase().includes(needle)
196
+ || entry.tags.some(tag => tag.toLowerCase().includes(needle)))
197
+ .map(entry => this.store.summarize(entry))
198
+ }
199
+
200
+ /** One host summary by alias. */
201
+ find(alias: string): SshHostSummary | undefined {
202
+ const entry = this.store.find(alias)
203
+ return entry === undefined ? undefined : this.store.summarize(entry)
204
+ }
205
+
206
+ // -------------------------------------------------------------- pool
207
+
208
+ /**
209
+ * Run `fn` with a live client for `alias`, reconnecting (up to the
210
+ * attempt budget) when the connection broke mid-flight.
211
+ */
212
+ private async withClient<T>(alias: string, fn: (client: Client) => Promise<T>, attempts = 3): Promise<T> {
213
+ let lastError: unknown
214
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
215
+ let record = this.pool.get(alias)
216
+ if (record === undefined || record.broken) {
217
+ if (record !== undefined) this.disposeRecord(alias, record)
218
+ record = await this.acquire(alias)
219
+ }
220
+ record.idleAt = Date.now()
221
+ record.inFlight += 1
222
+ try {
223
+ const result = await fn(record.client)
224
+ record.idleAt = Date.now()
225
+ return result
226
+ } finally {
227
+ record.inFlight -= 1
228
+ }
229
+ }
230
+ throw lastError instanceof Error ? lastError : new Error(String(lastError))
231
+ }
232
+
233
+ /**
234
+ * Build one full jump chain for an entry: hop clients connected through in
235
+ * order, each forwarding a stream to the next destination, ending with the
236
+ * target client. Shared by the pool and standalone shell sessions.
237
+ */
238
+ private async connectChain(entry: SshHostEntry): Promise<{ client: Client; hops: Client[] }> {
239
+ const hops: Client[] = []
240
+ let sock: ConnectConfig['sock']
241
+ const chain = entry.proxyJump
242
+ for (let index = 0; index < chain.length; index += 1) {
243
+ const hopAlias = chain[index]
244
+ const hop = this.store.find(hopAlias)
245
+ if (hop === undefined) {
246
+ for (const client of hops) client.end()
247
+ throw new Error(`proxyJump alias '${hopAlias}' not found — create it first`)
248
+ }
249
+ const hopClient = await connectClient(buildConnectConfig(hop, sock))
250
+ hops.push(hopClient)
251
+ const next = index + 1 < chain.length ? this.store.find(chain[index + 1]) : undefined
252
+ const nextHost = next !== undefined ? next.host : entry.host
253
+ const nextPort = next !== undefined ? next.port : entry.port
254
+ sock = await new Promise<ConnectConfig['sock']>((resolve, reject) => {
255
+ hopClient.forwardOut('127.0.0.1', 0, nextHost, nextPort, (error, stream) => {
256
+ if (error !== undefined) {
257
+ for (const client of hops) client.end()
258
+ reject(error)
259
+ } else {
260
+ resolve(stream)
261
+ }
262
+ })
263
+ })
264
+ }
265
+ try {
266
+ const client = await connectClient(buildConnectConfig(entry, sock))
267
+ return { client, hops }
268
+ } catch (error) {
269
+ for (const client of hops) client.end()
270
+ throw error
271
+ }
272
+ }
273
+
274
+ /** In-flight acquire promises, deduped per alias (concurrent first use). */
275
+ private readonly acquireQueue = new Map<string, Promise<PoolRecord>>()
276
+
277
+ /** Connect (or reuse) the pooled chain for one alias; pins nothing. */
278
+ private async acquire(alias: string): Promise<PoolRecord> {
279
+ const pending = this.acquireQueue.get(alias)
280
+ if (pending !== undefined) return pending
281
+ const task = this.doAcquire(alias)
282
+ this.acquireQueue.set(alias, task)
283
+ try {
284
+ return await task
285
+ } finally {
286
+ if (this.acquireQueue.get(alias) === task) this.acquireQueue.delete(alias)
287
+ }
288
+ }
289
+
290
+ private async doAcquire(alias: string): Promise<PoolRecord> {
291
+ const entry = this.store.find(alias)
292
+ if (entry === undefined) throw new Error(`alias '${alias}' not found — add it first`)
293
+ const { client, hops } = await this.connectChain(entry)
294
+ const record: PoolRecord = { client, hops, idleAt: Date.now(), pinned: false, broken: false, inFlight: 0 }
295
+ client.on('error', () => { record.broken = true })
296
+ client.on('close', () => { record.broken = true })
297
+ this.pool.set(alias, record)
298
+ return record
299
+ }
300
+
301
+ /**
302
+ * Tear down one alias's record. When `record` is given and no longer the
303
+ * pooled record for the alias (a concurrent acquire replaced it), nothing
304
+ * is torn down — the connection belongs to someone else now.
305
+ */
306
+ private disposeRecord(alias: string, record?: PoolRecord): void {
307
+ const current = this.pool.get(alias)
308
+ if (record !== undefined && current !== record) return
309
+ if (current === undefined) return
310
+ this.pool.delete(alias)
311
+ try { current.client.end() } catch { /* already closed */ }
312
+ for (const hop of current.hops) {
313
+ try { hop.end() } catch { /* already closed */ }
314
+ }
315
+ }
316
+
317
+ /** Close connections idle beyond the threshold (skips pinned and in-flight). */
318
+ private sweep(): void {
319
+ const cutoff = Date.now() - this.opts.idleTimeoutMs
320
+ for (const [alias, record] of this.pool) {
321
+ if (!record.pinned && record.inFlight === 0 && record.idleAt < cutoff) {
322
+ this.disposeRecord(alias, record)
323
+ }
324
+ }
325
+ }
326
+
327
+ // --------------------------------------------------------------- exec
328
+
329
+ /** Run one command on `alias` (reusing the pooled connection). */
330
+ async exec(alias: string, command: string, timeoutMs?: number): Promise<ExecResult> {
331
+ const started = Date.now()
332
+ const budget = timeoutMs !== undefined && timeoutMs > 0 ? timeoutMs : this.opts.defaultExecTimeoutMs
333
+ return this.withClient(alias, async (client) => {
334
+ return await new Promise<ExecResult>((resolve, reject) => {
335
+ client.exec(command, (error, stream) => {
336
+ if (error !== undefined) {
337
+ reject(error)
338
+ return
339
+ }
340
+ const stdout = { text: '', truncated: false }
341
+ const stderr = { text: '', truncated: false }
342
+ let timedOut = false
343
+ let settled = false
344
+ const finish = (): void => {
345
+ if (settled) return
346
+ settled = true
347
+ clearTimeout(timer)
348
+ resolve({
349
+ success: false,
350
+ exitCode: null,
351
+ timedOut,
352
+ stdout: stdout.text,
353
+ stderr: stderr.text,
354
+ durationMs: Date.now() - started,
355
+ error: timedOut ? `command timed out after ${budget} ms` : undefined,
356
+ })
357
+ }
358
+ const timer = setTimeout(() => {
359
+ timedOut = true
360
+ try { stream.signal('KILL') } catch { /* channel gone */ }
361
+ try { stream.close() } catch { /* channel gone */ }
362
+ // Hard deadline: settle now even if the peer never acks the
363
+ // channel close (the stream 'close' handler is then a no-op).
364
+ finish()
365
+ }, budget)
366
+ stream.on('data', (chunk: Buffer) => appendOutput(stdout, chunk, this.opts.maxOutputBytes))
367
+ stream.stderr.on('data', (chunk: Buffer) => appendOutput(stderr, chunk, this.opts.maxOutputBytes))
368
+ stream.on('close', (code: number | null) => {
369
+ if (settled) return
370
+ settled = true
371
+ clearTimeout(timer)
372
+ resolve({
373
+ success: code === 0 && !timedOut,
374
+ exitCode: code,
375
+ timedOut,
376
+ stdout: stdout.text,
377
+ stderr: stderr.text,
378
+ durationMs: Date.now() - started,
379
+ })
380
+ })
381
+ stream.on('error', (streamError: Error) => {
382
+ if (settled) return
383
+ settled = true
384
+ clearTimeout(timer)
385
+ reject(streamError)
386
+ })
387
+ })
388
+ })
389
+ })
390
+ }
391
+
392
+ /** Run one command against many hosts concurrently. */
393
+ async cluster(options: {
394
+ command: string
395
+ aliases?: string[]
396
+ environment?: string
397
+ tags?: string[]
398
+ timeoutMs?: number
399
+ maxWorkers?: number
400
+ }): Promise<ClusterResult[]> {
401
+ let targets = this.store.list()
402
+ if (options.aliases !== undefined && options.aliases.length > 0) {
403
+ targets = targets.filter(entry => options.aliases!.includes(entry.alias))
404
+ }
405
+ if (options.environment !== undefined && options.environment !== '') {
406
+ targets = targets.filter(entry => entry.environment === options.environment)
407
+ }
408
+ if (options.tags !== undefined && options.tags.length > 0) {
409
+ // ALL semantics (matches the ssh_cluster tool description).
410
+ targets = targets.filter(entry => options.tags!.every(tag => entry.tags.includes(tag)))
411
+ }
412
+ if (targets.length === 0) return []
413
+ if (options.maxWorkers !== undefined && (!Number.isInteger(options.maxWorkers) || options.maxWorkers < 1)) {
414
+ throw new Error('maxWorkers must be a positive integer')
415
+ }
416
+ const workers = Math.min(this.opts.defaultMaxWorkers, options.maxWorkers ?? this.opts.defaultMaxWorkers, targets.length)
417
+ const results: ClusterResult[] = []
418
+ const queue = [...targets]
419
+ const run = async (): Promise<void> => {
420
+ while (queue.length > 0) {
421
+ const entry = queue.shift()!
422
+ try {
423
+ const result = await this.exec(entry.alias, options.command, options.timeoutMs)
424
+ results.push({ alias: entry.alias, ok: result.success, exitCode: result.exitCode, timedOut: result.timedOut, stdout: result.stdout, stderr: result.stderr, durationMs: result.durationMs })
425
+ } catch (error) {
426
+ results.push({ alias: entry.alias, ok: false, error: error instanceof Error ? error.message : String(error) })
427
+ }
428
+ }
429
+ }
430
+ await Promise.all(Array.from({ length: workers }, () => run()))
431
+ return results
432
+ }
433
+
434
+ // -------------------------------------------------------------- shell
435
+
436
+ /** Open a PTY shell session for the web terminal (standalone connection). */
437
+ async openShell(alias: string, size: { cols: number; rows: number }): Promise<ShellSession> {
438
+ const entry = this.store.find(alias)
439
+ if (entry === undefined) throw new Error(`alias '${alias}' not found — add it first`)
440
+ // The shell is a long-lived exclusive stream: use its own connection so
441
+ // closing it can never tear down a pooled exec/tunnel sharing the alias.
442
+ const { client, hops } = await this.connectChain(entry)
443
+ return await new Promise<ShellSession>((resolve, reject) => {
444
+ client.shell({ term: 'xterm-256color', cols: size.cols, rows: size.rows }, (error, stream) => {
445
+ if (error !== undefined) {
446
+ try { client.end() } catch { /* closed */ }
447
+ for (const hop of hops) { try { hop.end() } catch { /* closed */ } }
448
+ reject(error)
449
+ return
450
+ }
451
+ let tornDown = false
452
+ const teardown = (): void => {
453
+ if (tornDown) return
454
+ tornDown = true
455
+ try { client.end() } catch { /* closed */ }
456
+ for (const hop of hops) { try { hop.end() } catch { /* closed */ } }
457
+ }
458
+ const session: ShellSession = {
459
+ send: (data) => { try { stream.write(data) } catch { /* channel gone */ } },
460
+ resize: (cols, rows) => { try { stream.setWindow(rows, cols, rows, cols) } catch { /* channel gone */ } },
461
+ close: () => {
462
+ try { stream.close() } catch { /* channel gone */ }
463
+ teardown()
464
+ },
465
+ pause: () => { try { stream.pause() } catch { /* channel gone */ } },
466
+ resume: () => { try { stream.resume() } catch { /* channel gone */ } },
467
+ }
468
+ stream.on('data', (chunk: Buffer) => { session.onData?.(chunk) })
469
+ stream.on('close', (code: number | null) => {
470
+ teardown()
471
+ session.onExit?.(code)
472
+ })
473
+ stream.on('error', (streamError: Error) => {
474
+ teardown()
475
+ session.onExit?.(null, streamError instanceof Error ? streamError.message : String(streamError))
476
+ })
477
+ resolve(session)
478
+ })
479
+ })
480
+ }
481
+
482
+ // -------------------------------------------------------------- sftp
483
+
484
+ /** Upload one local file (or directory tree) to a remote path. */
485
+ async upload(alias: string, localPath: string, remotePath: string, recursive: boolean, onProgress?: (progress: TransferProgress) => void): Promise<{ bytes: number; files: number }> {
486
+ // Remote paths must be absolute: the mkdir chain and fastPut must agree
487
+ // on one resolution (relative paths previously created dirs at the root).
488
+ if (!remotePath.startsWith('/')) {
489
+ throw new Error(`remotePath must be an absolute path (got '${remotePath}')`)
490
+ }
491
+ const local = resolvePath(localPath)
492
+ if (!existsSync(local)) throw new Error(`local path not found: '${localPath}'`)
493
+ return this.withClient(alias, async (client) => {
494
+ const sftp = await this.sftp(client)
495
+ const stat = statSync(local)
496
+ let files: string[]
497
+ if (stat.isDirectory()) {
498
+ if (!recursive) throw new Error(`'${localPath}' is a directory — enable recursive upload`)
499
+ files = walkLocalDir(local)
500
+ await this.ensureRemoteDir(sftp, remotePath)
501
+ } else {
502
+ files = ['']
503
+ await this.ensureRemoteDir(sftp, dirname(remotePath))
504
+ }
505
+ let bytes = 0
506
+ for (const rel of files) {
507
+ const src = rel === '' ? local : join(local, rel)
508
+ // Remote paths always use forward slashes; normalize any OS separators.
509
+ const remoteRel = rel.split(/[\\/]/).join('/')
510
+ const dst = rel === '' ? remotePath : remotePath.replace(/\/$/, '') + '/' + remoteRel
511
+ await this.fastPut(sftp, src, dst, onProgress)
512
+ bytes += statSync(src).size
513
+ }
514
+ return { bytes, files: files.length }
515
+ })
516
+ }
517
+
518
+ /** Download one remote file to a local path. */
519
+ async download(alias: string, remotePath: string, localPath: string, onProgress?: (progress: TransferProgress) => void): Promise<{ bytes: number }> {
520
+ return this.withClient(alias, async (client) => {
521
+ const sftp = await this.sftp(client)
522
+ const stat = await new Promise<{ isDirectory: () => boolean }>((resolve, reject) => {
523
+ sftp.stat(remotePath, (error, stats) => error !== undefined ? reject(error) : resolve(stats))
524
+ })
525
+ if (stat.isDirectory()) {
526
+ throw new Error(`'${remotePath}' is a directory — directory download is not supported yet (download individual files)`)
527
+ }
528
+ const local = resolvePath(localPath)
529
+ if (!existsSync(dirname(local))) mkdirSync(dirname(local), { recursive: true })
530
+ await this.fastGet(sftp, remotePath, local, onProgress)
531
+ return { bytes: statSync(local).size }
532
+ })
533
+ }
534
+
535
+ /** List a remote directory (file browser). */
536
+ async ls(alias: string, path: string): Promise<import('./protocol.ts').RemoteDirEntry[]> {
537
+ return this.withClient(alias, async (client) => {
538
+ const sftp = await this.sftp(client)
539
+ return await new Promise((resolve, reject) => {
540
+ sftp.readdir(path, (error, list) => {
541
+ if (error !== undefined) {
542
+ reject(error)
543
+ return
544
+ }
545
+ const entries: import('./protocol.ts').RemoteDirEntry[] = list.map(item => ({
546
+ name: item.filename,
547
+ type: item.attrs.isDirectory() ? 'dir' : item.attrs.isFile() ? 'file' : 'other',
548
+ size: item.attrs.size,
549
+ mtimeMs: item.attrs.mtime * 1000,
550
+ mode: item.attrs.mode,
551
+ }))
552
+ resolve(entries)
553
+ })
554
+ })
555
+ })
556
+ }
557
+
558
+ private sftp(client: Client): Promise<import('ssh2').SFTPWrapper> {
559
+ return new Promise((resolve, reject) => {
560
+ client.sftp((error, sftp) => error !== undefined ? reject(error) : resolve(sftp))
561
+ })
562
+ }
563
+
564
+ /** Create a remote directory chain (stat-then-mkdir per segment). */
565
+ private ensureRemoteDir(sftp: import('ssh2').SFTPWrapper, remote: string): Promise<void> {
566
+ return new Promise((resolve, reject) => {
567
+ const segments = remote.replace(/^\/+/, '').split('/').filter(segment => segment !== '')
568
+ const walk = (index: number): void => {
569
+ if (index >= segments.length) {
570
+ resolve()
571
+ return
572
+ }
573
+ const current = '/' + segments.slice(0, index + 1).join('/')
574
+ sftp.stat(current, (statError) => {
575
+ if (statError === undefined) {
576
+ walk(index + 1)
577
+ return
578
+ }
579
+ // Statting a missing path fails; mkdir it (idempotent because the
580
+ // stat check runs first — some sftp servers throw on EEXIST).
581
+ sftp.mkdir(current, (mkdirError) => {
582
+ if (mkdirError !== undefined) {
583
+ reject(mkdirError)
584
+ return
585
+ }
586
+ walk(index + 1)
587
+ })
588
+ })
589
+ }
590
+ walk(0)
591
+ })
592
+ }
593
+
594
+ private fastPut(sftp: import('ssh2').SFTPWrapper, src: string, dst: string, onProgress?: (progress: TransferProgress) => void): Promise<void> {
595
+ return new Promise((resolve, reject) => {
596
+ let last = 0
597
+ let lastEmit = 0
598
+ const started = Date.now()
599
+ onProgress?.({ phase: 'transferring', file: dst, transferred: 0, total: statSync(src).size, percent: 0 })
600
+ sftp.fastPut(src, dst, { concurrency: this.opts.sftpConcurrency, step: (transferred: number, _chunk: number, total: number) => {
601
+ const now = Date.now()
602
+ // Throttle: high-speed links fire one callback per chunk; the UI only
603
+ // needs ~10 frames per second.
604
+ if (now - lastEmit < 100 && transferred < total) return
605
+ lastEmit = now
606
+ const elapsed = (now - started) / 1000
607
+ onProgress?.({
608
+ phase: 'transferring',
609
+ file: dst,
610
+ transferred,
611
+ total,
612
+ percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
613
+ speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : undefined,
614
+ })
615
+ last = transferred
616
+ } }, (error) => {
617
+ if (error !== undefined) {
618
+ onProgress?.({ phase: 'error', file: dst, transferred: 0, total: 0, percent: 0, error: String(error) })
619
+ reject(error)
620
+ } else {
621
+ onProgress?.({ phase: 'done', file: dst, transferred: statSync(src).size, total: statSync(src).size, percent: 100 })
622
+ resolve()
623
+ }
624
+ })
625
+ })
626
+ }
627
+
628
+ private fastGet(sftp: import('ssh2').SFTPWrapper, src: string, dst: string, onProgress?: (progress: TransferProgress) => void): Promise<void> {
629
+ return new Promise((resolve, reject) => {
630
+ let last = 0
631
+ let lastEmit = 0
632
+ const started = Date.now()
633
+ sftp.fastGet(src, dst, { concurrency: this.opts.sftpConcurrency, step: (transferred: number, _chunk: number, total: number) => {
634
+ const now = Date.now()
635
+ if (now - lastEmit < 100 && transferred < total) return
636
+ lastEmit = now
637
+ const elapsed = (now - started) / 1000
638
+ onProgress?.({
639
+ phase: 'transferring',
640
+ file: src,
641
+ transferred,
642
+ total,
643
+ percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
644
+ speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : undefined,
645
+ })
646
+ last = transferred
647
+ } }, (error) => {
648
+ if (error !== undefined) {
649
+ onProgress?.({ phase: 'error', file: src, transferred: 0, total: 0, percent: 0, error: String(error) })
650
+ reject(error)
651
+ } else {
652
+ onProgress?.({ phase: 'done', file: src, transferred: statSync(dst).size, total: statSync(dst).size, percent: 100 })
653
+ resolve()
654
+ }
655
+ })
656
+ })
657
+ }
658
+
659
+ // ------------------------------------------------------------- tunnel
660
+
661
+ /** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
662
+ async startTunnel(alias: string, options: { remotePort: number; remoteHost?: string; localPort?: number }): Promise<TunnelInfo> {
663
+ if (!Number.isInteger(options.remotePort) || options.remotePort < 1 || options.remotePort > 65535) {
664
+ throw new Error('remotePort must be an integer in 1..65535')
665
+ }
666
+ if (options.localPort !== undefined && (!Number.isInteger(options.localPort) || options.localPort < 1 || options.localPort > 65535)) {
667
+ throw new Error('localPort must be an integer in 1..65535')
668
+ }
669
+ const entry = this.store.find(alias)
670
+ if (entry === undefined) throw new Error(`alias '${alias}' not found — add it first`)
671
+ const remoteHost = options.remoteHost ?? '127.0.0.1'
672
+ const id = `tun-${this.nextTunnelId++}`
673
+ const info: TunnelInfo = {
674
+ id,
675
+ alias,
676
+ localPort: 0,
677
+ remoteHost,
678
+ remotePort: options.remotePort,
679
+ state: 'connecting',
680
+ startedAt: Date.now(),
681
+ }
682
+ const record = await this.acquire(alias)
683
+ const client = record.client
684
+ const sockets = new Set<import('node:net').Socket>()
685
+ const server = createServer((socket) => {
686
+ sockets.add(socket)
687
+ socket.on('close', () => { sockets.delete(socket) })
688
+ client.forwardOut('127.0.0.1', 0, remoteHost, options.remotePort, (error, stream) => {
689
+ if (error !== undefined) {
690
+ socket.destroy()
691
+ return
692
+ }
693
+ // Both ends of the pipe can die independently; destroy the pair so an
694
+ // unhandled 'error' event can never crash the host process.
695
+ const destroy = (): void => {
696
+ try { socket.destroy() } catch { /* gone */ }
697
+ try { stream.close() } catch { /* gone */ }
698
+ }
699
+ stream.on('error', destroy)
700
+ socket.on('error', destroy)
701
+ stream.on('close', destroy)
702
+ socket.on('close', destroy)
703
+ stream.pipe(socket).pipe(stream)
704
+ })
705
+ })
706
+ try {
707
+ await new Promise<void>((resolve, reject) => {
708
+ server.once('error', reject)
709
+ server.listen(options.localPort ?? 0, '127.0.0.1', () => {
710
+ server.removeListener('error', reject)
711
+ resolve()
712
+ })
713
+ })
714
+ } catch (error) {
715
+ // Roll back: never leave an unpinned orphan connection behind.
716
+ if (!record.pinned && record.inFlight === 0) this.disposeRecord(alias, record)
717
+ throw error
718
+ }
719
+ record.pinned = true
720
+ const address = server.address()
721
+ info.localPort = typeof address === 'object' && address !== null ? address.port : 0
722
+ info.state = 'forwarding'
723
+ this.tunnels.set(id, { info, server, alias, sockets })
724
+ return info
725
+ }
726
+
727
+ /** All active tunnels. */
728
+ listTunnels(): TunnelInfo[] {
729
+ return [...this.tunnels.values()].map(tunnel => ({ ...tunnel.info }))
730
+ }
731
+
732
+ /** Stop one tunnel (closes the listener, live sockets, and the pinned connection). */
733
+ stopTunnel(id: string): boolean {
734
+ const tunnel = this.tunnels.get(id)
735
+ if (tunnel === undefined) return false
736
+ this.tunnels.delete(id)
737
+ try { tunnel.server.close() } catch { /* already closed */ }
738
+ for (const socket of tunnel.sockets) {
739
+ try { socket.destroy() } catch { /* already closed */ }
740
+ }
741
+ tunnel.sockets.clear()
742
+ this.disposeRecord(tunnel.alias)
743
+ return true
744
+ }
745
+
746
+ /** Stop all tunnels (optionally for one alias). */
747
+ stopAllTunnels(alias?: string): number {
748
+ let count = 0
749
+ for (const [id, tunnel] of [...this.tunnels]) {
750
+ if (alias === undefined || tunnel.alias === alias) {
751
+ this.stopTunnel(id)
752
+ count += 1
753
+ }
754
+ }
755
+ return count
756
+ }
757
+
758
+ // ------------------------------------------------------------- misc
759
+
760
+ /** Probe connectivity: connect, run `true`, close. */
761
+ async test(alias: string): Promise<TestResult> {
762
+ const started = Date.now()
763
+ try {
764
+ const result = await this.exec(alias, 'true', 10_000)
765
+ return result.success
766
+ ? { ok: true, latencyMs: result.durationMs }
767
+ : { ok: false, latencyMs: result.durationMs, error: `remote exit code ${result.exitCode}` }
768
+ } catch (error) {
769
+ return { ok: false, latencyMs: Date.now() - started, error: error instanceof Error ? error.message : String(error) }
770
+ }
771
+ }
772
+
773
+ /** Close every pooled connection and tunnel. */
774
+ dispose(): void {
775
+ if (this.sweepTimer !== undefined) clearInterval(this.sweepTimer)
776
+ for (const id of [...this.tunnels.keys()]) this.stopTunnel(id)
777
+ for (const alias of [...this.pool.keys()]) this.disposeRecord(alias)
778
+ }
779
+ }
780
+