diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b79f4c..d83c6d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,23 @@ jobs: - run: sh -n install.sh && bash -n install.sh - run: bun build --compile src/main.ts --outfile dist/hqterm && ./dist/hqterm --version + # The Windows installer for real: download, verify, install, PATH, twice + # (a re-run over an existing hqsh.exe must work too). + windows-install: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: install.ps1 (Windows PowerShell) + shell: powershell + run: ./install.ps1 + - name: install.ps1 again (pwsh), then hqsh runs from the user PATH + shell: pwsh + run: | + ./install.ps1 + $env:Path = [Environment]::GetEnvironmentVariable('Path', 'User') + ';' + $env:Path + hqsh version + if ($LASTEXITCODE -ne 0) { exit 1 } + desktop: runs-on: ubuntu-latest defaults: diff --git a/README.md b/README.md index 0876b3d..0d4d0b6 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,16 @@ curl -fsSL https://hqterm.sh/install | sh Installs `hqterm` and `hqsh` for Linux or macOS (x86_64, arm64) into `~/.local/bin` (or `$HQTERM_BIN`). On a server you connect to, only hqsh is needed: `curl -fsSL https://hqterm.sh/install | sh -s -- --server` (the `i` key -in hqterm does it for you). +in hqterm does it for you). That also runs `hqsh server setup`: sessions run as +systemd user units on Linux (lingering on, so they survive logout) and as +launchd jobs on macOS. Windows PCs and servers: +`irm https://hqterm.sh/install.ps1 | iex` (hqsh.exe, put on your user PATH; +sessions run on ConPTY). + +When Tailscale is running, your online tailnet machines appear as hosts and +hqsh connects over the tailnet (`--tailscale auto|on|off`). + +**Full documentation: https://hqterm.sh/docs** (`site/docs.html`). ## Use @@ -82,7 +91,8 @@ Releases: push a `v*` tag matching package.json; CI builds `hqterm-{linux,darwin}-{amd64,arm64}`, `hqterm-desktop-linux-{x86_64,aarch64}.AppImage` and `SHA256SUMS` (bump `desktop/package.json` too). -`site/index.html` is the hqterm.sh landing page; `install.sh` is served at -https://hqterm.sh/install. +`site/index.html` is the hqterm.sh landing page and `site/docs.html` is +https://hqterm.sh/docs; `install.sh` is served at https://hqterm.sh/install and +`install.ps1` at https://hqterm.sh/install.ps1. MIT. diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 6a9c9df..73d80dd 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "hqterm-desktop", - "version": "0.2.5", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hqterm-desktop", - "version": "0.2.5", + "version": "0.3.0", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/desktop/package.json b/desktop/package.json index eac2865..5bf0a88 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "hqterm-desktop", "productName": "hqterm", - "version": "0.2.5", + "version": "0.3.0", "description": "hqterm desktop: a terminal window that shows hqtui/qc images and HD emoji (xterm.js + node-pty).", "license": "MIT", "author": "Profullstack, Inc. ", diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..e3c5765 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,89 @@ +# hqterm installer for Windows: https://hqterm.sh +# +# irm https://hqterm.sh/install.ps1 | iex +# +# Installs hqsh (the client and the host side of hqterm sessions) from the +# latest GitHub release of profullstack/hqsh into ~\.local\bin (or +# $env:HQTERM_BIN), checks it against SHA256SUMS, and puts that directory on +# your user PATH so ssh sessions find it. No admin rights. Safe to run again. +# Pin a version with $env:HQSH_VERSION = "v0.3.0". +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +function Say($msg) { Write-Host "hqterm install: $msg" } + +$bin = if ($env:HQTERM_BIN) { $env:HQTERM_BIN } else { Join-Path $HOME '.local\bin' } +$arch = switch ($env:PROCESSOR_ARCHITECTURE) { + 'AMD64' { 'amd64' } + 'ARM64' { 'arm64' } + default { throw "hqterm install: unsupported CPU $($env:PROCESSOR_ARCHITECTURE) (x64 and arm64 only)" } +} +$base = if ($env:HQSH_VERSION) { + "https://github.com/profullstack/hqsh/releases/download/$($env:HQSH_VERSION)" +} else { + 'https://github.com/profullstack/hqsh/releases/latest/download' +} + +New-Item -ItemType Directory -Force -Path $bin | Out-Null +$tmp = Join-Path ([IO.Path]::GetTempPath()) ("hqterm-" + [Guid]::NewGuid()) +New-Item -ItemType Directory -Path $tmp | Out-Null +try { + $asset = "hqsh-windows-$arch.exe" + Say "downloading $asset from github.com/profullstack/hqsh" + try { + Invoke-WebRequest -UseBasicParsing "$base/$asset" -OutFile (Join-Path $tmp $asset) + } catch { + if ($arch -eq 'arm64') { + # Older releases have no arm64 build; Windows on ARM runs x64 binaries. + $asset = 'hqsh-windows-amd64.exe' + Say "no arm64 build in this release; using $asset (runs under emulation)" + Invoke-WebRequest -UseBasicParsing "$base/$asset" -OutFile (Join-Path $tmp $asset) + } else { throw } + } + $file = Join-Path $tmp $asset + try { + Invoke-WebRequest -UseBasicParsing "$base/SHA256SUMS" -OutFile (Join-Path $tmp 'SHA256SUMS') + $line = Get-Content (Join-Path $tmp 'SHA256SUMS') | Where-Object { $_ -match "\s\*?$([regex]::Escape($asset))$" } | Select-Object -First 1 + if (-not $line) { + Say "warning: $asset is not listed in SHA256SUMS; not verified" + } else { + $want = ($line -split '\s+')[0].ToLower() + $got = (Get-FileHash -Algorithm SHA256 $file).Hash.ToLower() + if ($want -ne $got) { throw "hqterm install: checksum mismatch for $asset (expected $want, got $got)" } + Say "verified $asset (sha256 $got)" + } + } catch [System.Net.WebException] { + Say 'warning: no SHA256SUMS in the release; not verified' + } + + $dest = Join-Path $bin 'hqsh.exe' + # A running hqsh.exe (a live session's daemon) cannot be overwritten, but it + # can be renamed: move it aside, then put the new one in place. + if (Test-Path $dest) { + $old = "$dest.old" + Remove-Item -Force $old -ErrorAction SilentlyContinue + try { Move-Item -Force $dest $old } catch { Say "warning: could not move the old hqsh.exe aside: $_" } + } + Move-Item -Force $file $dest + Say "installed $dest" +} finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue +} + +# On the user PATH, so `ssh this-pc hqsh server attach` finds it. +$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') +if (-not (($userPath -split ';') -contains $bin)) { + [Environment]::SetEnvironmentVariable('Path', ($(if ($userPath) { "$userPath;" } else { '' }) + $bin), 'User') + Say "added $bin to your user PATH (new terminals and ssh sessions pick it up)" +} +$env:Path = "$env:Path;$bin" + +& (Join-Path $bin 'hqsh.exe') server setup 2>$null +if ($LASTEXITCODE -ne 0) { Say "note: this hqsh has no 'server setup' yet; sessions start as detached processes" } + +if (-not (Get-Service -Name sshd -ErrorAction SilentlyContinue)) { + Say 'note: to connect TO this PC, enable the OpenSSH server (as Administrator):' + Say ' Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0' + Say " Start-Service sshd; Set-Service sshd -StartupType Automatic" +} +Say 'done. Connect from here with: hqsh (or hqterm on Linux/macOS)' diff --git a/install.sh b/install.sh index 30bc3d2..95711d2 100755 --- a/install.sh +++ b/install.sh @@ -251,6 +251,11 @@ case ":$PATH:" in esac if [ "$SERVER" = 1 ]; then + # Sessions run under the service manager: systemd (with lingering, so they + # survive logout; this turns it on) or launchd. Older hqsh lacks `setup`. + if ! "$BIN/hqsh" server setup 2>/dev/null; then + say "note: this hqsh has no 'server setup'; sessions start as detached processes" + fi say "done: hqsh is ready on this host" else say "done. Try: hqterm doctor then: hqterm" diff --git a/package.json b/package.json index 19963d7..383d7dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hqterm", - "version": "0.2.5", + "version": "0.3.0", "description": "Terminal session manager: persistent hqsh sessions with images and HD emoji, built on hqtui.", "license": "MIT", "private": true, diff --git a/site/docs.html b/site/docs.html new file mode 100644 index 0000000..c0fb2d6 --- /dev/null +++ b/site/docs.html @@ -0,0 +1,231 @@ + + + + + +hqterm docs: install, hqterm, hqsh, servers, desktop + + + + + + +
+ +
+

