npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

wharf-tui

v0.8.0

Published

Keyboard-driven terminal SSH client with a local-first encrypted vault

Readme

wharf

your fleet, one terminal

Wharf is a keyboard-driven, terminal-based SSH client — manage your hosts, keys and team projects from a fast TUI. It is local-first: everything works with no account, backed by a local encrypted vault. Signing in only adds the online features — cross-machine sync and team projects — and the server never sees your plaintext.

This repo is the TUI client (the flagship). Other surfaces (web auth + landing, mobile companion, sync backend, deployment) live in sibling wharf-* repos.

Status

Usable SSH client with real account sync. Real SSH transport, encrypted vault persistence, host management, ~/.ssh/config and Termius import, reachability probes and key generation are implemented and tested. Device-code sign-in, cross-machine vault sync and team projects all run against the live wharf-backend (see Account sync). See Roadmap.

Install

The command is wharf everywhere. Pick whichever fits your machine:

# macOS
brew install Janne6565/tap/wharf

# anywhere with npm, bun or pnpm — the package is wharf-tui, the command is wharf
bun i -g wharf-tui

# Arch Linux
yay -S wharf-tui-bin

# Debian / Ubuntu
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://janne6565.github.io/wharf-tui/wharf-archive-keyring.gpg \
  | sudo tee /etc/apt/keyrings/wharf-archive-keyring.gpg > /dev/null
echo "deb [signed-by=/etc/apt/keyrings/wharf-archive-keyring.gpg] https://janne6565.github.io/wharf-tui stable main" \
  | sudo tee /etc/apt/sources.list.d/wharf.list
sudo apt update && sudo apt install wharf

# Windows
winget install Janne6565.Wharf
# or: scoop bucket add janne6565 https://github.com/Janne6565/scoop-bucket; scoop install wharf
# or: choco install wharf

# anything else with a shell (checksum-verified, no root needed)
curl -fsSL https://raw.githubusercontent.com/Janne6565/wharf-tui/main/scripts/install.sh | sh

Or grab a .tar.gz, .deb, .rpm or .apk straight from the releases page — every release ships a checksums.txt to verify against.

On Windows, one thing differs: sessions run inside wharf and close when it quits. Detaching with ctrl+\, the sessions overlay and reattaching with replay all work the same; what does not is a session surviving to the next run, which is built on POSIX primitives Windows has no equivalent for. See docs/PACKAGING.md.

No root, no daemon. The vault lives at ${XDG_DATA_HOME:-~/.local/share}/wharf/vault.enc (override with WHARF_VAULT).

Build from source

go install github.com/Janne6565/wharf-tui/cmd/wharf@latest

# or from a checkout:
go run ./cmd/wharf
go build -o wharf ./cmd/wharf && ./wharf

# stamp a release identity (otherwise --version reports "dev (<commit>)"):
go build -ldflags "-X main.version=v1.2.3" -o wharf ./cmd/wharf

# the original design prototype (sample data, simulated shell, no disk I/O):
go run ./cmd/wharf --demo

Requires Go 1.26+. Releases are cut with GoReleaser — see docs/PACKAGING.md.

CLI

wharf [flags] [host]

Bare wharf opens the TUI. A host argument names a saved host to connect to right after the vault unlocks — exact name, else a unique name prefix, both case-insensitive (wharf prod works while only one host starts with prod). An argument that matches nothing, or several hosts, only raises a toast: you land on the hosts list, never back at your shell. Personal hosts only — project hosts need a projects sync that has not run yet at unlock time.

Verbs are spelled as flags precisely so they never claim a name a host could have:

| flag | what it does | | --- | --- | | --version | print the version and exit | | --logout | delete the local sync session (sign this device out) and exit | | --doctor | print resolved paths and environment, then exit | | --reset | destructive: erase this device's vault, session and caches | | --vault <path> | vault file path, overriding $WHARF_VAULT | | --proxy <url> | egress proxy for outbound SSH, overriding $WHARF_PROXY and the saved setting | | --remote <token> -- <cmd> | run one command on a host through a remote-access grant and exit | | --demo | sample data and a simulated session — no disk I/O, no real SSH |

--logout deliberately needs no master password. The session file is sealed under the vault DEK, so the in-TUI sign-out is only reachable while unlocked — this is the escape hatch for a vault you cannot open at all. It is local only: the refresh token cannot be read, let alone revoked server-side, without the vault key. To invalidate sessions on the server, reset with your recovery code — that rotates the code and revokes every token.

