Fix remote Node.js detection for nvm, mise, asdf, and volta (#6037)
* Fix remote Node.js detection for nvm, mise, asdf, and volta Remote Node resolution failed when node was installed via a version manager (nvm with custom NVM_DIR, mise, asdf, volta) or when the user's login shell was zsh/fish rather than bash. Root cause: the SSH exec transport runs every command under /bin/sh, which never sources shell init files. The only init-aware path was a hardcoded `bash -lc` fallback that missed zsh/fish users and was never reached for the newer version managers. nvm was handled by guessing ~/.nvm (breaking custom NVM_DIR), and mise/asdf/volta had no probes at all. There was also no version gate, so nvm's highest-version glob could return Node 8/10/12 and crash the relay on launch. Fix: resolve via the user's own $SHELL as a login shell first (the only path that runs nvm.sh / mise activate / asdf.sh init hooks), then fall back to direct path probes for all major managers (nvm respecting $NVM_DIR, fnm, mise, asdf, volta, n) plus system locations. Every candidate is version-checked against the relay's Node 18+ requirement before being accepted. * Address CodeRabbit review: probes-first, no || short-circuit - Reorder to path-probes first (deterministic, doesn't depend on shell rc-file semantics where bash -lc skips .bashrc and zsh -lc skips .zshrc — exactly where nvm/mise/asdf hooks live). - Join probes with newlines instead of || so an empty `ls | sort -V | tail -1` (exit 0) doesn't mask later probes. - Deduplicate candidate paths before version-checking. - Drop unreachable mock and fix misleading $SHELL-unset test name. - Login shell is now a fallback for custom ~/.profile PATH setups. * Fix remote Node path probing portability Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
a0d9505ba5
commit
44935d4ccb
|
|
@ -0,0 +1,283 @@
|
|||
import { execFileSync } from 'node:child_process'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
|
||||
const execCommandMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./ssh-relay-deploy-helpers', () => ({
|
||||
execCommand: execCommandMock
|
||||
}))
|
||||
|
||||
// Why: await import() is required so vi.mock() above registers before the
|
||||
// module under test is evaluated. Static import would bypass the mock.
|
||||
const { resolveRemoteNodePath } = await import('./ssh-remote-node-resolution')
|
||||
|
||||
const conn = {} as SshConnection
|
||||
|
||||
describe('resolveRemoteNodePath', () => {
|
||||
beforeEach(() => {
|
||||
execCommandMock.mockReset()
|
||||
})
|
||||
|
||||
// ── Path-probe strategy (runs first) ───────────────────────────────────
|
||||
|
||||
it('resolves system node via the path probe', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('/usr/local/bin/node\n') // path probe
|
||||
.mockResolvedValueOnce('v20.0.0\n') // version check
|
||||
|
||||
await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node')
|
||||
})
|
||||
|
||||
it('probes mise install directories', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('/home/u/.local/share/mise/installs/node/20/bin/node\n')
|
||||
.mockResolvedValueOnce('v20.11.0\n')
|
||||
|
||||
await resolveRemoteNodePath(conn)
|
||||
|
||||
const callScript = execCommandMock.mock.calls[0]![1] as string
|
||||
expect(callScript).toContain('"$HOME/.local/share/mise/installs/node"/*/bin/node')
|
||||
})
|
||||
|
||||
it('probes asdf install directories', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('/home/u/.asdf/installs/nodejs/20.11.0/bin/node\n')
|
||||
.mockResolvedValueOnce('v20.11.0\n')
|
||||
|
||||
await resolveRemoteNodePath(conn)
|
||||
|
||||
const callScript = execCommandMock.mock.calls[0]![1] as string
|
||||
expect(callScript).toContain('"$HOME/.asdf/installs/nodejs"/*/bin/node')
|
||||
})
|
||||
|
||||
it('probes volta bin directory', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('/home/u/.volta/bin/node\n')
|
||||
.mockResolvedValueOnce('v20.11.0\n')
|
||||
|
||||
await resolveRemoteNodePath(conn)
|
||||
|
||||
const callScript = execCommandMock.mock.calls[0]![1] as string
|
||||
expect(callScript).toContain('$HOME/.volta/bin/node')
|
||||
})
|
||||
|
||||
it('respects a custom NVM_DIR instead of hardcoding $HOME/.nvm', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('/custom/nvm/versions/node/v20.11.0/bin/node\n')
|
||||
.mockResolvedValueOnce('v20.11.0\n')
|
||||
|
||||
await resolveRemoteNodePath(conn)
|
||||
|
||||
const callScript = execCommandMock.mock.calls[0]![1] as string
|
||||
expect(callScript).toContain('nvm_dirs=${NVM_DIR:-"$HOME/.nvm"}')
|
||||
expect(callScript).toContain('NVM_DIR[[:space:]]*=')
|
||||
expect(callScript).toContain('"$nvm_dir"/versions/node/*/bin/node')
|
||||
})
|
||||
|
||||
it('quotes version-manager directory prefixes while leaving globs active', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('/home/u/.fnm/node-versions/v20.11.0/installation/bin/node\n')
|
||||
.mockResolvedValueOnce('v20.11.0\n')
|
||||
|
||||
await resolveRemoteNodePath(conn)
|
||||
|
||||
const callScript = execCommandMock.mock.calls[0]![1] as string
|
||||
expect(callScript).toContain('"$HOME/.fnm/node-versions"/*/installation/bin/node')
|
||||
expect(callScript).toContain('"$HOME/.local/share/mise/installs/node"/*/bin/node')
|
||||
expect(callScript).toContain('"$HOME/.asdf/installs/nodejs"/*/bin/node')
|
||||
})
|
||||
|
||||
it('does not depend on GNU sort when probing version-manager directories', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n')
|
||||
.mockResolvedValueOnce('v20.11.0\n')
|
||||
|
||||
await resolveRemoteNodePath(conn)
|
||||
|
||||
const callScript = execCommandMock.mock.calls[0]![1] as string
|
||||
expect(callScript).not.toContain('sort -V')
|
||||
})
|
||||
|
||||
it('keeps the path-probe script successful when optional directories are missing', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n')
|
||||
.mockResolvedValueOnce('v20.11.0\n')
|
||||
|
||||
await resolveRemoteNodePath(conn)
|
||||
|
||||
const callScript = execCommandMock.mock.calls[0]![1] as string
|
||||
expect(callScript.trimEnd()).toMatch(/\ntrue$/)
|
||||
})
|
||||
|
||||
it('expands tilde NVM_DIR assignments from shell dotfiles', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n')
|
||||
.mockResolvedValueOnce('v20.11.0\n')
|
||||
|
||||
await resolveRemoteNodePath(conn)
|
||||
|
||||
const callScript = execCommandMock.mock.calls[0]![1] as string
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), 'orca-nvm-probe-'))
|
||||
try {
|
||||
const nodePath = path.join(home, 'tilde-nvm/versions/node/v20.11.0/bin/node')
|
||||
mkdirSync(path.dirname(nodePath), { recursive: true })
|
||||
writeFileSync(nodePath, '#!/bin/sh\nprintf "v20.11.0\\n"\n')
|
||||
chmodSync(nodePath, 0o755)
|
||||
writeFileSync(path.join(home, '.zshrc'), 'export NVM_DIR=~/tilde-nvm\n')
|
||||
|
||||
const output = execFileSync('/bin/sh', ['-c', callScript], {
|
||||
encoding: 'utf8',
|
||||
env: { HOME: home, PATH: '/usr/bin:/bin' }
|
||||
})
|
||||
|
||||
expect(output.split('\n')).toContain(nodePath)
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('joins probes with newlines, not ||, so a missing dir does not mask later probes', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('/usr/local/bin/node\n')
|
||||
.mockResolvedValueOnce('v20.0.0\n')
|
||||
|
||||
await resolveRemoteNodePath(conn)
|
||||
|
||||
const callScript = execCommandMock.mock.calls[0]![1] as string
|
||||
// Why: an `||` chain would stop after the first successful probe and hide
|
||||
// later version managers that may hold the first usable Node.
|
||||
expect(callScript).not.toMatch(/node\b.*\|\|/)
|
||||
})
|
||||
|
||||
it('rejects a path-probe candidate whose version is below the minimum', async () => {
|
||||
// Probe returns two candidates; the first (v10) is too old, the second
|
||||
// (v20) must be selected instead.
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce(
|
||||
'/home/u/.nvm/versions/node/v10.24.1/bin/node\n/home/u/.nvm/versions/node/v20.11.0/bin/node\n'
|
||||
)
|
||||
.mockResolvedValueOnce('v10.24.1\n') // first candidate fails the gate
|
||||
.mockResolvedValueOnce('v20.11.0\n') // second candidate passes
|
||||
|
||||
await expect(resolveRemoteNodePath(conn)).resolves.toBe(
|
||||
'/home/u/.nvm/versions/node/v20.11.0/bin/node'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts Node 18 (the exact minimum) as valid', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('/usr/local/bin/node\n')
|
||||
.mockResolvedValueOnce('v18.0.0\n')
|
||||
|
||||
await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node')
|
||||
})
|
||||
|
||||
it('deduplicates repeated candidate paths before version-checking', async () => {
|
||||
// Why: some managers leave stale shims that resolve to the same binary;
|
||||
// we should not version-check the same path twice.
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('/usr/local/bin/node\n/usr/local/bin/node\n')
|
||||
.mockResolvedValueOnce('v20.0.0\n')
|
||||
|
||||
await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node')
|
||||
expect(execCommandMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('falls back to the login shell when path probes find nothing', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('\n') // path probe: empty
|
||||
.mockResolvedValueOnce('/bin/zsh') // $SHELL
|
||||
.mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n') // command -v node
|
||||
.mockResolvedValueOnce('v20.11.0\n')
|
||||
|
||||
await expect(resolveRemoteNodePath(conn)).resolves.toBe(
|
||||
'/home/u/.nvm/versions/node/v20.11.0/bin/node'
|
||||
)
|
||||
|
||||
expect(execCommandMock).toHaveBeenNthCalledWith(3, conn, `'/bin/zsh' -lc 'command -v node'`, {
|
||||
wrapCommand: false,
|
||||
timeoutMs: 8_000
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the login shell when every path-probe candidate is too old', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('/old/node\n') // path probe
|
||||
.mockResolvedValueOnce('v10.24.1\n') // too old
|
||||
.mockResolvedValueOnce('/bin/bash') // $SHELL
|
||||
.mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n')
|
||||
.mockResolvedValueOnce('v20.11.0\n')
|
||||
|
||||
await expect(resolveRemoteNodePath(conn)).resolves.toBe(
|
||||
'/home/u/.nvm/versions/node/v20.11.0/bin/node'
|
||||
)
|
||||
})
|
||||
|
||||
// ── Login-shell strategy (fallback) ───────────────────────────────────
|
||||
|
||||
it('respects a non-default $SHELL instead of hardcoding bash', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('\n') // path probe: empty
|
||||
.mockResolvedValueOnce('/usr/bin/fish') // $SHELL
|
||||
.mockResolvedValueOnce('/opt/homebrew/bin/node\n')
|
||||
.mockResolvedValueOnce('v22.0.0\n')
|
||||
|
||||
await resolveRemoteNodePath(conn)
|
||||
|
||||
expect(execCommandMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
conn,
|
||||
`'/usr/bin/fish' -lc 'command -v node'`,
|
||||
{ wrapCommand: false, timeoutMs: 8_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('uses /bin/sh when the remote shell expansion falls back to it', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('\n') // path probe: empty
|
||||
.mockResolvedValueOnce('/bin/sh\n') // ${SHELL:-/bin/sh}
|
||||
.mockResolvedValueOnce('/usr/local/bin/node\n')
|
||||
.mockResolvedValueOnce('v20.0.0\n')
|
||||
|
||||
await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node')
|
||||
expect(execCommandMock).toHaveBeenNthCalledWith(3, conn, `'/bin/sh' -c 'command -v node'`, {
|
||||
wrapCommand: false,
|
||||
timeoutMs: 8_000
|
||||
})
|
||||
})
|
||||
|
||||
// ── Failure ───────────────────────────────────────────────────────────
|
||||
|
||||
it('throws when both strategies find no usable node', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('\n') // path probe: empty
|
||||
.mockResolvedValueOnce('/bin/bash') // $SHELL
|
||||
.mockResolvedValueOnce('\n') // command -v node: empty
|
||||
|
||||
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/)
|
||||
})
|
||||
|
||||
it('throws when the path-probe exec fails and the login shell finds nothing', async () => {
|
||||
execCommandMock
|
||||
.mockRejectedValueOnce(new Error('SSH exec channel failed')) // path probe errors
|
||||
.mockResolvedValueOnce('/bin/zsh') // $SHELL
|
||||
.mockResolvedValueOnce('\n') // command -v node: empty
|
||||
|
||||
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/)
|
||||
})
|
||||
|
||||
it('throws when every candidate across both strategies is below the minimum', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('/old/node\n') // path probe
|
||||
.mockResolvedValueOnce('v8.17.0\n') // too old
|
||||
.mockResolvedValueOnce('/bin/bash') // $SHELL
|
||||
.mockResolvedValueOnce('/old/node2\n') // login shell
|
||||
.mockResolvedValueOnce('v6.17.0\n') // too old
|
||||
|
||||
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,12 +1,20 @@
|
|||
import type { SshConnection } from './ssh-connection'
|
||||
import { execCommand } from './ssh-relay-deploy-helpers'
|
||||
import { shellEscape } from './ssh-connection-utils'
|
||||
import type { RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { isWindowsRemoteHost, normalizeWindowsRemotePath } from './ssh-remote-platform'
|
||||
import { powerShellCommand } from './ssh-remote-powershell'
|
||||
import { execCommand } from './ssh-relay-deploy-helpers'
|
||||
|
||||
// Why: the relay requires Node.js 18+. Version managers like nvm keep every
|
||||
// installed version on disk, so a naive "highest version" glob can hand back
|
||||
// Node 8/10/12 and crash the relay on launch. Gate every candidate on this.
|
||||
const MIN_NODE_MAJOR = 18
|
||||
|
||||
// Why: the login-shell fallback catches custom PATH setups in ~/.profile that
|
||||
// the path probes don't cover. Interactive configs (conda prompts, etc.) can
|
||||
// hang a login shell, so keep this short.
|
||||
const LOGIN_SHELL_PROBE_TIMEOUT_MS = 8_000
|
||||
|
||||
// Why: non-login SSH shells (the default for `exec`) don't source
|
||||
// .bashrc/.zshrc, so node installed via nvm/fnm/Homebrew isn't in PATH.
|
||||
// We try common locations and fall back to a login-shell `which`.
|
||||
export async function resolveRemoteNodePath(
|
||||
conn: SshConnection,
|
||||
host?: RemoteHostPlatform
|
||||
|
|
@ -15,48 +23,163 @@ export async function resolveRemoteNodePath(
|
|||
return resolveRemoteWindowsNodePath(conn)
|
||||
}
|
||||
|
||||
const script = [
|
||||
'command -v node 2>/dev/null',
|
||||
'command -v /usr/local/bin/node 2>/dev/null',
|
||||
'command -v /opt/homebrew/bin/node 2>/dev/null',
|
||||
// Why: nvm installs into a versioned directory. `ls -1` sorts
|
||||
// alphabetically, which misorders versions (e.g. v9 > v18). Pipe
|
||||
// through `sort -V` (version sort) so we pick the highest version.
|
||||
'ls -1 $HOME/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V | tail -1',
|
||||
'command -v $HOME/.local/bin/node 2>/dev/null',
|
||||
'command -v $HOME/.fnm/aliases/default/bin/node 2>/dev/null'
|
||||
].join(' || ')
|
||||
|
||||
try {
|
||||
const result = await execCommand(conn, script)
|
||||
const nodePath = result.trim().split('\n')[0]
|
||||
if (nodePath) {
|
||||
console.log(`[ssh-relay] Found node at: ${nodePath}`)
|
||||
return nodePath
|
||||
}
|
||||
} catch {
|
||||
// Fall through to login shell attempt
|
||||
// Strategy 1: probe well-known install directories for every common Node
|
||||
// version manager (nvm, fnm, mise, asdf, volta, n) plus system locations.
|
||||
// This doesn't depend on shell startup-file semantics — bash -lc skips
|
||||
// .bashrc and zsh -lc skips .zshrc, but those are exactly the files where
|
||||
// nvm/mise/asdf hooks live. Probing directories directly is deterministic.
|
||||
const probedPath = await tryResolveViaKnownPaths(conn)
|
||||
if (probedPath) {
|
||||
return probedPath
|
||||
}
|
||||
|
||||
// Why: last resort — source the full login profile. This is separated into
|
||||
// its own exec because `bash -lc` can hang on remotes with interactive
|
||||
// shell configs (conda prompts, etc.). If this times out, the error message
|
||||
// from execCommand will tell us it was the login shell attempt.
|
||||
try {
|
||||
console.log('[ssh-relay] Trying login shell to find node...')
|
||||
const result = await execCommand(conn, "bash -lc 'command -v node' 2>/dev/null")
|
||||
const nodePath = result.trim().split('\n')[0]
|
||||
if (nodePath) {
|
||||
console.log(`[ssh-relay] Found node via login shell: ${nodePath}`)
|
||||
return nodePath
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
// Strategy 2 (fallback): ask the user's login shell. Catches custom PATH
|
||||
// setups in ~/.profile / ~/.bash_profile that the probes don't cover.
|
||||
const loginShellPath = await tryResolveViaLoginShell(conn)
|
||||
if (loginShellPath) {
|
||||
return loginShellPath
|
||||
}
|
||||
|
||||
throwNodeNotFound()
|
||||
}
|
||||
|
||||
// Probe the on-disk install directories of every common Node version manager
|
||||
// plus system package-manager locations. Every probe runs unconditionally so
|
||||
// a missing directory prints nothing rather than short-circuiting later
|
||||
// probes. Returns the first candidate that meets the minimum version.
|
||||
async function tryResolveViaKnownPaths(conn: SshConnection): Promise<string | null> {
|
||||
const script = `
|
||||
command -v node 2>/dev/null
|
||||
nvm_dirs=\${NVM_DIR:-"$HOME/.nvm"}
|
||||
for nvm_file in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"
|
||||
do
|
||||
[ -r "$nvm_file" ] || continue
|
||||
nvm_dir_from_file=$(sed -n 's/^[[:space:]]*export[[:space:]][[:space:]]*NVM_DIR[[:space:]]*=[[:space:]]*//p; s/^[[:space:]]*NVM_DIR[[:space:]]*=[[:space:]]*//p' "$nvm_file" | tail -n 1)
|
||||
case "$nvm_dir_from_file" in
|
||||
\\"*\\") nvm_dir_from_file=\${nvm_dir_from_file#\\"}; nvm_dir_from_file=\${nvm_dir_from_file%%\\"*} ;;
|
||||
\\'*\\') nvm_dir_from_file=\${nvm_dir_from_file#\\'}; nvm_dir_from_file=\${nvm_dir_from_file%%\\'*} ;;
|
||||
*) nvm_dir_from_file=\${nvm_dir_from_file%%[[:space:]]*} ;;
|
||||
esac
|
||||
case "$nvm_dir_from_file" in
|
||||
'$HOME'*) nvm_dir_from_file="$HOME\${nvm_dir_from_file#'$HOME'}" ;;
|
||||
"~/"*) nvm_dir_from_file="$HOME/\${nvm_dir_from_file#\\~/}" ;;
|
||||
esac
|
||||
[ -n "$nvm_dir_from_file" ] && nvm_dirs="$nvm_dirs
|
||||
$nvm_dir_from_file"
|
||||
done
|
||||
printf '%s\\n' "$nvm_dirs" | while IFS= read -r nvm_dir
|
||||
do
|
||||
[ -n "$nvm_dir" ] || continue
|
||||
for candidate in "$nvm_dir"/versions/node/*/bin/node
|
||||
do
|
||||
[ -x "$candidate" ] && printf '%s\\n' "$candidate"
|
||||
done
|
||||
done
|
||||
for candidate in \\
|
||||
/usr/local/bin/node \\
|
||||
/opt/homebrew/bin/node \\
|
||||
"$HOME/.local/bin/node" \\
|
||||
"$HOME/.fnm/aliases/default/bin/node" \\
|
||||
"$HOME/.fnm/node-versions"/*/installation/bin/node \\
|
||||
"$HOME/.local/share/mise/shims/node" \\
|
||||
"$HOME/.local/share/mise/installs/node"/*/bin/node \\
|
||||
"$HOME/.asdf/shims/node" \\
|
||||
"$HOME/.asdf/installs/nodejs"/*/bin/node \\
|
||||
"$HOME/.volta/bin/node" \\
|
||||
/usr/local/n/versions/node/*/bin/node
|
||||
do
|
||||
[ -x "$candidate" ] && printf '%s\\n' "$candidate"
|
||||
done
|
||||
true
|
||||
`
|
||||
|
||||
try {
|
||||
const result = await execCommand(conn, script)
|
||||
const seen = new Set<string>()
|
||||
for (const line of result.split('\n')) {
|
||||
const candidate = line.trim()
|
||||
if (!candidate || seen.has(candidate)) {
|
||||
continue
|
||||
}
|
||||
seen.add(candidate)
|
||||
if (await nodeMeetsVersionRequirement(conn, candidate)) {
|
||||
console.log(`[ssh-relay] Found node via path probe: ${candidate}`)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to login shell.
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Run `command -v node` under the user's login shell, then verify the result
|
||||
// meets the minimum version. Returns null on any failure (shell missing, no
|
||||
// node found, version too old, timeout) so callers fall through to the error.
|
||||
async function tryResolveViaLoginShell(conn: SshConnection): Promise<string | null> {
|
||||
try {
|
||||
// Why: $SHELL is the user's configured login shell (set by chsh / passwd).
|
||||
// Using it — rather than hardcoding bash — means zsh/fish users whose
|
||||
// custom PATH hooks live in profile files get coverage too. We fall back
|
||||
// to sh if $SHELL is unset (rare, e.g. restricted accounts).
|
||||
const shellResult = await execCommand(conn, 'echo "${SHELL:-/bin/sh}"', {
|
||||
timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS
|
||||
})
|
||||
const shell = shellResult.trim().split('\n')[0]
|
||||
if (!shell) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nodePath = await execCommand(conn, buildCommandInShell(shell, 'command -v node'), {
|
||||
wrapCommand: false,
|
||||
timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS
|
||||
})
|
||||
const candidate = nodePath.trim().split('\n')[0]
|
||||
if (!candidate) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (await nodeMeetsVersionRequirement(conn, candidate)) {
|
||||
console.log(`[ssh-relay] Found node via login shell (${shell}): ${candidate}`)
|
||||
return candidate
|
||||
}
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildCommandInShell(shell: string, command: string): string {
|
||||
const shellName = shell.split('/').at(-1)
|
||||
// Why: dash and POSIX sh do not require `-l`; when $SHELL falls back to
|
||||
// /bin/sh, prefer a portable command over login-shell semantics.
|
||||
const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc'
|
||||
return `${shellEscape(shell)} ${mode} ${shellEscape(command)}`
|
||||
}
|
||||
|
||||
// Returns true if `nodePath` runs and reports Node >= MIN_NODE_MAJOR.
|
||||
// Caches nothing — this runs at most a few times per resolution (one per
|
||||
// candidate), and the exec round-trip dominates.
|
||||
async function nodeMeetsVersionRequirement(
|
||||
conn: SshConnection,
|
||||
nodePath: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const versionOutput = await execCommand(conn, `${shellEscape(nodePath)} --version`, {
|
||||
wrapCommand: false
|
||||
})
|
||||
const match = versionOutput.trim().match(/^v?(\d+)/)
|
||||
if (!match) {
|
||||
return false
|
||||
}
|
||||
const major = Number.parseInt(match[1]!, 10)
|
||||
return major >= MIN_NODE_MAJOR
|
||||
} catch {
|
||||
// Binary missing or fails to run — not usable.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRemoteWindowsNodePath(conn: SshConnection): Promise<string> {
|
||||
const script = [
|
||||
'$paths = @()',
|
||||
|
|
|
|||
Loading…
Reference in New Issue