Skip to content

Instantly share code, notes, and snippets.

@ostark
Last active August 17, 2026 14:20
Show Gist options
  • Select an option

  • Save ostark/867c4ac820ee212fc16e8b28f2e3c302 to your computer and use it in GitHub Desktop.

Select an option

Save ostark/867c4ac820ee212fc16e8b28f2e3c302 to your computer and use it in GitHub Desktop.
Interactive EC2 instance picker + SSH (test/stage/prod via aws-cli, grouped by Name tag, no dependencies)

ec2 — interactive EC2 instance picker with SSH

Lists running EC2 instances in one of your AWS environments (test, stage, prod), lets you pick one, and opens an SSH session to its private IP.

  • Instances are grouped by their Name tag: pick the role first, then the host
  • Arrow-key picker with type-to-filter; the last host per environment is preselected
  • No dependencies — Node builtins plus the aws, ssh and stty binaries

Written for a setup with one AWS account per environment, reached over a VPN or SSH tunnel.

Requirements

  • Node.js 18+
  • AWS CLI v2 with one named profile per environment
  • An open VPN / tunnel — the script connects to private IPs and never uses public ones
  • macOS or Linux (the picker reads /dev/tty and drives stty)

Install

Downloads the script to ~/.local/bin/ec2-ssh.mjs and links it as ec2.

With the GitHub CLI:

mkdir -p ~/.local/bin && gh gist view 867c4ac820ee212fc16e8b28f2e3c302 --raw --filename ec2-ssh.mjs > /tmp/ec2-ssh.mjs && install -m 755 /tmp/ec2-ssh.mjs ~/.local/bin/ec2-ssh.mjs && ln -sfn ec2-ssh.mjs ~/.local/bin/ec2 && ~/.local/bin/ec2 --help | head -1

Without gh, over the public API:

mkdir -p ~/.local/bin && curl -fsSL https://api.github.com/gists/867c4ac820ee212fc16e8b28f2e3c302 | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>process.stdout.write(JSON.parse(s).files["ec2-ssh.mjs"].content))' > /tmp/ec2-ssh.mjs && install -m 755 /tmp/ec2-ssh.mjs ~/.local/bin/ec2-ssh.mjs && ln -sfn ec2-ssh.mjs ~/.local/bin/ec2 && ~/.local/bin/ec2 --help | head -1

Both download to /tmp first and then move the file into place, so a failed download cannot destroy a working installation.

The API route is used instead of the obvious raw/ec2-ssh.mjs URL on purpose: GitHub answers anonymous requests to gist.githubusercontent.com with 429 Too Many Requests for entire IP ranges, which makes a raw-URL installer fail on some corporate networks. For a manual download the raw URL is still the shortest thing that works when your network is not affected.

~/.local/bin has to be in your PATH:

echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc

If you prefer an alias over the symlink, drop the ln -sfn part and add:

alias ec2="$HOME/.local/bin/ec2-ssh.mjs"

The symlink is the better option: an alias only exists in interactive shells, while the symlink also works in scripts, xargs or under sudo.

Configuration

SSH_USER and AWS_PROFILE_PREFIX are required — the script has no defaults for them and refuses to start until both are set. The AWS profile it queries is AWS_PROFILE_PREFIX + the environment name. For profiles named acme-test, acme-stage, acme-prod and admin as the remote user, add to ~/.zshrc (or ~/.bashrc):

export SSH_USER=admin
export AWS_PROFILE_PREFIX=acme-

If your profiles are named exactly like the environments, set the prefix to an empty string — that counts as configured:

export AWS_PROFILE_PREFIX=
Variable Required Purpose
SSH_USER yes remote user to log in as
AWS_PROFILE_PREFIX yes (may be empty) prefix for the profile name, e.g. acme- → acme-prod
AWS_REGION no — AWS_DEFAULT_REGION, else eu-central-1 region queried
SSH_TERM no — derived from TERM terminal type reported to the host

ec2 --help works without any configuration and prints which values are currently in effect.

Different environment names? ENVIRONMENTS at the top of the script is the one value you edit in the file itself.

Usage