--reset erases this device's wharf state — vault.enc, session.enc, the cached project blobs and the lock sidecar — and it cannot be undone: the recovery code unlocks a vault file, so once the file is gone, so is every host, key and stored password in it. The one exception worth knowing: if the device is signed in, the server's copy is untouched, so signing in again with your master password pulls the vault back.

It only deletes after a typed confirmation:

$ wharf --reset
Are you sure you want to reset your wharf instance?

This permanently erases, on this device:
  • ~/.local/share/wharf/projects
  • ~/.local/share/wharf/session.enc
  • ~/.local/share/wharf/vault.enc
  • ~/.local/share/wharf/vault.lock
…
Type "I am sure" to confirm:

y/yes does not count — the phrase has to be typed out (case, spacing and the apostrophe in I'm sure are forgiven). Three further guards: it lists only paths that actually exist, it refuses when stdin is not a terminal so a pipe or CI job can never satisfy the prompt, and it refuses while another wharf instance holds the vault lock, whose next save would otherwise write the vault straight back.

--doctor reads no secrets and never unlocks the vault; it prints the version, Go version and platform, the resolved vault / config / session / known_hosts paths (with a present/missing marker each), the API base and device URL, and the proxy in effect with any password redacted. It is what to paste into a bug report.

Environment: WHARF_VAULT (vault file path), WHARF_CONFIG (machine-local config file), WHARF_API_BASE (sync backend base URL), WHARF_NO_BROWSER (set to anything to stop sign-in opening the pairing page for you), WHARF_REMOTE_TOKEN (see Remote access), and the proxy variables below.

Egress proxy

On a network that only reaches the outside world through a proxy, point wharf at it — settings tab → Egress proxy, or --proxy, or $WHARF_PROXY:

socks5://proxy.corp:1080          SOCKS5 (socks5h:// is accepted and identical)
http://proxy.corp:3128            HTTP CONNECT — what most corporate proxies speak
https://proxy.corp:3129           the same, with TLS to the proxy itself
proxy.corp:1080                   no scheme: read as socks5://
off                               force direct, ignoring $ALL_PROXY

Interactive sessions, port forwards and the reachability probes all take this path; the local end of a -R forward does not, since that is a dial from this machine to something this machine can already reach. $NO_PROXY is honoured with its usual grammar (suffixes, CIDRs, bare IPs, *), and loopback is always dialled directly.

Precedence, highest first: --proxy, $WHARF_PROXY, the saved setting, then $ALL_PROXY / $HTTPS_PROXY. The saved setting deliberately outranks the last pair: those are ambient defaults exported for whatever tooling reads them, while a value typed into wharf's settings screen means wharf. Setting it to off at any level forces a direct connection and stops the search.

The setting is not synced. It describes the network the machine is on, not the account — syncing an office proxy onto a laptop at home would break every connection there. It lives in ${XDG_CONFIG_HOME:-~/.config}/wharf/config.json, which is plaintext: a password in the URL is stripped before writing, so proxies that need credentials want $WHARF_PROXY instead, which lives no longer than the process you set it on.

Behind a proxy the status dots read ? unknown rather than offline when a probe fails: a proxy declining CONNECT to port 22 on policy and a host that is genuinely down look identical from here, and calling a reachable host offline is the mistake that stops someone trying.

Detach key

ctrl+\ leaves an attached session running. Some terminals and multiplexers claim that combination before wharf ever sees it, so it is rebindable: settings → Detach key, then press the key you want. It is stored beside the proxy in the same machine-local config.json ("detachKey": "ctrl+]"), and for the same reason — which control keys survive the trip depends on the terminal in front of you, not on the account.

An attached terminal is in raw mode: wharf sees a byte stream on its way to the remote, not keypresses. So the binding has to be a ctrl combination, and the ones the remote shell cannot do without — ctrl+c, ctrl+d, ctrl+z, escape, tab, enter, backspace, flow control — are refused with the reason why. A change takes effect immediately, including for sessions that are already running.

There are two in-session hotkeys, and they work the same way: the detach key and the remote-access key (ctrl+] by default — see Remote access), stored beside it as "remoteKey": "ctrl+]". They draw from one set of bindable keys and one set of refusals, and they cannot be bound to the same key: whichever byte the attach loop swallows for one is a byte the other can never see, so binding both to it would silently disable one. The capture modal refuses from either direction and names the conflict. If a hand-edited config gets past that anyway, detach wins — a session you cannot leave is the one failure neither key may cause.