hqterm docs

+

hqterm is the app you use. hqsh is the engine: it keeps a session alive on the host and reconnects to it. Both are free and MIT-licensed. Source: hqterm, hqsh.

+ +

Install

+ + + + + + +
WhereCommandInstalls
Your Linux or macOS machinecurl -fsSL https://hqterm.sh/install | shhqterm and hqsh in ~/.local/bin, plus the desktop app on a Linux desktop
A Linux or macOS servercurl -fsSL https://hqterm.sh/install | sh -s -- --serverhqsh only. It also runs hqsh server setup, which turns on systemd lingering.
Windows (PC or server)irm https://hqterm.sh/install.ps1 | iexhqsh.exe in ~\.local\bin, added to your user PATH
Desktop app onlycurl -fsSL https://hqterm.sh/install | sh -s -- --desktopThe Linux AppImage in ~/.local/opt/hqterm-desktop, plus a launcher entry
+

Nothing needs sudo or admin rights, and the installers are safe to run again. Every download is checked against the release's SHA256SUMS. hqsh must be installed on both ends: the plain install covers your machine, and --server covers each host.

+ + + + + + +
install.sh option
--serverInstall hqsh only, for hosts you connect to
--desktop / --no-desktopAlways or never install the desktop app. By default it is installed on Linux when a display is present.
--bin=DIRInstall into DIR instead of ~/.local/bin (or $HQTERM_BIN)
HQTERM_VERSION, HQSH_VERSIONPin a release, for example HQSH_VERSION=v0.3.0
+ +

