@linxin666/dsh-ssh 0.1.14 → 0.1.15

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.
package/src/engine.ts CHANGED
@@ -1,176 +1,43 @@
1
1
  /**
2
- * The SSH engine: a per-alias persistent connection pool (ssh2) with
2
+ * The SSH engine facade: a per-alias persistent connection pool (ssh2) with
3
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.
4
+ * local port-forward tunnels and cluster execution. The heavy lifting lives
5
+ * in the engine/ modules (connection-pool, pty, sftp, tunnel, cluster); this
6
+ * class composes them behind one new SshEngine instance per plugin apply.
6
7
  */
7
8
 
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, type SFTPWrapper } 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
- }
9
+ import type { ClusterResult, ExecResult, SshHostSummary, TestResult, TransferProgress, TunnelInfo } from './protocol.ts'
10
+ import type { HostStore } from './store.ts'
11
+ import {
12
+ DEFAULTS,
13
+ execCommand,
14
+ disposeRecord,
15
+ sweepPool,
16
+ type EngineOptions,
17
+ type PoolRecord,
18
+ } from './engine/connection-pool.ts'
19
+ import { openShell, type ShellSession } from './engine/pty.ts'
20
+ import { download, ls, upload } from './engine/sftp.ts'
21
+ import { listTunnels, startTunnel, stopAllTunnels, stopTunnel, type TunnelRecord } from './engine/tunnel.ts'
22
+ import { cluster } from './engine/cluster.ts'
23
+
24
+ export type { EngineOptions } from './engine/connection-pool.ts'
25
+ export type { ShellSession } from './engine/pty.ts'
26
+ export type { PoolRecord } from './engine/connection-pool.ts'
27
+ export type { TunnelRecord } from './engine/tunnel.ts'
162
28
 
163
29
  /**
164
30
  * The engine. Owns the pool, tunnels, and all operations. One instance per
165
31
  * plugin apply; dispose() closes every connection.
166
32
  */
167
33
  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>()
34
+ readonly store: HostStore
35
+ readonly opts: Required<EngineOptions>
36
+ readonly pool = new Map<string, PoolRecord>()
37
+ readonly acquireQueue = new Map<string, Promise<PoolRecord>>()
38
+ readonly tunnels = new Map<string, TunnelRecord>()
39
+ nextTunnelId = 1
172
40
  private sweepTimer: NodeJS.Timeout | undefined
173
- private nextTunnelId = 1
174
41
 
175
42
  /**
176
43
  * @param store - the host config store.
@@ -179,7 +46,7 @@ export class SshEngine {
179
46
  constructor(store: HostStore, options?: EngineOptions) {
180
47
  this.store = store
181
48
  this.opts = { ...DEFAULTS, ...options }
182
- this.sweepTimer = setInterval(() => this.sweep(), Math.max(10_000, this.opts.idleTimeoutMs / 4))
49
+ this.sweepTimer = setInterval(() => sweepPool(this), Math.max(10_000, this.opts.idleTimeoutMs / 4))
183
50
  this.sweepTimer.unref?.()
184
51
  }
185
52
 
@@ -203,190 +70,11 @@ export class SshEngine {
203
70
  return entry === undefined ? undefined : this.store.summarize(entry)
204
71
  }
205
72
 
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
73
+ // ------------------------------------------------------------ exec
328
74
 
329
75
  /** Run one command on `alias` (reusing the pooled connection). */
330
76
  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
- })
77
+ return execCommand(this, alias, command, timeoutMs)
390
78
  }
391
79
 
392
80
  /** Run one command against many hosts concurrently. */
@@ -398,382 +86,53 @@ export class SshEngine {
398
86
  timeoutMs?: number
399
87
  maxWorkers?: number
400
88
  }): 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
89
+ return cluster(this, options)
432
90
  }
433
91
 
434
92
  // -------------------------------------------------------------- shell
435
93
 
436
94
  /** Open a PTY shell session for the web terminal (standalone connection). */
437
95
  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
- })
96
+ return openShell(this, alias, size)
480
97
  }
481
98
 
482
99
  // -------------------------------------------------------------- sftp
483
100
 
484
101
  /** Upload one local file (or directory tree) to a remote path. */