Command What it does
ec2 pick the environment, then the role, then the host
ec2 stage pick a host on stage
ec2 prod web pre-filter the list to instances matching web
ec2 prod ftp -- uptime run a remote command instead of opening a shell
ec2 prod --list print all instances, one line per host, no picker
ec2 --help usage and the currently effective defaults

The filter matches the Name tag, private IP, instance ID and instance type. If it narrows the list down to a single host, the script connects straight away without asking.

--list writes the host lines to stdout and everything else to stderr, so it pipes cleanly:

ec2 prod proxy --list | awk '{print $2}'

Picker keys

Key Action
↑ ↓ / ctrl-p ctrl-n move the selection
any character filter the list
backspace / ctrl-u delete one character / clear the filter
enter select
esc / ctrl-c / ← back to the role list, or quit at the top level

Notes

  • MFA: if your profiles assume a role with MFA, the AWS CLI asks for the code on the first call of a session. The prompt is passed through to your terminal; the code is never read or stored by this script.
  • Instance age: the last column shows how long the host has been running (47m, 31h, 12d) — useful to tell a freshly replaced host from a long-lived one.
  • Host keys are not pinned (StrictHostKeyChecking=no, UserKnownHostsFile=/dev/null). Auto scaling groups and karpenter recycle private IPs, so a known_hosts entry would turn into a false "identification changed" alarm within days. If your instances are long-lived, change those two options in the script.
  • TERM mapping: terminals like ghostty or kitty ship terminfo entries the servers do not have, which makes htop fail with Error opening terminal: xterm-ghostty. Non-portable values are reported as xterm-256color; override with SSH_TERM.
  • Clean screen: the visible screen is cleared right before the login, the scrollback is kept. Skipped when a remote command is given.
  • State: the last host per environment is cached in ~/.cache/ec2-ssh/last-host.json.