Remote access

macOS and Linux only. The grant is served on a 0600 unix socket inside a 0700 directory, and the file-mode half of that has no Windows equivalent — a named pipe is secured by an ACL, which is a different design and deserves to be made on its own terms. On Windows both keys still exist and say why they did nothing — r inline, ctrl+] as a printed line in the session — because a key that silently does nothing is worse than one that refuses out loud. Exec itself is not the missing piece: sessions run in-process there, so sessd.Remote.Exec works fine. What is missing is the cross-process capability.

An AI coding agent running in your terminal cannot SSH anywhere useful. It has no way through a passphrase prompt, a TOFU confirmation or a 2FA challenge, so the usual answer is to hand it ~/.ssh and hope. Remote access is the other answer: press r on a connected host on the dashboard, or ctrl+] without leaving the session you are attached to, and wharf mints a revocable, auditable, exec-only capability on that one host, then prints (and tries to copy) the command line that uses it:

wharf --remote 8Q2c… -- <your command>

The agent runs that in its own shell. It never sees a key, a password or the vault.

agent's shell                    wharf (TUI)                    session host child
  │                               │ holds the grant + token      │ owns ssh.Client + PTY
  │  connect, token frame ──────▶ │ constant-time compare        │
  │                               │ TTL / revoked / in-flight    │
  │                               │ audit event ──▶ overlay      │
  │                               │ exec ──────────────────────▶ │ new channel, no PTY,
  │ ◀── stdout/stderr/exit ────── │ ◀────────────────────────────│ same ssh.Client

The exec rides the existing ssh.Client, in a channel of its own. That is the whole trick: no second dial, so no second authentication, so a capability handed to an agent can never raise a passphrase or TOFU prompt in your face. It is also isolated from the PTY session's scrollback ring — nothing the agent runs shows up in your own terminal history, only in the audit log.

The grammar. Everything after -- is the command:

wharf --remote TOKEN -- curl -sS -d '{"a":1}' localhost:9000/health
wharf --remote TOKEN --sh -- 'journalctl -u api | tail -50'
wharf --remote TOKEN --timeout 10m -- go test ./...

The separator is required, not optional — it is what makes the command's own flags (curl -sS) unambiguous, so a forgotten -- is an error naming the fix rather than "unknown flag curl". --timeout and --sh are the only flags allowed before it.

By default the words after -- are an argv, and wharf quotes them exactly once, itself, before running them under the remote's login shell (exec "$SHELL" -lc …). This matters more than it sounds. Letting the caller quote and passing the words through sends the string across two shell parsers, and the second one re-expands whatever survived the first: the canonical curl -d '{"a":1}' loses its braces to brace expansion or its quotes to word splitting, depending on which shell is on the far end. Quoting once makes the far end's shell irrelevant to the payload — arguments arrive byte for byte. The login shell is there for the other half of the problem: without -l, tooling installed by nvm, pyenv, asdf or Homebrew's shellenv is simply absent and the agent is told "command not found" about something that plainly exists.

--sh is the escape hatch for what genuinely wants a shell — pipes, redirection, &&. It takes one argument, the script as a single quoted word, and passes it through verbatim.

Streams. stdout and stderr are forwarded as they arrive, never buffered wholesale. stdin is forwarded when it is not a terminal (a pipe, a file), capped at 720 KiB — v1 sends it inside the request rather than streaming it, so it is held in memory in three processes at once and, more to the point, has to fit in one 1 MiB frame after encoding/json base64s it: 1 MiB in is 1.4 MiB on the wire, so the cap is (1 MiB − 64 KiB) × ¾, with the reserve covering the command and the JSON around it. The error names the limit.

--timeout defaults to 2m. Finite on purpose: a hung command holds one of the grant's four in-flight slots until the grant's whole TTL runs out, and an agent that gets no answer retries rather than waits. --timeout 0 waits as long as the grant lives.

Exit status:

| code | meaning | | --- | --- | | anything | the remote command's own exit status, verbatim | | 125 | wharf's own failure: no grant accepted the token, a timeout, a bad command line |

125 follows the convention env(1) and timeout(1) established for exactly this problem — a wrapper has to report its own failure through the same channel it is forwarding somebody else's exit code on. 1 and 2 were rejected because they are the two codes ordinary programs return most often, so a caller could not tell "your command failed" from "wharf could not run it"; usage errors use 125 as well, so there is exactly one code to test for. The residual ambiguity is real: a remote command that itself exits 125 is indistinguishable from a wharf-side failure. Everything wharf reports is accompanied by a wharf: line on stderr, and that is the tiebreak.