hqterm

+
hqterm                          # the session manager (below)
+hqterm connect <host> [name]    # attach to (or create) session name (default main) on host
+hqterm desktop [--host H] [--session S]   # the desktop app, optionally attached to H/S
+hqterm doctor [--try kitty|iterm]         # what this terminal supports, with a test image
+hqterm fonts install|status|remove        # the OpenEmoji colour font
+hqterm update [--check] [--force]         # update hqterm, hqsh and the desktop app in place
+hqterm --version | --help
+

Hosts come from ~/.ssh/config (concrete Host entries), from ~/.config/hqterm/hosts.json (hosts you add with a), and, when Tailscale is running, from your online tailnet machines, marked ts.

+ +

The session manager

+ + + + + + + + + + +
Key
Enter / clickAttach to the selected session (one click, no double-click)
nNew session on the host (default name main)
aAdd a host (saved to hosts.json)
iInstall hqsh on the selected host over ssh
rRefresh the session list
Tab / arrowsMove between hosts and sessions
Ctrl-^ then .Inside a session: detach and return to hqterm. The session keeps running.
qQuit
+ +

hqsh

+
hqsh [user@]host [--session NAME] [--steal | --read-only] [--tailscale auto|on|off] [-- ssh flags...]
+
+hqsh dev2                    # connect (or resume) session "main" on dev2
+hqsh dev2 -s work            # another session
+hqsh dev2 -- -p 2202 -i ~/.ssh/key   # extra ssh flags after --
+hqsh version
+

Close the laptop, change networks, lose Wi-Fi: when the connection comes back, hqsh reconnects with backoff (0.5 s, doubling up to 10 s) and replays exactly the output you missed, from a numbered buffer on the host. It never repeats output and never leaves a gap. If you were away so long that the buffer moved on, full-screen programs are asked to repaint.

+ + + + + +
Key / result
Ctrl-^ then .Detach. The shell keeps running, and hqsh host picks it up again.
Ctrl-^ Ctrl-^Send a literal Ctrl-^
exit statusWhen the shell exits, hqsh exits with the shell's status (0 when you detach)
+

hqsh uses your ssh as it is: ~/.ssh/config, keys, agent, ProxyJump, known_hosts. It opens no port, needs no UDP, and runs no service you have to expose.

+ +

Shared sessions, like tmux

+

Any number of clients can attach to one session at once. They all see the output, any of them can type, and the window is the smallest of their sizes.

+
hqsh dev2 --read-only        # -r: watch; your keys are not sent
+hqsh dev2 --steal            # -d: detach everyone else first (tmux attach -d)
+

A slow client never stalls the others: it falls behind on its own, and after a gap it resumes like any reconnect. --steal applies to the first connection only, so a reconnect never kicks anyone. In the desktop app, splitting a remote pane opens a new session (main-2, ...). Opening the same host and session in two panes or two windows gives you a shared view.

