diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd4d7db --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/dist/ +/internal/provision/assets/*.gz +/test/integration/.work/ diff --git a/Makefile b/Makefile index 2c112a0..0fa7dd5 100644 --- a/Makefile +++ b/Makefile @@ -1,37 +1,29 @@ -.PHONY: install uninstall clean test +.PHONY: build agents test integration install uninstall clean +PREFIX ?= $(HOME)/.local -PREFIX ?= /usr/local -SESS_DIR ?= $(HOME)/.sess +agents: + ./scripts/build-agents.sh -BIN_DIR = $(DESTDIR)$(PREFIX)/bin -COMPLETION_DIR_BASH = $(DESTDIR)$(PREFIX)/share/bash-completion/completions -COMPLETION_DIR_ZSH = $(DESTDIR)$(PREFIX)/share/zsh/site-functions +build: agents + go build -trimpath -ldflags='-s -w' -o dist/sess ./cmd/sess test: - @echo "Running sess tests..." - @bash -n bin/sess && echo "✓ sess: syntax OK" || exit 1 - @bash -n etc/bash-completion/sess && echo "✓ bash completion: OK" || exit 1 - @bash -c 'source etc/bash-completion/sess' && echo "✓ bash completion loads OK" || exit 1 - @echo "Done." + go test -race ./... + go vet ./... -install: bin/sess - @mkdir -p $(BIN_DIR) - @mkdir -p $(COMPLETION_DIR_BASH) - @mkdir -p $(COMPLETION_DIR_ZSH) - cp bin/sess $(BIN_DIR)/sess - chmod +x $(BIN_DIR)/sess - cp etc/bash-completion/sess $(COMPLETION_DIR_BASH)/sess - cp etc/zsh-completion/_sess $(COMPLETION_DIR_ZSH)/_sess - @echo "Installed to $(PREFIX)" - @echo " sess → $(BIN_DIR)/sess" - @echo " bash comp → $(COMPLETION_DIR_BASH)/sess" - @echo " zsh comp → $(COMPLETION_DIR_ZSH)/_sess" +integration: build + python3 test/integration/run.py + +install: build + install -d "$(DESTDIR)$(PREFIX)/bin" + install -m 755 dist/sess "$(DESTDIR)$(PREFIX)/bin/sess" + install -d "$(DESTDIR)$(PREFIX)/share/bash-completion/completions" "$(DESTDIR)$(PREFIX)/share/zsh/site-functions" + dist/sess completion bash > "$(DESTDIR)$(PREFIX)/share/bash-completion/completions/sess" + dist/sess completion zsh > "$(DESTDIR)$(PREFIX)/share/zsh/site-functions/_sess" uninstall: - rm -f $(BIN_DIR)/sess - rm -f $(COMPLETION_DIR_BASH)/sess - rm -f $(COMPLETION_DIR_ZSH)/_sess - @echo "Uninstalled from $(PREFIX)" + rm -f "$(DESTDIR)$(PREFIX)/bin/sess" "$(DESTDIR)$(PREFIX)/share/bash-completion/completions/sess" "$(DESTDIR)$(PREFIX)/share/zsh/site-functions/_sess" clean: - rm -rf node_modules \ No newline at end of file + rm -rf dist + rm -f internal/provision/assets/*.gz diff --git a/README.md b/README.md index af54ab3..78c6161 100644 --- a/README.md +++ b/README.md @@ -1,259 +1,168 @@ -
- # sess -tmux sessions that remember where they were and reconnect over SSH when the link drops. - -[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) - -> One tool. No worktrees. No containers. - -
+**A persistent SSH terminal. Powered by zmx.** -## Why - -Close the laptop and your SSH session dies. tmux keeps the shell alive on the server, but getting back means typing `ssh`, then `tmux attach`, then remembering which session was in which directory. mosh and Eternal Terminal fix the transport, but each needs its own server daemon on every box. - -`sess` is one bash script on top of `ssh` and `tmux`. It keeps a small state dir per session (cwd, branch, logs), copies itself to your VM with `sess init`, and when you attach to a remote session it re-runs `ssh -t host "sess "` every 3 seconds until you stop it. Nothing else to install, nothing listening on a port. - -## Demo +Give a terminal on your VM a name. Leave it running. Come back to the same shell, directory, and programs after you detach or lose your connection. +```sh +sess init user@host +sess set --host user@host +sess new work +# Ctrl+\ to detach +sess attach work ``` -sess new feature-auth # create + auto-attach (cwd = where you ran it) -...work in session... -Ctrl+b d # detach (session persists in tmux) -sess feature-auth # reattach (loops on SSH drop if a remote is set) -sess rm feature-auth # destroy session -``` +Run `sess` to browse your sessions. Your terminal application handles tabs and windows; sess handles session management and reconnection. -Inside a session the tmux status bar shows the session name on the left and, on the right, the git branch of the current pane directory, the directory name, and the time. It refreshes every 3 seconds and follows wherever you `cd`. +## Build and install -``` - session-name main rfq-modular 22:18 -``` - - - -## Quickstart +Requires Go 1.26.5+ to build. The installed client needs OpenSSH. Linux and macOS on AMD64 and ARM64 are supported build targets. -Prerequisites: `tmux`, `git`, `bash`, and `ssh` for remote sessions. `sess doctor` checks all four. Runs on macOS and Linux (`package.json` `os`). - -Install from source (the `sess-sh` npm package is not published yet). This installs `bin/sess` plus bash and zsh completions under `PREFIX`, default `/usr/local`): - -```bash -git clone https://github.com/deepaksilaych/sess.git +```sh +git clone https://github.com/DeepakSilaych/sess.git cd sess -sudo make install # or: PREFIX=$HOME/.local make install +make build +./dist/sess --help +make install # default: ~/.local/bin +# or: make install PREFIX=/usr/local ``` -Or just put `bin/` on your PATH: +Ensure the installation's `bin` directory is in your PATH. `make build` embeds the remote helpers for all four targets, so the remote VM needs neither Go nor a compiler. `go install` alone does not generate those helpers; use `make build` or a release archive. -```bash -chmod +x bin/sess -export PATH="$PWD/bin:$PATH" -``` +This is the zmx-based 0.6 development version. Existing tmux sessions from 0.5 continue to belong to tmux; they cannot be converted into live zmx sessions. Attach to them with tmux while finishing that work. The old `~/.sess` state is left untouched. -Local sessions: +## Prepare a host -```bash -cd ~/code/my-repo -sess new feature-auth # creates ~/.sess/sessions/feature-auth, starts tmux, attaches -# Ctrl+b d to detach -sess ls # SESSION / BRANCH / STATUS / CREATED -sess feature-auth # reattach +```sh +sess init dev # existing SSH alias, or user@host +sess set --host dev # choose the default separately +sess doctor ``` -Remote sessions (sessions live on the VM, the laptop is just a terminal): +`init` verifies SSH, detects the VM's platform, installs the remote helper and pinned zmx 0.8.1 under `~/.local/share/sess/bin`, and checks the result. No sudo, extra network port, or laptop daemon. The zmx archive is downloaded over HTTPS and verified against its pinned SHA-256 digest before installation. A different existing managed zmx version is not silently replaced. -```bash -sess init user@dev-vm # one time: installs tmux+git, copies sess to ~/bin/sess, registers "default" -sess new feature-auth # runs "sess new feature-auth --local" on the VM over ssh -t, attaches -sess feature-auth # ssh -t + attach, retried every 3s after any disconnect -sess ssh # plain ssh to the VM -``` +Use SSH keys available to `ssh-agent` for session operations and reconnecting. Provisioning can prompt through SSH; subsequent operations use OpenSSH's batch mode so a background refresh cannot ask for a password. Configure ports, jump hosts, and identities in `~/.ssh/config`. -Verify: +`init` does not change your default host. It also does not edit your shell startup files. -```bash -sess status # version, state dir, session count, previously active sessions -sess connections feature-auth -``` +## Commands -## How it works +| Command | Shortcut | Purpose | +| --- | --- | --- | +| `sess init ` | | Prepare the VM | +| `sess set --host ` | `sess set -h ` | Save the default SSH destination | +| `sess new ` | `sess n ` | Create and attach | +| `sess attach ` | `sess a ` | Attach to an existing session | +| `sess remove ` | `sess rm ` | End the session and its programs | +| `sess ls` | | List live sessions | +| `sess detach` | | Inside a session, detach all attached terminals | +| `sess` | | Open the interactive session browser | +| `sess doctor` | | Check SSH and remote dependencies | +| `sess version` | | Print version | +| `sess completion bash` | | Generate bash, zsh, or fish completion | -Everything is in `bin/sess` (one bash script, ~970 lines). The main `case` at the bottom dispatches subcommands; any other word is treated as a session name and goes to `cmd_attach`. +Use `--host` or `-h` to override the host for a session command without changing the default: -``` -laptop dev VM (same script at ~/bin/sess) ------- ---------------------------------- -sess feature-auth - | - | ~/.sess/remote has default.host? - | - no ---> cmd_attach (local) - | _tmux_start: tmux has-session? else new-session -d -c $SESS_CWD tmux-init.sh - | _apply_tmux_status; tmux attach-session - | on return: connections += detach | exit - | - yes --> cmd_remote_attach - while true: - ssh -t user@vm "sess feature-auth" -----> cmd_attach (local, on the VM) - ^ tmux attach ... returns on - | ssh exits (drop / Ctrl+b d / exit) detach, exit, or SIGHUP - | - print "[sess] disconnected. reconnecting in 3s... (Ctrl+C to stop)" - sleep 3 -``` - -1. `sess new ` writes `~/.sess/sessions//state` (`SESS_SESSION`, `SESS_BRANCH`, `SESS_CWD=$(pwd)`, `SESS_CREATED`), appends to `log`, adds the name to `~/.sess/active-sessions`, then calls `cmd_attach`. If a remote is selected it instead `exec`s `ssh -t host "sess new --local"`, so the state lives on the VM. -2. `cmd_attach` sources `state`, and `_tmux_start` creates the tmux session on first use with `tmux new-session -d -s -c tmux-init.sh`. That init script exports `SESS_SESSION`, `SESS_BRANCH`, `SESS_DIR`, `cd`s to the saved cwd and `exec`s `$SHELL`. If the tmux session already exists it is reused. -3. `_apply_tmux_status` runs on every attach and sets session-scoped tmux options: `status-interval 3`, session name on the left, `git rev-parse --abbrev-ref HEAD` in `#{pane_current_path}` plus `#{b:pane_current_path}` and `%H:%M` on the right. No `.tmux.conf` changes. -4. When `tmux attach-session` returns, `cmd_attach` checks `tmux has-session`. Still there means you detached (`detach` is written to `connections`); gone means the shell exited (`exit`, and the name is dropped from `active-sessions`). -5. The reconnect loop is `cmd_remote_attach`. It only runs when `~/.sess/remote` has a `default.host=` line. It does not look at the ssh exit code: any return, including an intentional `Ctrl+b d`, prints the disconnected message, sleeps 3 seconds and runs ssh again. `Ctrl+C` during that 3 second wait ends the loop. No `ServerAliveInterval` is set, so how fast a dead link is noticed depends on your ssh config. -6. `sess init` (`cmd_init`) installs tmux and git with whichever of `apt-get`, `dnf`, `yum`, `apk`, `pacman`, `brew` it finds, `scp`s the running script (symlinks resolved by `_self_path`) to `~/bin/sess`, appends `~/bin` to PATH in `.bashrc`, `.zshrc` and `.profile`, checks `~/bin/sess version`, and writes `name.host=user@host` to `~/.sess/remote`. -7. `sess up` (`cmd_up`) reads `~/.sess/active-sessions`. Locally it reattaches the last one, or if you are already inside tmux it starts all of them and `switch-client`s to the first. With a default remote it checks each name with `ssh host "test -d ~/.sess/sessions/"`, then on macOS opens one Terminal.app tab per session via `osascript`, and on Linux runs `ssh -t host "sess "` one after another. - -### Compared with - -| | ssh + tmux by hand | mosh | Eternal Terminal | sess | -|---|---|---|---|---| -| Server side | tmux | mosh-server | etserver | tmux + `~/bin/sess` (copied by `sess init`) | -| Transport | ssh | UDP, own protocol | own TCP protocol | ssh | -| After a drop | you re-run `ssh` and `tmux attach` | link resumes by itself | link resumes by itself | `sess` re-runs `ssh -t host "sess "` every 3s | -| Remembers cwd, branch and how each attach ended | no | no | no | yes, `~/.sess/sessions//` | - -## Features - -| Feature | Where | -|---|---| -| Per-session state dir: `state`, `log`, `connections`, `tmux-init.sh` | `cmd_new`, `_tmux_start`, `_log_event`, `_conn_log` | -| `SESS_SESSION`, `SESS_BRANCH`, `SESS_DIR` exported inside every session shell | `tmux-init.sh` written by `_tmux_start` | -| tmux status bar: session name, branch of current pane dir, dir name, HH:MM, 3s refresh | `_apply_tmux_status` | -| Connection log with `detach` and `exit` events | `cmd_attach`, `cmd_connections` | -| Activity log: created, attached, detached, exited, removed, diff, code | `_log_event`, `cmd_log` | -| Remote reconnect loop, 3s retry, stop with Ctrl+C | `cmd_remote_attach` | -| `sess init` provisions a host: package install, copy script, PATH, register | `cmd_init` | -| Multiple remotes: `--remote `, `--local`, interactive pick, `default` first when non-interactive | `_select_remote` | -| `sess up` reattaches previously active sessions; macOS opens a Terminal tab per session | `cmd_up`, `_local_up`, `_remote_up` | -| `sess code` opens `$SESS_EDITOR`, else `cursor`, else `code`; remote via `--remote host` then a `vscode-remote://` URI | `cmd_code` | -| bash and zsh completion for commands, session names, git branches, remote names | `etc/bash-completion/sess`, `etc/zsh-completion/_sess` | -| npm wrapper so `npx sess-sh` works once published; zero npm dependencies | `bin/sess-cli.js`, `package.json` | - -All commands (`sess help`): - -``` -sess new [branch] Create session + auto-attach - --remote use a specific remote - --local force local, skip remotes -sess Attach to existing session (also: sess attach ) - Ctrl+b d to detach - -sess ls List sessions -sess rm Remove session (kills tmux) - -sess diff [path] Git diff in session's directory -sess log [N] Activity log (default: 20) -sess connections [N] Connection log (detach/exit) -sess path Print session's cwd (for scripts) -sess code Open Cursor/VS Code for session -sess status [name] Session info or overall status - -sess ssh [args] SSH to configured remote VM -sess up Reconnect to previously active sessions -sess init [name] Provision a host (tmux+git+sess) + register it -sess remote add [name] Register an already-provisioned remote -sess remote ls List configured remotes -sess remote rm [name] Remove a remote - -sess doctor Check prerequisites -sess help Show help -sess version Show version +```sh +sess n build -h staging +sess a build -h staging +sess ls -h staging +sess rm build -h staging ``` -Aliases: `ls`/`list`, `rm`/`remove`, `connections`/`conn`. `[branch]` is a label stored in `state` and exported as `SESS_BRANCH`; it defaults to the current branch of the cwd (else `main`) and is never checked out. +Host resolution is explicit flag → saved default → actionable error. There is no local fallback. `-h` means host; help is `--help`. -Which commands go to the VM when a `default` remote is configured: +Names are 1–48 letters, numbers, hyphens, or underscores, starting with a letter or number. Names are scoped to the remote account. Aliases for the same account and VM see the same sessions. -| Proxied over ssh | Run against the laptop's `~/.sess` only | -|---|---| -| `sess new` (unless `--local`), `sess `, `sess ls`, `sess rm`, `sess up`, `sess ssh` | `sess diff`, `sess log`, `sess connections`, `sess path`, `sess status `, `sess code` | +New shells start in the remote home directory. Use `cd` normally. Sessions share the VM's filesystem and Git checkout; creating a session does not copy a repository. -## Configuration +### Scripts -Environment variables read by `bin/sess`: +```sh +sess new build --detach # also: -d; does not need a terminal +sess ls --json # {"host": "dev", "sessions": [...]} +sess ls --quiet # names only; also: -q +``` -| Variable | Default | Purpose | -|---|---|---| -| `SESS_DIR` | `~/.sess` | State directory (sessions, remotes, active list) | -| `SESS_EDITOR` | `cursor`, then `code` | Editor command for `sess code` | -| `SHELL` | `bash` | Shell exec'd inside a new tmux session | +Redirected bare `sess` prints a list. `new` without `--detach` and `attach` require an interactive terminal. Errors go to stderr with a nonzero exit status. JSON list output includes stable session IDs, client counts, creation time, initial directory, PID, and attached/detached state. -Set inside every session shell by `tmux-init.sh`: `SESS_SESSION`, `SESS_BRANCH`, `SESS_DIR`. +## Detach and reconnect -Files under `SESS_DIR`: +- **Ctrl+\** detaches only your current terminal. Work stays on the VM. +- **Close the terminal tab** to disconnect; attach from another terminal later. +- **`sess detach` inside the remote shell** detaches every client of that session. +- **Ctrl+C while attached** interrupts the foreground program as usual. +- **`exit` at the main shell prompt** ends the session. +- **`sess rm `** terminates the session; files written to the VM remain. -| Path | Contents | -|---|---| -| `remote` | `name.host=user@host` lines; `default` is the one attach/ls/rm/up/ssh use | -| `active-sessions` | One session name per line, most recent last | -| `sessions//state` | `SESS_SESSION`, `SESS_BRANCH`, `SESS_CWD`, `SESS_CREATED` (sourced by the script) | -| `sessions//log` | Timestamped activity log | -| `sessions//connections` | Timestamped `detach` / `exit` events | -| `sessions//tmux-init.sh` | Generated init script for the tmux session | +When SSH reports a transient connection failure, sess retries with delays of 1, 2, 4, 8, then at most 15 seconds. Ctrl+C during reconnection cancels the attempt. Intentional detach and normal shell exit do not reconnect. Authentication, host-key, configuration, and unknown SSH errors are reported instead of blindly retried. -Makefile: `PREFIX` (default `/usr/local`) and `DESTDIR` control where `make install` puts the script and completions. +A reconnect request carries the original session ID. If somebody removed the session and reused its name, reconnect stops instead of knowingly attaching you to the replacement. Missing sessions are never recreated by the sess attach command. -Optional macOS hook: `etc/wakeup` is a SleepWatcher script that runs `sess up` in the background 3 seconds after wake. Install with `brew install sleepwatcher`, `cp etc/wakeup ~/.wakeup`, `chmod +x ~/.wakeup`. An already-attached remote session reconnects on its own through the retry loop; the hook is only for reopening sessions you were not attached to. +No reconnect process remains after you close the client terminal. Run `sess a ` to return. The VM and zmx must stay alive: live sessions do not survive a VM reboot, backend crash, or operating-system process cleanup. -## Design decisions +## Session browser -- One script, copied as-is. `sess init` scps `bin/sess` to the VM, so every remote command is just `ssh host "sess ..."` running the same code. No daemon, no port, no protocol of its own. -- Reconnect is a blind retry. `cmd_remote_attach` ignores the ssh exit status and reattaches after 3 seconds no matter why ssh returned. Simple and hard to break, but it means `Ctrl+b d` on a remote session comes back after 3 seconds; `Ctrl+C` during the wait is the way out. -- State is plain text. `state` is a shell file that gets `source`d, `remote` is `key=value`, logs are one line per event. Easy to `cat`, `grep` and `scp`, and easy to hand-edit if something goes wrong. -- tmux options are set per session with `tmux set-option -t `, applied on every attach. Your `.tmux.conf` is untouched, but the status line of a sess session is always sess's. -- The branch argument is metadata, not a checkout. Sessions are meant to be cheap labels over one working tree, which is the "no worktrees" part of the tagline. -- Only the remote named `default` drives attach, ls, rm, up and ssh. Other remote names exist for `sess new --remote ` and the interactive picker. +The browser refreshes asynchronously, shows host context and client counts, and has distinct loading, empty, filtered, and unreachable-host states. -## Project layout +| Key | Action | +| --- | --- | +| `↑` / `↓`, `k` / `j` | Select a session | +| `Enter` | Attach; return to the browser on detach | +| `n` | Create and attach | +| `x` | Remove; type the session name to confirm in the browser | +| `/` | Filter by name | +| `h` | Browse another host without changing your saved default | +| `r` | Refresh | +| `?` | Keyboard help | +| `q`, `Esc` | Quit or cancel the current form | -``` -bin/sess The tool. Single bash script, VERSION at the top. -bin/sess-cli.js npm shim: execFileSync(bin/sess, argv) -etc/bash-completion/sess bash completion -etc/zsh-completion/_sess zsh completion -etc/wakeup Optional macOS SleepWatcher hook that runs `sess up` -docs/index.html Static landing page -test/test_sess.sh Smoke test (uses a temp SESS_DIR, needs git) -Makefile install / uninstall / test / clean -package.json npm package `sess-sh`, bin `sess` -``` +The terminal is handed directly to SSH while attached; sess does not wrap the shell in another TUI. `NO_COLOR` is supported. Start attachments from an ordinary laptop terminal, rather than nesting persistent terminals across SSH. -## Development +## Under the hood -```bash -make test # bash -n on bin/sess, loads the bash completion -bash test/test_sess.sh # smoke test: version, help, doctor, ls, status, path, log, rm +```text +laptop VM +sess CLI / browser ── SSH ──> sess agent + │ + zmx client + │ Unix socket + zmx daemon + │ PTY + shell + programs ``` -`make test` does not run `test/test_sess.sh`; run both. The smoke test creates a throwaway git repo and a session state dir under `/tmp` and cleans them up on exit. It never attaches (that needs a TTY). +The agent speaks a versioned JSON command protocol over ordinary SSH. Requests are encoded as a single argument; names and hosts are validated rather than interpolated into shell commands. Interactive attachment uses SSH's PTY. The client never reimplements SSH authentication or terminal emulation. -There is no linter or CI workflow in the repo. The version string lives in two places, `VERSION=` in `bin/sess` and `"version"` in `package.json`; bump both before `npm publish`. The published files are the `files` list in `package.json`. +zmx owns the terminal process and restores its display when a client returns. sess uses a private, account-specific runtime directory, separate from ordinary zmx sessions. A random ID is stored on each session; zmx supplies live session state, so a local cache cannot claim that an unreachable VM is empty. -## Limitations +The remote agent is installed at an absolute path relative to `$HOME`; SSH startup PATH is not required. Session shells receive the managed binary directory on PATH so `sess detach` works there. User shell configuration can still override PATH. The reported directory is the directory at creation, not a continually tracked shell `pwd`. -Things you can see in the code today: +Local configuration is `~/.config/sess/config.json` (`XDG_CONFIG_HOME` supported), written atomically with private permissions. `SESS_CONFIG` overrides its path. Runtime and backend overrides `SESS_RUNTIME_DIR` and `SESS_ZMX` are intended for development/testing. `SESS_SSH` selects a local SSH executable or wrapper. -- No `drop` event. The connection log only ever gets `detach` or `exit`; the help text and `docs/index.html` still mention `drop`. A network drop usually kills the remote script with the SSH session, so nothing is written. -- `sess up` with a default remote reads the laptop's `~/.sess/active-sessions`, but the remote paths of `sess new` and `sess ` hand off to ssh before writing it. In a pure remote workflow it reports "No previously active sessions". -- The Terminal tabs that `sess up` opens on macOS run a plain `ssh -t host 'sess '`, without the retry loop. On Linux the remote `sess up` attaches one session at a time. -- `sess diff`, `log`, `connections`, `path`, `status ` and `code` only read the laptop's state dir, so with a default remote they fail for sessions that exist only on the VM. Run them on the VM through `sess ssh` instead. -- A session created with `sess new --remote dev2` can only be reattached with `sess ` if `dev2` is also the `default` remote. +## Development -## Contributing +```sh +make build # builds client and embeds remote helpers +make test # unit tests with race detector + go vet +make integration # real SSH and zmx in disposable Docker +``` -Open an issue or PR at [deepaksilaych/sess](https://github.com/deepaksilaych/sess). Run `make test` and `bash test/test_sess.sh` before pushing. +Integration testing requires Docker, Python 3, and OpenSSH. It generates temporary keys/configuration, binds SSH only on loopback, and removes its test container on exit. It does not operate on your configured VMs. -## License +```text +cmd/sess/ Client entry point +cmd/sess-agent/ Small remote helper entry point +internal/api/ Shared data types and validation +internal/cli/ Commands and completion +internal/tui/ Interactive session browser +internal/transport/ System SSH and reconnect policy +internal/backend/ zmx lifecycle and namespace +internal/agent/ Remote request handling +internal/provision/ Embedded helpers and verified zmx installer +internal/store/ Local configuration +scripts/ Cross-build and release packaging +test/integration/ Isolated SSH lifecycle tests +``` -MIT. See [LICENSE](LICENSE). +[Full user guide](docs/user-guide.md) · [zmx](https://github.com/neurosnap/zmx) · [MIT license](LICENSE) diff --git a/bin/sess b/bin/sess index b4970a1..373f8bd 100755 --- a/bin/sess +++ b/bin/sess @@ -1,969 +1,9 @@ -#!/usr/bin/env bash -# sess — tmux session manager -# Single binary, zero dependencies beyond tmux+git. -# Auto-reconnect: sess up reconnects to previously active sessions. -set -euo pipefail - -VERSION="0.5.0" -PROG="$(basename "$0")" -SESS_DIR="${SESS_DIR:-$HOME/.sess}" - -# ─── Output ───────────────────────────────────────────────────────────────── -die() { printf '%s\n' "$*" >&2; exit 1; } -info() { printf '%s\n' "$*"; } - -# ─── Helpers ───────────────────────────────────────────────────────────────── -_sess_dir() { echo "$SESS_DIR/sessions/$1"; } -_state() { echo "$SESS_DIR/sessions/$1/state"; } -_log_file() { echo "$SESS_DIR/sessions/$1/log"; } -_is_macos() { [[ "$(uname -s)" == "Darwin" ]]; } -_is_linux() { [[ "$(uname -s)" == "Linux" ]]; } - -# Resolve the real path to this script, following symlinks (for scp'ing to remotes) -_self_path() { - local src="${BASH_SOURCE[0]}" - while [[ -L "$src" ]]; do - local dir - dir="$(cd -P "$(dirname "$src")" && pwd)" - src="$(readlink "$src")" - [[ "$src" = /* ]] || src="$dir/$src" - done - printf '%s\n' "$src" -} - -# ─── Remote config ────────────────────────────────────────────────────────── -_remote_config() { echo "$SESS_DIR/remote"; } - -_remote_get() { - local name="$1" key="$2" - local cfg - cfg="$(_remote_config)" - [[ -f "$cfg" ]] || return 1 - grep "^${name}.${key}=" "$cfg" | head -1 | cut -d= -f2- -} - -_remote_set() { - local name="$1" key="$2" value="$3" - local cfg - cfg="$(_remote_config)" - mkdir -p "$(dirname "$cfg")" - if [[ -f "$cfg" ]]; then - local tmp - tmp="$(mktemp)" - grep -v "^${name}.${key}=" "$cfg" > "$tmp" 2>/dev/null || true - echo "${name}.${key}=${value}" >> "$tmp" - mv "$tmp" "$cfg" - else - echo "${name}.${key}=${value}" > "$cfg" - fi -} - -# List configured remote names (unique, in file order) -_remote_names() { - local cfg - cfg="$(_remote_config)" - [[ -f "$cfg" ]] || return 0 - sed -n 's/^\([^.]*\)\.host=.*/\1/p' "$cfg" | awk '!seen[$0]++' -} - -# Resolve which remote host to use for a new session. -# Order: explicit --remote flag > only remote configured > interactive pick > none (local). -_select_remote() { - local explicit="${1:-}" - if [[ -n "$explicit" ]]; then - local host - host="$(_remote_get "$explicit" "host" 2>/dev/null || true)" - [[ -n "$host" ]] || die "Remote '$explicit' not configured. Use: sess remote ls" - echo "$host" - return 0 - fi - - local names=() - while IFS= read -r n; do - [[ -n "$n" ]] && names+=("$n") - done < <(_remote_names) - - case "${#names[@]}" in - 0) return 0 ;; - 1) _remote_get "${names[0]}" "host"; return 0 ;; - *) - if [[ ! -t 0 ]]; then - # Non-interactive: prefer "default" if present, else the first configured remote. - local host - host="$(_remote_get "default" "host" 2>/dev/null || true)" - if [[ -n "$host" ]]; then - echo "$host" - else - _remote_get "${names[0]}" "host" - fi - return 0 - fi - - printf 'Multiple remotes configured:\n' >&2 - local i=1 n h - for n in "${names[@]}"; do - h="$(_remote_get "$n" "host")" - printf ' %d) %s (%s)\n' "$i" "$n" "$h" >&2 - i=$((i + 1)) - done - printf ' %d) local (no remote)\n' "$i" >&2 - printf 'Select remote [1-%d]: ' "$i" >&2 - local choice - read -r choice - - if [[ "$choice" == "$i" ]]; then - echo "" - return 0 - fi - if [[ "$choice" =~ ^[0-9]+$ ]] && [[ "$choice" -ge 1 ]] && [[ "$choice" -le ${#names[@]} ]]; then - _remote_get "${names[$((choice - 1))]}" "host" - else - die "Invalid selection." - fi - ;; - esac -} - -# ─── Connection log ────────────────────────────────────────────────────────── -_conn_log() { - local session="$1" event="$2" detail="${3:-}" - local conn_log - conn_log="$(_sess_dir "$session")/connections" - mkdir -p "$(dirname "$conn_log")" - printf '%s %s%s\n' "$(date -Iseconds)" "$event" "${detail:+ ($detail)}" >> "$conn_log" -} - -# ─── Log an event ─────────────────────────────────────────────────────────── -_log_event() { - local session="$1" event="$2" - local log - log="$(_log_file "$session")" - mkdir -p "$(dirname "$log")" - printf '%s %s\n' "$(date -Iseconds)" "$event" >> "$log" -} - -# ─── Record which sessions are "active" (last attached) ──────────────────── -_mark_active() { - local session="$1" - local active_file="$SESS_DIR/active-sessions" - mkdir -p "$(dirname "$active_file")" - local tmp - tmp="$(mktemp)" - grep -v "^${session}$" "$active_file" 2>/dev/null > "$tmp" || true - echo "$session" >> "$tmp" - mv "$tmp" "$active_file" -} - -_mark_inactive() { - local session="$1" - local active_file="$SESS_DIR/active-sessions" - [[ -f "$active_file" ]] || return 0 - local tmp - tmp="$(mktemp)" - grep -v "^${session}$" "$active_file" 2>/dev/null > "$tmp" || true - mv "$tmp" "$active_file" -} - -# ─── Apply tmux status bar for a session ──────────────────────────────────── -_apply_tmux_status() { - local session="$1" - - # Status bar styling - tmux set-option -t "$session" status on - tmux set-option -t "$session" status-interval 3 - tmux set-option -t "$session" status-style "bg=colour235,fg=colour250" - tmux set-option -t "$session" status-left-length 50 - tmux set-option -t "$session" status-right-length 100 - - # Left: session name - tmux set-option -t "$session" status-left \ - "#[fg=colour39,bold] ${session} #[default]" - - # Right: git branch | short path | time - tmux set-option -t "$session" status-right \ - "#[fg=colour245]#(cd '#{pane_current_path}' && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo ?) #[fg=colour39] #{b:pane_current_path} #[fg=colour250] %H:%M " - - # Window list styling - tmux set-option -t "$session" window-status-current-style "fg=colour39,bold" - tmux set-option -t "$session" window-status-current-format " #W " - tmux set-option -t "$session" window-status-format " #W " - - # Status bar separator - tmux set-option -t "$session" status-left-style "bg=colour237" - tmux set-option -t "$session" status-right-style "bg=colour237" -} - -# ─── Launch or reuse tmux session ─────────────────────────────────────────── -_tmux_start() { - local session="$1" cwd="$2" branch="${3:-main}" - local sdir - sdir="$(_sess_dir "$session")" - - if ! tmux has-session -t "$session" 2>/dev/null; then - # Write init script that sets sess env vars then execs the user's shell - local init_path="$sdir/tmux-init.sh" - local user_shell="${SHELL:-bash}" - cat > "$init_path" </dev/null || cd -exec '${user_shell}' -INIT - chmod +x "$init_path" - - tmux new-session -d -s "$session" -c "$cwd" "$init_path" - fi - # Always refresh status bar on attach (covers existing sessions too) - _apply_tmux_status "$session" -} - -# ─── cmd: new ──────────────────────────────────────────────────────────────── -cmd_new() { - local remote_name="" force_local=false - local args=() - while [[ $# -gt 0 ]]; do - case "$1" in - -r|--remote) remote_name="${2:?Usage: sess new [branch] --remote }"; shift 2 ;; - --local) force_local=true; shift ;; - *) args+=("$1"); shift ;; - esac - done - set -- "${args[@]}" - - local session="${1:?Usage: sess new [branch] [--remote |--local]}" - local branch="${2:-}" - - # If a remote is configured (or selected), create the session on the VM and attach - local remote_host="" - if ! $force_local; then - remote_host="$(_select_remote "$remote_name")" - fi - if [[ -n "$remote_host" ]]; then - info "Creating session '$session' on $remote_host..." - exec ssh -t "$remote_host" "sess new $session${branch:+ $branch} --local" - fi - - local sdir - sdir="$(_sess_dir "$session")" - - [[ -d "$sdir" ]] && die "Session '$session' already exists. Use: sess $session" - - if [[ -z "$branch" ]] && git rev-parse --abbrev-ref HEAD &>/dev/null; then - branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)" - fi - branch="${branch:-main}" - - mkdir -p "$sdir" - - cat > "$(_state "$session")" </dev/null || true)" - if [[ -n "$remote" ]]; then - cmd_remote_attach "$session" - return - fi - - local sdir - sdir="$(_sess_dir "$session")" - [[ -d "$sdir" ]] || die "Session '$session' does not exist. Use: sess new $session" - - source "$(_state "$session")" - - local cwd="${SESS_CWD:-$(pwd)}" - - _log_event "$session" "attached" - _mark_active "$session" - - _tmux_start "$session" "$cwd" "${SESS_BRANCH:-main}" - - # Attach — returns on detach (Ctrl+b d) or when session is killed - tmux attach-session -t "$session" || true - - # Classify how this ended - if tmux has-session -t "$session" 2>/dev/null; then - _conn_log "$session" "detach" "Ctrl+b d" - _log_event "$session" "detached (Ctrl+b d)" - else - _conn_log "$session" "exit" - _log_event "$session" "exited" - _mark_inactive "$session" - fi -} - -# ─── cmd: ls ───────────────────────────────────────────────────────────────── -cmd_ls() { - local remote_host - remote_host="$(_remote_get "default" "host" 2>/dev/null || true)" - if [[ -n "$remote_host" ]]; then - ssh "$remote_host" "sess ls" - return - fi - - local sbase="$SESS_DIR/sessions" - [[ -d "$sbase" ]] || { info "No sessions. Use: sess new "; return 0; } - - local count=0 - for d in "$sbase"/*/; do - [[ -d "$d" ]] || continue - count=$((count + 1)) - done - - [[ $count -eq 0 ]] && { info "No sessions. Use: sess new "; return 0; } - - printf '%-20s %-15s %-10s %-20s\n' "SESSION" "BRANCH" "STATUS" "CREATED" - printf '%-20s %-15s %-10s %-20s\n' "--------------------" "---------------" "----------" "--------------------" - - for d in "$sbase"/*/; do - [[ -d "$d" ]] || continue - local name - name="$(basename "$d")" - local state="$d/state" - local branch="?" created="?" - - if [[ -f "$state" ]]; then - branch="$(grep '^SESS_BRANCH=' "$state" | cut -d= -f2)" - created="$(grep '^SESS_CREATED=' "$state" | cut -d= -f2)" - created="${created%%+*}" - fi - - local status="inactive" - tmux has-session -t "$name" 2>/dev/null && status="active" - - printf '%-20s %-15s %-10s %-20s\n' "$name" "$branch" "$status" "$created" - done -} - -# ─── cmd: rm ───────────────────────────────────────────────────────────────── -cmd_rm() { - local session="${1:?Usage: sess rm }" - - local remote_host - remote_host="$(_remote_get "default" "host" 2>/dev/null || true)" - if [[ -n "$remote_host" ]]; then - ssh "$remote_host" "sess rm $session" - return - fi - - local sdir - sdir="$(_sess_dir "$session")" - - [[ -d "$sdir" ]] || die "Session '$session' does not exist." - - # Kill tmux session if running - if tmux has-session -t "$session" 2>/dev/null; then - tmux kill-session -t "$session" - fi - - _log_event "$session" "removed" - _mark_inactive "$session" - rm -rf "$sdir" - info "Session '$session' removed." -} - -# ─── cmd: diff ─────────────────────────────────────────────────────────────── -cmd_diff() { - local session="${1:?Usage: sess diff [path]}" - local path="${2:-}" - - local sdir - sdir="$(_sess_dir "$session")" - [[ -d "$sdir" ]] || die "Session '$session' does not exist." - - source "$(_state "$session")" - - local cwd="${SESS_CWD:-$(pwd)}" - git -C "$cwd" rev-parse --is-inside-work-tree &>/dev/null || die "Session '$session' cwd is not a git repository." - - _log_event "$session" "diff${path:+ (path: $path)}" - - if [[ -n "$path" ]]; then - git -C "$cwd" diff -- "$path" - else - git -C "$cwd" diff - fi -} - -# ─── cmd: log ──────────────────────────────────────────────────────────────── -cmd_log() { - local session="${1:?Usage: sess log [N]}" - local lines="${2:-20}" - - local sdir - sdir="$(_sess_dir "$session")" - [[ -d "$sdir" ]] || die "Session '$session' does not exist." - - local log - log="$(_log_file "$session")" - - if [[ ! -f "$log" ]]; then - info "No activity log for session '$session'." - return 0 - fi - - info "Activity log for '$session':" - tail -n "$lines" "$log" -} - -# ─── cmd: path ─────────────────────────────────────────────────────────────── -cmd_path() { - local session="${1:?Usage: sess path }" - - local sdir - sdir="$(_sess_dir "$session")" - [[ -d "$sdir" ]] || die "Session '$session' does not exist." - - source "$(_state "$session")" - - echo "${SESS_CWD:-$(pwd)}" -} - -# ─── cmd: status ───────────────────────────────────────────────────────────── -cmd_status() { - local session="${1:-}" - - if [[ -z "$session" ]]; then - info "sess $VERSION" - info "State dir: $SESS_DIR" - info "Platform: $(uname -s)" - - local count=0 - for _ in "$SESS_DIR/sessions"/*/; do - [[ -d "$_" ]] && count=$((count + 1)) || true - done - info "Sessions: $count" - - local active_file="$SESS_DIR/active-sessions" - if [[ -f "$active_file" ]] && [[ -s "$active_file" ]]; then - info "Previously active:" - while IFS= read -r name; do - info " $name" - done < "$active_file" - fi - return 0 - fi - - local sdir - sdir="$(_sess_dir "$session")" - [[ -d "$sdir" ]] || die "Session '$session' does not exist." - - source "$(_state "$session")" - - info "Session: $session" - info " Branch: ${SESS_BRANCH:-?}" - info " CWD: ${SESS_CWD:-?}" - info " Created: ${SESS_CREATED:-?}" - - if tmux has-session -t "$session" 2>/dev/null; then - info " Status: active (tmux session running)" - else - info " Status: inactive" - fi - - local log - log="$(_log_file "$session")" - if [[ -f "$log" ]]; then - info "" - info "Last 5 events:" - tail -n 5 "$log" | while IFS= read -r line; do - info " $line" - done - fi -} - -# ─── cmd: up ───────────────────────────────────────────────────────────────── -cmd_up() { - local active_file="$SESS_DIR/active-sessions" - - if [[ ! -f "$active_file" ]] || [[ ! -s "$active_file" ]]; then - info "No previously active sessions." - info "Start one with: sess new " - return 0 - fi - - local remote_host - remote_host="$(_remote_get "default" "host" 2>/dev/null || true)" - - if [[ -n "$remote_host" ]]; then - _remote_up "$remote_host" - else - _local_up - fi -} - -_local_up() { - local active_file="$SESS_DIR/active-sessions" - local sessions=() - while IFS= read -r name; do - [[ -d "$(_sess_dir "$name")" ]] || continue - sessions+=("$name") - done < "$active_file" - - if [[ ${#sessions[@]} -eq 0 ]]; then - info "No active sessions to reconnect to." - return 0 - fi - - info "Reconnecting to ${#sessions[@]} session(s)..." - - # If inside tmux already, switch-client; otherwise attach - if [[ -n "${TMUX:-}" ]]; then - # Already in a tmux session — switch to the first one - local first="${sessions[0]}" - for session in "${sessions[@]}"; do - source "$(_state "$session")" - local cwd="${SESS_CWD:-$(pwd)}" - _tmux_start "$session" "$cwd" "${SESS_BRANCH:-main}" - done - tmux switch-client -t "${first}" - else - # Attach to the last active session - local last="${sessions[${#sessions[@]}-1]}" - cmd_attach "$last" - fi -} - -# ─── cmd: ssh ──────────────────────────────────────────────────────────────── -cmd_ssh() { - local remote_host - remote_host="$(_remote_get "default" "host" 2>/dev/null || true)" - [[ -n "$remote_host" ]] || die "No remote configured. Use: sess remote add " - - exec ssh "$remote_host" "$@" -} - -# ─── cmd: code ──────────────────────────────────────────────────────────────── -cmd_code() { - local session="${1:?Usage: sess code }" - local sdir - sdir="$(_sess_dir "$session")" - [[ -d "$sdir" ]] || die "Session '$session' does not exist." - - source "$(_state "$session")" - local cwd="${SESS_CWD:-$(pwd)}" - - local remote_host - remote_host="$(_remote_get "default" "host" 2>/dev/null || true)" - - local editor_cmd="${SESS_EDITOR:-}" - if [[ -z "$editor_cmd" ]]; then - if command -v cursor &>/dev/null; then - editor_cmd="cursor" - elif command -v code &>/dev/null; then - editor_cmd="code" - else - die "Neither 'cursor' nor 'code' found. Install one or set SESS_EDITOR." - fi - fi - - if [[ -n "$remote_host" ]]; then - local uri="vscode-remote://ssh-remote+${remote_host}${cwd}" - _log_event "$session" "code (remote: $editor_cmd)" - info "Opening $editor_cmd on $remote_host:$cwd" - "$editor_cmd" --remote "$remote_host" "$cwd" 2>/dev/null || \ - "$editor_cmd" "$uri" 2>/dev/null || \ - "$editor_cmd" "$cwd" - else - _log_event "$session" "code (local: $editor_cmd)" - info "Opening $editor_cmd at $cwd" - "$editor_cmd" "$cwd" - fi -} - -# ─── cmd: remote ───────────────────────────────────────────────────────────── -cmd_remote() { - local subcmd="${1:?Usage: sess remote ...}" - shift - - case "$subcmd" in - add) remote_add "$@" ;; - rm|remove) remote_rm "$@" ;; - ls) remote_ls "$@" ;; - *) die "Unknown remote subcommand: $subcmd. Use: add, ls, rm" ;; - esac -} - -remote_add() { - local name="${1:-default}" - local host="${2:?Usage: sess remote add [name] }" - - _remote_set "$name" "host" "$host" - - info "Testing SSH connection to $host..." - if ssh -o ConnectTimeout=5 -o BatchMode=yes "$host" "echo ok" &>/dev/null; then - info "SSH connection to $host works." - else - info "SSH connection to $host failed. Make sure you have key-based auth set up." - info " ssh-copy-id $host" - fi - - info "Checking if sess is installed on remote..." - if ssh "$host" "command -v sess" &>/dev/null; then - info "sess is available on $host." - else - info "sess is not installed on $host." - info "Install it with: sess init $host" - fi - - info "Remote '$name' configured: $host" -} - -# ─── cmd: init ─────────────────────────────────────────────────────────────── -# Provisions a remote host: installs tmux + git, ships this sess script over, -# puts it on PATH, and registers the remote. -cmd_init() { - local host="${1:?Usage: sess init [name]}" - local name="${2:-default}" - - info "Testing SSH connection to $host..." - ssh -o ConnectTimeout=8 "$host" "echo ok" &>/dev/null \ - || die "Could not connect to $host. Check the host/credentials, e.g.: ssh $host" - info "SSH connection to $host works." - - info "Installing tmux + git on $host (may prompt for sudo password)..." - ssh -t "$host" ' - set -e - if command -v tmux >/dev/null 2>&1 && command -v git >/dev/null 2>&1; then - echo "tmux + git already installed." - exit 0 - fi - if command -v apt-get >/dev/null 2>&1; then - sudo apt-get update -y && sudo apt-get install -y tmux git - elif command -v dnf >/dev/null 2>&1; then - sudo dnf install -y tmux git - elif command -v yum >/dev/null 2>&1; then - sudo yum install -y tmux git - elif command -v apk >/dev/null 2>&1; then - sudo apk add --no-cache tmux git - elif command -v pacman >/dev/null 2>&1; then - sudo pacman -Sy --noconfirm tmux git - elif command -v brew >/dev/null 2>&1; then - brew install tmux git - else - echo "No supported package manager found. Install tmux and git manually." >&2 - exit 1 - fi - ' || die "Failed to install tmux/git on $host." - - info "Installing sess on $host..." - local self - self="$(_self_path)" - ssh "$host" "mkdir -p ~/bin" - scp -q "$self" "$host:~/bin/sess" - ssh "$host" "chmod +x ~/bin/sess" - - ssh "$host" ' - for rc in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do - [ -f "$rc" ] || continue - grep -qs "HOME/bin" "$rc" || printf "export PATH=\"\$HOME/bin:\$PATH\"\n" >> "$rc" - done - ' - - info "Verifying install..." - ssh "$host" '~/bin/sess version' || die "sess did not install correctly on $host." - - _remote_set "$name" "host" "$host" - info "" - info "Remote '$name' ready: $host" - info "Start a session with: sess new " -} - -remote_rm() { - local name="${1:-default}" - local cfg - cfg="$(_remote_config)" - [[ -f "$cfg" ]] || { info "No remotes configured."; return 0; } - - local tmp - tmp="$(mktemp)" - grep -v "^${name}\." "$cfg" > "$tmp" 2>/dev/null || true - mv "$tmp" "$cfg" - - info "Remote '$name' removed." -} - -remote_ls() { - local cfg - cfg="$(_remote_config)" - if [[ ! -f "$cfg" ]] || [[ ! -s "$cfg" ]]; then - info "No remotes configured. Use: sess remote add " - return 0 - fi - - info "Configured remotes:" - while IFS='=' read -r key value; do - local name="${key%%.*}" - local prop="${key#*.}" - info " $name.$prop = $value" - done < "$cfg" -} - -# ─── Remote attach (SSH wrapper) ──────────────────────────────────────────── -cmd_remote_attach() { - local session="$1" - local remote_host - remote_host="$(_remote_get "default" "host" 2>/dev/null || true)" - [[ -n "$remote_host" ]] || die "No remote configured. Use: sess remote add " - - while true; do - ssh -t "$remote_host" "sess $session" || true - printf '\n[sess] disconnected. reconnecting in 3s... (Ctrl+C to stop)\n' - sleep 3 - done -} - -# ─── Remote up: reconnect to all active sessions on VM ────────────────────── -_remote_up() { - local remote_host="$1" - local active_file="$SESS_DIR/active-sessions" - - [[ -f "$active_file" ]] || { info "No previously active sessions."; return 0; } - - local sessions=() - while IFS= read -r name; do - sessions+=("$name") - done < "$active_file" - - if [[ ${#sessions[@]} -eq 0 ]]; then - info "No active sessions to reconnect to." - return 0 - fi - - local alive_sessions=() - for session in "${sessions[@]}"; do - if ssh "$remote_host" "test -d ~/.sess/sessions/$session" 2>/dev/null; then - alive_sessions+=("$session") - fi - done - - if [[ ${#alive_sessions[@]} -eq 0 ]]; then - info "No active sessions found on $remote_host." - return 0 - fi - - info "Reconnecting to ${#alive_sessions[@]} session(s) on $remote_host..." - - for session in "${alive_sessions[@]}"; do - if _is_macos; then - osascript -e "tell application \"Terminal\"" \ - -e "activate" \ - -e "set currentTab to do script \"ssh -t $remote_host 'sess $session'\"" \ - -e "end tell" 2>/dev/null || { - info "Could not open Terminal tab for '$session'." - } - else - ssh -t "$remote_host" "sess $session" - fi - done -} - -# ─── cmd: doctor ───────────────────────────────────────────────────────────── -cmd_doctor() { - local ok=true - - info "Platform: $(uname -s)" - - # tmux - if command -v tmux &>/dev/null; then - info "tmux: $(tmux -V)" - else - info "tmux: NOT installed" - info " Install: apt install tmux / brew install tmux" - ok=false - fi - - # git - if command -v git &>/dev/null; then - info "git: $(git --version)" - else - info "git: NOT installed" - ok=false - fi - - # bash - if command -v bash &>/dev/null; then - info "bash: $(bash --version | head -1)" - else - info "bash: NOT installed" - ok=false - fi - - # SSH - if command -v ssh &>/dev/null; then - info "ssh: $(ssh -V 2>&1 | head -1 || echo 'installed')" - else - info "ssh: NOT installed (needed for remote sessions)" - ok=false - fi - - local remote_host - remote_host="$(_remote_get "default" "host" 2>/dev/null || true)" - if [[ -n "$remote_host" ]]; then - info "Remote: $remote_host" - if ssh -o ConnectTimeout=5 -o BatchMode=yes "$remote_host" "echo ok" &>/dev/null; then - info "Remote SSH: working" - else - info "Remote SSH: connection failed (key-based auth required)" - ok=false - fi - else - info "Remote: not configured (use: sess remote add )" - fi - - info "" - if $ok; then - info "All prerequisites met." - else - info "Some prerequisites missing. See above." - fi -} - -# ─── cmd: connections ──────────────────────────────────────────────────────── -cmd_connections() { - local session="${1:?Usage: sess connections }" - local lines="${2:-20}" - - local sdir - sdir="$(_sess_dir "$session")" - [[ -d "$sdir" ]] || die "Session '$session' does not exist." - - local conn_log="$sdir/connections" - - if [[ ! -f "$conn_log" ]]; then - info "No connection log for session '$session'." - return 0 - fi - - info "Connection log for '$session':" - tail -n "$lines" "$conn_log" -} - -# ─── cmd: help ─────────────────────────────────────────────────────────────── -cmd_help() { - cat <<'HELP' -sess — tmux session manager - -USAGE: - sess new [branch] Create session + auto-attach - --remote use a specific remote - --local force local, skip remotes - sess Attach to existing session - Ctrl+b d to detach - - sess ls List sessions - sess rm Remove session (kills tmux) - - sess diff [path] Git diff in session's directory - sess log [N] Activity log (default: last 20) - sess connections [N] Connection log (detach/exit/drop) - sess path Print session's cwd (for scripts) - sess code Open Cursor/VS Code for session - sess status [name] Session info or overall status - - sess ssh [args] SSH to configured remote VM - sess up Reconnect to previously active sessions - sess init [name] Provision a host: install tmux+git+sess, - register it as a remote - sess remote add [name] Register an already-provisioned remote - sess remote ls List configured remotes - sess remote rm [name] Remove a remote - - sess doctor Check prerequisites - sess help This help - sess version Show version - -TMUX STATUS BAR: - Inside a session the bottom bar shows: - [left] session-name - [right] git-branch dir-name HH:MM - Updates every 3 seconds. No configuration needed. - -DETACH / REATTACH: - Ctrl+b d detach from session (session persists in tmux) - sess reattach - sess up reconnect all previously active sessions - - Standard tmux keybindings work inside sessions: - Ctrl+b c new window - Ctrl+b " split horizontal - Ctrl+b % split vertical - Ctrl+b [ scroll mode - -CONNECTION LOG: - Every time a session ends, sess records how it ended: - detach — Ctrl+b d (intentional detach) - exit — shell exited (typed exit or Ctrl+d) - - View with: sess connections - -AUTO-RECONNECT: - When you close your laptop, SSH drops but sessions survive on the VM (tmux keeps them). - When you open it, run: sess up - This reconnects to all sessions you had active before. - -REMOTE SESSIONS: - sess init user@dev-vm # one-time: install + register - sess ssh # just SSH to the VM - sess new feature-auth # creates on remote - sess feature-auth # SSHes to remote, attaches - sess code feature-auth # opens Cursor/VS Code on laptop - sess up # reconnects all active on wake - - Multiple remotes configured? sess new prompts you to pick one, - or skip the prompt with: sess new --remote - Only one remote configured — it's used automatically, no prompt. - -ENVIRONMENT: - SESS_DIR State directory (default: ~/.sess) - SESS_SESSION Set inside sessions (session name) - SESS_BRANCH Set inside sessions (branch) - SESS_EDITOR Editor for 'sess code' (default: cursor or code) -HELP -} - -# ─── Main dispatch ─────────────────────────────────────────────────────────── -case "${1:-help}" in - new) shift; cmd_new "$@" ;; - rm|remove) shift; cmd_rm "$@" ;; - ls|list) shift; cmd_ls "$@" ;; - attach) shift; cmd_attach "$@" ;; - up) shift; cmd_up "$@" ;; - ssh) shift; cmd_ssh "$@" ;; - code) shift; cmd_code "$@" ;; - diff) shift; cmd_diff "$@" ;; - log) shift; cmd_log "$@" ;; - connections|conn) shift; cmd_connections "$@" ;; - path) shift; cmd_path "$@" ;; - status) shift; cmd_status "$@" ;; - remote) shift; cmd_remote "$@" ;; - init) shift; cmd_init "$@" ;; - doctor) shift; cmd_doctor "$@" ;; - help|--help|-h) cmd_help ;; - version|--version|-v) info "sess $VERSION" ;; - *) - # Check local dir OR remote configured (sessions live on VM) - if [[ -d "$(_sess_dir "$1")" ]] || [[ -n "$(_remote_get "default" "host" 2>/dev/null || true)" ]]; then - cmd_attach "$1" - else - die "Unknown command: $1. Run 'sess help' for usage." - fi - ;; -esac +#!/bin/sh +# Source-checkout convenience launcher. Releases install the compiled executable. +set -eu +base=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +if [ ! -x "$base/dist/sess" ]; then + printf 'sess has moved to Go + zmx. Run make build in %s first.\n' "$base" >&2 + exit 1 +fi +exec "$base/dist/sess" "$@" diff --git a/bin/sess-cli.js b/bin/sess-cli.js deleted file mode 100755 index 4f02e20..0000000 --- a/bin/sess-cli.js +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env node -// npx wrapper — delegates to the real bash script -const { execFileSync } = require('child_process'); -const path = require('path'); - -// The bash script is next to this file -const script = path.join(__dirname, 'sess'); -try { - execFileSync(script, process.argv.slice(2), { - stdio: 'inherit', - env: { ...process.env } - }); -} catch (e) { - process.exit(e.status || 1); -} \ No newline at end of file diff --git a/cmd/sess-agent/main.go b/cmd/sess-agent/main.go new file mode 100644 index 0000000..bcc0c1e --- /dev/null +++ b/cmd/sess-agent/main.go @@ -0,0 +1,8 @@ +package main + +import ( + "github.com/DeepakSilaych/sess/internal/agent" + "os" +) + +func main() { os.Exit(agent.Run(os.Args[1:])) } diff --git a/cmd/sess/main.go b/cmd/sess/main.go new file mode 100644 index 0000000..095474f --- /dev/null +++ b/cmd/sess/main.go @@ -0,0 +1,8 @@ +package main + +import ( + "github.com/DeepakSilaych/sess/internal/cli" + "os" +) + +func main() { os.Exit(cli.Main()) } diff --git a/docs/index.html b/docs/index.html index d74e64d..e8276fa 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,547 +1,33 @@ - + - - -sess — tmux session manager - + + + +sess — A persistent SSH terminal - - - - -
-
- - - - - - -
-