$WHARF_REMOTE_TOKEN supplies the token when it is not in argv, for a caller who would rather keep it out of ps. argv wins when both are set, so a pasted command line always beats a stale exported variable.

On the dashboard. r on the hosts tab mints a grant on the selected host — or revokes the standing one, whatever is selected (see below). With no live session on that host it sets an inline error instead of dialling, because a grant must ride a connection that already exists. There is one grant at a time, app-wide — "which of my three grants is that command running under" is a question a security feature should never make you ask. While it stands, the header carries a ⚡ remote <host> badge, and A opens the overlay from any tab: the command line as selectable text, the expiry, the command count, and the live audit log — every command, when it started and how it ended, newest first, capped at 200 lines. x revokes from there, c re-copies.

What the log guarantees. Every command that reaches the host gets an entry, and so does every one that is turned away — a revoked or expired grant, or one already at its four-command ceiling. A burst of refusals is the shape an agent makes when it keeps trying after being cut off, and a log that showed only what succeeded would hide exactly that. Nothing is dropped on the way: the log is written by the grant itself and is the source of truth, and the signal that wakes the UI carries no data, so a coalesced or missed wake-up costs a repaint and never a row. That is worth saying because it was not always true — the first version fed the log through a bounded queue that discarded events under pressure, which an agent could have used to flood the log and run its real command in the gap. The one remaining way to lose a row is the 200-entry cap, and it drops the oldest first: a sustained flood pushes its own earlier commands off the end, and the flood is then the visible thing. The guarantee is "nothing is silently lost", not "unbounded history". The log deliberately outlives the grant — revoking is exactly when you want to read it — and each row names its own host, so the rows of a grant that was replaced or revoked stay legible next to the new one's.

While attached: ctrl+]. You do not have to leave the shell you are working in. The attach loop watches a second control byte beside the detach key, and pressing it toggles the grant for this session's host and prints the result straight into the terminal:

web1:~$ tail -f /var/log/api.log

wharf: remote access ON for web1 · expires 15:04 · sent to clipboard
wharf --remote 8Q2c… -- <your command>

It always starts on a fresh line — the remote may have left the cursor mid-prompt, and a notice spliced into one is worse than no notice.

Press it again and it revokes, saying so in one line. Press it while a grant is live on a different host and it replaces that one, naming the host that lost it (remote access ON for web1 — db1 lost it) — a moved capability must never be silent about what it was moved from. The hotkey byte is swallowed by wharf and never reaches the remote; the bytes around it in the same read go through in their original order.

The command line is printed in full even when the copy worked, because OSC 52 has no reply and some terminals swallow it. That does put the token in this terminal's scrollback — the same exposure the overlay already accepts, and consistency is worth more than a marginal reduction — which is also why wharf 2>debug.log, script(1) and friends matter here: what is on screen is one thing, what is on disk is another.

It will smudge a full-screen app. Printing two lines into a terminal that vim, htop or a tmux status bar is drawing on corrupts the display until that app repaints. There is no general fix and none is planned: wharf does not know what the remote is drawing, and the alternatives — suppressing the notice, or clearing and redrawing someone else's screen — are each worse than a smudge. The clipboard copy is what makes it survivable in practice: press the key, ignore the mess, ctrl+l, paste. Worth knowing before it happens, because it looks like a bug and is not one.

Dashboard r and the hotkey differ on purpose. r revokes a live grant whatever the cursor is on, and only mints when there is none — taking a capability back must not depend on where a list cursor happens to sit, least of all when that host's session has just died and its row may be gone. Attached, the host is unambiguous by construction, so the hotkey can do the more useful thing and move the grant here.

The key is rebindable exactly like the detach key — settings tab → Remote-access key — and the two cannot collide: whichever byte the attach loop swallows for one is a byte the other can never see, so the capture modal refuses from either direction and names the conflict (ctrl+] is already the detach key) rather than just saying no.

The copy is best-effort, and both surfaces say which happened — the overlay in a line of its own, the hotkey in its status line. It is OSC 52 — the only clipboard mechanism that works over SSH, since it asks the terminal emulator on your desk to hold the text rather than needing pbcopy, xclip or a local daemon. Some terminals silently drop the sequence (Terminal.app), and wharf refuses to write it at all when stderr is not a terminal: under script(1), a systemd unit, tmux logging or plain wharf 2>debug.log, the sequence would put the token in a file, and the token never touching disk is a hard rule. So in those cases nothing is copied — the overlay says so and shows the line to select by hand, and it never reports a copy that did not happen.

