orca/src/main/ssh/ssh-relay-deploy-helpers.ts
2026-04-13 19:23:09 -07:00

244 lines
7.9 KiB
TypeScript

import { createReadStream } from 'fs'
import type { SFTPWrapper, ClientChannel } from 'ssh2'
import type { SshConnection } from './ssh-connection'
import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol'
import type { MultiplexerTransport } from './ssh-channel-multiplexer'
// ── SFTP upload helpers ───────────────────────────────────────────────
export async function uploadDirectory(
sftp: SFTPWrapper,
localDir: string,
remoteDir: string
): Promise<void> {
const { readdirSync, statSync } = await import('fs')
const { join: pathJoin } = await import('path')
const entries = readdirSync(localDir)
for (const entry of entries) {
const localPath = pathJoin(localDir, entry)
const remotePath = `${remoteDir}/${entry}`
const stat = statSync(localPath)
if (stat.isDirectory()) {
await mkdirSftp(sftp, remotePath)
await uploadDirectory(sftp, localPath, remotePath)
} else {
await uploadFile(sftp, localPath, remotePath)
}
}
}
export function mkdirSftp(sftp: SFTPWrapper, path: string): Promise<void> {
return new Promise((resolve, reject) => {
sftp.mkdir(path, (err) => {
// Ignore "already exists" errors (SFTP status code 4 = SSH_FX_FAILURE)
if (err && (err as { code?: number }).code !== 4) {
reject(err)
} else {
resolve()
}
})
})
}
export function uploadFile(
sftp: SFTPWrapper,
localPath: string,
remotePath: string
): Promise<void> {
return new Promise((resolve, reject) => {
const readStream = createReadStream(localPath)
const writeStream = sftp.createWriteStream(remotePath)
writeStream.on('close', resolve)
writeStream.on('error', reject)
readStream.on('error', reject)
readStream.pipe(writeStream)
})
}
// ── Sentinel detection ────────────────────────────────────────────────
export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTransport> {
return new Promise<MultiplexerTransport>((resolve, reject) => {
let sentinelReceived = false
let stderrOutput = ''
let bufferedStdout = Buffer.alloc(0)
const timeout = setTimeout(() => {
if (!sentinelReceived) {
channel.close()
reject(
new Error(
`Relay failed to start within ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}`
)
)
}
}, RELAY_SENTINEL_TIMEOUT_MS)
const MAX_BUFFER_CAP = 64 * 1024
channel.stderr.on('data', (data: Buffer) => {
stderrOutput += data.toString('utf-8')
if (stderrOutput.length > MAX_BUFFER_CAP) {
stderrOutput = stderrOutput.slice(-MAX_BUFFER_CAP)
}
})
channel.on('close', () => {
if (!sentinelReceived) {
clearTimeout(timeout)
reject(
new Error(
`Relay process exited before ready.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}`
)
)
}
})
const dataCallbacks: ((data: Buffer) => void)[] = []
const closeCallbacks: (() => void)[] = []
channel.on('data', (data: Buffer) => {
if (sentinelReceived) {
for (const cb of dataCallbacks) {
cb(data)
}
return
}
bufferedStdout = Buffer.concat([bufferedStdout, data])
const text = bufferedStdout.toString('utf-8')
const sentinelIdx = text.indexOf(RELAY_SENTINEL)
if (sentinelIdx !== -1) {
sentinelReceived = true
clearTimeout(timeout)
const afterSentinel = bufferedStdout.subarray(
Buffer.byteLength(text.substring(0, sentinelIdx + RELAY_SENTINEL.length), 'utf-8')
)
const transport: MultiplexerTransport = {
write: (buf: Buffer) => channel.stdin.write(buf),
onData: (cb) => {
dataCallbacks.push(cb)
},
onClose: (cb) => {
closeCallbacks.push(cb)
}
}
channel.on('close', () => {
for (const cb of closeCallbacks) {
cb()
}
})
resolve(transport)
if (afterSentinel.length > 0) {
for (const cb of dataCallbacks) {
cb(afterSentinel)
}
}
}
})
})
}
// ── Remote command execution ──────────────────────────────────────────
const EXEC_TIMEOUT_MS = 30_000
export async function execCommand(conn: SshConnection, command: string): Promise<string> {
const channel = await conn.exec(command)
return new Promise((resolve, reject) => {
let stdout = ''
let stderr = ''
let settled = false
const timeout = setTimeout(() => {
if (!settled) {
settled = true
channel.close()
reject(new Error(`Command "${command}" timed out after ${EXEC_TIMEOUT_MS / 1000}s`))
}
}, EXEC_TIMEOUT_MS)
channel.on('data', (data: Buffer) => {
stdout += data.toString('utf-8')
})
channel.stderr.on('data', (data: Buffer) => {
stderr += data.toString('utf-8')
})
channel.on('close', (code: number) => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
if (code !== 0) {
reject(new Error(`Command "${command}" failed (exit ${code}): ${stderr.trim()}`))
} else {
resolve(stdout)
}
})
})
}
// ── Remote Node.js resolution ─────────────────────────────────────────
// 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): Promise<string> {
// Why: non-login SSH exec channels don't source .bashrc/.zshrc, so node
// installed via nvm/fnm/Homebrew may not be in PATH. We probe common
// locations directly, then fall back to sourcing the profile explicitly.
// The glob in $HOME/.nvm/... is expanded by the shell, not by `command -v`.
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
}
// 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
}
throw new Error(
'Node.js not found on remote host. Orca relay requires Node.js 18+. ' +
'Install Node.js on the remote and try again.'
)
}