A Visual Studio Code extension for managing multiple SSH servers from inside the editor — interactive terminals, running the same command on a group of servers, remote-file editing via SFTP, and a YAML-driven config you commit to git.
Built on standard VS Code APIs (TreeView, Pseudoterminal, OutputChannel, QuickPick, FileSystemProvider, SecretStorage). The aggregated "console" panel is a Webview using VS Code theme variables so light/dark themes both look native. Connections use the ssh2 library to talk to standard OpenSSH servers — no custom server-side software required.
Install
From VS Code Marketplace (recommended):
ext install BostonIdentity.ssh-fleet-vscode
Or search for "SSH Fleet" in VS Code's Extensions view (Cmd+Shift+X / Ctrl+Shift+X).
From source (for development or sideloading a pre-release build):
git clone <this-repo>
cd ssh-fleet-vscode
npm install
npm run build
# F5 in VS Code to launch an Extension Development Host
The published VSIX works on macOS / Linux / Windows from the same artifact — native crypto bindings are excluded so ssh2 falls back to its pure-JS implementation.
Quick start
- Click the SSH Fleet icon in the Activity Bar.
- First-run wizard: pick a working directory — this is where all configs, downloaded files, and the host-key trust store live. Three options:
- Reuse
~/.ssh-fleet(offered if it already has content) - Create a new folder (default:
~/SSH Fleet) - Pick an existing folder
- Reuse
- The wizard scaffolds the layout and a
default.ymlconfig. Edit it via Open Config File, or drop existing YAML configs into<workdir>/config/. - Reload (auto on save) → TreeView populates → hover a server row and click the terminal icon (or run
SSH Fleet: Open Terminalfrom the Command Palette). The first connection prompts for host-key verification and any required password / 2FA codes.
Working directory layout
<workdir>/
├── config/
│ ├── default.yml # active config (controlled by .last_config)
│ ├── docker-test.yml # alternative configs — switch via "Switch Active Config…"
│ └── .last_config # plain-text basename of the active config
├── tasks/
│ └── *.yml # task library, all loaded; same-name overrides inline
├── mirror/
│ └── <server>/<path> # downloaded remote files (Finder-browsable)
└── known_hosts.json # host-key trust store
Migrating an existing YAML config? A best-effort compatibility layer accepts common alternate field names so a legacy config can be dropped in unchanged: username → user, key_file → auth.keyPath, server_warn_patterns → serverWarnPatterns, user@host:port shorthand strings, environment/module → server meta, and flat credential fields written directly on the entry (password, passwordRef, cachePassword) instead of inside an auth: block. Edits the extension makes to a config — rotating a password, moving one into the keychain — keep whichever of those two shapes the entry already uses, so a file that other tooling reads and rewrites stays in its format. Legacy password: ENC(...) placeholders from a deprecated master-password scheme are detected and rerouted to per-server passwordRef — the extension prompts you on first connect, and the new password lands in the OS keychain via VS Code's SecretStorage.
Features
| Capability | What it gives you |
|---|---|
| Interactive Terminal | A reusable SSH shell channel per server in a native VS Code Terminal tab. Tab completion, ANSI colors, vim/top/tmux all work because we use client.shell() with a real PTY. |
| Run on Multiple Servers | QuickPick multi-select → run the same command across N servers in parallel. Output is streamed to a single OutputChannel with [server-name] prefixes and per-server line buffering (no interleaving). |
| Tasks | Declarative tasks in YAML: command (run shell command), upload (SFTP write + chmod), script (upload + run + cleanup). Run on any subset of servers via QuickPick. |
| Remote file editing — direct | ssh-fleet://<server>/<path> URI scheme. Open any remote file in the editor with full language support. Save writes back via SFTP. mtime conflict guard warns if the remote was modified externally. |
| Remote file editing — mirror | Safer for production configs. Download a remote file into a local mirror; edit locally with zero network round-trips per save; Push to Remote (cloud-upload icon in editor title) when ready. Diff view on conflict. |
| Mount remote folder | Add ssh-fleet://<server>/<path> as a workspace folder — the entire remote tree shows up in the Explorer with normal cmd+P file-find. |
| File upload | Right-click any local file in the Explorer → Upload Local File to Server…. Tracks the upload in the mirror manifest so subsequent push/pull/diff work. |
| Drag-and-drop upload | Drop files or folders onto the Servers tree to upload them to the ticked servers. A confirmation names the destination and the target servers first. Folders are packed to .tar.gz locally and can be extracted on arrival; existing files and directories are backed up first when safety.autoBackup is on. |
| Auto-reconnect | TCP drops trigger 1s → 30s exponential-backoff reconnect (6 attempts). Explicit disconnect is honored. |
| Aliases | YAML-defined aliases (ll: "ls -ltrah") auto-installed in every shell on connect. |
| Auto-backup | Before a destructive change, the extension copies the target to $BACKUP_DIR/<original path>.<timestamp> — the backup tree mirrors the real tree, so restore paths are obvious and same-named files never collide. Covers destructive commands (rm, mv, cp, sed -i, > /path/file), uploads, server-to-server copies, and saving a remote file from the editor. Editor saves back up once per editing session, not once per Ctrl+S, so the copy that survives is the content as it was before you started. Wildcards skipped with a warning. Requires safety.autoBackup.backupDir to be set; there is no default. |
| Dest-check | Before cp / mv / tee / > runs, the extension stats the destination path on every target server; if it already exists, a modal asks for explicit overwrite confirmation (skipped per-server when the path is missing). |
| Safety patterns | Glob-pattern server tags (*prod* → label PROD color #dc2626). Renders as a colored emoji badge in the TreeView. Modal confirmation before any destructive command on a tagged server. |
| Bookmarks | Saved remote paths, available via QuickPick to insert into the active SSH terminal. |
| Command history | Per-server history in globalState. "Run From History…" QuickPick filters by server, dates, and content. |
| Standalone task files | Drop *.yml files into <workdir>/tasks/ (the working-directory you set during the first-run wizard). Auto-loaded; same-name tasks override the active config's inline tasks: block. The Tasks tree groups rows by source file so you can see which file a task came from at a glance. |
| Authentication | Public key (with passphrase via SecretStorage), password (via SecretStorage), SSH agent (SSH_AUTH_SOCK), and keyboard-interactive for 2FA / Duo / SecurID / PAM-challenge servers. |
| Host-key verification | Trust-on-first-use prompt on first connect; refuses connection if the fingerprint changes (defends against MITM). Manage trusted hosts via the Manage Known Hosts… command. |
Configuration
Configs live in <workdir>/config/*.yml. One file is active at a time —
switch via SSH Fleet: Switch Active Config… (writes the basename to
.last_config for next session). Set the workspace root via
ssh-fleet.workspaceDir setting or the Setup Workspace… command.
Minimal example
servers:
- name: web-01
host: 10.1.2.3
user: deploy
auth:
type: key
keyPath: ~/.ssh/id_ed25519
groups: [prod, web]
- name: dev-box
host: 192.168.1.50
user: dev
auth:
type: agent
groups: [dev]
auth.type can be key, password, or agent. Keyboard-interactive (2FA / OTP) is automatic — the server requests it mid-handshake and we surface a VS Code input box.
Auth examples
# Key with passphrase (passphrase stored in keychain on first prompt)
auth:
type: key
keyPath: ~/.ssh/id_rsa
passphraseRef: prod-key
# Password via keychain (extension prompts on first connect)
auth:
type: password
passwordRef: web-01-password
# Dynamic password / OTP / TOTP — never cached, prompts every connect.
# Use this when the "password" the server expects is actually a one-time
# code (RSA SecurID, Duo OTP, time-based code, etc.) — caching would
# always be stale.
auth:
type: password
cachePassword: false
# Flat form — credential fields on the entry itself, no `auth:` block.
# Accepted for configs generated by other tooling; `password`,
# `passwordRef` and `cachePassword` work the same as inside `auth:`.
- host: app-01.example.net
username: svc
passwordRef: app-01-password
# Config-wide default: every server in this file opts out of caching.
# Useful when the whole config is OTP-only. Put this at the top level
# of the YAML, NOT inside auth:
#
# settings:
# cachePassword: false
# servers:
# - name: prod-1
# auth: { type: password } # inherits cachePassword: false
# - name: dev-1
# auth:
# type: password
# cachePassword: true # this one DOES cache (override)
#
# Cascade order: per-server > settings.cachePassword > default (true)
# SSH agent — uses $SSH_AUTH_SOCK
auth:
type: agent
# Auto-detect key (no auth block at all):
# Tries ~/.ssh/id_ed25519 → id_rsa → id_ecdsa → falls back to agent
Server shorthand strings
servers:
- "[email protected]"
- "[email protected]:2222"
Environment variables in the config
$VAR and ${VAR} in the config are replaced with your workstation's
environment at load time — handy for keeping machine-specific values and
secrets out of the file:
servers:
- name: web-01
host: 10.0.0.1
user: ${USER}
auth:
type: password
password: ${SSH_PASSWORD} # never written to the file
keyPath: $HOME/.ssh/id_ed25519
An unset variable is left exactly as written, so a typo shows up as a
literal $TYPO instead of silently becoming an empty string.
Fields that hold remote shell code are exempt — tasks[].command,
tasks[].args, aliases: and settings.lsCommand. $VAR there belongs
to the shell on the server:
tasks:
- name: disk-usage
command: du -sh $HOME/* # the SERVER's home, not yours
To put a local value into a remote command on purpose, use the task's
env: block — those values are expanded:
tasks:
- name: deploy
command: echo "$RELEASE" # read on the server…
env:
RELEASE: ${BUILD_TAG} # …from this machine's $BUILD_TAG
Tasks
tasks:
# Plain shell command
- name: uptime
command: uptime
timeout: 10
# Upload a local file
- name: deploy-script
src: ./scripts/setup.sh
dest: /usr/local/bin/setup.sh
mode: "0755"
# Upload + run + cleanup
- name: rotate-logs
src: ./scripts/rotate.sh
args: "--keep 7"
timeout: 60
# Any interpreter works — python/node/etc. With `interpreter:` the file
# needs no shebang or exec bit; without it, a shebang line is required.
- name: health-check
src: ./scripts/health.py
interpreter: python3
args: "--verbose"
# Confirm before running destructive operations
- name: restart-nginx
command: sudo systemctl restart nginx
confirmBeforeRun: true
Type is auto-detected: src + dest → upload, src alone → script, otherwise command.
Aliases, bookmarks, safety
aliases:
ll: "ls -ltrah"
ports: "ss -tulpn"
bookmarks:
- /var/log/
- /opt/app/
safety:
serverWarnPatterns:
- pattern: "*prod*"
label: PROD
color: "#dc2626"
- pattern: "*staging*"
label: STAGE
color: "#eab308"
autoBackup:
enabled: true
backupDir: /opt/backup
commands: [rm, mv, cp, sed, ">"]
destCheck:
enabled: true
commands: [cp, mv, ">"]
Standalone task library
Drop *.yml files into <workdir>/tasks/. Each file may use a bare list or a wrapped { tasks: [...] } form (both supported):
# <workdir>/tasks/maintenance.yml
tasks:
- name: disk-usage
command: df -h
timeout: 15
- name: free-mem
command: free -h
timeout: 5
Override hierarchy (last wins by task name):
tasks:block in the active config (<workdir>/config/<active>.yml)<workdir>/tasks/*.yml(task library)
The TreeView and Run Task command auto-refresh when you add, edit, or delete a task file.
Commands
All commands are prefixed SSH Fleet: in the Command Palette.
Connection
- Connect, Disconnect, Disconnect All
- Open Terminal (also: click the server name in TreeView)
- Show Active Connections (status bar click target)
Files
- Open Remote File… — direct edit via
ssh-fleet://URI - Mount Remote Folder as Workspace… — browse the whole remote tree
- Browse Files… — combined file/folder picker
- Download Remote File… — start tracking; edit locally
- Push to Remote — appears in editor title for tracked files (☁⬆)
- Pull from Remote — refresh local copy with conflict guard (☁⬇)
- Stop Tracking, Show Mirrored Files…, Reveal Mirror Folder in OS
- Upload Local File to Server… — also via right-click on local files
Run
- Run Command on Server… — single-server one-shot
- Run Command on Multiple Servers… — multi-select fan-out
- Run Task… — pick from configured tasks
- Run From History… — re-issue a previous command
Workspace
- Setup Workspace… — first-run wizard (also runs automatically if unset)
- Switch Workspace… — re-pick the working directory
- Switch Active Config… — pick which
<workdir>/config/*.ymlis active - Reveal Workspace in OS — open
<workdir>in Finder/Explorer
Config
- Open Config File — creates from template if missing
- Reload Config
- Open Tasks Folder… — opens
<workdir>/tasks/ - Add Server — interactive wizard
- Bookmarks… — add / remove / insert into terminal
- Manage Known Hosts… — list / forget trusted host fingerprints
Settings
| Setting | Default | Description |
|---|---|---|
ssh-fleet.workspaceDir |
"" |
Working directory holding config/, tasks/, mirror/, known_hosts.json. Empty = first-run wizard prompts. |
ssh-fleet.defaultTimeout |
60 |
Default command timeout (seconds, 0 = none). |
ssh-fleet.keepaliveInterval |
30 |
TCP keepalive interval (seconds) for active SSH connections. |
Config-file settings: block
A few cross-cutting defaults live in the active config YAML rather than in VS Code settings, so they ride along with the config and stay in git:
settings:
defaultTimeout: 60
keepaliveSeconds: 30
lsCommand: "ls -ltr"
cachePassword: false # config-wide opt-out of password caching
# (per-server `auth.cachePassword` overrides)
maxServersPerAction: 20 # hard cap on servers per single action;
# 0 = unlimited (you accept the blast radius)
groupOrder: [prod, canary] # sidebar display order for server groups;
# unlisted groups follow alphabetically.
# Maintained by right-click group →
# "Move Group Up / Down".
maxFileOpenSize: 50 # MB; warn before opening files larger
# than this in editor; 0 = no warning
maxFileDownloadSize: 500 # MB; hard refuse click / `:dl` over
# this; 0 = unlimited (raise at your own risk)
plaintextPasswords: migrate # a plaintext password in the config is
# moved into the OS keychain on load and
# replaced with a `passwordRef` (this
# rewrites the file, keeping each entry's
# existing shape). 'warn' only reports it;
# 'ignore' says nothing. `$VAR` references
# and ENC(...) are left alone. Use 'warn'
# if other tooling generates this file and
# would put the plaintext back.
autoConnectOnRun: confirm # what to do when a run targets selected
# servers that aren't connected: 'confirm'
# pops a modal listing them first (default),
# 'always' connects without asking,
# 'never' skips disconnected servers.
remoteTempDir: /tmp # remote staging dir for `script` task
# bodies, "Download as archive…" tars and
# directory sync. Point at a writable (and
# executable) dir when /tmp is noexec —
# e.g. /var/tmp/ssh-fleet. Absolute path,
# no `~`, and creatable by the SSH user on
# every server; auto-created if missing,
# falling back to $HOME when it can't be.
archiveFormat: auto # 'auto' | 'zip' | 'tar.gz' — controls the
# right-click "Download as archive…" output.
# 'auto' tries zip first, falls back to
# tar.gz when `zip` isn't installed on the
# remote. Set 'zip' to force (fails loudly
# if zip is missing) or 'tar.gz' to force
# the always-available format.
archiveMinDepth: 2 # refuse to archive paths with fewer than
# N segments. Default 2 blocks `/`, `/tmp`,
# `/etc`, etc.; lower to 1 to allow first-
# level dirs or 0 to disable the guard.
shortenHostnames: true # `[aaa.bbb.example.com]` output prefixes
# display as `[aaa]` to save horizontal
# space; full name kept on hover. IPv4/
# IPv6 are never truncated. Set false to
# keep the full hostname inline.
preventSleep: false # if true, prevents the LOCAL workstation
# from sleeping while you're operating
# SSH Fleet in this window — latches on the
# first sign of use (TreeView visible,
# first connect, or Console open) and
# holds until the window closes. mac uses
# caffeinate, Linux uses systemd-inhibit,
# Windows spawns python.exe with a small
# ctypes script (requires python.exe on
# PATH on Windows).
maxServersPerAction defends against fat-finger Select-All on a large
fleet — the TreeView refuses ticks past the cap and the dispatch path
re-checks defensively. To run a one-off action on more servers, raise the
cap in the config file (deliberately no in-UI override).
maxFileOpenSize / maxFileDownloadSize defend against accidentally
clicking a 2 GB log or binary in ls -l output. Click-to-open also
refuses extensions that look binary (.so, .tar.gz, .png, .pdf, …)
unless you confirm a modal — those would render as garbage in the
editor. Raise the caps if you genuinely need to open / download larger
files; you accept the consequence (slow editor, RAM use, disk fill).
Timestamps in file names
Every timestamp this extension writes into a file name — backups, downloaded
copies, staged archives — is UTC in ISO 8601 basic form, marked with a
trailing Z, and is generated once per operation on your machine:
/opt/backup/opt/app/config.yml.20260801T140530Z # backup
<workdir>/download/config_web-01_20260801T140530Z.yml # download
One fan-out gets one identifier, so ls, find -name '*20260801T1405*'
and bulk cleanup can pick out "everything from that change" across the whole
fleet. Two consequences worth knowing:
- The name will not match either wall clock when your workstation and your
servers sit in different zones — that's why it says
Z. To read a backup's creation time in the server's own zone, usels -lcorstat -c %z(notecp -apreserves the original file's mtime on the copy, sols -lshows when the content was last edited, not when the backup was taken). - UTC is also why a backup taken during a daylight-saving fall-back hour can't collide with, or sort ahead of, one taken an hour earlier.
The one exception is the same-directory .bakYYYYMMDD copy, which uses your
calendar date — it's a short human "did I already back this up today?" label,
and the menu item shows the exact suffix before you click it.
Two file-editing models — when to use which
Direct (Open Remote File) |
Mirror (Download Remote File) |
|
|---|---|---|
| Save | Each Cmd+S writes via SFTP | Each Cmd+S writes locally only |
| Network | Per save | Only on Push / Pull |
| Conflict guard | Modal warning before overwrite | Modal + diff view; pre-flight stat |
| Best for | Quick edits, exploration, log peek | Production configs, multi-step edits |
| Auto-save risk | High (each pause = SFTP write) | None |
| Underlying mechanism | vscode.FileSystemProvider |
Local file + SFTP read/write on demand |
Architecture
- One ssh2 Client per server, kept open until you disconnect
- Three channel types coexist on each Client:
shellfor interactive Terminal,execfor batch one-shots,sftpfor remote files - TCP keepalive (30s by default) keeps NAT/firewalls from killing idle sockets
- Auto-reconnect with exponential backoff handles TCP drops without losing the user's logical session
- State is kept in VS Code's
globalState(command history, bookmarks, mirror manifest, known hosts). Sensitive values (passwords, key passphrases) go to the OS keychain via VS Code'sSecretStorageAPI.
Development
npm install # install deps
npm run typecheck # tsc --noEmit
npm run build # esbuild bundle to dist/extension.js
npm run watch # esbuild watch mode
npm test # vitest unit suites (no SSH needed)
npm run smoke:up # bring up local docker sshd
npm run smoke # run smoke harness against it
npm run smoke:down # tear down
# F5 in VS Code → launches Extension Development Host
License
MIT — see LICENSE.