tmux session manager

-

- One tool. No worktrees. No containers. Sessions that survive everything. -

- -
- - $ npx sess-sh -
- - -
- -
-

What it does

-

- Persistent terminal sessions backed by tmux. Create a session, work inside it, detach with Ctrl+b d. The session survives SSH drops, laptop sleep, and network failures. -

- -
-
-

tmux persistence

-

Native tmux sessions. Full tmux keybindings available. Sessions survive anything.

-
-
-

Native status bar

-

Session name, git branch, current dir, and time — right in the tmux status line.

-
-
-

Connection log

-

Every session end classified: detach, exit, or drop. Know what happened.

-
-
-

Remote sessions

-

sess init user@host once. Then sess new, sess ssh all go through SSH automatically.

-
-
-

Auto-reconnect on wake

-

sess up reconnects to all previously active sessions. Zero commands to type.

-
-
-
- -
-

Every command

-

- One tool. No subcommands to memorize. Just sess <name>. -

- - - - - - - - - - - - - - - - - - - - - - - - -
CommandDescription
sess new <name>Create session + auto-attach
sess <name>Reattach (Ctrl+b d to detach)
sess lsList sessions
sess rm <name>Destroy session
sess diff <name>Git diff in session directory
sess log <name>Activity log
sess connections <name>Connection log (detach/exit/drop)
sess path <name>Print session's cwd
sess code <name>Open Cursor / VS Code
sess sshSSH to configured VM
sess upReconnect all active sessions
sess init <user@host> [name]Install tmux+git+sess on a host, register it
sess remote add [name] <host>Register an already-provisioned remote
sess doctorCheck prerequisites
-
- -
-