485
102
  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, (client) => this.withSftp(client, async (sftp) => {
494
- const stat = statSync(local)
495
- let files: string[]
496
- if (stat.isDirectory()) {
497
- if (!recursive) throw new Error(`'${localPath}' is a directory — enable recursive upload`)
498
- files = walkLocalDir(local)
499
- await this.ensureRemoteDir(sftp, remotePath)
500
- } else {
501
- files = ['']
502
- await this.ensureRemoteDir(sftp, dirname(remotePath))
503
- }
504
- let bytes = 0
505
- for (const rel of files) {
506
- const src = rel === '' ? local : join(local, rel)
507
- // Remote paths always use forward slashes; normalize any OS separators.
508
- const remoteRel = rel.split(/[\\/]/).join('/')
509
- const dst = rel === '' ? remotePath : remotePath.replace(/\/$/, '') + '/' + remoteRel
510
- await this.fastPut(sftp, src, dst, onProgress)
511
- bytes += statSync(src).size
512
- }
513
- return { bytes, files: files.length }
514
- }))
103
+ return upload(this, alias, localPath, remotePath, recursive, onProgress)
515
104
  }
516
105
 
517
106
  /** Download one remote file to a local path. */
518
107
  async download(alias: string, remotePath: string, localPath: string, onProgress?: (progress: TransferProgress) => void): Promise<{ bytes: number }> {
519
- return this.withClient(alias, (client) => this.withSftp(client, async (sftp) => {
520
- const stat = await new Promise<{ isDirectory: () => boolean }>((resolve, reject) => {
521
- sftp.stat(remotePath, (error, stats) => error !== undefined ? reject(error) : resolve(stats))
522
- })
523
- if (stat.isDirectory()) {
524
- throw new Error(`'${remotePath}' is a directory — directory download is not supported yet (download individual files)`)
525
- }
526
- const local = resolvePath(localPath)
527
- if (!existsSync(dirname(local))) mkdirSync(dirname(local), { recursive: true })
528
- await this.fastGet(sftp, remotePath, local, onProgress)
529
- return { bytes: statSync(local).size }
530
- }))
108
+ return download(this, alias, remotePath, localPath, onProgress)
531
109
  }
532
110
 
533
111
  /** List a remote directory (file browser). */