Revocation is synchronous: when it returns, the socket is unlinked, no further command can start, and an in-flight one is cancelled — its output stops reaching wharf immediately. It fires on all of:

| trigger | | | --- | --- | | r again, or x in the overlay | you revoke it | | TTL | 60 minutes, fixed at mint time and never extended — a renewable grant is a permanent one with extra steps | | the session ending | the grant rides that connection and must not outlive it | | q (lock the vault) | locking is what you do when you walk away | | ctrl+q (quit) | closed explicitly, so quitting cannot race a command that is starting |

What revocation does not do is reach into the host. A command the agent already started may keep running there after the grant is closed. Wharf asks it to stop — an SSH signal request, SIGTERM then SIGKILL — but that request is advisory and sshd has long been reported not to act on it, and closing the channel is no substitute: without a PTY there is no controlling terminal to hang up, which is the same reason ssh host 'sleep 3600' leaves a sleep behind while ssh -t does not. A cancelled command that writes output normally dies of SIGPIPE on its next write; one that writes nothing — sleep 3600, something already backgrounded — may run to completion. What revocation guarantees is that no further command can be started and that nothing more reaches wharf, not that the host is quiet.

Nothing about a grant is persisted — not to the vault, not to config.json, not to a filename. A wharf restart is itself a revocation.

Trust model. A grant is a bearer token on a 0600 unix socket in a 0700 runtime directory. Anyone who can read the token and open the socket can run commands on that host, as that user, until it is revoked or expires. Not a sandbox, not a restricted command set: real shell, on a real host, for the life of the grant. The accepted risks, stated plainly: the token transits the clipboard and then an agent's context, which is frequently logged and summarised — the short TTL and the single-host binding mitigate that, they do not eliminate it, which is why the default TTL should not be raised; a prompt-injected agent has that shell and can do whatever the remote user can, so the live audit log is a core feature and not decoration — the argument is only as good as the log's completeness, which is why that is spelled out above; and the token appears in the agent's shell history and in ps output, since argv is world-readable ($WHARF_REMOTE_TOKEN is the way out for a caller who cares). And revocation withdraws the capability, not the consequences of what it was already used for: a command already running on the host may finish, as described above.

What makes it worth having anyway is the comparison, which is not against "nothing" but against what people actually do. Handing an agent ~/.ssh gives it every host, every key, persistently, with no record. Leaving an OpenSSH ControlMaster socket around grants a full interactive shell to anyone on the machine who can stat it, silently, with no log and no off-switch. A grant is one host, exec only, in its own channel — no PTY, no forwards, no access to the host spec — with a 32-byte token on top of the file mode (compared in constant time), an hour's ceiling, five independent things that end it, and every command printed as it runs. It is a narrowing of the trust boundary sessions already accept, not a new safety guarantee.

How it works

  • First run asks one question: use wharf on this machine only, or sign in to a Wharf account.

    • 1 — local only creates your vault here: choose a master password, then write down the 40-character recovery code — it is shown exactly once and is the only way back in if you forget the password.
    • 2 — sign in opens the pairing page in your browser, pairs there, and then installs your account's vault as this machine's vault, so the account's master password and recovery code are the only ones this machine has. Nothing is created locally.

    Every later run starts at the unlock screen (r switches to recovery-code entry, which forces a password reset and issues a new code).

  • Sessions are full-fidelity, and they outlive wharf. Connecting hands your real terminal to the remote shell — vim, htop and tmux behave exactly as over plain ssh. Press ctrl+\ (rebindable — see Detach key) to detach: the session keeps running while you use the dashboard, and reattaching replays recent output. Press S for the live-sessions overlay. Quitting wharf does not kill them (on macOS and Linux) — see Sessions that outlive wharf.

  • A host can have several sessions. enter on a host that already has one opens a picker: reattach to a specific session, kill one (x twice — a live shell is not something to lose to a stray key), or start another with n.

  • Two auth modes per host. key (the default): ssh-agent → configured key file (passphrase prompted in the TUI) → synced vault keys → keyboard-interactive (2FA). password: stored/prompted password → keyboard-interactive — it never offers public keys, so servers with a strict MaxAuthTries aren't burned on key attempts they'll never accept. The host form shows only the fields the mode needs (key path + vault key, or password).

  • A host can be bound to one vault key. Pick it in the host form (vault key, ‹ › to change) and that key is the only one offered — the agent is skipped too. This matters once the vault holds more than a handful of keys: a server allows about six authentication attempts per connection, and an unbound host spends them walking the list. Unbound hosts still offer everything, in batches small enough that the server does not hang up, so a large vault works either way — binding just makes it one attempt instead of many. A Termius import carries the binding over, since the profile records which key each host uses. Host keys are verified against ~/.ssh/known_hosts; unknown hosts show a fingerprint confirmation (TOFU), and a changed host key is a hard refusal — no override.

  • Passwords can be saved per host (they live only inside the encrypted vault): set one in the host form, or press ctrl+r ("remember") in the password prompt — after a successful login it's stored and future connects go straight to the shell. A rejected saved password falls back to the interactive prompt.

  • Probes are advisory. The online/degraded/offline dots come from an async TCP check; they never block connecting.

