dsh-remote 0.8.18 → 0.8.20

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/lib/pool.js ADDED
@@ -0,0 +1,511 @@
1
+ // Persistent SSH/SFTP connection pool (one identity → one live client).
2
+ import ssh2 from 'ssh2'
3
+ import { readFileSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
5
+ import path from 'node:path'
6
+ import { toSftpPath, truncate } from './paths.js'
7
+ import { createHostKeyGuard } from './hostkey.js'
8
+
9
+ const { Client } = ssh2
10
+
11
+ export class SshPool {
12
+ constructor(config, opts = {}) {
13
+ this.config = config
14
+ this._knownHostsFile = opts.knownHostsFile
15
+ this.client = null
16
+ this.connecting = null
17
+ this.proxyPool = null
18
+ // Generational token: bumped on every target change / close so a stale
19
+ // in-flight connect can never hand this pool a connection to an old host.
20
+ this.epoch = 0
21
+ /** Auto-detected remote platform: unknown | windows | posix (per target). */
22
+ this.platform = 'unknown'
23
+ /** Resolved Git Bash bash.exe path on Windows remotes ('' when none). */
24
+ this.gitBashPath = ''
25
+ /** Resolved terminal strategy: native | git-bash. */
26
+ this.shellMode = 'native'
27
+ /** In-flight platform detection promise (cached). */
28
+ this._detecting = null
29
+ /** Optional async resolver for a machine-stored (keychain) password. */
30
+ this.passwordResolver = null
31
+ /** Optional hook called with the live client after a successful connect. */
32
+ this.onReady = null
33
+ /** Optional hook called when the pool closes. */
34
+ this.onCloseHook = null
35
+ }
36
+
37
+ resolveKeyPath() {
38
+ const p = this.config.privateKeyPath
39
+ if (!p) return ''
40
+ if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
41
+ return p
42
+ }
43
+
44
+ setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }) {
45
+ if (host !== undefined) this.config.host = String(host)
46
+ if (port !== undefined && Number(port)) this.config.port = Number(port)
47
+ if (username !== undefined) this.config.username = String(username)
48
+ if (password !== undefined && password !== null) this.config.password = String(password)
49
+ if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath)
50
+ if (passphrase !== undefined) this.config.passphrase = String(passphrase)
51
+ if (workspace !== undefined) this.config.workspace = String(workspace)
52
+ if (useAgent !== undefined) this.config.useAgent = !!useAgent
53
+ if (keyboardInteractive !== undefined) this.config.keyboardInteractive = !!keyboardInteractive
54
+ if (proxy !== undefined) this.config.proxy = proxy
55
+ if (hostKeyMode !== undefined) this.config.hostKeyMode = String(hostKeyMode)
56
+ // the new target may be a different OS — re-detect on the next command
57
+ this.platform = 'unknown'
58
+ this.gitBashPath = ''
59
+ this.shellMode = 'native'
60
+ this._detecting = null
61
+ this.close()
62
+ return this
63
+ }
64
+
65
+ connect() {
66
+ if (this.client) return Promise.resolve(this.client)
67
+ if (this.connecting) return this.connecting
68
+ const epoch = this.epoch
69
+ const pending = this._doConnect(epoch)
70
+ this.connecting = pending
71
+ const clear = () => {
72
+ if (this.epoch === epoch && this.connecting === pending) this.connecting = null
73
+ }
74
+ pending.then(clear, clear)
75
+ return pending
76
+ }
77
+
78
+ async _doConnect(epoch) {
79
+ const isCurrent = () => this.epoch === epoch
80
+ const knownHostsFile = typeof this._knownHostsFile === 'function' ? this._knownHostsFile() : this._knownHostsFile
81
+ const guard = createHostKeyGuard(this.config, knownHostsFile)
82
+ const client = new Client()
83
+ let settled = false
84
+ const fail = (err) => {
85
+ if (settled) return
86
+ settled = true
87
+ if (isCurrent() && this.client === client) this.client = null
88
+ throw guard.lastError ? new Error(guard.lastError) : err
89
+ }
90
+
91
+ // Proxy jump: SSH to the bastion first, then tunnel to the target through it.
92
+ let sock = null
93
+ const proxyCfg = this.config.proxy
94
+ if (proxyCfg && proxyCfg.host) {
95
+ try {
96
+ this.proxyPool = new SshPool({
97
+ ...this.config,
98
+ host: proxyCfg.host,
99
+ port: Number(proxyCfg.port) || 22,
100
+ username: proxyCfg.username || this.config.username || 'root',
101
+ password: proxyCfg.password || '',
102
+ privateKeyPath: proxyCfg.privateKeyPath || '',
103
+ passphrase: proxyCfg.passphrase || '',
104
+ proxy: undefined,
105
+ }, { knownHostsFile: this._knownHostsFile })
106
+ const pclient = await this.proxyPool.connect()
107
+ if (!isCurrent()) throw new Error('ssh target changed during proxy connect')
108
+ sock = await new Promise((res, rej) => {
109
+ pclient.forwardOut('127.0.0.1', 0, this.config.host, this.config.port, (e, ch) => (e ? rej(new Error('proxy forward to target failed: ' + ((e && e.message) || e))) : res(ch)))
110
+ })
111
+ } catch (err) {
112
+ return fail(err)
113
+ }
114
+ }
115
+
116
+ return new Promise((resolve, reject) => {
117
+ const rejectOnce = (err) => {
118
+ if (settled) return
119
+ settled = true
120
+ if (isCurrent() && this.client === client) this.client = null
121
+ reject(guard.lastError ? new Error(guard.lastError) : err)
122
+ }
123
+ client.on('ready', () => {
124
+ if (settled) return
125
+ settled = true
126
+ if (!isCurrent()) {
127
+ try { client.end() } catch {}
128
+ reject(new Error('ssh target changed during connect'))
129
+ return
130
+ }
131
+ this.client = client
132
+ resolve(client)
133
+ if (this.onReady) { try { this.onReady(client) } catch {} }
134
+ })
135
+ client.on('error', (e) => rejectOnce(e))
136
+ client.on('close', () => {
137
+ if (isCurrent() && this.client === client) this.client = null
138
+ rejectOnce(new Error('ssh connection closed'))
139
+ })
140
+
141
+ const buildOpts = async () => {
142
+ const opts = {
143
+ host: this.config.host,
144
+ port: this.config.port,
145
+ username: this.config.username,
146
+ readyTimeout: this.config.connectTimeoutMs,
147
+ keepaliveInterval: 15000,
148
+ keepaliveCountMax: 3,
149
+ hostVerifier: (key) => guard.verifier(key),
150
+ }
151
+ if (sock) opts.sock = sock
152
+ if (this.config.useAgent) {
153
+ const sockPath = process.env.SSH_AUTH_SOCK
154
+ if (sockPath) opts.agent = sockPath
155
+ }
156
+ let password = this.config.password || ''
157
+ if (!password && this.passwordResolver) {
158
+ try { password = (await this.passwordResolver()) || '' } catch {}
159
+ }
160
+ if (password) {
161
+ opts.password = password
162
+ opts.tryKeyboard = true
163
+ } else if (this.config.keyboardInteractive && !this.config.privateKeyPath) {
164
+ opts.tryKeyboard = true
165
+ }
166
+ if (this.config.privateKeyPath) {
167
+ const keyPath = this.resolveKeyPath()
168
+ if (!keyPath) {
169
+ throw new Error('no credentials: set a password or a privateKeyPath to connect')
170
+ }
171
+ let key
172
+ try {
173
+ key = readFileSync(keyPath)
174
+ } catch (err) {
175
+ throw new Error(`cannot read private key "${keyPath}": ${err && err.message}`)
176
+ }
177
+ opts.privateKey = key
178
+ opts.passphrase = this.config.passphrase || undefined
179
+ } else if (!password && !opts.agent) {
180
+ throw new Error('no credentials: set a password, a privateKeyPath, or enable useAgent to connect')
181
+ }
182
+ return opts
183
+ }
184
+
185
+ buildOpts().then(
186
+ (opts) => {
187
+ if (opts.tryKeyboard) {
188
+ client.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
189
+ finish(prompts.map(() => this.config.password || ''))
190
+ })
191
+ }
192
+ client.connect(opts)
193
+ },
194
+ (err) => rejectOnce(err),
195
+ )
196
+ })
197
+ }
198
+
199
+ /** Detect the remote platform + locate Git Bash once; cached per target. */
200
+ detect() {
201
+ if (this.platform !== 'unknown') return Promise.resolve()
202
+ if (this._detecting) return this._detecting
203
+ this._detecting = this._detect().finally(() => {
204
+ this._detecting = null
205
+ })
206
+ return this._detecting
207
+ }
208
+
209
+ async _detect() {
210
+ let res
211
+ try {
212
+ // `cmd /c ver` works under cmd.exe, PowerShell AND Git Bash (all print
213
+ // "Microsoft Windows …"); on POSIX hosts `cmd` simply doesn't exist.
214
+ res = await this._execRaw('cmd /c ver', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
215
+ } catch (err) {
216
+ this.platform = 'unknown'
217
+ this.shellMode = 'native'
218
+ return
219
+ }
220
+ const out = String(res.stdout || '') + '\n' + String(res.stderr || '')
221
+ if (res.code === 0 && /microsoft windows/i.test(out)) {
222
+ this.platform = 'windows'
223
+ await this._resolveGitBash()
224
+ return
225
+ }
226
+ // inconclusive — probe for a Git-Bash/MSYS remote (uname prints MINGW64_NT…)
227
+ try {
228
+ const u = await this._execRaw('uname -s', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
229
+ if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) {
230
+ this.platform = 'windows'
231
+ await this._resolveGitBash()
232
+ return
233
+ }
234
+ } catch {}
235
+ this.platform = 'posix'
236
+ this.shellMode = 'native'
237
+ this.gitBashPath = ''
238
+ }
239
+
240
+ /** On a Windows remote, locate Git Bash (config path → PATH → common installs). */
241
+ async _resolveGitBash() {
242
+ const cfg = String(this.config.shell || '').trim()
243
+ if (cfg && cfg !== 'git-bash' && cfg !== 'native') {
244
+ if (await this._cmdExists(cfg)) {
245
+ this.gitBashPath = cfg
246
+ this.shellMode = 'git-bash'
247
+ return
248
+ }
249
+ }
250
+ if (cfg === 'native') {
251
+ this.shellMode = 'native'
252
+ this.gitBashPath = ''
253
+ return
254
+ }
255
+ try {
256
+ const r = await this._execRaw('cmd /c where bash', { timeoutMs: 8000 })
257
+ const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim()
258
+ if (first) {
259
+ this.gitBashPath = first
260
+ this.shellMode = 'git-bash'
261
+ return
262
+ }
263
+ } catch {}
264
+ const candidates = [
265
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
266
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
267
+ ...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []),
268
+ '%LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe',
269
+ ]
270
+ for (const c of candidates) {
271
+ if (await this._cmdExists(c)) {
272
+ this.gitBashPath = c
273
+ this.shellMode = 'git-bash'
274
+ return
275
+ }
276
+ }
277
+ this.shellMode = 'git-bash' // wanted but not found — commands fall back to raw
278
+ this.gitBashPath = ''
279
+ }
280
+
281
+ async _cmdExists(p) {
282
+ try {
283
+ const r = await this._execRaw(`cmd /c if exist "${p}" (echo Y) else (echo N)`, { timeoutMs: 8000 })
284
+ return /Y/.test(String(r.stdout || ''))
285
+ } catch {
286
+ return false
287
+ }
288
+ }
289
+
290
+ /** Run one remote command; resolves { code, signal, stdout, stderr }.
291
+ * On Windows remotes with Git Bash the script is piped to `bash -s` over the
292
+ * exec-channel stdin — no shell quoting round-trip, so any content (quotes,
293
+ * backslashes, newlines) survives verbatim. */
294
+ exec(command, timeoutMsOrOpts) {
295
+ const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
296
+ return this.detect().then(() => {
297
+ if (this.platform === 'windows' && this.gitBashPath) {
298
+ const script = String(command)
299
+ return this._execRaw(`"${this.gitBashPath}" -s`, { ...opts, timeoutMs: opts.timeoutMs || this.config.commandTimeoutMs }, (stream) => {
300
+ try { stream.end(script) } catch {}
301
+ })
302
+ }
303
+ return this._execRaw(command, opts)
304
+ })
305
+ }
306
+
307
+ /** Raw exec (no detection / no wrapper). Optional stdinWriter(stream) feeds
308
+ * the remote process stdin (used by the Git Bash `-s` mode). */
309
+ _execRaw(command, opts, stdinWriter) {
310
+ const timeoutMs = (opts && opts.timeoutMs) || this.config.commandTimeoutMs
311
+ return this.connect().then(
312
+ (client) =>
313
+ new Promise((resolve, reject) => {
314
+ let retried = false
315
+ const runOn = (c) => {
316
+ const execOpts = {}
317
+ if (opts && opts.pty) execOpts.pty = true
318
+ if (opts && opts.env && typeof opts.env === 'object') execOpts.env = opts.env
319
+ c.exec(command, execOpts, (err, stream) => {
320
+ if (err) {
321
+ // Channel-open failure — or a session termination — usually
322
+ // means the pooled connection died server-side (idle timeout /
323
+ // network reset) while keepalive hadn't noticed. Drop it and
324
+ // retry ONCE on a fresh connection.
325
+ if (!retried && /channel open failure|open failed|unexpected .* session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
326
+ retried = true
327
+ this.invalidate()
328
+ return this.connect().then(
329
+ (fresh) => runOn(fresh),
330
+ (e2) => reject(new Error('ssh exec failed (reconnect): ' + ((e2 && e2.message) || e2))),
331
+ )
332
+ }
333
+ return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
334
+ }
335
+ let stdout = ''
336
+ let stderr = ''
337
+ let settled = false
338
+ let exitCode = null
339
+ let exitSignal = null
340
+ const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
341
+ const settle = () => {
342
+ if (settled) return
343
+ settled = true
344
+ clearTimeout(timer)
345
+ resolve({
346
+ code: exitCode,
347
+ signal: exitSignal,
348
+ stdout: truncate(stdout, this.config.maxOutputChars),
349
+ stderr: truncate(stderr, this.config.maxOutputChars),
350
+ })
351
+ }
352
+ const timer = setTimeout(() => {
353
+ if (settled) return
354
+ exitCode = -1
355
+ exitSignal = 'TIMEOUT'
356
+ // Kill the remote command (SIGTERM) rather than just dropping the
357
+ // channel, so a runaway process cannot keep running and holding
358
+ // the SSH connection after we've given up on its output.
359
+ try {
360
+ if (typeof stream.signal === 'function') stream.signal('SIGTERM')
361
+ } catch {}
362
+ const hardClose = setTimeout(() => {
363
+ try { stream.close() } catch {}
364
+ }, 800)
365
+ if (typeof hardClose.unref === 'function') hardClose.unref()
366
+ settle()
367
+ }, timeoutMs)
368
+ stream.on('close', (code, signal) => {
369
+ if (settled) return
370
+ exitCode = code
371
+ exitSignal = signal
372
+ settle()
373
+ })
374
+ stream.on('data', (d) => {
375
+ if (stdout.length < hardCap) stdout += d
376
+ })
377
+ stream.stderr.on('data', (d) => {
378
+ if (stderr.length < hardCap) stderr += d
379
+ })
380
+ stream.on('error', (e) => {
381
+ if (settled) return
382
+ settled = true
383
+ clearTimeout(timer)
384
+ reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
385
+ })
386
+ if (stdinWriter) {
387
+ try { stdinWriter(stream) } catch {}
388
+ }
389
+ })
390
+ }
391
+ runOn(client)
392
+ }),
393
+ )
394
+ }
395
+
396
+ /** Resolve a promisified SFTP client. All paths normalized via toSftpPath(). */
397
+ sftp() {
398
+ return this.connect().then(
399
+ (client) =>
400
+ new Promise((resolve, reject) => {
401
+ let retried = false
402
+ const runOn = (c) => {
403
+ c.sftp((err, sftp) => {
404
+ if (err) {
405
+ // Same dead-connection recovery as exec(): a channel open
406
+ // failure — or a session termination (remote closed the
407
+ // SFTP subchannel, e.g. transient network blip / sshd idle
408
+ // drop) — means the pooled connection is stale: drop it and
409
+ // retry ONCE on a fresh connection.
410
+ if (!retried && /channel open failure|open failed|unexpected sftp session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
411
+ retried = true
412
+ this.invalidate()
413
+ return this.connect().then(
414
+ (fresh) => runOn(fresh),
415
+ (e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))),
416
+ )
417
+ }
418
+ return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
419
+ }
420
+ const withTimeout = (fn) => (...args) =>
421
+ new Promise((r2, j2) => {
422
+ const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs)
423
+ const done = (e, v) => {
424
+ clearTimeout(timer)
425
+ e ? j2(e) : r2(v)
426
+ }
427
+ try { fn(...args, done) } catch (e) { clearTimeout(timer); j2(e) }
428
+ })
429
+ const P = (p) => toSftpPath(p)
430
+ resolve({
431
+ readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
432
+ stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
433
+ lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
434
+ mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
435
+ rmdir: (dir) => withTimeout((d, cb) => sftp.rmdir(d, cb))(P(dir)),
436
+ unlink: (p) => withTimeout((d, cb) => sftp.unlink(d, cb))(P(p)),
437
+ rename: (p, d) => withTimeout((a, b, cb) => sftp.rename(a, b, cb))(P(p), P(d)),
438
+ realpath: (p) => withTimeout((d, cb) => sftp.realpath(d, cb))(P(p)),
439
+ readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
440
+ writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
441
+ fastGet: (p, lp) => withTimeout((d, l, cb) => sftp.fastGet(d, l, cb))(P(p), lp),
442
+ fastPut: (lp, p) => withTimeout((l, d, cb) => sftp.fastPut(l, d, cb))(lp, P(p)),
443
+ // Range read for oversized previews: never downloads the whole file.
444
+ readPartial: (p, offset, length) => withTimeout((d, off, len, cb) => {
445
+ sftp.open(d, 'r', (e, handle) => {
446
+ if (e) return cb(e)
447
+ const buf = Buffer.alloc(len)
448
+ sftp.read(handle, buf, 0, len, off, (e2, bytesRead, data) => {
449
+ sftp.close(handle, () => {})
450
+ if (e2) return cb(e2)
451
+ cb(null, Buffer.isBuffer(data) ? data.subarray(0, bytesRead) : buf.subarray(0, bytesRead))
452
+ })
453
+ })
454
+ })(P(p), offset, length),
455
+ })
456
+ })
457
+ }
458
+ runOn(client)
459
+ }),
460
+ )
461
+ }
462
+
463
+ /**
464
+ * Drop the cached client and force a fresh connection on the next call.
465
+ * Called when a channel open fails (e.g. "Channel open failure: open
466
+ * failed") — the pooled SSH connection is usually dead server-side while
467
+ * keepalive has not yet noticed, and reusing it keeps failing. The epoch
468
+ * bump orphans any in-flight connect; the client is ended so ssh2 frees
469
+ * its sockets.
470
+ */
471
+ invalidate() {
472
+ this.epoch++
473
+ const client = this.client
474
+ this.client = null
475
+ const pending = this.connecting
476
+ this.connecting = null
477
+ if (pending && typeof pending.catch === 'function') {
478
+ try { pending.catch(() => {}) } catch {}
479
+ }
480
+ if (this.proxyPool) {
481
+ try { this.proxyPool.close() } catch {}
482
+ this.proxyPool = null
483
+ }
484
+ if (client) {
485
+ try { client.end() } catch {}
486
+ }
487
+ }
488
+
489
+ close() {
490
+ this.epoch++
491
+ const client = this.client
492
+ this.client = null
493
+ const pending = this.connecting
494
+ this.connecting = null
495
+ if (pending && typeof pending.catch === 'function') {
496
+ try { pending.catch(() => {}) } catch {}
497
+ }
498
+ if (this.proxyPool) {
499
+ try { this.proxyPool.close() } catch {}
500
+ this.proxyPool = null
501
+ }
502
+ if (client) {
503
+ try {
504
+ client.end()
505
+ } catch {}
506
+ }
507
+ if (this.onCloseHook) {
508
+ try { this.onCloseHook() } catch {}
509
+ }
510
+ }
511
+ }
@@ -0,0 +1,143 @@
1
+ // Shared remote SFTP helpers used by both rw_* tools and the sidebar HTTP routes.
2
+ import { joinRemotePath, normalizeRemotePath } from './paths.js'
3
+
4
+ /** Structured listing: name + type + size + mtime + mode (SFTP protocol-level). */
5
+ export async function listDirStructured(sftp, p) {
6
+ const target = normalizeRemotePath(p || '/')
7
+ let list
8
+ try {
9
+ list = await sftp.readdir(target)
10
+ } catch (err) {
11
+ const msg = String((err && err.message) || err)
12
+ if (/no such file|not found|does not exist|ENOENT/i.test(msg)) {
13
+ return { path: target, items: [], missing: true }
14
+ }
15
+ throw new Error('browse failed: ' + msg)
16
+ }
17
+ const items = []
18
+ const symIdx = []
19
+ for (const e of list) {
20
+ const name = String(e.filename)
21
+ if (name === '.' || name === '..' || !name) continue
22
+ const a = e.attrs || {}
23
+ let type
24
+ if (a.isSymbolicLink && a.isSymbolicLink()) {
25
+ type = 'symlink'
26
+ symIdx.push(items.length)
27
+ } else if (a.isDirectory && a.isDirectory()) {
28
+ type = 'dir'
29
+ } else {
30
+ type = 'file'
31
+ }
32
+ items.push({
33
+ type,
34
+ name,
35
+ size: typeof a.size === 'number' ? a.size : 0,
36
+ mtime: typeof a.mtime === 'number' ? a.mtime : 0,
37
+ mode: typeof a.mode === 'number' ? a.mode.toString(8) : '',
38
+ })
39
+ }
40
+ if (symIdx.length) {
41
+ await Promise.all(symIdx.map(async (i) => {
42
+ const full = joinRemotePath(target, items[i].name)
43
+ try {
44
+ const st = await sftp.lstat(full)
45
+ items[i].type = st && st.isDirectory && st.isDirectory() ? 'dir' : 'file'
46
+ } catch { /* degrade to file */ }
47
+ }))
48
+ }
49
+ return { path: target, items }
50
+ }
51
+
52
+ /** Windows drive letters as display-form dir entries ("This PC" root view). */
53
+ export async function listRemoteDrives(sshPool) {
54
+ let letters = []
55
+ try {
56
+ const res = await sshPool.exec('cmd /c fsutil fsinfo drives', { timeoutMs: Math.min(sshPool.config.commandTimeoutMs || 8000, 8000) })
57
+ letters = [...String(res.stdout || '').matchAll(/([a-zA-Z]):\\?/g)].map((mm) => mm[1].toUpperCase())
58
+ } catch {}
59
+ if (!letters.length) {
60
+ try {
61
+ const res = await sshPool.exec('ls -d /[a-z] 2>/dev/null', { timeoutMs: Math.min(sshPool.config.commandTimeoutMs || 8000, 8000) })
62
+ letters = String(res.stdout || '')
63
+ .split(/\s+/)
64
+ .map((s) => s.replace(/^\/+|\/+$/g, ''))
65
+ .filter((s) => /^[a-zA-Z]$/.test(s))
66
+ .map((s) => s.toUpperCase())
67
+ } catch {}
68
+ }
69
+ return [...new Set(letters)].sort().map((l) => ({ name: l + ':\\', path: l + ':\\', type: 'dir', drive: true, size: 0, mtime: 0 }))
70
+ }
71
+
72
+ /** Recursive remote delete (bounded): unlink files bottom-up, then rmdir. */
73
+ export async function removeRemoteTree(sftp, p, maxFiles = 2000) {
74
+ let removed = 0
75
+ const walk = async (dir) => {
76
+ if (removed >= maxFiles) return
77
+ let entries = []
78
+ try { entries = (await sftp.readdir(dir)) || [] } catch { return }
79
+ for (const e of entries) {
80
+ if (removed >= maxFiles) return
81
+ const name = String(e.filename)
82
+ if (name === '.' || name === '..') continue
83
+ const fp = joinRemotePath(dir, name)
84
+ const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory())
85
+ if (isDir) await walk(fp)
86
+ else {
87
+ try { await sftp.unlink(fp); removed++ } catch {}
88
+ }
89
+ }
90
+ try { await sftp.rmdir(dir); removed++ } catch { /* already gone or non-empty */ }
91
+ }
92
+ await walk(p)
93
+ return removed
94
+ }
95
+
96
+ function looksBinary(buf) {
97
+ const n = Math.min(buf.length, 8192)
98
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true
99
+ return false
100
+ }
101
+
102
+ /**
103
+ * Read a remote file for the sidebar/editor. Oversized files use a range read
104
+ * (`readPartial`) so we never `fastGet` the whole blob onto disk.
105
+ */
106
+ export async function previewRemoteFile(sftp, target, opts = {}) {
107
+ const {
108
+ maxBytes = 256 * 1024,
109
+ encoding = 'utf-8',
110
+ decodeBuf = (buf) => buf.toString('utf8'),
111
+ } = opts
112
+ const cap = Math.min(Math.max(Number(maxBytes) || 256 * 1024, 1024), 2 * 1024 * 1024)
113
+ const st = await sftp.stat(target)
114
+ const mtime = st && typeof st.mtime === 'number' ? st.mtime : 0
115
+ const size = st && typeof st.size === 'number' ? st.size : 0
116
+ let buf
117
+ let truncated = false
118
+ if (size > cap) {
119
+ truncated = true
120
+ if (typeof sftp.readPartial === 'function') {
121
+ buf = await sftp.readPartial(target, 0, cap)
122
+ } else {
123
+ const full = await sftp.readFile(target)
124
+ buf = full.subarray(0, cap)
125
+ }
126
+ } else {
127
+ buf = await sftp.readFile(target)
128
+ }
129
+ if (looksBinary(buf)) {
130
+ return {
131
+ ok: true, binary: true, size, mtime,
132
+ head: buf.slice(0, Math.min(buf.length, 4096)).toString('base64'),
133
+ truncated,
134
+ }
135
+ }
136
+ let content = decodeBuf(buf, encoding).replace(/\r\n/g, '\n')
137
+ if (!truncated && content.length > cap) {
138
+ truncated = true
139
+ content = content.slice(0, cap)
140
+ }
141
+ if (truncated) content += `\n…[truncated: ${Math.max(0, size - cap)} more bytes]`
142
+ return { ok: true, binary: false, content, truncated, size, mtime }
143
+ }