Why not just use...?

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
sessgit worktreetmuxDocker
Creation timeinstant~1sinstantseconds
SSH persistenceauto-reconnectmanualyesmanual
Status bartmux nativenoyesno
Connection logdetach/exit/dropnonono
-
- -
-

Install

-

- One command. Requires tmux, git, and bash. -

- -
- npx sess-sh - -
- -
- npm install -g sess-sh - -
- -
- git clone https://github.com/DeepakSilaych/sess.git && cd sess && sudo make install - -
-
- - - - - - - \ No newline at end of file +
+ +
Powered by zmx

A persistent
SSH terminal.

Your work stays on the VM. Name a terminal, disconnect, and come back to the same running shell.

The zmx-based 0.6 version is in development. Build instructions are in the repository.

+
YOUR LAPTOP → DEV VM
$ sess init dev
+$ sess set --host dev
+$ sess new work
+
+# Work on the VM. Press Ctrl+\ to detach.
+
+$ sess attach work
+

Pick up where you left off.

Detach intentionally or lose your network connection. zmx holds your terminal on the VM; sess gets you back into it. Automatic reconnection stops when you intentionally detach.

Your terminal, your layout.

Use your terminal app for tabs and windows. Run sess for a searchable session browser, then attach directly to your remote shell.

+

A small command set.

+ + + + + + +
CommandWhat it does
sess init <host>Prepare sess and zmx on your VM
sess set --host <host>Save your default SSH destination
sess new <name>Create and attach · shortcut n
sess attach <name>Return to a session · shortcut a
sess lsList sessions · --json for scripts
sess remove <name>Terminate a session · shortcut rm

Use --host or -h to override the host for one command. SSH aliases and user@host work throughout. Use --help for help.

+

SSH connects. zmx persists.

sess uses system SSH and installs a small helper on the VM. Each persistent terminal lives under zmx, separately from its SSH connection. No extra network port or laptop background service is needed.

Live sessions depend on the VM and backend staying alive. A VM reboot ends its processes; persistence is not a backup or automatic job recovery.

