dsh-remote 0.8.34 → 0.8.35
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/README.en.md +72 -153
- package/README.md +57 -135
- package/lib/client.js +13 -3
- package/lib/ignore.js +33 -0
- package/lib/index.js +109 -21
- package/lib/pool.js +124 -53
- package/lib/search.js +53 -8
- package/lib/update.js +30 -7
- package/package.json +1 -1
package/lib/pool.js
CHANGED
|
@@ -332,10 +332,16 @@ export class SshPool {
|
|
|
332
332
|
* the remote process stdin (used by the Git Bash `-s` mode). */
|
|
333
333
|
_execRaw(command, opts, stdinWriter) {
|
|
334
334
|
const timeoutMs = (opts && opts.timeoutMs) || this.config.commandTimeoutMs
|
|
335
|
+
const signal = opts && opts.signal
|
|
335
336
|
return this.connect().then(
|
|
336
337
|
(client) =>
|
|
337
338
|
new Promise((resolve, reject) => {
|
|
338
339
|
let retried = false
|
|
340
|
+
// Caller cancellation (issue #44): a tool that hands us exec.signal
|
|
341
|
+
// expects the remote command to stop when the turn is cancelled.
|
|
342
|
+
// Resolve the same shape as a timeout so callers need no new branch.
|
|
343
|
+
let onAbort = null
|
|
344
|
+
const cleanupAbort = () => { if (signal && onAbort) signal.removeEventListener('abort', onAbort) }
|
|
339
345
|
const runOn = (c) => {
|
|
340
346
|
const execOpts = {}
|
|
341
347
|
if (opts && opts.pty) execOpts.pty = true
|
|
@@ -366,6 +372,7 @@ export class SshPool {
|
|
|
366
372
|
if (settled) return
|
|
367
373
|
settled = true
|
|
368
374
|
clearTimeout(timer)
|
|
375
|
+
cleanupAbort()
|
|
369
376
|
resolve({
|
|
370
377
|
code: exitCode,
|
|
371
378
|
signal: exitSignal,
|
|
@@ -389,10 +396,33 @@ export class SshPool {
|
|
|
389
396
|
if (typeof hardClose.unref === 'function') hardClose.unref()
|
|
390
397
|
settle()
|
|
391
398
|
}, timeoutMs)
|
|
392
|
-
|
|
399
|
+
if (signal) {
|
|
400
|
+
if (signal.aborted) {
|
|
401
|
+
try { if (typeof stream.signal === 'function') stream.signal('SIGTERM') } catch {}
|
|
402
|
+
try { stream.close() } catch {}
|
|
403
|
+
exitCode = -1
|
|
404
|
+
exitSignal = 'ABORTED'
|
|
405
|
+
return settle()
|
|
406
|
+
}
|
|
407
|
+
onAbort = () => {
|
|
408
|
+
if (settled) return
|
|
409
|
+
exitCode = -1
|
|
410
|
+
exitSignal = 'ABORTED'
|
|
411
|
+
try { if (typeof stream.signal === 'function') stream.signal('SIGTERM') } catch {}
|
|
412
|
+
const hardClose = setTimeout(() => { try { stream.close() } catch {} }, 500)
|
|
413
|
+
if (typeof hardClose.unref === 'function') hardClose.unref()
|
|
414
|
+
settle()
|
|
415
|
+
}
|
|
416
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
417
|
+
}
|
|
418
|
+
stream.on('close', (code, sigName) => {
|
|
393
419
|
if (settled) return
|
|
394
420
|
exitCode = code
|
|
395
|
-
|
|
421
|
+
// `sigName` is the signal NAME that killed the remote process
|
|
422
|
+
// (e.g. 'SIGTERM'), not the caller's AbortSignal. The rename
|
|
423
|
+
// matters: `signal` in this scope is the AbortSignal, and
|
|
424
|
+
// assigning it here would leak an object into the result.
|
|
425
|
+
exitSignal = sigName
|
|
396
426
|
settle()
|
|
397
427
|
})
|
|
398
428
|
stream.on('data', (d) => {
|
|
@@ -405,6 +435,7 @@ export class SshPool {
|
|
|
405
435
|
if (settled) return
|
|
406
436
|
settled = true
|
|
407
437
|
clearTimeout(timer)
|
|
438
|
+
cleanupAbort()
|
|
408
439
|
reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
|
|
409
440
|
})
|
|
410
441
|
if (stdinWriter) {
|
|
@@ -424,60 +455,100 @@ export class SshPool {
|
|
|
424
455
|
new Promise((resolve, reject) => {
|
|
425
456
|
let retried = false
|
|
426
457
|
const runOn = (c) => {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
458
|
+
// Bound the subchannel open itself, per attempt. Without this, a
|
|
459
|
+
// remote whose sftp subsystem is unresponsive (overloaded sshd,
|
|
460
|
+
// exceeded MaxSessions, half-open TCP) leaves `c.sftp(cb)` parked
|
|
461
|
+
// forever with no callback — issue #44 point 3. ssh2 only calls back
|
|
462
|
+
// on success or on a channel error, and a wedged peer produces
|
|
463
|
+
// neither.
|
|
464
|
+
//
|
|
465
|
+
// The timer must be created per attempt, not once outside: the
|
|
466
|
+
// callback clears it, so a single outer timer would leave the retry
|
|
467
|
+
// (below) with no timeout at all.
|
|
468
|
+
let timedOut = false
|
|
469
|
+
const attemptTimer = setTimeout(() => {
|
|
470
|
+
timedOut = true
|
|
471
|
+
reject(new Error(`ssh sftp open timed out after ${this.config.commandTimeoutMs}ms`))
|
|
472
|
+
}, this.config.commandTimeoutMs)
|
|
473
|
+
if (typeof attemptTimer.unref === 'function') attemptTimer.unref()
|
|
474
|
+
const clearAttempt = () => { clearTimeout(attemptTimer) }
|
|
475
|
+
try {
|
|
476
|
+
c.sftp((err, sftp) => {
|
|
477
|
+
clearAttempt()
|
|
478
|
+
if (timedOut) return
|
|
479
|
+
if (err) {
|
|
480
|
+
// Same dead-connection recovery as exec(): a channel open
|
|
481
|
+
// failure — or a session termination (remote closed the
|
|
482
|
+
// SFTP subchannel, e.g. transient network blip / sshd idle
|
|
483
|
+
// drop) — means the pooled connection is stale: drop it and
|
|
484
|
+
// retry ONCE on a fresh connection.
|
|
485
|
+
if (!retried && /channel open failure|open failed|unexpected sftp session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
|
|
486
|
+
retried = true
|
|
487
|
+
this.invalidate()
|
|
488
|
+
return this.connect().then(
|
|
489
|
+
(fresh) => runOn(fresh),
|
|
490
|
+
(e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))),
|
|
491
|
+
)
|
|
450
492
|
}
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
})
|
|
493
|
+
return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
|
|
494
|
+
}
|
|
495
|
+
// A dead channel must fail fast. ssh2's own EOF handling only
|
|
496
|
+
// rejects the requests that were pending at that moment
|
|
497
|
+
// (SFTP.js cleanupRequests); every request issued afterwards is
|
|
498
|
+
// registered in `_requests` and never answered. Without the flag
|
|
499
|
+
// below each such call waits out the full commandTimeoutMs — so a
|
|
500
|
+
// tree walk over N entries costs N × 2 × timeout, i.e. days on a
|
|
501
|
+
// large directory (issue #44). Mark the channel dead on EOF/close
|
|
502
|
+
// and reject immediately from then on.
|
|
503
|
+
let dead = false
|
|
504
|
+
const markDead = () => { dead = true }
|
|
505
|
+
if (typeof sftp.on === 'function') {
|
|
506
|
+
sftp.on('end', markDead)
|
|
507
|
+
sftp.on('close', markDead)
|
|
508
|
+
sftp.on('error', markDead)
|
|
509
|
+
}
|
|
510
|
+
const withTimeout = (fn) => (...args) =>
|
|
511
|
+
new Promise((r2, j2) => {
|
|
512
|
+
if (dead) return j2(new Error('sftp channel closed'))
|
|
513
|
+
const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs)
|
|
514
|
+
const finish = (e, v) => {
|
|
515
|
+
clearTimeout(timer)
|
|
516
|
+
e ? j2(e) : r2(v)
|
|
517
|
+
}
|
|
518
|
+
try { fn(...args, finish) } catch (e) { clearTimeout(timer); j2(e) }
|
|
477
519
|
})
|
|
478
|
-
|
|
520
|
+
const P = (p) => toSftpPath(p)
|
|
521
|
+
resolve({
|
|
522
|
+
readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
|
|
523
|
+
stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
|
|
524
|
+
lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
|
|
525
|
+
mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
|
|
526
|
+
rmdir: (dir) => withTimeout((d, cb) => sftp.rmdir(d, cb))(P(dir)),
|
|
527
|
+
unlink: (p) => withTimeout((d, cb) => sftp.unlink(d, cb))(P(p)),
|
|
528
|
+
rename: (p, d) => withTimeout((a, b, cb) => sftp.rename(a, b, cb))(P(p), P(d)),
|
|
529
|
+
realpath: (p) => withTimeout((d, cb) => sftp.realpath(d, cb))(P(p)),
|
|
530
|
+
readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
|
|
531
|
+
writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
|
|
532
|
+
fastGet: (p, lp) => withTimeout((d, l, cb) => sftp.fastGet(d, l, cb))(P(p), lp),
|
|
533
|
+
fastPut: (lp, p) => withTimeout((l, d, cb) => sftp.fastPut(l, d, cb))(lp, P(p)),
|
|
534
|
+
// Range read for oversized previews: never downloads the whole file.
|
|
535
|
+
readPartial: (p, offset, length) => withTimeout((d, off, len, cb) => {
|
|
536
|
+
sftp.open(d, 'r', (e, handle) => {
|
|
537
|
+
if (e) return cb(e)
|
|
538
|
+
const buf = Buffer.alloc(len)
|
|
539
|
+
sftp.read(handle, buf, 0, len, off, (e2, bytesRead, data) => {
|
|
540
|
+
sftp.close(handle, () => {})
|
|
541
|
+
if (e2) return cb(e2)
|
|
542
|
+
cb(null, Buffer.isBuffer(data) ? data.subarray(0, bytesRead) : buf.subarray(0, bytesRead))
|
|
543
|
+
})
|
|
544
|
+
})
|
|
545
|
+
})(P(p), offset, length),
|
|
546
|
+
})
|
|
479
547
|
})
|
|
480
|
-
})
|
|
548
|
+
} catch (e) {
|
|
549
|
+
clearAttempt()
|
|
550
|
+
reject(new Error('ssh sftp failed: ' + ((e && e.message) || e)))
|
|
551
|
+
}
|
|
481
552
|
}
|
|
482
553
|
runOn(client)
|
|
483
554
|
}),
|
package/lib/search.js
CHANGED
|
@@ -46,7 +46,20 @@ function looksBinary(buf) {
|
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Recursively search a remote directory.
|
|
49
|
-
*
|
|
49
|
+
*
|
|
50
|
+
* Cancellation (issue #44): the walk takes an AbortSignal and checks it at every
|
|
51
|
+
* directory and every entry. Before this it was completely uninterruptible —
|
|
52
|
+
* no signal was accepted and no flag was consulted — so a large-tree search
|
|
53
|
+
* could never be stopped: DSH tool cancellation is *cooperative*
|
|
54
|
+
* (@deepseek-ai/dsh-tool-call-timeout-policy can only ask a tool to stop), which
|
|
55
|
+
* meant `session/cancel`, steering, and the tool timeout policy all had no
|
|
56
|
+
* effect and the turn stayed `running` forever. On abort we return the partial
|
|
57
|
+
* result instead of throwing, so the model still gets whatever was found.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} sftp - pooled SFTP client
|
|
60
|
+
* @param {string} rootDir
|
|
61
|
+
* @param {object} opts - `signal` (AbortSignal), `maxDurationMs`, plus the caps below
|
|
62
|
+
* @returns {Promise<{matches: Array<{path, line, text}>, scanned: number, truncated: boolean, cancelled?: boolean}>}
|
|
50
63
|
*/
|
|
51
64
|
export async function searchTree(sftp, rootDir, opts = {}) {
|
|
52
65
|
const {
|
|
@@ -57,15 +70,27 @@ export async function searchTree(sftp, rootDir, opts = {}) {
|
|
|
57
70
|
maxDepth = 12,
|
|
58
71
|
maxFiles = 20000,
|
|
59
72
|
maxScanBytes = 1024 * 1024,
|
|
73
|
+
maxDurationMs = 0, // 0 = no wall-clock budget
|
|
60
74
|
isIgnored = () => false,
|
|
75
|
+
signal,
|
|
61
76
|
} = opts
|
|
62
77
|
|
|
63
78
|
const matches = []
|
|
64
79
|
let scanned = 0
|
|
65
80
|
let truncated = false
|
|
81
|
+
let cancelled = false
|
|
82
|
+
const startedAt = Date.now()
|
|
83
|
+
|
|
84
|
+
/** True when the caller asked us to stop (abort, or the time budget is spent). */
|
|
85
|
+
const shouldStop = () => {
|
|
86
|
+
if (cancelled || truncated) return true
|
|
87
|
+
if (signal && signal.aborted) { cancelled = true; return true }
|
|
88
|
+
if (maxDurationMs > 0 && Date.now() - startedAt >= maxDurationMs) { truncated = true; return true }
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
66
91
|
|
|
67
92
|
const walk = async (dir, depth) => {
|
|
68
|
-
if (
|
|
93
|
+
if (shouldStop() || scanned >= maxFiles) return
|
|
69
94
|
let entries = []
|
|
70
95
|
try { entries = (await sftp.readdir(dir)) || [] } catch { return }
|
|
71
96
|
const dirs = []
|
|
@@ -77,29 +102,32 @@ export async function searchTree(sftp, rootDir, opts = {}) {
|
|
|
77
102
|
}
|
|
78
103
|
// Depth-first, dirs first, but stop as soon as we hit caps.
|
|
79
104
|
for (const name of dirs) {
|
|
80
|
-
if (
|
|
105
|
+
if (shouldStop() || scanned >= maxFiles) return
|
|
81
106
|
if (depth <= 0) continue
|
|
82
107
|
const sub = joinRemotePath(dir, name)
|
|
83
108
|
if (isIgnored(name, true)) continue
|
|
84
109
|
await walk(sub, depth - 1)
|
|
85
110
|
}
|
|
86
|
-
if (
|
|
111
|
+
if (shouldStop() || scanned >= maxFiles) return
|
|
87
112
|
|
|
88
113
|
const fileNames = entries
|
|
89
114
|
.filter((e) => !(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory()))
|
|
90
115
|
.map((e) => String(e.filename))
|
|
91
116
|
.filter((n) => n !== '.' && n !== '..')
|
|
92
117
|
await mapLimit(fileNames, 4, async (name) => {
|
|
93
|
-
|
|
118
|
+
// Checked per entry: this is the loop that used to run for hours.
|
|
119
|
+
if (shouldStop() || scanned >= maxFiles || matches.length >= maxMatches) return
|
|
94
120
|
if (glob && !matchGlob(name, glob)) return
|
|
95
121
|
if (isIgnored(name, false)) return
|
|
96
122
|
const fp = joinRemotePath(dir, name)
|
|
97
123
|
scanned++
|
|
98
124
|
let st
|
|
99
125
|
try { st = await sftp.stat(fp) } catch { return }
|
|
126
|
+
if (shouldStop()) return
|
|
100
127
|
if (st.size > maxScanBytes) return
|
|
101
128
|
let buf
|
|
102
129
|
try { buf = await sftp.readFile(fp) } catch { return }
|
|
130
|
+
if (shouldStop()) return
|
|
103
131
|
if (looksBinary(buf)) return
|
|
104
132
|
const content = buf.toString('utf8').replace(/\r\n/g, '\n')
|
|
105
133
|
const lines = content.split('\n')
|
|
@@ -124,7 +152,7 @@ export async function searchTree(sftp, rootDir, opts = {}) {
|
|
|
124
152
|
|
|
125
153
|
await walk(rootDir, maxDepth)
|
|
126
154
|
if (scanned >= maxFiles) truncated = true
|
|
127
|
-
return { matches, scanned, truncated }
|
|
155
|
+
return { matches, scanned, truncated, cancelled }
|
|
128
156
|
}
|
|
129
157
|
|
|
130
158
|
function parseGrepLines(stdout, maxMatches) {
|
|
@@ -151,7 +179,12 @@ export async function searchViaShell(pool, rootDir, opts = {}) {
|
|
|
151
179
|
glob,
|
|
152
180
|
contextLines = 0,
|
|
153
181
|
maxMatches = 500,
|
|
182
|
+
signal,
|
|
154
183
|
} = opts
|
|
184
|
+
// Honour an already-aborted signal before spawning a remote grep on a huge tree.
|
|
185
|
+
if (signal && signal.aborted) {
|
|
186
|
+
return { matches: [], scanned: 0, truncated: false, cancelled: true, via: 'none' }
|
|
187
|
+
}
|
|
155
188
|
const ic = ignoreCase ? ' -i' : ''
|
|
156
189
|
const globFlag = glob ? ` --glob ${shq(glob)}` : ''
|
|
157
190
|
const ctx = contextLines > 0 ? ` -C ${Number(contextLines)}` : ''
|
|
@@ -159,7 +192,14 @@ export async function searchViaShell(pool, rootDir, opts = {}) {
|
|
|
159
192
|
const grepGlob = glob ? ` --include=${shq(glob)}` : ''
|
|
160
193
|
const grep = `grep -R -n -I -E${ic}${grepGlob}${ctx} -- ${shq(pattern)} ${shq(rootDir)}`
|
|
161
194
|
const script = `if command -v rg >/dev/null 2>&1; then ${rg}; elif command -v grep >/dev/null 2>&1; then ${grep}; else exit 127; fi`
|
|
162
|
-
const res = await pool.exec(script, { timeoutMs: opts.timeoutMs })
|
|
195
|
+
const res = await pool.exec(script, { timeoutMs: opts.timeoutMs, signal })
|
|
196
|
+
// A cancelled call must return as cancelled (with whatever partial output the
|
|
197
|
+
// remote produced), NOT throw: throwing would make searchRemote fall through
|
|
198
|
+
// to the slower SFTP walk, i.e. keep running after the caller said stop.
|
|
199
|
+
if (res.signal === 'ABORTED') {
|
|
200
|
+
const partial = parseGrepLines(res.stdout, maxMatches)
|
|
201
|
+
return { matches: partial.matches, scanned: partial.matches.length, truncated: false, cancelled: true, via: 'none' }
|
|
202
|
+
}
|
|
163
203
|
// 0 = hits, 1 = no hits; other codes mean the tools are missing / failed.
|
|
164
204
|
if (res.signal === 'TIMEOUT') throw new Error('search timed out')
|
|
165
205
|
if (res.code !== 0 && res.code !== 1) throw new Error(res.stderr || `search exit ${res.code}`)
|
|
@@ -178,7 +218,12 @@ export async function searchRemote(pool, rootDir, opts = {}) {
|
|
|
178
218
|
try {
|
|
179
219
|
const fast = await searchViaShell(pool, rootDir, opts)
|
|
180
220
|
if (fast && Array.isArray(fast.matches)) return fast
|
|
181
|
-
} catch {
|
|
221
|
+
} catch (e) {
|
|
222
|
+
// An abort is terminal, not a "try the slower fallback" signal.
|
|
223
|
+
if (opts.signal && opts.signal.aborted) {
|
|
224
|
+
return { matches: [], scanned: 0, truncated: false, cancelled: true, via: 'none' }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
182
227
|
}
|
|
183
228
|
const sftp = await pool.sftp()
|
|
184
229
|
return searchTree(sftp, rootDir, opts)
|
package/lib/update.js
CHANGED
|
@@ -96,7 +96,18 @@ export function gtVersion(a, b) {
|
|
|
96
96
|
return false
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* 查询 npm registry 的最新版本。
|
|
101
|
+
*
|
|
102
|
+
* 返回 `{ version }` 或 `{ error }`(**不再把一切失败压成 null**)。
|
|
103
|
+
*
|
|
104
|
+
* 为什么要区分失败原因:原先任何失败都返回 `null`,调用方只能笼统报
|
|
105
|
+
* "无法连接 npm registry"。实测踩过:本机被 npm 限流(`/latest` 返回 **429**,
|
|
106
|
+
* 而同一时刻 registry 根路径是 200、CI 从另一个出口 IP 也能正常取到),
|
|
107
|
+
* 却被显示成"连不上"——把"等一下就好"误导成"网络坏了"。429/超时/离线
|
|
108
|
+
* 三者的处置完全不同,必须让用户看到真实原因。
|
|
109
|
+
* @param timeoutMs - 超时(看门狗只负责中止,不影响进程存活)。
|
|
110
|
+
*/
|
|
100
111
|
export async function fetchLatestVersion(timeoutMs = 8000) {
|
|
101
112
|
try {
|
|
102
113
|
const controller = new AbortController()
|
|
@@ -104,16 +115,28 @@ export async function fetchLatestVersion(timeoutMs = 8000) {
|
|
|
104
115
|
// 纯看门狗:调用方并不 await 它,所以必须 unref,否则这一段超时窗口
|
|
105
116
|
// 会拖住事件循环、让本该退出的短命进程(脚本、测试)多挂数秒。
|
|
106
117
|
if (typeof timer.unref === 'function') timer.unref()
|
|
118
|
+
let res
|
|
107
119
|
try {
|
|
108
|
-
|
|
109
|
-
if (!res.ok) return null
|
|
110
|
-
const data = await res.json()
|
|
111
|
-
return typeof data?.version === 'string' && data.version ? data.version : null
|
|
120
|
+
res = await fetch(REGISTRY_URL, { signal: controller.signal, headers: { accept: 'application/json' } })
|
|
112
121
|
} finally {
|
|
113
122
|
clearTimeout(timer)
|
|
114
123
|
}
|
|
115
|
-
|
|
116
|
-
|
|
124
|
+
if (res.status === 429) {
|
|
125
|
+
return { error: 'npm registry 限流(HTTP 429)——请求过于频繁,稍后会自动恢复', reason: 'rate-limited', status: 429 }
|
|
126
|
+
}
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
return { error: `npm registry 返回 HTTP ${res.status}`, reason: 'http', status: res.status }
|
|
129
|
+
}
|
|
130
|
+
const data = await res.json().catch(() => null)
|
|
131
|
+
const version = typeof data?.version === 'string' && data.version ? data.version : null
|
|
132
|
+
if (!version) return { error: 'npm registry 响应里没有可用的版本号', reason: 'malformed' }
|
|
133
|
+
return { version }
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const aborted = err?.name === 'AbortError'
|
|
136
|
+
return {
|
|
137
|
+
error: aborted ? `连接 npm registry 超时(${timeoutMs}ms)` : '无法连接 npm registry',
|
|
138
|
+
reason: aborted ? 'timeout' : 'network',
|
|
139
|
+
}
|
|
117
140
|
}
|
|
118
141
|
}
|
|
119
142
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.35",
|
|
4
4
|
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 20 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|