Account sync

Signing in pairs the TUI with your account at wharf.jannekeipert.de and keeps the vault in sync across machines. The server only ever stores ciphertext (the vault blob is uploaded verbatim); it never sees your master password or plaintext.

Pairing (no account password is ever typed into the terminal):

  1. Open wharf.jannekeipert.de/device in your browser and sign in — it shows an 8-character pairing code.
  2. In the TUI: settings tab → Accountenter (or enter on the projects gate), then type the code (the XXXX-XXXX dash form is fine).
  3. Done — the header shows your email plus a live sync indicator: ● synced / ⠋ syncing / ● offline / ● conflict.

One password, one recovery code. Signing in does not leave the machine with two vaults. A vault blob carries its own password slot and its own recovery slot, and an account additionally has server-side credentials derived from the same password and the same code — so a locally created vault pushed into an account would answer to a recovery code the server has never heard of, and the browser's reset flow would break. Instead, pairing adopts the account vault: it is downloaded, installed verbatim as this machine's vault file, and the hosts and keys you already had here are merged into it and pushed on the next pass. Afterwards the account's master password and recovery code are the ones that work here too.

  • If this machine's password already matches the account's, the adoption is silent. If it doesn't, the TUI asks for the account's master password once.
  • Merging never overwrites: a local host or key whose name is already taken on the account side is skipped, and the toast says how many were kept and how many clashed.
  • An account created through Google/GitHub that has never set a master password has no vault to adopt. The TUI will not invent one — it points you at wharf.jannekeipert.de/set-password and leaves the account untouched.

Your email must be verified before the backend hands out a session. If it isn't, pairing is refused with "email not verified" — confirm the address in the browser (verification is web-only; the TUI never registers or verifies) and type the same code again: a rejection on this path does not use it up.

What syncs: the whole vault payload — hosts (including saved per-host passwords) and settings. SSH key files are not synced; they stay in ~/.ssh.

When it syncs: on unlock (pull), a few seconds after each change (debounced push), and on demand with s on the settings tab. Sync uses optimistic versioning: pushes carry the last-seen remote version, and a lost race pulls first and re-evaluates.

Conflicts: if this machine and the account vault both changed since the last sync, Wharf never merges silently — a prompt asks you to keep local (overwrite remote) or take remote (discard local changes). One exception: right after pairing, if one side has zero hosts and the other doesn't, the non-empty side wins automatically.

Session file: pairing stores a device-local session (refresh token + sync bookkeeping) next to the vault as session.enc, mode 0600, encrypted with a key derived from the unlocked vault (HKDF subkey of the vault DEK, XChaCha20-Poly1305). It is never part of the synced payload. Consequences: sync only works while the vault is unlocked, and re-creating the vault (new DEK) invalidates the session — just pair again. Signing out (settings → Account) deletes the session file and keeps the local vault.

Master password note: a remote vault blob is encrypted by whichever client wrote it, under your master password with its own salts. The TUI keeps the password you unlocked with in memory (zeroed on lock/quit) to open pulled blobs. If it turns out not to open the account's vault, sync does not wedge: the TUI offers to adopt the account vault, asking for its master password once and merging this machine's hosts into it.

Projects

A project is a shared host workspace: its hosts live in their own encrypted blob, sealed to every member's published key. Private keys are never shared.

  • Opening a project keeps you on the projects tab. enter moves the cursor into that project's hosts in the detail pane — enter connects, esc goes back. tab rings through list → hosts → members. f is the old behaviour on purpose: the merged hosts tab, filtered to this project (esc clears it).
  • Moving a host in or out is p on the hosts tab: pick the personal vault or any project you can write to. The host leaves one document and lands in the other, each pushed on its own. A name already taken at the destination is refused before anything is removed — the two sides are separate blobs with separate versioned pushes and cannot be made atomic, so the host must never be in flight between them. Saved passwords travel with the host; a host in a project is readable by every member, so move deliberately.