534
112
  async ls(alias: string, path: string): Promise<import('./protocol.ts').RemoteDirEntry[]> {
535
- return this.withClient(alias, (client) => this.withSftp(client, async (sftp) => {
536
- return await new Promise((resolve, reject) => {
537
- sftp.readdir(path, (error, list) => {
538
- if (error !== undefined) {
539
- reject(error)
540
- return
541
- }
542
- const entries: import('./protocol.ts').RemoteDirEntry[] = list.map(item => ({
543
- name: item.filename,
544
- type: item.attrs.isDirectory() ? 'dir' : item.attrs.isFile() ? 'file' : 'other',
545
- size: item.attrs.size,
546
- mtimeMs: item.attrs.mtime * 1000,
547
- mode: item.attrs.mode,
548
- }))
549
- resolve(entries)
550
- })
551
- })
552
- }))
553
- }
554
-
555
- /**
556
- * Open one SFTP channel, run the operation, and release the channel exactly
557
- * once when the operation settles (success or error). ssh2 keeps each
558
- * subsystem channel open until end(); without this, every transfer leaks a
559
- * channel until sshd's MaxSessions cap makes all later opens fail.
560
- */
561
- private async withSftp<T>(client: Client, run: (sftp: SFTPWrapper) => Promise<T>): Promise<T> {
562
- const sftp = await this.sftp(client)
563
- let ended = false
564
- const endOnce = (): void => {
565
- if (ended) return
566
- ended = true
567
- try { sftp.end() } catch { /* channel already closed */ }
568
- }
569
- // The channel can also close underneath us (peer reset, timeout); the
570
- // guard makes the finally below a no-op instead of ending it twice.
571
- sftp.once('close', endOnce)
572
- try {
573
- return await run(sftp)
574
- } finally {
575
- endOnce()
576
- }
577
- }
578
-
579
- private sftp(client: Client): Promise<SFTPWrapper> {
580
- return new Promise((resolve, reject) => {
581
- client.sftp((error, sftp) => error !== undefined ? reject(error) : resolve(sftp))
582
- })
583
- }
584
-
585
- /** Create a remote directory chain (stat-then-mkdir per segment). */
586
- private ensureRemoteDir(sftp: SFTPWrapper, remote: string): Promise<void> {
587
- return new Promise((resolve, reject) => {
588
- const segments = remote.replace(/^\/+/, '').split('/').filter(segment => segment !== '')
589
- const walk = (index: number): void => {
590
- if (index >= segments.length) {
591
- resolve()
592
- return
593
- }
594
- const current = '/' + segments.slice(0, index + 1).join('/')
595
- sftp.stat(current, (statError) => {
596
- if (statError === undefined) {
597
- walk(index + 1)
598
- return
599
- }
600
- // Statting a missing path fails; mkdir it (idempotent because the
601
- // stat check runs first — some sftp servers throw on EEXIST).
602
- sftp.mkdir(current, (mkdirError) => {
603
- if (mkdirError !== undefined) {
604
- reject(mkdirError)
605
- return
606
- }
607
- walk(index + 1)
608
- })
609
- })
610
- }
611
- walk(0)
612
- })
613
- }
614
-
615
- private fastPut(sftp: SFTPWrapper, src: string, dst: string, onProgress?: (progress: TransferProgress) => void): Promise<void> {
616
- return new Promise((resolve, reject) => {
617
- let last = 0
618
- let lastEmit = 0
619
- const started = Date.now()
620
- onProgress?.({ phase: 'transferring', file: dst, transferred: 0, total: statSync(src).size, percent: 0 })
621
- sftp.fastPut(src, dst, { concurrency: this.opts.sftpConcurrency, step: (transferred: number, _chunk: number, total: number) => {
622
- const now = Date.now()
623
- // Throttle: high-speed links fire one callback per chunk; the UI only
624
- // needs ~10 frames per second.
625
- if (now - lastEmit < 100 && transferred < total) return
626
- lastEmit = now
627
- const elapsed = (now - started) / 1000
628
- onProgress?.({
629
- phase: 'transferring',
630
- file: dst,
631
- transferred,
632
- total,
633
- percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
634
- speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : undefined,
635
- })
636
- last = transferred
637
- } }, (error) => {
638
- if (error !== undefined) {
639
- onProgress?.({ phase: 'error', file: dst, transferred: 0, total: 0, percent: 0, error: String(error) })
640
- reject(error)
641
- } else {
642
- onProgress?.({ phase: 'done', file: dst, transferred: statSync(src).size, total: statSync(src).size, percent: 100 })
643
- resolve()
644
- }
645
- })
646
- })
647
- }
648
-
649
- private fastGet(sftp: SFTPWrapper, src: string, dst: string, onProgress?: (progress: TransferProgress) => void): Promise<void> {
650
- return new Promise((resolve, reject) => {
651
- let last = 0
652
- let lastEmit = 0
653
- const started = Date.now()
654
- sftp.fastGet(src, dst, { concurrency: this.opts.sftpConcurrency, step: (transferred: number, _chunk: number, total: number) => {
655
- const now = Date.now()
656
- if (now - lastEmit < 100 && transferred < total) return
657
- lastEmit = now
658
- const elapsed = (now - started) / 1000
659
- onProgress?.({
660
- phase: 'transferring',
661
- file: src,
662
- transferred,
663
- total,
664
- percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
665
- speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : undefined,
666
- })
667
- last = transferred
668
- } }, (error) => {
669
- if (error !== undefined) {
670
- onProgress?.({ phase: 'error', file: src, transferred: 0, total: 0, percent: 0, error: String(error) })
671
- reject(error)
672
- } else {
673
- onProgress?.({ phase: 'done', file: src, transferred: statSync(dst).size, total: statSync(dst).size, percent: 100 })
674
- resolve()
675
- }
676
- })
677
- })
113
+ return ls(this, alias, path)
678
114
  }
679
115
 
680
116
  // ------------------------------------------------------------- tunnel
681
117
 