Read the full user guide ↗
+
sess · macOS & Linux · MIT license
+
diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 0000000..1dba4d8 --- /dev/null +++ b/docs/user-guide.md @@ -0,0 +1,392 @@ +# sess + +**A persistent SSH terminal. Powered by zmx.** + +Create a named terminal on your VM, work in it, and return to the same running shell later. Your commands and processes run on the VM. Your laptop provides the keyboard and display. + +> **Version:** This guide covers the zmx-based 0.6 development version. See the [README](../README.md#build-and-install) for building and installing. Existing 0.5 tmux sessions remain separate. + +## Quick start + +Run these commands on your laptop after installing sess: + +```bash +# Prepare the VM for persistent sessions. +sess init user@host + +# Choose the host used by subsequent commands. +sess set --host user@host + +# Create a session and connect to it. +sess new work + +# You are now on the VM. Work normally. +cd ~/projects/my-app +npm run dev +``` + +Press **Ctrl+\** to detach. The shell and development server continue running on the VM. + +Later, from your laptop: + +```bash +sess ls +sess attach work +``` + +You return to the same terminal. + +Use standard SSH destinations: **`user@host`**, a hostname, or an alias from your SSH configuration. `user:host` is not the username-and-host syntax used by SSH. [SSH destination syntax](https://man.openbsd.org/ssh#SYNOPSIS). + +## 1. Prepare a VM + +```bash +sess init user@host +``` + +Or use an existing SSH alias: + +```bash +sess init dev +``` + +Run `init` on your laptop. It connects to the VM and prepares the remote side: + +1. Checks that SSH access works. +2. Detects the VM's operating system and architecture. +3. Installs the compatible sess helper and zmx for your remote account, or verifies an existing installation. +4. Checks that sess can run remote session operations. +5. Reports that the host is ready. + +You need an existing VM and working SSH access. Session commands and automatic reconnect use SSH batch mode: configure key authentication and unlock encrypted keys in ssh-agent before connecting. `init` does not create the VM or configure your cloud account. Its normal installation uses your remote account's writable directories. sess installs its remote helper and pinned zmx under `~/.local/share/sess/bin`. It leaves shell startup files unchanged. + +**`init` does not change your default host.** Setup and host selection are separate operations. Running it again checks the installation; it refuses to replace a different managed zmx version automatically. + +### SSH aliases + +An alias keeps hostnames, ports, and key paths out of everyday commands. For example, in your laptop's `~/.ssh/config`: + +```sshconfig +Host dev + HostName 203.0.113.10 + User deepak + Port 22 + IdentityFile ~/.ssh/id_ed25519 +``` + +Replace the example address and key path with your own. First verify: + +```bash +ssh dev +``` + +Then use `dev` anywhere sess expects a host. sess uses your system SSH client and its configured authentication and connection settings. [SSH configuration](https://man.openbsd.org/ssh_config). + +## 2. Set the default host + +```bash +sess set --host dev +``` + +Short form: + +```bash +sess set -h dev +``` + +This saves the default on your laptop. It does not install software, start a session, or move existing sessions. + +Without an override, session commands use this host: + +```bash +sess new api +sess attach api +sess remove api +sess ls +``` + +### Override the host for one command + +Every session command accepts `--host` or `-h`: + +```bash +sess new tests --host staging +sess attach tests -h staging +sess ls -h staging +sess remove tests -h staging +``` + +An override applies only to that command. Your default remains unchanged. + +The selection rule is always: + +1. Use the command's `--host` / `-h`, if supplied. +2. Otherwise, use the saved default host. +3. If neither exists, explain how to set or provide a host and stop. + +sess does not guess a destination or fall back to a local session. Output identifies the selected host, including before attachment and removal. + +`-h` means **host** throughout sess. Use `--help` for help. + +## 3. Create a session + +```bash +sess new +sess n +``` + +Examples: + +```bash +sess new api +sess n tests +sess n experiments -h staging +``` + +For scripts, use `sess new --detach` (or `-d`) to create without attaching. + +Creation starts a new persistent shell on the selected VM and immediately attaches your terminal to it. The shell starts in the remote account's home directory. Use `cd` normally after connecting. + +Choose a name of 1–48 characters beginning with a letter or number, followed by letters, numbers, hyphens, or underscores: `api`, `build-42`, or `release_tests`. + +If that name already exists on the selected account and host, `new` reports it and suggests `sess attach `. It does not overwrite the session. + +Names belong to the remote account on the VM. `api` on `dev` and `api` on `staging` are independent. Two SSH aliases reaching the same account on the same VM see the same sessions. + +Creating another session does not create another repository checkout. Sessions use the VM's normal filesystem; sessions working in the same directory share its files and Git branch. + +## 4. Attach to existing work + +```bash +sess attach +sess a +``` + +Examples: + +```bash +sess a api +sess a tests --host staging +``` + +Attaching reconnects to the existing terminal. It preserves the running shell's directory, environment, and programs. + +If the session does not exist, sess reports that it is missing. **Attach never silently creates a fresh session.** Use `sess new ` when you want a new shell. + +You can attach from another laptop with sess installed and SSH access to the same remote account. Set or explicitly supply the same destination; the session is already on the VM. + +Multiple attached terminals share one shell and its input. They are not separate workspaces. Create separate named sessions when you want independent terminals. + +## 5. Detach without stopping work + +### Detach just this terminal + +Press **Ctrl+\**: hold Control and press the backslash key. + +The session continues running on the VM. Your local terminal returns to the shell from which you launched sess. If you attached through the sess browser, you return to the browser. + +Intentional detach ends that attachment. sess does not immediately reconnect you. + +### Close the terminal + +You can also close the terminal tab or window. To return, open another terminal and run: + +```bash +sess a api +``` + +### Detach from a shell command + +At a shell prompt inside the session: + +```bash +sess detach +``` + +This command detaches **all terminals attached to the current session**, matching the underlying `zmx detach` operation. It leaves the session running. Use **Ctrl+\** when you only want to detach your own terminal. + +`sess detach` takes no session name or host flag and must be run inside a sess session. It does not terminate any running program. The equivalent advanced backend command is `zmx detach`. [zmx attachment and detach behaviour](https://github.com/neurosnap/zmx#usage). + +### Detach, interrupt, and exit are different + +| Action | Result | +| --- | --- | +| `Ctrl+\` | Detach this terminal; leave the session running | +| Close your local terminal | Disconnect; leave the remote session running | +| `sess detach` inside the session | Detach every attached terminal; leave the session running | +| `Ctrl+C` while attached | Send an interrupt to the foreground program, following normal terminal behaviour | +| `exit` at the session's main shell prompt | End that shell and session | +| `sess remove ` from your laptop | Terminate and remove the named session | + +`Ctrl+D` at an empty shell prompt commonly exits the shell; behaviour depends on the shell and its settings. Exiting a nested shell returns to its parent instead of necessarily ending the session. + +If an application needs `Ctrl+\`, zmx supports disabling its detach shortcut with `ZMX_NO_DETACH_KEY`. In that configuration, close the client terminal or use the detach command when a shell prompt is available. [zmx shortcut configuration](https://github.com/neurosnap/zmx#usage). + +## 6. List sessions + +```bash +sess ls +sess ls --host staging +``` + +Use `sess ls --json` for structured output or `sess ls --quiet` for session names only. + +The list shows live sess-managed sessions on the selected host, including sessions with no attached terminals. For example: + +```text +Host: dev + +NAME CLIENTS +api 1 +tests 0 +experiments 0 +``` + +The command also shows attached/detached state, age, and the initial directory. These are illustrative output columns. Zero clients means the session is running with nobody attached. + +A session whose main shell has exited is no longer a live session. sess does not retain a stopped-session definition that can restore its processes. + +If the host cannot be reached, sess reports that it cannot retrieve the list. It must not present an unreachable host as having no sessions. + +## 7. Remove a session + +```bash +sess remove +sess rm +``` + +Examples: + +```bash +sess rm tests +sess rm experiments -h staging +``` + +Removal terminates the named session and the processes managed within it, disconnects its attached terminals, and removes its sess metadata. Unsaved work in those programs can be lost. Independently daemonized services are outside the session's lifecycle. + +Files already written to the VM remain. Removal does not delete your project directory or undo changes to its files. + +Use detach when you intend to return. Use remove when you are finished with that terminal. + +Creating the same name afterward starts a fresh shell; it does not restore the removed processes. + +## 8. Network loss and automatic reconnection + +While an attachment is open, sess manages reconnection for you: + +1. The SSH connection drops or becomes unresponsive. +2. sess displays the host, session name, and reconnecting status. +3. sess retries transient connection failures with a delay between attempts. +4. When SSH works again, sess attaches to the same existing session. + +Press **Ctrl+C while the reconnect message is displayed** to stop retrying and return to your local shell. This does not remove the remote session. + +A deliberate detach or normal shell exit ends the connection. Authentication failures, host-key problems, incompatible installations, and missing sessions produce an explanation rather than an endless retry loop. + +If you close the terminal, quit sess, or reboot your laptop, its reconnect process is gone. Open a terminal and run `sess attach ` to return. No background laptop service is required. + +An unreachable VM has an unknown session state. sess can confirm whether the session survived only after reaching the VM again. + +## 9. What runs under the hood + +```text +YOUR LAPTOP YOUR VM + +Terminal application sess remote helper + | | + sess client ===== SSH =====> zmx client + | + Unix socket + | + zmx daemon + | + Virtual terminal + | + Shell + programs +``` + +### On your laptop + +sess stores your default destination and runs session commands over SSH. During attachment, it manages connection status and reconnect attempts. The terminal application handles display, keyboard input, tabs, and windows. + +### On the VM + +The remote sess helper translates operations into zmx actions. zmx uses a daemon and Unix socket for each session and holds the shell through a virtual terminal, or PTY. It retains terminal state and scrollback with `libghostty-vt` for restoration on reattachment. [zmx architecture](https://github.com/neurosnap/zmx#impl). + +The SSH connection can end while that daemon and shell continue. sess needs no additional network listener beyond the VM's SSH service. + +### The command lifecycle + +**New:** resolve the host → connect through SSH → check for a duplicate → create the persistent terminal → attach. + +**Attach:** resolve the host → verify the named session exists → attach → supervise the connection until detach, exit, or failure. + +**List:** resolve the host → request the remote session list → print it locally. + +**Remove:** resolve the host → terminate the named session → remove its metadata → report the result. + +The host owns the running session. Changing laptop settings cannot move it. sess enforces separate create and attach operations even though the backend's own attach command can create sessions. + +## 10. What persistence means + +| Event | What to expect | +| --- | --- | +| Detach or close the client terminal | Work continues on the VM | +| Laptop sleeps or loses its network | Work can continue on the VM; reconnect when the link returns | +| Laptop reboots | Remote work can continue; attach manually afterward | +| A program crashes inside the shell | That program ends; the shell may remain usable | +| The session's main shell exits | The session ends | +| VM reboots, loses power, or is destroyed | Live sessions and their processes are lost | +| zmx crashes or the OS kills its processes | Session persistence can be lost | + +Persistence depends on the VM staying alive and allowing user processes to survive logout. It is not process checkpointing, a backup, or automatic job recovery after a VM reboot. + +Restored terminal output is convenient working history. Save important output to files when you need a durable record. + +Backend upgrades can also affect live sessions; incompatible zmx communication changes are a documented risk. sess setup preserves an existing supported backend and refuses an automatic change to a different managed version. [zmx known issues](https://github.com/neurosnap/zmx#known-issues). + +## 11. Session browser and help + +Run: + +```bash +sess +``` + +In an interactive terminal, this opens the session browser for your default host. Use `sess --host staging` to browse another host. The browser lets you select and attach, create a session, or remove one. It uses the same rules as the commands above. + +After attachment, the session uses the terminal directly. sess does not add windows, panes, splits, or a permanent status bar. For independent visible terminals, use separate tabs in your terminal application. + +When output is redirected or no interactive terminal is available, bare `sess` prints the selected host's session list. + +Additional commands: + +```bash +sess doctor # Check laptop configuration and the selected VM +sess doctor -h staging # Check a specific VM +sess --help # Show commands +sess new --help # Show command-specific help +sess version # Show the installed version +``` + +Start sess attachments from an ordinary laptop terminal. If already inside a persistent terminal, detach before starting another attachment; nested zmx sessions across SSH have documented display limitations. [zmx known issues](https://github.com/neurosnap/zmx#known-issues). + +## Command reference + +| Command | Alias | Purpose | +| --- | --- | --- | +| `sess init ` | — | Prepare sess and zmx on the VM | +| `sess set --host ` | `sess set -h ` | Save the default host on this laptop | +| `sess new ` | `sess n ` | Create and attach | +| `sess attach ` | `sess a ` | Attach to an existing session | +| `sess remove ` | `sess rm ` | Terminate and remove a session | +| `sess ls` | — | List live sessions on the selected host | +| `sess detach` | — | From inside a session, detach all its clients | +| `sess` | — | Open the session browser | +| `sess doctor` | — | Check the selected host and dependencies | +| `sess --help` | — | Show help | +| `sess version` | — | Show version | + +`new`, `attach`, `remove`, `ls`, the browser, and `doctor` accept `--host ` or `-h ` without changing the saved default. + +**Daily loop: create → work → detach → attach. Remove when finished.** diff --git a/etc/bash-completion/sess b/etc/bash-completion/sess index 7b7c6c2..d875882 100644 --- a/etc/bash-completion/sess +++ b/etc/bash-completion/sess @@ -1,53 +1,426 @@ -# bash completion for sess -_sess() { - local cur prev commands +# bash completion V2 for sess -*- shell-script -*- + +__sess_debug() +{ + if [[ -n ${BASH_COMP_DEBUG_FILE-} ]]; then + echo "$*" >> "${BASH_COMP_DEBUG_FILE}" + fi +} + +# Macs have bash3 for which the bash-completion package doesn't include +# _init_completion. This is a minimal version of that function. +__sess_init_completion() +{ COMPREPLY=() - cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" - commands="new rm ls diff log connections path status up ssh init remote doctor help version" - - _sess_names() { - local sdir="${SESS_DIR:-$HOME/.sess}/sessions" - [[ -d "$sdir" ]] || return - for d in "$sdir"/*/; do - [[ -d "$d" ]] || continue - basename "$d" + _get_comp_words_by_ref "$@" cur prev words cword +} + +# This function calls the sess program to obtain the completion +# results and the directive. It fills the 'out' and 'directive' vars. +__sess_get_completion_results() { + local requestComp lastParam lastChar args + + # Prepare the command to request completions for the program. + # Calling ${words[0]} instead of directly sess allows handling aliases + args=("${words[@]:1}") + requestComp="${words[0]} __complete ${args[*]}" + + lastParam=${words[$((${#words[@]}-1))]} + lastChar=${lastParam:$((${#lastParam}-1)):1} + __sess_debug "lastParam ${lastParam}, lastChar ${lastChar}" + + if [[ -z ${cur} && ${lastChar} != = ]]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go method. + __sess_debug "Adding extra empty parameter" + requestComp="${requestComp} ''" + fi + + # When completing a flag with an = (e.g., sess -n=) + # bash focuses on the part after the =, so we need to remove + # the flag part from $cur + if [[ ${cur} == -*=* ]]; then + cur="${cur#*=}" + fi + + __sess_debug "Calling ${requestComp}" + # Use eval to handle any environment variables and such + out=$(eval "${requestComp}" 2>/dev/null) + + # Extract the directive integer at the very end of the output following a colon (:) + directive=${out##*:} + # Remove the directive + out=${out%:*} + if [[ ${directive} == "${out}" ]]; then + # There is not directive specified + directive=0 + fi + __sess_debug "The completion directive is: ${directive}" + __sess_debug "The completions are: ${out}" +} + +__sess_process_completion_results() { + local shellCompDirectiveError=1 + local shellCompDirectiveNoSpace=2 + local shellCompDirectiveNoFileComp=4 + local shellCompDirectiveFilterFileExt=8 + local shellCompDirectiveFilterDirs=16 + local shellCompDirectiveKeepOrder=32 + + if (((directive & shellCompDirectiveError) != 0)); then + # Error code. No completion. + __sess_debug "Received error from custom completion go code" + return + else + if (((directive & shellCompDirectiveNoSpace) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + __sess_debug "Activating no space" + compopt -o nospace + else + __sess_debug "No space directive not supported in this version of bash" + fi + fi + if (((directive & shellCompDirectiveKeepOrder) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + # no sort isn't supported for bash less than < 4.4 + if [[ ${BASH_VERSINFO[0]} -lt 4 || ( ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -lt 4 ) ]]; then + __sess_debug "No sort directive not supported in this version of bash" + else + __sess_debug "Activating keep order" + compopt -o nosort + fi + else + __sess_debug "No sort directive not supported in this version of bash" + fi + fi + if (((directive & shellCompDirectiveNoFileComp) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + __sess_debug "Activating no file completion" + compopt +o default + else + __sess_debug "No file completion directive not supported in this version of bash" + fi + fi + fi + + # Separate activeHelp from normal completions + local completions=() + local activeHelp=() + __sess_extract_activeHelp + + if (((directive & shellCompDirectiveFilterFileExt) != 0)); then + # File extension filtering + local fullFilter="" filter filteringCmd + + # Do not use quotes around the $completions variable or else newline + # characters will be kept. + for filter in ${completions[*]}; do + fullFilter+="$filter|" done - } - - _sess_branches() { - git branch --format='%(refname:short)' 2>/dev/null - } - - _sess_remotes() { - local cfg="${SESS_DIR:-$HOME/.sess}/remote" - [[ -f "$cfg" ]] || return - sed -n 's/^\([^.]*\)\.host=.*/\1/p' "$cfg" | awk '!seen[$0]++' - } - - case "$prev" in - new) - COMPREPLY=($(compgen -W "$(_sess_branches) --remote --local" -- "$cur")) - ;; - --remote) - COMPREPLY=($(compgen -W "$(_sess_remotes)" -- "$cur")) - ;; - rm|diff|log|path|status|connections|conn) - COMPREPLY=($(compgen -W "$(_sess_names)" -- "$cur")) - ;; - remote) - COMPREPLY=($(compgen -W "add ls rm" -- "$cur")) - ;; - add) - COMPREPLY=($(compgen -W "default" -- "$cur")) - ;; - *) - if [[ $COMP_CWORD -eq 1 ]]; then - local sessions - sessions="$(_sess_names)" - COMPREPLY=($(compgen -W "$commands $sessions" -- "$cur")) - fi - ;; + + filteringCmd="_filedir $fullFilter" + __sess_debug "File filtering command: $filteringCmd" + $filteringCmd + elif (((directive & shellCompDirectiveFilterDirs) != 0)); then + # File completion for directories only + + local subdir + subdir=${completions[0]} + if [[ -n $subdir ]]; then + __sess_debug "Listing directories in $subdir" + pushd "$subdir" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return + else + __sess_debug "Listing directories in ." + _filedir -d + fi + else + __sess_handle_completion_types + fi + + __sess_handle_special_char "$cur" : + __sess_handle_special_char "$cur" = + + # Print the activeHelp statements before we finish + __sess_handle_activeHelp +} + +__sess_handle_activeHelp() { + # Print the activeHelp statements + if ((${#activeHelp[*]} != 0)); then + if [ -z $COMP_TYPE ]; then + # Bash v3 does not set the COMP_TYPE variable. + printf "\n"; + printf "%s\n" "${activeHelp[@]}" + printf "\n" + __sess_reprint_commandLine + return + fi + + # Only print ActiveHelp on the second TAB press + if [ $COMP_TYPE -eq 63 ]; then + printf "\n" + printf "%s\n" "${activeHelp[@]}" + + if ((${#COMPREPLY[*]} == 0)); then + # When there are no completion choices from the program, file completion + # may kick in if the program has not disabled it; in such a case, we want + # to know if any files will match what the user typed, so that we know if + # there will be completions presented, so that we know how to handle ActiveHelp. + # To find out, we actually trigger the file completion ourselves; + # the call to _filedir will fill COMPREPLY if files match. + if (((directive & shellCompDirectiveNoFileComp) == 0)); then + __sess_debug "Listing files" + _filedir + fi + fi + + if ((${#COMPREPLY[*]} != 0)); then + # If there are completion choices to be shown, print a delimiter. + # Re-printing the command-line will automatically be done + # by the shell when it prints the completion choices. + printf -- "--" + else + # When there are no completion choices at all, we need + # to re-print the command-line since the shell will + # not be doing it itself. + __sess_reprint_commandLine + fi + elif [ $COMP_TYPE -eq 37 ] || [ $COMP_TYPE -eq 42 ]; then + # For completion type: menu-complete/menu-complete-backward and insert-completions + # the completions are immediately inserted into the command-line, so we first + # print the activeHelp message and reprint the command-line since the shell won't. + printf "\n" + printf "%s\n" "${activeHelp[@]}" + + __sess_reprint_commandLine + fi + fi +} + +__sess_reprint_commandLine() { + # The prompt format is only available from bash 4.4. + # We test if it is available before using it. + if (x=${PS1@P}) 2> /dev/null; then + printf "%s" "${PS1@P}${COMP_LINE[@]}" + else + # Can't print the prompt. Just print the + # text the user had typed, it is workable enough. + printf "%s" "${COMP_LINE[@]}" + fi +} + +# Separate activeHelp lines from real completions. +# Fills the $activeHelp and $completions arrays. +__sess_extract_activeHelp() { + local activeHelpMarker="_activeHelp_ " + local endIndex=${#activeHelpMarker} + + while IFS='' read -r comp; do + [[ -z $comp ]] && continue + + if [[ ${comp:0:endIndex} == $activeHelpMarker ]]; then + comp=${comp:endIndex} + __sess_debug "ActiveHelp found: $comp" + if [[ -n $comp ]]; then + activeHelp+=("$comp") + fi + else + # Not an activeHelp line but a normal completion + completions+=("$comp") + fi + done <<<"${out}" +} + +__sess_handle_completion_types() { + __sess_debug "__sess_handle_completion_types: COMP_TYPE is $COMP_TYPE" + + case $COMP_TYPE in + 37|42) + # Type: menu-complete/menu-complete-backward and insert-completions + # If the user requested inserting one completion at a time, or all + # completions at once on the command-line we must remove the descriptions. + # https://github.com/spf13/cobra/issues/1508 + + # If there are no completions, we don't need to do anything + (( ${#completions[@]} == 0 )) && return 0 + + local tab=$'\t' + + # Strip any description and escape the completion to handled special characters + IFS=$'\n' read -ra completions -d '' < <(printf "%q\n" "${completions[@]%%$tab*}") + + # Only consider the completions that match + IFS=$'\n' read -ra COMPREPLY -d '' < <(IFS=$'\n'; compgen -W "${completions[*]}" -- "${cur}") + + # compgen looses the escaping so we need to escape all completions again since they will + # all be inserted on the command-line. + IFS=$'\n' read -ra COMPREPLY -d '' < <(printf "%q\n" "${COMPREPLY[@]}") + ;; + + *) + # Type: complete (normal completion) + __sess_handle_standard_completion_case + ;; esac } -complete -F _sess sess + +__sess_handle_standard_completion_case() { + local tab=$'\t' + + # If there are no completions, we don't need to do anything + (( ${#completions[@]} == 0 )) && return 0 + + # Short circuit to optimize if we don't have descriptions + if [[ "${completions[*]}" != *$tab* ]]; then + # First, escape the completions to handle special characters + IFS=$'\n' read -ra completions -d '' < <(printf "%q\n" "${completions[@]}") + # Only consider the completions that match what the user typed + IFS=$'\n' read -ra COMPREPLY -d '' < <(IFS=$'\n'; compgen -W "${completions[*]}" -- "${cur}") + + # compgen looses the escaping so, if there is only a single completion, we need to + # escape it again because it will be inserted on the command-line. If there are multiple + # completions, we don't want to escape them because they will be printed in a list + # and we don't want to show escape characters in that list. + if (( ${#COMPREPLY[@]} == 1 )); then + COMPREPLY[0]=$(printf "%q" "${COMPREPLY[0]}") + fi + return 0 + fi + + local longest=0 + local compline + # Look for the longest completion so that we can format things nicely + while IFS='' read -r compline; do + [[ -z $compline ]] && continue + + # Before checking if the completion matches what the user typed, + # we need to strip any description and escape the completion to handle special + # characters because those escape characters are part of what the user typed. + # Don't call "printf" in a sub-shell because it will be much slower + # since we are in a loop. + printf -v comp "%q" "${compline%%$tab*}" &>/dev/null || comp=$(printf "%q" "${compline%%$tab*}") + + # Only consider the completions that match + [[ $comp == "$cur"* ]] || continue + + # The completions matches. Add it to the list of full completions including + # its description. We don't escape the completion because it may get printed + # in a list if there are more than one and we don't want show escape characters + # in that list. + COMPREPLY+=("$compline") + + # Strip any description before checking the length, and again, don't escape + # the completion because this length is only used when printing the completions + # in a list and we don't want show escape characters in that list. + comp=${compline%%$tab*} + if ((${#comp}>longest)); then + longest=${#comp} + fi + done < <(printf "%s\n" "${completions[@]}") + + # If there is a single completion left, remove the description text and escape any special characters + if ((${#COMPREPLY[*]} == 1)); then + __sess_debug "COMPREPLY[0]: ${COMPREPLY[0]}" + COMPREPLY[0]=$(printf "%q" "${COMPREPLY[0]%%$tab*}") + __sess_debug "Removed description from single completion, which is now: ${COMPREPLY[0]}" + else + # Format the descriptions + __sess_format_comp_descriptions $longest + fi +} + +__sess_handle_special_char() +{ + local comp="$1" + local char=$2 + if [[ "$comp" == *${char}* && "$COMP_WORDBREAKS" == *${char}* ]]; then + local word=${comp%"${comp##*${char}}"} + local idx=${#COMPREPLY[*]} + while ((--idx >= 0)); do + COMPREPLY[idx]=${COMPREPLY[idx]#"$word"} + done + fi +} + +__sess_format_comp_descriptions() +{ + local tab=$'\t' + local comp desc maxdesclength + local longest=$1 + + local i ci + for ci in ${!COMPREPLY[*]}; do + comp=${COMPREPLY[ci]} + # Properly format the description string which follows a tab character if there is one + if [[ "$comp" == *$tab* ]]; then + __sess_debug "Original comp: $comp" + desc=${comp#*$tab} + comp=${comp%%$tab*} + + # $COLUMNS stores the current shell width. + # Remove an extra 4 because we add 2 spaces and 2 parentheses. + maxdesclength=$(( COLUMNS - longest - 4 )) + + # Make sure we can fit a description of at least 8 characters + # if we are to align the descriptions. + if ((maxdesclength > 8)); then + # Add the proper number of spaces to align the descriptions + for ((i = ${#comp} ; i < longest ; i++)); do + comp+=" " + done + else + # Don't pad the descriptions so we can fit more text after the completion + maxdesclength=$(( COLUMNS - ${#comp} - 4 )) + fi + + # If there is enough space for any description text, + # truncate the descriptions that are too long for the shell width + if ((maxdesclength > 0)); then + if ((${#desc} > maxdesclength)); then + desc=${desc:0:$(( maxdesclength - 1 ))} + desc+="…" + fi + comp+=" ($desc)" + fi + COMPREPLY[ci]=$comp + __sess_debug "Final comp: $comp" + fi + done +} + +__start_sess() +{ + local cur prev words cword split + + COMPREPLY=() + + # Call _init_completion from the bash-completion package + # to prepare the arguments properly + if declare -F _init_completion >/dev/null 2>&1; then + _init_completion -n =: || return + else + __sess_init_completion -n =: || return + fi + + __sess_debug + __sess_debug "========= starting completion logic ==========" + __sess_debug "cur is ${cur}, words[*] is ${words[*]}, #words[@] is ${#words[@]}, cword is $cword" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $cword location, so we need + # to truncate the command-line ($words) up to the $cword location. + words=("${words[@]:0:$cword+1}") + __sess_debug "Truncated words[*]: ${words[*]}," + + local out directive + __sess_get_completion_results + __sess_process_completion_results +} + +if [[ $(type -t compopt) = "builtin" ]]; then + complete -o default -F __start_sess sess +else + complete -o default -o nospace -F __start_sess sess +fi + +# ex: ts=4 sw=4 et filetype=sh diff --git a/etc/wakeup b/etc/wakeup deleted file mode 100755 index 89dacc3..0000000 --- a/etc/wakeup +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# ~/.wakeup — macOS SleepWatcher hook -# Called automatically when the laptop wakes from sleep. -# -# Install SleepWatcher: -# brew install sleepwatcher -# cp wakeup ~/.wakeup -# chmod +x ~/.wakeup -# -# SleepWatcher will call this script on every wake. -# It reconnects to all previously active sess sessions. - -# Don't block wake — run in background -( - # Wait a few seconds for network to come up - sleep 3 - - # Check if sess is available - command -v sess &>/dev/null || exit 0 - - # Check if there are active sessions to reconnect to - SESS_DIR="${SESS_DIR:-$HOME/.sess}" - active_file="$SESS_DIR/active-sessions" - [[ -f "$active_file" ]] || exit 0 - [[ -s "$active_file" ]] || exit 0 - - # Reconnect to all previously active sessions - sess up -) & \ No newline at end of file diff --git a/etc/zsh-completion/_sess b/etc/zsh-completion/_sess index f25d08a..fa3fadd 100644 --- a/etc/zsh-completion/_sess +++ b/etc/zsh-completion/_sess @@ -1,67 +1,212 @@ #compdef sess +compdef _sess sess -_sess() { - local -a commands - commands=( - 'new:Create session and auto-attach' - 'rm:Remove session' - 'ls:List sessions' - 'diff:Git diff in session directory' - 'log:Activity log' - 'path:Print session cwd' - 'status:Session or overall status' - 'up:Reconnect to previously active sessions' - 'init:Provision a remote host (tmux+git+sess) and register it' - 'remote:Manage remote VM connections (add, ls, rm)' - 'doctor:Check prerequisites' - 'help:Show help' - 'version:Show version' - ) - - _sess_names() { - local sdir="${SESS_DIR:-$HOME/.sess}/sessions" - [[ -d "$sdir" ]] || return - for d in "$sdir"/*/; do - [[ -d "$d" ]] || continue - basename "$d" +# zsh completion for sess -*- shell-script -*- + +__sess_debug() +{ + local file="$BASH_COMP_DEBUG_FILE" + if [[ -n ${file} ]]; then + echo "$*" >> "${file}" + fi +} + +_sess() +{ + local shellCompDirectiveError=1 + local shellCompDirectiveNoSpace=2 + local shellCompDirectiveNoFileComp=4 + local shellCompDirectiveFilterFileExt=8 + local shellCompDirectiveFilterDirs=16 + local shellCompDirectiveKeepOrder=32 + + local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder + local -a completions + + __sess_debug "\n========= starting completion logic ==========" + __sess_debug "CURRENT: ${CURRENT}, words[*]: ${words[*]}" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CURRENT location, so we need + # to truncate the command-line ($words) up to the $CURRENT location. + # (We cannot use $CURSOR as its value does not work when a command is an alias.) + words=("${=words[1,CURRENT]}") + __sess_debug "Truncated words[*]: ${words[*]}," + + lastParam=${words[-1]} + lastChar=${lastParam[-1]} + __sess_debug "lastParam: ${lastParam}, lastChar: ${lastChar}" + + # For zsh, when completing a flag with an = (e.g., sess -n=) + # completions must be prefixed with the flag + setopt local_options BASH_REMATCH + if [[ "${lastParam}" =~ '-.*=' ]]; then + # We are dealing with a flag with an = + flagPrefix="-P ${BASH_REMATCH}" + fi + + # Prepare the command to obtain completions + requestComp="${words[1]} __complete ${words[2,-1]}" + if [ "${lastChar}" = "" ]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go completion code. + __sess_debug "Adding extra empty parameter" + requestComp="${requestComp} \"\"" + fi + + __sess_debug "About to call: eval ${requestComp}" + + # Use eval to handle any environment variables and such + out=$(eval ${requestComp} 2>/dev/null) + __sess_debug "completion output: ${out}" + + # Extract the directive integer following a : from the last line + local lastLine + while IFS='\n' read -r line; do + lastLine=${line} + done < <(printf "%s\n" "${out[@]}") + __sess_debug "last line: ${lastLine}" + + if [ "${lastLine[1]}" = : ]; then + directive=${lastLine[2,-1]} + # Remove the directive including the : and the newline + local suffix + (( suffix=${#lastLine}+2)) + out=${out[1,-$suffix]} + else + # There is no directive specified. Leave $out as is. + __sess_debug "No directive found. Setting do default" + directive=0 + fi + + __sess_debug "directive: ${directive}" + __sess_debug "completions: ${out}" + __sess_debug "flagPrefix: ${flagPrefix}" + + if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then + __sess_debug "Completion received error. Ignoring completions." + return + fi + + local activeHelpMarker="_activeHelp_ " + local endIndex=${#activeHelpMarker} + local startIndex=$((${#activeHelpMarker}+1)) + local hasActiveHelp=0 + while IFS='\n' read -r comp; do + # Check if this is an activeHelp statement (i.e., prefixed with $activeHelpMarker) + if [ "${comp[1,$endIndex]}" = "$activeHelpMarker" ];then + __sess_debug "ActiveHelp found: $comp" + comp="${comp[$startIndex,-1]}" + if [ -n "$comp" ]; then + compadd -x "${comp}" + __sess_debug "ActiveHelp will need delimiter" + hasActiveHelp=1 + fi + + continue + fi + + if [ -n "$comp" ]; then + # If requested, completions are returned with a description. + # The description is preceded by a TAB character. + # For zsh's _describe, we need to use a : instead of a TAB. + # We first need to escape any : as part of the completion itself. + comp=${comp//:/\\:} + + local tab="$(printf '\t')" + comp=${comp//$tab/:} + + __sess_debug "Adding completion: ${comp}" + completions+=${comp} + lastComp=$comp + fi + done < <(printf "%s\n" "${out[@]}") + + # Add a delimiter after the activeHelp statements, but only if: + # - there are completions following the activeHelp statements, or + # - file completion will be performed (so there will be choices after the activeHelp) + if [ $hasActiveHelp -eq 1 ]; then + if [ ${#completions} -ne 0 ] || [ $((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then + __sess_debug "Adding activeHelp delimiter" + compadd -x "--" + hasActiveHelp=0 + fi + fi + + if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then + __sess_debug "Activating nospace." + noSpace="-S ''" + fi + + if [ $((directive & shellCompDirectiveKeepOrder)) -ne 0 ]; then + __sess_debug "Activating keep order." + keepOrder="-V" + fi + + if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then + # File extension filtering + local filteringCmd + filteringCmd='_files' + for filter in ${completions[@]}; do + if [ ${filter[1]} != '*' ]; then + # zsh requires a glob pattern to do file filtering + filter="\*.$filter" + fi + filteringCmd+=" -g $filter" done - } - - if (( CURRENT == 2 )); then - local sessions - sessions=($(_sess_names)) - _describe 'command' commands - _describe 'session' sessions - elif (( CURRENT == 3 )); then - case "${words[2]}" in - new) - _arguments ':branch:_git_branches' '--remote[use a specific remote]' '--local[force local, skip remotes]' - ;; - rm|diff|log|path|status) - _arguments ':session:_values "sessions" $(_sess_names)' - ;; - init) - _arguments ':host:_ssh_hosts' - ;; - remote) - local -a remote_cmds - remote_cmds=(add ls rm) - _describe 'remote command' remote_cmds - ;; - esac - elif (( CURRENT >= 4 )); then - case "${words[2]}:${words[3]}" in - remote:add) - _arguments ':name:default' ':host:_ssh_hosts' - ;; - new:--remote) - local cfg="${SESS_DIR:-$HOME/.sess}/remote" - local -a remotes - [[ -f "$cfg" ]] && remotes=($(sed -n 's/^\([^.]*\)\.host=.*/\1/p' "$cfg" | sort -u)) - _describe 'remote' remotes - ;; - esac + filteringCmd+=" ${flagPrefix}" + + __sess_debug "File filtering command: $filteringCmd" + _arguments '*:filename:'"$filteringCmd" + elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then + # File completion for directories only + local subdir + subdir="${completions[1]}" + if [ -n "$subdir" ]; then + __sess_debug "Listing directories in $subdir" + pushd "${subdir}" >/dev/null 2>&1 + else + __sess_debug "Listing directories in ." + fi + + local result + _arguments '*:dirname:_files -/'" ${flagPrefix}" + result=$? + if [ -n "$subdir" ]; then + popd >/dev/null 2>&1 + fi + return $result + else + __sess_debug "Calling _describe" + if eval _describe $keepOrder "completions" completions $flagPrefix $noSpace; then + __sess_debug "_describe found some completions" + + # Return the success of having called _describe + return 0 + else + __sess_debug "_describe did not find completions." + __sess_debug "Checking if we should do file completion." + if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + __sess_debug "deactivating file completion" + + # We must return an error code here to let zsh know that there were no + # completions found by _describe; this is what will trigger other + # matching algorithms to attempt to find completions. + # For example zsh can match letters in the middle of words. + return 1 + else + # Perform file completion + __sess_debug "Activating file completion" + + # We must return the result of this command, so it must be the + # last command, or else we must store its result to return it. + _arguments '*:filename:_files'" ${flagPrefix}" + fi + fi fi } -_sess "$@" +# don't run the completion function when being source-ed or eval-ed +if [ "$funcstack[1]" = "_sess" ]; then + _sess +fi diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cb908d4 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module github.com/DeepakSilaych/sess + +go 1.26.5 + +require ( + charm.land/bubbletea/v2 v2.0.9 + charm.land/lipgloss/v2 v2.0.6 + github.com/charmbracelet/x/ansi v0.11.8 + github.com/spf13/cobra v1.10.2 + golang.org/x/term v0.46.0 +) + +require ( + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.48.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e44e7a8 --- /dev/null +++ b/go.sum @@ -0,0 +1,52 @@ +charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8= +charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..1854133 --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,97 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/DeepakSilaych/sess/internal/api" + "github.com/DeepakSilaych/sess/internal/backend" + "os" + "os/signal" + "syscall" + "time" +) + +func Run(args []string) int { + if len(args) == 1 && args[0] == "detach" { + z, e := backend.Open() + if e == nil { + e = z.Detach(context.Background()) + } + if e != nil { + fmt.Fprintln(os.Stderr, e) + return 1 + } + return 0 + } + if len(args) != 2 || (args[0] != "rpc" && args[0] != "attach") { + fmt.Fprintln(os.Stderr, "sess remote helper; use sess on your laptop (or sess detach here)") + return 2 + } + r, e := api.Decode(args[1]) + if e != nil { + fmt.Fprintln(os.Stderr, e) + return 40 + } + res := api.Response{Protocol: api.Protocol, Version: api.Version} + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGHUP, syscall.SIGTERM) + defer cancel() + if args[0] == "rpc" { + var stop context.CancelFunc + ctx, stop = context.WithTimeout(ctx, 15*time.Second) + defer stop() + } + z, e := backend.Open() + if e == nil { + res.Backend, e = z.Version(ctx) + } + if e == nil { + switch r.Action { + case "ping": + case "list": + res.Sessions, e = z.List(ctx) + case "create": + var s api.Session + s, e = z.Create(ctx, r.Name) + if e == nil { + res.Session = &s + } + case "find": + var s api.Session + s, e = z.Find(ctx, r.Name, r.ID) + if e == nil { + res.Session = &s + } + case "remove": + e = z.Remove(ctx, r.Name, r.ID) + case "attach": + if args[0] != "attach" { + e = fmt.Errorf("attachment requires a terminal request") + } else { + e = z.Attach(ctx, r.Name, r.ID) + } + default: + e = fmt.Errorf("unknown operation %q", r.Action) + } + } + if args[0] == "attach" { + if e != nil { + fmt.Fprintln(os.Stderr, e) + return 40 + } + return 0 + } + if e != nil { + var ae *api.Error + if errors.As(e, &ae) { + res.Error = ae + } else { + res.Error = &api.Error{Code: "remote", Message: e.Error()} + } + } + if e = json.NewEncoder(os.Stdout).Encode(res); e != nil { + return 40 + } + return 0 +} diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..a9b2565 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,95 @@ +// Package api defines the versioned SSH command contract shared by sess and its agent. +package api + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "regexp" + "strings" + "time" +) + +const Version = "0.6.0" +const Protocol = 1 +const ZMXVersion = "0.8.1" + +type Session struct { + Name string `json:"name"` + ID string `json:"id"` + Clients int `json:"clients"` + PID int `json:"pid"` + Created time.Time `json:"created"` + Directory string `json:"directory"` + Status string `json:"status"` +} +type Request struct { + Protocol int `json:"protocol"` + Action string `json:"action"` + Name string `json:"name,omitempty"` + ID string `json:"id,omitempty"` +} +type Response struct { + Protocol int `json:"protocol"` + Version string `json:"version"` + Backend string `json:"backend,omitempty"` + Sessions []Session `json:"sessions,omitempty"` + Session *Session `json:"session,omitempty"` + Error *Error `json:"error,omitempty"` +} +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` +} + +func (e *Error) Error() string { + if e.Hint != "" { + return e.Message + "\n\n" + e.Hint + } + return e.Message +} +func Fail(code, message, hint string) error { return &Error{code, message, hint} } + +var nameRE = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,47}$`) + +func ValidateName(s string) error { + if !nameRE.MatchString(s) { + return Fail("invalid_name", "Invalid session name: "+s, "Use 1–48 letters, numbers, hyphens or underscores; begin with a letter or number.") + } + return nil +} + +// Only SSH destinations, never options, URIs, port suffixes or shell fragments. +var hostRE = regexp.MustCompile(`^(?:[a-zA-Z0-9_][a-zA-Z0-9_.-]*@)?(?:[a-zA-Z0-9_][a-zA-Z0-9_.-]*|\[[a-fA-F0-9:]+\])$`) + +func ValidateHost(s string) error { + if len(s) > 253 || !hostRE.MatchString(s) { + return Fail("invalid_host", "Invalid SSH destination: "+s, "Use user@host or an SSH alias. Configure ports and keys in ~/.ssh/config.") + } + return nil +} +func Encode(r Request) string { + r.Protocol = Protocol + b, _ := json.Marshal(r) + return base64.RawURLEncoding.EncodeToString(b) +} +func Decode(s string) (Request, error) { + var r Request + if len(s) > 4096 { + return r, fmt.Errorf("request too large") + } + b, e := base64.RawURLEncoding.DecodeString(s) + if e != nil { + return r, e + } + d := json.NewDecoder(strings.NewReader(string(b))) + d.DisallowUnknownFields() + if e = d.Decode(&r); e != nil { + return r, e + } + if r.Protocol != Protocol { + return r, Fail("protocol", "Incompatible sess agent.", "Run sess init to update the remote helper.") + } + return r, nil +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..92b1aae --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,30 @@ +package api + +import "testing" + +func TestValidation(t *testing.T) { + for _, s := range []string{"dev", "user@dev.example.com", "user@[2001:db8::1]"} { + if e := ValidateHost(s); e != nil { + t.Errorf("%s: %v", s, e) + } + } + for _, s := range []string{"-oProxyCommand=id", "user:host", "dev;touch /tmp/x", "user@host\n", "$(id)", "dev host", "ssh://dev"} { + if ValidateHost(s) == nil { + t.Errorf("accepted unsafe host %q", s) + } + } + for _, s := range []string{"../bad", "a/b", "-flag", "a;b", "name with spaces", ""} { + if ValidateName(s) == nil { + t.Errorf("accepted name %q", s) + } + } +} +func TestWireRoundTrip(t *testing.T) { + r, e := Decode(Encode(Request{Action: "create", Name: "build-42"})) + if e != nil || r.Name != "build-42" || r.Protocol != Protocol { + t.Fatalf("%+v %v", r, e) + } + if _, e = Decode("e30"); e == nil { + t.Fatal("accepted missing protocol") + } +} diff --git a/internal/backend/integration_test.go b/internal/backend/integration_test.go new file mode 100644 index 0000000..924cf5a --- /dev/null +++ b/internal/backend/integration_test.go @@ -0,0 +1,47 @@ +package backend + +import ( + "context" + "os" + "testing" +) + +// Optional native-backend check, in addition to the real SSH Docker suite. +func TestNativeZMX(t *testing.T) { + bin := os.Getenv("SESS_TEST_ZMX") + if bin == "" { + t.Skip("set SESS_TEST_ZMX to a verified zmx 0.8.1 executable") + } + dir, e := os.MkdirTemp("/tmp", "sess-native-") + if e != nil { + t.Fatal(e) + } + defer os.RemoveAll(dir) + t.Setenv("HOME", dir) + t.Setenv("SHELL", "/bin/sh") + z := &ZMX{Binary: bin, Runtime: dir, Home: dir} + ctx := context.Background() + if _, e = z.Version(ctx); e != nil { + t.Fatal(e) + } + s, e := z.Create(ctx, "native-test") + if e != nil { + t.Fatal(e) + } + defer z.Remove(ctx, s.Name, s.ID) + if s.ID == "" || s.Clients != 0 { + t.Fatalf("unexpected created state: %+v", s) + } + if _, e = z.Create(ctx, "native-test"); e == nil { + t.Fatal("duplicate creation succeeded") + } + if _, e = z.Find(ctx, s.Name, "wrong-identity"); e == nil { + t.Fatal("reattached a replacement") + } + if e = z.Remove(ctx, s.Name, s.ID); e != nil { + t.Fatal(e) + } + if ss, e := z.List(ctx); e != nil || len(ss) != 0 { + t.Fatal(ss, e) + } +} diff --git a/internal/backend/zmx.go b/internal/backend/zmx.go new file mode 100644 index 0000000..3b9b01b --- /dev/null +++ b/internal/backend/zmx.go @@ -0,0 +1,259 @@ +// Package backend isolates sess from zmx's command and runtime details. +package backend + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "github.com/DeepakSilaych/sess/internal/api" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +type ZMX struct{ Binary, Runtime, Home string } + +func Open() (*ZMX, error) { + h, e := os.UserHomeDir() + if e != nil { + return nil, e + } + bin := os.Getenv("SESS_ZMX") + if bin == "" { + bin = filepath.Join(h, ".local/share/sess/bin/zmx") + } + runtime := os.Getenv("SESS_RUNTIME_DIR") + if runtime == "" { + sum := sha256.Sum256([]byte(h)) + runtime = fmt.Sprintf("/tmp/sess-%d-%x", os.Getuid(), sum[:4]) + } + if e = os.MkdirAll(runtime, 0700); e != nil { + return nil, e + } + st, e := os.Lstat(runtime) + if e != nil { + return nil, e + } + if !st.IsDir() || st.Mode()&os.ModeSymlink != 0 || st.Mode().Perm()&0077 != 0 { + return nil, fmt.Errorf("sess runtime must be a private directory: %s", runtime) + } + if stat, ok := st.Sys().(*syscall.Stat_t); ok && stat.Uid != uint32(os.Getuid()) { + return nil, fmt.Errorf("sess runtime has a different owner") + } + return &ZMX{bin, runtime, h}, nil +} +func (z *ZMX) Env(name string) []string { + env := []string{} + for _, v := range os.Environ() { + if strings.HasPrefix(v, "ZMX_") || strings.HasPrefix(v, "SESS_SESSION=") || strings.HasPrefix(v, "SESS_RUNTIME_DIR=") || strings.HasPrefix(v, "PATH=") { + continue + } + env = append(env, v) + } + env = append(env, "PATH="+filepath.Join(z.Home, ".local/share/sess/bin")+":"+os.Getenv("PATH"), "ZMX_DIR="+z.Runtime, "ZMX_DIR_MODE=0700", "ZMX_LOG_MODE=0600", "SESS_RUNTIME_DIR="+z.Runtime) + if os.Getenv("ZMX_NO_DETACH_KEY") != "" { + env = append(env, "ZMX_NO_DETACH_KEY=1") + } + if name != "" { + env = append(env, "SESS_SESSION="+name) + } + return env +} +func (z *ZMX) command(ctx context.Context, args ...string) *exec.Cmd { + c := exec.CommandContext(ctx, z.Binary, args...) + c.Env = z.Env("") + c.Dir = z.Home + return c +} +func (z *ZMX) Version(ctx context.Context) (string, error) { + b, e := z.command(ctx, "version").CombinedOutput() + if e != nil { + return "", api.Fail("backend", "zmx is not available.", "Run sess init to prepare this VM.") + } + line := strings.Split(strings.TrimSpace(string(b)), "\n")[0] + // Pin the CLI output contract; never operate on an untested IPC version. + fields := strings.Fields(line) + found := false + for _, f := range fields { + if strings.TrimPrefix(f, "v") == api.ZMXVersion { + found = true + } + } + if !found { + return line, api.Fail("backend_version", "Unsupported zmx version: "+line, "sess requires zmx "+api.ZMXVersion+". Keep existing sessions running and update after finishing them.") + } + return line, nil +} +func ParseList(raw string) ([]api.Session, error) { + sessions := []api.Session{} + for _, line := range strings.Split(strings.TrimSpace(raw), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + values := map[string]string{} + for _, part := range strings.Split(line, "\t") { + k, v, ok := strings.Cut(strings.TrimSpace(part), "=") + if ok { + values[k] = v + } + } + name := values["name"] + if e := api.ValidateName(name); e != nil { + return nil, fmt.Errorf("unexpected zmx list output") + } + if values["err"] != "" { + return nil, fmt.Errorf("zmx session %s is unreachable: %s", name, values["err"]) + } + clients, e := strconv.Atoi(values["clients"]) + if e != nil { + return nil, fmt.Errorf("invalid zmx client count") + } + pid, e := strconv.Atoi(values["pid"]) + if e != nil { + return nil, e + } + created, e := strconv.ParseInt(values["created"], 10, 64) + if e != nil { + return nil, e + } + status := "detached" + if clients > 0 { + status = "attached" + } + directory := values["cwd"] + if strings.HasPrefix(directory, "file://") { + if u, e := url.Parse(directory); e == nil { + directory = u.Path + } + } + sessions = append(sessions, api.Session{Name: name, ID: values["sess_id"], Clients: clients, PID: pid, Created: time.Unix(created, 0).UTC(), Directory: directory, Status: status}) + } + sort.Slice(sessions, func(i, j int) bool { return sessions[i].Name < sessions[j].Name }) + return sessions, nil +} +func (z *ZMX) List(ctx context.Context) ([]api.Session, error) { + c := z.command(ctx, "list") + var stderr strings.Builder + c.Stderr = &stderr + b, e := c.Output() + if e != nil { + return nil, fmt.Errorf("list sessions: %s: %w", strings.TrimSpace(stderr.String()), e) + } + return ParseList(string(b)) +} +func (z *ZMX) Find(ctx context.Context, name, id string) (api.Session, error) { + if e := api.ValidateName(name); e != nil { + return api.Session{}, e + } + ss, e := z.List(ctx) + if e != nil { + return api.Session{}, e + } + for _, s := range ss { + if s.Name == name { + if id != "" && id != s.ID { + return s, api.Fail("replaced", "Session was replaced: "+name, "Run sess attach "+name+" to connect to the new session explicitly.") + } + return s, nil + } + } + return api.Session{}, api.Fail("not_found", "Session not found: "+name, "Run sess ls to see sessions, or sess new "+name+" to create one.") +} +func (z *ZMX) lock(name string) (func(), error) { + if e := api.ValidateName(name); e != nil { + return nil, e + } + f, e := os.OpenFile(filepath.Join(z.Runtime, "."+name+".lock"), os.O_CREATE|os.O_RDWR, 0600) + if e != nil { + return nil, e + } + if e = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); e != nil { + f.Close() + return nil, api.Fail("busy", "Session operation already in progress: "+name, "Try again in a moment.") + } + return func() { syscall.Flock(int(f.Fd()), syscall.LOCK_UN); f.Close() }, nil +} +func (z *ZMX) Create(ctx context.Context, name string) (api.Session, error) { + unlock, e := z.lock(name) + if e != nil { + return api.Session{}, e + } + defer unlock() + ss, e := z.List(ctx) + if e != nil { + return api.Session{}, e + } + for _, s := range ss { + if s.Name == name { + return s, api.Fail("exists", "Session already exists: "+name, "Use sess attach "+name+".") + } + } + var id [16]byte + if _, e = rand.Read(id[:]); e != nil { + return api.Session{}, e + } + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/sh" + } + c := z.command(ctx, "attach", "--labels", "sess_id="+hex.EncodeToString(id[:]), name, shell, "-i") + c.Env = z.Env(name) + // zmx supports a non-TTY client. An explicit detach byte leaves the newly + // created PTY alive, so a dropped SSH connection cannot create it twice. + c.Stdin = strings.NewReader("\x1c") + c.Stdout = io.Discard + var stderr strings.Builder + c.Stderr = &stderr + if e = c.Run(); e != nil { + return api.Session{}, fmt.Errorf("create session: %s: %w", stderr.String(), e) + } + return z.Find(ctx, name, "") +} +func (z *ZMX) Remove(ctx context.Context, name, id string) error { + unlock, e := z.lock(name) + if e != nil { + return e + } + defer unlock() + if _, e = z.Find(ctx, name, id); e != nil { + return e + } + b, e := z.command(ctx, "kill", name).CombinedOutput() + if e != nil { + return fmt.Errorf("remove session: %s: %w", string(b), e) + } + return nil +} +func (z *ZMX) Attach(ctx context.Context, name, id string) error { + if _, e := z.Find(ctx, name, id); e != nil { + return e + } + // zmx attach is an upsert. If the session exits after the check, /usr/bin/false + // prevents the backend from accidentally opening a replacement shell. + c := z.command(ctx, "attach", name, "/usr/bin/false") + c.Env = z.Env(name) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return c.Run() +} +func (z *ZMX) Detach(ctx context.Context) error { + name := os.Getenv("SESS_SESSION") + if e := api.ValidateName(name); e != nil { + return api.Fail("outside_session", "Not inside a sess session.", "Press Ctrl+\\ in an attached terminal, or run sess detach inside its shell.") + } + c := z.command(ctx, "detach") + c.Env = append(z.Env(name), "ZMX_SESSION="+name) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return c.Run() +} diff --git a/internal/backend/zmx_test.go b/internal/backend/zmx_test.go new file mode 100644 index 0000000..e743f52 --- /dev/null +++ b/internal/backend/zmx_test.go @@ -0,0 +1,65 @@ +package backend + +import ( + "context" + "errors" + "github.com/DeepakSilaych/sess/internal/api" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseList(t *testing.T) { + ss, e := ParseList("name=work\tpid=42\tclients=0\tcreated=1700000000\tcwd=/home/user/project with spaces\tsess_id=abc\nname=api\tpid=43\tclients=2\tcreated=1700000001\tcwd=/root\tsess_id=def\n") + if e != nil || len(ss) != 2 { + t.Fatal(ss, e) + } + if ss[0].Name != "api" || ss[0].Status != "attached" || ss[1].Directory != "/home/user/project with spaces" { + t.Fatalf("%+v", ss) + } + if _, e = ParseList("name=work\terr=Timeout\tstatus=unreachable"); e == nil { + t.Fatal("unreachable session treated as absent") + } + if _, e = ParseList("name=bad\tclients=no"); e == nil { + t.Fatal("invalid backend contract accepted") + } +} +func TestFindIdentity(t *testing.T) { + d := t.TempDir() + bin := filepath.Join(d, "zmx") + if e := os.WriteFile(bin, []byte("#!/bin/sh\nprintf 'name=work\\tpid=42\\tclients=0\\tcreated=1700000000\\tsess_id=new\\n'\n"), 0700); e != nil { + t.Fatal(e) + } + z := &ZMX{bin, d, d} + _, e := z.Find(context.Background(), "work", "old") + var ae *api.Error + if !errors.As(e, &ae) || ae.Code != "replaced" { + t.Fatal(e) + } + _, e = z.Find(context.Background(), "missing", "") + if !errors.As(e, &ae) || ae.Code != "not_found" { + t.Fatal(e) + } +} +func TestPrivateRuntime(t *testing.T) { + d := t.TempDir() + p := filepath.Join(d, "public") + os.Mkdir(p, 0755) + t.Setenv("SESS_RUNTIME_DIR", p) + if _, e := Open(); e == nil { + t.Fatal("accepted shared runtime") + } +} +func TestEnvironmentIsolation(t *testing.T) { + t.Setenv("ZMX_SESSION", "unrelated") + t.Setenv("ZMX_DIR", "/other") + z := &ZMX{Runtime: "/private", Home: "/home/test"} + env := strings.Join(z.Env("api"), "\n") + if strings.Contains(env, "ZMX_SESSION=") || strings.Contains(env, "ZMX_DIR=/other") { + t.Fatal(env) + } + if !strings.Contains(env, "SESS_SESSION=api") { + t.Fatal(env) + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..34c4d59 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,302 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/DeepakSilaych/sess/internal/api" + "github.com/DeepakSilaych/sess/internal/backend" + "github.com/DeepakSilaych/sess/internal/provision" + "github.com/DeepakSilaych/sess/internal/store" + "github.com/DeepakSilaych/sess/internal/transport" + "github.com/DeepakSilaych/sess/internal/tui" + "github.com/spf13/cobra" + "golang.org/x/term" + "io" + "os" + "os/signal" + "strings" + "text/tabwriter" + "time" +) + +func New(out, errout io.Writer) *cobra.Command { + var host string + root := &cobra.Command{Use: "sess", Short: "Persistent SSH terminals, powered by zmx", Long: "sess keeps named terminals running on your VM.\nCreate a session, detach, and return to the same shell later.", SilenceUsage: true, SilenceErrors: true} + root.SetOut(out) + root.SetErr(errout) + root.PersistentFlags().StringVarP(&host, "host", "h", "", "SSH destination or alias (overrides the saved default)") + root.PersistentFlags().Bool("help", false, "Show help") + root.SetHelpCommand(&cobra.Command{Use: "help [command]", Short: "Show help for a command", RunE: func(c *cobra.Command, args []string) error { + target, _, e := root.Find(args) + if e != nil { + return e + } + return target.Help() + }}) + client := func() (*transport.Client, error) { + h, e := store.Resolve(host) + if e != nil { + return nil, e + } + return transport.New(h) + } + interactive := func() bool { return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) } + attach := func(ctx context.Context, c *transport.Client, s api.Session) error { + fmt.Fprintf(errout, "Attaching to %s / %s · Ctrl+\\ to detach\n", c.Host, s.Name) + e := c.Attach(ctx, s, errout) + if e == nil { + fmt.Fprintf(errout, "\nConnection ended · %s / %s\n", c.Host, s.Name) + } + return e + } + root.RunE = func(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return fmt.Errorf("unknown command %q; run sess --help", args[0]) + } + c, e := client() + if e != nil { + return e + } + if !interactive() { + ss, e := c.List(cmd.Context()) + if e != nil { + return e + } + printList(out, c.Host, ss, false, false) + return nil + } + if os.Getenv("ZMX_SESSION") != "" { + return fmt.Errorf("detach from your current persistent terminal before opening another attachment") + } + lastError := "" + for { + choice, e := tui.Run(cmd.Context(), c.Host, lastError) + if e != nil { + return e + } + if choice.Session == nil { + return nil + } + c, _ = transport.New(choice.Host) + lastError = "" + if e = attach(cmd.Context(), c, *choice.Session); e != nil { + if cmd.Context().Err() != nil { + return e + } + lastError = e.Error() + } + } + } + root.AddCommand(&cobra.Command{Use: "init ", Short: "Prepare sess and zmx on a VM", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if host != "" { + return fmt.Errorf("init takes its host as an argument; use sess init %s", host) + } + c, e := transport.New(args[0]) + if e != nil { + return e + } + return provision.Init(cmd.Context(), c, out) + }}) + root.AddCommand(&cobra.Command{Use: "set --host ", Short: "Save the default SSH host", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + if host == "" { + return fmt.Errorf("provide a destination: sess set --host ") + } + if e := store.SetHost(host); e != nil { + return e + } + fmt.Fprintf(out, "Default host: %s\n", host) + return nil + }}) + var detached bool + newcmd := &cobra.Command{Use: "new ", Aliases: []string{"n"}, Short: "Create a persistent terminal and attach", Args: cobra.ExactArgs(1), Example: " sess new api\n sess n tests -h dev\n sess new build --detach", RunE: func(cmd *cobra.Command, args []string) error { + if e := api.ValidateName(args[0]); e != nil { + return e + } + if !detached && !interactive() { + return fmt.Errorf("attachment requires an interactive terminal; use sess new %s --detach", args[0]) + } + if !detached && os.Getenv("ZMX_SESSION") != "" { + return fmt.Errorf("detach from your current persistent terminal first") + } + c, e := client() + if e != nil { + return e + } + s, e := c.Create(cmd.Context(), args[0]) + if e != nil { + return e + } + fmt.Fprintf(out, "Created %s / %s\n", c.Host, s.Name) + if detached { + return nil + } + return attach(cmd.Context(), c, s) + }} + newcmd.Flags().BoolVarP(&detached, "detach", "d", false, "Create without attaching (for scripts)") + root.AddCommand(newcmd) + root.AddCommand(&cobra.Command{Use: "attach ", Aliases: []string{"a"}, Short: "Attach to an existing terminal", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if e := api.ValidateName(args[0]); e != nil { + return e + } + if !interactive() { + return fmt.Errorf("attach requires an interactive terminal") + } + if os.Getenv("ZMX_SESSION") != "" { + return fmt.Errorf("detach from your current persistent terminal first") + } + c, e := client() + if e != nil { + return e + } + s, e := c.Find(cmd.Context(), args[0]) + if e != nil { + return e + } + return attach(cmd.Context(), c, s) + }}) + root.AddCommand(&cobra.Command{Use: "remove ", Aliases: []string{"rm"}, Short: "Terminate a session and its programs", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if e := api.ValidateName(args[0]); e != nil { + return e + } + c, e := client() + if e != nil { + return e + } + s, e := c.Find(cmd.Context(), args[0]) + if e != nil { + return e + } + fmt.Fprintf(errout, "Removing %s / %s\n", c.Host, s.Name) + if e = c.Remove(cmd.Context(), s); e != nil { + return e + } + fmt.Fprintf(out, "Removed %s\n", s.Name) + return nil + }}) + var jsonOut, quiet bool + ls := &cobra.Command{Use: "ls", Short: "List live sessions on the selected VM", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + if jsonOut && quiet { + return fmt.Errorf("choose either --json or --quiet") + } + c, e := client() + if e != nil { + return e + } + ss, e := c.List(cmd.Context()) + if e != nil { + return e + } + return printList(out, c.Host, ss, jsonOut, quiet) + }} + ls.Flags().BoolVar(&jsonOut, "json", false, "Print structured JSON") + ls.Flags().BoolVarP(&quiet, "quiet", "q", false, "Print session names only") + root.AddCommand(ls) + root.AddCommand(&cobra.Command{Use: "detach", Short: "Inside a session, detach all of its clients", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + if host != "" { + return fmt.Errorf("detach runs inside the current session and accepts no host") + } + z, e := backend.Open() + if e != nil { + return e + } + return z.Detach(cmd.Context()) + }}) + root.AddCommand(&cobra.Command{Use: "doctor", Short: "Check SSH and remote session support", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + c, e := client() + if e != nil { + return e + } + fmt.Fprintf(out, "Host %s\nClient sess %s\n", c.Host, api.Version) + r, e := c.Request(cmd.Context(), api.Request{Action: "ping"}) + if e != nil { + return e + } + fmt.Fprintf(out, "Remote sess %s\nBackend %s\nProtocol %d\n\nReady for persistent terminals.\n", r.Version, r.Backend, r.Protocol) + return nil + }}) + root.AddCommand(&cobra.Command{Use: "version", Short: "Show the installed version", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { fmt.Fprintf(out, "sess %s\n", api.Version) }}) + completion := &cobra.Command{Use: "completion ", Short: "Generate shell completions", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return root.GenBashCompletionV2(out, true) + case "zsh": + return root.GenZshCompletion(out) + case "fish": + return root.GenFishCompletion(out, true) + default: + return fmt.Errorf("choose bash, zsh or fish") + } + }} + root.AddCommand(completion) + for _, cmd := range root.Commands() { + if cmd.Name() == "attach" || cmd.Name() == "remove" { + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + c, e := client() + if e != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + ctx, cancel := context.WithTimeout(cmd.Context(), 2*time.Second) + defer cancel() + ss, e := c.List(ctx) + if e != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + names := []string{} + for _, s := range ss { + if strings.HasPrefix(s.Name, toComplete) { + names = append(names, s.Name) + } + } + return names, cobra.ShellCompDirectiveNoFileComp + } + } + } + root.RegisterFlagCompletionFunc("host", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + c, _ := store.Load() + return c.Hosts, cobra.ShellCompDirectiveNoFileComp + }) + return root +} +func printList(out io.Writer, host string, ss []api.Session, jsonOut, quiet bool) error { + if jsonOut { + return json.NewEncoder(out).Encode(struct { + Host string `json:"host"` + Sessions []api.Session `json:"sessions"` + }{host, ss}) + } + if quiet { + for _, s := range ss { + fmt.Fprintln(out, s.Name) + } + return nil + } + fmt.Fprintf(out, "Host: %s\n\n", host) + if len(ss) == 0 { + fmt.Fprintln(out, "No sessions yet. Start one with: sess new ") + return nil + } + w := tabwriter.NewWriter(out, 0, 4, 3, ' ', 0) + fmt.Fprintln(w, "NAME\tSTATE\tCLIENTS\tAGE\tDIRECTORY") + for _, s := range ss { + fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\n", s.Name, s.Status, s.Clients, tui.Age(s.Created), tui.Clean(s.Directory)) + } + return w.Flush() +} +func Main() int { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + if e := New(os.Stdout, os.Stderr).ExecuteContext(ctx); e != nil { + if errors.Is(e, context.Canceled) { + fmt.Fprintln(os.Stderr, "\nDisconnected. Remote sessions are unchanged.") + return 130 + } + fmt.Fprintln(os.Stderr, "Error: "+e.Error()) + return 1 + } + return 0 +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..2ca99fd --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,51 @@ +package cli + +import ( + "bytes" + "path/filepath" + "strings" + "testing" +) + +func TestHelpAndHostFlag(t *testing.T) { + t.Setenv("SESS_CONFIG", filepath.Join(t.TempDir(), "config.json")) + for _, args := range [][]string{{"--help"}, {"new", "--help"}, {"attach", "--help"}} { + var b bytes.Buffer + c := New(&b, &b) + c.SetArgs(args) + if e := c.Execute(); e != nil { + t.Fatal(e) + } + s := b.String() + if !strings.Contains(s, "--host") || !strings.Contains(s, "--help") { + t.Fatal(s) + } + } + var b bytes.Buffer + c := New(&b, &b) + c.SetArgs([]string{"set", "-h", "dev"}) + if e := c.Execute(); e != nil { + t.Fatal(e) + } + if !strings.Contains(b.String(), "Default host: dev") { + t.Fatal(b.String()) + } +} +func TestAliases(t *testing.T) { + r := New(&bytes.Buffer{}, &bytes.Buffer{}) + for a, w := range map[string]string{"n": "new", "a": "attach", "rm": "remove"} { + c, _, e := r.Find([]string{a}) + if e != nil || c.Name() != w { + t.Fatal(a, e) + } + } +} +func TestNonTTYNewDoesNotMutate(t *testing.T) { + t.Setenv("SESS_CONFIG", filepath.Join(t.TempDir(), "config")) + r := New(&bytes.Buffer{}, &bytes.Buffer{}) + r.SetArgs([]string{"new", "work", "-h", "unreachable"}) + e := r.Execute() + if e == nil || !strings.Contains(e.Error(), "--detach") { + t.Fatal(e) + } +} diff --git a/internal/provision/assets/README b/internal/provision/assets/README new file mode 100644 index 0000000..4368bcd --- /dev/null +++ b/internal/provision/assets/README @@ -0,0 +1 @@ +Generated compressed sess-agent binaries are embedded here by scripts/build-agents.sh. diff --git a/internal/provision/provision.go b/internal/provision/provision.go new file mode 100644 index 0000000..b5dc371 --- /dev/null +++ b/internal/provision/provision.go @@ -0,0 +1,197 @@ +package provision + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "embed" + "fmt" + "github.com/DeepakSilaych/sess/internal/api" + "github.com/DeepakSilaych/sess/internal/transport" + "io" + "net/http" + "os" + "path" + "strings" + "time" +) + +//go:embed assets/* +var assets embed.FS +var digests = map[string]string{ + "linux-arm64": "943eb44c812333fd450da12097521afd3339436e86f8c2ac618b905c4c9ece68", + "linux-amd64": "dfd75720b942466f28870731cc86dbc07afa72fb8f3bd5eeb4ff707e4eecebe8", + "darwin-arm64": "1d86b1c9fba47fa707a6f0e976b20510b07c1c26d0ed010b9414b2a2c5e6beef", + "darwin-amd64": "3208578ad91d8a62077772dc8a1369a92033d9e84169bac673ef8542b6ff9707", +} + +func Platform(raw string) (string, error) { + f := strings.Fields(raw) + if len(f) != 2 { + return "", fmt.Errorf("cannot detect remote platform (shell startup must not print output)") + } + osname := map[string]string{"Linux": "linux", "Darwin": "darwin"}[f[0]] + arch := map[string]string{"aarch64": "arm64", "arm64": "arm64", "x86_64": "amd64"}[f[1]] + p := osname + "-" + arch + if _, ok := digests[p]; !ok { + return "", fmt.Errorf("unsupported VM platform: %s %s", f[0], f[1]) + } + return p, nil +} +func Agent(platform string) ([]byte, error) { + data, e := assets.ReadFile("assets/" + platform + ".gz") + if e != nil { + return nil, fmt.Errorf("this sess build has no %s remote helper; build with make build or use a release archive", platform) + } + r, e := gzip.NewReader(bytes.NewReader(data)) + if e != nil { + return nil, e + } + defer r.Close() + return io.ReadAll(io.LimitReader(r, 32<<20)) +} +func Backend(ctx context.Context, platform string) ([]byte, error) { + p := strings.Split(platform, "-") + osname := p[0] + if osname == "darwin" { + osname = "macos" + } + arch := "x86_64" + if p[1] == "arm64" { + arch = "aarch64" + } + url := fmt.Sprintf("https://github.com/neurosnap/zmx/releases/download/v%s/zmx-%s-%s-%s.tar.gz", api.ZMXVersion, api.ZMXVersion, osname, arch) + req, e := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if e != nil { + return nil, e + } + client := http.Client{Timeout: 90 * time.Second} + res, e := client.Do(req) + if e != nil { + return nil, e + } + defer res.Body.Close() + if res.StatusCode != 200 { + return nil, fmt.Errorf("download zmx: %s", res.Status) + } + data, e := io.ReadAll(io.LimitReader(res.Body, 64<<20)) + if e != nil { + return nil, e + } + if fmt.Sprintf("%x", sha256.Sum256(data)) != digests[platform] { + return nil, fmt.Errorf("zmx archive checksum does not match pinned release") + } + return ExtractZMX(data) +} +func ExtractZMX(data []byte) ([]byte, error) { + gz, e := gzip.NewReader(bytes.NewReader(data)) + if e != nil { + return nil, e + } + defer gz.Close() + tr := tar.NewReader(gz) + for { + h, e := tr.Next() + if e == io.EOF { + break + } + if e != nil { + return nil, e + } + if path.Base(h.Name) == "zmx" && h.Typeflag == tar.TypeReg { + if h.Size > 32<<20 { + return nil, fmt.Errorf("zmx binary too large") + } + return io.ReadAll(io.LimitReader(tr, 32<<20)) + } + } + return nil, fmt.Errorf("zmx archive contains no executable") +} +func bundle(agent, zmx []byte) ([]byte, error) { + var b bytes.Buffer + tw := tar.NewWriter(&b) + for _, f := range []struct { + name string + data []byte + }{{"sess", agent}, {"zmx", zmx}} { + if e := tw.WriteHeader(&tar.Header{Name: f.name, Mode: 0755, Size: int64(len(f.data))}); e != nil { + return nil, e + } + if _, e := tw.Write(f.data); e != nil { + return nil, e + } + } + if e := tw.Close(); e != nil { + return nil, e + } + return b.Bytes(), nil +} + +const installScript = `set -eu +umask 077 +base="$HOME/.local/share/sess" +mkdir -p "$base/bin" "$HOME/.local/bin" +staging=$(mktemp -d "$base/.install.XXXXXXXX") +trap 'rm -rf "$staging"' EXIT HUP INT TERM +tar -xf - -C "$staging" +if [ -x "$base/bin/zmx" ]; then + if ! "$base/bin/zmx" version | head -n 1 | grep -Eq '(^|[[:space:]])v?0[.]8[.]1([[:space:]]|$)'; then + echo 'Existing sess zmx version differs. Finish active sessions before changing the backend.' >&2 + exit 1 + fi +else + mv "$staging/zmx" "$base/bin/zmx" +fi +mv "$staging/sess" "$base/bin/sess" +# Never overwrite a separately installed sess command. +if [ ! -e "$HOME/.local/bin/sess" ] && [ ! -L "$HOME/.local/bin/sess" ]; then + ln -s "$base/bin/sess" "$HOME/.local/bin/sess" +fi +` + +func Init(ctx context.Context, c *transport.Client, out io.Writer) error { + fmt.Fprintf(out, "[1/4] Connecting to %s\n", c.Host) + cmd := c.Command(ctx, false, false, "uname -s; uname -m") + cmd.Stdin = os.Stdin + cmd.Stderr = out + raw, e := cmd.Output() + if e != nil { + return fmt.Errorf("SSH check failed: %w", e) + } + p, e := Platform(string(raw)) + if e != nil { + return e + } + fmt.Fprintf(out, "[2/4] Preparing %s helper and zmx %s\n", p, api.ZMXVersion) + agent, e := Agent(p) + if e != nil { + return e + } + zmx, e := Backend(ctx, p) + if e != nil { + return e + } + archive, e := bundle(agent, zmx) + if e != nil { + return e + } + fmt.Fprintln(out, "[3/4] Installing for your remote account") + cmd = c.Command(ctx, false, true, "sh -c "+shellQuote(installScript)) + cmd.Stdin = bytes.NewReader(archive) + cmd.Stdout = out + cmd.Stderr = out + if e = cmd.Run(); e != nil { + return fmt.Errorf("remote installation failed: %w", e) + } + fmt.Fprintln(out, "[4/4] Verifying remote session support") + _, e = c.Request(ctx, api.Request{Action: "ping"}) + if e != nil { + return e + } + fmt.Fprintf(out, "\nReady: %s\n\nSet your default host:\n sess set --host %s\n", c.Host, c.Host) + return nil +} + +func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" } diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go new file mode 100644 index 0000000..b66f661 --- /dev/null +++ b/internal/provision/provision_test.go @@ -0,0 +1,33 @@ +package provision + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "testing" +) + +func TestPlatform(t *testing.T) { + for raw, want := range map[string]string{"Linux\nx86_64\n": "linux-amd64", "Darwin\narm64\n": "darwin-arm64"} { + got, e := Platform(raw) + if got != want || e != nil { + t.Fatal(got, e) + } + } + if _, e := Platform("Welcome!\nLinux\nx86_64"); e == nil { + t.Fatal("startup noise accepted") + } +} +func TestExtractNoPathTraversal(t *testing.T) { + var b bytes.Buffer + gz := gzip.NewWriter(&b) + tw := tar.NewWriter(gz) + tw.WriteHeader(&tar.Header{Name: "../../zmx", Typeflag: tar.TypeReg, Mode: 0755, Size: 3}) + tw.Write([]byte("bin")) + tw.Close() + gz.Close() + data, e := ExtractZMX(b.Bytes()) + if e != nil || string(data) != "bin" { + t.Fatal(e) + } /* extracted to memory, never uses archive path */ +} diff --git a/internal/store/config.go b/internal/store/config.go new file mode 100644 index 0000000..3238dab --- /dev/null +++ b/internal/store/config.go @@ -0,0 +1,114 @@ +package store + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/DeepakSilaych/sess/internal/api" + "os" + "path/filepath" +) + +type Config struct { + Version int `json:"version"` + Host string `json:"host"` + Hosts []string `json:"hosts,omitempty"` +} + +func Path() (string, error) { + if p := os.Getenv("SESS_CONFIG"); p != "" { + return p, nil + } + d := os.Getenv("XDG_CONFIG_HOME") + if d == "" { + h, e := os.UserHomeDir() + if e != nil { + return "", e + } + d = filepath.Join(h, ".config") + } + return filepath.Join(d, "sess", "config.json"), nil +} +func Load() (Config, error) { + c := Config{Version: 1} + p, e := Path() + if e != nil { + return c, e + } + b, e := os.ReadFile(p) + if errors.Is(e, os.ErrNotExist) { + return c, nil + } + if e != nil { + return c, e + } + if e = json.Unmarshal(b, &c); e != nil { + return c, fmt.Errorf("read %s: %w", p, e) + } + if c.Version != 1 { + return c, fmt.Errorf("unsupported config version %d", c.Version) + } + if c.Host != "" { + e = api.ValidateHost(c.Host) + } + return c, e +} +func Save(c Config) error { + p, e := Path() + if e != nil { + return e + } + if e = os.MkdirAll(filepath.Dir(p), 0700); e != nil { + return e + } + b, e := json.MarshalIndent(c, "", " ") + if e != nil { + return e + } + f, e := os.CreateTemp(filepath.Dir(p), ".config-*") + if e != nil { + return e + } + defer os.Remove(f.Name()) + if _, e = f.Write(append(b, '\n')); e == nil { + e = f.Sync() + } + ce := f.Close() + if e != nil { + return e + } + if ce != nil { + return ce + } + return os.Rename(f.Name(), p) +} +func SetHost(host string) error { + if e := api.ValidateHost(host); e != nil { + return e + } + c, e := Load() + if e != nil { + return e + } + c.Host = host + for _, h := range c.Hosts { + if h == host { + return Save(c) + } + } + c.Hosts = append(c.Hosts, host) + return Save(c) +} +func Resolve(override string) (string, error) { + if override != "" { + return override, api.ValidateHost(override) + } + c, e := Load() + if e != nil { + return "", e + } + if c.Host == "" { + return "", api.Fail("no_host", "No default host configured.", "Run sess init , then sess set --host . Or pass --host for this command.") + } + return c.Host, nil +} diff --git a/internal/store/config_test.go b/internal/store/config_test.go new file mode 100644 index 0000000..1a30965 --- /dev/null +++ b/internal/store/config_test.go @@ -0,0 +1,41 @@ +package store + +import ( + "os" + "path/filepath" + "testing" +) + +func TestHostResolution(t *testing.T) { + p := filepath.Join(t.TempDir(), "config.json") + t.Setenv("SESS_CONFIG", p) + if _, e := Resolve(""); e == nil { + t.Fatal("missing host silently accepted") + } + if e := SetHost("dev"); e != nil { + t.Fatal(e) + } + if h, e := Resolve("staging"); e != nil || h != "staging" { + t.Fatal(h, e) + } + if h, _ := Resolve(""); h != "dev" { + t.Fatal("override changed default") + } + info, e := os.Stat(p) + if e != nil || info.Mode().Perm() != 0600 { + t.Fatal("config must be private", e) + } + if e = SetHost("dev"); e != nil { + t.Fatal(e) + } + c, _ := Load() + if len(c.Hosts) != 1 { + t.Fatal("duplicate hosts") + } + if e = os.WriteFile(p, []byte("garbage"), 0600); e != nil { + t.Fatal(e) + } + if _, e = Resolve(""); e == nil { + t.Fatal("corrupt config ignored") + } +} diff --git a/internal/transport/ssh.go b/internal/transport/ssh.go new file mode 100644 index 0000000..b63573a --- /dev/null +++ b/internal/transport/ssh.go @@ -0,0 +1,167 @@ +package transport + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "github.com/DeepakSilaych/sess/internal/api" + "golang.org/x/term" + "io" + "os" + "os/exec" + "strings" + "time" +) + +const AgentPath = `"$HOME/.local/share/sess/bin/sess"` + +type Client struct{ Host string } + +func New(host string) (*Client, error) { + if e := api.ValidateHost(host); e != nil { + return nil, e + } + return &Client{host}, nil +} +func (c *Client) Command(ctx context.Context, tty, batch bool, remote string) *exec.Cmd { + bin := os.Getenv("SESS_SSH") + if bin == "" { + bin = "ssh" + } + args := []string{"-o", "ConnectTimeout=8", "-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3"} + if tty { + args = append(args, "-t") + } else { + args = append(args, "-T") + } + if batch { + args = append(args, "-o", "BatchMode=yes") + } + args = append(args, c.Host, remote) + return exec.CommandContext(ctx, bin, args...) +} +func (c *Client) Request(ctx context.Context, r api.Request) (api.Response, error) { + var response api.Response + cmd := c.Command(ctx, false, true, "exec "+AgentPath+" rpc "+api.Encode(r)) + var stderr bytes.Buffer + cmd.Stderr = &stderr + b, e := cmd.Output() + if e != nil { + return response, ConnectionError(c.Host, stderr.String(), e) + } + if e = json.Unmarshal(b, &response); e != nil { + return response, api.Fail("protocol", "Unexpected response from "+c.Host+".", "Run sess init "+c.Host+". Ensure non-interactive shell startup files do not print to stdout.") + } + if response.Protocol != api.Protocol { + return response, api.Fail("protocol", "Remote sess protocol is incompatible.", "Run sess init "+c.Host+".") + } + if response.Error != nil { + return response, response.Error + } + return response, nil +} +func (c *Client) List(ctx context.Context) ([]api.Session, error) { + r, e := c.Request(ctx, api.Request{Action: "list"}) + if r.Sessions == nil { + r.Sessions = []api.Session{} + } + return r.Sessions, e +} +func (c *Client) Find(ctx context.Context, name string) (api.Session, error) { + r, e := c.Request(ctx, api.Request{Action: "find", Name: name}) + if e != nil { + return api.Session{}, e + } + if r.Session == nil { + return api.Session{}, fmt.Errorf("remote did not return a session") + } + return *r.Session, nil +} +func (c *Client) Create(ctx context.Context, name string) (api.Session, error) { + r, e := c.Request(ctx, api.Request{Action: "create", Name: name}) + if e != nil { + return api.Session{}, e + } + if r.Session == nil { + return api.Session{}, fmt.Errorf("remote did not return a session") + } + return *r.Session, nil +} +func (c *Client) Remove(ctx context.Context, s api.Session) error { + _, e := c.Request(ctx, api.Request{Action: "remove", Name: s.Name, ID: s.ID}) + return e +} + +// Do not retry generic 255 exits. OpenSSH uses 255 for authentication, host-key +// and configuration errors as well as transport failures. +func Transient(detail string) bool { + s := strings.ToLower(detail) + for _, permanent := range []string{"permission denied", "host key verification failed", "remote host identification has changed", "bad configuration", "bad owner", "could not resolve hostname", "no matching", "too many authentication failures", "no such file or directory"} { + if strings.Contains(s, permanent) { + return false + } + } + for _, transient := range []string{"connection timed out", "operation timed out", "connection refused", "connection reset", "broken pipe", "connection closed", "closed by remote host", "network is unreachable", "no route to host", "timeout, server", "software caused connection abort"} { + if strings.Contains(s, transient) { + return true + } + } + return false +} +func ConnectionError(host, detail string, cause error) error { + detail = strings.TrimSpace(detail) + if strings.Contains(detail, ".local/share/sess/bin/sess") && strings.Contains(detail, "not found") { + return api.Fail("not_initialized", "sess is not installed on "+host+".", "Run sess init "+host+".") + } + if detail == "" { + detail = cause.Error() + } + return api.Fail("ssh", "Cannot connect to "+host+": "+detail, "Check ssh "+host+". For non-interactive operations, unlock your SSH key in ssh-agent.") +} +func Backoff(attempt int) time.Duration { + d := time.Second << min(attempt, 4) + return min(d, 15*time.Second) +} +func (c *Client) Attach(ctx context.Context, s api.Session, notice io.Writer) error { + if s.ID == "" { + return api.Fail("identity", "Session has no sess identity.", "Create a fresh session using sess new.") + } + if term.IsTerminal(int(os.Stdin.Fd())) { + state, e := term.GetState(int(os.Stdin.Fd())) + if e == nil { + defer term.Restore(int(os.Stdin.Fd()), state) + } + } + for attempt := 0; ; attempt++ { + cmd := c.Command(ctx, true, true, "exec "+AgentPath+" attach "+api.Encode(api.Request{Action: "attach", Name: s.Name, ID: s.ID})) + var detail bytes.Buffer + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = io.MultiWriter(notice, &detail) + e := cmd.Run() + if ctx.Err() != nil { + return ctx.Err() + } + if e == nil { + return nil + } // explicit detach or remote shell exit + var ex *exec.ExitError + if !errors.As(e, &ex) || ex.ExitCode() != 255 || !Transient(detail.String()) { + if errors.As(e, &ex) && ex.ExitCode() == 40 { + return api.Fail("attach", "Remote attachment ended with an error.", "Run sess ls --host "+c.Host+" to check the session.") + } + return ConnectionError(c.Host, detail.String(), e) + } + delay := Backoff(attempt) + fmt.Fprintf(notice, "\nReconnecting to %s / %s in %s · Ctrl+C to cancel\n", c.Host, s.Name, delay) + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } +} diff --git a/internal/transport/ssh_test.go b/internal/transport/ssh_test.go new file mode 100644 index 0000000..2d3243a --- /dev/null +++ b/internal/transport/ssh_test.go @@ -0,0 +1,34 @@ +package transport + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestRetryClassification(t *testing.T) { + for _, s := range []string{"Connection reset by peer", "Connection to 127.0.0.1 closed by remote host.", "ssh: connect to host dev port 22: Connection refused", "client_loop: send disconnect: Broken pipe"} { + if !Transient(s) { + t.Fatal(s) + } + } + for _, s := range []string{"Permission denied (publickey).", "Host key verification failed.", "Could not resolve hostname dev", "Bad configuration option", "exit status 255"} { + if Transient(s) { + t.Fatal(s) + } + } + if Backoff(0) != time.Second || Backoff(50) != 15*time.Second { + t.Fatal("unbounded retry") + } +} +func TestSSHArguments(t *testing.T) { + c, _ := New("user@dev") + cmd := c.Command(context.Background(), true, true, "exec helper") + joined := strings.Join(cmd.Args, " ") + for _, part := range []string{"-t", "BatchMode=yes", "ServerAliveInterval=10", "user@dev exec helper"} { + if !strings.Contains(joined, part) { + t.Fatal(joined) + } + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..6e025f7 --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,455 @@ +// Package tui is a presentation layer over the same operations as the CLI. +package tui + +import ( + tea "charm.land/bubbletea/v2" + lip "charm.land/lipgloss/v2" + "context" + "fmt" + "github.com/DeepakSilaych/sess/internal/api" + "github.com/DeepakSilaych/sess/internal/transport" + "github.com/charmbracelet/x/ansi" + "os" + "strings" + "time" + "unicode" +) + +type Service interface { + List(context.Context) ([]api.Session, error) + Create(context.Context, string) (api.Session, error) + Remove(context.Context, api.Session) error +} +type Choice struct { + Host string + Session *api.Session +} +type Model struct { + host string + service Service + ctx context.Context + sessions []api.Session + cursor, width, height, frame int + filter, input, mode string + loading, mutating bool + failure, notice, operationError string + pending *api.Session + updated time.Time + choice Choice + generation int +} +type listMsg struct { + sessions []api.Session + err error + generation int +} +type createMsg struct { + session api.Session + err error +} +type removeMsg struct { + err error + name string +} +type tickMsg time.Time + +func New(ctx context.Context, host string, s Service) Model { + return Model{ctx: ctx, host: host, service: s, width: 90, height: 28, loading: true} +} +func (m Model) Init() tea.Cmd { return tea.Batch(m.fetch(), tick()) } +func tick() tea.Cmd { + return tea.Tick(150*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) }) +} +func (m Model) fetch() tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(m.ctx, 12*time.Second) + defer cancel() + ss, e := m.service.List(ctx) + return listMsg{ss, e, m.generation} + } +} +func (m Model) visible() []api.Session { + ss := []api.Session{} + for _, s := range m.sessions { + if strings.Contains(strings.ToLower(s.Name), strings.ToLower(m.filter)) { + ss = append(ss, s) + } + } + return ss +} +func (m Model) selected() *api.Session { + ss := m.visible() + if len(ss) == 0 { + return nil + } + s := ss[min(m.cursor, len(ss)-1)] + return &s +} +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch v := msg.(type) { + case tea.WindowSizeMsg: + m.width = v.Width + m.height = v.Height + case tickMsg: + m.frame++ + if m.frame%20 == 0 && !m.loading && !m.mutating && m.mode == "" { + m.loading = true + return m, tea.Batch(m.fetch(), tick()) + } + return m, tick() + case listMsg: + if v.generation != m.generation { + return m, nil + } + m.loading = false + if v.err != nil { + m.failure = v.err.Error() + } else { + selected := "" + if s := m.selected(); s != nil { + selected = s.ID + } + m.sessions = v.sessions + m.failure = "" + m.updated = time.Now() + m.cursor = 0 + for i, s := range m.visible() { + if s.ID == selected { + m.cursor = i + } + } + } + case createMsg: + m.mutating = false + if v.err != nil { + m.operationError = v.err.Error() + m.mode = "" + } else { + m.choice = Choice{m.host, &v.session} + return m, tea.Quit + } + case removeMsg: + m.mutating = false + m.mode = "" + m.input = "" + if v.err != nil { + m.operationError = v.err.Error() + } else { + m.operationError = "" + m.notice = "Removed " + v.name + } + m.loading = true + return m, m.fetch() + case tea.KeyPressMsg: + key := v.String() + if key == "ctrl+c" { + return m, tea.Quit + } + if m.mutating { + return m, nil + } + if m.mode != "" { + if key == "esc" { + m.mode = "" + m.input = "" + return m, nil + } + if m.mode == "help" { + m.mode = "" + return m, nil + } + if key == "enter" { + switch m.mode { + case "search": + m.mode = "" + return m, nil + case "host": + if e := api.ValidateHost(m.input); e != nil { + m.operationError = e.Error() + return m, nil + } + m.host = m.input + m.operationError = "" + m.updated = time.Time{} + m.service, _ = transport.New(m.host) + m.sessions = nil + m.cursor = 0 + m.generation++ + m.mode = "" + m.filter = "" + m.input = "" + m.failure = "" + m.loading = true + return m, m.fetch() + case "new": + if e := api.ValidateName(m.input); e != nil { + m.operationError = e.Error() + return m, nil + } + m.mutating = true + m.operationError = "" + name := m.input + return m, func() tea.Msg { s, e := m.service.Create(m.ctx, name); return createMsg{s, e} } + case "remove": + s := m.pending + if s == nil { + return m, nil + } + if m.input != s.Name { + m.operationError = "Type the session name exactly to remove it." + return m, nil + } + m.mutating = true + m.operationError = "" + return m, func() tea.Msg { return removeMsg{m.service.Remove(m.ctx, *s), s.Name} } + } + } + if key == "backspace" { + r := []rune(m.input) + if len(r) > 0 { + m.input = string(r[:len(r)-1]) + } + } else if text := v.Key().Text; text != "" && len(m.input) < 253 { + m.input += Clean(text) + } + if m.mode == "search" { + m.filter = m.input + m.cursor = 0 + } + return m, nil + } + switch key { + case "q", "esc": + return m, tea.Quit + case "j", "down": + m.cursor = min(m.cursor+1, max(0, len(m.visible())-1)) + case "k", "up": + m.cursor = max(0, m.cursor-1) + case "home", "g": + m.cursor = 0 + case "end", "G": + m.cursor = max(0, len(m.visible())-1) + case "/": + m.mode = "search" + m.input = m.filter + case "n": + m.operationError = "" + m.mode = "new" + m.input = "" + m.failure = "" + case "h": + m.operationError = "" + m.mode = "host" + m.input = "" + m.failure = "" + case "x", "delete": + if m.selected() != nil && m.failure == "" { + m.mode = "remove" + m.pending = m.selected() + m.input = "" + m.operationError = "" + } + case "r": + if !m.loading { + m.loading = true + return m, m.fetch() + } + case "?": + m.mode = "help" + case "enter": + if s := m.selected(); s != nil && m.failure == "" { + m.choice = Choice{m.host, s} + return m, tea.Quit + } + } + } + return m, nil +} +func Clean(s string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, ansi.Strip(s)) +} +func cut(s string, n int) string { return ansi.Truncate(Clean(s), max(0, n), "…") } +func style(color string) lip.Style { + if _, ok := os.LookupEnv("NO_COLOR"); ok { + return lip.NewStyle() + } + return lip.NewStyle().Foreground(lip.Color(color)) +} + +var cyan = style("#65D9F4") +var muted = style("#8995A9") +var green = style("#82D9A5") +var red = style("#FF8D8D") +var bold = lip.NewStyle().Bold(true) + +func (m Model) View() tea.View { + w := max(20, m.width-4) + if m.width < 44 || m.height < 16 { + v := tea.NewView("\n sess\n\n Enlarge the terminal to 44 × 16.\n Press q to quit.") + v.AltScreen = true + return v + } + var b strings.Builder + state := "CONNECTED" + if m.loading { + state = []string{"◐", "◓", "◑", "◒"}[m.frame%4] + " SYNCING" + } + if m.failure != "" { + state = "NEEDS ATTENTION" + } + b.WriteString(cyan.Bold(true).Render("sess") + muted.Render(" / ") + bold.Render(cut(m.host, w-30)) + " " + muted.Render(state) + "\n") + b.WriteString(muted.Render("Persistent SSH terminals · powered by zmx") + "\n\n") + attached := 0 + for _, s := range m.sessions { + if s.Clients > 0 { + attached++ + } + } + sessionLabel := " sessions " + if len(m.sessions) == 1 { + sessionLabel = " session " + } + b.WriteString(bold.Render(fmt.Sprintf("%d", len(m.sessions))) + muted.Render(sessionLabel) + green.Render(fmt.Sprintf("%d", attached)) + muted.Render(" attached ") + cyan.Render(fmt.Sprintf("%d", len(m.sessions)-attached)) + muted.Render(" detached") + "\n") + query := m.filter + if query == "" { + query = "Press / to filter sessions" + } + if m.mode == "search" { + query = m.input + "▏" + } + b.WriteString(muted.Render("⌕ ") + cut(query, w-3) + "\n\n") + if m.mode == "help" { + b.WriteString(bold.Render("Make yourself at home") + "\n\n") + b.WriteString("↑/↓ or j/k Select a session\nEnter Attach to your selected terminal\nn Create and attach\nx Remove selected session\n/ Filter by name\nh Browse another SSH host\nr Refresh now\nq Quit the browser\n\n") + b.WriteString(cyan.Render("Inside a session: Ctrl+\\ detaches your terminal.") + "\n") + b.WriteString(muted.Render("Any key returns to your sessions.")) + } else if m.mode == "new" || m.mode == "host" || m.mode == "remove" { + title, desc := "New session", "Start a shell in your remote home directory." + if m.mode == "host" { + title = "Switch host" + desc = "SSH alias or user@host. Your saved default stays unchanged." + } + if m.mode == "remove" { + title = "Remove session" + if s := m.pending; s != nil { + desc = "Stops its programs and disconnects all clients. Type " + s.Name + " to remove." + } + } + b.WriteString(bold.Render(title) + "\n\n" + lip.NewStyle().Width(w).Render(desc) + "\n\n") + b.WriteString(cyan.Render("› ") + cut(m.input, w-4) + "▏\n\n") + if m.mutating { + b.WriteString(muted.Render("Working…")) + } else { + b.WriteString(muted.Render("enter confirm esc cancel")) + } + } else { + nameW := max(8, min(36, w-34)) + b.WriteString(muted.Render(fmt.Sprintf(" %-*s %-10s %7s %s", nameW, "NAME", "STATE", "CLIENTS", "AGE")) + "\n") + b.WriteString(muted.Render(strings.Repeat("─", w)) + "\n") + ss := m.visible() + rows := max(1, m.height-19) + start := max(0, m.cursor-rows+1) + if len(ss) == 0 { + text := "No sessions yet. Press n to start your first terminal." + if m.loading && m.updated.IsZero() { + text = "Connecting to your host…" + } else if m.failure != "" { + text = "Could not load sessions. Press r to retry or h to change host." + } else if m.filter != "" { + text = "No matching sessions. Press / to change your filter." + } + b.WriteString("\n" + lip.NewStyle().Width(w).Render(text) + "\n") + } else { + for i := start; i < min(len(ss), start+rows); i++ { + s := ss[i] + marker := " " + if i == m.cursor { + marker = "› " + } + line := fmt.Sprintf("%s%-*s %-10s %7d %s", marker, nameW, cut(s.Name, nameW), s.Status, s.Clients, Age(s.Created)) + if i == m.cursor { + selectedStyle := cyan.Bold(true) + if _, noColor := os.LookupEnv("NO_COLOR"); !noColor { + selectedStyle = selectedStyle.Background(lip.Color("#1D3440")) + } + b.WriteString(selectedStyle.Width(w).Render(line)) + } else { + b.WriteString(line) + } + b.WriteByte('\n') + } + if len(ss) > rows { + b.WriteString(muted.Render(fmt.Sprintf(" %d–%d of %d", start+1, min(len(ss), start+rows), len(ss))) + "\n") + } + if s := m.selected(); s != nil { + b.WriteByte('\n') + b.WriteString(muted.Render("DIRECTORY ") + cut(s.Directory, w-12) + "\n") + b.WriteString(muted.Render("SESSION ") + cut(s.Name+" · "+m.host, w-12) + "\n") + } + } + } + problem := m.operationError + if problem == "" { + problem = m.failure + } + if problem != "" { + b.WriteString("\n" + red.Render(lip.NewStyle().Width(w).Render(Clean(problem))) + "\n") + } else if m.notice != "" { + b.WriteString("\n" + green.Render(m.notice) + "\n") + } + footer := "enter attach n new x remove / filter h host ? help q quit" + if w < 65 { + footer = "enter attach n new ? help q quit" + } + gap := max(1, m.height-lip.Height(b.String())-2) + b.WriteString(strings.Repeat("\n", gap) + muted.Render(footer)) + content := lip.NewStyle().Padding(1, 2).Render(b.String()) + // Crop defensively at tiny heights; no scrolling of the user's outer terminal. + lines := strings.Split(content, "\n") + for i, line := range lines { + lines[i] = ansi.Truncate(line, m.width, "") + } + content = strings.Join(lines, "\n") + if len(lines) > m.height { + content = strings.Join(lines[:m.height], "\n") + } + v := tea.NewView(content) + v.AltScreen = true + return v +} +func Age(t time.Time) string { + if t.IsZero() { + return "—" + } + d := time.Since(t) + if d < time.Minute { + return "now" + } + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + if d < 24*time.Hour { + return fmt.Sprintf("%dh", int(d.Hours())) + } + return fmt.Sprintf("%dd", int(d.Hours()/24)) +} +func Run(ctx context.Context, host string, previousError string) (Choice, error) { + c, e := transport.New(host) + if e != nil { + return Choice{}, e + } + browserCtx, cancel := context.WithCancel(ctx) + defer cancel() + initial := New(browserCtx, host, c) + initial.operationError = previousError + model, e := tea.NewProgram(initial, tea.WithContext(browserCtx)).Run() + if e != nil { + return Choice{}, e + } + return model.(Model).choice, nil +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go new file mode 100644 index 0000000..d1e4c8b --- /dev/null +++ b/internal/tui/model_test.go @@ -0,0 +1,66 @@ +package tui + +import ( + tea "charm.land/bubbletea/v2" + "context" + "errors" + "github.com/DeepakSilaych/sess/internal/api" + "strings" + "testing" + "time" +) + +type fakeService struct{} + +func (fakeService) List(context.Context) ([]api.Session, error) { return nil, nil } +func (fakeService) Create(context.Context, string) (api.Session, error) { return api.Session{}, nil } +func (fakeService) Remove(context.Context, api.Session) error { return nil } +func TestUnknownHostIsNotEmpty(t *testing.T) { + m := New(context.Background(), "dev", fakeService{}) + u, _ := m.Update(listMsg{err: errors.New("host offline")}) + m = u.(Model) + view := m.View().Content + if !strings.Contains(view, "NEEDS ATTENTION") || strings.Contains(view, "No sessions yet") { + t.Fatal(view) + } +} +func TestStaleHostResponseIgnored(t *testing.T) { + m := New(context.Background(), "new-host", fakeService{}) + m.generation = 2 + u, _ := m.Update(listMsg{sessions: []api.Session{{Name: "wrong"}}, generation: 1}) + if len(u.(Model).sessions) != 0 { + t.Fatal("stale response rendered") + } +} +func TestViews(t *testing.T) { + m := New(context.Background(), "dev", fakeService{}) + m.loading = false + m.sessions = []api.Session{{Name: "api", ID: "abc", Status: "detached", Directory: "/home/user/code", Created: time.Now()}} + for _, size := range []tea.WindowSizeMsg{{Width: 120, Height: 32}, {Width: 60, Height: 20}, {Width: 40, Height: 12}} { + u, _ := m.Update(size) + view := u.(Model).View() + if strings.Count(view.Content, "\n") >= size.Height { + t.Fatalf("view exceeds %d rows", size.Height) + } + } + m.filter = "missing" + if !strings.Contains(m.View().Content, "No matching") { + t.Fatal("missing filter state") + } +} +func TestSanitizeRemoteText(t *testing.T) { + if s := Clean("\x1b]52;c;payload\x07path\x1b[31m\n"); s != "path" { + t.Fatalf("terminal controls leaked: %q", s) + } +} + +func TestOperationErrorSurvivesRefresh(t *testing.T) { + m := New(context.Background(), "dev", fakeService{}) + u, _ := m.Update(createMsg{err: errors.New("name already exists")}) + m = u.(Model) + u, _ = m.Update(listMsg{}) + m = u.(Model) + if !strings.Contains(m.View().Content, "name already exists") { + t.Fatal("refresh erased an operation error") + } +} diff --git a/package.json b/package.json deleted file mode 100644 index 30f52d6..0000000 --- a/package.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "sess-sh", - "version": "0.5.0", - "description": "tmux session manager — one tool, no worktrees, no containers", - "keywords": [ - "tmux", - "session", - "git", - "ssh", - "terminal", - "development", - "devtools", - "cli", - "remote", - "reconnect", - "wake" - ], - "homepage": "https://github.com/deepaksilaych/sess#readme", - "bugs": { - "url": "https://github.com/deepaksilaych/sess/issues" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepaksilaych/sess.git" - }, - "license": "MIT", - "author": "Deepak Silaych", - "bin": { - "sess": "bin/sess-cli.js" - }, - "files": [ - "bin/sess", - "bin/sess-cli.js", - "etc/bash-completion/sess", - "etc/zsh-completion/_sess", - "etc/wakeup", - "README.md", - "LICENSE" - ], - "os": [ - "darwin", - "linux" - ], - "dependencies": {}, - "devDependencies": {} -} \ No newline at end of file diff --git a/scripts/build-agents.sh b/scripts/build-agents.sh new file mode 100755 index 0000000..5070b20 --- /dev/null +++ b/scripts/build-agents.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu +cd "$(dirname "$0")/.." +for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do + target_os=${target%/*} + target_arch=${target#*/} + target_file="internal/provision/assets/$target_os-$target_arch" + CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" go build -trimpath -ldflags='-s -w' -o "$target_file" ./cmd/sess-agent + gzip -n -f "$target_file" +done diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..edfa853 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -eu +cd "$(dirname "$0")/.." +./scripts/build-agents.sh +mkdir -p dist +for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do + target_os=${target%/*} + target_arch=${target#*/} + release_dir="dist/sess-$target_os-$target_arch" + mkdir -p "$release_dir" + CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" go build -trimpath -ldflags='-s -w' -o "$release_dir/sess" ./cmd/sess + cp README.md LICENSE "$release_dir/" + tar -czf "$release_dir.tar.gz" -C "$release_dir" sess README.md LICENSE +done +(cd dist && shasum -a 256 sess-*.tar.gz > SHA256SUMS) diff --git a/test/integration/.dockerignore b/test/integration/.dockerignore new file mode 100644 index 0000000..fda0b56 --- /dev/null +++ b/test/integration/.dockerignore @@ -0,0 +1,2 @@ +.work +__pycache__ diff --git a/test/integration/Dockerfile b/test/integration/Dockerfile new file mode 100644 index 0000000..89a7577 --- /dev/null +++ b/test/integration/Dockerfile @@ -0,0 +1,6 @@ +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends openssh-server ca-certificates procps && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /run/sshd /root/.ssh && chmod 700 /root/.ssh && ssh-keygen -A +RUN printf 'PermitRootLogin prohibit-password\nPasswordAuthentication no\n' > /etc/ssh/sshd_config.d/sess.conf +EXPOSE 22 +CMD ["/usr/sbin/sshd", "-D", "-e"] diff --git a/test/integration/run.py b/test/integration/run.py new file mode 100644 index 0000000..16111f7 --- /dev/null +++ b/test/integration/run.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Real SSH + zmx lifecycle tests in a disposable Docker container. + +Requires Docker, OpenSSH, Python 3, and `make build`. No user SSH config, +keys, remote hosts or zmx sessions are modified. Container is removed on exit. +""" +import fcntl +import json +import os +from pathlib import Path +import pty +import select +import shlex +import signal +import struct +import subprocess +import tempfile +import termios +import time + +ROOT = Path(__file__).resolve().parents[2] +BIN = ROOT / 'dist/sess' + +class Terminal: + def __init__(self, args, env, width=100, height=30): + self.pid, self.fd = pty.fork() + if self.pid == 0: + os.execve(str(BIN), [str(BIN), *args], env) + fcntl.ioctl(self.fd, termios.TIOCSWINSZ, struct.pack('HHHH', height, width, 0, 0)) + self.output = b'' + self.status = None + def read(self, seconds=.1): + if select.select([self.fd], [], [], seconds)[0]: + try: + chunk = os.read(self.fd, 65536) + except OSError: + return + self.output += chunk + # Answer cursor-position probes without a real emulator. + if b'\x1b[6n' in chunk: + os.write(self.fd, b'\x1b[1;1R') + def expect(self, text, timeout=15): + end = time.monotonic() + timeout + while text not in self.output: + if time.monotonic() > end: + raise AssertionError(f'terminal missing {text!r}: {self.output[-4000:]!r}') + self.read() + def send(self, text): + os.write(self.fd, text) + def finish(self, timeout=8): + end = time.monotonic() + timeout + while self.status is None: + self.read() + pid, status = os.waitpid(self.pid, os.WNOHANG) + if pid: + self.status = os.waitstatus_to_exitcode(status) + os.close(self.fd) + return self.status + if time.monotonic() > end: + raise AssertionError(f'terminal did not exit: {self.output[-2000:]!r}') + def kill(self): + if self.status is None: + try: + os.kill(self.pid, signal.SIGTERM) + self.finish() + except (ProcessLookupError, ChildProcessError, AssertionError): + pass + +def main(): + subprocess.run(['docker', 'build', '-q', '-t', 'sess-integration:local', str(ROOT/'test/integration')], check=True) + terminals = [] + cid = None + with tempfile.TemporaryDirectory(prefix='sess-integration-') as tmp: + d = Path(tmp) + try: + subprocess.run(['ssh-keygen','-q','-t','ed25519','-N','','-f',str(d/'key')], check=True) + cid = subprocess.check_output(['docker','run','-d','--rm','-p','127.0.0.1::22','-v',f'{d}/key.pub:/root/.ssh/authorized_keys:ro','sess-integration:local'], text=True).strip() + port = subprocess.check_output(['docker','port',cid,'22'],text=True).strip().rsplit(':',1)[1] + (d/'ssh_config').write_text(f'Host vm alternate\n HostName 127.0.0.1\n Port {port}\n User root\n IdentityFile {d}/key\n IdentitiesOnly yes\n UserKnownHostsFile {d}/known_hosts\n StrictHostKeyChecking accept-new\n LogLevel ERROR\n') + wrapper=d/'ssh' + wrapper.write_text('#!/bin/sh\nexec /usr/bin/ssh -F '+shlex.quote(str(d/'ssh_config'))+' "$@"\n') + wrapper.chmod(0o700) + env={**os.environ,'SESS_SSH':str(wrapper),'SESS_CONFIG':str(d/'config.json'),'TERM':'xterm-256color','NO_COLOR':'1'} + env.pop('ZMX_SESSION',None) + def run(*args, ok=True): + r=subprocess.run([str(BIN),*args],env=env,text=True,capture_output=True,timeout=120) + if ok and r.returncode: raise AssertionError(f'{args}: {r.stdout}\n{r.stderr}') + if not ok and not r.returncode: raise AssertionError(f'{args} unexpectedly succeeded') + return r + def ssh(command): + return subprocess.check_output([str(wrapper),'vm',command],env=env,text=True,timeout=15) + def listing():return json.loads(run('ls','--json').stdout)['sessions'] + def terminal(*args): + t=Terminal(args,env);terminals.append(t);return t + # Wait for sshd without relying on arbitrary sleeps. + for attempt in range(30): + r=subprocess.run([str(wrapper),'vm','true'],env=env,capture_output=True) + if not r.returncode:break + time.sleep(.2) + run('ls',ok=False) + print(run('init','vm').stdout,flush=True) + assert not (d/'config.json').exists(), 'init changed the default' + run('set','-h','vm') + assert listing()==[] + ssh(r"printf '\034' | ~/.local/share/sess/bin/zmx attach unrelated >/dev/null") + assert 'unrelated' in ssh('~/.local/share/sess/bin/zmx list --short') + assert listing()==[], 'sess exposed an unrelated zmx session' + run('n','api','--detach') + first=listing()[0] + assert first['name']=='api' and first['id'] and first['clients']==0 + run('new','api','--detach',ok=False) + run('rm','missing',ok=False) + assert json.loads(run('ls','-h','alternate','--json').stdout)['sessions'][0]['id']==first['id'] + assert json.loads((d/'config.json').read_text())['host']=='vm' + print('PASS setup, aliases, host overrides, duplicate and missing names',flush=True) + + t=terminal('a','api');t.expect(b'root@') + t.send(b'export SESS_CHECK=kept; cd /tmp; printf "SHELL_READY\\n"\r') + t.expect(b'\rSHELL_READY\r\n') + t.send(b'\x1c');assert t.finish()==0 + assert listing()[0]['id']==first['id'] + t=terminal('attach','api');t.expect(b'root@') + t.send(b'printf "CHECK:%s:%s\\n" "$SESS_CHECK" "$PWD"\r');t.expect(b'\rCHECK:kept:/tmp\r\n') + t.send(b'\x1c');assert t.finish()==0 + print('PASS detach, reattach, environment and working-directory persistence',flush=True) + + t=terminal('a','api');t.expect(b'root@') + # Break only the client's transport; zmx on the VM must survive. + child=subprocess.check_output(['pgrep','-P',str(t.pid)],text=True).strip().splitlines()[0] + os.kill(int(child),signal.SIGTERM) + # A local SSH kill is not a classified transient network failure. + assert t.finish()!=0 + assert listing()[0]['id']==first['id'] + t=terminal('a','api');t.expect(b'root@');t.send(b'sess detach\r');assert t.finish()==0 + print('PASS killed SSH transport preserves session; shell detach command works',flush=True) + + t=terminal('a','api');t.expect(b'root@');t.output=b'' + subprocess.run(['docker','exec',cid,'pkill','-KILL','-f','^sshd: root@pts/'],check=True) + t.expect(b'Reconnecting',timeout=45) + t.output=b'';t.expect(b'root@',timeout=25) + t.send(b'printf "RECOVERED:%s\\n" "$SESS_CHECK"\r') + t.expect(b'\rRECOVERED:kept\r\n') + t.send(b'\x1c');assert t.finish()==0 + assert listing()[0]['id']==first['id'] + print('PASS actual SSH disconnect automatically reconnects to the same shell',flush=True) + + one=terminal('a','api');one.expect(b'root@') + two=terminal('a','api');two.expect(b'root@') + assert listing()[0]['clients']==2 + one.send(b'sess detach\r') + assert one.finish()==0 and two.finish()==0 + assert listing()[0]['clients']==0 + print('PASS multiple clients and detach-all semantics',flush=True) + + + t=terminal('a','api');t.expect(b'root@');t.send(b'exit\r');assert t.finish()==0 + assert listing()==[], 'shell exit left a live session' + t=terminal('a','api');assert t.finish()!=0 + assert listing()==[], 'attach recreated a missing session' + run('n','remove-me','--detach');run('rm','remove-me');assert listing()==[] + print('PASS shell exit, attach-only semantics and removal',flush=True) + + run('n','browser-session','--detach') + t=terminal();t.expect(b'browser-session');t.read(.3) + (ROOT/'test/integration/.work').mkdir(exist_ok=True) + (ROOT/'test/integration/.work/tui.ansi').write_bytes(t.output) + t.send(b'/no-match\r');t.expect(b'No matching sessions');t.send(b'q');assert t.finish()==0 + (ROOT/'test/integration/.work').mkdir(exist_ok=True) + print('PASS real interactive TUI, filtering and terminal cleanup',flush=True) + t=terminal();t.expect(b'browser-session');t.send(b'n');t.expect(b'New session') + t.send(b'tui-created\r');t.expect(b'root@') + t.output=b'';t.send(b'\x1c');t.expect(b'SESSION browser-session') + t.send(b'x');t.expect(b'Type browser-session') + t.send(b'browser-session\r');t.expect(b'Removed browser-session') + t.send(b'q');assert t.finish()==0 + assert [s['name'] for s in listing()]==['tui-created'] + print('PASS TUI create, attach, return and confirmed removal',flush=True) + for name in ['api','release-check','tests']:run('n',name,'--detach') + active=terminal('a','api');active.expect(b'root@') + visualenv=env.copy();visualenv.pop('NO_COLOR',None);visualenv['COLORTERM']='truecolor' + preview=Terminal([],visualenv);terminals.append(preview) + preview.expect(b'SESSION');preview.expect(b'release-check');preview.read(.3) + (ROOT/'test/integration/.work/tui.ansi').write_bytes(preview.output) + preview.send(b'q');assert preview.finish()==0 + active.send(b'\x1c');assert active.finish()==0 + assert 'unrelated' in ssh('~/.local/share/sess/bin/zmx list --short'), 'sess touched another zmx namespace' + print('PASS ordinary zmx sessions stay isolated',flush=True) + print('All SSH integration checks passed.',flush=True) + finally: + for t in terminals:t.kill() + if cid:subprocess.run(['docker','rm','-f',cid],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + +if __name__=='__main__':main() diff --git a/test/test_sess.sh b/test/test_sess.sh deleted file mode 100755 index 9a591cc..0000000 --- a/test/test_sess.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env bash -# test_sess.sh — Smoke tests for sess v2 -# Non-interactive tests only (tmux attach requires a terminal) -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SESS="$SCRIPT_DIR/../bin/sess" -export SESS_DIR="/tmp/sess-test-$$" - -cleanup() { rm -rf "$SESS_DIR"; } -trap cleanup EXIT - -echo "Running sess v2 tests..." - -check() { - local label="$1" needle="$2" out="$3" - if printf '%s' "$out" | grep -q "$needle"; then - echo "✓ $label: OK" - else - echo "✗ $label: ERROR" - printf '%s\n' "$out" - exit 1 - fi -} - -# Syntax checks -bash -n "$SESS" && echo "✓ sess: syntax OK" || { echo "✗ sess: syntax ERROR"; exit 1; } -bash -c "source $SCRIPT_DIR/../etc/bash-completion/sess" && echo "✓ bash completion: OK" || { echo "✗ bash completion: ERROR"; exit 1; } - -# Version -check "version" "sess" "$($SESS version)" - -# Help -check "help" "DETACH" "$($SESS help)" - -# Doctor -check "doctor" "tmux" "$($SESS doctor 2>&1)" - -# Empty list -check "empty ls" "No sessions" "$($SESS ls)" - -# Create a temp git repo -REPO="/tmp/sess-test-repo-$$" -mkdir -p "$REPO" && cd "$REPO" -git init && git config user.email "test@test.com" && git config user.name "Test" -echo "hello" > README.md && git add . && git commit -m "initial" - -# Create a session (non-interactive — can't auto-attach, but can create state) -# We'll create the session directory manually to test non-attach commands -mkdir -p "$SESS_DIR/sessions/test-session" -cat > "$SESS_DIR/sessions/test-session/state" < "$SESS_DIR/sessions/test-session/log" - -# List should show our session -check "ls shows session" "test-session" "$($SESS ls)" - -# Status -check "status" "test-session" "$($SESS status test-session)" - -# Path -check "path" "sess-test-repo" "$($SESS path test-session)" - -# Log -check "log" "created" "$($SESS log test-session)" - -# Remove -$SESS rm test-session && echo "✓ rm: OK" || { echo "✗ rm: ERROR"; exit 1; } - -# Verify removed -check "removed" "No sessions" "$($SESS ls)" - -# Cleanup repo -rm -rf "$REPO" - -echo "" -echo "All tests passed!"