+

hqterm connect always attaches normally. For --steal or --read-only, run hqsh directly.

+ +

Tailscale

+

If Tailscale is running on your machine and the host is an online peer on your tailnet, hqsh connects over the peer's tailnet address. That path survives network changes better than a public IP and needs no open port.

+
    +
  • Matching: a host counts as a peer if its alias is the peer's machine name, MagicDNS name or tailnet IP, or if your ssh alias resolves to one of those.
  • +
  • What changes: only the address (-o HostName=). The user, port and keys still come from your ssh config, and HostKeyAlias keeps your known_hosts entry.
  • +
  • Fallback: if the tailnet path fails on the first connect, hqsh uses the normal route. While reconnecting, it alternates between the two, so whichever network is up wins.
  • +
+
hqsh dev2                    # auto (default): the tailnet when dev2 is a peer
+hqsh dev2 --tailscale on     # require it; fail if dev2 is not a peer
+hqsh dev2 --tailscale off    # never (or HQSH_TAILSCALE=off)
+

hqterm also lists your online tailnet machines as hosts. Set HQTERM_TAILSCALE=off to hide them.

+ +

Servers: what runs on the host

+

There is nothing to open or enable. When you connect, ssh runs hqsh server attach SESSION. If the session's daemon is not running, attach starts it under the OS service manager:

+ + + + + + +
HostThe session runs asSurvives logout
Linux with systemdA user unit, hqsh-<session>.serviceYes, with lingering on
macOSA launchd job, sh.hqterm.hqsh.<session>Yes
Windows 10 1809+ / Server 2019+A process outside the ssh connection's job, on a ConPTYYes
Anything elseA detached processUnless the OS kills it
+
hqsh server setup            # how sessions start here; turns lingering on where systemd needs it
+hqsh server setup --check    # report only
+hqsh server list [--json]    # live sessions and how many clients each has
+systemctl --user status 'hqsh-*'     # Linux: the units
+journalctl --user -u 'hqsh-*'        # and their logs
+systemctl --user stop hqsh-main.service   # end a session (its shell gets SIGTERM)
+

Lingering (loginctl enable-linger) keeps your systemd user manager running after your last logout. Without it, logind stops your units when you log out, so until it is on hqsh falls back to a detached process. --server installs and hqsh server setup turn it on. If your distribution does not allow that for yourself, root can run loginctl enable-linger USER.

+

On the host, hqsh can live anywhere on the PATH, or in ~/.local/bin. The client tries ~/.local/bin/hqsh too, because ssh commands often lack that directory on their PATH. Sockets go in $XDG_RUNTIME_DIR/hqsh (else ~/.local/state/hqsh), mode 0700. Each session runs your login shell ($SHELL -l) in your home directory, with HQSH_SESSION set to the session name.

+ +

Windows hosts

+
    +
  1. Enable the OpenSSH server, as Administrator: Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0, then Start-Service sshd; Set-Service sshd -StartupType Automatic.
  2. +
  3. As the user you will log in as: irm https://hqterm.sh/install.ps1 | iex.
  4. +
  5. From your machine: hqsh winbox or hqterm connect winbox.
  6. +
+

Sessions run PowerShell 7 (pwsh) when it is installed, else Windows PowerShell, else cmd.exe. HQSH_SHELL picks another. Win32-OpenSSH ends everything in a connection's job when it disconnects, so hqsh starts the session daemon outside that job: by breakaway when the job allows it, else through WMI (Win32_Process.Create). hqsh server setup reports which applies.

+ +

A whole fleet

+

Run curl -fsSL https://hqterm.sh/install | sh -s -- --server on each host, as each user that will connect. To do it for every account on a box at once, as root, run cli-tools' root-ubuntu.sh. It installs hqsh for root and every login, does nothing when hqsh is already current, and enables lingering.

+
for h in web1 web2 db1; do ssh "$h" 'curl -fsSL https://hqterm.sh/install | sh -s -- --server'; done
+ +

The desktop app

+

hqterm's own terminal window (Electron and xterm.js with WebGL): tabs, split panes that come back where you left them, and hqtui/qc images and HD emoji drawn in full. Linux x86_64 and arm64.