682
118
  /** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
683
119
  async startTunnel(alias: string, options: { remotePort: number; remoteHost?: string; localPort?: number }): Promise<TunnelInfo> {
684
- if (!Number.isInteger(options.remotePort) || options.remotePort < 1 || options.remotePort > 65535) {
685
- throw new Error('remotePort must be an integer in 1..65535')
686
- }
687
- if (options.localPort !== undefined && (!Number.isInteger(options.localPort) || options.localPort < 1 || options.localPort > 65535)) {
688
- throw new Error('localPort must be an integer in 1..65535')
689
- }
690
- const entry = this.store.find(alias)
691
- if (entry === undefined) throw new Error(`alias '${alias}' not found — add it first`)
692
- const remoteHost = options.remoteHost ?? '127.0.0.1'
693
- const id = `tun-${this.nextTunnelId++}`
694
- const info: TunnelInfo = {
695
- id,
696
- alias,
697
- localPort: 0,
698
- remoteHost,
699
- remotePort: options.remotePort,
700
- state: 'connecting',
701
- startedAt: Date.now(),
702
- }
703
- const record = await this.acquire(alias)
704
- const client = record.client
705
- const sockets = new Set<import('node:net').Socket>()
706
- const server = createServer((socket) => {
707
- sockets.add(socket)
708
- socket.on('close', () => { sockets.delete(socket) })
709
- client.forwardOut('127.0.0.1', 0, remoteHost, options.remotePort, (error, stream) => {
710
- if (error !== undefined) {
711
- socket.destroy()
712
- return
713
- }
714
- // Both ends of the pipe can die independently; destroy the pair so an
715
- // unhandled 'error' event can never crash the host process.
716
- const destroy = (): void => {
717
- try { socket.destroy() } catch { /* gone */ }
718
- try { stream.close() } catch { /* gone */ }
719
- }
720
- stream.on('error', destroy)
721
- socket.on('error', destroy)
722
- stream.on('close', destroy)
723
- socket.on('close', destroy)
724
- stream.pipe(socket).pipe(stream)
725
- })
726
- })
727
- try {
728
- await new Promise<void>((resolve, reject) => {
729
- server.once('error', reject)
730
- server.listen(options.localPort ?? 0, '127.0.0.1', () => {
731
- server.removeListener('error', reject)
732
- resolve()
733
- })
734
- })
735
- } catch (error) {
736
- // Roll back: never leave an unpinned orphan connection behind.
737
- if (!record.pinned && record.inFlight === 0) this.disposeRecord(alias, record)
738
- throw error
739
- }
740
- record.pinned = true
741
- const address = server.address()
742
- info.localPort = typeof address === 'object' && address !== null ? address.port : 0
743
- info.state = 'forwarding'
744
- this.tunnels.set(id, { info, server, alias, sockets })
745
- return info
120
+ return startTunnel(this, alias, options)
746
121
  }
747
122
 
748
123
  /** All active tunnels. */
749
124
  listTunnels(): TunnelInfo[] {
750
- return [...this.tunnels.values()].map(tunnel => ({ ...tunnel.info }))
125
+ return listTunnels(this)
751
126
  }
752
127
 
753
128
  /** Stop one tunnel (closes the listener, live sockets, and the pinned connection). */
754
129
  stopTunnel(id: string): boolean {
755
- const tunnel = this.tunnels.get(id)
756
- if (tunnel === undefined) return false
757
- this.tunnels.delete(id)
758
- try { tunnel.server.close() } catch { /* already closed */ }
759
- for (const socket of tunnel.sockets) {
760
- try { socket.destroy() } catch { /* already closed */ }
761
- }
762
- tunnel.sockets.clear()
763
- this.disposeRecord(tunnel.alias)
764
- return true
130
+ return stopTunnel(this, id)
765
131
  }
766
132
 
767
133
  /** Stop all tunnels (optionally for one alias). */
768
134
  stopAllTunnels(alias?: string): number {
769
- let count = 0
770
- for (const [id, tunnel] of [...this.tunnels]) {
771
- if (alias === undefined || tunnel.alias === alias) {
772
- this.stopTunnel(id)
773
- count += 1
774
- }
775
- }
776
- return count
135
+ return stopAllTunnels(this, alias)
777
136
  }
778
137
 
779
138
  // ------------------------------------------------------------- misc
@@ -785,7 +144,7 @@ export class SshEngine {
785
144
  const result = await this.exec(alias, 'true', 10_000)
786
145
  return result.success
787
146
  ? { ok: true, latencyMs: result.durationMs }
788
- : { ok: false, latencyMs: result.durationMs, error: `remote exit code ${result.exitCode}` }
147
+ : { ok: false, latencyMs: result.durationMs, error: 'remote exit code ' + result.exitCode }
789
148
  } catch (error) {
790
149
  return { ok: false, latencyMs: Date.now() - started, error: error instanceof Error ? error.message : String(error) }
791
150
  }
@@ -794,8 +153,7 @@ export class SshEngine {
794
153
  /** Close every pooled connection and tunnel. */
795
154
  dispose(): void {
796
155
  if (this.sweepTimer !== undefined) clearInterval(this.sweepTimer)
797
- for (const id of [...this.tunnels.keys()]) this.stopTunnel(id)
798
- for (const alias of [...this.pool.keys()]) this.disposeRecord(alias)
156
+ for (const id of [...this.tunnels.keys()]) stopTunnel(this, id)
157
+ for (const alias of [...this.pool.keys()]) disposeRecord(this, alias)
799
158
  }
800
- }
801
-
159
+ }