Backend: defaults to https://wharf.jannekeipert.de; override with WHARF_API_BASE (e.g. a local wharf-backend on http://localhost:8080).

Upgrading

Projects add a v2 vault payload carrying your project identity. By design, a pre-projects (v1) build hard-errors on a v2 payload rather than silently dropping the identity — so once any device writes v2, upgrade all of your devices before opening the vault on them. The same applies to every later bump: v3 added synced SSH keys, and v4 added the ML-KEM-768 half of your project identity (see below). Your master password and recovery code are unaffected by any of them; the vault DEK and both unlock slots are unchanged.

Post-quantum project keys (v4). Project keys used to be sealed to a bare X25519 key — classical, and the server keeps every sealed key indefinitely, so a copy taken today could be decrypted by a future quantum computer. wharf now seals them with a hybrid ML-KEM-768 + X25519 wrap, which needs both to be broken. The first time you open the projects tab after upgrading, wharf adds the ML-KEM half to your existing identity and republishes it. Nothing is re-granted and no project loses access: the upgraded key keeps your X25519 key, so everything already sealed to you still opens. Other members seal to you in whichever version your published key is, so a device still on an older build keeps working. If a device that first created your identity is lost for good, open the projects tab and press R on the "sync first" notice to reset your project identity (rotates your published key; every project re-enters awaiting-access until an admin re-grants).

The model

| Without an account (local) | Adds when you sign in | | --- | --- | | Hosts, keys/identities, settings — encrypted vault | Cross-machine sync of your vault | | Real SSH sessions: connect / detach / reattach | Projects: shared host workspaces (planned) | | ~/.ssh/config + Termius import, key generation, probes | Invite teammates, roles (owner/admin/member) (planned) |

Security model

  • Master password → key via argon2id, entirely client-side.
  • Vault sealed with XChaCha20-Poly1305; the file is designed to be synced verbatim as an opaque ciphertext blob (zero-knowledge server).
  • Two unlock slots: master password and the one-time recovery code. Regenerating the code invalidates the old one. No email reset, no support backdoor.
  • Sign-in is a browser device-code pairing: authentication happens in the browser and the TUI never sends a password to the server. It may ask for your account's master password — that is the key to your vault ciphertext, needed locally to open the blob it just downloaded, exactly as the unlock screen needs it every run. The device session lives in an encrypted session.enc next to the vault (see Account sync).
  • One password and one recovery code per account, on every device. Signing in installs the account's vault blob verbatim rather than uploading a locally created one, so the recovery slot inside the blob and the recovery credential on the server stay the same secret — which is what the browser's reset flow requires.
  • The server distributes project public keys, so its copy of yours is checked. Project keys are sealed to each member's published key; a server that swapped in its own key for your account would receive every project key shared with you, and the only symptom would be projects stuck in awaiting-access. On every visit to the projects tab wharf compares the key the server publishes for your account against the one in this vault. On a difference it shows a warning with both fingerprints (SHA-256 of the key, base64, first 16 characters in blocks of four — identical across the web and mobile clients, so you can compare them by eye), refuses to hand project keys to anyone until it is resolved, and offers p to republish your local key over the server's copy. A server that cannot be reached is unknown, not a mismatch.

Sessions that outlive wharf

macOS and Linux only. On Windows sessions run inside wharf itself and end when it does — os/exec has no ExtraFiles there, so the descriptor handoff below is not expressible. Everything else in this section, detach included, behaves the same while wharf is open.

Connecting does not open the SSH connection inside the TUI. wharf re-executes itself as a session host — one child process per session — hands it a listening unix socket, and that child owns the ssh.Client, the PTY and the 256 KiB scrollback ring. The TUI attaches over the socket and proxies bytes to your terminal.

So quitting wharf just drops a control connection. The shell keeps running, and the next wharf scans the socket directory, reattaches to whatever is still alive and lists it in the live strip — alt+1..9 or enter on a host row marked live picks up where you left off, scrollback and all.

One child per session is deliberate: no singleton daemon to supervise, no protocol handshake to keep compatible across upgrades, and a crash costs one session instead of all of them.