#!/usr/bin/env node
// Lists running EC2 instances of one environment via the AWS CLI, lets you pick
// one, and opens an SSH session to its PRIVATE IP. Assumes the VPN/tunnel is up
// so private IPs are reachable.
//
// Dependency-free: Node builtins plus the `aws`, `ssh` and `stty` binaries.
//
// Usage:
// scripts/ec2-ssh.mjs # pick environment, then instance
// scripts/ec2-ssh.mjs stage # pick instance on stage
// scripts/ec2-ssh.mjs prod webapp # pre-filter the instance list
// scripts/ec2-ssh.mjs prod webapp -- uptime # run a remote command instead
// scripts/ec2-ssh.mjs stage --list # only print the instances
//
// Instances are grouped by their Name tag: pick the role first, then the host
// (skipped when the role has a single instance).
//
// In the picker: arrow keys (or ctrl-p/ctrl-n) move, typing filters, enter
// selects, esc/ctrl-c goes back or aborts. The last host used per environment
// is preselected.
//
// Env overrides: SSH_USER (default ec2-user), AWS_REGION, AWS_PROFILE_PREFIX
// (prepended to the environment name to form the AWS profile), SSH_TERM.
// ENVIRONMENTS below is the one thing you edit in the file itself.
import { spawnSync } from 'node:child_process'
import { openSync, readSync, closeSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { basename, dirname, join } from 'node:path'
const CMD = basename(process.argv[1] ?? 'ec2-ssh.mjs')
const ENVIRONMENTS = ['test', 'stage', 'prod'] // your environment names
// No defaults for these two: guessing a remote user or an AWS profile name is
// worse than refusing to start. An explicitly empty AWS_PROFILE_PREFIX counts as
// configured, for profiles that are named exactly like the environments.
const SSH_USER = process.env.SSH_USER
const AWS_PROFILE_PREFIX = process.env.AWS_PROFILE_PREFIX
const AWS_REGION = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'eu-central-1'
const STATE_FILE = join(homedir(), '.cache', 'ec2-ssh', 'last-host.json')
const VISIBLE_ROWS = 12
const c = {
red: '\x1b[0;31m',
green: '\x1b[0;32m',
yellow: '\x1b[1;33m',
blue: '\x1b[0;34m',
bold: '\x1b[1m',
dim: '\x1b[2m',
invert: '\x1b[7m',
off: '\x1b[0m',
}
const info = (msg) => console.error(`${c.blue} info${c.off} ${msg}`)
const success = (msg) => console.error(`${c.green} done${c.off} ${msg}`)
const fail = (msg) => {
console.error(`${c.red} fail${c.off} ${msg}`)
process.exit(1)
}
// ssh sends the local TERM with the pty request, and terminals like ghostty or
// kitty ship terminfo entries the servers do not have — htop then dies with
// "Error opening terminal: xterm-ghostty". Anything outside this list is
// reported as xterm-256color; SSH_TERM overrides.
const PORTABLE_TERMS = new Set([
'xterm',
'xterm-color',
'xterm-16color',
'xterm-256color',
'screen',
'screen-256color',
'tmux',
'tmux-256color',
'vt100',
'vt220',
'linux',
'ansi',
'dumb',
])
function remoteTerm() {
const term = process.env.TERM ?? ''
return process.env.SSH_TERM ?? (PORTABLE_TERMS.has(term) ? term : 'xterm-256color')
}
const HELP = `Lists running EC2 instances per environment and opens an SSH session.
Usage:
${CMD} pick environment, then instance
${CMD} stage pick instance on stage
${CMD} prod webapp pre-filter the instance list
${CMD} prod webapp -- uptime run a remote command instead
${CMD} stage --list only print the instances (flat, one line per host)
Instances are grouped by Name tag: pick the role first, then the host. Picker
keys: arrow keys / ctrl-p / ctrl-n move, typing filters, enter selects, esc or
ctrl-c goes back or aborts. The last host per environment is preselected.
Environments: ${ENVIRONMENTS.join(', ')} — AWS profile is AWS_PROFILE_PREFIX + environment name
Required (no defaults): SSH_USER (${SSH_USER ?? 'NOT SET'}),
AWS_PROFILE_PREFIX (${AWS_PROFILE_PREFIX === undefined ? 'NOT SET' : `"${AWS_PROFILE_PREFIX}"`})
Optional: AWS_REGION (${AWS_REGION}), SSH_TERM (${remoteTerm()}, local TERM is ${process.env.TERM ?? 'unset'})`
// ─── Arguments ────────────────────────────────────────────────────────
const argv = process.argv.slice(2)
const separator = argv.indexOf('--')
const remoteCommand = separator === -1 ? [] : argv.slice(separator + 1)
const flags = separator === -1 ? argv : argv.slice(0, separator)
if (flags.includes('-h') || flags.includes('--help')) {
console.log(HELP)
process.exit(0)
}
const listOnly = flags.includes('--list')
const positional = flags.filter((arg) => !arg.startsWith('-'))
if (positional.length > 2) fail(`unexpected argument: ${positional[2]}`)
const [environmentArg, filter] = positional
// Checked before anything else touches AWS, so a missing setup is reported
// instead of an MFA prompt for a profile that cannot exist.
const missing = [
SSH_USER === undefined ? 'SSH_USER' : null,
AWS_PROFILE_PREFIX === undefined ? 'AWS_PROFILE_PREFIX' : null,
].filter(Boolean)
if (missing.length > 0) {
const rc = { zsh: '~/.zshrc', bash: '~/.bashrc' }[basename(process.env.SHELL ?? '')]
console.error(`${c.red} fail${c.off} not configured: ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} not set
${CMD} ships without defaults for these. Add to ${rc ?? 'your shell config'}:
${c.bold}export SSH_USER=ec2-user${c.off} ${c.dim}# remote user to log in as${c.off}
${c.bold}export AWS_PROFILE_PREFIX=acme-${c.off} ${c.dim}# + environment name = AWS profile, e.g. acme-prod${c.off}
Then reload it${rc ? `: source ${rc}` : ''}, or open a new shell.
${c.dim}Set AWS_PROFILE_PREFIX to an empty string if your profiles are named
exactly like the environments (${ENVIRONMENTS.join(', ')}). See ${CMD} --help.${c.off}`)
process.exit(1)
}
// ─── Remembered last host per environment ─────────────────────────────
function readState() {
try {
return JSON.parse(readFileSync(STATE_FILE, 'utf8'))
} catch {
return {}
}
}
function rememberHost(environment, instanceId) {
try {
mkdirSync(dirname(STATE_FILE), { recursive: true })
writeFileSync(STATE_FILE, JSON.stringify({ ...readState(), [environment]: instanceId }, null, 2))
} catch {
// a non-writable cache dir must not block the connection
}
}
// ─── Terminal input ───────────────────────────────────────────────────
// Everything is read with readSync and the fd is closed right away: a streaming
// reader would leave a pending read() on /dev/tty that swallows the MFA code
// the AWS CLI prompts for later on.
function openTty() {
for (const mode of ['r+', 'r']) {
try {
return openSync('/dev/tty', mode)
} catch {
continue
}
}
return fail('no terminal available for input — run this from an interactive shell')
}
function stty(fd, args) {
const result = spawnSync('stty', args, { stdio: [fd, 'pipe', 'ignore'], encoding: 'utf8' })
return result.status === 0 ? (result.stdout ?? '').trim() : null
}
function readKey(fd) {
const buffer = Buffer.alloc(8)
for (;;) {
try {
const read = readSync(fd, buffer, 0, buffer.length, null)
return read === 0 ? null : buffer.subarray(0, read).toString('utf8')
} catch (error) {
if (error.code === 'EAGAIN') continue
throw error
}
}
}
// Canonical line prompt, used when raw mode is unavailable.
function promptLine(fd, prompt) {
process.stderr.write(prompt)
const byte = Buffer.alloc(1)
let line = ''
for (;;) {
let read = 0
try {
read = readSync(fd, byte, 0, 1, null)
} catch (error) {
if (error.code === 'EAGAIN') continue
throw error
}
if (read === 0) break // EOF
const char = byte.toString('utf8')
if (char === '\n') break
line += char
}
return line
}
// ─── Picker ───────────────────────────────────────────────────────────
function pick(items, prompt, options = {}) {
const label = options.label ?? ((item) => item)
if (items.length === 0) return null
if (items.length === 1) return items[0]
const fd = openTty()
try {
const saved = stty(fd, ['-g'])
if (saved === null) return pickNumbered(fd, items, prompt, label)
// Never draw taller than the window: a scrolling frame would break the
// cursor-up arithmetic used to redraw.
const [rows, columns] = (stty(fd, ['size']) ?? '').split(' ').map(Number)
const layout = {
maxRows: Math.min(options.maxRows ?? VISIBLE_ROWS, rows > 6 ? rows - 3 : 20),
width: columns > 20 ? columns - 1 : 100,
}
// -isig so ctrl-c arrives as a byte and the terminal is always restored
// below; opost stays on, so plain "\n" still returns the carriage.
stty(fd, ['-icanon', '-echo', '-isig', 'min', '1', 'time', '0'])
try {
return pickInteractive(fd, items, prompt, { ...options, label }, layout)
} finally {
stty(fd, [saved])
}
} finally {
closeSync(fd)
}
}
// Split a read into single keys: arrow keys arrive as one escape sequence, and a
// fast typist or a paste can deliver several keys in the same read.
function keysIn(chunk) {
const keys = []
for (let index = 0; index < chunk.length; ) {
const isSequence = chunk[index] === '\x1b' && chunk[index + 1] === '['
keys.push(chunk.slice(index, index + (isSequence ? 3 : 1)))
index += isSequence ? 3 : 1
}
return keys
}
function pickInteractive(fd, items, prompt, options, layout) {
const { label, preselected = () => false, back = 'quit' } = options
const labels = items.map(label)
const rowWidth = Math.min(layout.width, Math.max(...labels.map((text) => text.length)) + 4)
let query = ''
let cursor = Math.max(
0,
items.findIndex((item) => preselected(item)),
)
let printed = 0
let pending = []
const render = (matches) => {
const height = Math.min(layout.maxRows, matches.length)
const start = Math.max(0, Math.min(cursor - (height >> 1), matches.length - height))
const lines = [
`${c.yellow} ?${c.off} ${prompt} ` +
`${c.dim}(${matches.length}/${items.length})${c.off} ` +
`${query}${c.dim}▌ ↑↓ move · type to filter · enter select · esc ${back}${c.off}`,
...matches.slice(start, start + height).map((match, row) => {
const selected = start + row === cursor
const text = `${selected ? ' ▸ ' : ' '}${labels[match]} `.padEnd(rowWidth).slice(0, rowWidth)
return selected ? `${c.invert}${c.bold}${text}${c.off}` : text
}),
]
if (matches.length > height) {
lines.push(` ${c.dim}… ${matches.length - height} more${c.off}`)
}
if (matches.length === 0) lines.push(` ${c.dim}no match${c.off}`)
// Rewind over the previous frame, then clear from the cursor down.
process.stderr.write(`${printed > 0 ? `\x1b[${printed}A\r` : ''}\x1b[0J${lines.join('\n')}\n`)
printed = lines.length
}
const erase = () => {
if (printed > 0) process.stderr.write(`\x1b[${printed}A\r\x1b[0J`)
printed = 0
}
let chosen = null
let aborted = false
for (;;) {
const needle = query.toLowerCase()
const matches = items
.map((item, index) => index)
.filter((index) => labels[index].toLowerCase().includes(needle))
if (cursor > matches.length - 1) cursor = Math.max(0, matches.length - 1)
render(matches)
if (pending.length === 0) {
const chunk = readKey(fd)
if (chunk === null) break
pending = keysIn(chunk)
}
const key = pending.shift()
// esc, ctrl-c and left arrow leave this picker: one level up, or out.
if (key === '\x03' || key === '\x1b' || key === '\x1b[D') {
aborted = true
break
}
if (key === '\r' || key === '\n') {
if (matches.length === 0) continue
chosen = items[matches[cursor]]
break
}
if (key === '\x1b[A' || key === '\x10') {
cursor = cursor > 0 ? cursor - 1 : Math.max(0, matches.length - 1)
continue
}
if (key === '\x1b[B' || key === '\x0e') {
cursor = cursor < matches.length - 1 ? cursor + 1 : 0
continue
}
if (key === '\x7f' || key === '\b') {
query = query.slice(0, -1)
cursor = 0
continue
}
if (key === '\x15') {
query = ''
cursor = 0
continue
}
if (key.startsWith('\x1b')) continue // any other escape sequence
const typed = [...key].filter((char) => char >= ' ' && char !== '\x7f').join('')
if (typed) {
query += typed
cursor = 0
}
}
erase()
if (aborted) return null
return chosen
}
function pickNumbered(fd, items, prompt, label) {
items.forEach((item, index) => console.error(` ${c.dim}[${index + 1}]${c.off} ${label(item)}`))
const answer = promptLine(fd, `${c.yellow} ?${c.off} ${prompt} [1-${items.length}] `)
const index = Number.parseInt(answer.trim(), 10)
if (!Number.isInteger(index) || index < 1 || index > items.length) fail('invalid selection')
return items[index - 1]
}
// ─── Environment ──────────────────────────────────────────────────────
const environment = environmentArg ?? pick(ENVIRONMENTS, 'environment')
if (environment === null) process.exit(130)
if (!ENVIRONMENTS.includes(environment)) {
fail(`unknown environment '${environment}' (expected: ${ENVIRONMENTS.join(', ')})`)
}
const profile = `${AWS_PROFILE_PREFIX}${environment}`
// ─── Instance list ────────────────────────────────────────────────────
info(`querying running instances in ${environment} (${profile}, ${AWS_REGION})`)
// stdin/stderr stay attached so the AWS CLI can prompt for the MFA code.
const aws = spawnSync(
'aws',
[
'ec2',
'describe-instances',
'--profile',
profile,
'--region',
AWS_REGION,
'--filters',
'Name=instance-state-name,Values=running',
'--output',
'json',
],
{ encoding: 'utf8', stdio: ['inherit', 'pipe', 'inherit'], maxBuffer: 64 * 1024 * 1024 },
)
if (aws.error?.code === 'ENOENT') fail('aws cli not found')
if (aws.status !== 0) fail(`aws describe-instances failed (exit ${aws.status})`)
// Sort IPs numerically, so .65 does not land behind .199.
const octets = (ip) => ip.split('.').reduce((value, octet) => value * 256 + Number(octet), 0)
// Compact uptime, e.g. 47m / 31h / 12d — tells replaced hosts from long-lived ones.
const age = (launchTime) => {
const minutes = Math.floor((Date.now() - Date.parse(launchTime ?? '')) / 60_000)
if (!Number.isFinite(minutes) || minutes < 0) return '-'
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
return hours < 48 ? `${hours}h` : `${Math.floor(hours / 24)}d`
}
let instances = JSON.parse(aws.stdout).Reservations.flatMap((reservation) => reservation.Instances)
.filter((instance) => instance.PrivateIpAddress)
.map((instance) => ({
name: instance.Tags?.find((tag) => tag.Key === 'Name')?.Value ?? '-',
ip: instance.PrivateIpAddress,
id: instance.InstanceId,
type: instance.InstanceType,
az: instance.Placement?.AvailabilityZone ?? '-',
age: age(instance.LaunchTime),
}))
.sort((a, b) => a.name.localeCompare(b.name) || octets(a.ip) - octets(b.ip))
if (instances.length === 0) fail(`no running instances found in ${environment}`)
if (filter) {
const needle = filter.toLowerCase()
instances = instances.filter((instance) =>
[instance.name, instance.ip, instance.id, instance.type].some((field) =>
field.toLowerCase().includes(needle),
),
)
if (instances.length === 0) fail(`no running instance in ${environment} matches '${filter}'`)
}
const width = Math.max(...instances.map((instance) => instance.name.length))
const label = (instance) =>
[
instance.name.padEnd(width),
instance.ip.padEnd(15),
instance.id.padEnd(19),
instance.type.padEnd(12),
instance.az,
instance.age.padStart(4),
].join(' ')
if (listOnly) {
for (const instance of instances) console.log(label(instance))
process.exit(0)
}
// ─── Connect ──────────────────────────────────────────────────────────
// Prod runs ~90 instances, so pick the role (Name tag) first and only descend
// into the individual hosts when a role has more than one.
const groups = [...new Set(instances.map((instance) => instance.name))].map((name) => {
const members = instances.filter((instance) => instance.name === name)
return {
name,
members,
types: [...new Set(members.map((member) => member.type))].join(','),
zones: [...new Set(members.map((member) => member.az.replace(/^.*-/, '')))].sort().join(','),
}
})
const groupWidth = Math.max(...groups.map((group) => group.name.length))
const typeWidth = Math.max(...groups.map((group) => group.types.length))
const groupLabel = (group) =>
[
group.name.padEnd(groupWidth),
`${group.members.length}×`.padStart(3),
group.types.padEnd(typeWidth),
group.zones,
].join(' ')
const memberLabel = (member) =>
[
member.ip.padEnd(15),
member.id.padEnd(19),
member.type.padEnd(12),
member.az,
member.age.padStart(4),
].join(' ')
const lastHost = readState()[environment]
let selected = null
while (selected === null) {
const group = pick(groups, `${environment} role`, {
label: groupLabel,
preselected: (candidate) => candidate.members.some((member) => member.id === lastHost),
maxRows: Infinity, // roles are few enough to show in full
})
if (group === null) process.exit(130)
if (group.members.length === 1) {
selected = group.members[0]
break
}
selected = pick(group.members, group.name, {
label: memberLabel,
preselected: (member) => member.id === lastHost,
back: 'back',
})
// Esc in the host picker returns to the role list — unless there is no list
// to return to, because a single role was auto-selected.
if (selected === null && groups.length === 1) process.exit(130)
}
rememberHost(environment, selected.id)
// Hand the session a clean screen: wipe the picker noise, but keep the
// scrollback (no \x1b[3J) so the work from before the login stays reachable.
// Skipped for remote commands, where the surrounding output is the point.
if (remoteCommand.length === 0 && process.stdout.isTTY) process.stdout.write('\x1b[2J\x1b[H')
success(`connecting to ${selected.name} (${selected.ip}) as ${SSH_USER}`)
// Host keys are deliberately not pinned: ASG and karpenter recycle the private
// IPs, so a known_hosts entry would turn into a false "identification changed"
// alarm within days. LogLevel=ERROR drops the resulting "permanently added"
// notice, which would otherwise land on the freshly cleared screen.
const ssh = spawnSync(
'ssh',
[
'-o',
'StrictHostKeyChecking=no',
'-o',
'UserKnownHostsFile=/dev/null',
'-o',
'LogLevel=ERROR',
`${SSH_USER}@${selected.ip}`,
...remoteCommand,
],
{ stdio: 'inherit', env: { ...process.env, TERM: remoteTerm() } },
)
process.exit(ssh.status ?? 1)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment