dsh-remote 0.8.42 → 0.8.45

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/web-attach.js CHANGED
@@ -222,14 +222,30 @@ export class WebAttach {
222
222
  * @param {object} [opts.net] - a `node:net`-compatible module (tests).
223
223
  * @param {number} [opts.localPortStart] - first local port to try.
224
224
  * @param {number} [opts.localPortMax] - how many ports to try before failing.
225
+ * @param {boolean} [opts.trackPool] - follow the pool across reconnects so a
226
+ * dropped SSH connection degrades to "disconnected" and then SELF-HEALS
227
+ * instead of being a permanent dead end (issue #54). `true` in production.
225
228
  */
226
229
  constructor(opts = {}) {
227
230
  this.net = opts.net || net
228
231
  this.localPortStart = opts.localPortStart || 3088
229
232
  this.localPortMax = opts.localPortMax || 50
233
+ /** Whether the pool's lifecycle hooks are ours to install. */
234
+ this.trackPool = opts.trackPool !== false
230
235
  /** @type {import('node:net').Server|null} */ this.server = null
231
236
  /** @type {Set<import('node:net').Socket>} */ this.sockets = new Set()
232
237
  /** @type {object|null} */ this.client = null
238
+ /** The pool this session borrowed its connection from (may be null). */
239
+ this.pool = null
240
+ /** Pool hooks we installed, so close() can hand them back untouched. */
241
+ this.prevOnReady = null
242
+ this.prevOnCloseHook = null
243
+ this.hookedPool = null
244
+ this._installedOnReady = null
245
+ this._installedOnClose = null
246
+ this.attachedClient = null
247
+ /** Bound listener, kept so it can be removed from the client. */
248
+ this.onClientClose = null
233
249
  this.localPort = 0
234
250
  this.remotePort = 0
235
251
  this.token = ''
@@ -239,6 +255,28 @@ export class WebAttach {
239
255
  this.machineId = ''
240
256
  this.command = ''
241
257
  this.startedAt = 0
258
+ /** Launch knobs remembered so a restart after a drop uses the same ones. */
259
+ this.waitSeconds = 0
260
+ this.omittedNoOpen = false
261
+ /** True while a reconnect-triggered re-resolve is in flight. */
262
+ this._resolving = false
263
+ /** True once the borrowed connection has been lost (drives recovery). */
264
+ this.connectionLost = false
265
+ /** undefined = "not asked yet"; false = the remote process is provably gone. */
266
+ this._remoteAlive = undefined
267
+ /** Set by close(): nothing may re-arm the tunnel afterwards. */
268
+ this.closed = false
269
+ /** Set once the borrowed connection has gone away (see _clientLost). */
270
+ this.lastError = ''
271
+ /**
272
+ * A listener that throws EARNS an 'error' event on the emitting object, and
273
+ * an 'error' with no listener is itself fatal — the very crash this module
274
+ * exists to prevent. Nothing here is expected to throw, but a lifecycle
275
+ * callback running on someone else's event loop must never be the reason
276
+ * the host process dies, so both hooks are wrapped.
277
+ */
278
+ this._onReady = (client) => { try { this._attach(client) } catch { /* never fatal */ } }
279
+ this._onPoolClose = () => { try { this._clientLost('ssh connection closed') } catch { /* never fatal */ } }
242
280
  }
243
281
 
244
282
  /** Whether the tunnel is currently listening. */
@@ -268,11 +306,17 @@ export class WebAttach {
268
306
  if (!pool) throw new Error('web-attach: a machine pool is required')
269
307
  this.machineId = String(machineId || '')
270
308
  this.command = command
309
+ this.waitSeconds = Number(waitSeconds) || 45
271
310
 
272
311
  const exec = spec.exec || ((cmd, opts) => pool.exec(cmd, opts))
273
312
  // Connecting first also surfaces credential/host-key problems BEFORE we
274
- // start a remote process we would then have to clean up.
275
- this.client = await pool.connect()
313
+ // start a remote process we would then have to clean up. `_attach` (rather
314
+ // than a bare assignment) is what subscribes to the connection's 'close',
315
+ // so the loss is noticed the moment it happens instead of on the next
316
+ // request — which is when the old code let ssh2 throw 'Not connected' from
317
+ // inside a socket callback and take the host process with it (issue #54).
318
+ this._attach(await pool.connect())
319
+ this._followPool(pool)
276
320
 
277
321
  if (existingUrl) {
278
322
  const parsed = parseWebStart(`dsh web: ${existingUrl}`)
@@ -398,24 +442,334 @@ export class WebAttach {
398
442
  })
399
443
  }
400
444
 
445
+ /**
446
+ * Borrow the given connection AND subscribe to its lifecycle.
447
+ *
448
+ * Idempotent for the same client (open() calls it once, the pool's `onReady`
449
+ * calls it again on every reconnect), and it always drops the previous
450
+ * client's listener first, so one session never accumulates listeners.
451
+ *
452
+ * @param {object|null} client - the live ssh2 client / OpenSshConnection.
453
+ */
454
+ _attach(client) {
455
+ if (!client) return
456
+ if (this.attachedClient && this.attachedClient !== client && this.onClientClose) {
457
+ // A different client replaced the one we borrowed (pool reconnect). Stop
458
+ // listening to the old one; its own 'close' has either fired already or
459
+ // belongs to a connection nobody uses any more.
460
+ try { this.attachedClient.removeListener('close', this.onClientClose) } catch { /* already gone */ }
461
+ }
462
+ this.client = client
463
+ if (this.attachedClient === client) return
464
+ this.attachedClient = client
465
+ this.onClientClose = () => { this._clientLost('ssh connection closed') }
466
+ // Without this, the object keeps handing out a connection that is already
467
+ // closed, and `forwardOut` then throws SYNCHRONOUSLY (issue #54).
468
+ try { client.on('close', this.onClientClose) } catch { /* not an emitter: nothing to watch */ }
469
+ }
470
+
471
+ /** The borrowed connection is gone: keep the session, drop the dead handle. */
472
+ _clientLost(reason) {
473
+ this.client = null
474
+ this.connectionLost = true
475
+ this.lastError = String(reason || 'ssh connection closed')
476
+ }
477
+
478
+ /**
479
+ * Register lifecycle hooks on the chosen pool so this tunnel follows it.
480
+ *
481
+ * The pool is the one place that both NOTICES a dead connection and later
482
+ * obtains a fresh one, and it exposes exactly two hooks for that:
483
+ * • `onCloseHook` — fires from `close()` and `invalidate()` (the latter is
484
+ * the "channel open failed, the pooled connection died server-side"
485
+ * self-heal path, which is the likeliest trigger);
486
+ * • `onReady` — fires after a successful (re)connect.
487
+ *
488
+ * Both are SINGLE-SLOT properties that the plugin already uses for
489
+ * `ForwardManager` (index.js), so this session CHAINS them rather than
490
+ * clobbering the forwards manager, and `_detachPool()` restores exactly what
491
+ * was there — the plugin may legitimately have none, one, or both.
492
+ *
493
+ * A real reconnect is the only way the remote port can move, so once the pool
494
+ * reports a new connection we must re-resolve (token, port) from the remote
495
+ * log. `onReady` itself cannot await: the pool fires it with the client in
496
+ * hand, so the re-resolve is kicked off and its failure is recorded rather
497
+ * than thrown into the pool's callback.
498
+ *
499
+ * @param {object} pool - the pool this session attached through.
500
+ */
501
+ _followPool(pool) {
502
+ if (!this.trackPool || this.closed || !pool || this.hookedPool === pool) return
503
+ this._detachPool()
504
+ this.pool = pool
505
+ this.hookedPool = pool
506
+ const prevReady = typeof pool.onReady === 'function' ? pool.onReady : null
507
+ const prevClose = typeof pool.onCloseHook === 'function' ? pool.onCloseHook : null
508
+ this.prevOnReady = prevReady
509
+ this.prevOnCloseHook = prevClose
510
+ const installedReady = (client) => {
511
+ this._attach(client)
512
+ if (prevReady) { try { prevReady(client) } catch { /* the forwards manager's problem, not ours */ } }
513
+ // A reconnect lands on a NEW remote process only if the old one died with
514
+ // the machine; re-read the log to find out which. The re-resolve runs off
515
+ // the event loop because the pool cannot await it — a hook that throws
516
+ // would earn an 'error' event on the pool, and an unhandled rejection
517
+ // would be fatal.
518
+ if (!this.closed && this.remotePort && !this._resolving) {
519
+ this._resolving = true
520
+ Promise.resolve()
521
+ .then(() => this._refreshRemotePort())
522
+ .catch((err) => { this.lastError = String((err && err.message) || err) })
523
+ .then(() => { this._resolving = false })
524
+ }
525
+ }
526
+ const installedClose = () => {
527
+ this._clientLost('ssh connection closed')
528
+ if (prevClose) { try { prevClose() } catch { /* never mask our own report */ } }
529
+ }
530
+ this._installedOnReady = installedReady
531
+ this._installedOnClose = installedClose
532
+ pool.onReady = installedReady
533
+ pool.onCloseHook = installedClose
534
+ }
535
+
536
+ /**
537
+ * Hand the pool's hooks back.
538
+ *
539
+ * Each slot is restored ONLY when it still holds the wrapper this session
540
+ * installed. The pool's hooks are single-slot, so if something else has taken
541
+ * the slot over in the meantime, putting a stale callback back would be worse
542
+ * than leaving it alone — it would silently disconnect a newer owner.
543
+ */
544
+ _detachPool() {
545
+ const pool = this.hookedPool
546
+ const mineReady = this._installedOnReady
547
+ const mineClose = this._installedOnClose
548
+ this.hookedPool = null
549
+ this._installedOnReady = null
550
+ this._installedOnClose = null
551
+ if (!pool) return
552
+ if (mineReady && pool.onReady === mineReady) pool.onReady = this.prevOnReady
553
+ if (mineClose && pool.onCloseHook === mineClose) pool.onCloseHook = this.prevOnCloseHook
554
+ this.prevOnReady = null
555
+ this.prevOnCloseHook = null
556
+ }
557
+
558
+ /**
559
+ * True when the borrowed connection was LOST and this is a session we can
560
+ * recover: we launched the remote process ourselves (so we hold its PID and
561
+ * log) and the tunnel is listening (so clients can actually be served).
562
+ *
563
+ * The `connectionLost` flag is deliberately separate from `this.client`: the
564
+ * pool's `onReady` re-attaches the new client BEFORE the re-resolve runs, so a
565
+ * check on `!this.client` would be false by then and recovery would never
566
+ * happen.
567
+ */
568
+ get _needsReResolve() {
569
+ return !!(this.connectionLost && !this.closed && this.logPath && this.remotePid && this.localPort)
570
+ }
571
+
572
+ /**
573
+ * Re-resolve the remote port after a reconnect, restarting the remote `dsh web`
574
+ * when it did not survive.
575
+ *
576
+ * Reaching a remote process needs a live SSH connection, so this reconnects
577
+ * through the pool first — the pool reports `onReady` for that same client in
578
+ * production, and in a test double the callback simply never fires.
579
+ */
580
+ async _refreshRemotePort() {
581
+ if (!this._needsReResolve) return
582
+ const pool = this.pool
583
+ if (!pool || (typeof pool.connect !== 'function' && typeof pool.exec !== 'function')) return
584
+ const exec = (cmd, opts) => (typeof pool.exec === 'function'
585
+ ? pool.exec(cmd, opts)
586
+ : Promise.reject(new Error('web-attach: the pool cannot run commands')))
587
+ if (!this.client) await pool.connect()
588
+ const match = await this._resolveRemotePort(exec)
589
+ this.remotePort = match.port
590
+ if (match.token) this.token = match.token
591
+ this.connectionLost = false
592
+ this.lastError = ''
593
+ }
594
+
595
+ /**
596
+ * Read this session's remote log and decide which port is live.
597
+ *
598
+ * The startup line is the ONLY source of the port (the launch deliberately
599
+ * uses `--port 0`), so after the remote process died and was restarted the log
600
+ * holds BOTH lines — the dead one and the new one — and neither position
601
+ * identifies the live one. Evidence decides:
602
+ * • a process that is still alive on the remote is probed first, and when
603
+ * the port it is LISTENING on is one of the logged ports, that is the
604
+ * answer (the `dsh web: …` line names its own port, so this is a
605
+ * confirmation rather than a second guess);
606
+ * • otherwise the newest logged port wins, which is right in every
607
+ * single-process case and is the best available answer while the remote is
608
+ * still booting (the next reconnect retries).
609
+ *
610
+ * @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
611
+ * @returns {Promise<{port: number, token: string, restarted: boolean}>}
612
+ */
613
+ async _resolveRemotePort(exec) {
614
+ this._remoteAlive = await this._remoteProcessAlive(exec)
615
+ if (!this._remoteAlive) return this._restartRemote(exec)
616
+
617
+ let out = ''
618
+ try {
619
+ const res = await exec(`cat "$HOME/${this.logPath}" 2>/dev/null`, { timeoutMs: 15000 })
620
+ out = String((res && res.stdout) || '')
621
+ } catch { /* an unreadable log: the listening probe below is the fallback */ }
622
+ const lines = [...out.matchAll(/dsh web:\s*(http:\/\/[^\s]+)/g)]
623
+ .map((m) => parseWebStart(`dsh web: ${m[1]}`))
624
+ .filter(Boolean)
625
+
626
+ // What the recorded process actually listens on beats any guess from the
627
+ // log, because a killed-and-restarted remote leaves BOTH startup lines in
628
+ // the log and neither position says which one is live.
629
+ const listening = await this._listeningRemotePort(exec)
630
+ if (listening) {
631
+ const match = lines.find((l) => l.port === listening)
632
+ return { port: listening, token: (match && match.token) || this.token, restarted: false }
633
+ }
634
+ if (lines.length) {
635
+ const newest = lines[lines.length - 1]
636
+ return { port: newest.port, token: newest.token, restarted: false }
637
+ }
638
+ // Alive but silent and not listening yet: the remote is booting. Keep the
639
+ // last known port and let the next reconnect re-resolve.
640
+ return { port: this.remotePort, token: this.token, restarted: false }
641
+ }
642
+
643
+ /**
644
+ * Whether the remote process THIS session started still exists.
645
+ *
646
+ * Verified through `/proc`, which is Linux-only; a host that cannot answer (or
647
+ * a failed probe) is treated as ALIVE on purpose. Restarting a server that was
648
+ * never dead would spawn a second one and leak a port, whereas assuming life
649
+ * merely retries a port that turns out to be stale.
650
+ *
651
+ * @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
652
+ * @returns {Promise<boolean>}
653
+ */
654
+ async _remoteProcessAlive(exec) {
655
+ const pid = Number(this.remotePid) || 0
656
+ if (pid < 1) return true
657
+ try {
658
+ const res = await exec(
659
+ `if [ -d /proc ]; then { [ -d /proc/${pid} ] && echo ALIVE; } || echo GONE; else echo ALIVE; fi`,
660
+ { timeoutMs: 15000 },
661
+ )
662
+ return !/GONE/.test(String((res && res.stdout) || ''))
663
+ } catch { return true }
664
+ }
665
+
666
+ /**
667
+ * Ask the remote which loopback port the recorded PID listens on (0 = none).
668
+ *
669
+ * Two `/proc` reads are needed because a TCP listener appears in a socket
670
+ * table (`/proc/net/tcp{,6}`), not in that process's link list: the fd
671
+ * symlinks give the socket INODE, and the table maps that inode to a port.
672
+ * Matching on the inode rather than the port is what makes this an
673
+ * observation instead of a second guess.
674
+ */
675
+ async _listeningRemotePort(exec) {
676
+ const pid = Number(this.remotePid) || 0
677
+ if (pid < 1) return 0
678
+ let inodes = ''
679
+ try {
680
+ // `ls -l` on a process's own fds needs no privileges for the same uid.
681
+ const res = await exec(
682
+ `ls -l /proc/${pid}/fd 2>/dev/null | sed -n 's/.*socket:\\[\\([0-9]*\\)\\].*/\\1/p' | sort -u | tr '\\n' ' '`,
683
+ { timeoutMs: 15000 },
684
+ )
685
+ inodes = String((res && res.stdout) || '').trim()
686
+ } catch { return 0 }
687
+ if (!inodes) return 0
688
+ const want = new Set(inodes.split(/\s+/).filter(Boolean))
689
+ // `/proc/net/tcp` addresses are little-endian hex, so loopback is
690
+ // `0100007F` (v4) and `00000000000000000000000001000000` (v6 `::1`).
691
+ const LOOPBACK = new Set(['0100007F', '00000000000000000000000001000000'])
692
+ for (const file of ['/proc/net/tcp', '/proc/net/tcp6']) {
693
+ let text = ''
694
+ try {
695
+ const res = await exec(`cat ${file} 2>/dev/null`, { timeoutMs: 15000 })
696
+ text = String((res && res.stdout) || '')
697
+ } catch { continue }
698
+ for (const line of text.split('\n').slice(1)) {
699
+ const cols = line.trim().split(/\s+/)
700
+ // sl local_address rem_address st tx:rx tr:tm->when retrnsmt uid timeout inode
701
+ if (cols.length < 10) continue
702
+ if (cols[3] !== '0A') continue // TCP_LISTEN
703
+ if (!want.has(cols[9])) continue
704
+ const [addr, portHex] = String(cols[1]).split(':')
705
+ if (!LOOPBACK.has(String(addr).toUpperCase())) continue
706
+ const port = parseInt(portHex, 16)
707
+ if (Number.isInteger(port) && port > 0 && port < 65536) return port
708
+ }
709
+ }
710
+ return 0
711
+ }
712
+
713
+ /**
714
+ * Relaunch the remote `dsh web` because the previous process is gone.
715
+ *
716
+ * A restart CANNOT change the listening port locally, and a caller holding a
717
+ * stale port silently talks to the wrong place at worst and hangs at best, so
718
+ * the two facts the caller needs are checked: this is a session that launched
719
+ * its own process (an attach onto someone else's DSH stays hands-off and can
720
+ * never restart it), and recovery is possible at all (`_refreshRemotePort`
721
+ * has already returned early when it is not).
722
+ */
723
+ async _restartRemote(exec) {
724
+ const command = this.command || 'dsh'
725
+ const waitSeconds = this.waitSeconds || 45
726
+ const timeoutMs = Math.max(30000, (waitSeconds + 20) * 1000)
727
+ let out = String(await this._launch(exec, { command, profile: 'web', logPath: this.logPath, waitSeconds, timeoutMs }, this.omittedNoOpen === true))
728
+ if (launchFailed(out) && /unknown option/i.test(out) && /--no-open/.test(out)) {
729
+ out = String(await this._launch(exec, { command, profile: 'web', logPath: this.logPath, waitSeconds, timeoutMs }, true))
730
+ this.omittedNoOpen = true
731
+ }
732
+ this.remotePid = parseRemotePid(out) || this.remotePid
733
+ const parsed = parseWebStart(out)
734
+ if (!parsed) {
735
+ const tail = out.split(TAIL_MARKER).slice(1).join(TAIL_MARKER).trim()
736
+ throw new Error('web-attach: the remote DSH could not be restarted after the SSH'
737
+ + ' connection dropped.' + explainLaunchFailure(tail || out) + (tail ? ` Remote output: ${tail}` : ''))
738
+ }
739
+ return { port: parsed.port, token: parsed.token, restarted: true }
740
+ }
741
+
401
742
  /** Carry one local socket to the remote web port over SSH. */
402
743
  _pipe(socket) {
403
744
  if (!this.client) { try { socket.destroy() } catch { /* already gone */ } return }
404
- this.client.forwardOut('127.0.0.1', 0, '127.0.0.1', this.remotePort, (err, channel) => {
405
- if (err) { try { socket.destroy() } catch { /* already gone */ } return }
406
- this.sockets.add(socket)
407
- socket.pipe(channel)
408
- channel.pipe(socket)
409
- const kill = () => {
410
- try { socket.destroy() } catch { /* already gone */ }
411
- try { channel.close() } catch { /* already closed */ }
412
- this.sockets.delete(socket)
413
- }
414
- socket.on('error', kill)
415
- channel.on('error', kill)
416
- socket.on('close', kill)
417
- channel.on('close', kill)
418
- })
745
+ // ssh2's forwardOut does NOT report a dead connection through its callback:
746
+ // when the socket is gone it THROWS 'Not connected' synchronously
747
+ // (ssh2/lib/client.js: `if (!this._sock || !isWritable(this._sock)) throw`)
748
+ // and the callback below is never reached — so `if (err)` cannot guard this
749
+ // path. Uncaught, that throw travels net.Server → TCP.onconnection and kills
750
+ // the whole host process (issue #54: every page refresh after the pooled
751
+ // connection dropped took the session, the browser and the remote orphan
752
+ // down with it). A dead tunnel is a UI state, not a reason to exit.
753
+ try {
754
+ this.client.forwardOut('127.0.0.1', 0, '127.0.0.1', this.remotePort, (err, channel) => {
755
+ if (err) { try { socket.destroy() } catch { /* already gone */ } return }
756
+ this.sockets.add(socket)
757
+ socket.pipe(channel)
758
+ channel.pipe(socket)
759
+ const kill = () => {
760
+ try { socket.destroy() } catch { /* already gone */ }
761
+ try { channel.close() } catch { /* already closed */ }
762
+ this.sockets.delete(socket)
763
+ }
764
+ socket.on('error', kill)
765
+ channel.on('error', kill)
766
+ socket.on('close', kill)
767
+ channel.on('close', kill)
768
+ })
769
+ } catch (err) {
770
+ this._clientLost(String((err && err.message) || err))
771
+ try { socket.destroy() } catch { /* already gone */ }
772
+ }
419
773
  }
420
774
 
421
775
  /**
@@ -462,6 +816,15 @@ export class WebAttach {
462
816
  * @param {(cmd: string) => Promise<unknown>} [opts.exec] - remote exec.
463
817
  */
464
818
  async close({ stopRemote = false, exec } = {}) {
819
+ // Stop following the pool BEFORE any await: a reconnect landing mid-teardown
820
+ // must not re-arm the tunnel this call is in the middle of closing.
821
+ this._detachPool()
822
+ if (this.attachedClient && this.onClientClose) {
823
+ try { this.attachedClient.removeListener('close', this.onClientClose) } catch { /* already gone */ }
824
+ }
825
+ this.attachedClient = null
826
+ this.onClientClose = null
827
+ this.pool = null
465
828
  for (const socket of [...this.sockets]) {
466
829
  try { socket.destroy() } catch { /* already gone */ }
467
830
  }
@@ -475,5 +838,9 @@ export class WebAttach {
475
838
  await Promise.resolve(exec(buildStopCommand(this.remotePid, this.logPath))).catch(() => {})
476
839
  }
477
840
  this.client = null
841
+ // The recorded PID and log path are FACTS about this session (the UI shows
842
+ // them, and `stopRemote` addresses them), so they survive; `closed` is what
843
+ // stops recovery work from being re-armed after teardown.
844
+ this.closed = true
478
845
  }
479
846
  }
package/lib/web-deploy.js CHANGED
@@ -351,6 +351,31 @@ export function versionAtLeast(a, b) {
351
351
  /** The first dsh release whose node-pty ships Linux prebuilds (measured). */
352
352
  export const PTY_FIXED_VERSION = '0.1.5-rc.2'
353
353
 
354
+ /**
355
+ * Minimum Node the remote must run, and the reason it is exactly this number.
356
+ *
357
+ * DSH's boot path imports `parseEnv` from `node:util`, and that export only
358
+ * exists from **20.12.0** (Node 21.7.0 and later have it too). Measured on a
359
+ * real host with two nodes installed:
360
+ *
361
+ * /usr/bin/node (v18.19.1) → parseEnv: undefined
362
+ * ~/.nvm/versions/node/v24.22.0/bin/node (v24.22.0) → parseEnv: function
363
+ *
364
+ * The failure mode is what makes this worth a BLOCKER rather than a warning: npm
365
+ * only prints EBADENGINE (it does not refuse), so all ~535 packages install
366
+ * cleanly and the crash arrives afterwards as
367
+ *
368
+ * SyntaxError: The requested module 'node:util' does not provide an export
369
+ * named 'parseEnv' at .../@deepseek-ai/dsh-app-boot/lib/index.js:4
370
+ *
371
+ * which does not read as "your node is too old". Checking the version BEFORE the
372
+ * install is the only way to turn that into an actionable message.
373
+ *
374
+ * Reference: Node.js 20.12.0 release notes, "Loading and parsing environment
375
+ * variables" — https://nodejs.org/en/blog/release/v20.12.0
376
+ */
377
+ export const MIN_NODE_VERSION = '20.12.0'
378
+
354
379
  /**
355
380
  * Turn facts into a verdict: what is wrong, why, and what to do.
356
381
  *
@@ -394,6 +419,17 @@ export function judgeProbe(facts, opts = {}) {
394
419
  add('NO_NODE', 'blocker', '远端没有 node',
395
420
  '自动部署需要 node 与 npm。',
396
421
  '先在该机器安装 Node.js(nvm 或系统包管理器),再回来重试。')
422
+ } else if (f.nodeVersion && !versionAtLeast(f.nodeVersion, MIN_NODE_VERSION)) {
423
+ // The install itself SUCCEEDS on this node — npm only warns (EBADENGINE) —
424
+ // and the failure surfaces later as a parseEnv import error that does not
425
+ // read as a version problem. So the check has to happen before the install.
426
+ add('NODE_TOO_OLD', 'blocker',
427
+ `远端 node ${f.nodeVersion} 版本过低(需要 ≥ ${MIN_NODE_VERSION})`,
428
+ `dsh 启动时要 import node:util 的 parseEnv,该导出从 Node ${MIN_NODE_VERSION} 起才有。`
429
+ + '在这个版本上 npm 只会给 EBADENGINE 警告、仍然把包装完,'
430
+ + '直到启动时才崩:SyntaxError: The requested module \'node:util\' does not provide an export named \'parseEnv\'。',
431
+ `升级该机器的 node 到 ${MIN_NODE_VERSION} 以上(例如 nvm install 22),`
432
+ + '并把 `webAttachCommand` 指向新 node 的绝对路径(如 ~/.nvm/versions/node/v22.x/bin/node <前缀>/node_modules/@deepseek-ai/dsh/lib/bin.js)。')
397
433
  }
398
434
  if (!f.npm) {
399
435
  add('NO_NPM', 'blocker', '远端没有 npm',
@@ -474,7 +510,13 @@ export function judgeProbe(facts, opts = {}) {
474
510
  const blockers = findings.filter((x) => x.severity === 'blocker')
475
511
  // A Windows remote with Git Bash can be installed into: pool.exec pipes every
476
512
  // command through `bash -s`, so the POSIX plan works there.
477
- const canAutoInstall = !!f.node && !!f.npm && (!f.windows || !!f.gitBash)
513
+ //
514
+ // A node older than MIN_NODE_VERSION makes an install POINTLESS as well as
515
+ // failing: the packages land, then the binary cannot boot. Promising
516
+ // "can auto-install: yes" there is what sent the reporter of issue #54 through
517
+ // a 535-package install that ended in a parseEnv SyntaxError.
518
+ const nodeUsable = !!f.node && (!f.nodeVersion || versionAtLeast(f.nodeVersion, MIN_NODE_VERSION))
519
+ const canAutoInstall = nodeUsable && !!f.npm && (!f.windows || !!f.gitBash)
478
520
 
479
521
  // Which command should the attach flow use? A verified deployment wins, then a
480
522
  // usable dsh already on PATH.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.42",
3
+ "version": "0.8.45",
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 22 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, one-click remote dsh deploy + web UI attach, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
5
5
  "license": "MIT",
6
6
  "author": "flymysql <flyphp@outlook.com>",
@@ -80,6 +80,7 @@
80
80
  "@deepseek-ai/dsh-session": "^0.1.0-rc.8",
81
81
  "@deepseek-ai/dsh-timeout": "^0.1.0-rc.8",
82
82
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
83
+ "react": "^18.3.1",
83
84
  "semver": "^7.8.5"
84
85
  },
85
86
  "scripts": {