+ + + + + + + + +
Key
Ctrl+Shift+T / WNew tab / close tab. Ctrl+Tab and Ctrl+PgUp/PgDn switch tabs.
Ctrl+Shift+D / ESplit right / split down. Splitting a remote pane opens a new session on that host.
Ctrl+Shift+ArrowsMove between panes (or click). Drag a divider to resize.
Ctrl+Shift+XClose the pane
Ctrl+Shift+C / VCopy / paste
Ctrl+= / Ctrl+- / Ctrl+0Zoom in / out / reset
+

The + button opens a shell, the session manager, or any host (as hqsh host --session main). The layout (tabs, splits, each remote pane's host and session) is saved to ~/.config/hqterm/desktop-layout.json. The next launch reattaches every remote pane; local panes come back as fresh shells.

+

Settings live in ~/.config/hqterm/desktop.json:

+
{ "fontFamily": "JetBrains Mono", "fontSize": 15, "theme": { "background": "#000" }, "restore": true, "gpu": true }
+

The default font size is 15. "gpu": false turns hardware acceleration off. Each start writes GPU status to ~/.config/hqterm/desktop.log. Every pane gets TERM_PROGRAM=hqterm, HQTUI_IMAGES=iterm and QC_HD=iterm.

+ +

Images and HD emoji

+

hqsh carries the raw terminal stream, so Kitty graphics, iTerm2 inline images and sixel reach your terminal. mosh cannot do this: it syncs only text and drops every image. Supported terminals: the hqterm desktop app, Kitty, Ghostty, WezTerm, iTerm2, Rio and Windows Terminal. hqterm doctor shows what yours supports, and hqterm fonts install adds the OpenEmoji colour font.

+ +

Environment variables

+ + + + + + + + + +
Variable
HQSH_TAILSCALEauto (default), on or off
HQSH_SUPERVISOROn the host: auto (default), systemd, launchd or fork
HQSH_SHELLOn the host: the session's shell, instead of $SHELL (or PowerShell/cmd on Windows)
HQSH_SESSIONSet inside every session to its name
HQTERM_TAILSCALEoff hides tailnet machines from hqterm's host list
HQTERM_BINThe install directory (default ~/.local/bin)
HQTERM_VERSION / HQSH_VERSIONPin the installers to a release
+ +

Troubleshooting

+

hqsh: could not reach HOST: the connection closed (status 1)

+

ssh connected, but the host did not run hqsh. Usually the port you reached is not a shell: an app on port 22, or a box whose real sshd is on another port. Check with ssh HOST echo ok. If that does not print ok, point an ssh alias at the right port and user (for example Port 2202, User root) and connect to the alias.

+

HOST has no hqsh

+

Install it on the host: ssh HOST 'curl -fsSL https://hqterm.sh/install | sh -s -- --server', or press i in the session manager. On Windows, run install.ps1 there.

+

Sessions disappear when I log out of the server

+

Run hqsh server setup on the host. If it cannot enable lingering itself, ask root for loginctl enable-linger USER.

+

Images or emoji art do not show

+

Run hqterm doctor. Under mosh, images can never work; use hqsh instead. Under tmux, keep allow-passthrough on.

+

The desktop app redraws slowly

+

Check ~/.config/hqterm/desktop.log. If webgl is not enabled, the GPU is blocklisted or missing and panes use the slower renderer. Update your graphics drivers, or file an issue with the log attached.

+ +

How it works

+
your terminal ── hqsh ══ ssh ══ hqsh server attach ── unix socket ── session daemon ── PTY/ConPTY ── your shell
+                                                                    (systemd unit / launchd job / outside the ssh job)
+

The daemon owns the shell and outlives connections. Output goes into a numbered ring buffer, and clients acknowledge what they have printed. On reconnect, the client says where it stopped and gets exactly the rest. Full detail: protocol.md.

+ +

Update and uninstall

+
hqterm update                # everything, in place (--check: just compare versions)
+curl -fsSL https://hqterm.sh/install | sh -s -- --server   # a host: run it again
+
+# uninstall (Linux/macOS)
+rm -f ~/.local/bin/hqterm ~/.local/bin/hqsh
+rm -rf ~/.local/opt/hqterm-desktop ~/.config/hqterm
+rm -f ~/.local/share/applications/hqterm.desktop ~/.local/share/icons/hqterm.png
+# Windows: Remove-Item ~\.local\bin\hqsh.exe, then remove that folder from your user PATH
+
+
+ + diff --git a/site/index.html b/site/index.html index 73c4572..93911b1 100644 --- a/site/index.html +++ b/site/index.html @@ -3,45 +3,54 @@ -hqterm - +hqterm: terminal sessions that never drop, on every machine you own + - - + + @@ -52,69 +61,91 @@ desktop computer

hqterm

-

Terminal sessions that never drop. With pictures.

+

Terminal sessions that never drop. On every machine you own.

-

Persistent terminal sessions that survive disconnects, sleep and Wi-Fi changes, like mosh, but they carry images and HD emoji too. Pick a host, pick a session, and you are back exactly where you left off.

+

Close the laptop on Wi-Fi, open it on 5G, and your shell, your editor and that 40-minute build are exactly where you left them. hqterm does what mosh and tmux do together, over the ssh you already have. It carries images and HD emoji too, and it works on Linux, macOS and Windows hosts.

curl -fsSL https://hqterm.sh/install | sh
-

Linux and macOS, x86_64 and arm64. Installs hqterm and hqsh into ~/.local/bin. No sudo.

+

Linux and macOS, x86_64 and arm64. Installs hqterm and hqsh into ~/.local/bin, no sudo. On each server: curl -fsSL https://hqterm.sh/install | sh -s -- --server

+
irm https://hqterm.sh/install.ps1 | iex
+

Windows PCs and servers, x64 and ARM64. No admin.

+ +
-

Survives disconnects

-

Close the laptop, change networks, reattach later. Your shell, editor and build keep running on the host.

+

Never lose a session

+

Sleep, roam, lose the network: hqsh reconnects on its own and replays exactly the output you missed, with no gaps and no repeats. The session lives on the host.

+
+
+ +

Share it like tmux

+

Attach from your laptop and your desktop at once, pair with a teammate, or let someone watch read-only. --steal takes a session over.

Images and HD emoji

-

mosh only syncs text and drops every image. hqsh carries the raw stream, so Kitty and iTerm2 images, and our OpenEmoji art, get through.

+

mosh syncs only text and drops every image. hqsh carries the raw stream, so Kitty, iTerm2 and sixel images and OpenEmoji art come through.

-

Just SSH

-

Runs over your existing ssh config, keys and agent. No new ports, no UDP, no daemon to open up.

+

Just ssh

+

Your keys, agent, ProxyJump and known_hosts. No new port, no UDP, no daemon to expose, nothing for a firewall to block.

+
+
+ +

Every OS, as a real service

+

Sessions run as systemd units on Linux, launchd jobs on macOS, and on ConPTY on Windows servers, so they survive logout and you can see them with your usual tools.

+
+
+ +

Tailscale built in

+

If Tailscale is running, hqsh connects over your tailnet automatically and falls back to the normal route when it must. Your tailnet machines show up as hosts.

-

hqterm desktop

-

Our own terminal window for Linux (KDE, GNOME, anything with a display): tabs, split panes that come back where you left them, and every hqtui/qc image and HD emoji drawn in full, even where Konsole cannot.

-
curl -fsSL https://hqterm.sh/install | sh -s -- --desktop
-

Then run hqterm desktop (or hqterm desktop --host dev2 --session main), or pick hqterm in your app launcher. Linux x86_64 and arm64 AppImage; the plain install adds it automatically on a Linux desktop. Direct download: hqterm-desktop-linux-x86_64.AppImage.

- - - - - - - - -
Ctrl+Shift+T / WNew tab / close tab (Ctrl+Tab switches)
Ctrl+Shift+D / ESplit right / split down (a split of a remote pane opens a new hqsh session on that host)
Ctrl+Shift+ArrowsMove between panes (or one click)
Ctrl+Shift+XClose the pane
Ctrl+Shift+C / VCopy / paste
Ctrl+= / Ctrl+-Zoom
- -

Quick start

-
hqterm                      # hosts from ~/.ssh/config, their sessions, one click to attach
-hqterm connect dev2 main     # straight into session "main" on dev2
-hqterm doctor               # what your terminal supports, with a test image
-hqterm fonts install        # OpenEmoji colour font for this terminal
+  
+

Why not mosh, Eternal Terminal or plain tmux?

+ + + + + + + + + +
hqtermmoshEternal Terminalssh + tmux
Survives sleep and network changesyesyesyesreattach by hand
Scrollback and exact replayyesscreen onlyyestmux's
Images and graphics protocolsyesdroppedyespassthrough only
Shared sessions, read-only watchersyesnonoyes
Extra ports or firewall changesnoneUDP 60000+TCP 2022none
Windows hostsyesnonono
Tailscale-awareyesnonono
+
-# on a server you connect to (hqterm does this for you with the i key): -curl -fsSL https://hqterm.sh/install | sh -s -- --server
+
+

Use it

+
hqterm                        # your hosts (ssh config, added, tailnet) and their sessions; click to attach
+hqterm connect dev2 main       # straight into session "main" on dev2
+hqterm desktop                # our terminal window: tabs, splits, images
+hqsh dev2 --read-only         # watch a session someone else is driving
+hqsh dev2 --steal             # take it over
+hqterm update                 # everything, in place
+# Ctrl-^ then .  detaches; the session keeps running
+

Every command, key, setting and fix is in the docs.

+
-

Keys

- - - - - - - -
Enter / clickAttach to the session (one click, no double-click)
nNew session (default name "main")
aAdd a host
iInstall hqsh on the selected host
Ctrl-^ .Detach from a session; it keeps running
qQuit
+
+

hqterm desktop

+

Our own terminal window for Linux (KDE, GNOME, anything with a display): GPU-rendered tabs and split panes that reattach to their sessions on the next launch. Every hqtui and qc image and HD emoji is drawn in full, even where Konsole cannot.

+
curl -fsSL https://hqterm.sh/install | sh -s -- --desktop
+
diff --git a/src/hostlist.ts b/src/hostlist.ts index d73ac2f..67710f6 100644 --- a/src/hostlist.ts +++ b/src/hostlist.ts @@ -4,9 +4,10 @@ */ import { loadUserHosts, mergeHosts, validHostName } from "./hosts.ts"; import { sshConfigHosts } from "./sshconfig.ts"; +import { tailscaleHosts } from "./tailscale.ts"; export function listHosts(): string[] { - return mergeHosts(sshConfigHosts(), loadUserHosts()).map((h) => h.name); + return mergeHosts(sshConfigHosts(), loadUserHosts(), tailscaleHosts()).map((h) => h.name); } export { validHostName }; diff --git a/src/hosts.ts b/src/hosts.ts index 9175513..a64dadc 100644 --- a/src/hosts.ts +++ b/src/hosts.ts @@ -8,7 +8,7 @@ import { join } from "node:path"; export interface Host { name: string; - source: "ssh" | "user"; + source: "ssh" | "user" | "tailscale"; } export function configDir(env = process.env, home = homedir()): string { @@ -35,12 +35,17 @@ export function parseHostsJson(text: string): string[] { return names; } -/** ssh config hosts first, then the user's own, each name once. */ -export function mergeHosts(sshHosts: string[], userHosts: string[]): Host[] { +/** + * ssh config hosts first, then the user's own, then Tailscale peers, each + * name once (an ssh alias that is also a peer stays an ssh host; hqsh still + * routes it over the tailnet). + */ +export function mergeHosts(sshHosts: string[], userHosts: string[], tailnet: string[] = []): Host[] { const out: Host[] = []; const seen = new Set(); for (const name of sshHosts) if (!seen.has(name)) (seen.add(name), out.push({ name, source: "ssh" })); for (const name of userHosts) if (!seen.has(name)) (seen.add(name), out.push({ name, source: "user" })); + for (const name of tailnet) if (!seen.has(name)) (seen.add(name), out.push({ name, source: "tailscale" })); return out; } diff --git a/src/main.ts b/src/main.ts index 5bdc7bd..9e80473 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,7 @@ import { update } from "./update.ts"; import { addUserHost, loadUserHosts, mergeHosts, validHostName } from "./hosts.ts"; import { connect, findHqsh, installArgs, INSTALL_URL, pressEnter, runInteractive, connectArgs } from "./run.ts"; import { sshConfigHosts } from "./sshconfig.ts"; +import { tailscaleHosts } from "./tailscale.ts"; import { initialState, runTui } from "./tui.ts"; import { VERSION } from "./version.ts"; @@ -36,13 +37,17 @@ Keys in the manager: Tab / arrows move q quit In a session: Ctrl-^ . detach (the session keeps running) -Hosts come from ~/.ssh/config and ~/.config/hqterm/hosts.json. +Hosts come from ~/.ssh/config, ~/.config/hqterm/hosts.json and, when +Tailscale runs here, your online tailnet machines (marked "ts"; +HQTERM_TAILSCALE=off hides them). +Servers need only hqsh: curl -fsSL ${INSTALL_URL} | sh -s -- --server + (Windows: irm https://hqterm.sh/install.ps1 | iex) Install: curl -fsSL ${INSTALL_URL} | sh Desktop app: curl -fsSL ${INSTALL_URL} | sh -s -- --desktop `; async function manager(): Promise { - const hosts = () => mergeHosts(sshConfigHosts(), loadUserHosts()); + const hosts = () => mergeHosts(sshConfigHosts(), loadUserHosts(), tailscaleHosts()); const state = initialState(hosts()); for (;;) { const action = await runTui(state, { diff --git a/src/tailscale.ts b/src/tailscale.ts new file mode 100644 index 0000000..f52258c --- /dev/null +++ b/src/tailscale.ts @@ -0,0 +1,43 @@ +/** + * Tailscale peers as hosts. When Tailscale runs here, every online machine + * on the tailnet joins the host list under its MagicDNS short name, and hqsh + * (0.3+) reaches it over the tailnet. HQTERM_TAILSCALE=off hides them. + */ +import { spawnSync } from "node:child_process"; + +interface Peer { + HostName?: string; + DNSName?: string; + Online?: boolean; + TailscaleIPs?: string[]; +} + +/** Host names from `tailscale status --json` output: online peers only. */ +export function parseTailscalePeers(json: string): string[] { + let data: { BackendState?: string; Peer?: Record }; + try { + data = JSON.parse(json); + } catch { + return []; + } + if (data?.BackendState !== "Running" || !data.Peer) return []; + const names: string[] = []; + for (const p of Object.values(data.Peer)) { + if (!p?.Online || !p.TailscaleIPs?.length) continue; + const dns = (p.DNSName ?? "").replace(/\.$/, "").toLowerCase(); + const name = dns.split(".")[0] || (p.HostName ?? "").toLowerCase(); + if (name && /^[a-z0-9][a-z0-9-]*$/.test(name) && !names.includes(name)) names.push(name); + } + return names.sort(); +} + +export function tailscaleHosts(env = process.env): string[] { + if ((env.HQTERM_TAILSCALE ?? "").toLowerCase() === "off") return []; + try { + const r = spawnSync("tailscale", ["status", "--json"], { encoding: "utf8", timeout: 1500 }); + if (r.status !== 0 || !r.stdout) return []; + return parseTailscalePeers(r.stdout); + } catch { + return []; + } +} diff --git a/src/tui.ts b/src/tui.ts index eefc2cf..b1fb5d9 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -126,7 +126,7 @@ export function render({ ui, width, theme }: Pick { test("concrete hosts only, in order, once", () => { @@ -151,3 +152,36 @@ describe("doctor advice", () => { expect(advice(base, true)).toEqual([]); }); }); + +describe("tailscale hosts", () => { + const json = JSON.stringify({ + BackendState: "Running", + Peer: { + a: { HostName: "Dev2", DNSName: "dev2.tail1234.ts.net.", TailscaleIPs: ["100.64.0.2"], Online: true }, + b: { HostName: "laptop", DNSName: "laptop.tail1234.ts.net.", TailscaleIPs: ["100.64.0.3"], Online: false }, + c: { HostName: "pi", DNSName: "", TailscaleIPs: ["100.64.0.4"], Online: true }, + d: { HostName: "bad name", DNSName: "", TailscaleIPs: ["100.64.0.5"], Online: true }, + }, + }); + + test("online peers by MagicDNS short name, else machine name; offline and odd names skipped", () => { + expect(parseTailscalePeers(json)).toEqual(["dev2", "pi"]); + }); + + test("nothing when Tailscale is stopped or the output is not JSON", () => { + expect(parseTailscalePeers(JSON.stringify({ BackendState: "NeedsLogin", Peer: {} }))).toEqual([]); + expect(parseTailscalePeers("tailscale: not running")).toEqual([]); + }); + + test("peers come after ssh and user hosts; an ssh alias that is also a peer stays ssh", () => { + expect(mergeHosts(["dev2"], ["box"], ["dev2", "pi"])).toEqual([ + { name: "dev2", source: "ssh" }, + { name: "box", source: "user" }, + { name: "pi", source: "tailscale" }, + ]); + }); + + test("HQTERM_TAILSCALE=off hides them", () => { + expect(tailscaleHosts({ HQTERM_TAILSCALE: "off" })).toEqual([]); + }); +});