wharf (TUI)                     wharf --session-host (one per session)
  │                               │ owns ssh.Client + PTY + scrollback ring
  │  spawn, listener on fd 3 ──▶  │ serves $XDG_RUNTIME_DIR/wharf/sessions/*.sock
  │  attach: raw stream ◀──────▶  │
  │  quit ──▶ (child lives on)    │
  └── next run: scan + adopt ──▶  │ still there

Reattaching

S opens the live-sessions overlay from any tab: every running session, newest state, enter to attach and x x to kill. enter on a host row that already has sessions opens the same picker scoped to that host, plus a + new session row.

alt+1..9 also jumps to a session, but only in terminals that send Option/Alt as Meta — macOS does not by default. In Terminal.app enable Settings → Profiles → Keyboard → Use Option as Meta key; in iTerm2 set Settings → Profiles → Keys → Left Option key → Esc+. Without that, S is the portable route and needs no configuration.

Boundaries. Sessions do not survive a reboot or a logout (the runtime directory is wiped). Port forwards are not hosted this way — they are documented as ephemeral and never persisted, so they still die with the TUI, and the quit prompt says so.

Trust model. The socket directory is 0700 (verified, not assumed — it is checked for ownership and mode on every start) and each socket is 0600. Anyone who can open the socket can type into that shell: the same exposure tmux and OpenSSH's ControlMaster accept, but new for wharf. The child never receives the master password and never touches the vault — it gets one host spec over the socket, authenticates with it, and holds nothing else. WHARF_RUNTIME_DIR overrides the location.

Keybindings

| Key | Action | | --- | --- | | j / k, / | move selection | | 14 | switch tab (hosts / projects / keys / settings) | | / | filter hosts (search as you type) | | tab | cycle pane focus (projects tab: list → hosts → members) | | enter | connect / open / toggle | | a / e / d | add / edit / delete host | | p | move the selected host into a project (or back to personal) | | f | show this project's hosts on the hosts tab (projects tab) | | m | import hosts + keys (~/.ssh/config or a local Termius profile) | | R | re-probe reachability | | r | grant / revoke remote access on the selected connected host (hosts tab) | | A | remote access: command line, expiry, live command log | | g | generate an ed25519 key (keys tab) | | s | sync now (settings tab, signed in) | | ctrl+r | remember the typed password (password prompt) | | ctrl+\ | detach the attached session (rebindable in settings) | | ctrl+] | toggle remote access for this session's host, while attached (rebindable in settings) | | S | live sessions: reattach, kill, or open another | | alt+1..9 | reattach a live session (needs Option-as-Meta — see below) | | q | lock the vault | | ctrl+q | quit (confirms when sessions or forwards are running) | | ? | toggle help |

Layout

main.go                     program entry (Bubble Tea)
internal/
  theme/        abyss · phosphor · amber palettes
  vault/        argon2id + XChaCha20-Poly1305 encrypted vault file
  store/        hosts & settings document persisted through the vault
  api/          HTTP client for wharf-backend (pairing, refresh, vault get/put)
  identity/     cross-client fingerprint of the X25519 project identity key
  sync/         sync engine: session file, optimistic versioning, conflicts
  sshx/         SSH engine: auth chain, known_hosts/TOFU, detachable sessions, exec
  sessd/        session-host child processes + their unix-socket protocol
  remoteaccess/ revocable exec-only grants: token, socket, audit (unix only)
  clipboard/    OSC 52 copy, injectable so headless tests emit no escapes
  keys/         ~/.ssh scan + ed25519 generation
  sshcfg/       ~/.ssh/config import
  termius/      local Termius profile import (IndexedDB + keyring, PuTTY .ppk conversion)
  probe/        advisory TCP reachability checks
  data/         demo-mode fixtures
  ui/           model · update · view (Elm architecture)

Built with Bubble Tea + Lip Gloss and golang.org/x/crypto/ssh.

Roadmap

  • [x] Sync client against wharf-backend (device-code auth, ciphertext push/pull)
  • [x] Port forwarding (-L/-R/-D, per host)
  • [x] Sessions that survive quitting wharf (session-host child processes)
  • [x] Team projects backed by the real backend
  • [x] Remote access: revocable exec-only grants for a local agent
  • [x] Grant or revoke remote access from inside an attached session (ctrl+])
  • [ ] Hardware keys (YubiKey resident / -SK)
  • [x] Bind a host to one synced key (host form, and carried over by the Termius import)
  • [ ] Assign a scanned key to a host from the keys tab
  • [ ] mosh fallback

License

MIT — see LICENSE.