{"id":51331898,"url":"https://github.com/fzlzjerry/hearth","last_synced_at":"2026-07-01T23:30:46.257Z","repository":{"id":367866461,"uuid":"1282621077","full_name":"fzlzjerry/hearth","owner":"fzlzjerry","description":"A self-hosted browser TUI for managing tmux sessions across multiple servers, keyboard-first with full interactive terminals.","archived":false,"fork":false,"pushed_at":"2026-06-28T03:19:05.000Z","size":5869,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-28T04:26:04.352Z","etag":null,"topics":["bun","fastify","nextjs","self-hosted","ssh","terminal","tmux","tmux-session-manager","typescript","web-terminal","websocket","xterm"],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/fzlzjerry.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-28T02:29:31.000Z","updated_at":"2026-06-28T03:19:08.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/fzlzjerry/hearth","commit_stats":null,"previous_names":["fzlzjerry/hearth"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/fzlzjerry/hearth","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fzlzjerry%2Fhearth","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fzlzjerry%2Fhearth/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fzlzjerry%2Fhearth/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fzlzjerry%2Fhearth/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/fzlzjerry","download_url":"https://codeload.github.com/fzlzjerry/hearth/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/fzlzjerry%2Fhearth/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35027339,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-01T02:00:05.325Z","response_time":130,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["bun","fastify","nextjs","self-hosted","ssh","terminal","tmux","tmux-session-manager","typescript","web-terminal","websocket","xterm"],"created_at":"2026-07-01T23:30:45.625Z","updated_at":"2026-07-01T23:30:46.252Z","avatar_url":"https://github.com/fzlzjerry.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Hearth\n\nA browser-based **TUI** for managing [tmux](https://github.com/tmux/tmux) sessions across many\nservers. Pick a session from the sidebar, drop into a full interactive terminal in your browser.\nIt looks and behaves like `lazygit` / `k9s` / `btop` (monospace, keyboard-first, zero chrome), not\na SaaS dashboard.\n\n![Hearth overview](docs/overview.png)\n*Servers and their tmux sessions in a keyboard-first sidebar.*\n\n![Attached terminal](docs/terminal.png)\n*Attach and you're in a full interactive terminal, Nerd Font glyphs included.*\n\n![Running an agent in a session](docs/agent.png)\n*Sessions stay alive on the server, so long-running work survives reconnects.*\n\n## How it works\n\nHearth is two apps. The split keeps the terminal stream off Vercel (serverless can't hold\nlong-lived WebSockets) and keeps every server credential on a machine you control.\n\n```mermaid\nflowchart LR\n  B[\"Browser\"]\n  W[\"apps/web\u003cbr/\u003eNext.js on Vercel\"]\n  CF[\"Cloudflare Tunnel\u003cbr/\u003ehearth.your-domain\"]\n  H[\"hearthd\u003cbr/\u003eyour VPS · 127.0.0.1:8080\"]\n  R[(\"remote server\u003cbr/\u003etmux\")]\n  L[(\"VPS-local\u003cbr/\u003etmux\")]\n\n  B --\u003e|\"1 · load UI (HTTPS)\"| W\n  B --\u003e|\"2 · REST via /api/*\"| W\n  W --\u003e|\"3 · REST + Bearer HEARTH_TOKEN\"| CF\n  B --\u003e|\"4 · terminal stream (WSS + JWT)\"| CF\n  CF --\u003e H\n  H --\u003e|\"ssh2 PTY\"| R\n  H --\u003e|\"node-pty\"| L\n```\n\n- **`apps/web`** (`@hearth/web`): Next.js 15 + Tailwind v4 + xterm.js. Renders the UI, proxies REST\n  to `hearthd` (injecting the service token server-side), and mints short-lived tokens for the\n  browser's direct terminal WebSocket. Deploys to Vercel. **Never carries terminal traffic.**\n- **`apps/hub`** (`@hearth/hub`, binary `hearthd`): Fastify + ssh2 + node-pty. Holds all server\n  credentials. Bridges a WebSocket to `tmux new -A -s \u003cname\u003e` (attach-or-create) over SSH (remote)\n  or node-pty (the VPS itself). Because tmux keeps sessions alive, a dropped connection simply\n  re-attaches.\n\n## Requirements\n\n| Where | Needs |\n| --- | --- |\n| Your dev machine | [Bun](https://bun.sh) 1.3+ · `tmux` (only to test a local session) |\n| The VPS running `hearthd` | Bun 1.3+ (install/build) · Node 20+ (runs the built daemon) · `tmux` if you host sessions on the VPS itself |\n| Each target server | `tmux` installed · reachable over SSH |\n| Vercel | nothing to install (managed) |\n\nInstall Bun: `curl -fsSL https://bun.sh/install | bash`\n\n\u003e `node-pty` (used only for a VPS-local session) is an optional native addon. Bun skips its native\n\u003e build by default, which is fine for the SSH path. To use a `local` server, install a C++ toolchain\n\u003e (Xcode CLT on macOS; `build-essential` + `python3` on Linux) and run `bun pm trust node-pty`.\n\n## Quick start (run it locally)\n\nThis gets the full app running on your machine with a terminal into your **own Mac/Linux box** (no\nremote server required).\n\n**1. Install and build**\n\n```bash\ngit clone \u003cthis-repo\u003e hearth \u0026\u0026 cd hearth\nbun install\n```\n\n**2. Generate the two shared secrets** (you'll paste the same values into both apps)\n\n```bash\nopenssl rand -hex 32   # use as HEARTH_TOKEN\nopenssl rand -hex 32   # use as JWT_SECRET\n```\n\n**3. Configure the hub** (`apps/hub`)\n\n```bash\ncp apps/hub/.env.example apps/hub/.env\n```\n\nEdit `apps/hub/.env` and set `HEARTH_TOKEN` and `JWT_SECRET` to the values from step 2. Then create\nthe server list:\n\n```bash\n# A local session on this machine, no SSH (needs tmux + a built node-pty):\necho '[{ \"id\": \"local\", \"name\": \"this machine\", \"local\": true }]' \u003e apps/hub/servers.json\n```\n\n(Prefer a remote box? Use `apps/hub/servers.json.example` as a template instead, see\n[Server configuration](#server-configuration).)\n\n**4. Configure the web app** (`apps/web`)\n\n```bash\ncp apps/web/.env.example apps/web/.env.local\n```\n\nEdit `apps/web/.env.local`:\n\n```ini\nHEARTH_HTTP_URL=http://localhost:8080\nHEARTH_WS_URL=ws://localhost:8080\nHEARTH_TOKEN=\u003csame value as the hub\u003e\nJWT_SECRET=\u003csame value as the hub\u003e\nDASHBOARD_PASSWORD=\u003cpick a password\u003e\n```\n\n**5. Run both** (two terminals, or use `bun run dev` from the root to run both at once)\n\n```bash\nbun run dev:hub   # → http://127.0.0.1:8080\nbun run dev:web   # → http://localhost:3000\n```\n\n**6. Open** http://localhost:3000, log in with `DASHBOARD_PASSWORD`, select a session, press `Enter`.\n\n## Configuration\n\n### Environment variables\n\n`HEARTH_TOKEN` and `JWT_SECRET` **must be identical** in both apps. Everything else is per-app.\n\n**`apps/hub/.env`** (the daemon, keeps secrets):\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `HOST` | `127.0.0.1` | Bind address. Keep on loopback in production (the tunnel reaches it). |\n| `PORT` | `8080` | Listen port. |\n| `HEARTH_TOKEN` | (required) | Static token the web app presents on REST calls. **Shared.** |\n| `JWT_SECRET` | (required) | HMAC secret for verifying terminal-WebSocket tokens. **Shared.** |\n| `SERVERS_FILE` | `./servers.json` | Path to the server inventory. |\n| `WS_PING_MS` | `30000` | Server keepalive ping interval (keeps idle terminals alive). |\n| `ALLOWED_ORIGIN` | (empty) | Comma-separated origins allowed to open the terminal WebSocket. Set to your Vercel URL in production. Empty allows any (fine for local dev). |\n| `LOG_LEVEL` | `info` | `fatal` \\| `error` \\| `warn` \\| `info` \\| `debug` \\| `trace` \\| `silent` |\n\n**`apps/web/.env.local`** (or Vercel env vars):\n\n| Variable | Example | Purpose |\n| --- | --- | --- |\n| `HEARTH_HTTP_URL` | `https://hearth.example.com` | Where the Next server reaches `hearthd`'s REST API. |\n| `HEARTH_WS_URL` | `wss://hearth.example.com` | Where the browser opens the terminal WebSocket. |\n| `HEARTH_TOKEN` | (required) | Must match the hub. **Shared.** |\n| `JWT_SECRET` | (required) | Must match the hub. **Shared.** |\n| `DASHBOARD_PASSWORD` | (required) | The single login password. |\n\n### Server configuration\n\n`apps/hub/servers.json` is a JSON array. Credentials live only here, on the hub.\n\n```json\n[\n  {\n    \"id\": \"vps\",\n    \"name\": \"vps · frankfurt\",\n    \"host\": \"203.0.113.10\",\n    \"port\": 22,\n    \"user\": \"deploy\",\n    \"identityFile\": \"~/.ssh/id_ed25519\"\n  },\n  { \"id\": \"this-box\", \"name\": \"this machine\", \"local\": true }\n]\n```\n\n| Field | Required | Notes |\n| --- | --- | --- |\n| `id` | yes | Unique, `[A-Za-z0-9_-]`. |\n| `name` | yes | Display label. |\n| `host` | remote only | Hostname or IP. |\n| `port` | no | SSH port, default `22`. |\n| `user` | remote only | SSH user. |\n| `identityFile` | no | Path to a private key on the hub (`~` is expanded). |\n| `password` | no | SSH password (use a key instead when possible). |\n| `local` | no | `true` runs tmux on the hub itself via node-pty (no SSH). |\n| `cwd` | no | Absolute start directory for **new** tmux sessions. Omit to use the default (local: the hub user's home; remote: the SSH login dir). |\n\nNew sessions start in `~` by default. To pin a server elsewhere, set an absolute `cwd` (e.g.\n`\"/srv/projects\"`). `cwd` only applies when a session is **created** — reattaching to an existing\nsession keeps its original directory, so kill and recreate it to pick up a change.\n\nYou can also add and remove servers from the UI (`a` / `x`); changes are written back to this file.\n\n## Deploying to production\n\n### 1. Run `hearthd` on your VPS\n\n```bash\ngit clone \u003cthis-repo\u003e /opt/hearth \u0026\u0026 cd /opt/hearth\nbun install\nbun run --filter @hearth/hub build      # builds apps/hub/dist/index.js\n\ncp apps/hub/.env.example apps/hub/.env  # fill in, then: chmod 600 apps/hub/.env\ncp apps/hub/servers.json.example apps/hub/servers.json   # edit your servers\n\n# install as a service (edit User / paths in the unit first)\nsudo cp apps/hub/deploy/hearthd.service /etc/systemd/system/\nsudo systemctl daemon-reload\nsudo systemctl enable --now hearthd\njournalctl -u hearthd -f                # check it started\n```\n\n### 2. Expose it with Cloudflare Tunnel\n\nThis gives `hearthd` a public `https://` + `wss://` address without opening any inbound port or\nexposing the VPS IP. Run on the VPS:\n\n```bash\ncloudflared tunnel login\ncloudflared tunnel create hearth\ncloudflared tunnel route dns hearth hearth.example.com\n```\n\nCopy `apps/hub/deploy/cloudflared-config.yml` to `~/.cloudflared/config.yml`, fill in the tunnel\nUUID and your hostname, then:\n\n```bash\ncloudflared tunnel run hearth        # or: sudo cloudflared service install\n```\n\nWebSockets are proxied automatically. (Cloudflare drops idle connections after ~100s; `hearthd`'s\n`WS_PING_MS` keeps terminals alive.)\n\n### 3. Deploy the web app to Vercel\n\n1. Import the repo. Set the project **Root Directory** to `apps/web` (Vercel detects Bun from the\n   `packageManager` field).\n2. Add the `apps/web` environment variables from the table above. Point `HEARTH_HTTP_URL` /\n   `HEARTH_WS_URL` at `https://` / `wss://hearth.example.com`, and use the same `HEARTH_TOKEN` /\n   `JWT_SECRET` as the hub.\n3. After the first deploy, set the hub's `ALLOWED_ORIGIN` to your Vercel URL and restart `hearthd`.\n\n## Keyboard shortcuts\n\n| Context | Keys |\n| --- | --- |\n| Sidebar | `j`/`k` or `↑`/`↓` move · `Enter` attach or expand · `n` new session · `d` kill session · `a` add server · `x` remove server · `r` refresh · `/` or `⌘K` jump · `1`–`9` switch tab |\n| Terminal | `⌘B` back to sidebar · `⌘1`–`9` tab · `⌘←`/`⌘→` adjacent tab · `⌘W` close tab · `⌘K` jump |\n\n`⌘` is `Ctrl` on non-Mac. While a terminal is focused, plain keystrokes go to the terminal; only\nthe `⌘`-combos are intercepted, so vim/tmux/etc. keep working.\n\n## Clipboard \u0026 images\n\n- **Select to copy.** Selecting text in a terminal copies it straight to your local clipboard. (The\n  browser Clipboard API needs a secure context, which the `https://` production deploy provides.)\n- **OSC 52.** Apps that set the clipboard via OSC 52 (vim with `clipboard=unnamedplus`, tmux\n  copy-mode, …) reach your local clipboard too. Inside tmux this only works if tmux forwards the\n  sequence — add to `~/.tmux.conf` on the target host:\n\n  ```tmux\n  set -g set-clipboard on\n  set -g allow-passthrough on\n  ```\n\n- **Paste an image.** Paste an image from your clipboard into a terminal and Hearth uploads it to the\n  target host under `~/.hearth/uploads/`, then types the absolute path into the prompt — so CLIs like\n  Claude Code (which can't read your local clipboard over SSH) can load it by path. Uploads are\n  capped at 10 MB and pruned after 7 days.\n\n## API reference\n\nREST endpoints require `Authorization: Bearer \u003cHEARTH_TOKEN\u003e` (the web app adds this for you):\n\n| Method | Path | Description |\n| --- | --- | --- |\n| `GET` | `/healthz` | Liveness (no auth). |\n| `GET` | `/servers` | Server list, credentials stripped. |\n| `POST` | `/servers` | Add a server (body: a server object). |\n| `DELETE` | `/servers/:id` | Remove a server. |\n| `GET` | `/servers/:id/sessions` | `{ name, windows, attached }[]`. |\n| `POST` | `/servers/:id/sessions` | Create a session (body: `{ \"name\": \"...\" }`). |\n| `DELETE` | `/servers/:id/sessions/:name` | Kill a session. |\n| `GET` | `/servers/:id/sessions/:name/preview` | `tmux capture-pane` snapshot. |\n| `POST` | `/servers/:id/upload` | Store a pasted image on the host (body: `{ \"filename\", \"mime\", \"dataBase64\" }`); returns `{ \"path\" }`. |\n\nTerminal WebSocket (token in the query string, short-lived):\n\n```\nGET /attach?server=\u003cid\u003e\u0026session=\u003cname\u003e\u0026cols=\u003cc\u003e\u0026rows=\u003cr\u003e\u0026token=\u003cjwt\u003e\n```\n\nBinary frames carry terminal bytes (both directions); text frames carry control JSON, e.g.\n`{ \"type\": \"resize\", \"cols\": 120, \"rows\": 40 }`.\n\n## Security model\n\n- **Login.** `/api/login` compares the password to `DASHBOARD_PASSWORD` in constant time, then sets\n  an `httpOnly`, `Secure`, `SameSite=Lax` session cookie (a 12h JWT).\n- **REST is proxied.** The browser only ever talks to same-origin `/api/*`. The Next server attaches\n  `Bearer HEARTH_TOKEN` and forwards to `hearthd`. `HEARTH_TOKEN` and `JWT_SECRET` never reach the\n  browser.\n- **The terminal WebSocket is direct and short-lived.** The browser fetches a ~60s token from\n  `/api/token` (cookie-gated) and connects straight to `hearthd`, which verifies the token and the\n  request `Origin` before opening the socket.\n- **No shell injection.** Session names are allowlisted (`^[A-Za-z0-9_.-]{1,64}$`) before any use;\n  the local path passes arguments as an array (no shell at all). Credentials stay in the hub's\n  `servers.json` and `.env`.\n\n## Project layout\n\n```\nhearth/\n├─ apps/\n│  ├─ web/    @hearth/web   Next.js UI + auth proxy (deploys to Vercel)\n│  └─ hub/    @hearth/hub   hearthd daemon (runs on your VPS)\n│     └─ deploy/            systemd unit + cloudflared config\n├─ package.json             Bun workspace + scripts\n└─ tsconfig.base.json\n```\n\nRoot scripts: `bun run dev` (both), `bun run build`, `bun run typecheck`, `bun run dev:web`,\n`bun run dev:hub`.\n\n## 友链\n\n- [linux.do](https://linux.do)\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffzlzjerry%2Fhearth","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ffzlzjerry%2Fhearth","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ffzlzjerry%2Fhearth/lists"}