From 05eace6555fdf3fc6c425a99dced7bb28b1da71d Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Tue, 6 Oct 2026 21:10:25 +0200 Subject: [PATCH 1/6] DEV-2054: OpenSpec change for remote hosts, installers, launcher and repo management --- .../.openspec.yaml | 2 + .../design.md | 68 ++++++ .../proposal.md | 35 +++ .../specs/cli/spec.md | 74 ++++++ .../specs/daemon/spec.md | 39 ++++ .../specs/hub/spec.md | 210 ++++++++++++++++++ .../specs/platform/spec.md | 117 ++++++++++ .../specs/protocol/spec.md | 103 +++++++++ .../specs/web/spec.md | 97 ++++++++ .../tasks.md | 57 +++++ 10 files changed, 802 insertions(+) create mode 100644 openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/.openspec.yaml create mode 100644 openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/design.md create mode 100644 openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/proposal.md create mode 100644 openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/cli/spec.md create mode 100644 openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/daemon/spec.md create mode 100644 openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/hub/spec.md create mode 100644 openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/platform/spec.md create mode 100644 openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/protocol/spec.md create mode 100644 openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/web/spec.md create mode 100644 openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/tasks.md diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/.openspec.yaml b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/.openspec.yaml new file mode 100644 index 0000000..aa32566 --- /dev/null +++ b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-06 diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/design.md b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/design.md new file mode 100644 index 0000000..e8e684b --- /dev/null +++ b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/design.md @@ -0,0 +1,68 @@ +## Context + +PR 5 of 5 for DEV-1989 (see proposal.md). Binding: PR 1's design (archived change `2026-10-05-dev-1989-…`) D2 (separate hub, one link code path), D4 (PTY prebuilds, install is a plain copy), D5 (outdated restart naming terminals), D6 (peer semantics, frozen `hello`/`shutdown`, `discoverRepos` from the browser carries only the host); PR 2's design (state paths and dialer; the daemon socket lives in the state directory, not `$XDG_RUNTIME_DIR`); PR 3's design (hub element split; `hub.links` owns every process the hub starts); PR 4's change `dev-2053-…`, still unarchived on this branch — this change's MODIFIED requirements are written on top of PR 4's text, so PR 4 must be archived first. `architecture/` is normative; principles applied: system 1–8; platform 1–4; hub 1–5; cli 1–2; protocol 2–4; daemon 6; web 1, 2, 4, 7. + +Today: the hub holds one local link per session; `addRepo`, `removeRepo`, hub-level `discoverRepos` and `reinstallDaemon` answer "not implemented"; the installers are CLI placeholders; the outdated banner uses `window.confirm` without naming terminals. The bundle is `dist/wtd.mjs` (esbuild, PTY package external) plus `dist/web`; the PTY package needs only `package.json`, `lib/` and `prebuilds/` at run time (Linux x64/arm64/arm/ia32, glibc and musl, ABIs 111–147). + +Environment fact that shaped D3: on Ubuntu, `~/.local/bin` and a user Node (`~/.local/node22/bin`) are put on PATH by `~/.bashrc` after its interactive guard or by `~/.profile`; `ssh host cmd` runs a non-interactive, non-login shell, so neither is on PATH there. + +## Goals / Non-Goals + +**Goals:** +- A remote host works with no dotfile edits on either side: `wtd install-remote ` (or "Install" on the page) and it connects. +- Local and remote links share one status machine, one restart path and one installer. +- No action kills terminals without a confirmation that names them when they can be known. + +**Non-Goals:** +- Adding or editing hosts from the page; changing hosts in open sessions (sessions keep their snapshot, PR 3). +- Installing on non-Linux remotes (no prebuilds); systemd units on remotes; `loginctl enable-linger`. +- A daemon-side lock making `removeRepo`'s terminal check atomic (point-in-time check, see spec). + +## Decisions + +### D1 Protocol 5 +Host entries gain `reason`; `instance` is also set for `outdated` (from the frozen `hello`, which every version can decode) so the page can match the terminals it last saw; `discoverRepos.roots` accept `~` and `~/…`. `wire.golden.json` re-blessed, `frozen.golden.json` untouched; every protocol-4 reference (golden assertion, samples, `--version`, daemon and connect scenarios) moves to 5. + +### D2 `~` is resolved where the home is +The hub expands `~/` only in local `repos` (as before); remote `repos` must be absolute, and discovery roots on every host stay unexpanded until the daemon expands them against its own home. Alternative — the hub learning a remote's `$HOME` through a second SSH command — rejected: a second channel outside `wtd connect`, an extra round trip per connect, and symlinked homes would make a configured path differ from the daemon's real path. + +### D3 Remote command and shim +The hub runs `"$HOME/.local/bin/wtd" connect` (double-quoted `$HOME` parses alike in sh, bash, zsh and fish). The shim execs an absolute Node chosen at install time: the running Node locally; remotely `--node`, else `node` on the plain PATH, else `$SHELL -l -i -c 'command -v node'` with stdout between markers (profile noise ignored). Alternative — `#!/usr/bin/env node` and documenting PATH setup — rejected: broken on a stock Ubuntu remote and on this machine. Re-running the installer picks up a moved Node. + +### D4 SSH command line (`platform.dialer`) +One builder used by the hub link and the installer: `BatchMode=yes` (no tty to prompt on), `ConnectTimeout=10`, `ControlMaster=auto` + `ControlPersist=10m` + `ControlPath=/run/ssh-%C` (without a ControlPath, `ControlMaster=auto` does not multiplex), `ServerAliveInterval=15`, `--` before the alias, and alias validation (no leading `-`, no whitespace or control characters). `WTD_SSH` replaces the program, as `WTD_BROWSER` does for the browser; the process and e2e tiers use a fake SSH that runs the remote command under a second temporary home. Sessions share one master per host by design: a killed client reconnects alone; a killed master makes every link of that host reconnect. + +### D5 Remote link lifecycle (`hub.links`) +`RemoteDaemon.open` mirrors `LocalDaemon.open`: same `Link` interface, frames over the child's stdio. The link ends on the child's `exit` or stdout end — never on stderr close, which a ControlPersist master forked from the child can keep open. Stderr feeds a 4 KiB rolling tail (control characters stripped) for `reason`. `close()` ends stdin and SIGTERMs the child after 1 s; the hub reaps only its own child. Probe first (task 1): whether the first client becomes the backgrounded master and what it inherits. + +### D6 One host coordinator for restart and reinstall (`hub.router`) +`DaemonRestarter` generalises over a daemon endpoint (`LocalDaemon` | `RemoteDaemon`, both `open(events, {start})`; remote `start: false` is not distinguishable, since `wtd connect` always auto-starts — the loop tolerates it). A per-host coordinator, shared across sessions, runs one operation at a time: identical requests share a run, a restart during a reinstall shares the reinstall, others queue. The probe decodes only the frozen `hello` (first frame), so daemons of any other version, older or newer, can be shut down. Reinstall success requires the hub's own version and protocol, which proves the new release answered; a `down` host has no old instance, so the loop simply dials (auto-starting the new release) until such a daemon answers. + +### D7 Installer (`platform.install`, new element) +Release directories are immutable and unique (`versions/-`), assembled under a dot-name and renamed into place; `current` is a symlink swapped by creating a temporary link and renaming it over. The previous release is kept (a running hub may still serve `web/` from it; running daemons already loaded their code), older ones are removed. Alternatives: in-place flat layout (a window with a half-written tree, breaks a concurrent `wtd connect`); `versions/` (cannot atomically replace the same version). Ownership checks use `lstat` on every path the installation owns; standard parents (`~/.local`, `~/.local/bin`, …) are created 0700 only when missing and never chmodded. Escaping is per format: POSIX single-quote escaping for the shim, Desktop Entry escaping for `Exec`, systemd escaping for `ExecStart`. +Remote transfer is one tar stream produced in-process (a small pure ustar writer; no local `tar`, no remote `rsync`) piped to a fixed bootstrap `exec sh -c 'cd && … tar -x -C "$T" && exec sh "$T/install.sh" "$T"'`, whose single-quoted part has no quote or backslash, so every common login shell parses it alike. The script, version and Node override are files in the archive, never interpolated into a command. The script prints a result line between markers; anything else on stdout is ignored. + +### D8 systemd (`platform.dialer` + `platform.install`) +When the unit file exists, every auto-start goes through `systemctl --user start` (falling back to the detached spawn if that fails), so the daemon ends up under systemd after the first restart or login without killing a running one. `Restart=on-failure`, not `always`: a restart from the page sends `shutdown` (exit 0) and the next dial starts the unit; with `always`, systemd and the dialer would race and the loser's "already running" exit would loop. The daemon's start lock stays the final guard. `WTD_SYSTEMCTL` replaces the program so tests never touch the user's systemd. + +### D9 Repo management through one-shot links +`discoverRepos`, `addRepo` and `removeRepo` each open a link of the hub's own to the host and close it after the answer, keeping the hub's requests out of the browser's `req` namespace on the session link (PR 1 D6). Config edits re-read the raw file, change one host's `repos` and keep everything else as written; before the atomic rename they compare the file's identity and content with what was read and start over on a mismatch (at most 3 tries), so a hand edit is never silently lost. Edits are serialised in the hub and pushed to every open session for that host only; the page then watches added repos and unwatches removed ones on its own session link. + +### D10 Terminal memory for confirmations (`web.client`) +The page stores, per host key (`local`, `remote:`), the daemon instance, a timestamp and up to 256 running terminals (worktree label, preset), refreshed from `repoState`, `termCreated`, `termExited`, `termClosed`. The confirmation dialog lists them only when the stored instance equals the host's reported instance. Every storage access is wrapped; failures lose only the list. + +### D11 Feasibility probes before tests +(a) ControlPersist: does the first `ssh` become a backgrounded master, what does it inherit (stderr), and does killing the client leave the master serving others? (b) Chrome `--app=http://127.0.0.1:/` window class under GNOME Wayland, for `StartupWMClass`. (c) `$SHELL -l -i -c 'command -v node'` over a non-tty SSH session: noise, hangs, job-control warnings. Outcomes go in a handoff comment on DEV-2054; a failure stops the work. + +## Risks / Trade-offs + +- [sshd `MaxSessions` (default 10) caps multiplexed sessions per master] → each page uses one session per host plus brief one-shot links; documented in the README. +- [A unit-managed daemon dies when the user manager stops at the last logout, unlike a detached daemon] → README recommends `loginctl enable-linger` with `--systemd`. +- [Unit daemons get the user manager's environment] → PTYs run `$SHELL -l -i`, which reloads the profile; GNOME imports the session environment into the user manager. +- [`removeRepo`'s terminal check is point-in-time] → a terminal created in the window keeps its repo's tab on the page (web spec) until it is closed. +- [`$SHELL -l -i` may print to stdout] → markers delimit the answer; probed (D11c). +- [The previous release is deleted by the second-next install] → a hub that old has been replaced by `wtd ui` (version check) long before. + +## Migration Plan + +Protocol 5: a protocol-4 daemon (local or remote) shows as `outdated`; the page offers "Restart daemon" (local) or "Reinstall & restart" (remote). Existing `config.json` files stay valid (all new keys optional). First remote use: `wtd install-remote ` or "Install" on the page. diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/proposal.md b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/proposal.md new file mode 100644 index 0000000..20dde0e --- /dev/null +++ b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/proposal.md @@ -0,0 +1,35 @@ +## Why + +worktree-term reaches only the local daemon: remote hosts, installation, a desktop launcher and adding repos from the UI are still missing. This change (DEV-2054, PR 5 of 5 for DEV-1989) adds them — the "Remote hosts" and "Launch and security" sections and milestone 6 "Polish" of DEV-1989. + +## What Changes + +- **BREAKING** (wire): `PROTOCOL_VERSION` 5 — host entries gain `reason`; `instance` is also reported for `outdated` hosts; `discoverRepos` roots may be `~` or `~/…`, expanded by the daemon against its own home. +- `hub.links`: an SSH link per remote host per session (`ssh … -- "$HOME/.local/bin/wtd" connect`, shared ControlMaster), with the existing status machine and backoff; the latest failure's cause is reported as the host's `reason`. +- `hub.config`: `roots` and `hosts: [{name, ssh, repos, roots}]`; the hub edits `config.json` for `addRepo` / `removeRepo`. +- `hub.router`: `restartDaemon` for remote hosts; `reinstallDaemon` (install the hub's bundle on a remote host, then restart its daemon); `discoverRepos`, `addRepo`, `removeRepo` through one-shot links; one coordinator per host for restart and reinstall. +- New `platform.install`: `wtd install-local [--systemd]` and `wtd install-remote [--node ]` — immutable release directories under `~/.local/share/worktree-term/versions/`, an atomically swapped `current` link, a `~/.local/bin/wtd` shim bound to an absolute Node path, a `.desktop` launcher and an optional systemd user unit. +- `platform.dialer`: auto-start goes through `systemctl --user start` when the user unit is installed; builds the SSH command line. +- Web: host status badges and banners with reasons; "Restart daemon" / "Reinstall & restart" / "Install" with an in-page confirmation naming the terminals last seen on that daemon instance; "Add repo" (discovered repos or a typed path) and "Remove repo". +- README: remote hosts, installation, launcher, systemd, configuration keys. +- Additions agreed during planning beyond the DEV-2054 brief: `BatchMode`, `ConnectTimeout` and `ControlPath` SSH options; `WTD_SSH` override; host `reason`; `reinstallDaemon` for `down` remote hosts ("Install"); typed path in "Add repo"; `removeRepo`; `--node`; auto-start through systemd; tar over SSH instead of rsync; versioned release directories. + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `protocol`: version 5; host entry `reason` and `instance` for `outdated`; `~`-relative discovery roots. +- `daemon`: protocol 5 handshake; discovery expands `~` roots against the daemon's home. +- `platform`: dialing auto-starts through systemd when the unit is installed; SSH command line and control path; new installation requirements. +- `hub`: remote hosts in configuration; SSH links and host `reason`; restart for remote hosts; reinstall; repo discovery, addition and removal. +- `web`: host status display; restart and reinstall confirmation naming terminals; add and remove repo; repo watches follow `hosts`. +- `cli`: version line shows protocol 5; `install-local` and `install-remote` options and behaviour. + +## Impact + +- Code: `src/protocol/**`, `src/daemon/worktrees/**`, `src/platform/dialer/**`, `src/platform/files/**`, new `src/platform/install/**`, `src/hub/**`, `src/cli/**`, `src/web/**`, `README.md`. +- Architecture: new element `platform.install` with arrows `platform.install → platform.files`, `platform.install → platform.dialer`, `cli → platform.install`, `hub.links → platform.install`; `platform.arc42.md` purpose; allowlist `platform.install`: `fs`, `child_process`. +- Tests: unit beside the code; `test/process/**` (installers into temp homes, a fake SSH remote through `WTD_SSH`); `test/e2e/**` (host status, add/remove repo, restart dialog); new `test/integration/**` (real `ssh localhost`, opt-in, needs sshd). +- Wire: `wire.golden.json` re-blessed for version 5; `frozen.golden.json` unchanged. A protocol-4 daemon shows as `outdated`. +- Dependencies: none added. diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/cli/spec.md b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/cli/spec.md new file mode 100644 index 0000000..28587c7 --- /dev/null +++ b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/cli/spec.md @@ -0,0 +1,74 @@ +## MODIFIED Requirements + +### Requirement: Version output +`wtd --version` SHALL print `wtd (protocol )` and a newline to stdout and exit 0. + +#### Scenario: Version +- **WHEN** the user runs `wtd --version` +- **THEN** stdout is `wtd (protocol 5)` followed by a newline, stderr is empty, and the exit code is 0 + +### Requirement: Help output +`wtd --help` SHALL print usage to stdout and exit 0. Usage SHALL list the commands `ui`, `hub`, `daemon`, `connect`, `install-local [--systemd]` and `install-remote [--node ]`, and the options `--help` and `--version`. + +#### Scenario: Help +- **WHEN** the user runs `wtd --help` +- **THEN** stdout lists every command and option above, stderr is empty, and the exit code is 0 + +### Requirement: Usage errors +Running `wtd` with no arguments, an unknown command, an option the command does not take, `--node` without a value, or `install-remote` without an alias SHALL print a one-line error and the usage to stderr, print nothing to stdout, and exit 2. + +#### Scenario: No arguments +- **WHEN** the user runs `wtd` +- **THEN** stderr contains the usage, stdout is empty, and the exit code is 2 + +#### Scenario: Unknown command +- **WHEN** the user runs `wtd frobnicate` +- **THEN** stderr names `frobnicate` as unknown and contains the usage, and the exit code is 2 + +#### Scenario: Unknown option +- **WHEN** the user runs `wtd --frob` +- **THEN** stderr names `--frob` as unknown and contains the usage, and the exit code is 2 + +#### Scenario: Option of another command +- **WHEN** the user runs `wtd install-remote box --systemd` +- **THEN** stderr names `--systemd` as unknown for `install-remote` and contains the usage, and the exit code is 2 + +#### Scenario: Missing alias +- **WHEN** the user runs `wtd install-remote` +- **THEN** stderr says an alias is required and contains the usage, and the exit code is 2 + +#### Scenario: Missing option value +- **WHEN** the user runs `wtd install-remote box --node` +- **THEN** stderr says `--node` needs a value and contains the usage, and the exit code is 2 + +### Requirement: Connect command +`wtd connect` SHALL connect to this host's daemon socket, starting the daemon first if no daemon is serving it, and then relay bytes unchanged from stdin to the socket and from the socket to stdout. When stdin ends it SHALL half-close the socket; when the socket closes it SHALL flush stdout and exit 0. If the daemon cannot be reached or started, or the socket fails, it SHALL print one line naming the cause to stderr and exit 1. + +#### Scenario: Bridge over a pipe with auto-start +- **WHEN** no daemon is running and a process runs `wtd connect` with piped stdio +- **THEN** a daemon is started and the first frame on stdout is the daemon's `hello` with `protocol` 5 + +#### Scenario: Requests relayed +- **WHEN** the client writes `hello` and a `watchRepo` frame to `wtd connect`'s stdin +- **THEN** the daemon's `repoState` and `done` frames appear on stdout unchanged + +#### Scenario: Socket close ends the bridge +- **WHEN** the client sends `shutdown` through `wtd connect` +- **THEN** the daemon exits, `wtd connect` exits 0, and the next `wtd connect` starts a daemon with a different `instance` + +## ADDED Requirements + +### Requirement: Install commands +`wtd install-local [--systemd]` SHALL perform a local installation, with the systemd option when `--systemd` is given. `wtd install-remote [--node ]` SHALL perform a remote installation to ``, with the given Node path when `--node` is given. On success each SHALL print one line to stdout naming the installed version and where it was installed and exit 0; on failure, one line naming the cause to stderr, without a stack trace, and exit 1. + +#### Scenario: Local install into a home +- **WHEN** the user runs `wtd install-local` with `HOME` set to an empty directory +- **THEN** stdout names the version, the exit code is 0, and `$HOME/.local/bin/wtd --version` prints the same version + +#### Scenario: Remote install through SSH +- **WHEN** the user runs `wtd install-remote box` and the SSH command reaches a Linux host with Node 20 or later +- **THEN** stdout names the version and `box`, and the exit code is 0 + +#### Scenario: Remote install failure +- **WHEN** the user runs `wtd install-remote box` and `box` cannot be reached +- **THEN** stderr is one line naming the cause and the exit code is 1 diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/daemon/spec.md b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/daemon/spec.md new file mode 100644 index 0000000..d50d386 --- /dev/null +++ b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/daemon/spec.md @@ -0,0 +1,39 @@ +## MODIFIED Requirements + +### Requirement: Handshake and version mismatch +On every new connection the daemon SHALL send `hello` with `protocol` equal to `PROTOCOL_VERSION`, its package version, and an `instance` chosen at random once per process start. If the client's first message is not `hello`, the daemon SHALL send `error{req: null, code: bad-message}` and close. If the client's `hello` carries a different `protocol`, the connection SHALL accept only the frozen `shutdown` message; every other frame, including undecodable ones, SHALL get `error{req: null, code: version-mismatch}`. + +#### Scenario: Daemon hello +- **WHEN** a client connects +- **THEN** the first frame it receives is the daemon's `hello` with `protocol` 5 + +#### Scenario: Message before hello +- **WHEN** a client's first message is a `watchRepo` +- **THEN** the daemon replies `error` with code `bad-message` and closes the connection + +#### Scenario: Mismatched client can only shut down +- **WHEN** a client sends `hello` with `protocol` 1, then a `watchRepo`, then `shutdown` +- **THEN** the `watchRepo` gets `error{req: null, code: version-mismatch}` and the `shutdown` stops the daemon + +### Requirement: Repo discovery +`discoverRepos{roots, depth}` SHALL return, sorted and without duplicates, the repositories found at most `depth` directory levels below each root, the root itself counting as level 0. A root `~` SHALL mean the daemon's home directory and a root `~/` the path `` below it. A repository SHALL be a directory containing a `.git` directory, or a bare repository. Directories containing a `.git` file SHALL NOT be reported. The search SHALL NOT descend into a found repository, a symbolic link, a hidden directory or `node_modules`, and SHALL skip missing roots and unreadable directories. Directories SHALL be visited in sorted order and the result SHALL hold at most the first 4096 repositories in that order. Each root SHALL be resolved to its real path before the search, so every reported repository is a real path. + +#### Scenario: Depth respected +- **WHEN** repos exist at levels 1 and 3 below a root and `depth` is 2 +- **THEN** only the level-1 repo is reported + +#### Scenario: Home-relative roots expanded by the daemon +- **WHEN** the daemon's home holds `GitHub/app` as a repo and a client discovers with roots `~` and `~/GitHub` and `depth` 3 +- **THEN** the daemon reports the repo once, by its real path below the daemon's home + +#### Scenario: Linked worktrees and submodules excluded +- **WHEN** a directory below a root contains a `.git` file +- **THEN** it is not reported + +#### Scenario: Deterministic cap +- **WHEN** more than 4096 repos exist below a root +- **THEN** two runs report the same 4096 repos + +#### Scenario: Root through a symbolic link +- **WHEN** a root is a symbolic link to a directory containing a repo +- **THEN** the repo is reported by its real path, which `watchRepo` accepts diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/hub/spec.md b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/hub/spec.md new file mode 100644 index 0000000..921ae9c --- /dev/null +++ b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/hub/spec.md @@ -0,0 +1,210 @@ +## MODIFIED Requirements + +### Requirement: Configuration +The configuration SHALL be `config.json` holding a JSON object with optional `port` (an integer from 1024 to 65535, default 7417), optional `repos` (at most 256 paths, each absolute or starting with `~/`, which is expanded against the home directory without resolving symbolic links), optional `roots` (1 to 32 discovery roots, each an absolute path, `~`, or `~/` followed by at least one character; default `["~"]`; never expanded by the hub), optional `presets` (1 to 64 objects `{name, command}`, each name 1–64 characters with at least one non-whitespace character and unique by exact comparison, each `command` null or 1–4096 characters) and optional `hosts` (at most 63 objects `{name, ssh, repos, roots}`, `repos` and `roots` optional). A host's `name` SHALL be 1–64 characters from `[A-Za-z0-9._-]`, unique among hosts; its `ssh` alias SHALL be 1–255 characters without whitespace or control characters and SHALL NOT start with `-`; its `repos` SHALL be at most 256 absolute paths; its `roots` SHALL follow the rules and default of the top-level `roots`. The top-level `repos` and `roots` SHALL belong to the local host. Any other key, in the object, in a preset or in a host, SHALL make it invalid. Without `presets` the presets SHALL be exactly `[{name: "shell", command: null}]`; with it, exactly the listed presets in their order. A missing file SHALL mean the defaults; the hub SHALL create it only to record a repo added from the page. An invalid configuration at start SHALL fail the start with one line naming the file and the first problem. Each new browser session SHALL use a snapshot of the configuration read when it starts; if that read is invalid, the session SHALL receive `error{req: null, host: null, code: internal}` naming the problem and use the last valid configuration. A changed port SHALL take effect only when the hub restarts. + +#### Scenario: Defaults without a file +- **WHEN** no `config.json` exists and the hub starts +- **THEN** it listens on port 7417, lists no repos and no remote hosts, and sessions receive the single preset `shell` with command null + +#### Scenario: Unknown key refused +- **WHEN** `config.json` holds the key `theme` and the hub starts +- **THEN** the start fails with a line naming the file and `theme` + +#### Scenario: Configured presets replace the default +- **WHEN** `config.json` lists presets `claude` (command `claude`) and `shell` (command null), in that order +- **THEN** sessions receive exactly those two presets in that order + +#### Scenario: Invalid presets refused +- **WHEN** `config.json` holds an empty `presets` list, two presets named `a`, or a preset whose command is "" +- **THEN** the start fails with a line naming the file and `presets` + +#### Scenario: Remote hosts listed in order +- **WHEN** `config.json` lists hosts `b` and `a`, in that order +- **THEN** sessions receive `hosts` with the local host at index 0, `b` at index 1 and `a` at index 2, both remote + +#### Scenario: Option-like alias refused +- **WHEN** a host's `ssh` is `-oProxyCommand=x` or contains a space +- **THEN** the start fails with a line naming the file and `ssh` + +#### Scenario: Home-relative remote repo refused +- **WHEN** a host's `repos` holds `~/app` +- **THEN** the start fails with a line naming the file and `repos` + +#### Scenario: Duplicate host names refused +- **WHEN** two hosts are named `box` +- **THEN** the start fails with a line naming the file and `hosts` + +#### Scenario: Edit picked up by a new session +- **WHEN** a repo or a preset is added to `config.json` while a session is open, and a new session starts +- **THEN** the new session's `hosts` lists the repo, its `presets` lists the preset, and the open session's do not change + +#### Scenario: Invalid edit keeps the last valid configuration +- **WHEN** `config.json` becomes invalid and a new session starts +- **THEN** that session receives an `internal` error and the repos and presets of the last valid configuration + +### Requirement: Daemon links and host status +For each session the hub SHALL hold one link per host of its configuration snapshot: index 0 the local host, `remote` false, named by the host name truncated to 64 characters (`local` when empty); then each configured remote host in configuration order at indices 1 and up, `remote` true, named by its configured `name`. The local link SHALL dial this host's daemon, starting it when absent. A remote link SHALL run the SSH command for the host's alias with the remote command `"$HOME/.local/bin/wtd" connect` and exchange stream frames over that process's standard input and output exactly as over the daemon socket. Every link SHALL exchange `hello`. A daemon `hello` with the same `protocol` SHALL make the host `connected` with its `daemonVersion` and `instance`; another `protocol` SHALL make it `outdated` with its `daemonVersion` and `instance`, keeping the link open. A failed dial or a lost link SHALL make the host `reconnecting` and retry after 250 ms, doubling up to 5 s; after 3 consecutive failures it SHALL be `down` while retrying continues. A remote link SHALL be lost when its SSH process exits or its standard output ends. A host's `reason` SHALL name the cause of the latest failure while it is `reconnecting` or `down`, and be null otherwise: for a remote host, the last non-empty line the SSH process wrote to standard error, taken from a tail of at most 4 KiB with control characters removed and cut to 1024 characters, or else the process's exit status; for the local host, the dial error. Closing a remote link SHALL end its SSH process's standard input and terminate that process if it has not exited 1 s later, and SHALL signal no other process. Every change of a host entry SHALL be sent to the session as `hosts`. + +#### Scenario: Local host connected +- **WHEN** a session starts and no daemon is running +- **THEN** a daemon is started and the session receives `hosts` with host 0 `connected` and the daemon's instance + +#### Scenario: Daemon restart observed +- **WHEN** the local daemon is killed while a session is open +- **THEN** the session receives host 0 as `reconnecting`, then `connected` with a different instance + +#### Scenario: Outdated daemon +- **WHEN** the local socket is served by a daemon speaking another protocol version +- **THEN** the session receives host 0 as `outdated` with that daemon's version and instance + +#### Scenario: Remote host connected +- **WHEN** a host `box` is configured and its SSH command reaches an installed `wtd` +- **THEN** the session receives host 1 named `box`, `remote` true, `connected` with the remote daemon's instance and a null `reason` + +#### Scenario: Killed SSH process reconnects to the same daemon +- **WHEN** the SSH process of a connected remote link is killed +- **THEN** the session receives host 1 as `reconnecting` and then `connected` with the same instance as before + +#### Scenario: Down host names the cause +- **WHEN** the SSH command for a host fails three times in a row, writing `ssh: Could not resolve hostname box` to standard error +- **THEN** the session receives that host as `down` with `reason` `ssh: Could not resolve hostname box` + +#### Scenario: Unbounded standard error stays bounded +- **WHEN** the SSH process writes 8 MiB to standard error without a newline and exits +- **THEN** the hub's resident memory grows by less than 16 MiB and the host's `reason` has at most 1024 characters + +#### Scenario: Closing a session spares the shared SSH master +- **WHEN** a session whose remote link created the SSH control master closes, and another session then links to the same host +- **THEN** only the closed session's SSH process ends, and the new link connects through the still-running master + +### Requirement: Routing +The hub SHALL forward a browser `host{host, m}` envelope's `m` to that host's link unchanged, and every daemon message other than `hello` to the session as `host{host, m}` unchanged, never rewriting request or terminal ids. It SHALL re-frame data frames between the stream and WebSocket encodings, adding or removing only the host index. It SHALL forward `ack` unchanged and never send an `ack` of its own. A request to a host that is not `connected` SHALL get `error{req, host, code}` with `version-mismatch` when the host is `outdated` and `host-unavailable` otherwise; `ack`, `resize`, `setVisible` and input for such a host SHALL be dropped. A message naming an unknown host index SHALL get `unknown-host`. + +#### Scenario: Round trip through the hub +- **WHEN** a browser watches a repo, creates and attaches a terminal and types into it +- **THEN** it receives `repoState`, `termCreated`, the snapshot and the echoed output, each daemon message byte-identical inside its envelope + +#### Scenario: Round trip to a remote host +- **WHEN** a browser does the same through host 1, a remote host +- **THEN** it receives the same messages inside envelopes for host 1 + +#### Scenario: Acks only from the browser +- **WHEN** a browser receives output and sends no `ack` +- **THEN** the daemon receives no `ack` for that terminal + +#### Scenario: Request to an unavailable host +- **WHEN** a browser sends `host{host: 0, m: watchRepo}` while host 0 is `reconnecting` +- **THEN** it receives `error` with that `req`, host 0 and `host-unavailable` + +#### Scenario: Unknown host +- **WHEN** a browser sends an envelope for host 5 +- **THEN** it receives `error` with `unknown-host` + +#### Scenario: Sessions are independent +- **WHEN** two sessions use the same request ids and watch the same repo +- **THEN** each receives only the replies to its own requests + +### Requirement: Daemon restart +`restartDaemon{req, host}` for a `connected` or `outdated` host, local or remote, SHALL send the daemon answering on that host the frozen `hello` and `shutdown`, then dial that host — starting a daemon when none serves — until a daemon whose `hello` has another `instance` and the hub's `protocol` answers, and then reply `done`; if that does not happen within 10 s it SHALL reply `error{code: internal}`. Only the frozen `hello` SHALL be decoded from a daemon being restarted. Restart and reinstall requests for one host, from any sessions, SHALL be coordinated: concurrent requests of the same kind SHALL share one run, a restart requested while a reinstall of that host runs SHALL share the reinstall, and any other request SHALL wait until the running one ends. For any other status it SHALL reply `host-unavailable`. + +#### Scenario: Outdated daemon restarted +- **WHEN** host 0 is `outdated` and the browser sends `restartDaemon` +- **THEN** the old daemon exits, host 0 becomes `connected` with a new instance and the hub's protocol, and the browser receives `done` + +#### Scenario: Newer daemon restarted +- **WHEN** host 0 is served by a daemon speaking protocol 6, whose messages after `hello` are not valid protocol-5 messages, and the browser sends `restartDaemon` +- **THEN** that daemon exits and host 0 becomes `connected` with a new instance and the hub's protocol + +#### Scenario: Remote daemon restarted +- **WHEN** a remote host is `connected` and the browser sends `restartDaemon` for it +- **THEN** the remote daemon is replaced by a new instance and the browser receives `done` + +#### Scenario: Concurrent restarts start one daemon +- **WHEN** two sessions send `restartDaemon` for host 0 at the same time +- **THEN** exactly one new daemon instance serves and both receive `done` + +#### Scenario: Restart during a reinstall shares it +- **WHEN** a session sends `restartDaemon` for a remote host while another session's `reinstallDaemon` of that host runs +- **THEN** exactly one new daemon instance serves and both receive `done` when the reinstall ends + +## ADDED Requirements + +### Requirement: Daemon reinstall +`reinstallDaemon{req, host}` for a remote host in any status SHALL install the hub's own bundle on that host as `wtd install-remote` does, within 120 s; then dial the host until a daemon answers whose `hello` carries the hub's package version and `protocol`, sending the frozen `hello` and `shutdown` to any daemon that answers with another version or protocol, within 10 s; and then reply `done`. A failure SHALL reply `error{code: internal}` with a message naming the failed step (`install` or `restart`) and its cause. For the local host it SHALL reply `error{code: internal}` saying the local daemon is restarted, not reinstalled. It SHALL be coordinated with restarts as the daemon restart requirement states. + +#### Scenario: Outdated remote reinstalled +- **WHEN** a remote host runs a daemon of another protocol from an older installation and the browser sends `reinstallDaemon` +- **THEN** the hub's bundle is installed there, the old daemon exits, the host becomes `connected` with the hub's version and protocol, and the browser receives `done` + +#### Scenario: Never-installed remote installed +- **WHEN** a remote host is `down` because `wtd` is not installed and the browser sends `reinstallDaemon` +- **THEN** `wtd` is installed there, the host becomes `connected`, and the browser receives `done` + +#### Scenario: Installation failure reported +- **WHEN** the remote host has no Node of version 20 or later and the browser sends `reinstallDaemon` +- **THEN** the browser receives `error` with code `internal` and a message starting `install:` that names the missing Node + +#### Scenario: Local host not reinstalled +- **WHEN** the browser sends `reinstallDaemon` for host 0 +- **THEN** it receives `error` with code `internal` and no daemon is touched + +### Requirement: Repo discovery through the hub +`discoverRepos{req, host}` for a `connected` host SHALL send that host's daemon, over a link of the hub's own, `discoverRepos` with the host's configured roots and depth 3, and reply `reposDiscovered{req, host, repos}` with the daemon's repos, or `error{req, host, code}` with the daemon's error code. For a host that is not `connected` it SHALL reply as routing does. + +#### Scenario: Discovery uses the configured roots +- **WHEN** host 0's roots are `["~/src"]` and the browser sends `discoverRepos` for host 0 +- **THEN** the daemon receives `discoverRepos` with roots `["~/src"]` and depth 3, and the browser receives `reposDiscovered` for host 0 with the repos found + +#### Scenario: Discovery does not disturb the session +- **WHEN** a browser uses request id 7 for both a `host` envelope and a hub-level `discoverRepos` +- **THEN** it receives each reply exactly once, correlated with its own request + +### Requirement: Adding repos +`addRepo{req, host, repo}` for a `connected` host SHALL check `repo` with `watchRepo` over a link of the hub's own, replying `error{req, host, code}` with the daemon's code if that fails; otherwise it SHALL add `repo` to that host's repos by a configuration edit and reply `done`. A repo already listed, as given or by a `~/` form expanding to it, SHALL reply `done` without an edit. For a host that is not `connected` it SHALL reply as routing does. + +#### Scenario: Repo added +- **WHEN** the browser sends `addRepo` for host 0 with the path of a repository's main worktree +- **THEN** `config.json` lists the path, the browser receives `done`, and then `hosts` listing the repo for host 0 + +#### Scenario: Not a repo refused +- **WHEN** the browser sends `addRepo` with a directory that is not a repository's main worktree +- **THEN** it receives `error` with `not-a-repo` and `config.json` is unchanged + +#### Scenario: Added repo reaches every session +- **WHEN** two sessions are open and one adds a repo to a remote host +- **THEN** both sessions receive `hosts` listing the repo for that host + +### Requirement: Removing repos +`removeRepo{req, host, repo}` for a `connected` host SHALL `watchRepo` it over a link of the hub's own; if the daemon lists any terminal for the repo, running or exited, it SHALL reply `busy` and change nothing. A `not-a-repo` error SHALL NOT prevent the removal; any other error SHALL be replied with the daemon's code. It SHALL then remove every entry of that host's repos naming `repo`, as given or by a `~/` form expanding to it, by a configuration edit, and reply `done`; a repo not listed SHALL reply `done` without an edit. The terminal check SHALL be made once, before the edit. For a host that is not `connected` it SHALL reply as routing does. + +#### Scenario: Repo removed +- **WHEN** the browser removes a repo without terminals that is listed as `~/app` +- **THEN** `config.json` no longer lists it, the browser receives `done`, and then `hosts` without it + +#### Scenario: Repo with terminals refused +- **WHEN** the browser removes a repo that has a running terminal, or an exited terminal not yet closed +- **THEN** it receives `error` with `busy` and `config.json` is unchanged + +#### Scenario: Deleted repo removed +- **WHEN** the browser removes a listed repo whose directory no longer exists +- **THEN** it is removed from `config.json` and the browser receives `done` + +### Requirement: Configuration edits +A configuration edit SHALL read the current `config.json` (an absent file read as `{}`), change only the `repos` of the edited host — the top-level `repos` for the local host, otherwise those of the host entry with the session's host name — keeping every other value as written, and replace the file atomically with an owner-only file holding the result as JSON indented by 2 spaces. It SHALL fail with `error{code: internal}` naming the problem when the current file is invalid, no longer lists the host, or the result would hold more than 256 repos for the host. If the file changed between the read and the replacement, the edit SHALL start over, at most 3 times in all, and then fail with `error{code: internal}`. Edits SHALL be applied one at a time. After an edit, every open session whose snapshot lists that host SHALL have the host's repos replaced by those of the edited file, `~/` expanded, and receive `hosts`; nothing else in any session's snapshot SHALL change. + +#### Scenario: Other entries kept as written +- **WHEN** `config.json` lists `~/a` for the local host and a repo is added for it +- **THEN** the file still lists `~/a` unexpanded, followed by the added path, and every other key is unchanged + +#### Scenario: Invalid file not overwritten +- **WHEN** `config.json` is invalid and the browser adds a repo +- **THEN** it receives `error` with code `internal` naming the problem and the file is unchanged + +#### Scenario: Concurrent hand edit kept +- **WHEN** `config.json` is changed by another writer after an edit read it and before the edit replaced it +- **THEN** the edit starts over from the changed file, and the result holds both the other writer's change and the added repo + +#### Scenario: Host gone from the file +- **WHEN** a session's host `box` was removed from `config.json` and that session adds a repo for `box` +- **THEN** it receives `error` with code `internal` and the file is unchanged diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/platform/spec.md b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/platform/spec.md new file mode 100644 index 0000000..bd12089 --- /dev/null +++ b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/platform/spec.md @@ -0,0 +1,117 @@ +## MODIFIED Requirements + +### Requirement: Daemon dialing with auto-start +Dialing SHALL connect to this host's daemon socket. When the socket does not exist or refuses connections, dialing SHALL start the daemon: when the daemon's systemd user unit file exists, by running the systemctl command with `--user start worktree-term-daemon.service`; otherwise, or when that command fails, by starting the daemon command it was given, detached in its own session with stdin ignored and stdout and stderr appended to `daemon.log` (moved to `daemon.log.1` first when over 1 MiB). It SHALL then retry connecting every 50 ms for up to 5 s. If no connection succeeds, dialing SHALL fail with `daemon did not start; see `. The systemctl command SHALL be `$WTD_SYSTEMCTL` when set and non-empty, else `systemctl`. + +#### Scenario: Running daemon reused +- **WHEN** a daemon serves the socket and a client dials +- **THEN** the client connects and no daemon is started + +#### Scenario: Absent daemon started +- **WHEN** no daemon serves the socket, no unit file exists, and a client dials +- **THEN** a daemon is started detached, its output goes to `daemon.log`, and the client connects + +#### Scenario: Absent daemon started through systemd +- **WHEN** the unit file exists, no daemon serves the socket, and a client dials +- **THEN** the systemctl command is run with `--user start worktree-term-daemon.service` and the daemon command is not started directly + +#### Scenario: Failing systemctl falls back +- **WHEN** the unit file exists, the systemctl command exits non-zero, and a client dials +- **THEN** the daemon command is started detached and the client connects + +#### Scenario: Concurrent dials start one daemon +- **WHEN** two clients dial at the same time with no daemon running +- **THEN** both connect to the same daemon instance + +#### Scenario: Start failure reported +- **WHEN** the daemon command exits without serving the socket +- **THEN** dialing fails after 5 s with a message naming the log path + +### Requirement: Hub and configuration paths +The configuration directory SHALL be `$XDG_CONFIG_HOME/worktree-term` when `XDG_CONFIG_HOME` is an absolute path, and `~/.config/worktree-term` otherwise, with `config.json` the hub configuration. The daemon's systemd user unit file SHALL be `systemd/user/worktree-term-daemon.service` under `$XDG_CONFIG_HOME` when it is an absolute path, and under `~/.config` otherwise. Under the state directory, `hub-token` SHALL be the hub token, `hub.log` the log of a detached hub, `run/hub.lock` and `run/hub.json` the hub start lock and hub record, and `run/ssh-%C` the SSH control path, `%C` standing for the 40 hexadecimal digits SSH substitutes. A control path whose UTF-8 encoding, with `%C` substituted, exceeds 107 bytes SHALL be refused with an error naming the path. Files and directories created for them SHALL be owner-only, as for the daemon's. + +#### Scenario: XDG_CONFIG_HOME honoured +- **WHEN** `XDG_CONFIG_HOME` is `/tmp/c` +- **THEN** the configuration file is `/tmp/c/worktree-term/config.json` and the unit file is `/tmp/c/systemd/user/worktree-term-daemon.service` + +#### Scenario: Relative XDG_CONFIG_HOME ignored +- **WHEN** `XDG_CONFIG_HOME` is `rel/dir` +- **THEN** the configuration file is `~/.config/worktree-term/config.json` + +#### Scenario: Over-long control path refused +- **WHEN** the state directory is so long that the substituted control path encodes to 108 bytes +- **THEN** resolving the control path fails with an error naming the path + +## ADDED Requirements + +### Requirement: SSH command line +The SSH command for an alias SHALL be the SSH program followed by `-o BatchMode=yes -o ConnectTimeout=10 -o ControlMaster=auto -o ControlPersist=10m -o ControlPath= -o ServerAliveInterval=15 --`, the alias, and the remote command as one argument. The SSH program SHALL be `$WTD_SSH` when set and non-empty, else `ssh`. An alias that is empty, longer than 255 characters, starts with `-`, or contains whitespace or a control character SHALL be refused with an error naming it. Every SSH process the hub or an installer starts SHALL use this command line. + +#### Scenario: Options precede the alias +- **WHEN** the SSH command for alias `box` and remote command `"$HOME/.local/bin/wtd" connect` is built +- **THEN** its arguments are the options above, `--`, `box` and `"$HOME/.local/bin/wtd" connect`, in that order + +#### Scenario: Option-like alias refused +- **WHEN** the SSH command for alias `-oProxyCommand=touch /tmp/x` is built +- **THEN** building fails with an error naming the alias and nothing is run + +### Requirement: Installed layout +An installation SHALL place under the data directory `~/.local/share/worktree-term` a release directory `versions/-<12 random lowercase hex digits>` holding `wtd.mjs`, the web bundle as `web/`, and `node_modules/@homebridge/node-pty-prebuilt-multiarch/` with only that package's `package.json`, `LICENSE`, `lib/` and `prebuilds/`. A release SHALL be assembled under a name starting with `.` in `versions/` and renamed into place once complete, and SHALL never be changed afterwards. The symbolic link `current` in the data directory SHALL then be replaced atomically to point to the new release. Afterwards every release other than the new one and the one `current` pointed to before SHALL be removed. The shim `~/.local/bin/wtd` SHALL be a POSIX shell script that runs the installation's Node, given by absolute path, with `$HOME/.local/share/worktree-term/current/wtd.mjs` and all its arguments, written atomically with mode 0700. The Node SHALL be an absolute path without NUL or newline, of version 20 or later, and the shim SHALL run it correctly whatever other characters its path holds. Directories created for the installation SHALL be owner-only; existing parent directories outside the data directory SHALL be left as they are. A path the installation owns — the data directory and everything below it, the shim, the launcher and the unit file — that exists but is a symbolic link where a directory or file is expected (other than `current`), is of the wrong type, or is owned by another user SHALL make the installation fail naming it, changing nothing. + +#### Scenario: Fresh layout +- **WHEN** an installation of version 0.2.0 runs in an empty home +- **THEN** `current` points to `versions/0.2.0-`, which holds `wtd.mjs`, `web/index.html` and the PTY package's `prebuilds/`, and `~/.local/bin/wtd --version` prints the version + +#### Scenario: Same version reinstalled +- **WHEN** version 0.2.0 is installed twice +- **THEN** `current` points to a different release directory than after the first installation, and the first release still exists + +#### Scenario: Old releases removed +- **WHEN** three installations run one after the other +- **THEN** only the releases of the second and third remain + +#### Scenario: Running release keeps working during a swap +- **WHEN** a process started through the shim keeps running while a new release is installed +- **THEN** that process keeps working and the shim's next run uses the new release + +#### Scenario: Unusual Node path +- **WHEN** the Node path contains a space, a single quote, `$` and `%` +- **THEN** the shim runs that Node + +#### Scenario: Planted symbolic link refused +- **WHEN** `~/.local/share/worktree-term/versions` is a symbolic link +- **THEN** the installation fails naming that path and nothing is written + +### Requirement: Local installation +A local installation SHALL install, as the installed layout states, the bundle of the running `wtd` — its `wtd.mjs`, the web bundle beside it and the PTY package it resolves — using the running Node. It SHALL then write the launcher `~/.local/share/applications/worktree-term.desktop`, owner-only, as a desktop entry of type `Application` named `worktree-term`, not run in a terminal, whose `Exec` runs the shim with `ui`, whose `Icon` is `icon.svg` of the web bundle under `current`, and whose `StartupWMClass` is the window class of the Chrome app window `wtd ui` opens; each value SHALL be escaped as desktop entries require. With the systemd option it SHALL also write the unit file, owner-only, running the shim with `daemon` (escaped as unit files require), with `Restart=on-failure` and installed for `default.target`; run the systemctl command with `--user daemon-reload` and then `--user enable worktree-term-daemon.service`; and start the unit as dialing does only when no daemon serves this host's socket, leaving a running daemon alone. A failing systemctl command SHALL fail the installation naming the command. + +#### Scenario: Launcher written +- **WHEN** a local installation runs in a home whose path contains a space +- **THEN** the launcher exists and its `Exec` line, unescaped as desktop entries define, runs the shim with `ui` + +#### Scenario: Unit installed and enabled +- **WHEN** a local installation runs with the systemd option and no daemon is running +- **THEN** the unit file exists, the systemctl command was run with daemon-reload, enable and start, and the unit's `ExecStart` runs the shim with `daemon` + +#### Scenario: Running daemon left alone +- **WHEN** a local installation runs with the systemd option while a daemon serves the socket +- **THEN** the unit is enabled but not started, and the running daemon keeps its instance + +### Requirement: Remote installation +A remote installation SHALL send the bundle of the running `wtd` as one tar archive to the SSH command for the alias, whose remote command SHALL be fixed and contain no quote inside its single-quoted part, so that every common login shell runs it alike. Every value the remote side needs — the version, the Node path given, the install script — SHALL travel inside the archive, never in the command. On the remote host it SHALL refuse a system other than Linux; take the given Node path, else `node` on the command's PATH, else the path `$SHELL -l -i -c 'command -v node'` prints between markers; refuse a Node older than 20; and install as the installed layout states, without launcher or unit file. It SHALL succeed naming the release and the Node used, and fail with one line naming the cause: the remote installer's message, or else the last non-empty line SSH wrote to standard error. + +#### Scenario: Remote installed +- **WHEN** a remote installation runs to a host with Node 22 only in its login shell's PATH +- **THEN** the remote `~/.local/bin/wtd connect` bridges to a daemon of the installed version, run by that Node + +#### Scenario: Hostile Node path is data +- **WHEN** the given Node path is `/opt/n'; touch x; '/node` +- **THEN** no file `x` is created and the installation fails naming that path as not a usable Node + +#### Scenario: Missing Node reported +- **WHEN** the remote host has no Node of version 20 or later +- **THEN** the installation fails with a line naming the alias and the missing Node, and the remote layout is unchanged + +#### Scenario: Unreachable host reported +- **WHEN** SSH cannot reach the alias +- **THEN** the installation fails with SSH's last error line diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/protocol/spec.md b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/protocol/spec.md new file mode 100644 index 0000000..365c0a1 --- /dev/null +++ b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/protocol/spec.md @@ -0,0 +1,103 @@ +## MODIFIED Requirements + +### Requirement: Protocol version and frozen handshake messages +The protocol SHALL define an integer `PROTOCOL_VERSION`, equal to 5. The `hello` message SHALL be `{t: "hello", protocol, version, instance}` where `protocol` is an integer ≥ 1, `version` is a string of 1–64 characters, and `instance` is a string of 1–64 characters from `[A-Za-z0-9_-]`. The `shutdown` message SHALL be `{t: "shutdown"}`. The shapes of `hello` and `shutdown` and the stream frame header SHALL be identical in every protocol version, so that peers of different versions can always exchange them. + +#### Scenario: Hello round-trips +- **WHEN** a `hello` with `protocol` 1, `version` "0.1.0" and `instance` "a1B2_c3" is encoded and decoded +- **THEN** the decoded value equals the original + +#### Scenario: Hello from another protocol version is still decodable +- **WHEN** a `hello` carrying `protocol` 7 is decoded +- **THEN** decoding succeeds and yields `protocol` 7 + +#### Scenario: Invalid instance rejected +- **WHEN** a `hello` whose `instance` contains a space is decoded +- **THEN** decoding fails with a protocol error + +### Requirement: Browser-link message catalogue +Browser-to-hub messages SHALL be exactly: `hello`; `host{host, m}` where `m` is any client-to-daemon message except `hello` and `shutdown`; `addRepo{req, host, repo}`; `removeRepo{req, host, repo}`; `discoverRepos{req, host}`; `restartDaemon{req, host}`; `reinstallDaemon{req, host}`. +Hub-to-browser messages SHALL be exactly: `hello`; `token{token}`; `host{host, m}` where `m` is any daemon-to-client message except `hello`; `hosts{hosts}`; `presets{presets}`; `done{req}`; `error{req, host, code, message}` with `req` and `host` each nullable; `reposDiscovered{req, host, repos}`. +A host entry SHALL be `{idx, name, remote, status, reason, daemonVersion, instance, repos}` with `status` one of `connecting`, `connected`, `reconnecting`, `down`, `outdated`, and `reason`, `daemonVersion` and `instance` nullable; `instance` SHALL be the `hello.instance` of the daemon the host is `connected` to or found `outdated`, and null in every other status. A preset SHALL be `{name, command}` with `command` nullable. + +#### Scenario: Envelope cannot carry a handshake message +- **WHEN** a browser `host` envelope whose `m` is a `hello` or a `shutdown` is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Every browser-link message round-trips +- **WHEN** a valid instance of each browser-link message is encoded and decoded +- **THEN** each decoded value equals its original + +#### Scenario: Host entry without instance rejected +- **WHEN** a `hosts` message whose entry lacks `instance` is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Host entry without reason rejected +- **WHEN** a `hosts` message whose entry lacks `reason` is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Outdated host carries its instance +- **WHEN** a `hosts` entry with `status` "outdated" and a non-null `instance` is decoded +- **THEN** decoding succeeds and yields that instance + +#### Scenario: Token message only from the hub +- **WHEN** a `token` message is decoded as a browser-to-hub message +- **THEN** decoding fails with a protocol error + +### Requirement: Value limits +Decoding and encoding SHALL enforce: paths are absolute, at most 4096 characters, without NUL; discovery roots are absolute paths, `~`, or `~/` followed by at least one character, at most 4096 characters, without NUL; terminal ids are integers from 1 to 2^32−1; offsets are integers from 0 to 2^53−1; `cols` and `rows` are integers from 1 to 1000; host indices are integers from 0 to 65535; `depth` is 1–6; `head` is null or 40 or 64 lowercase hex digits; host names and tab ids are 1–64 characters; preset names are 1–64 characters with at least one non-whitespace character; `branch` and `command` are null or at most 4096 characters, and a preset's `command` is null or 1–4096 characters; `signal` is null or at most 32 characters; `version` is 1–64 characters; tokens and one-time codes are exactly 64 lowercase hex digits; error messages and host reasons are at most 1024 characters; error codes are one of `bad-message`, `unknown-host`, `unknown-term`, `unknown-worktree`, `not-watched`, `busy`, `spawn-failed`, `version-mismatch`, `not-a-repo`, `host-unavailable`, `internal`. Collections SHALL hold at most: 1024 worktrees, terminals, checked paths or layout entries per message; 32 discovery roots (at least 1); 4096 discovered repos; 4096 visible terminal ids; 64 hosts; 256 repos per host; 64 presets. + +#### Scenario: Relative path rejected +- **WHEN** a `watchRepo` whose `repo` is "repo/a" is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Home-relative discovery roots accepted +- **WHEN** a `discoverRepos` whose `roots` are "~" and "~/GitHub" is decoded +- **THEN** decoding succeeds and yields both roots unchanged + +#### Scenario: Other relative discovery roots rejected +- **WHEN** a `discoverRepos` whose `roots` hold "GitHub", "~user/x" or "~/" is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Home-relative repo rejected +- **WHEN** a `watchRepo` or `addRepo` whose `repo` is "~/x" is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Overlong host reason rejected +- **WHEN** a `hosts` entry whose `reason` has 1025 characters is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Collection over limit rejected +- **WHEN** a `worktreesChanged` with 1025 worktrees is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Not-a-repo error code accepted +- **WHEN** an `error` whose `code` is "not-a-repo" is decoded +- **THEN** decoding succeeds + +#### Scenario: Host-unavailable error code accepted +- **WHEN** a hub `error` whose `code` is "host-unavailable" is decoded +- **THEN** decoding succeeds + +#### Scenario: Malformed token rejected +- **WHEN** a `token` message whose `token` has 63 hex digits, or contains an uppercase digit, is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Unknown error code rejected +- **WHEN** an `error` whose `code` is "oops" is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Blank preset name rejected +- **WHEN** a `presets` message with a preset named " " is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Empty preset command rejected +- **WHEN** a `presets` message with a preset whose `command` is "" is decoded +- **THEN** decoding fails with a protocol error + +### Requirement: Shared constants +The protocol SHALL export: `PROTOCOL_VERSION` = 5; `MAX_FRAME` = 16 MiB; `MAX_INPUT` = 64 KiB; `FLOW_HIGH` = 512 KiB; `FLOW_LOW` = 128 KiB; `ACK_EVERY` = 64 KiB; `LAG_EVICT_MS` = 2000. + +#### Scenario: Constants hold their values +- **WHEN** the exported constants are read +- **THEN** each equals the value stated above diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/web/spec.md b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/web/spec.md new file mode 100644 index 0000000..3f3f678 --- /dev/null +++ b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/web/spec.md @@ -0,0 +1,97 @@ +## MODIFIED Requirements + +### Requirement: Reconnect and restore +When its WebSocket closes, the page SHALL show that it is reconnecting and reconnect with backoff from 250 ms doubling up to 5 s. Requests pending to a host SHALL fail when the session closes or the host leaves `connected`. When a host becomes `connected`, the page SHALL compare the instance with the last instance it saw `connected` for that host: if it differs, every terminal of that host SHALL be disposed and its state cleared; in both cases the page SHALL then watch each repo of the host and, once that watch is done, re-attach every terminal of that repo it had attached that is still listed. While a host stays `connected`, a `hosts` message listing a repo of that host not listed before SHALL make the page watch it, and one no longer listing a repo SHALL make the page unwatch it, drop its state and remove its tab — except that a repo whose terminals the page holds with a live process SHALL keep its tab and state until none is left. + +#### Scenario: Hub restart keeps terminals +- **WHEN** the hub restarts while the daemon keeps running +- **THEN** the page reconnects and each attached terminal shows the same screen in the same terminal object + +#### Scenario: Daemon restart drops terminals +- **WHEN** the local daemon is replaced by a new instance +- **THEN** the old terminals disappear from the page and no output of the new daemon is written into them + +#### Scenario: Remote link loss keeps terminals +- **WHEN** the SSH process of a connected remote host is killed and the host becomes `connected` again with the same instance +- **THEN** each attached terminal of that host shows the same screen in the same terminal object and receives new output + +#### Scenario: Repo added elsewhere is watched +- **WHEN** another page adds a repo to a connected host +- **THEN** this page shows the repo's tab with its worktrees + +### Requirement: Repo tabs +The page SHALL show one tab per repo of each host, in the order the hub lists them, labelled with the repo directory's name, adding leading path segments until labels of the same host are unique, and prefixed `:` for a remote host; the full path SHALL show on hover. A repo whose watch fails SHALL show the error and its path in its tab's area. A tab of a host that is not `connected` SHALL show the host's status. + +#### Scenario: Duplicate names disambiguated +- **WHEN** the repos are `/a/x/app` and `/b/y/app` +- **THEN** the tabs are labelled `x/app` and `y/app` + +#### Scenario: Watch failure shown +- **WHEN** a configured repo path is not a repository's main worktree +- **THEN** its tab shows the `not-a-repo` error naming the path + +#### Scenario: Remote tab shows host status +- **WHEN** a remote host `box` with repo `/srv/app` is `reconnecting` +- **THEN** the tab labelled `box:app` shows the status `reconnecting` + +### Requirement: Outdated host +For an `outdated` host the page SHALL show that its daemon is outdated, with an action — "Restart daemon" for the local host, "Reinstall & restart" for a remote host — that first asks for confirmation in the page and then sends `restartDaemon` or `reinstallDaemon`. For a `down` remote host it SHALL offer "Install", confirmed the same way, sending `reinstallDaemon`. The confirmation SHALL list the running terminals the page last saw on the daemon instance the host reports — each by worktree label and preset — with the time it last saw them; without such a record it SHALL say that every running terminal on the host will be killed. The page SHALL keep, per host, the running terminals it last saw with their daemon instance in browser storage, keyed separately for the local host and for each remote host name, at most 256 terminals per host; unavailable storage SHALL only lose the list. A failed action SHALL show its error on the page. + +#### Scenario: Restart from the page +- **WHEN** the local host is `outdated` and the user confirms the restart +- **THEN** the daemon is restarted and the host's repos are shown again + +#### Scenario: Confirmation names the terminals +- **WHEN** the page saw terminals `claude` on worktree `feat` and `shell` on `main` running on a daemon instance, is reloaded, and that host is then `outdated` with the same instance +- **THEN** the confirmation lists both terminals before anything is sent + +#### Scenario: Unknown terminals stated +- **WHEN** a host is `outdated` with an instance the page has no record of +- **THEN** the confirmation says every running terminal on the host will be killed + +#### Scenario: Reinstall from the page +- **WHEN** a remote host is `outdated` and the user confirms "Reinstall & restart" +- **THEN** the page sends `reinstallDaemon` and shows the host's repos once it is `connected` + +#### Scenario: Cancel sends nothing +- **WHEN** the user cancels the confirmation +- **THEN** no request is sent + +## ADDED Requirements + +### Requirement: Host status display +For each host that is `down` or `outdated` the page SHALL show a banner naming the host, its status and its `reason` when present, with the action the outdated host requirement states. A `connecting` or `reconnecting` host SHALL show its status only on its repo tabs. + +#### Scenario: Down host banner +- **WHEN** a remote host `box` is `down` with reason `ssh: Could not resolve hostname box` +- **THEN** the page shows a banner naming `box`, `down` and that reason, with an "Install" action + +#### Scenario: Recovered host clears the banner +- **WHEN** a `down` host becomes `connected` +- **THEN** its banner disappears + +### Requirement: Add repo +The repo tab bar SHALL end with an "Add repo" control opening a dialog with a host selector — listing every host with its status, hosts that are not `connected` disabled, preselecting the host of the selected tab — a list of the repos discovered on the selected host minus those it lists, a filter narrowing that list by substring, and a field for typing an absolute path. Choosing a repo or submitting a path SHALL send `addRepo`; on `done` the dialog SHALL close and the added repo's tab SHALL be selected once the hub lists it; an error SHALL be shown in the dialog. Escape SHALL close the dialog without sending anything. + +#### Scenario: Discovered repo added +- **WHEN** the user opens "Add repo", picks a discovered repo of the local host +- **THEN** its tab appears, is selected, and shows its worktrees + +#### Scenario: Configured repos not offered +- **WHEN** the local host lists repo `/r/a` and discovery finds `/r/a` and `/r/b` +- **THEN** the dialog offers only `/r/b` + +#### Scenario: Typed path refused +- **WHEN** the user submits a typed path that is not a repository's main worktree +- **THEN** the dialog shows the `not-a-repo` error and stays open + +### Requirement: Remove repo +Each repo tab SHALL offer a "Remove repo" action that asks for confirmation in the page and then sends `removeRepo`. A `busy` reply SHALL tell the user to close the repo's terminals first; on `done` the tab SHALL disappear once the hub no longer lists the repo. + +#### Scenario: Repo removed +- **WHEN** the user removes a repo without terminals and confirms +- **THEN** its tab disappears + +#### Scenario: Busy repo kept +- **WHEN** the user removes a repo with a terminal and confirms +- **THEN** the tab stays and the page says to close the repo's terminals first diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/tasks.md b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/tasks.md new file mode 100644 index 0000000..a013752 --- /dev/null +++ b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/tasks.md @@ -0,0 +1,57 @@ +## 1. Feasibility gate (STOP and flag on any failure) + +- [ ] 1.1 Probe OpenSSH ControlPersist against a reachable sshd (needs `openssh-server` on localhost with key auth — the user installs it with sudo; ask for it): whether the first `ssh -o ControlMaster=auto -o ControlPersist=10m -o ControlPath=…` client forks a backgrounded master, which of the client's stdio the master keeps open, whether killing the client leaves the master serving a second client, and whether killing the master ends every client; record the outcome in a handoff comment on DEV-2054 (design D5, D11a) +- [ ] 1.2 Probe the window class GNOME on Wayland reports for `google-chrome --app=http://127.0.0.1:/` (and whether it depends on the port), for the launcher's `StartupWMClass`; record it on DEV-2054 (design D11b) +- [ ] 1.3 Probe `$SHELL -l -i -c 'command -v node'` run through `ssh localhost` without a tty for bash, zsh (if present) and dash: stdout noise, stderr warnings, hangs; record it on DEV-2054 (design D11c) + +## 2. Architecture (approved edits) + +- [ ] 2.1 Add `platform.install` to `architecture/model/typescript.c4` with arrows `platform.install -> platform.files`, `platform.install -> platform.dialer`, `cli -> platform.install`, `hub.links -> platform.install`; set the `platform.arc42.md` purpose to "Node-only infrastructure shared by daemon, hub and CLI: XDG paths and file I/O (`files`); reaching a host's daemon over its socket, with auto-start, or over `ssh` (`dialer`); installing the bundle locally or over `ssh` (`install`)."; add `platform.install`: `fs`, `child_process` to the dependency allowlist in `test/architecture/dependencies.test.ts`; placeholder `src/platform/install/index.ts` keeping every new arrow live; regenerate the arc42 diagrams; verify `la-arch-check`, `likec4 validate architecture` and the architecture tests pass + +## 3. Tests (pr-tests stage; all fail before implementation) + +- [ ] 3.1 Protocol v5 unit tests: `PROTOCOL_VERSION` 5; host entry `reason` (required, nullable, 1024 limit) and `instance` for `outdated`; discovery roots `~`, `~/x` accepted, `GitHub`, `~user/x`, `~/` rejected; `~/x` repos rejected in `watchRepo` and `addRepo`; `wire.golden.json` expectations for version 5; CLI `--version` and connect hello show 5; verify they fail +- [ ] 3.2 Daemon unit/process tests: discovery expands `~` and `~/…` against the daemon's home (temp `HOME`) and dedupes; protocol-5 hello; verify they fail +- [ ] 3.3 Platform unit tests: SSH command line (option order, `--`, `WTD_SSH`, alias refusal incl. leading `-`, whitespace, control characters, 256 chars); control path and unit file paths (XDG handling, 107-byte limit); dialer through systemd (unit present → `WTD_SYSTEMCTL --user start`, failure falls back, absent → detached spawn); verify they fail +- [ ] 3.4 `platform.install` unit tests: ustar writer (names > 100 bytes via prefix, modes, symlink-free, round trip through `tar -t`); bootstrap command (fixed, no quote or backslash inside the single-quoted part); escaping for shim (paths with space, `'`, `$`, `%`, `\`), Desktop Entry `Exec` and systemd `ExecStart`, each checked by unescaping per its format; Node path validation (relative, NUL, newline refused); release naming and the keep-current-and-previous rule; verify they fail +- [ ] 3.5 Hub unit tests: config schema (`roots`, `hosts`, alias refusal, remote `~/` repos refused, duplicate names, 63 hosts, unknown host key); host transitions carrying `reason` and `instance` for `outdated`; stderr tail (4 KiB bound, control characters, last non-empty line, 1024 cut, invalid UTF-8); config editor (local/remote host, `~/` forms kept, absent file, duplicate add, remove by `~/` form, 256 limit, invalid file, host gone); host coordinator (sharing, restart-during-reinstall, queueing); verify they fail +- [ ] 3.6 `test/process` installers: `wtd install-local` into a temp `HOME` (layout, `current`, shim runs `--version`, launcher, home path with a space); `--systemd` with a fake `WTD_SYSTEMCTL` recording calls (daemon-reload, enable, start only without a running daemon); same-version reinstall keeps the first release; third install prunes the first; a process started from release 1 keeps working across two reinstalls; planted symlink / foreign-type paths refused with nothing written; `wtd install-remote` through a fake `WTD_SSH` that runs the remote command with `sh -c` under a second temp `HOME` and a PATH without Node, Node found through `--node`, through PATH and through `$SHELL -l -i`; hostile `--node` creates no file; missing Node and unreachable host fail with one line and exit 1; verify they fail +- [ ] 3.7 `test/process/hub-router.test.ts` / `hub-server.test.ts` with a fake remote (fake `WTD_SSH`, second temp `HOME`, installed with `install-remote`): remote host connected with name, index and `remote`; round trip through host 1; killed SSH process → `reconnecting` → `connected` with the same instance; failing SSH (stderr line) → `down` with that `reason`; 8 MiB unterminated stderr keeps RSS bounded; restart of a remote host; reinstall of an outdated (protocol-4 fake) and of a never-installed remote; restart during reinstall shares it; reinstall on host 0 refused; restart of a fake daemon speaking protocol 6 with non-v5 messages after `hello`; discover with configured roots and request-id isolation; add (done + `hosts` to two sessions, `not-a-repo`), remove (done, `busy` for running and for exited-unclosed terminals, deleted repo removed, `~/` form); config edits (invalid file untouched, concurrent writer between read and replace, host gone); verify they fail +- [ ] 3.8 `test/e2e/`: fixture with a fake remote host; every new and modified `web` scenario — host status on remote tabs, down banner with reason and "Install", banner cleared on recovery, outdated local "Restart daemon" and remote "Reinstall & restart" through the in-page dialog, dialog naming terminals after a reload (stored by instance), unknown-instance wording, cancel sends nothing; remote link loss keeps terminal objects; repo added from another page is watched; Add repo (discovered pick, configured repos not offered, typed path error, Escape); Remove repo (removed, busy); update PR 3/4 tests that used `window.confirm` for restart; verify they fail +- [ ] 3.9 `test/integration/` (opt-in, real `ssh localhost`, needs sshd): install-remote then connect round trip; reconnect after killing the hub's SSH process; outdated remote daemon reinstalled and restarted; closing one session spares the shared master; verify they fail + +## 4. Protocol v5 + +- [ ] 4.1 `PROTOCOL_VERSION` 5, host entry `reason` and `instance` for `outdated`, discovery-root schema; re-bless `wire.golden.json` (`frozen.golden.json` unchanged); verify 3.1 and the existing protocol tests pass + +## 5. Daemon + +- [ ] 5.1 `daemon.worktrees` discovery expands `~` roots against the daemon's home before resolving real paths (design D2); verify 3.2 passes + +## 6. Platform + +- [ ] 6.1 `platform.files`: control path and unit file paths, `lstat`-based ownership/type checks (design D7); `platform.dialer`: SSH command builder and systemd auto-start with fallback (design D4, D8); verify 3.3 passes +- [ ] 6.2 `platform.install`: bundle source resolution, ustar writer, release assembly and atomic `current` swap with pruning, shim/launcher/unit writers with per-format escaping, local and remote installation with the fixed bootstrap and in-archive script (design D3, D7, D8); verify 3.4 and 3.6 pass + +## 7. Hub + +- [ ] 7.1 `hub.config`: `roots`, `hosts`, remote repo rules; raw-file editor with identity/content check and retries (design D2, D9); verify the config parts of 3.5 pass +- [ ] 7.2 `hub.links`: `RemoteDaemon` over the SSH command with the D5 lifecycle and stderr tail; one-shot links; `installRemote` delegating to `platform.install`; verify the link parts of 3.5 and 3.7 pass +- [ ] 7.3 `hub.router`: per-session remote hosts, `reason`, outdated `instance`; endpoint-generic restarter; host coordinator; `reinstallDaemon`; hub-level `discoverRepos`, `addRepo`, `removeRepo`; edits pushed to every session (design D6, D9); verify 3.5 and 3.7 pass + +## 8. CLI + +- [ ] 8.1 Per-command option parsing, `install-local [--systemd]`, `install-remote [--node ]` delegating to `platform.install`, help text; remove the installer forward pointers; verify the CLI unit tests and 3.6 pass + +## 9. Web + +- [ ] 9.1 `web.client`: terminal memory per host key and instance (design D10); hub-level discover/add/remove/reinstall requests; watches follow `hosts` while connected, keeping repos with live terminals; verify their unit tests pass +- [ ] 9.2 `web.ui`: host status on repo tabs, down/outdated banners with actions, in-page restart/reinstall/install dialog naming terminals, Add repo dialog, Remove repo action; `icon.svg` in the web bundle as favicon; verify 3.8 passes + +## 10. Documentation + +- [ ] 10.1 README: remote hosts (config `hosts`, `roots`, absolute remote repos, `install-remote`, `--node`, sshd `MaxSessions`), `install-local`, the launcher, `--systemd` with `loginctl enable-linger`, `WTD_SSH`; verify the README's configuration examples parse in a unit test + +## 11. Final gates + +- [ ] 11.1 `pnpm test` green (incl. Playwright); `pnpm test:integration` green with sshd on localhost; `la-typecheck`, `la-arch-check`, `likec4 validate architecture` exit 0; `pnpm build` succeeds; `openspec validate dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install --strict` passes From 310a9aee8ddfd97d9a4f75a93bd12fad5be72279 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Wed, 7 Oct 2026 10:46:25 +0200 Subject: [PATCH 2/6] DEV-2054 PR 5: failing tests for remote hosts, installers, repo management and host status; platform.install in the model; probe outcomes (old Node on the plain PATH passed over) --- README.md | 18 +- architecture/hub.arc42.md | 4 +- architecture/model/typescript.c4 | 5 + architecture/platform.arc42.md | 9 +- architecture/system.arc42.md | 7 +- living-architecture.yaml | 18 +- .../design.md | 2 +- .../specs/platform/spec.md | 6 +- .../tasks.md | 26 +- src/cli/run.test.ts | 39 +- src/cli/run.ts | 15 +- src/daemon/terminals/attention.ts | 19 +- src/daemon/worktrees/discover.test.ts | 33 +- src/hub/config/config.test.ts | 156 ++++- src/hub/config/editor.test.ts | 180 ++++++ src/hub/links/index.ts | 4 + src/hub/links/remote.test.ts | 210 +++++++ src/hub/links/tail.test.ts | 64 ++ src/hub/router/coordinator.test.ts | 166 +++++ src/hub/router/hosts.test.ts | 45 +- src/platform/dialer/dialer.test.ts | 175 +++++- src/platform/files/files.test.ts | 70 ++- src/platform/install/index.ts | 13 + src/platform/install/install.test.ts | 383 ++++++++++++ src/protocol/constants.test.ts | 2 +- src/protocol/messages.test.ts | 43 ++ src/protocol/test-samples.ts | 29 +- src/protocol/wire.golden.test.ts | 19 +- src/web/client/memory.test.ts | 105 ++++ src/web/layout/tree.ts | 4 +- src/web/ui/app.tsx | 16 +- src/web/ui/view.ts | 13 +- test/architecture/dependencies.ts | 1 + test/architecture/structure.test.ts | 4 +- test/e2e/add-repo.spec.ts | 215 +++++++ test/e2e/contract.ts | 42 +- test/e2e/fixture.ts | 106 +++- test/e2e/hosts.spec.ts | 268 ++++++++ test/e2e/restore.spec.ts | 27 +- test/integration/ssh-localhost.test.ts | 194 ++++++ test/process/cli.test.ts | 32 +- test/process/connect.test.ts | 2 +- test/process/daemon-server.test.ts | 4 +- test/process/daemon-worktrees.test.ts | 7 + test/process/hub-cli.test.ts | 28 + test/process/hub-router.test.ts | 576 +++++++++++++++++- test/process/install-helpers.ts | 259 ++++++++ test/process/install-local.test.ts | 373 ++++++++++++ test/process/install-remote.test.ts | 275 +++++++++ test/support/fake-daemon.ts | 6 +- test/support/fake-ssh.ts | 293 +++++++++ test/support/hub-host.ts | 6 +- 52 files changed, 4444 insertions(+), 172 deletions(-) create mode 100644 src/hub/config/editor.test.ts create mode 100644 src/hub/links/remote.test.ts create mode 100644 src/hub/links/tail.test.ts create mode 100644 src/hub/router/coordinator.test.ts create mode 100644 src/platform/install/index.ts create mode 100644 src/platform/install/install.test.ts create mode 100644 src/web/client/memory.test.ts create mode 100644 test/e2e/add-repo.spec.ts create mode 100644 test/e2e/hosts.spec.ts create mode 100644 test/integration/ssh-localhost.test.ts create mode 100644 test/process/install-helpers.ts create mode 100644 test/process/install-local.test.ts create mode 100644 test/process/install-remote.test.ts create mode 100644 test/support/fake-ssh.ts diff --git a/README.md b/README.md index 812e0c4..840138c 100644 --- a/README.md +++ b/README.md @@ -21,15 +21,15 @@ The hub reads `$XDG_CONFIG_HOME/worktree-term/config.json` (by default `~/.confi ## Keyboard -| Keys | Action | -| --- | --- | -| Alt+Up / Alt+Down | Previous / next worktree in the sidebar, under its filter | -| Alt+Left / Alt+Right | Previous / next terminal tab | -| Alt+Shift+arrows | Focus the pane in that direction | -| Ctrl+Shift+T | New tab | -| Ctrl+Shift+D / Ctrl+Shift+E | Split the focused pane right / down | -| Ctrl+Shift+W | Close the focused pane | -| Ctrl+Shift+C / Ctrl+Shift+V | Copy the selection / paste | +| Keys | Action | +| --------------------------- | --------------------------------------------------------- | +| Alt+Up / Alt+Down | Previous / next worktree in the sidebar, under its filter | +| Alt+Left / Alt+Right | Previous / next terminal tab | +| Alt+Shift+arrows | Focus the pane in that direction | +| Ctrl+Shift+T | New tab | +| Ctrl+Shift+D / Ctrl+Shift+E | Split the focused pane right / down | +| Ctrl+Shift+W | Close the focused pane | +| Ctrl+Shift+C / Ctrl+Shift+V | Copy the selection / paste | The preset picker takes 1–9, the arrow keys and Enter, and Escape cancels it. diff --git a/architecture/hub.arc42.md b/architecture/hub.arc42.md index 0688878..c89d0b4 100644 --- a/architecture/hub.arc42.md +++ b/architecture/hub.arc42.md @@ -16,6 +16,7 @@ flowchart TD subgraph platform["Platform"] platform__files["Files"] platform__dialer["Dialer"] + platform__install["Install"] end subgraph hub["Hub"] hub__server["Server"] @@ -29,6 +30,7 @@ flowchart TD hub__server --> platform__files hub__config --> platform__files hub__links --> platform__dialer + hub__links --> platform__install hub__main --> hub__server hub__main --> hub__router hub__main --> hub__links @@ -38,7 +40,7 @@ flowchart TD hub__router --> hub__config cli --> hub__main classDef leaf fill:none; - class protocol,platform__files,platform__dialer,hub__server,hub__links,hub__config,hub__router,hub__main,cli leaf; + class protocol,platform__files,platform__dialer,platform__install,hub__server,hub__links,hub__config,hub__router,hub__main,cli leaf; ``` diff --git a/architecture/model/typescript.c4 b/architecture/model/typescript.c4 index f8d9107..d61c61b 100644 --- a/architecture/model/typescript.c4 +++ b/architecture/model/typescript.c4 @@ -24,6 +24,7 @@ model { } files = component 'Files' dialer = component 'Dialer' + install = component 'Install' } daemon = node 'Daemon' { @@ -74,6 +75,8 @@ model { } platform.dialer -> platform.files + platform.install -> platform.files + platform.install -> platform.dialer daemon -> protocol daemon -> platform.files @@ -89,6 +92,7 @@ model { hub.server -> platform.files hub.config -> platform.files hub.links -> platform.dialer + hub.links -> platform.install hub.main -> hub.server hub.main -> hub.router hub.main -> hub.links @@ -101,6 +105,7 @@ model { cli -> hub.main cli -> platform.files cli -> platform.dialer + cli -> platform.install cli -> protocol web.client -> protocol diff --git a/architecture/platform.arc42.md b/architecture/platform.arc42.md index b61fee5..f207ca0 100644 --- a/architecture/platform.arc42.md +++ b/architecture/platform.arc42.md @@ -2,7 +2,7 @@ ## Purpose & context -Node-only infrastructure shared by daemon, hub and CLI: XDG paths and file I/O (`files`); daemon socket connect and auto-start (`dialer`). +Node-only infrastructure shared by daemon, hub and CLI: XDG paths and file I/O (`files`); reaching a host's daemon over its socket, with auto-start, or over `ssh` (`dialer`); installing the bundle locally or over `ssh` (`install`). ## Building blocks @@ -15,6 +15,7 @@ flowchart TD subgraph platform["Platform"] platform__files["Files"] platform__dialer["Dialer"] + platform__install["Install"] end daemon["Daemon"] subgraph hub["Hub"] @@ -24,14 +25,18 @@ flowchart TD end cli["CLI"] platform__dialer --> platform__files + platform__install --> platform__files + platform__install --> platform__dialer daemon --> platform__files hub__server --> platform__files hub__config --> platform__files hub__links --> platform__dialer + hub__links --> platform__install cli --> platform__files cli --> platform__dialer + cli --> platform__install classDef leaf fill:none; - class platform__files,platform__dialer,daemon,hub__server,hub__links,hub__config,cli leaf; + class platform__files,platform__dialer,platform__install,daemon,hub__server,hub__links,hub__config,cli leaf; ``` diff --git a/architecture/system.arc42.md b/architecture/system.arc42.md index f39f594..4de6eca 100644 --- a/architecture/system.arc42.md +++ b/architecture/system.arc42.md @@ -16,6 +16,7 @@ flowchart TD subgraph platform["Platform"] platform__files["Files"] platform__dialer["Dialer"] + platform__install["Install"] end subgraph daemon["Daemon"] daemon__worktrees["Worktrees"] @@ -40,6 +41,8 @@ flowchart TD web__main["Main"] end platform__dialer --> platform__files + platform__install --> platform__files + platform__install --> platform__dialer daemon --> protocol daemon --> platform__files daemon__main --> daemon__server @@ -53,6 +56,7 @@ flowchart TD hub__server --> platform__files hub__config --> platform__files hub__links --> platform__dialer + hub__links --> platform__install hub__main --> hub__server hub__main --> hub__router hub__main --> hub__links @@ -64,6 +68,7 @@ flowchart TD cli --> hub__main cli --> platform__files cli --> platform__dialer + cli --> platform__install cli --> protocol web__client --> protocol web__main --> web__ui @@ -74,7 +79,7 @@ flowchart TD web__ui --> web__layout web__terminals --> web__client classDef leaf fill:none; - class protocol,platform__files,platform__dialer,daemon__worktrees,daemon__terminals,daemon__state,daemon__server,daemon__main,hub__server,hub__links,hub__config,hub__router,hub__main,cli,web__client,web__terminals,web__layout,web__ui,web__main leaf; + class protocol,platform__files,platform__dialer,platform__install,daemon__worktrees,daemon__terminals,daemon__state,daemon__server,daemon__main,hub__server,hub__links,hub__config,hub__router,hub__main,cli,web__client,web__terminals,web__layout,web__ui,web__main leaf; ``` diff --git a/living-architecture.yaml b/living-architecture.yaml index 99ac7f8..c942ea1 100644 --- a/living-architecture.yaml +++ b/living-architecture.yaml @@ -1,13 +1,13 @@ -tracker: linear # detected: Linear MCP + egor/dev-NNNN- branches -openspec: true # detected: openspec/ -architecture: true # detected: architecture/index.yaml +tracker: linear # detected: Linear MCP + egor/dev-NNNN- branches +openspec: true # detected: openspec/ +architecture: true # detected: architecture/index.yaml reviewers: - codex: true # detected: Codex MCP server available + codex: true # detected: Codex MCP server available sonar: - project_key: MotleyAI_worktree-term # plan: design.md D8 -issue_key_pattern: "DEV-\\d+" # plan: design.md D8 + project_key: MotleyAI_worktree-term # plan: design.md D8 +issue_key_pattern: "DEV-\\d+" # plan: design.md D8 commands: - test: pnpm test # plan: design.md D8 - lint: pnpm lint # plan: design.md D8 + test: pnpm test # plan: design.md D8 + lint: pnpm lint # plan: design.md D8 typecheck: - typescript: tsc -b # plan: design.md D8 + typescript: tsc -b # plan: design.md D8 diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/design.md b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/design.md index e8e684b..db344fc 100644 --- a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/design.md +++ b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/design.md @@ -27,7 +27,7 @@ Host entries gain `reason`; `instance` is also set for `outdated` (from the froz The hub expands `~/` only in local `repos` (as before); remote `repos` must be absolute, and discovery roots on every host stay unexpanded until the daemon expands them against its own home. Alternative — the hub learning a remote's `$HOME` through a second SSH command — rejected: a second channel outside `wtd connect`, an extra round trip per connect, and symlinked homes would make a configured path differ from the daemon's real path. ### D3 Remote command and shim -The hub runs `"$HOME/.local/bin/wtd" connect` (double-quoted `$HOME` parses alike in sh, bash, zsh and fish). The shim execs an absolute Node chosen at install time: the running Node locally; remotely `--node`, else `node` on the plain PATH, else `$SHELL -l -i -c 'command -v node'` with stdout between markers (profile noise ignored). Alternative — `#!/usr/bin/env node` and documenting PATH setup — rejected: broken on a stock Ubuntu remote and on this machine. Re-running the installer picks up a moved Node. +The hub runs `"$HOME/.local/bin/wtd" connect` (double-quoted `$HOME` parses alike in sh, bash, zsh and fish). The shim execs an absolute Node chosen at install time: the running Node locally; remotely `--node`, else the first Node ≥ 20 of `node` on the plain PATH and `$SHELL -l -i -c 'command -v node'` (stdout between markers, profile noise ignored); a stock Ubuntu remote often has apt's Node 18 on the plain PATH. Alternative — `#!/usr/bin/env node` and documenting PATH setup — rejected: broken on a stock Ubuntu remote and on this machine. Re-running the installer picks up a moved Node. ### D4 SSH command line (`platform.dialer`) One builder used by the hub link and the installer: `BatchMode=yes` (no tty to prompt on), `ConnectTimeout=10`, `ControlMaster=auto` + `ControlPersist=10m` + `ControlPath=/run/ssh-%C` (without a ControlPath, `ControlMaster=auto` does not multiplex), `ServerAliveInterval=15`, `--` before the alias, and alias validation (no leading `-`, no whitespace or control characters). `WTD_SSH` replaces the program, as `WTD_BROWSER` does for the browser; the process and e2e tiers use a fake SSH that runs the remote command under a second temporary home. Sessions share one master per host by design: a killed client reconnects alone; a killed master makes every link of that host reconnect. diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/platform/spec.md b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/platform/spec.md index bd12089..6ad6269 100644 --- a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/platform/spec.md +++ b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/platform/spec.md @@ -98,12 +98,16 @@ A local installation SHALL install, as the installed layout states, the bundle o - **THEN** the unit is enabled but not started, and the running daemon keeps its instance ### Requirement: Remote installation -A remote installation SHALL send the bundle of the running `wtd` as one tar archive to the SSH command for the alias, whose remote command SHALL be fixed and contain no quote inside its single-quoted part, so that every common login shell runs it alike. Every value the remote side needs — the version, the Node path given, the install script — SHALL travel inside the archive, never in the command. On the remote host it SHALL refuse a system other than Linux; take the given Node path, else `node` on the command's PATH, else the path `$SHELL -l -i -c 'command -v node'` prints between markers; refuse a Node older than 20; and install as the installed layout states, without launcher or unit file. It SHALL succeed naming the release and the Node used, and fail with one line naming the cause: the remote installer's message, or else the last non-empty line SSH wrote to standard error. +A remote installation SHALL send the bundle of the running `wtd` as one tar archive to the SSH command for the alias, whose remote command SHALL be fixed and contain no quote inside its single-quoted part, so that every common login shell runs it alike. Every value the remote side needs — the version, the Node path given, the install script — SHALL travel inside the archive, never in the command. On the remote host it SHALL refuse a system other than Linux; take the given Node path, refusing it when it is older than 20; without one, take the first Node of version 20 or later among `node` on the command's PATH and the path `$SHELL -l -i -c 'command -v node'` prints between markers, in that order; and install as the installed layout states, without launcher or unit file. It SHALL succeed naming the release and the Node used, and fail with one line naming the cause: the remote installer's message, or else the last non-empty line SSH wrote to standard error. #### Scenario: Remote installed - **WHEN** a remote installation runs to a host with Node 22 only in its login shell's PATH - **THEN** the remote `~/.local/bin/wtd connect` bridges to a daemon of the installed version, run by that Node +#### Scenario: Old Node on the command's PATH passed over +- **WHEN** the command's PATH holds Node 18 and the login shell's PATH holds Node 22 +- **THEN** the installation uses the login shell's Node 22 + #### Scenario: Hostile Node path is data - **WHEN** the given Node path is `/opt/n'; touch x; '/node` - **THEN** no file `x` is created and the installation fails naming that path as not a usable Node diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/tasks.md b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/tasks.md index a013752..a1132b1 100644 --- a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/tasks.md +++ b/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/tasks.md @@ -1,24 +1,24 @@ ## 1. Feasibility gate (STOP and flag on any failure) -- [ ] 1.1 Probe OpenSSH ControlPersist against a reachable sshd (needs `openssh-server` on localhost with key auth — the user installs it with sudo; ask for it): whether the first `ssh -o ControlMaster=auto -o ControlPersist=10m -o ControlPath=…` client forks a backgrounded master, which of the client's stdio the master keeps open, whether killing the client leaves the master serving a second client, and whether killing the master ends every client; record the outcome in a handoff comment on DEV-2054 (design D5, D11a) -- [ ] 1.2 Probe the window class GNOME on Wayland reports for `google-chrome --app=http://127.0.0.1:/` (and whether it depends on the port), for the launcher's `StartupWMClass`; record it on DEV-2054 (design D11b) -- [ ] 1.3 Probe `$SHELL -l -i -c 'command -v node'` run through `ssh localhost` without a tty for bash, zsh (if present) and dash: stdout noise, stderr warnings, hangs; record it on DEV-2054 (design D11c) +- [x] 1.1 Probe OpenSSH ControlPersist against a reachable sshd (needs `openssh-server` on localhost with key auth — the user installs it with sudo; ask for it): whether the first `ssh -o ControlMaster=auto -o ControlPersist=10m -o ControlPath=…` client forks a backgrounded master, which of the client's stdio the master keeps open, whether killing the client leaves the master serving a second client, and whether killing the master ends every client; record the outcome in a handoff comment on DEV-2054 (design D5, D11a) +- [x] 1.2 Probe the window class GNOME on Wayland reports for `google-chrome --app=http://127.0.0.1:/` (and whether it depends on the port), for the launcher's `StartupWMClass`; record it on DEV-2054 (design D11b) +- [x] 1.3 Probe `$SHELL -l -i -c 'command -v node'` run through `ssh localhost` without a tty for bash, zsh (if present) and dash: stdout noise, stderr warnings, hangs; record it on DEV-2054 (design D11c) ## 2. Architecture (approved edits) -- [ ] 2.1 Add `platform.install` to `architecture/model/typescript.c4` with arrows `platform.install -> platform.files`, `platform.install -> platform.dialer`, `cli -> platform.install`, `hub.links -> platform.install`; set the `platform.arc42.md` purpose to "Node-only infrastructure shared by daemon, hub and CLI: XDG paths and file I/O (`files`); reaching a host's daemon over its socket, with auto-start, or over `ssh` (`dialer`); installing the bundle locally or over `ssh` (`install`)."; add `platform.install`: `fs`, `child_process` to the dependency allowlist in `test/architecture/dependencies.test.ts`; placeholder `src/platform/install/index.ts` keeping every new arrow live; regenerate the arc42 diagrams; verify `la-arch-check`, `likec4 validate architecture` and the architecture tests pass +- [x] 2.1 Add `platform.install` to `architecture/model/typescript.c4` with arrows `platform.install -> platform.files`, `platform.install -> platform.dialer`, `cli -> platform.install`, `hub.links -> platform.install`; set the `platform.arc42.md` purpose to "Node-only infrastructure shared by daemon, hub and CLI: XDG paths and file I/O (`files`); reaching a host's daemon over its socket, with auto-start, or over `ssh` (`dialer`); installing the bundle locally or over `ssh` (`install`)."; add `platform.install`: `fs`, `child_process` to the dependency allowlist in `test/architecture/dependencies.test.ts`; placeholder `src/platform/install/index.ts` keeping every new arrow live; regenerate the arc42 diagrams; verify `la-arch-check`, `likec4 validate architecture` and the architecture tests pass ## 3. Tests (pr-tests stage; all fail before implementation) -- [ ] 3.1 Protocol v5 unit tests: `PROTOCOL_VERSION` 5; host entry `reason` (required, nullable, 1024 limit) and `instance` for `outdated`; discovery roots `~`, `~/x` accepted, `GitHub`, `~user/x`, `~/` rejected; `~/x` repos rejected in `watchRepo` and `addRepo`; `wire.golden.json` expectations for version 5; CLI `--version` and connect hello show 5; verify they fail -- [ ] 3.2 Daemon unit/process tests: discovery expands `~` and `~/…` against the daemon's home (temp `HOME`) and dedupes; protocol-5 hello; verify they fail -- [ ] 3.3 Platform unit tests: SSH command line (option order, `--`, `WTD_SSH`, alias refusal incl. leading `-`, whitespace, control characters, 256 chars); control path and unit file paths (XDG handling, 107-byte limit); dialer through systemd (unit present → `WTD_SYSTEMCTL --user start`, failure falls back, absent → detached spawn); verify they fail -- [ ] 3.4 `platform.install` unit tests: ustar writer (names > 100 bytes via prefix, modes, symlink-free, round trip through `tar -t`); bootstrap command (fixed, no quote or backslash inside the single-quoted part); escaping for shim (paths with space, `'`, `$`, `%`, `\`), Desktop Entry `Exec` and systemd `ExecStart`, each checked by unescaping per its format; Node path validation (relative, NUL, newline refused); release naming and the keep-current-and-previous rule; verify they fail -- [ ] 3.5 Hub unit tests: config schema (`roots`, `hosts`, alias refusal, remote `~/` repos refused, duplicate names, 63 hosts, unknown host key); host transitions carrying `reason` and `instance` for `outdated`; stderr tail (4 KiB bound, control characters, last non-empty line, 1024 cut, invalid UTF-8); config editor (local/remote host, `~/` forms kept, absent file, duplicate add, remove by `~/` form, 256 limit, invalid file, host gone); host coordinator (sharing, restart-during-reinstall, queueing); verify they fail -- [ ] 3.6 `test/process` installers: `wtd install-local` into a temp `HOME` (layout, `current`, shim runs `--version`, launcher, home path with a space); `--systemd` with a fake `WTD_SYSTEMCTL` recording calls (daemon-reload, enable, start only without a running daemon); same-version reinstall keeps the first release; third install prunes the first; a process started from release 1 keeps working across two reinstalls; planted symlink / foreign-type paths refused with nothing written; `wtd install-remote` through a fake `WTD_SSH` that runs the remote command with `sh -c` under a second temp `HOME` and a PATH without Node, Node found through `--node`, through PATH and through `$SHELL -l -i`; hostile `--node` creates no file; missing Node and unreachable host fail with one line and exit 1; verify they fail -- [ ] 3.7 `test/process/hub-router.test.ts` / `hub-server.test.ts` with a fake remote (fake `WTD_SSH`, second temp `HOME`, installed with `install-remote`): remote host connected with name, index and `remote`; round trip through host 1; killed SSH process → `reconnecting` → `connected` with the same instance; failing SSH (stderr line) → `down` with that `reason`; 8 MiB unterminated stderr keeps RSS bounded; restart of a remote host; reinstall of an outdated (protocol-4 fake) and of a never-installed remote; restart during reinstall shares it; reinstall on host 0 refused; restart of a fake daemon speaking protocol 6 with non-v5 messages after `hello`; discover with configured roots and request-id isolation; add (done + `hosts` to two sessions, `not-a-repo`), remove (done, `busy` for running and for exited-unclosed terminals, deleted repo removed, `~/` form); config edits (invalid file untouched, concurrent writer between read and replace, host gone); verify they fail -- [ ] 3.8 `test/e2e/`: fixture with a fake remote host; every new and modified `web` scenario — host status on remote tabs, down banner with reason and "Install", banner cleared on recovery, outdated local "Restart daemon" and remote "Reinstall & restart" through the in-page dialog, dialog naming terminals after a reload (stored by instance), unknown-instance wording, cancel sends nothing; remote link loss keeps terminal objects; repo added from another page is watched; Add repo (discovered pick, configured repos not offered, typed path error, Escape); Remove repo (removed, busy); update PR 3/4 tests that used `window.confirm` for restart; verify they fail -- [ ] 3.9 `test/integration/` (opt-in, real `ssh localhost`, needs sshd): install-remote then connect round trip; reconnect after killing the hub's SSH process; outdated remote daemon reinstalled and restarted; closing one session spares the shared master; verify they fail +- [x] 3.1 Protocol v5 unit tests: `PROTOCOL_VERSION` 5; host entry `reason` (required, nullable, 1024 limit) and `instance` for `outdated`; discovery roots `~`, `~/x` accepted, `GitHub`, `~user/x`, `~/` rejected; `~/x` repos rejected in `watchRepo` and `addRepo`; `wire.golden.json` expectations for version 5; CLI `--version` and connect hello show 5; verify they fail +- [x] 3.2 Daemon unit/process tests: discovery expands `~` and `~/…` against the daemon's home (temp `HOME`) and dedupes; protocol-5 hello; verify they fail +- [x] 3.3 Platform unit tests: SSH command line (option order, `--`, `WTD_SSH`, alias refusal incl. leading `-`, whitespace, control characters, 256 chars); control path and unit file paths (XDG handling, 107-byte limit); dialer through systemd (unit present → `WTD_SYSTEMCTL --user start`, failure falls back, absent → detached spawn); verify they fail +- [x] 3.4 `platform.install` unit tests: ustar writer (names > 100 bytes via prefix, modes, symlink-free, round trip through `tar -t`); bootstrap command (fixed, no quote or backslash inside the single-quoted part); escaping for shim (paths with space, `'`, `$`, `%`, `\`), Desktop Entry `Exec` and systemd `ExecStart`, each checked by unescaping per its format; Node path validation (relative, NUL, newline refused); release naming and the keep-current-and-previous rule; verify they fail +- [x] 3.5 Hub unit tests: config schema (`roots`, `hosts`, alias refusal, remote `~/` repos refused, duplicate names, 63 hosts, unknown host key); host transitions carrying `reason` and `instance` for `outdated`; stderr tail (4 KiB bound, control characters, last non-empty line, 1024 cut, invalid UTF-8); config editor (local/remote host, `~/` forms kept, absent file, duplicate add, remove by `~/` form, 256 limit, invalid file, host gone); host coordinator (sharing, restart-during-reinstall, queueing); verify they fail +- [x] 3.6 `test/process` installers: `wtd install-local` into a temp `HOME` (layout, `current`, shim runs `--version`, launcher, home path with a space); `--systemd` with a fake `WTD_SYSTEMCTL` recording calls (daemon-reload, enable, start only without a running daemon); same-version reinstall keeps the first release; third install prunes the first; a process started from release 1 keeps working across two reinstalls; planted symlink / foreign-type paths refused with nothing written; `wtd install-remote` through a fake `WTD_SSH` that runs the remote command with `sh -c` under a second temp `HOME` and a PATH without Node, Node found through `--node`, through PATH and through `$SHELL -l -i`, Node 18 on PATH passed over for the login shell's Node 22; hostile `--node` creates no file; missing Node and unreachable host fail with one line and exit 1; verify they fail +- [x] 3.7 `test/process/hub-router.test.ts` / `hub-server.test.ts` with a fake remote (fake `WTD_SSH`, second temp `HOME`, installed with `install-remote`): remote host connected with name, index and `remote`; round trip through host 1; killed SSH process → `reconnecting` → `connected` with the same instance; failing SSH (stderr line) → `down` with that `reason`; 8 MiB unterminated stderr keeps RSS bounded; restart of a remote host; reinstall of an outdated (protocol-4 fake) and of a never-installed remote; restart during reinstall shares it; reinstall on host 0 refused; restart of a fake daemon speaking protocol 6 with non-v5 messages after `hello`; discover with configured roots and request-id isolation; add (done + `hosts` to two sessions, `not-a-repo`), remove (done, `busy` for running and for exited-unclosed terminals, deleted repo removed, `~/` form); config edits (invalid file untouched, host gone; the concurrent writer between read and replace is covered by the `ConfigEditor` unit test of 3.5); verify they fail +- [x] 3.8 `test/e2e/`: fixture with a fake remote host; every new and modified `web` scenario — host status on remote tabs, down banner with reason and "Install", banner cleared on recovery, outdated local "Restart daemon" and remote "Reinstall & restart" through the in-page dialog, dialog naming terminals after a reload (stored by instance), unknown-instance wording, cancel sends nothing; remote link loss keeps terminal objects; repo added from another page is watched; Add repo (discovered pick, configured repos not offered, typed path error, Escape); Remove repo (removed, busy); update PR 3/4 tests that used `window.confirm` for restart; verify they fail +- [x] 3.9 `test/integration/` (opt-in, real `ssh localhost`, needs sshd): install-remote then connect round trip; reconnect after killing the hub's SSH process; outdated remote daemon reinstalled and restarted; closing one session spares the shared master; verify they fail ## 4. Protocol v5 diff --git a/src/cli/run.test.ts b/src/cli/run.test.ts index 0be20e0..2999088 100644 --- a/src/cli/run.test.ts +++ b/src/cli/run.test.ts @@ -46,7 +46,7 @@ const expectUsageError = async (result: Result, mentions: RegExp): Promise describe('wtd --version', () => { it('prints the package and protocol versions', async () => { - expect(await wtd('--version')).toEqual({ code: 0, stdout: `wtd ${packageVersion()} (protocol 4)\n`, stderr: '' }); + expect(await wtd('--version')).toEqual({ code: 0, stdout: `wtd ${packageVersion()} (protocol 5)\n`, stderr: '' }); }); }); @@ -58,7 +58,8 @@ describe('wtd --help', () => { for (const word of [...COMMANDS, '--help', '--version']) { expect(stdout).toMatch(new RegExp(`(^|\\s)${word}(\\s|$)`, 'm')); } - expect(stdout).toContain('install-remote '); + expect(stdout).toContain('install-local [--systemd]'); + expect(stdout).toContain('install-remote [--node ]'); }); }); @@ -86,13 +87,33 @@ describe('usage errors', () => { it('requires an alias for install-remote', async () => { await expectUsageError(await wtd('install-remote'), /alias.*required|required.*alias/i); }); -}); -describe('not-yet-implemented commands', () => { - it.each([ - ['install-local', ['install-local']], - ['install-remote', ['install-remote', 'devbox']], - ])('%s prints "not implemented" and exits 2', async (verb, argv) => { - expect(await wtd(...argv)).toEqual({ code: 2, stdout: '', stderr: `wtd ${verb}: not implemented\n` }); + it('requires an alias for install-remote even with --node', async () => { + await expectUsageError(await wtd('install-remote', '--node', '/usr/bin/node'), /alias.*required|required.*alias/i); + }); + + it('names --systemd as unknown for install-remote', async () => { + await expectUsageError(await wtd('install-remote', 'box', '--systemd'), /--systemd.*install-remote|install-remote.*--systemd/); + }); + + it('names --node as unknown for install-local', async () => { + await expectUsageError(await wtd('install-local', '--node', '/usr/bin/node'), /--node.*install-local|install-local.*--node/); + }); + + it.each(['ui', 'hub', 'daemon', 'connect'])('names --node and --systemd as unknown for %s', async (verb) => { + await expectUsageError(await wtd(verb, '--systemd'), /--systemd/); + await expectUsageError(await wtd(verb, '--node', '/n'), /--node/); + }); + + it('requires a value for --node', async () => { + await expectUsageError(await wtd('install-remote', 'box', '--node'), /--node.*value|value.*--node/i); + }); + + it('refuses --systemd with a value', async () => { + await expectUsageError(await wtd('install-local', '--systemd=yes'), /--systemd/); + }); + + it('refuses a second alias', async () => { + await expectUsageError(await wtd('install-remote', 'box', 'other'), /other/); }); }); diff --git a/src/cli/run.ts b/src/cli/run.ts index d6b069f..4a7db51 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -7,6 +7,7 @@ import { AlreadyRunningError, runDaemon } from '../daemon/main/index.js'; import { HubAlreadyRunningError, openUi, runHub } from '../hub/main/index.js'; import { dial } from '../platform/dialer/index.js'; import { currentHostPaths } from '../platform/files/index.js'; +import { installLocal, installRemote } from '../platform/install/index.js'; import { PROTOCOL_VERSION } from '../protocol/index.js'; export interface CliIo { @@ -40,16 +41,6 @@ interface Command { run: (io: CliIo) => Promise; } -/** Adapts a synchronous placeholder entry to a command. */ -const placeholderRun = (entry: () => void) => (): Promise => { - entry(); - return Promise.resolve(0); -}; - -const notImplemented = (): void => { - throw new Error(NOT_IMPLEMENTED); -}; - const message = (error: unknown): string => (error instanceof Error ? error.message : String(error)).replace(/\s+/g, (run) => (run.includes('\n') ? ' ' : run)); @@ -131,8 +122,8 @@ const COMMANDS: ReadonlyMap = new Map([ ['hub', { args: [], run: hub }], ['daemon', { args: [], run: daemon }], ['connect', { args: [], run: connect }], - ['install-local', { args: [], run: placeholderRun(notImplemented) }], - ['install-remote', { args: ['alias'], run: placeholderRun(notImplemented) }], + ['install-local', { args: [], run: installLocal }], + ['install-remote', { args: ['alias'], run: installRemote }], ]); class UsageError extends Error { diff --git a/src/daemon/terminals/attention.ts b/src/daemon/terminals/attention.ts index d2d6260..8efe21e 100644 --- a/src/daemon/terminals/attention.ts +++ b/src/daemon/terminals/attention.ts @@ -115,14 +115,17 @@ export class Attention { private arm(ms: number): void { if (this.timer !== null) return; const timer = { - handle: this.clock.setTimeout(() => { - if (this.timer !== timer || this.ended) return; - this.timer = null; - if (this.state !== 'working') return; - const remaining = this.quietSince + IDLE_AFTER_MS - this.clock.now(); - if (remaining > 0) this.arm(remaining); - else this.set('idle'); - }, Math.max(0, ms)), + handle: this.clock.setTimeout( + () => { + if (this.timer !== timer || this.ended) return; + this.timer = null; + if (this.state !== 'working') return; + const remaining = this.quietSince + IDLE_AFTER_MS - this.clock.now(); + if (remaining > 0) this.arm(remaining); + else this.set('idle'); + }, + Math.max(0, ms), + ), }; this.timer = timer; } diff --git a/src/daemon/worktrees/discover.test.ts b/src/daemon/worktrees/discover.test.ts index aaae7e1..22dd37d 100644 --- a/src/daemon/worktrees/discover.test.ts +++ b/src/daemon/worktrees/discover.test.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process'; import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { discoverRepos } from './index.js'; let root: string; @@ -12,6 +12,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllEnvs(); chmodSync(root, 0o700); rmSync(root, { recursive: true, force: true }); }); @@ -125,4 +126,34 @@ describe('discoverRepos', () => { expect(first).toEqual(expected); expect(second).toEqual(expected); }, 30_000); + + describe('home-relative roots', () => { + beforeEach(() => { + mkdirSync(join(root, 'home')); + vi.stubEnv('HOME', join(root, 'home')); + }); + + it('expands ~ and ~/… against the home directory and reports each repo once', async () => { + const app = repo('home', 'GitHub', 'app'); + expect(await discoverRepos(['~', '~/GitHub'], 3)).toEqual([app]); + }); + + it('counts depth from the expanded root', async () => { + const near = repo('home', 'near'); + repo('home', 'a', 'b', 'far'); + expect(await discoverRepos(['~'], 2)).toEqual([near]); + }); + + it('reports repos by their real paths when the home is a symbolic link', async () => { + const app = repo('realhome', 'src', 'app'); + vi.stubEnv('HOME', join(root, 'linkhome')); + symlinkSync(join(root, 'realhome'), join(root, 'linkhome')); + expect(await discoverRepos(['~/src'], 1)).toEqual([app]); + }); + + it('skips a missing home-relative root', async () => { + const one = repo('home', 'one'); + expect(await discoverRepos(['~/missing', '~'], 1)).toEqual([one]); + }); + }); }); diff --git a/src/hub/config/config.test.ts b/src/hub/config/config.test.ts index 0464ca8..8370643 100644 --- a/src/hub/config/config.test.ts +++ b/src/hub/config/config.test.ts @@ -40,11 +40,11 @@ const presets = (n: number): { name: string; command: string | null }[] => describe('parseConfig', () => { it('uses port 7417, no repos and the shell preset for a missing file', () => { expect(DEFAULT_PORT).toBe(7417); - expect(parseConfig(null, HOME)).toEqual({ port: 7417, repos: [], presets: SHELL }); + expect(parseConfig(null, HOME)).toEqual({ port: 7417, repos: [], roots: ['~'], presets: SHELL, hosts: [] }); }); it('uses the defaults for an empty object', () => { - expect(parse({})).toEqual({ port: 7417, repos: [], presets: SHELL }); + expect(parse({})).toEqual({ port: 7417, repos: [], roots: ['~'], presets: SHELL, hosts: [] }); }); it.each([1024, 8080, 65535])('accepts port %d', (port) => { @@ -164,6 +164,111 @@ describe('parseConfig', () => { expect(problem(text)).not.toBe(''); }); + it('defaults the local discovery roots to ~ and keeps given roots unexpanded', () => { + expect(parse({}).roots).toEqual(['~']); + expect(parse({ roots: ['~', '~/src', '/srv/git'] }).roots).toEqual(['~', '~/src', '/srv/git']); + }); + + it('accepts 32 roots and refuses 33', () => { + expect(parse({ roots: paths(32) }).roots).toHaveLength(32); + expect(problem(JSON.stringify({ roots: paths(33) }))).toContain('roots'); + }); + + it.each([ + ['an empty list', []], + ['a relative root', ['src']], + ['a bare ~/', ['~/']], + ['another user’s home', ['~bob/src']], + ['a root with NUL', ['/a\u0000b']], + ['a root over 4096 characters', ['/' + 'a'.repeat(4096)]], + ['a number', [1]], + ['a single string', '~'], + ])('refuses %s as roots, naming the key', (_name, roots) => { + expect(problem(JSON.stringify({ roots }))).toContain('roots'); + }); + + it('lists remote hosts in order with their repos and roots, defaulting both', () => { + const config = parse({ + hosts: [ + { name: 'b', ssh: 'b.example.org', repos: ['/srv/app'], roots: ['~/src'] }, + { name: 'a.1_x-y', ssh: 'user@a' }, + ], + }); + expect(config.hosts).toEqual([ + { name: 'b', ssh: 'b.example.org', repos: ['/srv/app'], roots: ['~/src'] }, + { name: 'a.1_x-y', ssh: 'user@a', repos: [], roots: ['~'] }, + ]); + }); + + it('accepts 63 hosts and refuses 64', () => { + const hosts = (n: number): unknown[] => Array.from({ length: n }, (_, i) => ({ name: `h${String(i)}`, ssh: `h${String(i)}` })); + expect(parse({ hosts: hosts(63) }).hosts).toHaveLength(63); + expect(problem(JSON.stringify({ hosts: hosts(64) }))).toContain('hosts'); + }); + + it.each([ + ['an option-like alias', '-oProxyCommand=x'], + ['an alias with a space', 'a b'], + ['an alias with a tab', 'a\tb'], + ['an alias with a control character', 'a\u0007b'], + ['an empty alias', ''], + ['a 256-character alias', 'h'.repeat(256)], + ['a numeric alias', 7], + ])('refuses %s, naming ssh', (_name, ssh) => { + expect(problem(JSON.stringify({ hosts: [{ name: 'box', ssh }] }))).toContain('ssh'); + }); + + it('accepts a 255-character alias', () => { + expect(parse({ hosts: [{ name: 'box', ssh: 'h'.repeat(255) }] }).hosts[0]?.ssh).toBe('h'.repeat(255)); + }); + + it.each([ + ['an empty name', ''], + ['a name with a space', 'my box'], + ['a name with a slash', 'a/b'], + ['a name with a colon', 'a:b'], + ['a 65-character name', 'h'.repeat(65)], + ])('refuses %s, naming the key', (_name, name) => { + expect(problem(JSON.stringify({ hosts: [{ name, ssh: 'box' }] }))).toContain('name'); + }); + + it('refuses duplicate host names, naming hosts', () => { + expect( + problem( + JSON.stringify({ + hosts: [ + { name: 'box', ssh: 'a' }, + { name: 'box', ssh: 'b' }, + ], + }), + ), + ).toContain('hosts'); + }); + + it.each([ + ['a home-relative repo', ['~/app']], + ['a relative repo', ['app']], + ['257 repos', paths(257)], + ])('refuses %s on a remote host, naming repos', (_name, repos) => { + expect(problem(JSON.stringify({ hosts: [{ name: 'box', ssh: 'box', repos }] }))).toContain('repos'); + }); + + it('refuses a remote host’s invalid roots, naming roots', () => { + expect(problem(JSON.stringify({ hosts: [{ name: 'box', ssh: 'box', roots: ['src'] }] }))).toContain('roots'); + }); + + it.each([ + ['an unknown key', { name: 'box', ssh: 'box', user: 'u' }, 'user'], + ['a missing alias', { name: 'box' }, 'ssh'], + ['a missing name', { ssh: 'box' }, 'name'], + ])('refuses a host with %s, naming the key', (_name, host, key) => { + expect(problem(JSON.stringify({ hosts: [host] }))).toContain(key); + }); + + it('refuses hosts that are not a list', () => { + expect(problem(JSON.stringify({ hosts: { name: 'box', ssh: 'box' } }))).toContain('hosts'); + }); + it('states its problem on one line', () => { expect(problem(JSON.stringify({ port: 1, repos: ['x'], extra: 1 }))).not.toContain('\n'); }); @@ -172,14 +277,14 @@ describe('parseConfig', () => { describe('loadConfig', () => { it('uses the defaults for a missing file without creating it or its directory', async () => { const path = join(dir, 'worktree-term', 'config.json'); - expect(await loadConfig(path, HOME)).toEqual({ port: 7417, repos: [], presets: SHELL }); + expect(await loadConfig(path, HOME)).toEqual({ port: 7417, repos: [], roots: ['~'], presets: SHELL, hosts: [] }); expect(existsSync(join(dir, 'worktree-term'))).toBe(false); }); it('reads the file', async () => { const path = join(dir, 'config.json'); - writeFileSync(path, JSON.stringify({ port: 9000, repos: ['~/app'], presets: [CLAUDE] })); - expect(await loadConfig(path, HOME)).toEqual({ port: 9000, repos: ['/home/u/app'], presets: [CLAUDE] }); + writeFileSync(path, JSON.stringify({ port: 9000, repos: ['~/app'], roots: ['~'], presets: [CLAUDE], hosts: [] })); + expect(await loadConfig(path, HOME)).toEqual({ port: 9000, repos: ['/home/u/app'], roots: ['~'], presets: [CLAUDE], hosts: [] }); }); it('fails with one line naming the file and the problem', async () => { @@ -221,7 +326,10 @@ describe('ConfigSource', () => { write(path, { repos: ['/a'] }); const source = new ConfigSource(path, HOME, await loadConfig(path, HOME)); write(path, { repos: ['/a', '/b'] }); - expect(await source.snapshot()).toEqual({ config: { port: 7417, repos: ['/a', '/b'], presets: SHELL }, problem: null }); + expect(await source.snapshot()).toEqual({ + config: { port: 7417, repos: ['/a', '/b'], roots: ['~'], presets: SHELL, hosts: [] }, + problem: null, + }); }); it('keeps the last valid configuration and reports the problem of an invalid file', async () => { @@ -232,12 +340,15 @@ describe('ConfigSource', () => { await source.snapshot(); write(path, { repos: ['relative'] }); const snapshot = await source.snapshot(); - expect(snapshot.config).toEqual({ port: 7417, repos: ['/a', '/b'], presets: SHELL }); + expect(snapshot.config).toEqual({ port: 7417, repos: ['/a', '/b'], roots: ['~'], presets: SHELL, hosts: [] }); expect(snapshot.problem).toContain('repos'); write(path, '{'); expect((await source.snapshot()).config.repos).toEqual(['/a', '/b']); write(path, { repos: ['/c'] }); - expect(await source.snapshot()).toEqual({ config: { port: 7417, repos: ['/c'], presets: SHELL }, problem: null }); + expect(await source.snapshot()).toEqual({ + config: { port: 7417, repos: ['/c'], roots: ['~'], presets: SHELL, hosts: [] }, + problem: null, + }); }); it('snapshots edited presets and keeps the last valid ones when they become invalid', async () => { @@ -245,19 +356,22 @@ describe('ConfigSource', () => { write(path, { repos: ['/a'] }); const source = new ConfigSource(path, HOME, await loadConfig(path, HOME)); write(path, { repos: ['/a'], presets: [CLAUDE, ...SHELL] }); - expect(await source.snapshot()).toEqual({ config: { port: 7417, repos: ['/a'], presets: [CLAUDE, ...SHELL] }, problem: null }); + expect(await source.snapshot()).toEqual({ + config: { port: 7417, repos: ['/a'], roots: ['~'], presets: [CLAUDE, ...SHELL], hosts: [] }, + problem: null, + }); write(path, { repos: ['/b'], presets: [{ name: 'claude', command: '' }] }); const snapshot = await source.snapshot(); - expect(snapshot.config).toEqual({ port: 7417, repos: ['/a'], presets: [CLAUDE, ...SHELL] }); + expect(snapshot.config).toEqual({ port: 7417, repos: ['/a'], roots: ['~'], presets: [CLAUDE, ...SHELL], hosts: [] }); expect(snapshot.problem).toContain('presets'); }); it('uses the defaults once the file is removed', async () => { const path = join(dir, 'config.json'); - write(path, { port: 9000, repos: ['/a'], presets: [CLAUDE] }); + write(path, { port: 9000, repos: ['/a'], roots: ['~'], presets: [CLAUDE], hosts: [] }); const source = new ConfigSource(path, HOME, await loadConfig(path, HOME)); rmSync(path); - expect(await source.snapshot()).toEqual({ config: { port: 7417, repos: [], presets: SHELL }, problem: null }); + expect(await source.snapshot()).toEqual({ config: { port: 7417, repos: [], roots: ['~'], presets: SHELL, hosts: [] }, problem: null }); }); }); @@ -285,6 +399,24 @@ describe('README', () => { expect(parseConfig(blocks[0] ?? '', HOME).presets).toEqual([CLAUDE, ...SHELL]); }); + it('gives a configuration example with remote hosts and discovery roots', () => { + const configs = jsonBlocks(section('Configuration')).map((block) => parseConfig(block, HOME)); + expect(configs.some((config) => config.hosts.length > 0 && config.hosts.some((host) => host.repos.length > 0))).toBe(true); + expect(configs.some((config) => config.roots.join() !== '~')).toBe(true); + }); + + it.each([ + 'wtd install-local', + 'wtd install-local --systemd', + 'loginctl enable-linger', + 'wtd install-remote', + '--node', + 'WTD_SSH', + 'MaxSessions', + ])('documents %s', (text) => { + expect(readme).toContain(text); + }); + it('tells Claude Code to ring the bell', () => { const [settings] = jsonBlocks(section('Attention marks')); expect(JSON.parse(settings ?? '')).toEqual({ preferredNotifChannel: 'terminal_bell' }); diff --git a/src/hub/config/editor.test.ts b/src/hub/config/editor.test.ts new file mode 100644 index 0000000..d5422aa --- /dev/null +++ b/src/hub/config/editor.test.ts @@ -0,0 +1,180 @@ +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { ConfigEditor, ConfigError } from './index.js'; + +const HOME = '/home/u'; + +let dir: string; +let path: string; + +beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'wtd-editor-'))); + path = join(dir, 'worktree-term', 'config.json'); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +const write = (value: unknown): void => { + mkdirSync(join(dir, 'worktree-term'), { recursive: true }); + writeFileSync(path, typeof value === 'string' ? value : JSON.stringify(value, null, 2)); +}; + +const read = (): unknown => JSON.parse(readFileSync(path, 'utf8')); +const text = (): string => readFileSync(path, 'utf8'); +const keysOf = (value: unknown): string[] => (typeof value === 'object' && value !== null ? Object.keys(value) : []); +const reposOf = (value: unknown): string[] => z.object({ repos: z.array(z.string()) }).parse(value).repos; +const editor = (hooks?: ConstructorParameters[2]): ConfigEditor => new ConfigEditor(path, HOME, hooks); + +const paths = (n: number): string[] => Array.from({ length: n }, (_, i) => `/r/${String(i)}`); + +const OTHERS = { + port: 9000, + roots: ['~/src'], + presets: [{ name: 'claude', command: 'claude' }], + hosts: [ + { name: 'box', ssh: 'box.example.org', repos: ['/srv/a'], roots: ['~'] }, + { name: 'gpu', ssh: 'gpu' }, + ], +}; + +describe('ConfigEditor adding repos', () => { + it('creates an absent file, owner-only, holding the added repo', async () => { + expect(await editor().addRepo(null, '/r/app')).toEqual({ changed: true, repos: ['/r/app'] }); + expect(read()).toEqual({ repos: ['/r/app'] }); + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + it('keeps every other value as written, ~/ forms unexpanded, and indents by 2 spaces', async () => { + write({ port: 9000, repos: ['~/a'], ...OTHERS }); + const result = await editor().addRepo(null, '/r/b'); + expect(result).toEqual({ changed: true, repos: ['/home/u/a', '/r/b'] }); + const expected = { port: 9000, repos: ['~/a', '/r/b'], ...OTHERS }; + expect(read()).toEqual(expected); + expect(keysOf(read())).toEqual(Object.keys(expected)); + expect(text().trimEnd()).toBe(JSON.stringify(expected, null, 2)); + }); + + it('adds to the named remote host only', async () => { + write(OTHERS); + expect(await editor().addRepo('box', '/srv/b')).toEqual({ changed: true, repos: ['/srv/a', '/srv/b'] }); + expect(read()).toEqual({ ...OTHERS, hosts: [{ ...OTHERS.hosts[0], repos: ['/srv/a', '/srv/b'] }, OTHERS.hosts[1]] }); + }); + + it('creates the repos of a remote host that has none', async () => { + write(OTHERS); + expect(await editor().addRepo('gpu', '/data/x')).toEqual({ changed: true, repos: ['/data/x'] }); + expect(read()).toEqual({ ...OTHERS, hosts: [OTHERS.hosts[0], { name: 'gpu', ssh: 'gpu', repos: ['/data/x'] }] }); + }); + + it.each([ + ['as given', ['/r/a'], '/r/a'], + ['by a ~/ form expanding to it', ['~/a'], '/home/u/a'], + ])('leaves a repo already listed %s unchanged', async (_name, repos, repo) => { + write({ repos }); + const before = text(); + const mtime = statSync(path).mtimeMs; + expect((await editor().addRepo(null, repo)).changed).toBe(false); + expect(text()).toBe(before); + expect(statSync(path).mtimeMs).toBe(mtime); + }); + + it('refuses a 257th repo for a host, leaving the file unchanged', async () => { + write({ repos: paths(256) }); + const before = text(); + await expect(editor().addRepo(null, '/r/new')).rejects.toBeInstanceOf(ConfigError); + await expect(editor().addRepo(null, '/r/new')).rejects.toThrow(/256/); + expect(text()).toBe(before); + }); + + it.each([ + ['an unknown key', JSON.stringify({ theme: 'dark' }), 'theme'], + ['invalid JSON', '{"repos":', 'JSON'], + ])('refuses to edit a file with %s, naming the problem and leaving it unchanged', async (_name, content, problem) => { + write(content); + await expect(editor().addRepo(null, '/r/a')).rejects.toThrow(problem); + expect(text()).toBe(content); + }); + + it('refuses to edit a host the file no longer lists, naming it', async () => { + write({ hosts: [{ name: 'gpu', ssh: 'gpu' }] }); + const before = text(); + await expect(editor().addRepo('box', '/srv/a')).rejects.toThrow('box'); + expect(text()).toBe(before); + }); + + it('replaces an existing looser file with an owner-only one', async () => { + write({ repos: [] }); + chmodSync(path, 0o644); + await editor().addRepo(null, '/r/a'); + expect(statSync(path).mode & 0o777).toBe(0o600); + }); +}); + +describe('ConfigEditor removing repos', () => { + it('removes every entry naming the repo, as given or by a ~/ form', async () => { + write({ repos: ['~/app', '/r/x', '/home/u/app'], ...OTHERS }); + expect(await editor().removeRepo(null, '/home/u/app')).toEqual({ changed: true, repos: ['/r/x'] }); + expect(read()).toEqual({ repos: ['/r/x'], ...OTHERS }); + }); + + it('removes from the named remote host only', async () => { + write({ repos: ['/srv/a'], ...OTHERS }); + expect(await editor().removeRepo('box', '/srv/a')).toEqual({ changed: true, repos: [] }); + expect(read()).toEqual({ repos: ['/srv/a'], ...OTHERS, hosts: [{ ...OTHERS.hosts[0], repos: [] }, OTHERS.hosts[1]] }); + }); + + it('leaves the file unchanged for a repo it does not list', async () => { + write({ repos: ['/r/a'] }); + const before = text(); + expect(await editor().removeRepo(null, '/r/b')).toEqual({ changed: false, repos: ['/r/a'] }); + expect(text()).toBe(before); + }); + + it('creates no file when removing from an absent one', async () => { + expect(await editor().removeRepo(null, '/r/a')).toEqual({ changed: false, repos: [] }); + expect(existsSync(path)).toBe(false); + }); +}); + +describe('ConfigEditor with other writers', () => { + it('starts over from a file changed between its read and its replacement, keeping both changes', async () => { + write({ repos: ['/r/a'] }); + const attempts: number[] = []; + const result = await editor({ + beforeReplace: (attempt) => { + attempts.push(attempt); + if (attempt === 1) writeFileSync(path, JSON.stringify({ repos: ['/r/a'], presets: [{ name: 'p', command: null }] })); + }, + }).addRepo(null, '/r/b'); + expect(attempts).toEqual([1, 2]); + expect(result).toEqual({ changed: true, repos: ['/r/a', '/r/b'] }); + expect(read()).toEqual({ repos: ['/r/a', '/r/b'], presets: [{ name: 'p', command: null }] }); + }); + + it('gives up after 3 attempts, leaving the other writer’s content', async () => { + write({ repos: [] }); + let n = 0; + const attempts: number[] = []; + const editing = editor({ + beforeReplace: (attempt) => { + attempts.push(attempt); + writeFileSync(path, JSON.stringify({ repos: [`/w/${String(++n)}`] })); + }, + }).addRepo(null, '/r/b'); + await expect(editing).rejects.toBeInstanceOf(ConfigError); + expect(attempts).toEqual([1, 2, 3]); + expect(read()).toEqual({ repos: ['/w/3'] }); + }); + + it('applies concurrent edits one at a time, losing none', async () => { + write({ repos: [] }); + const shared = editor(); + await Promise.all([shared.addRepo(null, '/r/1'), shared.addRepo(null, '/r/2'), shared.addRepo('box', '/s/1').catch(() => undefined)]); + expect(reposOf(read()).sort()).toEqual(['/r/1', '/r/2']); + }); +}); diff --git a/src/hub/links/index.ts b/src/hub/links/index.ts index a3b6173..081baba 100644 --- a/src/hub/links/index.ts +++ b/src/hub/links/index.ts @@ -1,5 +1,6 @@ import { connect, type Socket } from 'node:net'; import { dial, spawnDetached } from '../../platform/dialer/index.js'; +import { installRemote } from '../../platform/install/index.js'; import { ProtocolError, StreamDecoder, type Frame } from '../../protocol/index.js'; /** The host files a local link needs. */ @@ -153,3 +154,6 @@ export class LocalDaemon { /** Starts `command` detached in its own session, appending its output to `log` (ignored when null). */ export const startDetached = (command: readonly string[], log: string | null): Promise => spawnDetached(command, log); + +/** Installs the hub's bundle on a remote host. */ +export const reinstallRemote = (): Promise => installRemote(); diff --git a/src/hub/links/remote.test.ts b/src/hub/links/remote.test.ts new file mode 100644 index 0000000..efa0184 --- /dev/null +++ b/src/hub/links/remote.test.ts @@ -0,0 +1,210 @@ +import { chmodSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { hostPaths } from '../../platform/files/index.js'; +import { encodeFrame, FrameKind, type Frame } from '../../protocol/index.js'; +import { RemoteDaemon, type Link } from './index.js'; + +let dir: string; +let paths: ReturnType; +let pidFiles: string[]; + +beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'wtd-remote-'))); + paths = hostPaths({ env: { XDG_STATE_HOME: join(dir, 's') }, home: '/nonexistent', host: 'local' }); + pidFiles = []; +}); + +afterEach(() => { + vi.unstubAllEnvs(); + for (const file of pidFiles) { + if (!existsSync(file)) continue; + for (const pid of readFileSync(file, 'utf8').split('\n').filter(Boolean)) { + try { + process.kill(Number(pid), 'SIGKILL'); + } catch { + // Already gone. + } + } + } + rmSync(dir, { recursive: true, force: true }); +}); + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Installs `body` as the SSH program; `$D` is the test directory, `$@` the SSH arguments. */ +const fakeSsh = (body: string): void => { + const program = join(dir, 'ssh'); + writeFileSync(program, `#!/bin/sh\nD='${dir}'\nfor a in "$@"; do printf '%s\\0' "$a"; done > "$D/args"\necho $$ >> "$D/pids"\n${body}\n`); + chmodSync(program, 0o755); + pidFiles.push(join(dir, 'pids'), join(dir, 'children')); + vi.stubEnv('WTD_SSH', program); +}; + +const fileText = (name: string): string => (existsSync(join(dir, name)) ? readFileSync(join(dir, name), 'utf8') : ''); + +interface Opened { + link: Link; + frames: Frame[]; + ended: Promise; + endedAt: () => number | null; +} + +const open = (alias = 'box'): Opened => { + const frames: Frame[] = []; + let at: number | null = null; + let resolveEnded: (error: Error | null) => void = () => undefined; + const ended = new Promise((resolve) => { + resolveEnded = resolve; + }); + const link = new RemoteDaemon(paths, alias).open({ + frame: (frame) => frames.push(frame), + ended: (error) => { + at = Date.now(); + resolveEnded(error); + }, + }); + return { link, frames, ended, endedAt: () => at }; +}; + +const controlFrame = (text: string): Uint8Array => encodeFrame({ kind: FrameKind.control, payload: new TextEncoder().encode(text) }); + +const waitFor = async (probe: () => boolean, what: string, timeout = 5000): Promise => { + const deadline = Date.now() + timeout; + while (!probe()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await sleep(20); + } +}; + +describe('RemoteDaemon', () => { + it('runs the SSH command for the alias with the connect command', async () => { + fakeSsh('exec cat'); + const { link } = open('box'); + await waitFor(() => fileText('args') !== '', 'the SSH program to run'); + expect(fileText('args').split('\0').slice(0, -1)).toEqual([ + '-o', + 'BatchMode=yes', + '-o', + 'ConnectTimeout=10', + '-o', + 'ControlMaster=auto', + '-o', + 'ControlPersist=10m', + '-o', + `ControlPath=${paths.runDir}/ssh-%C`, + '-o', + 'ServerAliveInterval=15', + '--', + 'box', + '"$HOME/.local/bin/wtd" connect', + ]); + link.close(); + }); + + it('exchanges stream frames over the process’s standard input and output', async () => { + fakeSsh('exec cat'); + const { link, frames } = open(); + link.send(controlFrame('{"t":"shutdown"}')); + link.send(controlFrame('{"t":"ack","termId":1,"offset":0}')); + await waitFor(() => frames.length === 2, 'two frames back'); + expect(frames.map((f) => new TextDecoder().decode(f.payload))).toEqual(['{"t":"shutdown"}', '{"t":"ack","termId":1,"offset":0}']); + link.close(); + }); + + it('ends with the last standard error line as the cause when SSH fails', async () => { + fakeSsh(`echo 'Warning: something' >&2; echo 'ssh: Could not resolve hostname box: Name or service not known' >&2; exit 255`); + const error = await open().ended; + expect(error?.message).toBe('ssh: Could not resolve hostname box: Name or service not known'); + }); + + it('ends with the exit status as the cause when SSH wrote nothing to standard error', async () => { + fakeSsh('exit 3'); + const error = await open().ended; + expect(error?.message).toMatch(/\b3\b/); + }); + + it('ends with a cause of at most 1024 characters after 8 MiB of standard error without a newline', async () => { + fakeSsh(`head -c 8388608 /dev/zero | tr '\\000' x >&2; exit 255`); + const error = await open().ended; + expect(error?.message).toMatch(/^x+$/); + expect(error?.message.length).toBeLessThanOrEqual(1024); + }); + + it('ends when the process exits although a child keeps its standard error open', async () => { + fakeSsh(`sleep 30 /dev/null & echo $! >> "$D/children"\nsleep 0.2; exit 255`); + const { ended } = open(); + const started = Date.now(); + await ended; + expect(Date.now() - started).toBeLessThan(3000); + }); + + it('ends when the process closes its standard output, even while it keeps running', async () => { + fakeSsh('exec >&-; exec sleep 30'); + const { ended } = open(); + const started = Date.now(); + await ended; + expect(Date.now() - started).toBeLessThan(3000); + }); + + it('ends with an error on a malformed stream', async () => { + fakeSsh(`printf '\\377\\377\\377\\377\\377\\377\\377\\377'; exec sleep 30`); + const error = await open().ended; + expect(error).toBeInstanceOf(Error); + }); + + it('closes by ending standard input, without signalling a process that then exits', async () => { + fakeSsh(`trap 'echo term >> "$D/signals"; exit 0' TERM\ncat >/dev/null\necho eof >> "$D/signals"`); + const { link, endedAt } = open(); + await waitFor(() => fileText('pids') !== '', 'the SSH program to run'); + link.close(); + await waitFor(() => fileText('signals').includes('eof'), 'standard input to end'); + await sleep(1500); + expect(fileText('signals')).toBe('eof\n'); + expect(endedAt()).toBeNull(); + }); + + it('terminates a process that has not exited 1 s after its standard input ended', async () => { + fakeSsh(`trap 'echo term >> "$D/signals"; exit 0' TERM\nwhile :; do sleep 0.05; done`); + const { link } = open(); + await waitFor(() => fileText('pids') !== '', 'the SSH program to run'); + const closedAt = Date.now(); + link.close(); + await sleep(700); + expect(fileText('signals')).toBe(''); + await waitFor(() => fileText('signals').includes('term'), 'SIGTERM', 3000); + expect(Date.now() - closedAt).toBeGreaterThanOrEqual(900); + }); + + it('signals no other process when closing', async () => { + fakeSsh(`sleep 30 /dev/null 2>&1 & echo $! >> "$D/children"\ntrap 'exit 0' TERM\nwhile :; do sleep 0.05; done`); + const { link } = open(); + await waitFor(() => fileText('children') !== '', 'the child to start'); + const ssh = Number(fileText('pids').trim()); + const child = Number(fileText('children').trim()); + link.close(); + await waitFor( + () => { + try { + process.kill(ssh, 0); + return false; + } catch { + return true; + } + }, + 'the SSH process to exit', + 3000, + ); + expect(() => process.kill(child, 0)).not.toThrow(); + }); + + it('does not report the end of a link it closed', async () => { + fakeSsh('exec cat'); + const { link, endedAt } = open(); + await waitFor(() => fileText('pids') !== '', 'the SSH program to run'); + link.close(); + await sleep(1500); + expect(endedAt()).toBeNull(); + }); +}); diff --git a/src/hub/links/tail.test.ts b/src/hub/links/tail.test.ts new file mode 100644 index 0000000..2815fa3 --- /dev/null +++ b/src/hub/links/tail.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { StderrTail } from './tail.js'; + +const encoder = new TextEncoder(); +const bytes = (text: string): Uint8Array => encoder.encode(text); + +const tailOf = (...chunks: (string | Uint8Array)[]): StderrTail => { + const tail = new StderrTail(); + for (const chunk of chunks) tail.push(typeof chunk === 'string' ? bytes(chunk) : chunk); + return tail; +}; + +describe('StderrTail', () => { + it('has no reason before any output', () => { + expect(tailOf().reason()).toBeNull(); + }); + + it('gives the last non-empty line', () => { + expect(tailOf('Warning: Permanently added box\nssh: Could not resolve hostname box\n\n').reason()).toBe( + 'ssh: Could not resolve hostname box', + ); + }); + + it('gives an unterminated last line', () => { + expect(tailOf('first\nlast without newline').reason()).toBe('last without newline'); + }); + + it('joins a line split across chunks, including inside a multi-byte character', () => { + const line = bytes('ssh: résolution impossible\n'); + const split = line.indexOf(0xc3) + 1; + expect(tailOf(line.subarray(0, split), line.subarray(split)).reason()).toBe('ssh: résolution impossible'); + }); + + it('removes control characters', () => { + expect(tailOf('ssh:\u0007 conn\u001bection\r refused\u007f\r\n').reason()).toBe('ssh: connection refused'); + }); + + it('has no reason when every line is empty or only control characters', () => { + expect(tailOf('\n\r\n\u0007\n').reason()).toBeNull(); + }); + + it('decodes invalid UTF-8 without failing', () => { + const reason = tailOf(Uint8Array.from([0x73, 0x73, 0x68, 0xff, 0xfe, 0x3a, 0x20, 0x78, 0x0a])).reason(); + expect(reason).toMatch(/^ssh.*: x$/); + }); + + it('cuts the reason to 1024 characters', () => { + expect(tailOf('x'.repeat(3000)).reason()).toHaveLength(1024); + }); + + it('keeps at most 4 KiB however much is written', () => { + const tail = new StderrTail(); + const chunk = bytes('y'.repeat(64 * 1024)); + for (let i = 0; i < 128; i++) tail.push(chunk); + expect(tail.size).toBeLessThanOrEqual(4096); + const reason = tail.reason(); + expect(reason).toMatch(/^y+$/); + expect(reason?.length).toBeLessThanOrEqual(1024); + }); + + it('gives the latest line after a long burst', () => { + expect(tailOf('z'.repeat(100_000), '\nssh: Connection timed out\n').reason()).toBe('ssh: Connection timed out'); + }); +}); diff --git a/src/hub/router/coordinator.test.ts b/src/hub/router/coordinator.test.ts new file mode 100644 index 0000000..aeeabfb --- /dev/null +++ b/src/hub/router/coordinator.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import { HostCoordinator } from './coordinator.js'; + +interface Deferred { + promise: Promise; + resolve: () => void; + reject: (error: Error) => void; +} + +const deferred = (): Deferred => { + let resolve: () => void = () => undefined; + let reject: (error: Error) => void = () => undefined; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +/** A coordinator whose operations record their start and finish when the test settles them. */ +const setup = (): { coordinator: HostCoordinator; started: string[]; settle: (kind: string, error?: Error) => void } => { + const started: string[] = []; + const running = new Map(); + const op = (kind: string) => (): Promise => { + started.push(kind); + const d = deferred(); + running.set(kind, d); + return d.promise; + }; + const coordinator = new HostCoordinator({ restart: op('restart'), reinstall: op('reinstall') }); + const settle = (kind: string, error?: Error): void => { + const d = running.get(kind); + if (d === undefined) throw new Error(`${kind} is not running`); + running.delete(kind); + if (error === undefined) d.resolve(); + else d.reject(error); + }; + return { coordinator, started, settle }; +}; + +const tick = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +/** Whether `promise` has settled by now. */ +const settled = async (promise: Promise): Promise => { + let done = false; + void promise.then( + () => (done = true), + () => (done = true), + ); + await tick(); + return done; +}; + +describe('HostCoordinator', () => { + it('shares one restart between concurrent requests', async () => { + const { coordinator, started, settle } = setup(); + const a = coordinator.restart(); + const b = coordinator.restart(); + await tick(); + expect(started).toEqual(['restart']); + settle('restart'); + await expect(Promise.all([a, b])).resolves.toEqual([undefined, undefined]); + }); + + it('shares one reinstall between concurrent requests', async () => { + const { coordinator, started, settle } = setup(); + const a = coordinator.reinstall(); + const b = coordinator.reinstall(); + await tick(); + expect(started).toEqual(['reinstall']); + settle('reinstall'); + await expect(Promise.all([a, b])).resolves.toEqual([undefined, undefined]); + }); + + it('lets a restart requested during a reinstall share the reinstall', async () => { + const { coordinator, started, settle } = setup(); + const reinstall = coordinator.reinstall(); + await tick(); + const restart = coordinator.restart(); + await tick(); + expect(started).toEqual(['reinstall']); + expect(await settled(restart)).toBe(false); + settle('reinstall'); + await expect(Promise.all([reinstall, restart])).resolves.toEqual([undefined, undefined]); + expect(started).toEqual(['reinstall']); + }); + + it('gives a restart sharing a failed reinstall that failure', async () => { + const { coordinator, settle } = setup(); + const reinstall = coordinator.reinstall(); + await tick(); + const restart = coordinator.restart(); + settle('reinstall', new Error('install: no Node')); + await expect(reinstall).rejects.toThrow('install: no Node'); + await expect(restart).rejects.toThrow('install: no Node'); + }); + + it('runs a reinstall requested during a restart once the restart ended', async () => { + const { coordinator, started, settle } = setup(); + const restart = coordinator.restart(); + await tick(); + const reinstall = coordinator.reinstall(); + await tick(); + expect(started).toEqual(['restart']); + settle('restart'); + await restart; + await tick(); + expect(started).toEqual(['restart', 'reinstall']); + expect(await settled(reinstall)).toBe(false); + settle('reinstall'); + await expect(reinstall).resolves.toBeUndefined(); + }); + + it('runs a queued reinstall even when the restart before it failed', async () => { + const { coordinator, started, settle } = setup(); + const restart = coordinator.restart(); + await tick(); + const reinstall = coordinator.reinstall(); + settle('restart', new Error('no new daemon')); + await expect(restart).rejects.toThrow('no new daemon'); + await tick(); + expect(started).toEqual(['restart', 'reinstall']); + settle('reinstall'); + await expect(reinstall).resolves.toBeUndefined(); + }); + + it('shares one queued reinstall between requests made during a restart', async () => { + const { coordinator, started, settle } = setup(); + void coordinator.restart(); + await tick(); + const a = coordinator.reinstall(); + const b = coordinator.reinstall(); + settle('restart'); + await tick(); + await tick(); + expect(started).toEqual(['restart', 'reinstall']); + settle('reinstall'); + await expect(Promise.all([a, b])).resolves.toEqual([undefined, undefined]); + }); + + it('starts a fresh run for a request after the previous one ended', async () => { + const { coordinator, started, settle } = setup(); + const first = coordinator.restart(); + await tick(); + settle('restart'); + await first; + const second = coordinator.restart(); + await tick(); + expect(started).toEqual(['restart', 'restart']); + settle('restart'); + await expect(second).resolves.toBeUndefined(); + }); + + it('starts a fresh run after a failed one', async () => { + const { coordinator, started, settle } = setup(); + const first = coordinator.restart(); + await tick(); + settle('restart', new Error('boom')); + await expect(first).rejects.toThrow('boom'); + const second = coordinator.restart(); + await tick(); + expect(started).toEqual(['restart', 'restart']); + settle('restart'); + await expect(second).resolves.toBeUndefined(); + }); +}); diff --git a/src/hub/router/hosts.test.ts b/src/hub/router/hosts.test.ts index b03ce6a..5f9b4a8 100644 --- a/src/hub/router/hosts.test.ts +++ b/src/hub/router/hosts.test.ts @@ -3,22 +3,53 @@ import { PROTOCOL_VERSION } from '../../protocol/index.js'; import { hostTransition, initialHostState, localHostName, retryDelay, type HostEvent, type HostState } from './hosts.js'; const hello = (protocol: number, instance = 'd_1'): HostEvent => ({ kind: 'hello', protocol, version: '9.9.9', instance }); -const failed: HostEvent = { kind: 'failed' }; +const failed: HostEvent = { kind: 'failed', reason: 'ssh: connect to host box port 22: Connection refused' }; +const failedWith = (reason: string): HostEvent => ({ kind: 'failed', reason }); const run = (...events: HostEvent[]): HostState => events.reduce(hostTransition, initialHostState); describe('host status', () => { it('starts connecting with no daemon', () => { - expect(initialHostState).toEqual({ status: 'connecting', daemonVersion: null, instance: null, failures: 0 }); + expect(initialHostState).toEqual({ status: 'connecting', reason: null, daemonVersion: null, instance: null, failures: 0 }); }); it('is connected to a daemon speaking our protocol', () => { - expect(run(hello(PROTOCOL_VERSION))).toEqual({ status: 'connected', daemonVersion: '9.9.9', instance: 'd_1', failures: 0 }); + expect(run(hello(PROTOCOL_VERSION))).toEqual({ + status: 'connected', + reason: null, + daemonVersion: '9.9.9', + instance: 'd_1', + failures: 0, + }); }); - it('is outdated for a daemon speaking another protocol, without an instance', () => { - expect(run(hello(PROTOCOL_VERSION + 1))).toEqual({ status: 'outdated', daemonVersion: '9.9.9', instance: null, failures: 0 }); - expect(run(hello(1)).status).toBe('outdated'); + it('is outdated for a daemon speaking another protocol, keeping its instance', () => { + expect(run(hello(PROTOCOL_VERSION + 1))).toEqual({ + status: 'outdated', + reason: null, + daemonVersion: '9.9.9', + instance: 'd_1', + failures: 0, + }); + expect(run(hello(1, 'old_7'))).toMatchObject({ status: 'outdated', instance: 'old_7' }); + }); + + it('names the latest failure as the reason while reconnecting and down', () => { + expect(run(failedWith('first')).reason).toBe('first'); + expect(run(failedWith('first'), failedWith('second')).reason).toBe('second'); + expect(run(failedWith('a'), failedWith('b'), failedWith('ssh: Could not resolve hostname box'))).toMatchObject({ + status: 'down', + reason: 'ssh: Could not resolve hostname box', + }); + }); + + it('clears the reason on a hello', () => { + expect(run(failed, hello(PROTOCOL_VERSION)).reason).toBeNull(); + expect(run(failed, hello(1)).reason).toBeNull(); + }); + + it('cuts a reason to 1024 characters', () => { + expect(run(failedWith('r'.repeat(5000))).reason).toBe('r'.repeat(1024)); }); it('is reconnecting after one or two consecutive failures and down after three', () => { @@ -43,7 +74,7 @@ describe('host status', () => { it('resets the failure count on a hello', () => { const recovered = run(failed, failed, failed, hello(PROTOCOL_VERSION, 'd_2')); - expect(recovered).toEqual({ status: 'connected', daemonVersion: '9.9.9', instance: 'd_2', failures: 0 }); + expect(recovered).toEqual({ status: 'connected', reason: null, daemonVersion: '9.9.9', instance: 'd_2', failures: 0 }); expect(hostTransition(recovered, failed)).toMatchObject({ status: 'reconnecting', failures: 1 }); }); diff --git a/src/platform/dialer/dialer.test.ts b/src/platform/dialer/dialer.test.ts index b46c150..0750250 100644 --- a/src/platform/dialer/dialer.test.ts +++ b/src/platform/dialer/dialer.test.ts @@ -1,10 +1,10 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { createServer, type Server, type Socket } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { hostPaths, type HostPaths } from '../files/index.js'; -import { dial, spawnDetached } from './index.js'; +import { dial, spawnDetached, sshCommand } from './index.js'; /** Stand-in daemon: logs to stdout and stderr, records its pid, serves its pid to every connection. */ const FAKE_DAEMON = ` @@ -35,6 +35,7 @@ beforeEach(() => { }); afterEach(async () => { + vi.unstubAllEnvs(); for (const socket of sockets.splice(0)) socket.destroy(); await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve)))); for (const pid of spawnedPids()) { @@ -47,14 +48,16 @@ afterEach(async () => { rmSync(dir, { recursive: true, force: true }); }); -const spawnedPids = (): number[] => - existsSync(pidsFile) - ? readFileSync(pidsFile, 'utf8') +const pidsIn = (file: string): number[] => + existsSync(file) + ? readFileSync(file, 'utf8') .split('\n') .filter((line) => line !== '') .map(Number) : []; +const spawnedPids = (): number[] => pidsIn(pidsFile); + const fakeDaemon = (): string[] => [process.execPath, '-e', FAKE_DAEMON, paths.socket, pidsFile]; const track = (socket: Socket): Socket => { @@ -124,6 +127,166 @@ describe('dial', () => { }, 15_000); }); +describe('dial through systemd', () => { + let unitPaths: HostPaths; + let calls: string; + let unitPids: string; + + /** A stand-in systemctl recording its arguments; `start` launches the fake daemon unless it exits `status`. */ + const fakeSystemctl = (status: number): string => { + const daemon = join(dir, 'daemon.cjs'); + writeFileSync(daemon, FAKE_DAEMON.replace('process.argv.slice(1)', 'process.argv.slice(2)')); + const script = join(dir, 'systemctl'); + writeFileSync( + script, + `#!/bin/sh +printf '%s\\n' "$*" >> '${calls}' +if [ "$*" = "--user start worktree-term-daemon.service" ]; then + [ ${String(status)} -ne 0 ] && exit ${String(status)} + setsid '${process.execPath}' '${daemon}' '${unitPaths.socket}' '${unitPids}' /dev/null 2>&1 & +fi +exit 0 +`, + ); + chmodSync(script, 0o755); + return script; + }; + + const recordedCalls = (): string[] => + existsSync(calls) + ? readFileSync(calls, 'utf8') + .split('\n') + .filter((l) => l !== '') + : []; + + const installUnit = (): void => { + mkdirSync(join(dir, 'c', 'systemd', 'user'), { recursive: true }); + writeFileSync(unitPaths.unitFile, '[Service]\n'); + }; + + const directDaemon = (): string[] => [process.execPath, '-e', FAKE_DAEMON, unitPaths.socket, pidsFile]; + + beforeEach(() => { + unitPaths = hostPaths({ env: { XDG_STATE_HOME: dir, XDG_CONFIG_HOME: join(dir, 'c') }, home: '/nonexistent', host: 'box' }); + calls = join(dir, 'systemctl.calls'); + unitPids = join(dir, 'unit-pids'); + }); + + afterEach(() => { + for (const pid of pidsIn(unitPids)) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Already gone. + } + } + }); + + it('starts the unit through WTD_SYSTEMCTL instead of the daemon command when the unit file exists', async () => { + installUnit(); + vi.stubEnv('WTD_SYSTEMCTL', fakeSystemctl(0)); + const socket = track(await dial(unitPaths, directDaemon())); + const [pid] = pidsIn(unitPids); + if (pid === undefined) throw new Error('systemctl started no daemon'); + expect(await readAll(socket)).toBe(String(pid)); + expect(recordedCalls()).toEqual(['--user start worktree-term-daemon.service']); + expect(spawnedPids()).toEqual([]); + }); + + it('falls back to the daemon command when systemctl fails', async () => { + installUnit(); + vi.stubEnv('WTD_SYSTEMCTL', fakeSystemctl(1)); + const socket = track(await dial(unitPaths, directDaemon())); + const [pid] = spawnedPids(); + if (pid === undefined) throw new Error('the daemon command was not started'); + expect(await readAll(socket)).toBe(String(pid)); + expect(sessionOf(pid)).toBe(pid); + expect(recordedCalls()).toEqual(['--user start worktree-term-daemon.service']); + expect(pidsIn(unitPids)).toEqual([]); + }); + + it('starts the daemon command directly without a unit file', async () => { + vi.stubEnv('WTD_SYSTEMCTL', fakeSystemctl(0)); + const socket = track(await dial(unitPaths, directDaemon())); + const [pid] = spawnedPids(); + if (pid === undefined) throw new Error('the daemon command was not started'); + expect(await readAll(socket)).toBe(String(pid)); + expect(recordedCalls()).toEqual([]); + }); + + it('runs no systemctl when a daemon serves the socket', async () => { + installUnit(); + vi.stubEnv('WTD_SYSTEMCTL', fakeSystemctl(0)); + mkdirSync(unitPaths.runDir, { recursive: true, mode: 0o700 }); + const server = createServer((c) => c.end('existing')); + servers.push(server); + await new Promise((resolve) => server.listen(unitPaths.socket, resolve)); + expect(await readAll(track(await dial(unitPaths, directDaemon())))).toBe('existing'); + expect(recordedCalls()).toEqual([]); + }); +}); + +describe('sshCommand', () => { + const REMOTE = '"$HOME/.local/bin/wtd" connect'; + const options = (): string[] => [ + '-o', + 'BatchMode=yes', + '-o', + 'ConnectTimeout=10', + '-o', + 'ControlMaster=auto', + '-o', + 'ControlPersist=10m', + '-o', + `ControlPath=${paths.runDir}/ssh-%C`, + '-o', + 'ServerAliveInterval=15', + ]; + + it('puts the options, --, the alias and the remote command as one argument, in that order', () => { + vi.stubEnv('WTD_SSH', undefined); + expect(sshCommand(paths, 'box', REMOTE)).toEqual(['ssh', ...options(), '--', 'box', REMOTE]); + }); + + it('runs WTD_SSH when set', () => { + vi.stubEnv('WTD_SSH', '/opt/fake ssh'); + expect(sshCommand(paths, 'box', REMOTE)).toEqual(['/opt/fake ssh', ...options(), '--', 'box', REMOTE]); + }); + + it('runs ssh when WTD_SSH is empty', () => { + vi.stubEnv('WTD_SSH', ''); + expect(sshCommand(paths, 'box', REMOTE)[0]).toBe('ssh'); + }); + + it.each([['box'], ['user@box.example.org'], ['h'.repeat(255)], ['box-1_2.3']])('accepts alias %j', (alias) => { + expect(sshCommand(paths, alias, REMOTE).at(-2)).toBe(alias); + }); + + it.each([ + ['an option-like alias', '-oProxyCommand=touch /tmp/x'], + ['a leading dash', '-box'], + ['an empty alias', ''], + ['a 256-character alias', 'h'.repeat(256)], + ['a space', 'my box'], + ['a tab', 'my\tbox'], + ['a newline', 'box\nx'], + ['an escape character', 'box\u001b'], + ['a NUL', 'box\u0000'], + ['a DEL', 'box\u007f'], + ])('refuses %s', (_name, alias) => { + expect(() => sshCommand(paths, alias, REMOTE)).toThrow(/alias/i); + }); + + it('names a refused alias', () => { + expect(() => sshCommand(paths, '-oProxyCommand=touch /tmp/x', REMOTE)).toThrow('-oProxyCommand=touch /tmp/x'); + }); + + it('refuses a control path that does not fit a unix socket address', () => { + const long = hostPaths({ env: { XDG_STATE_HOME: '/' + 'a'.repeat(44) }, home: '/nonexistent', host: 'b' }); + expect(() => sshCommand(long, 'box', REMOTE)).toThrow(`${long.runDir}/ssh-%C`); + }); +}); + /** Stand-in program: records its pid, session and stdio targets, then idles. */ const RECORDER = ` const fs = require('node:fs'); diff --git a/src/platform/files/files.test.ts b/src/platform/files/files.test.ts index cdae772..0411027 100644 --- a/src/platform/files/files.test.ts +++ b/src/platform/files/files.test.ts @@ -25,12 +25,14 @@ import { hostPaths, makeOwnerOnly, openLog, + ownedEntry, preparePrivateDirs, readFileIfExists, readPrivateFile, readTree, removeFile, renameFile, + sshControlPath, writeFileAtomic, } from './index.js'; @@ -169,6 +171,7 @@ describe('host paths', () => { hubLog: '/tmp/x/worktree-term/hub.log', hubLock: '/tmp/x/worktree-term/run/hub.lock', hubRecord: '/tmp/x/worktree-term/run/hub.json', + unitFile: '/home/u/.config/systemd/user/worktree-term-daemon.service', }); }); @@ -176,6 +179,7 @@ describe('host paths', () => { const paths = hostPaths({ env: { XDG_CONFIG_HOME: '/tmp/c' }, home: '/home/u', host: 'box' }); expect(paths.configDir).toBe('/tmp/c/worktree-term'); expect(paths.config).toBe('/tmp/c/worktree-term/config.json'); + expect(paths.unitFile).toBe('/tmp/c/systemd/user/worktree-term-daemon.service'); }); it.each([ @@ -183,7 +187,9 @@ describe('host paths', () => { ['empty', { XDG_CONFIG_HOME: '' }], ['unset', {}], ])('falls back to ~/.config when XDG_CONFIG_HOME is %s', (_name, env) => { - expect(hostPaths({ env, home: '/home/u', host: 'box' }).config).toBe('/home/u/.config/worktree-term/config.json'); + const paths = hostPaths({ env, home: '/home/u', host: 'box' }); + expect(paths.config).toBe('/home/u/.config/worktree-term/config.json'); + expect(paths.unitFile).toBe('/home/u/.config/systemd/user/worktree-term-daemon.service'); }); it('keeps hub files under the state directory whatever the configuration directory', () => { @@ -229,6 +235,30 @@ describe('host paths', () => { }); }); +describe('SSH control path', () => { + const pathsUnder = (stateHome: string): ReturnType => + hostPaths({ env: { XDG_STATE_HOME: stateHome }, home: '/home/u', host: 'b' }); + + it('is ssh-%C in the run directory', () => { + expect(sshControlPath(pathsUnder('/s'))).toBe('/s/worktree-term/run/ssh-%C'); + }); + + it('accepts a control path of 107 bytes once %C is substituted', () => { + const stateHome = '/' + 'a'.repeat(43); + expect(Buffer.byteLength(`${stateHome}/worktree-term/run/ssh-${'0'.repeat(40)}`)).toBe(107); + expect(sshControlPath(pathsUnder(stateHome))).toBe(`${stateHome}/worktree-term/run/ssh-%C`); + }); + + it.each([ + ['ASCII', '/' + 'a'.repeat(44)], + ['multi-byte', '/' + 'é'.repeat(22)], + ])('refuses an %s control path of 108 bytes once %C is substituted, naming it', (_name, stateHome) => { + const path = `${stateHome}/worktree-term/run/ssh-%C`; + expect(Buffer.byteLength(path.replace('%C', '0'.repeat(40)))).toBe(108); + expect(() => sshControlPath(pathsUnder(stateHome))).toThrow(path); + }); +}); + describe('current host paths', () => { it('derives the paths from the environment, home and host name', () => { const paths = currentHostPaths(); @@ -382,6 +412,44 @@ describe('file helpers', () => { }); }); +describe('owned entries', () => { + it('reports a missing path as absent', async () => { + expect(await ownedEntry(join(dir, 'missing'), 'directory')).toBe(false); + }); + + it('reports an owned path of the expected kind as present, without following a symbolic link', async () => { + mkdirSync(join(dir, 'd')); + writeFileSync(join(dir, 'f'), ''); + symlinkSync(join(dir, 'missing-target'), join(dir, 'l')); + expect(await ownedEntry(join(dir, 'd'), 'directory')).toBe(true); + expect(await ownedEntry(join(dir, 'f'), 'file')).toBe(true); + expect(await ownedEntry(join(dir, 'l'), 'symlink')).toBe(true); + }); + + it.each([ + ['a symbolic link where a directory is expected', 'directory'], + ['a file where a directory is expected', 'directory'], + ['a directory where a file is expected', 'file'], + ['a directory where a symbolic link is expected', 'symlink'], + ] as const)('refuses %s, naming the path', async (name, kind) => { + const path = join(dir, 'p'); + if (name.startsWith('a symbolic link')) { + mkdirSync(join(dir, 'target')); + symlinkSync(join(dir, 'target'), path); + } else if (name.startsWith('a file')) writeFileSync(path, ''); + else mkdirSync(path); + await expect(ownedEntry(path, kind)).rejects.toThrow(path); + }); + + it('refuses a path owned by another user, naming it', async () => { + const path = join(dir, 'theirs'); + mkdirSync(path); + hooks.foreignOwner = path; + await expect(ownedEntry(path, 'directory')).rejects.toThrow(path); + await expect(ownedEntry(path, 'directory')).rejects.toThrow(/another user/); + }); +}); + describe('daemon log', () => { it('opens the log owner-only for appending', async () => { const log = join(dir, 'daemon.log'); diff --git a/src/platform/install/index.ts b/src/platform/install/index.ts new file mode 100644 index 0000000..f874b5c --- /dev/null +++ b/src/platform/install/index.ts @@ -0,0 +1,13 @@ +import { spawnDetached } from '../dialer/index.js'; +import { currentHostPaths } from '../files/index.js'; + +// Placeholder keeping the model's arrows live; the installer replaces it. +const notImplemented = (): Promise => Promise.reject(new Error('not implemented')); + +/** Installs the running bundle for this user. */ +export const installLocal = (): Promise => notImplemented(); + +/** Installs the running bundle on the SSH host `alias`. */ +export const installRemote = (): Promise => notImplemented(); + +export const placeholderDependencies = [spawnDetached, currentHostPaths] as const; diff --git a/src/platform/install/install.test.ts b/src/platform/install/install.test.ts new file mode 100644 index 0000000..7e31bab --- /dev/null +++ b/src/platform/install/install.test.ts @@ -0,0 +1,383 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + checkNodePath, + desktopEntry, + releaseName, + releasesToRemove, + REMOTE_INSTALL_COMMAND, + shimScript, + systemdUnit, + tarArchive, + type TarEntry, +} from './index.js'; + +let dir: string; + +beforeEach(() => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'wtd-install-'))); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +const encoder = new TextEncoder(); +const bytes = (text: string): Uint8Array => encoder.encode(text); + +/** Paths with every character the escapers must handle, besides the plain one. */ +const AWKWARD = [ + 'plain', + 'with space', + "it's", + 'dollar$HOME', + 'per%cent', + 'back\\slash', + 'dq"uote', + 'back`tick', + 'sem;i&co|lon', + 'tab\there', +]; + +const writeTar = (entries: readonly TarEntry[]): string => { + const path = join(dir, 'archive.tar'); + writeFileSync(path, tarArchive(entries)); + return path; +}; + +describe('tarArchive', () => { + const extract = (archive: string): string => { + const out = join(dir, 'out'); + mkdirSync(out); + execFileSync('tar', ['--no-same-owner', '-p', '-x', '-C', out, '-f', archive]); + return out; + }; + + it('round-trips directories and files with their modes through tar', () => { + const entries: TarEntry[] = [ + { path: 'a', mode: 0o755 }, + { path: 'a/x.txt', mode: 0o644, data: bytes('hello\n') }, + { path: 'a/run', mode: 0o755, data: bytes('#!/bin/sh\necho hi\n') }, + { path: 'a/empty', mode: 0o600, data: new Uint8Array() }, + { path: 'a/é-ünïcode', mode: 0o644, data: bytes('ü') }, + ]; + const out = extract(writeTar(entries)); + expect(statSync(join(out, 'a')).isDirectory()).toBe(true); + expect(statSync(join(out, 'a')).mode & 0o777).toBe(0o755); + expect(readFileSync(join(out, 'a', 'x.txt'), 'utf8')).toBe('hello\n'); + expect(statSync(join(out, 'a', 'x.txt')).mode & 0o777).toBe(0o644); + expect(statSync(join(out, 'a', 'run')).mode & 0o777).toBe(0o755); + expect(readFileSync(join(out, 'a', 'empty')).length).toBe(0); + expect(statSync(join(out, 'a', 'empty')).mode & 0o777).toBe(0o600); + expect(readFileSync(join(out, 'a', 'é-ünïcode'), 'utf8')).toBe('ü'); + }); + + it('stores a file of several blocks intact', () => { + const data = Uint8Array.from({ length: 1024 * 1024 + 7 }, (_, i) => (i * 31) % 251); + const out = extract(writeTar([{ path: 'big.bin', mode: 0o644, data }])); + expect(Buffer.compare(readFileSync(join(out, 'big.bin')), Buffer.from(data))).toBe(0); + }); + + it('stores names longer than 100 bytes through the ustar prefix', () => { + const parent = 'p'.repeat(120); + const name = `${parent}/${'n'.repeat(90)}`; + const archive = writeTar([ + { path: parent, mode: 0o755 }, + { path: name, mode: 0o644, data: bytes('deep') }, + ]); + expect(execFileSync('tar', ['-t', '-f', archive], { encoding: 'utf8' }).split('\n').filter(Boolean)).toEqual([`${parent}/`, name]); + expect(readFileSync(join(extract(archive), name), 'utf8')).toBe('deep'); + }); + + it('lists only regular files and directories', () => { + const archive = writeTar([ + { path: 'd', mode: 0o700 }, + { path: 'd/f', mode: 0o600, data: bytes('x') }, + ]); + const types = execFileSync('tar', ['-tv', '-f', archive], { encoding: 'utf8' }) + .split('\n') + .filter(Boolean) + .map((line) => line[0]); + expect(types).toEqual(['d', '-']); + }); + + it('pads to whole 512-byte blocks and ends with two zero blocks', () => { + const archive = tarArchive([{ path: 'f', mode: 0o644, data: bytes('abc') }]); + expect(archive.length % 512).toBe(0); + expect(archive.length).toBeGreaterThanOrEqual(512 * 4); + expect(archive.subarray(archive.length - 1024).every((b) => b === 0)).toBe(true); + }); + + it.each([ + ['a single component over 100 bytes', 'x'.repeat(101)], + ['a name over 255 bytes', `${'d'.repeat(150)}/${'e'.repeat(110)}`], + ['a prefix over 155 bytes', `${'d'.repeat(160)}/f`], + ])('refuses %s, naming it', (_name, path) => { + expect(() => tarArchive([{ path, mode: 0o644, data: bytes('') }])).toThrow(path); + }); + + it.each([['/abs'], ['../up'], ['a/../b'], ['']])('refuses the unsafe path %j', (path) => { + expect(() => tarArchive([{ path, mode: 0o644, data: bytes('') }])).toThrow(); + }); +}); + +describe('REMOTE_INSTALL_COMMAND', () => { + it('is one single-quoted sh -c script without a quote or backslash inside', () => { + const match = /^exec sh -c '([^']*)'$/.exec(REMOTE_INSTALL_COMMAND); + expect(match).not.toBeNull(); + expect(match?.[1]).not.toMatch(/['\\]/); + }); + + it.each(['sh', 'bash', 'dash'].filter((shell) => spawnSync('sh', ['-c', `command -v ${shell}`]).status === 0))( + 'unpacks the archive from stdin and runs its install.sh from the home directory under %s', + (shell) => { + const home = join(dir, 'home dir'); + mkdirSync(home); + const archive = tarArchive([{ path: 'install.sh', mode: 0o755, data: bytes('printf "ok:%s\\n" "$1"; pwd; ls "$1"\n') }]); + const result = spawnSync(shell, ['-c', REMOTE_INSTALL_COMMAND], { + input: archive, + cwd: dir, + env: { HOME: home, PATH: process.env['PATH'] ?? '/usr/bin:/bin' }, + encoding: 'utf8', + }); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + const [ok, cwd, ...listing] = result.stdout.trimEnd().split('\n'); + expect(ok).toMatch(/^ok:\//); + expect(cwd).toBe(home); + expect(listing).toContain('install.sh'); + }, + ); +}); + +/** Arguments a stand-in Node at `path` was run with, one per NUL-terminated record in `log`. */ +const fakeNode = (path: string, log: string): void => { + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, `#!/bin/sh\nfor a in "$@"; do printf '%s\\0' "$a"; done > '${log.replaceAll("'", "'\\''")}'\n`); + chmodSync(path, 0o755); +}; + +const argsIn = (log: string): string[] => readFileSync(log, 'utf8').split('\0').slice(0, -1); + +describe('shimScript', () => { + it.each(AWKWARD)('runs a Node whose path contains %j with wtd.mjs under the home and every argument', (part) => { + const node = join(dir, `n ${part}`, 'bin', 'node'); + const log = join(dir, 'args'); + fakeNode(node, log); + const home = join(dir, `home ${part.replaceAll('\t', '_')}`); + const shim = join(home, '.local', 'bin', 'wtd'); + mkdirSync(join(home, '.local', 'bin'), { recursive: true }); + writeFileSync(shim, shimScript(node)); + chmodSync(shim, 0o700); + const result = spawnSync(shim, ['connect', 'a b', '$x', "'", ''], { env: { HOME: home, PATH: '/usr/bin:/bin' }, encoding: 'utf8' }); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + expect(argsIn(log)).toEqual([join(home, '.local', 'share', 'worktree-term', 'current', 'wtd.mjs'), 'connect', 'a b', '$x', "'", '']); + }); + + it('is a POSIX shell script', () => { + expect(shimScript('/usr/bin/node').startsWith('#!/bin/sh\n')).toBe(true); + }); + + it.each([ + ['a relative path', 'node'], + ['a NUL', '/opt/no\u0000de'], + ['a newline', '/opt/no\nde'], + ])('refuses a Node path with %s', (_name, node) => { + expect(() => shimScript(node)).toThrow(); + }); +}); + +describe('checkNodePath', () => { + it.each([['/usr/bin/node'], ['/opt/my node/bin/node'], ["/opt/n'; touch x; '/node"], ['/a/$HOME/%/\\/node']])('accepts %j', (path) => { + expect(() => { + checkNodePath(path); + }).not.toThrow(); + }); + + it.each([ + ['a relative path', 'bin/node'], + ['an empty path', ''], + ['a NUL', '/opt/no\u0000de'], + ['a newline', '/opt/no\nde'], + ['a carriage return', '/opt/no\rde'], + ])('refuses %s, naming the path', (_name, path) => { + expect(() => { + checkNodePath(path); + }).toThrow(/node/i); + }); +}); + +/** Values of the `[group]` section of an INI-like file, by key. */ +const section = (text: string, group: string): Map => { + const values = new Map(); + let inGroup = false; + for (const line of text.split('\n')) { + if (line.startsWith('[')) inGroup = line === `[${group}]`; + else if (inGroup && line.includes('=') && !line.startsWith('#')) { + const at = line.indexOf('='); + values.set(line.slice(0, at), line.slice(at + 1)); + } + } + return values; +}; + +/** A Desktop Entry string value, unescaped. */ +const desktopString = (raw: string): string => + raw.replace(/\\(.)/g, (_m, c: string) => ({ s: ' ', n: '\n', t: '\t', r: '\r', '\\': '\\' })[c] ?? `\\${c}`); + +const RESERVED = /[ \t\n"'\\><~|&;$*?#()`]/; + +/** An unescaped Desktop Entry `Exec` value split into arguments, as the spec defines. */ +const execArguments = (value: string): string[] => { + const args: string[] = []; + let at = 0; + while (at < value.length) { + let arg = ''; + if (value[at] === '"') { + at++; + for (;;) { + const c = value[at++]; + if (c === undefined) throw new Error(`unterminated quote in ${value}`); + if (c === '"') break; + if (c === '\\') { + const next = value[at++] ?? ''; + if (!'"`$\\'.includes(next)) throw new Error(`bad escape \\${next} in ${value}`); + arg += next; + } else { + if ('"`$\\'.includes(c)) throw new Error(`unescaped ${c} in ${value}`); + arg += c; + } + } + } else { + while (at < value.length && value[at] !== ' ') arg += value[at++] ?? ''; + if (RESERVED.test(arg)) throw new Error(`unquoted reserved character in ${arg}`); + } + if (value[at] !== undefined && value[at] !== ' ') throw new Error(`no separator after an argument in ${value}`); + if (value[at] === ' ') at++; + if (/%[^%]/.test(arg.replaceAll('%%', ''))) throw new Error(`field code in ${arg}`); + args.push(arg.replaceAll('%%', '%')); + } + return args; +}; + +describe('desktopEntry', () => { + it.each(AWKWARD)('runs the shim with ui from a home whose path contains %j', (part) => { + const home = `/home/u ${part}`; + const shim = `${home}/.local/bin/wtd`; + const icon = `${home}/.local/share/worktree-term/current/web/icon.svg`; + const entry = section(desktopEntry({ shim, icon }), 'Desktop Entry'); + expect(execArguments(desktopString(entry.get('Exec') ?? ''))).toEqual([shim, 'ui']); + expect(desktopString(entry.get('Icon') ?? '')).toBe(icon); + }); + + it('is an application named worktree-term, outside a terminal, matching the app window', () => { + const entry = section(desktopEntry({ shim: '/home/u/.local/bin/wtd', icon: '/i.svg' }), 'Desktop Entry'); + expect(entry.get('Type')).toBe('Application'); + expect(entry.get('Name')).toBe('worktree-term'); + expect(entry.get('Terminal')).toBe('false'); + expect(entry.get('StartupWMClass')).toBe('chrome-127.0.0.1__-Default'); + }); +}); + +/** An `ExecStart` value split into arguments, as systemd.service defines, with specifiers and `$$` resolved. */ +const execStartArguments = (value: string): string[] => { + const args: string[] = []; + let arg: string | null = null; + let quote: string | null = null; + const ESCAPES: Record = { n: '\n', t: '\t', s: ' ', '\\': '\\', '"': '"', "'": "'", r: '\r' }; + for (let at = 0; at < value.length; at++) { + const c = value[at] ?? ''; + if (c === '\\') { + const next = value[++at] ?? ''; + if (next === 'x') { + arg = (arg ?? '') + String.fromCharCode(Number.parseInt(value.slice(at + 1, at + 3), 16)); + at += 2; + } else { + const escaped = ESCAPES[next]; + if (escaped === undefined) throw new Error(`bad escape \\${next} in ${value}`); + arg = (arg ?? '') + escaped; + } + } else if (quote !== null) { + if (c === quote) quote = null; + else arg = (arg ?? '') + c; + } else if (c === '"' || c === "'") { + quote = c; + arg ??= ''; + } else if (c === ' ' || c === '\t') { + if (arg !== null) args.push(arg); + arg = null; + } else arg = (arg ?? '') + c; + } + if (quote !== null) throw new Error(`unterminated quote in ${value}`); + if (arg !== null) args.push(arg); + return args.map((a) => { + if ( + /%[^%]/.test(a.replaceAll('%%', '')) || + /\$[^$]/.test(a.replaceAll('$$', '')) || + /[%$]$/.test(a.replaceAll('%%', '').replaceAll('$$', '')) + ) { + throw new Error(`unresolved specifier or variable in ${a}`); + } + return a.replaceAll('%%', '%').replaceAll('$$', '$'); + }); +}; + +describe('systemdUnit', () => { + it.each(AWKWARD)('runs the shim with daemon from a home whose path contains %j', (part) => { + const shim = `/home/u ${part}/.local/bin/wtd`; + const service = section(systemdUnit(shim), 'Service'); + expect(execStartArguments(service.get('ExecStart') ?? '')).toEqual([shim, 'daemon']); + }); + + it('restarts only on failure and is wanted by default.target', () => { + const unit = systemdUnit('/home/u/.local/bin/wtd'); + expect(section(unit, 'Service').get('Restart')).toBe('on-failure'); + expect(section(unit, 'Install').get('WantedBy')).toBe('default.target'); + }); +}); + +describe('release names', () => { + it('are the version and 12 random lowercase hex digits', () => { + const a = releaseName('0.2.0'); + const b = releaseName('0.2.0'); + expect(a).toMatch(/^0\.2\.0-[0-9a-f]{12}$/); + expect(b).toMatch(/^0\.2\.0-[0-9a-f]{12}$/); + expect(a).not.toBe(b); + }); +}); + +describe('releasesToRemove', () => { + it('keeps the new release and the one current pointed to before', () => { + expect( + releasesToRemove(['0.1.0-aaaaaaaaaaaa', '0.2.0-bbbbbbbbbbbb', '0.2.0-cccccccccccc'], '0.2.0-cccccccccccc', '0.2.0-bbbbbbbbbbbb'), + ).toEqual(['0.1.0-aaaaaaaaaaaa']); + }); + + it('keeps only the new release on a first installation', () => { + expect(releasesToRemove(['0.1.0-aaaaaaaaaaaa', '0.2.0-bbbbbbbbbbbb'], '0.2.0-bbbbbbbbbbbb', null)).toEqual(['0.1.0-aaaaaaaaaaaa']); + }); + + it('ignores a previous release that no longer exists', () => { + expect(releasesToRemove(['0.1.0-aaaaaaaaaaaa', '0.2.0-bbbbbbbbbbbb'], '0.2.0-bbbbbbbbbbbb', '0.0.9-dddddddddddd')).toEqual([ + '0.1.0-aaaaaaaaaaaa', + ]); + }); + + it('leaves releases still being assembled alone', () => { + expect(releasesToRemove(['.0.3.0-eeeeeeeeeeee', '0.2.0-bbbbbbbbbbbb'], '0.2.0-bbbbbbbbbbbb', null)).toEqual([]); + }); +}); + +describe('test helpers', () => { + it('parse a quoted Desktop Entry Exec and a systemd ExecStart', () => { + expect(execArguments(desktopString('"/a b/\\\\$x" ui'))).toEqual(['/a b/$x', 'ui']); + expect(execStartArguments('"/a b/$$x%%" daemon')).toEqual(['/a b/$x%', 'daemon']); + expect(() => execArguments('/a b/x ui')).not.toThrow(); + expect(() => execArguments('/a$b ui')).toThrow(); + expect(() => execStartArguments('/a/$HOME daemon')).toThrow(); + }); +}); diff --git a/src/protocol/constants.test.ts b/src/protocol/constants.test.ts index 19ee727..5a256d6 100644 --- a/src/protocol/constants.test.ts +++ b/src/protocol/constants.test.ts @@ -4,7 +4,7 @@ import { ACK_EVERY, FLOW_HIGH, FLOW_LOW, LAG_EVICT_MS, MAX_FRAME, MAX_INPUT, PRO describe('shared constants', () => { it('hold their specified values', () => { expect({ PROTOCOL_VERSION, MAX_FRAME, MAX_INPUT, FLOW_HIGH, FLOW_LOW, ACK_EVERY, LAG_EVICT_MS }).toEqual({ - PROTOCOL_VERSION: 4, + PROTOCOL_VERSION: 5, MAX_FRAME: 16 * 1024 * 1024, MAX_INPUT: 64 * 1024, FLOW_HIGH: 512 * 1024, diff --git a/src/protocol/messages.test.ts b/src/protocol/messages.test.ts index e076836..0091d95 100644 --- a/src/protocol/messages.test.ts +++ b/src/protocol/messages.test.ts @@ -435,6 +435,20 @@ describe('value limits', () => { ['0 discovery roots', 'clientToDaemon', discover([], 1), false], ['32 discovery roots', 'clientToDaemon', discover(paths(32), 1), true], ['33 discovery roots', 'clientToDaemon', discover(paths(33), 1), false], + ['home discovery root', 'clientToDaemon', discover(['~'], 1), true], + ['home-relative discovery root', 'clientToDaemon', discover(['~/GitHub'], 1), true], + ['relative discovery root', 'clientToDaemon', discover(['GitHub'], 1), false], + ['other user home discovery root', 'clientToDaemon', discover(['~user/x'], 1), false], + ['bare ~/ discovery root', 'clientToDaemon', discover(['~/'], 1), false], + ['home-relative discovery root with NUL', 'clientToDaemon', discover(['~/a\u0000b'], 1), false], + ['4096-char home-relative discovery root', 'clientToDaemon', discover(['~/' + 'a'.repeat(4094)], 1), true], + ['4097-char home-relative discovery root', 'clientToDaemon', discover(['~/' + 'a'.repeat(4095)], 1), false], + ['home-relative watchRepo path', 'clientToDaemon', watch('~/x'), false], + ['home-relative addRepo repo', 'browserToHub', { t: 'addRepo', req: 1, host: 0, repo: '~/x' }, false], + ['home-relative removeRepo repo', 'browserToHub', { t: 'removeRepo', req: 1, host: 0, repo: '~/x' }, false], + ['home-relative createTerm worktree', 'clientToDaemon', { ...create('shell', null), worktree: '~/w' }, false], + ['1024-char host reason', 'hubToBrowser', { t: 'hosts', hosts: [{ ...hosts[0], status: 'down', reason: 'r'.repeat(1024) }] }, true], + ['1025-char host reason', 'hubToBrowser', { t: 'hosts', hosts: [{ ...hosts[0], status: 'down', reason: 'r'.repeat(1025) }] }, false], ['4096 discovered repos', 'daemonToClient', { t: 'reposDiscovered', req: 1, repos: paths(4096) }, true], ['4097 discovered repos', 'daemonToClient', { t: 'reposDiscovered', req: 1, repos: paths(4097) }, false], ['4097 discovered repos via hub', 'hubToBrowser', { t: 'reposDiscovered', req: 1, host: 0, repos: paths(4097) }, false], @@ -463,6 +477,15 @@ describe('value limits', () => { expect(decodes(dir, value)).toBe(ok); }); + it('decodes home-relative discovery roots unchanged', () => { + expect(decodeMessage('clientToDaemon', raw({ t: 'discoverRepos', req: 1, roots: ['~', '~/GitHub'], depth: 3 }))).toEqual({ + t: 'discoverRepos', + req: 1, + roots: ['~', '~/GitHub'], + depth: 3, + }); + }); + it('rejects a relative watchRepo path', () => { expectRejected('clientToDaemon', raw({ t: 'watchRepo', req: 1, repo: 'repo/a' })); }); @@ -527,6 +550,26 @@ describe('host entries and tokens', () => { expectRejected('hubToBrowser', raw({ t: 'hosts', hosts: [withoutInstance] })); }); + it('rejects a host entry without reason', () => { + const withoutReason = Object.fromEntries(Object.entries(entry ?? {}).filter(([key]) => key !== 'reason')); + expectRejected('hubToBrowser', raw({ t: 'hosts', hosts: [withoutReason] })); + }); + + it('decodes an outdated host entry carrying its instance', () => { + const outdated = { ...entry, status: 'outdated', daemonVersion: '0.0.9', instance: 'old_D' }; + expect(decodeMessage('hubToBrowser', raw({ t: 'hosts', hosts: [outdated] }))).toEqual({ t: 'hosts', hosts: [outdated] }); + }); + + it.each([ + ['a null reason', null, true], + ['an SSH error line', 'ssh: Could not resolve hostname box: Name or service not known', true], + ['a 1024-char reason', 'r'.repeat(1024), true], + ['a 1025-char reason', 'r'.repeat(1025), false], + ['a numeric reason', 255, false], + ])('decodes a host entry with %s', (_name, reason, ok) => { + expect(decodes('hubToBrowser', withEntry({ status: 'down', reason }))).toBe(ok); + }); + it.each([ ['a null instance', null, true], ['a daemon instance', 'A-_9'.repeat(16), true], diff --git a/src/protocol/test-samples.ts b/src/protocol/test-samples.ts index cf799f5..bb49b05 100644 --- a/src/protocol/test-samples.ts +++ b/src/protocol/test-samples.ts @@ -90,11 +90,29 @@ export const fullTabLayout: Layout = { }; export const hosts: HostEntry[] = [ - { idx: 0, name: 'local', remote: false, status: 'connected', daemonVersion: '0.1.0', instance: 'd_1-A', repos: [REPO] }, - { idx: 1, name: 'devbox', remote: true, status: 'outdated', daemonVersion: '0.0.9', instance: null, repos: [] }, - { idx: 2, name: 'gpu', remote: true, status: 'connecting', daemonVersion: null, instance: null, repos: [WT] }, - { idx: 3, name: 'old', remote: true, status: 'reconnecting', daemonVersion: null, instance: null, repos: [] }, - { idx: 65535, name: 'x'.repeat(64), remote: true, status: 'down', daemonVersion: null, instance: null, repos: [] }, + { idx: 0, name: 'local', remote: false, status: 'connected', reason: null, daemonVersion: '0.1.0', instance: 'd_1-A', repos: [REPO] }, + { idx: 1, name: 'devbox', remote: true, status: 'outdated', reason: null, daemonVersion: '0.0.9', instance: 'old_D', repos: [] }, + { idx: 2, name: 'gpu', remote: true, status: 'connecting', reason: null, daemonVersion: null, instance: null, repos: [WT] }, + { + idx: 3, + name: 'old', + remote: true, + status: 'reconnecting', + reason: 'ssh: connect to host old port 22: Connection refused', + daemonVersion: null, + instance: null, + repos: [], + }, + { + idx: 65535, + name: 'x'.repeat(64), + remote: true, + status: 'down', + reason: 'r'.repeat(1024), + daemonVersion: null, + instance: null, + repos: [], + }, ]; export const TOKEN = '0123456789abcdef'.repeat(4); @@ -105,6 +123,7 @@ export const clientToDaemonSamples: MessageOf<'clientToDaemon'>[] = [ { t: 'watchRepo', req: 1, repo: REPO }, { t: 'unwatchRepo', req: 2, repo: REPO }, { t: 'discoverRepos', req: 3, roots: ['/home/u/GitHub', '/srv'], depth: 3 }, + { t: 'discoverRepos', req: 11, roots: ['~', '~/GitHub'], depth: 3 }, { t: 'createTerm', req: 4, worktree: WT, preset: 'claude', command: 'claude --resume', cols: 80, rows: 24 }, { t: 'createTerm', req: 5, worktree: WT, preset: 'shell', command: null, cols: 1000, rows: 1 }, { t: 'attach', req: 6, termId: 1 }, diff --git a/src/protocol/wire.golden.test.ts b/src/protocol/wire.golden.test.ts index 55e7c0d..120f532 100644 --- a/src/protocol/wire.golden.test.ts +++ b/src/protocol/wire.golden.test.ts @@ -127,6 +127,21 @@ const EDGE_CASES: readonly unknown[] = [ { t: 'presets', presets: [{ name: ' ', command: null }] }, { t: 'presets', presets: [{ name: 'p', command: '' }] }, { t: 'setLayout', req: 1, worktree: WT, layout: { tabs: [{ id: 'a', root: ninePanes }], active: 0 } }, + { t: 'discoverRepos', req: 1, roots: ['GitHub'], depth: 3 }, + { t: 'discoverRepos', req: 1, roots: ['~user/x'], depth: 3 }, + { t: 'discoverRepos', req: 1, roots: ['~/'], depth: 3 }, + { t: 'watchRepo', req: 1, repo: '~/x' }, + { t: 'addRepo', req: 1, host: 0, repo: '~/x' }, + { + t: 'hosts', + hosts: [{ idx: 0, name: 'local', remote: false, status: 'connected', daemonVersion: '0.1.0', instance: 'd_1-A', repos: [] }], + }, + { + t: 'hosts', + hosts: [ + { idx: 1, name: 'box', remote: true, status: 'down', reason: 'r'.repeat(1025), daemonVersion: null, instance: null, repos: [] }, + ], + }, ]; const allJson = [...new Set([...DIRECTIONS.flatMap((dir) => samples[dir].map((m) => raw(m))), ...EDGE_CASES.map((m) => raw(m))])]; @@ -198,8 +213,8 @@ describe('wire golden', () => { await expect(JSON.stringify(current, null, 2) + '\n').toMatchFileSnapshot('./wire.golden.json'); }); - it('is blessed for protocol version 4', () => { - expect(previousSchema.parse(wireJson).protocolVersion).toBe(4); + it('is blessed for protocol version 5', () => { + expect(previousSchema.parse(wireJson).protocolVersion).toBe(5); }); it('records every sample as valid in its own direction', () => { diff --git a/src/web/client/memory.test.ts b/src/web/client/memory.test.ts new file mode 100644 index 0000000..3e463df --- /dev/null +++ b/src/web/client/memory.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { hostKey, TerminalMemory, type RememberedTerminal, type TerminalStorage } from './memory.js'; + +/** An in-memory stand-in for `localStorage`. */ +const mapStorage = (): TerminalStorage & { items: Map } => { + const items = new Map(); + return { + items, + getItem: (key) => items.get(key) ?? null, + setItem: (key, value) => { + items.set(key, value); + }, + }; +}; + +const throwing: TerminalStorage = { + getItem: () => { + throw new Error('SecurityError'); + }, + setItem: () => { + throw new Error('QuotaExceededError'); + }, +}; + +const TERMS: RememberedTerminal[] = [ + { worktree: 'feat', preset: 'claude' }, + { worktree: 'main', preset: 'shell' }, +]; + +describe('hostKey', () => { + it('keys the local host and each remote host name separately', () => { + expect(hostKey({ remote: false, name: 'laptop' })).toBe('local'); + expect(hostKey({ remote: true, name: 'box' })).toBe('remote:box'); + expect(hostKey({ remote: true, name: 'local' })).not.toBe(hostKey({ remote: false, name: 'local' })); + }); +}); + +describe('TerminalMemory', () => { + it('recalls the terminals remembered for a host and instance, with the time it saw them', () => { + const memory = new TerminalMemory(mapStorage()); + memory.remember('local', 'inst_1', TERMS, 1000); + expect(memory.recall('local', 'inst_1')).toEqual({ at: 1000, terminals: TERMS }); + }); + + it('survives a reload through its storage', () => { + const storage = mapStorage(); + new TerminalMemory(storage).remember('remote:box', 'inst_1', TERMS, 2000); + expect(new TerminalMemory(storage).recall('remote:box', 'inst_1')).toEqual({ at: 2000, terminals: TERMS }); + }); + + it('recalls nothing for another instance or a null one', () => { + const memory = new TerminalMemory(mapStorage()); + memory.remember('local', 'inst_1', TERMS, 1000); + expect(memory.recall('local', 'inst_2')).toBeNull(); + expect(memory.recall('local', null)).toBeNull(); + }); + + it('keeps hosts apart', () => { + const memory = new TerminalMemory(mapStorage()); + memory.remember('local', 'inst_1', TERMS, 1000); + memory.remember('remote:box', 'inst_1', [{ worktree: 'x', preset: 'shell' }], 3000); + expect(memory.recall('local', 'inst_1')?.terminals).toEqual(TERMS); + expect(memory.recall('remote:box', 'inst_1')?.terminals).toEqual([{ worktree: 'x', preset: 'shell' }]); + expect(memory.recall('remote:gpu', 'inst_1')).toBeNull(); + }); + + it('replaces what it remembered for a host, including an empty list', () => { + const memory = new TerminalMemory(mapStorage()); + memory.remember('local', 'inst_1', TERMS, 1000); + memory.remember('local', 'inst_2', [], 5000); + expect(memory.recall('local', 'inst_1')).toBeNull(); + expect(memory.recall('local', 'inst_2')).toEqual({ at: 5000, terminals: [] }); + }); + + it('keeps at most 256 terminals per host', () => { + const memory = new TerminalMemory(mapStorage()); + const many = Array.from({ length: 300 }, (_, i) => ({ worktree: `wt${String(i)}`, preset: 'shell' })); + memory.remember('local', 'inst_1', many, 1000); + expect(memory.recall('local', 'inst_1')?.terminals).toHaveLength(256); + }); + + it('loses only the list when storage throws', () => { + const memory = new TerminalMemory(throwing); + expect(() => { + memory.remember('local', 'inst_1', TERMS, 1000); + }).not.toThrow(); + expect(memory.recall('local', 'inst_1')).toBeNull(); + }); + + it('works without storage', () => { + const memory = new TerminalMemory(null); + memory.remember('local', 'inst_1', TERMS, 1000); + expect(memory.recall('local', 'inst_1')).toBeNull(); + }); + + it('ignores stored data it cannot read', () => { + const storage = mapStorage(); + const memory = new TerminalMemory(storage); + memory.remember('local', 'inst_1', TERMS, 1000); + for (const key of storage.items.keys()) storage.items.set(key, '{"not":"a record"'); + expect(new TerminalMemory(storage).recall('local', 'inst_1')).toBeNull(); + for (const key of storage.items.keys()) storage.items.set(key, JSON.stringify({ instance: 'inst_1', at: 'x', terminals: 7 })); + expect(new TerminalMemory(storage).recall('local', 'inst_1')).toBeNull(); + }); +}); diff --git a/src/web/layout/tree.ts b/src/web/layout/tree.ts index 02845cd..40bdecf 100644 --- a/src/web/layout/tree.ts +++ b/src/web/layout/tree.ts @@ -92,7 +92,9 @@ export const split = (layout: Layout | null, termId: number, dir: SplitDir, newT const index = selected.active; return { tabs: selected.tabs.map((tab, i) => - i === index ? { ...tab, root: replaceLeaf(tab.root, termId, { split: dir, ratio: 0.5, a: { term: termId }, b: { term: newTerm } }) } : tab, + i === index + ? { ...tab, root: replaceLeaf(tab.root, termId, { split: dir, ratio: 0.5, a: { term: termId }, b: { term: newTerm } }) } + : tab, ), active: index, }; diff --git a/src/web/ui/app.tsx b/src/web/ui/app.tsx index cdce2a0..5ca2cce 100644 --- a/src/web/ui/app.tsx +++ b/src/web/ui/app.tsx @@ -52,11 +52,19 @@ const OutdatedHost = ({ host, client }: { host: HostEntry; client: HubClient }) /** An attention mark, absent without one; its title gives the number of terminals per mark. */ const Mark = ({ of }: { of: Aggregate }) => - of.mark === null ? null : ( - - ); + of.mark === null ? null : ; -const WorktreeEntry = ({ entry, selected, terminals, view }: { entry: SidebarEntry; selected: boolean; terminals: Terminal[]; view: View }) => ( +const WorktreeEntry = ({ + entry, + selected, + terminals, + view, +}: { + entry: SidebarEntry; + selected: boolean; + terminals: Terminal[]; + view: View; +}) => (
(() => { const tab = this.activeTab.value; if (tab === null) return []; - return paneRects(tab.root, this.localArea.value).map((p) => ({ ...p, terminal: tab.terminals.find((t) => t.termId === p.termId) ?? null })); + return paneRects(tab.root, this.localArea.value).map((p) => ({ + ...p, + terminal: tab.terminals.find((t) => t.termId === p.termId) ?? null, + })); }); readonly dividers = computed(() => { const tab = this.activeTab.value; @@ -571,13 +574,15 @@ export class View { let next: Layout; if (op.t === 'split' && terminals.some((t) => t.termId === op.target) && canSplit(latest, op.target)) { next = split(latest, op.target, op.dir, termId); - } - else if (canAddTab(latest)) next = selectTab(latest, termId); + } else if (canAddTab(latest)) next = selectTab(latest, termId); else return; const tab = next.tabs[next.active]; batch(() => { if (tab !== undefined) { - this.focusRecords.value = new Map(this.focusRecords.peek()).set(`${String(host)}:${worktree}:${tab.id}`, { termId, root: tab.root }); + this.focusRecords.value = new Map(this.focusRecords.peek()).set(`${String(host)}:${worktree}:${tab.id}`, { + termId, + root: tab.root, + }); } this.storeLayout(host, worktree, next); }); diff --git a/test/architecture/dependencies.ts b/test/architecture/dependencies.ts index 498477f..ec0b6de 100644 --- a/test/architecture/dependencies.ts +++ b/test/architecture/dependencies.ts @@ -24,6 +24,7 @@ export const ALLOWLIST: Readonly> = { protocol: ['zod'], 'platform.files': ['fs'], 'platform.dialer': ['net', 'child_process'], + 'platform.install': ['fs', 'child_process'], 'daemon.worktrees': ['fs', 'child_process'], 'daemon.terminals': ['@homebridge/node-pty-prebuilt-multiarch', '@xterm/headless', '@xterm/addon-serialize', 'fs'], 'daemon.state': ['zod'], diff --git a/test/architecture/structure.test.ts b/test/architecture/structure.test.ts index b59fcc1..1c9c191 100644 --- a/test/architecture/structure.test.ts +++ b/test/architecture/structure.test.ts @@ -24,6 +24,7 @@ describe('model', () => { ['web', 'src/web', false], ['platform.files', 'src/platform/files', true], ['platform.dialer', 'src/platform/dialer', true], + ['platform.install', 'src/platform/install', true], ['daemon.worktrees', 'src/daemon/worktrees', true], ['daemon.terminals', 'src/daemon/terminals', true], ['daemon.state', 'src/daemon/state', true], @@ -45,7 +46,8 @@ describe('model', () => { it('reads the arrows', () => { expect(model.arrows).toContainEqual({ from: 'cli', to: 'daemon.main' }); expect(model.arrows).toContainEqual({ from: 'daemon', to: 'protocol' }); - expect(model.arrows).toHaveLength(34); + expect(model.arrows).toContainEqual({ from: 'platform.install', to: 'platform.dialer' }); + expect(model.arrows).toHaveLength(38); }); }); diff --git a/test/e2e/add-repo.spec.ts b/test/e2e/add-repo.spec.ts new file mode 100644 index 0000000..8f2bacc --- /dev/null +++ b/test/e2e/add-repo.spec.ts @@ -0,0 +1,215 @@ +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Locator, Page } from '@playwright/test'; +import { addWorktree, makeRepo } from '../support/daemon-host.js'; +import { installRemote } from '../support/fake-ssh.js'; +import { byTestId, hostBanner, repoTab, TID, worktreeEntry } from './contract.js'; +import { + confirmAction, + expect, + hubRequests, + LOCAL, + makeRepoWith, + newTerminal, + nodeRemote, + openUi, + ready, + REMOTE, + selectRepo, + test, + writeHubConfig, +} from './fixture.js'; + +/** Opens the add-repo dialog and returns it. */ +const openAddRepo = async (page: Page): Promise => { + await page.locator(byTestId(TID.addRepo)).click(); + const dialog = page.locator(byTestId(TID.addRepoDialog)); + await expect(dialog).toBeVisible(); + return dialog; +}; + +/** Paths of the discovered repos the dialog offers, in order. */ +const offered = (dialog: Locator): Promise => + dialog.locator(byTestId(TID.addRepoOption)).evaluateAll((options) => options.map((o) => o.getAttribute('title') ?? '')); + +test.describe('add repo', () => { + test('picking a discovered repo adds its tab, selects it and shows its worktrees', async ({ hub, page, wire }) => { + const src = join(hub.dir, 'src'); + const one = makeRepo(join(src, 'one')); + const two = makeRepo(join(src, 'two')); + const wt = addWorktree(two, join(hub.dir, 'two-feature'), 'feature'); + writeHubConfig(hub, { repos: [one], roots: [src] }); + await openUi(hub, page); + await expect(page.locator(worktreeEntry(one))).toBeVisible(); + + const dialog = await openAddRepo(page); + await expect(dialog.locator(byTestId(TID.addRepoHost))).toHaveValue(String(LOCAL)); + await expect.poll(() => offered(dialog)).toEqual([two]); + const mark = wire.markSent(); + await dialog.locator(byTestId(TID.addRepoOption)).first().click(); + await expect(dialog).toHaveCount(0); + expect(hubRequests(wire, mark)).toEqual([{ t: 'addRepo', host: LOCAL }]); + await expect(page.locator(repoTab(two))).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator(worktreeEntry(wt))).toBeVisible(); + }); + + test('repos the host already lists are not offered', async ({ hub, page }) => { + const root = join(hub.dir, 'r'); + const a = makeRepo(join(root, 'a')); + const b = makeRepo(join(root, 'b')); + writeHubConfig(hub, { repos: [a], roots: [root] }); + await openUi(hub, page); + const dialog = await openAddRepo(page); + await expect.poll(() => offered(dialog)).toEqual([b]); + }); + + test('the filter narrows the discovered repos by substring', async ({ hub, page }) => { + const root = join(hub.dir, 'r'); + const alpha = makeRepo(join(root, 'alpha')); + const beta = makeRepo(join(root, 'beta')); + const betamax = makeRepo(join(root, 'betamax')); + writeHubConfig(hub, { roots: [root] }); + await openUi(hub, page); + const dialog = await openAddRepo(page); + await expect.poll(() => offered(dialog)).toEqual([alpha, beta, betamax]); + const filter = dialog.locator(byTestId(TID.addRepoFilter)); + await filter.fill('beta'); + await expect.poll(() => offered(dialog)).toEqual([beta, betamax]); + await filter.fill('max'); + await expect.poll(() => offered(dialog)).toEqual([betamax]); + }); + + test('a typed path that is not a repository shows not-a-repo and keeps the dialog open', async ({ hub, page, wire }) => { + const { repo } = makeRepoWith(hub, 'app', []); + const plain = join(hub.dir, 'plain'); + mkdirSync(plain); + writeHubConfig(hub, { repos: [repo], roots: [join(hub.dir, 'nothing-here')] }); + await openUi(hub, page); + const dialog = await openAddRepo(page); + const mark = wire.markSent(); + await dialog.locator(byTestId(TID.addRepoPath)).fill(plain); + await dialog.locator(byTestId(TID.addRepoPath)).press('Enter'); + await expect(dialog.locator(byTestId(TID.addRepoError))).toContainText('not-a-repo'); + await expect(dialog).toBeVisible(); + expect(hubRequests(wire, mark)).toEqual([{ t: 'addRepo', host: LOCAL }]); + await expect(page.locator(repoTab(plain))).toHaveCount(0); + }); + + test('a typed repository path is added and selected', async ({ hub, page }) => { + const { repo } = makeRepoWith(hub, 'app', []); + const other = makeRepoWith(hub, 'other', ['side']); + writeHubConfig(hub, { repos: [repo], roots: [join(hub.dir, 'nothing-here')] }); + await openUi(hub, page); + const dialog = await openAddRepo(page); + await dialog.locator(byTestId(TID.addRepoPath)).fill(other.repo); + await dialog.locator(byTestId(TID.addRepoPath)).press('Enter'); + await expect(dialog).toHaveCount(0); + await expect(page.locator(repoTab(other.repo))).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator(worktreeEntry(other.worktrees[0] ?? ''))).toBeVisible(); + }); + + test('Escape closes the dialog without sending anything', async ({ hub, page, wire }) => { + const root = join(hub.dir, 'r'); + const b = makeRepo(join(root, 'b')); + writeHubConfig(hub, { roots: [root] }); + await openUi(hub, page); + const dialog = await openAddRepo(page); + await expect.poll(() => offered(dialog)).toEqual([b]); + const mark = wire.markSent(); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await page.waitForTimeout(300); + expect(hubRequests(wire, mark)).toEqual([]); + await expect(page.locator(repoTab(b))).toHaveCount(0); + }); + + test('the host selector lists every host with its status, disables unconnected ones and adds to a remote', async ({ hub, page, ssh }) => { + test.setTimeout(120_000); + const remote = nodeRemote(ssh, 'box'); + const app = makeRepo(join(remote.home, 'app')); + const other = makeRepo(join(remote.home, 'other')); + await installRemote(hub, ssh, 'box'); + ssh.failHost('gpu', 'ssh: Could not resolve hostname gpu'); + writeHubConfig(hub, { + roots: [join(hub.dir, 'nothing-here')], + hosts: [ + { name: 'box', ssh: 'box', repos: [app], roots: [remote.home] }, + { name: 'gpu', ssh: 'gpu', repos: [] }, + ], + }); + await openUi(hub, page); + await expect(page.locator(hostBanner(2))).toContainText('down', { timeout: 15_000 }); + await selectRepo(page, app); + await expect(page.locator(worktreeEntry(app))).toBeVisible({ timeout: 15_000 }); + + const dialog = await openAddRepo(page); + const select = dialog.locator(byTestId(TID.addRepoHost)); + await expect(select).toHaveValue(String(REMOTE)); + await expect(select.locator('option')).toHaveCount(3); + await expect(select.locator(`option[value="${String(LOCAL)}"]`)).toBeEnabled(); + await expect(select.locator(`option[value="${String(REMOTE)}"]`)).toContainText('box'); + await expect(select.locator(`option[value="${String(REMOTE)}"]`)).toContainText('connected'); + const gpu = select.locator('option[value="2"]'); + await expect(gpu).toContainText('gpu'); + await expect(gpu).toContainText('down'); + await expect(gpu).toBeDisabled(); + + await expect.poll(() => offered(dialog), { timeout: 15_000 }).toEqual([other]); + await dialog.locator(byTestId(TID.addRepoOption)).first().click(); + await expect(dialog).toHaveCount(0); + await expect(page.locator(repoTab(other))).toContainText('box:other'); + await expect(page.locator(repoTab(other))).toHaveAttribute('aria-selected', 'true'); + }); + + test('a repo added by another session is watched and shown with its worktrees', async ({ hub, page }) => { + const first = makeRepoWith(hub, 'one', []); + const second = makeRepoWith(hub, 'two', ['side']); + writeHubConfig(hub, { repos: [first.repo] }); + await openUi(hub, page); + await expect(page.locator(worktreeEntry(first.repo))).toBeVisible(); + + const other = await hub.session(); + try { + await other.waitHost(LOCAL, (h) => h.status === 'connected', { timeout: 10_000 }); + const reply = await other.hubRequest({ t: 'addRepo', host: LOCAL, repo: second.repo }); + expect(reply.m.t).toBe('done'); + } finally { + other.close(); + } + await expect(page.locator(repoTab(second.repo))).toBeVisible(); + await selectRepo(page, second.repo); + await expect(page.locator(worktreeEntry(second.worktrees[0] ?? ''))).toBeVisible(); + }); +}); + +test.describe('remove repo', () => { + test('removing a repo without terminals after confirming makes its tab disappear', async ({ hub, page, wire }) => { + const a = makeRepoWith(hub, 'a', []); + const b = makeRepoWith(hub, 'b', []); + writeHubConfig(hub, { repos: [a.repo, b.repo] }); + await openUi(hub, page); + await expect(page.locator(worktreeEntry(a.repo))).toBeVisible(); + const mark = wire.markSent(); + await page.locator(repoTab(a.repo)).locator(byTestId(TID.removeRepo)).click(); + await expect(page.locator(byTestId(TID.confirmDialog))).toBeVisible(); + expect(hubRequests(wire, mark)).toEqual([]); + await confirmAction(page); + await expect(page.locator(repoTab(a.repo))).toHaveCount(0); + expect(hubRequests(wire, mark)).toEqual([{ t: 'removeRepo', host: LOCAL }]); + await expect(page.locator(repoTab(b.repo))).toBeVisible(); + }); + + test('a repo with a terminal keeps its tab and the page says to close its terminals first', async ({ hub, page }) => { + const { repo } = makeRepoWith(hub, 'app', []); + writeHubConfig(hub, { repos: [repo] }); + await openUi(hub, page); + const term = await newTerminal(page); + await ready(page, term); + await page.locator(repoTab(repo)).locator(byTestId(TID.removeRepo)).click(); + await confirmAction(page); + const notice = page.locator(byTestId(TID.notice)); + await expect(notice).toContainText(/close/i); + await expect(notice).toContainText(/terminal/i); + await expect(page.locator(repoTab(repo))).toBeVisible(); + }); +}); diff --git a/test/e2e/contract.ts b/test/e2e/contract.ts index 786c8ff..494585d 100644 --- a/test/e2e/contract.ts +++ b/test/e2e/contract.ts @@ -20,14 +20,42 @@ export const TID = { outdatedUi: 'outdated-ui', /** Visible while the WebSocket is closed and the page reconnects. */ reconnecting: 'reconnecting', - /** One per repo, in hub order; text = label, `title` = full path, `aria-selected="true"` when selected. */ + /** One per repo, in hub order; text starts with the label (`:` prefixed for a remote host), `title` = full path, `aria-selected="true"` when selected. */ repoTab: 'repo-tab', + /** Inside the repo tab of a host that is not `connected`: text = the host's status; absent while connected. */ + hostStatus: 'host-status', + /** Button inside each repo tab; opens the confirmation dialog, then sends `removeRepo` for that tab's host and repo. */ + removeRepo: 'remove-repo', /** In the selected repo's area when its watch failed; text holds the error code and the path. */ repoError: 'repo-error', - /** Banner of an `outdated` host; text contains "outdated". */ - hostOutdated: 'host-outdated', - /** Button in the outdated banner; asks `window.confirm` (message says terminals will be killed), then sends `restartDaemon`. */ - restartDaemon: 'restart-daemon', + /** One per `down` or `outdated` host, none for other statuses; `data-host` = host index; text names the host, its status and its reason when present. */ + hostBanner: 'host-banner', + /** Button inside a host banner: "Restart daemon" (outdated local), "Reinstall & restart" (outdated remote) or "Install" (down remote); opens the confirmation dialog, then sends `restartDaemon` or `reinstallDaemon`. */ + hostAction: 'host-action', + /** In-page confirmation of a host action or a repo removal; nothing is sent before it is confirmed; text says every running terminal on the host will be killed when it lists none. */ + confirmDialog: 'confirm-dialog', + /** One running terminal the page last saw on the daemon instance the host reports; text holds its worktree label and preset. */ + confirmTarget: 'confirm-target', + /** Present when the dialog lists terminals; text says when the page last saw them. */ + confirmSeen: 'confirm-seen', + /** Confirms the confirmation dialog. */ + confirmOk: 'confirm-ok', + /** Cancels the confirmation dialog, sending nothing. */ + confirmCancel: 'confirm-cancel', + /** Last control of the repo tab bar; opens the add-repo dialog. */ + addRepo: 'add-repo', + /** The add-repo dialog; Escape closes it without sending anything. */ + addRepoDialog: 'add-repo-dialog', + /** ` { + view.changeAddRepoHost(Number(event.currentTarget.value)); + }} + > + {client.store.hosts.value.map((h) => ( + + ))} + + { + view.setAddRepoField('filter', event.currentTarget.value); + }} + /> +
+ {dialog.discovered === null &&
Discovering…
} + {view.offered.value.map((repo) => ( + + ))} +
+ { + view.setAddRepoField('path', event.currentTarget.value); + }} + onKeyDown={(event) => { + if (event.key !== 'Enter') return; + event.preventDefault(); + view.chooseRepo(event.currentTarget.value); + }} + /> + {dialog.error !== null && ( +
+ {dialog.error} +
+ )} +
+ +
+ + ); +}; + +/** One repo tab: its label, the host's status while not connected, attention marks and a remove action. */ +const RepoTabButton = ({ client, view, tab, selected }: { client: HubClient; view: View; tab: RepoTab; selected: boolean }) => { + const key = repoKey(tab.host, tab.repo); + const status = client.store.hosts.value.find((h) => h.idx === tab.host)?.status ?? null; + const select = (): void => { + view.selectRepo(tab); + }; + return ( + ); }; @@ -362,13 +575,14 @@ const CloseDialog = ({ view, request }: { view: View; request: CloseRequest }) = tabIndex={-1} ref={dialog} onKeyDown={(event) => { - if (event.key !== 'Enter' && event.key !== 'Escape') return; - // A focused button acts on its own click. - if (event.key === 'Enter' && event.target !== event.currentTarget) return; - event.preventDefault(); - event.stopPropagation(); - if (event.key === 'Enter') view.confirmClose(); - else view.cancelClose(); + dialogKeys( + () => { + view.confirmClose(); + }, + () => { + view.cancelClose(); + }, + )(event); }} >
Close running terminals?
@@ -478,30 +692,27 @@ export const App = ({ client, manager, view }: AppProps): JSX.Element => { if (status === 'outdated') return ; const selected = view.tab.value; const notice = view.notice.value ?? client.store.notice.value; + const confirmation = view.confirmation.value; + const addRepo = view.addRepo.value; return (
{view.tabs.value.map((t) => { const key = repoKey(t.host, t.repo); const isSelected = selected !== null && repoKey(selected.host, selected.repo) === key; - return ( - - ); + return ; })} + {status === 'reconnecting' && ( @@ -520,11 +731,9 @@ export const App = ({ client, manager, view }: AppProps): JSX.Element => { Checked only
- {client.store.hosts.value - .filter((h) => h.status === 'outdated') - .map((h) => ( - - ))} + {client.store.hosts.value.filter(hasBanner).map((h) => ( + + ))} {notice !== null && ( )} + {confirmation !== null && } + {addRepo !== null && }
); }; diff --git a/src/web/ui/hosts.test.ts b/src/web/ui/hosts.test.ts new file mode 100644 index 0000000..2d981e4 --- /dev/null +++ b/src/web/ui/hosts.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { hasBanner, hostAction, offeredRepos } from './hosts.js'; + +describe('hostAction', () => { + it.each([ + [false, 'outdated', { kind: 'restart', label: 'Restart daemon' }], + [true, 'outdated', { kind: 'reinstall', label: 'Reinstall & restart' }], + [true, 'down', { kind: 'reinstall', label: 'Install' }], + [false, 'down', null], + [true, 'connected', null], + [true, 'reconnecting', null], + [false, 'connecting', null], + ] as const)('gives a %s host that is %s the action %j', (remote, status, action) => { + expect(hostAction({ remote, status })).toEqual(action); + }); +}); + +describe('hasBanner', () => { + it.each([ + ['down', true], + ['outdated', true], + ['connected', false], + ['connecting', false], + ['reconnecting', false], + ] as const)('is %s for a %s host', (status, banner) => { + expect(hasBanner({ status })).toBe(banner); + }); +}); + +describe('offeredRepos', () => { + const discovered = ['/r/alpha', '/r/beta', '/r/betamax']; + + it('offers the discovered repos the host does not list, in order', () => { + expect(offeredRepos(discovered, ['/r/beta'], '')).toEqual(['/r/alpha', '/r/betamax']); + }); + + it('narrows them to paths containing the filter', () => { + expect(offeredRepos(discovered, [], 'beta')).toEqual(['/r/beta', '/r/betamax']); + expect(offeredRepos(discovered, [], 'max')).toEqual(['/r/betamax']); + expect(offeredRepos(discovered, [], 'zzz')).toEqual([]); + }); +}); diff --git a/src/web/ui/hosts.ts b/src/web/ui/hosts.ts new file mode 100644 index 0000000..d1df9de --- /dev/null +++ b/src/web/ui/hosts.ts @@ -0,0 +1,23 @@ +import type { HostEntry } from '../../protocol/index.js'; + +/** What a host banner's action does. */ +export interface HostAction { + kind: 'restart' | 'reinstall'; + label: string; +} + +/** The banner action of `host`: restart an outdated local daemon, reinstall an outdated remote, install on a down remote. */ +export const hostAction = (host: Pick): HostAction | null => { + if (host.status === 'outdated') { + return host.remote ? { kind: 'reinstall', label: 'Reinstall & restart' } : { kind: 'restart', label: 'Restart daemon' }; + } + if (host.status === 'down' && host.remote) return { kind: 'reinstall', label: 'Install' }; + return null; +}; + +/** Whether `host` gets a banner. */ +export const hasBanner = (host: Pick): boolean => host.status === 'down' || host.status === 'outdated'; + +/** Discovered repos the host does not list yet, narrowed to those whose path contains `filter`. */ +export const offeredRepos = (discovered: readonly string[], listed: readonly string[], filter: string): string[] => + discovered.filter((repo) => !listed.includes(repo) && repo.includes(filter)); diff --git a/src/web/ui/sidebar.ts b/src/web/ui/sidebar.ts index 2071d19..3e23081 100644 --- a/src/web/ui/sidebar.ts +++ b/src/web/ui/sidebar.ts @@ -1,4 +1,7 @@ import type { Terminal, Worktree } from '../../protocol/index.js'; +import { worktreeLabel } from '../client/index.js'; + +export { worktreeLabel }; /** One sidebar entry. */ export interface SidebarEntry { @@ -12,17 +15,8 @@ export interface SidebarEntry { export type WorktreeFilter = 'all' | 'checked'; -const SHORT_HEAD = 7; - const directoryName = (path: string): string => path.slice(path.lastIndexOf('/') + 1); -/** The branch, or ` @ ` when detached, or the directory name. */ -export const worktreeLabel = (worktree: Worktree): string => { - if (worktree.branch !== null) return worktree.branch; - const dir = directoryName(worktree.path); - return worktree.detached && worktree.head !== null ? `${dir} @ ${worktree.head.slice(0, SHORT_HEAD)}` : dir; -}; - /** Listed worktrees in the daemon's order without bare ones, then vanished worktrees that still have terminals. */ export const sidebarEntries = (state: { worktrees: readonly Worktree[]; diff --git a/src/web/ui/view.ts b/src/web/ui/view.ts index e72a47c..798cda6 100644 --- a/src/web/ui/view.ts +++ b/src/web/ui/view.ts @@ -1,6 +1,6 @@ import { batch, computed, effect, signal } from '@preact/signals'; import type { HostEntry, Layout, Preset, Terminal } from '../../protocol/index.js'; -import { repoKey, type HubClient, type RepoState } from '../client/index.js'; +import { hostKey, repoKey, RequestError, type HubClient, type Recalled, type RepoState } from '../client/index.js'; import { canAddTab, canSplit, @@ -24,6 +24,7 @@ import { } from '../layout/index.js'; import type { Box, TerminalManager } from '../terminals/index.js'; import { closeTargets, needsConfirm } from './closing.js'; +import { hostAction, offeredRepos, type HostAction } from './hosts.js'; import { keymap, type Action } from './keymap.js'; import { pickerValid, type PickerContext, type PickerOp, type PickerWorld } from './picker.js'; import { repoTabs, type RepoTab } from './repo-tabs.js'; @@ -70,6 +71,9 @@ const markAfterPaint = (name: string): void => { const message = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +/** An error as the add-repo dialog shows it: the code of a refused request, then its message. */ +const describe = (error: unknown): string => (error instanceof RequestError ? `${error.code}: ${error.message}` : message(error)); + /** The box of a pane's terminal: the pane below its header. */ export const terminalBoxOf = (rect: Rect): Box => ({ left: rect.left, @@ -135,6 +139,21 @@ interface Picker { index: number; } +/** What the in-page confirmation asks about. */ +export type Confirmation = + | { t: 'host'; host: HostEntry; action: HostAction; recalled: Recalled | null } + | { t: 'removeRepo'; host: number; repo: string; label: string }; + +/** The add-repo dialog. */ +export interface AddRepoDialog { + host: number; + /** Repos discovered on the host; null while discovery runs. */ + discovered: readonly string[] | null; + filter: string; + path: string; + error: string | null; +} + /** The page's selection, stored per browser, and what it shows; keeps the terminal manager in step. */ export class View { readonly selectedRepo = signal(load(REPO_KEY)); @@ -146,13 +165,30 @@ export class View { readonly closing = signal(null); /** A problem of the page itself, such as a failed copy. */ readonly notice = signal(null); + readonly confirmation = signal(null); + readonly addRepo = signal(null); private readonly drag = signal(null); /** The focused pane per tab key, with the tab's panes when it was last resolved; replaced when the user chooses a pane. */ private readonly focusRecords = signal(new Map()); /** Per host and worktree, a terminal its full layout misses that the page shows anyway. */ private readonly outside = signal(new Map()); - readonly tabs = computed(() => repoTabs(this.client.store.hosts.value)); + readonly tabs = computed(() => { + const kept = this.client.store.kept.value; + return repoTabs( + this.client.store.hosts.value.map((h) => ({ + ...h, + repos: [...h.repos, ...(kept.get(h.idx) ?? []).filter((r) => !h.repos.includes(r))], + })), + ); + }); + /** The repos the add-repo dialog offers. */ + readonly offered = computed(() => { + const dialog = this.addRepo.value; + if (dialog?.discovered == null) return []; + const listed = this.client.store.hosts.value.find((h) => h.idx === dialog.host)?.repos ?? []; + return offeredRepos(dialog.discovered, listed, dialog.filter); + }); readonly tab = computed(() => { const tabs = this.tabs.value; return tabs.find((t) => repoKey(t.host, t.repo) === this.selectedRepo.value) ?? tabs[0] ?? null; @@ -258,7 +294,9 @@ export class View { return this.canCreate.value && focused !== null && canSplit(this.layout.value, focused); }); /** Whether a picker or dialog owns the keys. */ - readonly modal = computed(() => this.picker.value !== null || this.closing.value !== null); + readonly modal = computed( + () => this.picker.value !== null || this.closing.value !== null || this.confirmation.value !== null || this.addRepo.value !== null, + ); private shownKey = ''; private focusKey = ''; @@ -403,6 +441,94 @@ export class View { this.closing.value = null; } + /** Asks to confirm the banner action of `host`, naming the terminals last seen on its daemon instance. */ + requestHostAction(host: HostEntry): void { + const action = hostAction(host); + if (action === null) return; + this.confirmation.value = { t: 'host', host, action, recalled: this.client.memory.recall(hostKey(host), host.instance) }; + } + + /** Asks to confirm removing the repo of `tab`. */ + requestRemoveRepo(tab: RepoTab): void { + this.confirmation.value = { t: 'removeRepo', host: tab.host, repo: tab.repo, label: tab.label }; + } + + cancelConfirmation(): void { + this.confirmation.value = null; + } + + /** Runs the confirmed action, showing its failure on the page. */ + confirm(): void { + const confirmation = this.confirmation.value; + this.confirmation.value = null; + if (confirmation === null) return; + if (confirmation.t === 'removeRepo') { + this.client.removeRepo(confirmation.host, confirmation.repo).catch((error: unknown) => { + this.notice.value = + error instanceof RequestError && error.code === 'busy' + ? `${confirmation.label} has terminals: close them first, then remove the repo.` + : `Removing ${confirmation.label} failed: ${message(error)}`; + }); + return; + } + const { host, action } = confirmation; + const run = action.kind === 'restart' ? this.client.restartDaemon(host.idx) : this.client.reinstallDaemon(host.idx); + run.catch((error: unknown) => { + this.notice.value = `${action.label} on ${host.name} failed: ${message(error)}`; + }); + } + + /** Opens the add-repo dialog for the selected tab's host and discovers its repos. */ + openAddRepo(): void { + this.changeAddRepoHost(this.tab.value?.host ?? 0); + } + + /** Shows the add-repo dialog for `host`, discovering its repos. */ + changeAddRepoHost(host: number): void { + const dialog = this.addRepo.value; + this.addRepo.value = { host, discovered: null, filter: dialog?.filter ?? '', path: dialog?.path ?? '', error: null }; + this.client + .discoverRepos(host) + .then((repos) => { + this.updateAddRepo(host, { discovered: repos }); + }) + .catch((error: unknown) => { + this.updateAddRepo(host, { discovered: [], error: describe(error) }); + }); + } + + setAddRepoField(field: 'filter' | 'path', value: string): void { + const dialog = this.addRepo.value; + if (dialog !== null) this.addRepo.value = { ...dialog, [field]: value }; + } + + closeAddRepo(): void { + this.addRepo.value = null; + } + + /** Adds `repo` to the dialog's host; on success closes the dialog and selects the repo's tab once the hub lists it. */ + chooseRepo(repo: string): void { + const dialog = this.addRepo.value; + if (dialog === null || repo === '') return; + const { host } = dialog; + this.updateAddRepo(host, { error: null }); + this.client + .addRepo(host, repo) + .then(() => { + if (this.addRepo.peek()?.host === host) this.addRepo.value = null; + this.selectedRepo.value = repoKey(host, repo); + localStorage.setItem(REPO_KEY, repoKey(host, repo)); + }) + .catch((error: unknown) => { + this.updateAddRepo(host, { error: describe(error) }); + }); + } + + private updateAddRepo(host: number, change: Partial): void { + const dialog = this.addRepo.peek(); + if (dialog?.host === host) this.addRepo.value = { ...dialog, ...change }; + } + /** Starts dragging `divider` of the shown tab. */ startDrag(divider: Divider): void { const tab = this.tab.value; diff --git a/test/process/hub-router.test.ts b/test/process/hub-router.test.ts index 2979a54..d77c828 100644 --- a/test/process/hub-router.test.ts +++ b/test/process/hub-router.test.ts @@ -941,7 +941,7 @@ describe('removing repos', () => { const before = readFileSync(host.configPath, 'utf8'); expect(await client.hubRequest({ t: 'removeRepo', host: 0, repo })).toMatchObject({ from: 'hub', - m: { t: 'error', req: 2, host: 0, code: 'busy' }, + m: { t: 'error', req: 3, host: 0, code: 'busy' }, }); expect(readFileSync(host.configPath, 'utf8')).toBe(before); }); diff --git a/test/process/install-remote.test.ts b/test/process/install-remote.test.ts index 6cec6bf..264d6a0 100644 --- a/test/process/install-remote.test.ts +++ b/test/process/install-remote.test.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { PROTOCOL_VERSION } from '../../src/protocol/index.js'; @@ -235,12 +235,30 @@ describe('wtd install-remote', () => { it('keeps the previous installation when the new one fails for want of Node', async () => { const remote = ssh.addHost('box'); - expect((await runInstallRemote(host, ssh, 'box', ['--node', process.execPath])).exit.code).toBe(0); + const nodeDir = join(host.dir, 'gone-node'); + mkdirSync(nodeDir); + symlinkSync(process.execPath, join(nodeDir, 'node')); + expect((await runInstallRemote(host, ssh, 'box', ['--node', join(nodeDir, 'node')])).exit.code).toBe(0); + rmSync(nodeDir, { recursive: true }); const before = treeSnapshot(join(remote.home, '.local')); expect((await runInstallRemote(host, ssh, 'box', [])).exit.code).toBe(1); expect(treeSnapshot(join(remote.home, '.local'))).toEqual(before); }); + it('reinstalls without --node with the Node the installed shim runs', async () => { + const nodeDir = join(host.dir, `given n'o$de`); + mkdirSync(nodeDir); + symlinkSync(process.execPath, join(nodeDir, 'node')); + const remote = ssh.addHost('box'); + expect((await runInstallRemote(host, ssh, 'box', ['--node', join(nodeDir, 'node')])).exit.code).toBe(0); + const first = remote.current(); + const run = await runInstallRemote(host, ssh, 'box', []); + expect(run.exit, run.stderr).toEqual({ code: 0, signal: null }); + expect(remote.current()).not.toBe(first); + expect(remote.releases()).toHaveLength(2); + await expectBridged('box', remote, join(nodeDir, 'node')); + }); + it('fails with SSH’s last error line when the host cannot be reached', async () => { const run = await runInstallRemote(host, ssh, 'nowhere', ['--node', process.execPath]); expect(run.exit).toEqual({ code: 1, signal: null }); From 065b349bbf9965dcae6fff3b85d2134f6df6013c Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Wed, 7 Oct 2026 14:25:19 +0200 Subject: [PATCH 4/6] DEV-2054 PR 5 review fixes: the add-repo dialog discovers once its host is connected; an SSH or systemctl flooding stderr past 1 MiB is ended instead of stalling; the remote installer keeps the last 64 KiB of output, takes the last result line and escalates to SIGKILL; links escalate to SIGKILL; Sonar cleanups; test hosts wait for processes using their HOME --- src/cli/run.ts | 29 ++++--- src/hub/config/config.ts | 2 +- src/hub/links/index.ts | 22 +++-- src/hub/links/tail.test.ts | 17 ++++ src/platform/dialer/index.ts | 14 +++- src/platform/dialer/tail.ts | 14 +++- src/platform/install/formats.ts | 32 +++++--- src/platform/install/install.test.ts | 2 +- src/platform/install/local.ts | 8 +- src/platform/install/remote.ts | 15 ++-- src/web/ui/app.tsx | 6 +- src/web/ui/hosts.test.ts | 20 ++++- src/web/ui/hosts.ts | 19 +++++ src/web/ui/view.ts | 53 +++++++++--- test/e2e/add-repo.spec.ts | 30 ++++++- test/e2e/fixture.ts | 6 +- test/e2e/hosts.spec.ts | 2 +- test/process/hub-router.test.ts | 21 +++++ test/process/install-helpers.ts | 118 ++++++++++++++++----------- test/process/install-local.test.ts | 21 +++++ test/process/install-remote.test.ts | 61 +++++++++++++- test/support/daemon-host.ts | 59 +++++++++----- test/support/fake-ssh.ts | 35 ++++++-- 23 files changed, 468 insertions(+), 138 deletions(-) diff --git a/src/cli/run.ts b/src/cli/run.ts index 5c06e02..6d696e6 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -189,21 +189,26 @@ interface Invocation { const isCommandOption = (name: string): name is keyof CommandOptions => name === 'systemd' || name === 'node'; +/** Records one option in `invocation`; throws UsageError for a misused or unknown one. */ +const applyOption = ( + invocation: Invocation, + { name, rawName, value }: { name: string; rawName: string; value: string | undefined }, +): void => { + if (name === 'node') { + if (value === undefined) throw new UsageError(`option ${rawName} needs a value`); + invocation.options.node = value; + } else if (value !== undefined) throw new UsageError(`option ${rawName} takes no value`); + if (name === 'help') invocation.help = true; + else if (name === 'version') invocation.version = true; + else if (name === 'systemd') invocation.options.systemd = true; + else if (!isCommandOption(name)) throw new UsageError(`unknown option ${rawName}`); + if (isCommandOption(name)) invocation.given.push({ name, rawName }); +}; + const parse = (argv: readonly string[]): Invocation => { const { tokens, positionals } = parseArgs({ args: [...argv], options: OPTIONS, allowPositionals: true, strict: false, tokens: true }); const invocation: Invocation = { help: false, version: false, positionals, options: { systemd: false, node: null }, given: [] }; - for (const token of tokens) { - if (token.kind !== 'option') continue; - if (token.name === 'node') { - if (token.value === undefined) throw new UsageError(`option ${token.rawName} needs a value`); - invocation.options.node = token.value; - } else if (token.value !== undefined) throw new UsageError(`option ${token.rawName} takes no value`); - if (token.name === 'help') invocation.help = true; - else if (token.name === 'version') invocation.version = true; - else if (token.name === 'systemd') invocation.options.systemd = true; - else if (!isCommandOption(token.name)) throw new UsageError(`unknown option ${token.rawName}`); - if (isCommandOption(token.name)) invocation.given.push({ name: token.name, rawName: token.rawName }); - } + for (const token of tokens) if (token.kind === 'option') applyOption(invocation, token); return invocation; }; diff --git a/src/hub/config/config.ts b/src/hub/config/config.ts index 0317aee..8b375f5 100644 --- a/src/hub/config/config.ts +++ b/src/hub/config/config.ts @@ -51,7 +51,7 @@ const absolutePath = z.string().max(MAX_PATH).regex(NO_NUL, 'must not contain NU const roots = z.array(discoveryRootSchema).min(1).max(MAX_ROOTS); // eslint-disable-next-line no-control-regex -- control characters are what aliases must not contain -const SSH_ALIAS = /^[^-\s\u0000-\u001f\u007f][^\s\u0000-\u001f\u007f]*$/; +const SSH_ALIAS = /^[^-\s\u0000-\u0008\u000e-\u001f\u007f][^\s\u0000-\u0008\u000e-\u001f\u007f]*$/; const remoteHost = z.strictObject({ name: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/, 'must be 1–64 characters from A–Z, a–z, 0–9, ., _ and -'), diff --git a/src/hub/links/index.ts b/src/hub/links/index.ts index 57cfc75..0600e12 100644 --- a/src/hub/links/index.ts +++ b/src/hub/links/index.ts @@ -170,7 +170,10 @@ class ProcessLink implements Link { this.child.stdout.once('end', () => { this.lost(); }); - this.tail.follow(this.child.stderr); + this.tail.follow(this.child.stderr, () => { + this.child.kill('SIGTERM'); + this.signalLater('SIGKILL', CLOSE_GRACE_MS); + }); this.child.stderr.once('close', () => { this.stderrDone = true; if (this.exit !== null) this.finish(this.cause()); @@ -204,13 +207,22 @@ class ProcessLink implements Link { this.terminate(); } - /** Ends standard input, then sends SIGTERM if the process is still running 1 s later. */ + /** Ends standard input, then sends SIGTERM if the process is still running 1 s later, and SIGKILL 1 s after that. */ private terminate(): void { this.child.stdin.end(); - if (this.child.exitCode !== null || this.child.signalCode !== null) return; + this.signalLater('SIGTERM', CLOSE_GRACE_MS); + this.signalLater('SIGKILL', 2 * CLOSE_GRACE_MS); + } + + private signalLater(signal: NodeJS.Signals, ms: number): void { + if (!this.running()) return; setTimeout(() => { - if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill('SIGTERM'); - }, CLOSE_GRACE_MS).unref(); + if (this.running()) this.child.kill(signal); + }, ms).unref(); + } + + private running(): boolean { + return this.child.exitCode === null && this.child.signalCode === null; } private receive(chunk: Uint8Array): void { diff --git a/src/hub/links/tail.test.ts b/src/hub/links/tail.test.ts index 2815fa3..e7a917b 100644 --- a/src/hub/links/tail.test.ts +++ b/src/hub/links/tail.test.ts @@ -1,3 +1,5 @@ +import { once } from 'node:events'; +import { PassThrough } from 'node:stream'; import { describe, expect, it } from 'vitest'; import { StderrTail } from './tail.js'; @@ -61,4 +63,19 @@ describe('StderrTail', () => { it('gives the latest line after a long burst', () => { expect(tailOf('z'.repeat(100_000), '\nssh: Connection timed out\n').reason()).toBe('ssh: Connection timed out'); }); + + it('closes a stream after 1 MiB and reports it full once', async () => { + const tail = new StderrTail(); + const stream = new PassThrough(); + stream.on('error', () => undefined); + let full = 0; + tail.follow(stream, () => { + full++; + }); + const chunk = bytes('w'.repeat(64 * 1024)); + for (let i = 0; i < 32 && !stream.destroyed; i++) stream.write(chunk); + await once(stream, 'close'); + expect(full).toBe(1); + expect(tail.size).toBeLessThanOrEqual(4096); + }); }); diff --git a/src/platform/dialer/index.ts b/src/platform/dialer/index.ts index 47f0f36..7bcd13e 100644 --- a/src/platform/dialer/index.ts +++ b/src/platform/dialer/index.ts @@ -1,4 +1,4 @@ -import { spawn } from 'node:child_process'; +import { spawn, type ChildProcess } from 'node:child_process'; import { connect, type Socket } from 'node:net'; import { openLog, pathExists, preparePrivateDirs, sshControlPath, UNIT_NAME, type HostPaths } from '../files/index.js'; @@ -78,7 +78,7 @@ const startDaemon = async (paths: HostPaths, command: readonly string[]): Promis const SSH_OPTIONS = ['BatchMode=yes', 'ConnectTimeout=10', 'ControlMaster=auto', 'ControlPersist=10m']; const MAX_ALIAS = 255; // eslint-disable-next-line no-control-regex -- control characters are what aliases must not contain -const BAD_ALIAS_CHARACTER = /[\s\u0000-\u001f\u007f]/; +const BAD_ALIAS_CHARACTER = /[\s\u0000-\u0008\u000e-\u001f\u007f]/; /** Throws naming `alias` unless it is a usable SSH alias: 1–255 characters, no leading `-`, whitespace or control characters. */ export const checkSshAlias = (alias: string): void => { @@ -127,4 +127,14 @@ export const dial = async (paths: HostPaths, command: readonly string[]): Promis } }; +/** Sends `child` SIGTERM, then SIGKILL if it is still running `graceMs` later. */ +export const stopProcess = (child: ChildProcess, graceMs: number): void => { + const running = (): boolean => child.exitCode === null && child.signalCode === null; + if (!running()) return; + child.kill('SIGTERM'); + setTimeout(() => { + if (running()) child.kill('SIGKILL'); + }, graceMs).unref(); +}; + export { StderrTail } from './tail.js'; diff --git a/src/platform/dialer/tail.ts b/src/platform/dialer/tail.ts index c9a0445..216a9d9 100644 --- a/src/platform/dialer/tail.ts +++ b/src/platform/dialer/tail.ts @@ -2,7 +2,7 @@ import type { Readable } from 'node:stream'; const MAX_TAIL = 4096; const MAX_REASON = 1024; -/** Most standard error read from one process; the rest is left unread, so a flood cannot grow memory. */ +/** Most standard error read from one process, so a flood cannot grow memory. */ const MAX_READ = 1024 * 1024; // eslint-disable-next-line no-control-regex -- control characters are what a reason must not contain @@ -16,13 +16,19 @@ export class StderrTail { return this.bytes.length; } - /** Feeds the tail from `stream`, closing it once 1 MiB has been read. */ - follow(stream: Readable): void { + /** + * Feeds the tail from `stream`. Once 1 MiB has been read it closes the stream and calls `full`: + * a writer left without a reader blocks, so the caller should end it. + */ + follow(stream: Readable, full?: () => void): void { let read = 0; stream.on('data', (chunk: Buffer) => { + if (stream.destroyed) return; read += chunk.length; this.push(chunk); - if (read >= MAX_READ) stream.destroy(); + if (read < MAX_READ) return; + stream.destroy(); + full?.(); }); } diff --git a/src/platform/install/formats.ts b/src/platform/install/formats.ts index f710589..5ddf772 100644 --- a/src/platform/install/formats.ts +++ b/src/platform/install/formats.ts @@ -8,8 +8,10 @@ export const checkNodePath = (path: string): void => { } }; +const QUOTED_QUOTE = String.raw`'\''`; + /** POSIX single-quoted form of `value`. */ -export const shellQuote = (value: string): string => `'${value.replaceAll("'", `'\\''`)}'`; +export const shellQuote = (value: string): string => `'${value.replaceAll("'", QUOTED_QUOTE)}'`; /** The `~/.local/bin/wtd` shim: runs `node` with the current release's `wtd.mjs` and every argument. */ export const shimScript = (node: string): string => { @@ -19,34 +21,42 @@ export const shimScript = (node: string): string => { /** A Desktop Entry string value. */ const desktopString = (value: string): string => - value.replaceAll('\\', '\\\\').replaceAll('\n', '\\n').replaceAll('\t', '\\t').replaceAll('\r', '\\r'); + value + .replaceAll('\\', String.raw`\\`) + .replaceAll('\n', String.raw`\n`) + .replaceAll('\t', String.raw`\t`) + .replaceAll('\r', String.raw`\r`); + +const ESCAPED_MATCH = String.raw`\$&`; /** One `Exec` argument, double-quoted as the Desktop Entry specification requires. */ -const execArgument = (value: string): string => `"${value.replace(/["`$\\]/g, '\\$&')}"`.replaceAll('%', '%%'); +const execArgument = (value: string): string => `"${value.replace(/["`$\\]/g, ESCAPED_MATCH)}"`.replaceAll('%', '%%'); /** The launcher: runs `shim ui` outside a terminal, matching the app window `wtd ui` opens. */ -export const desktopEntry = ({ shim, icon }: { shim: string; icon: string }): string => - [ +export const desktopEntry = ({ shim, icon }: { shim: string; icon: string }): string => { + const exec = `${execArgument(shim)} ui`; + return [ '[Desktop Entry]', 'Type=Application', 'Name=worktree-term', 'Comment=Worktree-centric terminal manager', - `Exec=${desktopString(`${execArgument(shim)} ui`)}`, + `Exec=${desktopString(exec)}`, `Icon=${desktopString(icon)}`, 'Terminal=false', `StartupWMClass=${WM_CLASS}`, 'Categories=Development;', '', ].join('\n'); +}; /** One `ExecStart` argument, double-quoted with C escapes, `%%` and `$$`. */ const unitArgument = (value: string): string => { const escaped = value - .replaceAll('\\', '\\\\') - .replaceAll('"', '\\"') - .replaceAll('\n', '\\n') - .replaceAll('\t', '\\t') - .replaceAll('\r', '\\r') + .replaceAll('\\', String.raw`\\`) + .replaceAll('"', String.raw`\"`) + .replaceAll('\n', String.raw`\n`) + .replaceAll('\t', String.raw`\t`) + .replaceAll('\r', String.raw`\r`) .replaceAll('%', '%%') .replaceAll('$', '$$$$'); return `"${escaped}"`; diff --git a/src/platform/install/install.test.ts b/src/platform/install/install.test.ts index 431ef3f..6cc4e8c 100644 --- a/src/platform/install/install.test.ts +++ b/src/platform/install/install.test.ts @@ -70,7 +70,7 @@ describe('tarArchive', () => { expect(readFileSync(join(out, 'a', 'x.txt'), 'utf8')).toBe('hello\n'); expect(statSync(join(out, 'a', 'x.txt')).mode & 0o777).toBe(0o644); expect(statSync(join(out, 'a', 'run')).mode & 0o777).toBe(0o755); - expect(readFileSync(join(out, 'a', 'empty')).length).toBe(0); + expect(readFileSync(join(out, 'a', 'empty'))).toHaveLength(0); expect(statSync(join(out, 'a', 'empty')).mode & 0o777).toBe(0o600); expect(readFileSync(join(out, 'a', 'é-ünïcode'), 'utf8')).toBe('ü'); }); diff --git a/src/platform/install/local.ts b/src/platform/install/local.ts index 7ce8ba6..ee95bce 100644 --- a/src/platform/install/local.ts +++ b/src/platform/install/local.ts @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process'; import { dirname, join } from 'node:path'; -import { dial, StderrTail, systemctlProgram } from '../dialer/index.js'; +import { dial, StderrTail, stopProcess, systemctlProgram } from '../dialer/index.js'; import { makePrivateDirs, UNIT_NAME, writeFileAtomic, type EntryKind, type HostPaths } from '../files/index.js'; import { checkNodePath, desktopEntry, shimScript, systemdUnit } from './formats.js'; import { checkOwned, installRelease, layoutOf, releaseEntries } from './release.js'; @@ -24,6 +24,8 @@ export interface Installed { dataDir: string; } +const KILL_GRACE_MS = 1000; + /** Runs `systemctl --user `; throws naming the command and its last error line when it fails. */ const systemctl = (...args: string[]): Promise => new Promise((resolve, reject) => { @@ -33,7 +35,9 @@ const systemctl = (...args: string[]): Promise => reject(new Error(`${command.join(' ')} failed: ${cause}`)); }; const child = spawn(command[0] ?? 'systemctl', command.slice(1), { stdio: ['ignore', 'ignore', 'pipe'] }); - tail.follow(child.stderr); + tail.follow(child.stderr, () => { + stopProcess(child, KILL_GRACE_MS); + }); child.once('error', (error) => { fail(error.message); }); diff --git a/src/platform/install/remote.ts b/src/platform/install/remote.ts index ac0738e..d5e9687 100644 --- a/src/platform/install/remote.ts +++ b/src/platform/install/remote.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { sshCommand, StderrTail } from '../dialer/index.js'; +import { sshCommand, StderrTail, stopProcess } from '../dialer/index.js'; import { preparePrivateDirs, type HostPaths } from '../files/index.js'; import { checkNodePath } from './formats.js'; import { releaseEntries } from './release.js'; @@ -149,9 +149,9 @@ const archiveOf = async ({ bundle, version, node }: RemoteInstallation): Promise return tarArchive(entries); }; -/** The result line the remote installer printed, without its marker, or null. */ +/** The last result line the remote installer printed, without its marker, or null. */ const resultOf = (stdout: string): string | null => { - const line = stdout.split('\n').find((l) => l.startsWith(`${RESULT} `)); + const line = stdout.split('\n').findLast((l) => l.startsWith(`${RESULT} `)); return line === undefined ? null : line.slice(RESULT.length + 1); }; @@ -167,14 +167,17 @@ export const installRemote = async (installation: RemoteInstallation): Promise { - child.kill('SIGTERM'); + stopProcess(child, EXIT_GRACE_MS); reject(new Error(`${alias}: the installation did not finish within ${String(timeoutMs / 1000)} s`)); }, timeoutMs); child.stdout.setEncoding('utf8'); + // The result line comes last, so only the end of the output is kept. child.stdout.on('data', (chunk: string) => { - if (out.length < MAX_STDOUT) out += chunk; + out = (out + chunk).slice(-MAX_STDOUT); + }); + tail.follow(child.stderr, () => { + stopProcess(child, EXIT_GRACE_MS); }); - tail.follow(child.stderr); child.stdin.on('error', () => undefined); child.once('error', (error) => { clearTimeout(timer); diff --git a/src/web/ui/app.tsx b/src/web/ui/app.tsx index 9a6ed03..86f8035 100644 --- a/src/web/ui/app.tsx +++ b/src/web/ui/app.tsx @@ -94,7 +94,10 @@ const ConfirmBody = ({ confirmation }: { confirmation: Confirmation }) => {

These running terminals on {host.name} will be killed:

    {recalled.terminals.map((t, i) => ( -
  • +
  • {t.worktree}: {t.preset}
  • ))} @@ -181,6 +184,7 @@ const AddRepoBox = ({ client, view, dialog }: { client: HubClient; view: View; d type="button" key={repo} role="option" + aria-selected={false} data-testid="add-repo-option" title={repo} onClick={() => { diff --git a/src/web/ui/hosts.test.ts b/src/web/ui/hosts.test.ts index 2d981e4..2610809 100644 --- a/src/web/ui/hosts.test.ts +++ b/src/web/ui/hosts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { hasBanner, hostAction, offeredRepos } from './hosts.js'; +import { hasBanner, hostAction, Latest, offeredRepos } from './hosts.js'; describe('hostAction', () => { it.each([ @@ -40,3 +40,21 @@ describe('offeredRepos', () => { expect(offeredRepos(discovered, [], 'zzz')).toEqual([]); }); }); + +describe('Latest', () => { + it('counts only the latest request', () => { + const latest = new Latest(); + const first = latest.start(); + const second = latest.start(); + expect(latest.isLatest(first)).toBe(false); + expect(latest.isLatest(second)).toBe(true); + }); + + it('makes every started request stale on drop', () => { + const latest = new Latest(); + const request = latest.start(); + latest.drop(); + expect(latest.isLatest(request)).toBe(false); + expect(latest.isLatest(latest.start())).toBe(true); + }); +}); diff --git a/src/web/ui/hosts.ts b/src/web/ui/hosts.ts index d1df9de..9349809 100644 --- a/src/web/ui/hosts.ts +++ b/src/web/ui/hosts.ts @@ -21,3 +21,22 @@ export const hasBanner = (host: Pick): boolean => host.stat /** Discovered repos the host does not list yet, narrowed to those whose path contains `filter`. */ export const offeredRepos = (discovered: readonly string[], listed: readonly string[], filter: string): string[] => discovered.filter((repo) => !listed.includes(repo) && repo.includes(filter)); + +/** Numbered requests where only the latest counts: an answer to an earlier one is stale. */ +export class Latest { + private last = 0; + + /** Starts a request and returns its number. */ + start(): number { + return ++this.last; + } + + /** Makes every request started so far stale. */ + drop(): void { + this.last++; + } + + isLatest(request: number): boolean { + return request === this.last; + } +} diff --git a/src/web/ui/view.ts b/src/web/ui/view.ts index 798cda6..603bdda 100644 --- a/src/web/ui/view.ts +++ b/src/web/ui/view.ts @@ -24,7 +24,7 @@ import { } from '../layout/index.js'; import type { Box, TerminalManager } from '../terminals/index.js'; import { closeTargets, needsConfirm } from './closing.js'; -import { hostAction, offeredRepos, type HostAction } from './hosts.js'; +import { hostAction, Latest, offeredRepos, type HostAction } from './hosts.js'; import { keymap, type Action } from './keymap.js'; import { pickerValid, type PickerContext, type PickerOp, type PickerWorld } from './picker.js'; import { repoTabs, type RepoTab } from './repo-tabs.js'; @@ -147,8 +147,10 @@ export type Confirmation = /** The add-repo dialog. */ export interface AddRepoDialog { host: number; - /** Repos discovered on the host; null while discovery runs. */ + /** Repos discovered on the host; null until discovery answers. */ discovered: readonly string[] | null; + /** Whether discovery runs once the host is connected: none has run yet, or the last one failed. */ + pending: boolean; filter: string; path: string; error: string | null; @@ -300,6 +302,10 @@ export class View { private shownKey = ''; private focusKey = ''; + /** Whether the add-repo dialog discovered since its host last became connected. */ + private discoveredSinceConnect = false; + /** The add-repo discovery whose answer the dialog shows. */ + private readonly discovery = new Latest(); constructor( private readonly client: HubClient, @@ -317,6 +323,9 @@ export class View { effect(() => { this.checkDrag(); }); + effect(() => { + this.checkAddRepo(); + }); manager.onFocus((host, termId) => { if (host === this.tab.peek()?.host) this.focusPane(termId); }); @@ -483,18 +492,12 @@ export class View { this.changeAddRepoHost(this.tab.value?.host ?? 0); } - /** Shows the add-repo dialog for `host`, discovering its repos. */ + /** Shows the add-repo dialog for `host`, discovering its repos once it is connected. */ changeAddRepoHost(host: number): void { - const dialog = this.addRepo.value; - this.addRepo.value = { host, discovered: null, filter: dialog?.filter ?? '', path: dialog?.path ?? '', error: null }; - this.client - .discoverRepos(host) - .then((repos) => { - this.updateAddRepo(host, { discovered: repos }); - }) - .catch((error: unknown) => { - this.updateAddRepo(host, { discovered: [], error: describe(error) }); - }); + const dialog = this.addRepo.peek(); + this.discoveredSinceConnect = false; + this.discovery.drop(); + this.addRepo.value = { host, discovered: null, pending: true, filter: dialog?.filter ?? '', path: dialog?.path ?? '', error: null }; } setAddRepoField(field: 'filter' | 'path', value: string): void { @@ -503,6 +506,7 @@ export class View { } closeAddRepo(): void { + this.discovery.drop(); this.addRepo.value = null; } @@ -524,6 +528,29 @@ export class View { }); } + /** Runs the dialog's pending discovery once its host is connected; a failed one again only after a reconnect. */ + private checkAddRepo(): void { + const dialog = this.addRepo.value; + const hosts = this.client.store.hosts.value; + if (dialog === null || !hosts.some((h) => h.idx === dialog.host && h.status === 'connected')) { + this.discoveredSinceConnect = false; + return; + } + if (!dialog.pending || this.discoveredSinceConnect) return; + this.discoveredSinceConnect = true; + const { host } = dialog; + const discovery = this.discovery.start(); + this.addRepo.value = { ...dialog, pending: false }; + this.client + .discoverRepos(host) + .then((repos) => { + if (this.discovery.isLatest(discovery)) this.updateAddRepo(host, { discovered: repos, error: null }); + }) + .catch((error: unknown) => { + if (this.discovery.isLatest(discovery)) this.updateAddRepo(host, { discovered: [], pending: true, error: describe(error) }); + }); + } + private updateAddRepo(host: number, change: Partial): void { const dialog = this.addRepo.peek(); if (dialog?.host === host) this.addRepo.value = { ...dialog, ...change }; diff --git a/test/e2e/add-repo.spec.ts b/test/e2e/add-repo.spec.ts index 8f2bacc..e1582df 100644 --- a/test/e2e/add-repo.spec.ts +++ b/test/e2e/add-repo.spec.ts @@ -28,6 +28,10 @@ const openAddRepo = async (page: Page): Promise => { return dialog; }; +/** Waits until the dialog lists `host` as connected. */ +const hostConnected = (dialog: Locator, host: number): Promise => + expect(dialog.locator(byTestId(TID.addRepoHost)).locator(`option[value="${String(host)}"]`)).toContainText('(connected)'); + /** Paths of the discovered repos the dialog offers, in order. */ const offered = (dialog: Locator): Promise => dialog.locator(byTestId(TID.addRepoOption)).evaluateAll((options) => options.map((o) => o.getAttribute('title') ?? '')); @@ -86,6 +90,7 @@ test.describe('add repo', () => { writeHubConfig(hub, { repos: [repo], roots: [join(hub.dir, 'nothing-here')] }); await openUi(hub, page); const dialog = await openAddRepo(page); + await hostConnected(dialog, LOCAL); const mark = wire.markSent(); await dialog.locator(byTestId(TID.addRepoPath)).fill(plain); await dialog.locator(byTestId(TID.addRepoPath)).press('Enter'); @@ -101,6 +106,7 @@ test.describe('add repo', () => { writeHubConfig(hub, { repos: [repo], roots: [join(hub.dir, 'nothing-here')] }); await openUi(hub, page); const dialog = await openAddRepo(page); + await hostConnected(dialog, LOCAL); await dialog.locator(byTestId(TID.addRepoPath)).fill(other.repo); await dialog.locator(byTestId(TID.addRepoPath)).press('Enter'); await expect(dialog).toHaveCount(0); @@ -118,7 +124,7 @@ test.describe('add repo', () => { const mark = wire.markSent(); await page.keyboard.press('Escape'); await expect(dialog).toHaveCount(0); - await page.waitForTimeout(300); + await page.waitForTimeout(300); // NOSONAR(S2925) — absence check: nothing to synchronise on expect(hubRequests(wire, mark)).toEqual([]); await expect(page.locator(repoTab(b))).toHaveCount(0); }); @@ -161,6 +167,28 @@ test.describe('add repo', () => { await expect(page.locator(repoTab(other))).toHaveAttribute('aria-selected', 'true'); }); + test('a dialog opened while its host connects discovers once the host is connected', async ({ hub, page, ssh }) => { + test.setTimeout(120_000); + const remote = nodeRemote(ssh, 'box'); + const app = makeRepo(join(remote.home, 'app')); + const other = makeRepo(join(remote.home, 'other')); + await installRemote(hub, ssh, 'box'); + const release = ssh.holdHost('box'); + writeHubConfig(hub, { + roots: [join(hub.dir, 'nothing-here')], + hosts: [{ name: 'box', ssh: 'box', repos: [app], roots: [remote.home] }], + }); + await openUi(hub, page); + await selectRepo(page, app); + const dialog = await openAddRepo(page); + const select = dialog.locator(byTestId(TID.addRepoHost)); + await expect(select).toHaveValue(String(REMOTE)); + await expect(select.locator(`option[value="${String(REMOTE)}"]`)).toContainText('connecting'); + release(); + await expect.poll(() => offered(dialog), { timeout: 15_000 }).toEqual([other]); + await expect(dialog.locator(byTestId(TID.addRepoError))).toHaveCount(0); + }); + test('a repo added by another session is watched and shown with its worktrees', async ({ hub, page }) => { const first = makeRepoWith(hub, 'one', []); const second = makeRepoWith(hub, 'two', ['side']); diff --git a/test/e2e/fixture.ts b/test/e2e/fixture.ts index 7e02100..3ff09bf 100644 --- a/test/e2e/fixture.ts +++ b/test/e2e/fixture.ts @@ -83,8 +83,10 @@ export const writeHubConfig = ( }; /** A reachable fake remote whose PATH offers this runner's Node, so the hub can install there. */ -export const nodeRemote = (ssh: FakeSsh, alias: string): FakeRemote => - ssh.addHost(alias, { path: `${currentNodeDir(join(ssh.dir, `node-${alias}`))}:${ssh.pathWithoutNode}` }); +export const nodeRemote = (ssh: FakeSsh, alias: string): FakeRemote => { + const nodeDir = currentNodeDir(join(ssh.dir, `node-${alias}`)); + return ssh.addHost(alias, { path: `${nodeDir}:${ssh.pathWithoutNode}` }); +}; /** Clicks the action of `host`'s banner and waits for the confirmation dialog. */ export const openHostAction = async (page: Page, host: number, label: string): Promise => { diff --git a/test/e2e/hosts.spec.ts b/test/e2e/hosts.spec.ts index 02bcce1..0e31428 100644 --- a/test/e2e/hosts.spec.ts +++ b/test/e2e/hosts.spec.ts @@ -104,7 +104,7 @@ test.describe('host actions', () => { await openHostAction(page, LOCAL, 'Restart daemon'); await page.locator(byTestId(TID.confirmDialog)).locator(byTestId(TID.confirmCancel)).click(); await expect(page.locator(byTestId(TID.confirmDialog))).toHaveCount(0); - await page.waitForTimeout(500); + await page.waitForTimeout(500); // NOSONAR(S2925) — absence check: nothing to synchronise on expect(hubRequests(wire, mark)).toEqual([]); expect(fake.shutdowns).toBe(0); await expect(page.locator(hostBanner(LOCAL))).toContainText('outdated'); diff --git a/test/process/hub-router.test.ts b/test/process/hub-router.test.ts index d77c828..2a34967 100644 --- a/test/process/hub-router.test.ts +++ b/test/process/hub-router.test.ts @@ -648,6 +648,14 @@ describe('remote hosts', () => { expect(residentBytes(hub.pid) - before).toBeLessThan(16 * MiB); }); + it('ends an SSH process that writes over 1 MiB to standard error instead of leaving its link stalled', async () => { + await installedBox(); + ssh.noisyHost('box', 2 * MiB); + const client = await host.session(); + const entry = await client.waitHost(1, (h) => h.status === 'reconnecting' || h.status === 'down', { timeout: REMOTE_TIMEOUT }); + expect(entry.reason).toMatch(/^x{1,1024}$/); + }); + it('answers requests to a down remote host with host-unavailable', async () => { ssh.failHost('box', 'ssh: Could not resolve hostname box'); configure([{ name: 'box', ssh: 'box' }]); @@ -657,6 +665,19 @@ describe('remote hosts', () => { expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 1, code: 'host-unavailable' } }); }); + it('kills SSH processes of a closed session that ignore SIGTERM', async () => { + ssh.addHost('box'); + ssh.hangHost('box'); + configure([{ name: 'box', ssh: 'box' }]); + const client = await host.session(); + const pids = await waitUntil(() => { + const live = ssh.livePids('box'); + return live.length > 0 && live; + }, 'an SSH process for box'); + client.close(); + await waitUntil(() => pids.every((pid) => !alive(pid)), 'the session’s SSH processes to be killed', 5000); + }); + it('ends the SSH processes of a closed session', async () => { await installedBox(); const client = await host.session(); diff --git a/test/process/install-helpers.ts b/test/process/install-helpers.ts index 3aad060..bf90562 100644 --- a/test/process/install-helpers.ts +++ b/test/process/install-helpers.ts @@ -20,7 +20,7 @@ export const packageVersion = (): string => { export const expectOneLine = (text: string): void => { expect(text.trimEnd()).not.toBe(''); expect(text.trimEnd()).not.toContain('\n'); - expect(text).not.toMatch(/\n\s+at /); + expect(text).not.toMatch(/\n[ \t]+at /); }; /** Where an installation in `home` puts its files. */ @@ -119,8 +119,8 @@ export const fakeSystemctl = (dir: string, log: string, failOn: string | null = failOn === null ? '' : `for a in "$@"; do [ "$a" = ${shellQuote(failOn)} ] && { echo "Failed to ${failOn}" >&2; exit 1; }; done\n`; writeFileSync( path, - `#!/bin/sh -${shellQuote(process.execPath)} -e 'require("node:fs").appendFileSync(process.argv[1], JSON.stringify(process.argv.slice(2)) + "\\n")' ${shellQuote(log)} "$@" + String.raw`#!/bin/sh +${shellQuote(process.execPath)} -e 'require("node:fs").appendFileSync(process.argv[1], JSON.stringify(process.argv.slice(2)) + "\n")' ${shellQuote(log)} "$@" ${fail}for a in "$@"; do if [ "$a" = start ]; then setsid "$HOME/.local/bin/wtd" daemon /dev/null 2>&1 & exit 0; fi done @@ -177,27 +177,69 @@ export const iniGroup = (text: string, group: string): Map => { export const desktopString = (raw: string): string => raw.replace(/\\(.)/g, (_match, c: string) => ({ s: ' ', n: '\n', t: '\t', r: '\r', '\\': '\\' })[c] ?? `\\${c}`); -/** The argv of a desktop entry `Exec` value, per the Desktop Entry specification. */ -export const desktopExec = (raw: string): string[] => { - const value = desktopString(raw); +/** A read position in a text. */ +class Scanner { + pos = 0; + + constructor(readonly text: string) {} + + get done(): boolean { + return this.pos >= this.text.length; + } + + peek(): string { + return this.text[this.pos] ?? ''; + } + + next(): string { + const c = this.peek(); + this.pos++; + return c; + } + + take(count: number): string { + const taken = this.text.slice(this.pos, this.pos + count); + this.pos += count; + return taken; + } +} + +/** Words split on unquoted `separators`; `word` reads on from the scanner, given the word so far. */ +const splitWords = (text: string, separators: string, word: (s: Scanner, arg: string | null) => string): string[] => { + const s = new Scanner(text); const args: string[] = []; let arg: string | null = null; - for (let i = 0; i < value.length; i++) { - const c = value[i] ?? ''; - if (c === ' ') { + while (!s.done) { + if (separators.includes(s.peek())) { + s.next(); if (arg !== null) args.push(arg); arg = null; - } else if (c === '"') { - arg ??= ''; - for (i++; i < value.length && value[i] !== '"'; i++) { - const q = value[i] ?? ''; - if (q === '\\' && '"`$\\'.includes(value[i + 1] ?? '')) arg += value[++i] ?? ''; - else arg += q; - } - if (i >= value.length) throw new Error(`unterminated quote in Exec ${raw}`); - } else arg = (arg ?? '') + c; + } else arg = word(s, arg); } if (arg !== null) args.push(arg); + return args; +}; + +/** The rest of a quoted part up to `quote`, which is consumed; `escaped` reads after a backslash. */ +const quoted = (s: Scanner, quote: string, escaped: (s: Scanner) => string, what: string): string => { + let arg = ''; + while (!s.done && s.peek() !== quote) { + const c = s.next(); + arg += c === '\\' ? escaped(s) : c; + } + if (s.done) throw new Error(`unterminated quote in ${what}`); + s.next(); + return arg; +}; + +/** The argv of a desktop entry `Exec` value, per the Desktop Entry specification. */ +export const desktopExec = (raw: string): string[] => { + const what = `Exec ${raw}`; + const escaped = (s: Scanner): string => ('"`$\\'.includes(s.peek()) ? s.next() : '\\'); + const args = splitWords(desktopString(raw), ' ', (s, arg) => { + const c = s.next(); + return (arg ?? '') + (c === '"' ? quoted(s, '"', escaped, what) : c); + }); return args.map((a) => { if (/%[^%]/.test(a.replaceAll('%%', ''))) throw new Error(`field code in Exec argument ${a}`); return a.replaceAll('%%', '%'); @@ -220,37 +262,19 @@ const C_ESCAPES: Record = { /** The argv of a systemd `ExecStart` value: quoting, C escapes, `%%` and `$$`. */ export const systemdExec = (raw: string): string[] => { - const args: string[] = []; - let arg: string | null = null; - const escape = (i: number): [string, number] => { - const c = raw[i + 1] ?? ''; - if (c === 'x') return [String.fromCharCode(parseInt(raw.slice(i + 2, i + 4), 16)), i + 3]; + const what = `ExecStart ${raw}`; + const escaped = (s: Scanner): string => { + const c = s.next(); + if (c === 'x') return String.fromCodePoint(Number.parseInt(s.take(2), 16)); const mapped = C_ESCAPES[c]; - if (mapped === undefined) throw new Error(`unknown escape \\${c} in ExecStart ${raw}`); - return [mapped, i + 1]; + if (mapped === undefined) throw new Error(`unknown escape \\${c} in ${what}`); + return mapped; }; - for (let i = 0; i < raw.length; i++) { - const c = raw[i] ?? ''; - if (c === ' ' || c === '\t') { - if (arg !== null) args.push(arg); - arg = null; - } else if ((c === '"' || c === "'") && arg === null) { - arg = ''; - for (i++; i < raw.length && raw[i] !== c; i++) { - if (raw[i] === '\\') { - const [text, at] = escape(i); - arg += text; - i = at; - } else arg += raw[i] ?? ''; - } - if (i >= raw.length) throw new Error(`unterminated quote in ExecStart ${raw}`); - } else if (c === '\\') { - const [text, at] = escape(i); - arg = (arg ?? '') + text; - i = at; - } else arg = (arg ?? '') + c; - } - if (arg !== null) args.push(arg); + const args = splitWords(raw, ' \t', (s, arg) => { + const c = s.next(); + if ((c === '"' || c === "'") && arg === null) return quoted(s, c, escaped, what); + return (arg ?? '') + (c === '\\' ? escaped(s) : c); + }); return args.map((a) => { if (/%[^%]/.test(a.replaceAll('%%', ''))) throw new Error(`specifier in ExecStart argument ${a}`); if (/\$[^$]/.test(a.replaceAll('$$', ''))) throw new Error(`variable in ExecStart argument ${a}`); diff --git a/test/process/install-local.test.ts b/test/process/install-local.test.ts index 04efa6f..1b301d2 100644 --- a/test/process/install-local.test.ts +++ b/test/process/install-local.test.ts @@ -252,6 +252,27 @@ describe('--systemd', () => { }); }); +describe('--systemd with a systemctl flooding standard error', () => { + it('ends a systemctl ignoring SIGTERM after 1 MiB of standard error and fails naming it', async () => { + const systemctl = join(host.dir, 'noisy-systemctl'); + writeFileSync( + systemctl, + String.raw`#!/bin/sh +trap '' TERM +head -c 2097152 /dev/zero | tr '\000' x >&2 +exec sleep 60 +`, + ); + chmodSync(systemctl, 0o755); + const started = Date.now(); + const run = await installLocal(['--systemd'], { WTD_SYSTEMCTL: systemctl }); + expect(Date.now() - started).toBeLessThan(20_000); + expect(run.exit).toEqual({ code: 1, signal: null }); + expectOneLine(run.stderr); + expect(run.stderr).toContain('--user daemon-reload failed'); + }); +}); + describe('reinstalling', () => { it('installs the same version into a new release, keeping the first', async () => { const paths = installPaths(host.home); diff --git a/test/process/install-remote.test.ts b/test/process/install-remote.test.ts index 264d6a0..91184fd 100644 --- a/test/process/install-remote.test.ts +++ b/test/process/install-remote.test.ts @@ -1,10 +1,23 @@ import { spawn } from 'node:child_process'; -import { existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + readlinkSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { hostPaths } from '../../src/platform/files/index.js'; +import { installRemote, type InstalledRemote } from '../../src/platform/install/index.js'; import { PROTOCOL_VERSION } from '../../src/protocol/index.js'; import { DaemonClient } from '../support/daemon-client.js'; -import { DaemonHost, waitUntil } from '../support/daemon-host.js'; +import { alive, BUNDLE, DaemonHost, waitUntil } from '../support/daemon-host.js'; import { REPO_ROOT } from '../support/exec.js'; import { currentNodeDir, FakeSsh, oldNodeDir, runInstallRemote, shellQuote, type FakeRemote } from '../support/fake-ssh.js'; import { expectOneLine, installPaths, PTY_PACKAGE, packageVersion, treeSnapshot } from './install-helpers.js'; @@ -291,3 +304,47 @@ describe('wtd install-remote', () => { expect(remote.releases()).toContain(remote.current()?.split('/').pop()); }); }); + +describe('installRemote with a stand-in SSH', () => { + /** Installs through `WTD_SSH` = a script running `body` after reading the archive. */ + const installWith = async (body: string, timeoutMs = 30_000): Promise => { + const program = join(host.dir, 'stand-in-ssh'); + writeFileSync(program, `#!/bin/sh\ncat >/dev/null\n${body}\n`); + chmodSync(program, 0o755); + vi.stubEnv('WTD_SSH', program); + try { + const paths = hostPaths({ env: { XDG_STATE_HOME: join(host.dir, 'state') }, home: host.dir, host: 'test' }); + return await installRemote({ paths, alias: 'box', bundle: BUNDLE, version: '1.2.3', node: null, timeoutMs }); + } finally { + vi.unstubAllEnvs(); + } + }; + + it('finds the result line after 1 MB of other output', async () => { + const installed = await installWith( + String.raw`head -c 1000000 /dev/zero | tr '\000' x; echo; echo '@@wtd-install@@ ok 1.2.3-abc /opt/node/bin/node'`, + ); + expect(installed).toEqual({ version: '1.2.3', release: '1.2.3-abc', node: '/opt/node/bin/node' }); + }); + + it('takes the last result line', async () => { + const install = installWith("echo '@@wtd-install@@ ok 9.9.9-early /tmp/node'; echo '@@wtd-install@@ error no Node 20 or later'"); + await expect(install).rejects.toThrow('box: no Node 20 or later'); + }); + + it('ends an SSH ignoring SIGTERM once it has written 1 MiB to standard error', async () => { + const started = Date.now(); + const install = installWith(String.raw`trap '' TERM; head -c 2097152 /dev/zero | tr '\000' x >&2; exec sleep 60`); + await expect(install).rejects.toThrow(/^x{1,1024}$/); + expect(Date.now() - started).toBeLessThan(10_000); + }); + + it('kills an SSH that ignores SIGTERM after the timeout', async () => { + const pidFile = join(host.dir, 'ssh.pid'); + const body = `trap '' TERM; echo $$ > ${shellQuote(pidFile)}; exec sleep 60`; + const install = installWith(body, 500); + await expect(install).rejects.toThrow('box: the installation did not finish within 0.5 s'); + const pid = Number(readFileSync(pidFile, 'utf8')); + await waitUntil(() => !alive(pid), 'the stand-in SSH to be killed', 5000); + }); +}); diff --git a/test/support/daemon-host.ts b/test/support/daemon-host.ts index abbfb4f..26a30ac 100644 --- a/test/support/daemon-host.ts +++ b/test/support/daemon-host.ts @@ -33,6 +33,32 @@ export const alive = (pid: number): boolean => { } }; +/** Live pids whose argv and environment satisfy `matches`. */ +const livePidsWhere = (matches: (cmdline: string[], environ: string[]) => boolean): number[] => { + const pids: number[] = []; + for (const name of readdirSync('/proc')) { + if (!/^\d+$/.test(name)) continue; + try { + const cmdline = readFileSync(`/proc/${name}/cmdline`, 'utf8').split('\0'); + const environ = readFileSync(`/proc/${name}/environ`, 'utf8').split('\0'); + if (matches(cmdline, environ) && alive(Number(name))) pids.push(Number(name)); + } catch { + // The process exited while we looked, or is another user's. + } + } + return pids; +}; + +const killAll = (pids: readonly number[]): void => { + for (const pid of pids) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Already gone. + } + } +}; + /** Session id of `pid`. */ export const sessionOf = (pid: number): number => { const stat = readFileSync(`/proc/${String(pid)}/stat`, 'utf8'); @@ -215,33 +241,24 @@ export class DaemonHost { /** Pids of daemons started for this host, whether by the test or by `wtd connect`. */ daemonPids(): number[] { - const pids: number[] = []; - for (const name of readdirSync('/proc')) { - if (!/^\d+$/.test(name)) continue; - try { - const cmdline = readFileSync(`/proc/${name}/cmdline`, 'utf8').split('\0'); - if (cmdline[1] !== BUNDLE || cmdline[2] !== 'daemon') continue; - const environ = readFileSync(`/proc/${name}/environ`, 'utf8').split('\0'); - if (environ.includes(`XDG_STATE_HOME=${this.stateHome}`) && alive(Number(name))) pids.push(Number(name)); - } catch { - // The process exited while we looked. - } - } - return pids; + return livePidsWhere( + (cmdline, environ) => cmdline[1] === BUNDLE && cmdline[2] === 'daemon' && environ.includes(`XDG_STATE_HOME=${this.stateHome}`), + ); + } + + /** Pids of every live process with this host's HOME, such as the shells of its terminals. */ + homePids(): number[] { + return livePidsWhere((_cmdline, environ) => environ.includes(`HOME=${this.home}`)); } async cleanup(): Promise { for (const client of this.clients) client.close(); for (const process_ of this.processes) process_.kill('SIGKILL'); - for (const pid of this.daemonPids()) { - try { - process.kill(pid, 'SIGKILL'); - } catch { - // Already gone. - } - } + killAll(this.daemonPids()); await waitUntil(() => this.daemonPids().length === 0, 'daemons to exit').catch(() => undefined); - // Shells hung up with the daemon may still be writing their history into HOME. + // Shells hung up with the daemon would otherwise go on writing their history into HOME. + killAll(this.homePids()); + await waitUntil(() => this.homePids().length === 0, 'processes using HOME to exit').catch(() => undefined); rmSync(this.dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } diff --git a/test/support/fake-ssh.ts b/test/support/fake-ssh.ts index 68d53c5..437c56c 100644 --- a/test/support/fake-ssh.ts +++ b/test/support/fake-ssh.ts @@ -1,4 +1,5 @@ import { + appendFileSync, chmodSync, existsSync, mkdirSync, @@ -14,8 +15,10 @@ import { delimiter, join } from 'node:path'; import { DaemonClient, type EventOf } from './daemon-client.js'; import { alive, hostFileName, waitUntil, type DaemonHost, type Exit } from './daemon-host.js'; +const QUOTED_QUOTE = String.raw`'\''`; + /** POSIX single-quoted form of `value`. */ -export const shellQuote = (value: string): string => `'${value.replaceAll("'", `'\\''`)}'`; +export const shellQuote = (value: string): string => `'${value.replaceAll("'", QUOTED_QUOTE)}'`; const NODE_NAMES = new Set(['node', 'nodejs', 'npm', 'npx', 'corepack', 'pnpm', 'yarn']); @@ -69,10 +72,10 @@ esac return dir; }; -const SSH_SCRIPT = (dir: string): string => `#!/bin/sh +const SSH_SCRIPT = (dir: string): string => String.raw`#!/bin/sh d=${shellQuote(dir)} f="$d/calls/$$" -for a in "$@"; do printf '%s\\0' "$a"; done > "$f.tmp" && mv "$f.tmp" "$f" +for a in "$@"; do printf '%s\0' "$a"; done > "$f.tmp" && mv "$f.tmp" "$f" while [ "$#" -gt 0 ] && [ "$1" != "--" ]; do shift; done if [ "$#" -ne 3 ]; then echo "fake ssh: expected -- " >&2; exit 255; fi alias=$2 @@ -80,8 +83,11 @@ cmd=$3 h="$d/hosts/$alias" if [ ! -f "$h" ]; then echo "ssh: Could not resolve hostname $alias: Name or service not known" >&2; exit 255; fi . "$h" -if [ -n "$R_FAIL_LINE" ]; then printf '%s\\n' "$R_FAIL_LINE" >&2; exit 255; fi -if [ -n "$R_FAIL_BYTES" ]; then head -c "$R_FAIL_BYTES" /dev/zero | tr '\\000' x >&2; exit 255; fi +if [ -n "$R_HANG" ]; then trap '' TERM; exec sleep 600; fi +if [ -n "$R_GATE" ]; then while [ ! -e "$R_GATE" ]; do sleep 0.05; done; fi +if [ -n "$R_FAIL_LINE" ]; then printf '%s\n' "$R_FAIL_LINE" >&2; exit 255; fi +if [ -n "$R_FAIL_BYTES" ]; then head -c "$R_FAIL_BYTES" /dev/zero | tr '\000' x >&2; exit 255; fi +if [ -n "$R_NOISE_BYTES" ]; then head -c "$R_NOISE_BYTES" /dev/zero | tr '\000' x >&2; fi cd "$R_HOME" || exit 255 exec env -i HOME="$R_HOME" PATH="$R_PATH" SHELL="$R_SHELL" LANG=C.UTF-8 USER="$USER" /bin/sh -c "$cmd" `; @@ -236,6 +242,25 @@ export class FakeSsh { this.writeHost(alias, { R_FAIL_BYTES: String(bytes) }); } + /** Makes every later SSH run for `alias` hang, ignoring SIGTERM and its standard input. */ + hangHost(alias: string): void { + appendFileSync(join(this.hostsDir, alias), 'R_HANG=1\n'); + } + + /** Makes every later SSH run for `alias` write `bytes` bytes to standard error before running its command. */ + noisyHost(alias: string, bytes: number): void { + appendFileSync(join(this.hostsDir, alias), `R_NOISE_BYTES=${String(bytes)}\n`); + } + + /** Holds every later SSH run for `alias` until the returned function is called. */ + holdHost(alias: string): () => void { + const gate = join(this.dir, `gate-${alias}`); + appendFileSync(join(this.hostsDir, alias), `R_GATE=${shellQuote(gate)}\n`); + return () => { + writeFileSync(gate, ''); + }; + } + /** Every run so far, in no particular order. */ calls(): SshCall[] { return readdirSync(this.callsDir) From cab68a08956ea7faeb678dcab1a44165b7a78eb2 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Wed, 7 Oct 2026 14:48:08 +0200 Subject: [PATCH 5/6] DEV-2054 PR 5: the SIGTERM-ignoring SSH test asserts its processes are gone --- test/process/hub-router.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/process/hub-router.test.ts b/test/process/hub-router.test.ts index 2a34967..b5ee2b0 100644 --- a/test/process/hub-router.test.ts +++ b/test/process/hub-router.test.ts @@ -676,6 +676,7 @@ describe('remote hosts', () => { }, 'an SSH process for box'); client.close(); await waitUntil(() => pids.every((pid) => !alive(pid)), 'the session’s SSH processes to be killed', 5000); + expect(pids.filter(alive)).toEqual([]); }); it('ends the SSH processes of a closed session', async () => { From c65737bf068ac1b00a6d7a692f06c657838e2a0c Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Wed, 7 Oct 2026 14:59:56 +0200 Subject: [PATCH 6/6] DEV-2054 PR 5: archive the OpenSpec change into the specs --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/cli/spec.md | 0 .../specs/daemon/spec.md | 0 .../specs/hub/spec.md | 0 .../specs/platform/spec.md | 0 .../specs/protocol/spec.md | 0 .../specs/web/spec.md | 0 .../tasks.md | 0 openspec/specs/cli/spec.md | 31 +++- openspec/specs/daemon/spec.md | 8 +- openspec/specs/hub/spec.md | 143 +++++++++++++++++- openspec/specs/platform/spec.md | 96 +++++++++++- openspec/specs/protocol/spec.md | 32 +++- openspec/specs/web/spec.md | 71 ++++++++- 16 files changed, 358 insertions(+), 23 deletions(-) rename openspec/changes/{dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install => archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install}/.openspec.yaml (100%) rename openspec/changes/{dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install => archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install}/design.md (100%) rename openspec/changes/{dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install => archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install}/proposal.md (100%) rename openspec/changes/{dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install => archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install}/specs/cli/spec.md (100%) rename openspec/changes/{dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install => archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install}/specs/daemon/spec.md (100%) rename openspec/changes/{dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install => archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install}/specs/hub/spec.md (100%) rename openspec/changes/{dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install => archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install}/specs/platform/spec.md (100%) rename openspec/changes/{dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install => archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install}/specs/protocol/spec.md (100%) rename openspec/changes/{dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install => archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install}/specs/web/spec.md (100%) rename openspec/changes/{dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install => archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install}/tasks.md (100%) diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/.openspec.yaml b/openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/.openspec.yaml similarity index 100% rename from openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/.openspec.yaml rename to openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/.openspec.yaml diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/design.md b/openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/design.md similarity index 100% rename from openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/design.md rename to openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/design.md diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/proposal.md b/openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/proposal.md similarity index 100% rename from openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/proposal.md rename to openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/proposal.md diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/cli/spec.md b/openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/cli/spec.md similarity index 100% rename from openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/cli/spec.md rename to openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/cli/spec.md diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/daemon/spec.md b/openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/daemon/spec.md similarity index 100% rename from openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/daemon/spec.md rename to openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/daemon/spec.md diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/hub/spec.md b/openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/hub/spec.md similarity index 100% rename from openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/hub/spec.md rename to openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/hub/spec.md diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/platform/spec.md b/openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/platform/spec.md similarity index 100% rename from openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/platform/spec.md rename to openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/platform/spec.md diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/protocol/spec.md b/openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/protocol/spec.md similarity index 100% rename from openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/protocol/spec.md rename to openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/protocol/spec.md diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/web/spec.md b/openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/web/spec.md similarity index 100% rename from openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/web/spec.md rename to openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/web/spec.md diff --git a/openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/tasks.md b/openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/tasks.md similarity index 100% rename from openspec/changes/dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/tasks.md rename to openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/tasks.md diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index 055f51a..7a981f5 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -10,17 +10,17 @@ The `wtd` command line surface: how a user discovers its commands and version, a #### Scenario: Version - **WHEN** the user runs `wtd --version` -- **THEN** stdout is `wtd (protocol 4)` followed by a newline, stderr is empty, and the exit code is 0 +- **THEN** stdout is `wtd (protocol 5)` followed by a newline, stderr is empty, and the exit code is 0 ### Requirement: Help output -`wtd --help` SHALL print usage to stdout and exit 0. Usage SHALL list the commands `ui`, `hub`, `daemon`, `connect`, `install-local` and `install-remote `, and the options `--help` and `--version`. +`wtd --help` SHALL print usage to stdout and exit 0. Usage SHALL list the commands `ui`, `hub`, `daemon`, `connect`, `install-local [--systemd]` and `install-remote [--node ]`, and the options `--help` and `--version`. #### Scenario: Help - **WHEN** the user runs `wtd --help` - **THEN** stdout lists every command and option above, stderr is empty, and the exit code is 0 ### Requirement: Usage errors -Running `wtd` with no arguments, an unknown command, an unknown option, or `install-remote` without an alias SHALL print a one-line error and the usage to stderr, print nothing to stdout, and exit 2. +Running `wtd` with no arguments, an unknown command, an option the command does not take, `--node` without a value, or `install-remote` without an alias SHALL print a one-line error and the usage to stderr, print nothing to stdout, and exit 2. #### Scenario: No arguments - **WHEN** the user runs `wtd` @@ -34,10 +34,18 @@ Running `wtd` with no arguments, an unknown command, an unknown option, or `inst - **WHEN** the user runs `wtd --frob` - **THEN** stderr names `--frob` as unknown and contains the usage, and the exit code is 2 +#### Scenario: Option of another command +- **WHEN** the user runs `wtd install-remote box --systemd` +- **THEN** stderr names `--systemd` as unknown for `install-remote` and contains the usage, and the exit code is 2 + #### Scenario: Missing alias - **WHEN** the user runs `wtd install-remote` - **THEN** stderr says an alias is required and contains the usage, and the exit code is 2 +#### Scenario: Missing option value +- **WHEN** the user runs `wtd install-remote box --node` +- **THEN** stderr says `--node` needs a value and contains the usage, and the exit code is 2 + ### Requirement: Daemon command `wtd daemon` SHALL run this host's daemon in the foreground until it receives `shutdown`, `SIGTERM` or `SIGINT`, then exit 0. It SHALL ignore `SIGHUP`. If a daemon is already serving this host's socket, it SHALL print `wtd daemon: already running` to stderr and exit 1. Any other start failure SHALL print one line naming the cause to stderr, without a stack trace, and exit 1. @@ -54,7 +62,7 @@ Running `wtd` with no arguments, an unknown command, an unknown option, or `inst #### Scenario: Bridge over a pipe with auto-start - **WHEN** no daemon is running and a process runs `wtd connect` with piped stdio -- **THEN** a daemon is started and the first frame on stdout is the daemon's `hello` with `protocol` 4 +- **THEN** a daemon is started and the first frame on stdout is the daemon's `hello` with `protocol` 5 #### Scenario: Requests relayed - **WHEN** the client writes `hello` and a `watchRepo` frame to `wtd connect`'s stdin @@ -101,3 +109,18 @@ Running `wtd` with no arguments, an unknown command, an unknown option, or `inst #### Scenario: Browser missing - **WHEN** the browser command does not exist - **THEN** stderr names the browser command and the exit code is 1 + +### Requirement: Install commands +`wtd install-local [--systemd]` SHALL perform a local installation, with the systemd option when `--systemd` is given. `wtd install-remote [--node ]` SHALL perform a remote installation to ``, with the given Node path when `--node` is given. On success each SHALL print one line to stdout naming the installed version and where it was installed and exit 0; on failure, one line naming the cause to stderr, without a stack trace, and exit 1. + +#### Scenario: Local install into a home +- **WHEN** the user runs `wtd install-local` with `HOME` set to an empty directory +- **THEN** stdout names the version, the exit code is 0, and `$HOME/.local/bin/wtd --version` prints the same version + +#### Scenario: Remote install through SSH +- **WHEN** the user runs `wtd install-remote box` and the SSH command reaches a Linux host with Node 20 or later +- **THEN** stdout names the version and `box`, and the exit code is 0 + +#### Scenario: Remote install failure +- **WHEN** the user runs `wtd install-remote box` and `box` cannot be reached +- **THEN** stderr is one line naming the cause and the exit code is 1 diff --git a/openspec/specs/daemon/spec.md b/openspec/specs/daemon/spec.md index 2d0fef6..06dcf76 100644 --- a/openspec/specs/daemon/spec.md +++ b/openspec/specs/daemon/spec.md @@ -25,7 +25,7 @@ On every new connection the daemon SHALL send `hello` with `protocol` equal to ` #### Scenario: Daemon hello - **WHEN** a client connects -- **THEN** the first frame it receives is the daemon's `hello` with `protocol` 4 +- **THEN** the first frame it receives is the daemon's `hello` with `protocol` 5 #### Scenario: Message before hello - **WHEN** a client's first message is a `watchRepo` @@ -115,12 +115,16 @@ While a repo is watched, the daemon SHALL send every watcher `worktreesChanged` - **THEN** no `worktreesChanged` is sent ### Requirement: Repo discovery -`discoverRepos{roots, depth}` SHALL return, sorted and without duplicates, the repositories found at most `depth` directory levels below each root, the root itself counting as level 0. A repository SHALL be a directory containing a `.git` directory, or a bare repository. Directories containing a `.git` file SHALL NOT be reported. The search SHALL NOT descend into a found repository, a symbolic link, a hidden directory or `node_modules`, and SHALL skip missing roots and unreadable directories. Directories SHALL be visited in sorted order and the result SHALL hold at most the first 4096 repositories in that order. Each root SHALL be resolved to its real path before the search, so every reported repository is a real path. +`discoverRepos{roots, depth}` SHALL return, sorted and without duplicates, the repositories found at most `depth` directory levels below each root, the root itself counting as level 0. A root `~` SHALL mean the daemon's home directory and a root `~/` the path `` below it. A repository SHALL be a directory containing a `.git` directory, or a bare repository. Directories containing a `.git` file SHALL NOT be reported. The search SHALL NOT descend into a found repository, a symbolic link, a hidden directory or `node_modules`, and SHALL skip missing roots and unreadable directories. Directories SHALL be visited in sorted order and the result SHALL hold at most the first 4096 repositories in that order. Each root SHALL be resolved to its real path before the search, so every reported repository is a real path. #### Scenario: Depth respected - **WHEN** repos exist at levels 1 and 3 below a root and `depth` is 2 - **THEN** only the level-1 repo is reported +#### Scenario: Home-relative roots expanded by the daemon +- **WHEN** the daemon's home holds `GitHub/app` as a repo and a client discovers with roots `~` and `~/GitHub` and `depth` 3 +- **THEN** the daemon reports the repo once, by its real path below the daemon's home + #### Scenario: Linked worktrees and submodules excluded - **WHEN** a directory below a root contains a `.git` file - **THEN** it is not reported diff --git a/openspec/specs/hub/spec.md b/openspec/specs/hub/spec.md index 2e1a8db..2d6c03e 100644 --- a/openspec/specs/hub/spec.md +++ b/openspec/specs/hub/spec.md @@ -137,11 +137,11 @@ After the handshake, a message that fails to decode, a second `hello`, or an out - **THEN** that session is closed and the other keeps working ### Requirement: Configuration -The configuration SHALL be `config.json` holding a JSON object with optional `port` (an integer from 1024 to 65535, default 7417), optional `repos` (at most 256 paths, each absolute or starting with `~/`, which is expanded against the home directory without resolving symbolic links) and optional `presets` (1 to 64 objects `{name, command}`, each name 1–64 characters with at least one non-whitespace character and unique by exact comparison, each `command` null or 1–4096 characters); any other key, in the object or in a preset, SHALL make it invalid. Without `presets` the presets SHALL be exactly `[{name: "shell", command: null}]`; with it, exactly the listed presets in their order. A missing file SHALL mean the defaults; the hub SHALL never create it. An invalid configuration at start SHALL fail the start with one line naming the file and the first problem. Each new browser session SHALL use a snapshot of the configuration read when it starts; if that read is invalid, the session SHALL receive `error{req: null, host: null, code: internal}` naming the problem and use the last valid configuration. A changed port SHALL take effect only when the hub restarts. +The configuration SHALL be `config.json` holding a JSON object with optional `port` (an integer from 1024 to 65535, default 7417), optional `repos` (at most 256 paths, each absolute or starting with `~/`, which is expanded against the home directory without resolving symbolic links), optional `roots` (1 to 32 discovery roots, each an absolute path, `~`, or `~/` followed by at least one character; default `["~"]`; never expanded by the hub), optional `presets` (1 to 64 objects `{name, command}`, each name 1–64 characters with at least one non-whitespace character and unique by exact comparison, each `command` null or 1–4096 characters) and optional `hosts` (at most 63 objects `{name, ssh, repos, roots}`, `repos` and `roots` optional). A host's `name` SHALL be 1–64 characters from `[A-Za-z0-9._-]`, unique among hosts; its `ssh` alias SHALL be 1–255 characters without whitespace or control characters and SHALL NOT start with `-`; its `repos` SHALL be at most 256 absolute paths; its `roots` SHALL follow the rules and default of the top-level `roots`. The top-level `repos` and `roots` SHALL belong to the local host. Any other key, in the object, in a preset or in a host, SHALL make it invalid. Without `presets` the presets SHALL be exactly `[{name: "shell", command: null}]`; with it, exactly the listed presets in their order. A missing file SHALL mean the defaults; the hub SHALL create it only to record a repo added from the page. An invalid configuration at start SHALL fail the start with one line naming the file and the first problem. Each new browser session SHALL use a snapshot of the configuration read when it starts; if that read is invalid, the session SHALL receive `error{req: null, host: null, code: internal}` naming the problem and use the last valid configuration. A changed port SHALL take effect only when the hub restarts. #### Scenario: Defaults without a file - **WHEN** no `config.json` exists and the hub starts -- **THEN** it listens on port 7417, lists no repos, and sessions receive the single preset `shell` with command null +- **THEN** it listens on port 7417, lists no repos and no remote hosts, and sessions receive the single preset `shell` with command null #### Scenario: Unknown key refused - **WHEN** `config.json` holds the key `theme` and the hub starts @@ -155,6 +155,22 @@ The configuration SHALL be `config.json` holding a JSON object with optional `po - **WHEN** `config.json` holds an empty `presets` list, two presets named `a`, or a preset whose command is "" - **THEN** the start fails with a line naming the file and `presets` +#### Scenario: Remote hosts listed in order +- **WHEN** `config.json` lists hosts `b` and `a`, in that order +- **THEN** sessions receive `hosts` with the local host at index 0, `b` at index 1 and `a` at index 2, both remote + +#### Scenario: Option-like alias refused +- **WHEN** a host's `ssh` is `-oProxyCommand=x` or contains a space +- **THEN** the start fails with a line naming the file and `ssh` + +#### Scenario: Home-relative remote repo refused +- **WHEN** a host's `repos` holds `~/app` +- **THEN** the start fails with a line naming the file and `repos` + +#### Scenario: Duplicate host names refused +- **WHEN** two hosts are named `box` +- **THEN** the start fails with a line naming the file and `hosts` + #### Scenario: Edit picked up by a new session - **WHEN** a repo or a preset is added to `config.json` while a session is open, and a new session starts - **THEN** the new session's `hosts` lists the repo, its `presets` lists the preset, and the open session's do not change @@ -164,7 +180,7 @@ The configuration SHALL be `config.json` holding a JSON object with optional `po - **THEN** that session receives an `internal` error and the repos and presets of the last valid configuration ### Requirement: Daemon links and host status -For each session the hub SHALL hold one link per configured host; in this version the only host is the local host, index 0, `remote` false, named by the host name truncated to 64 characters (`local` when empty). The local link SHALL dial this host's daemon, starting it when absent, and exchange `hello`. A daemon `hello` with the same `protocol` SHALL make the host `connected` with its `daemonVersion` and `instance`; another `protocol` SHALL make it `outdated` with its `daemonVersion`, keeping the link open. A failed dial or a lost link SHALL make the host `reconnecting` and retry after 250 ms, doubling up to 5 s; after 3 consecutive failures it SHALL be `down` while retrying continues. Every change of a host entry SHALL be sent to the session as `hosts`. +For each session the hub SHALL hold one link per host of its configuration snapshot: index 0 the local host, `remote` false, named by the host name truncated to 64 characters (`local` when empty); then each configured remote host in configuration order at indices 1 and up, `remote` true, named by its configured `name`. The local link SHALL dial this host's daemon, starting it when absent. A remote link SHALL run the SSH command for the host's alias with the remote command `"$HOME/.local/bin/wtd" connect` and exchange stream frames over that process's standard input and output exactly as over the daemon socket. Every link SHALL exchange `hello`. A daemon `hello` with the same `protocol` SHALL make the host `connected` with its `daemonVersion` and `instance`; another `protocol` SHALL make it `outdated` with its `daemonVersion` and `instance`, keeping the link open. A failed dial or a lost link SHALL make the host `reconnecting` and retry after 250 ms, doubling up to 5 s; after 3 consecutive failures it SHALL be `down` while retrying continues. A remote link SHALL be lost when its SSH process exits or its standard output ends. A host's `reason` SHALL name the cause of the latest failure while it is `reconnecting` or `down`, and be null otherwise: for a remote host, the last non-empty line the SSH process wrote to standard error, taken from a tail of at most 4 KiB with control characters removed and cut to 1024 characters, or else the process's exit status; for the local host, the dial error. Closing a remote link SHALL end its SSH process's standard input and terminate that process if it has not exited 1 s later, and SHALL signal no other process. Every change of a host entry SHALL be sent to the session as `hosts`. #### Scenario: Local host connected - **WHEN** a session starts and no daemon is running @@ -176,15 +192,39 @@ For each session the hub SHALL hold one link per configured host; in this versio #### Scenario: Outdated daemon - **WHEN** the local socket is served by a daemon speaking another protocol version -- **THEN** the session receives host 0 as `outdated` with that daemon's version +- **THEN** the session receives host 0 as `outdated` with that daemon's version and instance + +#### Scenario: Remote host connected +- **WHEN** a host `box` is configured and its SSH command reaches an installed `wtd` +- **THEN** the session receives host 1 named `box`, `remote` true, `connected` with the remote daemon's instance and a null `reason` + +#### Scenario: Killed SSH process reconnects to the same daemon +- **WHEN** the SSH process of a connected remote link is killed +- **THEN** the session receives host 1 as `reconnecting` and then `connected` with the same instance as before + +#### Scenario: Down host names the cause +- **WHEN** the SSH command for a host fails three times in a row, writing `ssh: Could not resolve hostname box` to standard error +- **THEN** the session receives that host as `down` with `reason` `ssh: Could not resolve hostname box` + +#### Scenario: Unbounded standard error stays bounded +- **WHEN** the SSH process writes 8 MiB to standard error without a newline and exits +- **THEN** the hub's resident memory grows by less than 16 MiB and the host's `reason` has at most 1024 characters + +#### Scenario: Closing a session spares the shared SSH master +- **WHEN** a session whose remote link created the SSH control master closes, and another session then links to the same host +- **THEN** only the closed session's SSH process ends, and the new link connects through the still-running master ### Requirement: Routing -The hub SHALL forward a browser `host{host, m}` envelope's `m` to that host's link unchanged, and every daemon message other than `hello` to the session as `host{host, m}` unchanged, never rewriting request or terminal ids. It SHALL re-frame data frames between the stream and WebSocket encodings, adding or removing only the host index. It SHALL forward `ack` unchanged and never send an `ack` of its own. A request to a host that is not `connected` SHALL get `error{req, host, code}` with `version-mismatch` when the host is `outdated` and `host-unavailable` otherwise; `ack`, `resize`, `setVisible` and input for such a host SHALL be dropped. A message naming an unknown host index SHALL get `unknown-host`. `addRepo`, `removeRepo`, hub-level `discoverRepos` and `reinstallDaemon` SHALL get `error{code: internal}` saying they are not implemented. +The hub SHALL forward a browser `host{host, m}` envelope's `m` to that host's link unchanged, and every daemon message other than `hello` to the session as `host{host, m}` unchanged, never rewriting request or terminal ids. It SHALL re-frame data frames between the stream and WebSocket encodings, adding or removing only the host index. It SHALL forward `ack` unchanged and never send an `ack` of its own. A request to a host that is not `connected` SHALL get `error{req, host, code}` with `version-mismatch` when the host is `outdated` and `host-unavailable` otherwise; `ack`, `resize`, `setVisible` and input for such a host SHALL be dropped. A message naming an unknown host index SHALL get `unknown-host`. #### Scenario: Round trip through the hub - **WHEN** a browser watches a repo, creates and attaches a terminal and types into it - **THEN** it receives `repoState`, `termCreated`, the snapshot and the echoed output, each daemon message byte-identical inside its envelope +#### Scenario: Round trip to a remote host +- **WHEN** a browser does the same through host 1, a remote host +- **THEN** it receives the same messages inside envelopes for host 1 + #### Scenario: Acks only from the browser - **WHEN** a browser receives output and sends no `ack` - **THEN** the daemon receives no `ack` for that terminal @@ -213,16 +253,28 @@ While a session's unsent WebSocket data is at least 1 MiB, the hub SHALL stop re - **THEN** the hub's resident memory grows by less than 64 MiB and other sessions keep receiving output ### Requirement: Daemon restart -`restartDaemon{req, host}` for a `connected` or `outdated` local host SHALL send the daemon `shutdown`, wait until its socket is no longer served, dial until a daemon whose `hello` has another `instance` and the hub's `protocol` answers, and then reply `done`; if that does not happen within 10 s it SHALL reply `error{code: internal}`. Concurrent restarts of one host, from any sessions, SHALL share one restart. For any other status it SHALL reply `host-unavailable`. +`restartDaemon{req, host}` for a `connected` or `outdated` host, local or remote, SHALL send the daemon answering on that host the frozen `hello` and `shutdown`, then dial that host — starting a daemon when none serves — until a daemon whose `hello` has another `instance` and the hub's `protocol` answers, and then reply `done`; if that does not happen within 10 s it SHALL reply `error{code: internal}`. Only the frozen `hello` SHALL be decoded from a daemon being restarted. Restart and reinstall requests for one host, from any sessions, SHALL be coordinated: concurrent requests of the same kind SHALL share one run, a restart requested while a reinstall of that host runs SHALL share the reinstall, and any other request SHALL wait until the running one ends. For any other status it SHALL reply `host-unavailable`. #### Scenario: Outdated daemon restarted - **WHEN** host 0 is `outdated` and the browser sends `restartDaemon` - **THEN** the old daemon exits, host 0 becomes `connected` with a new instance and the hub's protocol, and the browser receives `done` +#### Scenario: Newer daemon restarted +- **WHEN** host 0 is served by a daemon speaking protocol 6, whose messages after `hello` are not valid protocol-5 messages, and the browser sends `restartDaemon` +- **THEN** that daemon exits and host 0 becomes `connected` with a new instance and the hub's protocol + +#### Scenario: Remote daemon restarted +- **WHEN** a remote host is `connected` and the browser sends `restartDaemon` for it +- **THEN** the remote daemon is replaced by a new instance and the browser receives `done` + #### Scenario: Concurrent restarts start one daemon - **WHEN** two sessions send `restartDaemon` for host 0 at the same time - **THEN** exactly one new daemon instance serves and both receive `done` +#### Scenario: Restart during a reinstall shares it +- **WHEN** a session sends `restartDaemon` for a remote host while another session's `reinstallDaemon` of that host runs +- **THEN** exactly one new daemon instance serves and both receive `done` when the reinstall ends + ### Requirement: Session end and hub shutdown When a session's WebSocket closes, the hub SHALL close that session's links; terminals SHALL keep running. On `SIGTERM`, `SIGINT` or an authenticated shutdown request the hub SHALL close every session with WebSocket code 1001, close all links, stop listening, remove its hub record and exit 0. @@ -233,3 +285,82 @@ When a session's WebSocket closes, the hub SHALL close that session's links; ter #### Scenario: Shutdown closes sessions - **WHEN** the hub receives `SIGTERM` with a session open - **THEN** the session is closed with code 1001 and the hub exits 0 + +### Requirement: Daemon reinstall +`reinstallDaemon{req, host}` for a remote host in any status SHALL install the hub's own bundle on that host as `wtd install-remote` does, within 120 s; then dial the host until a daemon answers whose `hello` carries the hub's package version and `protocol`, sending the frozen `hello` and `shutdown` to any daemon that answers with another version or protocol, within 10 s; and then reply `done`. A failure SHALL reply `error{code: internal}` with a message naming the failed step (`install` or `restart`) and its cause. For the local host it SHALL reply `error{code: internal}` saying the local daemon is restarted, not reinstalled. It SHALL be coordinated with restarts as the daemon restart requirement states. + +#### Scenario: Outdated remote reinstalled +- **WHEN** a remote host runs a daemon of another protocol from an older installation and the browser sends `reinstallDaemon` +- **THEN** the hub's bundle is installed there, the old daemon exits, the host becomes `connected` with the hub's version and protocol, and the browser receives `done` + +#### Scenario: Never-installed remote installed +- **WHEN** a remote host is `down` because `wtd` is not installed and the browser sends `reinstallDaemon` +- **THEN** `wtd` is installed there, the host becomes `connected`, and the browser receives `done` + +#### Scenario: Installation failure reported +- **WHEN** the remote host has no Node of version 20 or later and the browser sends `reinstallDaemon` +- **THEN** the browser receives `error` with code `internal` and a message starting `install:` that names the missing Node + +#### Scenario: Local host not reinstalled +- **WHEN** the browser sends `reinstallDaemon` for host 0 +- **THEN** it receives `error` with code `internal` and no daemon is touched + +### Requirement: Repo discovery through the hub +`discoverRepos{req, host}` for a `connected` host SHALL send that host's daemon, over a link of the hub's own, `discoverRepos` with the host's configured roots and depth 3, and reply `reposDiscovered{req, host, repos}` with the daemon's repos, or `error{req, host, code}` with the daemon's error code. For a host that is not `connected` it SHALL reply as routing does. + +#### Scenario: Discovery uses the configured roots +- **WHEN** host 0's roots are `["~/src"]` and the browser sends `discoverRepos` for host 0 +- **THEN** the daemon receives `discoverRepos` with roots `["~/src"]` and depth 3, and the browser receives `reposDiscovered` for host 0 with the repos found + +#### Scenario: Discovery does not disturb the session +- **WHEN** a browser uses request id 7 for both a `host` envelope and a hub-level `discoverRepos` +- **THEN** it receives each reply exactly once, correlated with its own request + +### Requirement: Adding repos +`addRepo{req, host, repo}` for a `connected` host SHALL check `repo` with `watchRepo` over a link of the hub's own, replying `error{req, host, code}` with the daemon's code if that fails; otherwise it SHALL add `repo` to that host's repos by a configuration edit and reply `done`. A repo already listed, as given or by a `~/` form expanding to it, SHALL reply `done` without an edit. For a host that is not `connected` it SHALL reply as routing does. + +#### Scenario: Repo added +- **WHEN** the browser sends `addRepo` for host 0 with the path of a repository's main worktree +- **THEN** `config.json` lists the path, the browser receives `done`, and then `hosts` listing the repo for host 0 + +#### Scenario: Not a repo refused +- **WHEN** the browser sends `addRepo` with a directory that is not a repository's main worktree +- **THEN** it receives `error` with `not-a-repo` and `config.json` is unchanged + +#### Scenario: Added repo reaches every session +- **WHEN** two sessions are open and one adds a repo to a remote host +- **THEN** both sessions receive `hosts` listing the repo for that host + +### Requirement: Removing repos +`removeRepo{req, host, repo}` for a `connected` host SHALL `watchRepo` it over a link of the hub's own; if the daemon lists any terminal for the repo, running or exited, it SHALL reply `busy` and change nothing. A `not-a-repo` error SHALL NOT prevent the removal; any other error SHALL be replied with the daemon's code. It SHALL then remove every entry of that host's repos naming `repo`, as given or by a `~/` form expanding to it, by a configuration edit, and reply `done`; a repo not listed SHALL reply `done` without an edit. The terminal check SHALL be made once, before the edit. For a host that is not `connected` it SHALL reply as routing does. + +#### Scenario: Repo removed +- **WHEN** the browser removes a repo without terminals that is listed as `~/app` +- **THEN** `config.json` no longer lists it, the browser receives `done`, and then `hosts` without it + +#### Scenario: Repo with terminals refused +- **WHEN** the browser removes a repo that has a running terminal, or an exited terminal not yet closed +- **THEN** it receives `error` with `busy` and `config.json` is unchanged + +#### Scenario: Deleted repo removed +- **WHEN** the browser removes a listed repo whose directory no longer exists +- **THEN** it is removed from `config.json` and the browser receives `done` + +### Requirement: Configuration edits +A configuration edit SHALL read the current `config.json` (an absent file read as `{}`), change only the `repos` of the edited host — the top-level `repos` for the local host, otherwise those of the host entry with the session's host name — keeping every other value as written, and replace the file atomically with an owner-only file holding the result as JSON indented by 2 spaces. It SHALL fail with `error{code: internal}` naming the problem when the current file is invalid, no longer lists the host, or the result would hold more than 256 repos for the host. If the file changed between the read and the replacement, the edit SHALL start over, at most 3 times in all, and then fail with `error{code: internal}`. Edits SHALL be applied one at a time. After an edit, every open session whose snapshot lists that host SHALL have the host's repos replaced by those of the edited file, `~/` expanded, and receive `hosts`; nothing else in any session's snapshot SHALL change. + +#### Scenario: Other entries kept as written +- **WHEN** `config.json` lists `~/a` for the local host and a repo is added for it +- **THEN** the file still lists `~/a` unexpanded, followed by the added path, and every other key is unchanged + +#### Scenario: Invalid file not overwritten +- **WHEN** `config.json` is invalid and the browser adds a repo +- **THEN** it receives `error` with code `internal` naming the problem and the file is unchanged + +#### Scenario: Concurrent hand edit kept +- **WHEN** `config.json` is changed by another writer after an edit read it and before the edit replaced it +- **THEN** the edit starts over from the changed file, and the result holds both the other writer's change and the added repo + +#### Scenario: Host gone from the file +- **WHEN** a session's host `box` was removed from `config.json` and that session adds a repo for `box` +- **THEN** it receives `error` with code `internal` and the file is unchanged diff --git a/openspec/specs/platform/spec.md b/openspec/specs/platform/spec.md index 8cc9369..1b5452c 100644 --- a/openspec/specs/platform/spec.md +++ b/openspec/specs/platform/spec.md @@ -66,16 +66,24 @@ The start lock SHALL record the holder's process id and a random nonce. A lock S - **THEN** a second acquirer does not take the lock and fails after 5 s ### Requirement: Daemon dialing with auto-start -Dialing SHALL connect to this host's daemon socket. When the socket does not exist or refuses connections, dialing SHALL start the daemon command it was given, detached in its own session with stdin ignored and stdout and stderr appended to `daemon.log` (moved to `daemon.log.1` first when over 1 MiB), and SHALL retry connecting every 50 ms for up to 5 s. If no connection succeeds, dialing SHALL fail with `daemon did not start; see `. +Dialing SHALL connect to this host's daemon socket. When the socket does not exist or refuses connections, dialing SHALL start the daemon: when the daemon's systemd user unit file exists, by running the systemctl command with `--user start worktree-term-daemon.service`; otherwise, or when that command fails, by starting the daemon command it was given, detached in its own session with stdin ignored and stdout and stderr appended to `daemon.log` (moved to `daemon.log.1` first when over 1 MiB). It SHALL then retry connecting every 50 ms for up to 5 s. If no connection succeeds, dialing SHALL fail with `daemon did not start; see `. The systemctl command SHALL be `$WTD_SYSTEMCTL` when set and non-empty, else `systemctl`. #### Scenario: Running daemon reused - **WHEN** a daemon serves the socket and a client dials - **THEN** the client connects and no daemon is started #### Scenario: Absent daemon started -- **WHEN** no daemon serves the socket and a client dials +- **WHEN** no daemon serves the socket, no unit file exists, and a client dials - **THEN** a daemon is started detached, its output goes to `daemon.log`, and the client connects +#### Scenario: Absent daemon started through systemd +- **WHEN** the unit file exists, no daemon serves the socket, and a client dials +- **THEN** the systemctl command is run with `--user start worktree-term-daemon.service` and the daemon command is not started directly + +#### Scenario: Failing systemctl falls back +- **WHEN** the unit file exists, the systemctl command exits non-zero, and a client dials +- **THEN** the daemon command is started detached and the client connects + #### Scenario: Concurrent dials start one daemon - **WHEN** two clients dial at the same time with no daemon running - **THEN** both connect to the same daemon instance @@ -85,12 +93,92 @@ Dialing SHALL connect to this host's daemon socket. When the socket does not exi - **THEN** dialing fails after 5 s with a message naming the log path ### Requirement: Hub and configuration paths -The configuration directory SHALL be `$XDG_CONFIG_HOME/worktree-term` when `XDG_CONFIG_HOME` is an absolute path, and `~/.config/worktree-term` otherwise, with `config.json` the hub configuration. Under the state directory, `hub-token` SHALL be the hub token, `hub.log` the log of a detached hub, and `run/hub.lock` and `run/hub.json` the hub start lock and hub record. Files and directories created for them SHALL be owner-only, as for the daemon's. +The configuration directory SHALL be `$XDG_CONFIG_HOME/worktree-term` when `XDG_CONFIG_HOME` is an absolute path, and `~/.config/worktree-term` otherwise, with `config.json` the hub configuration. The daemon's systemd user unit file SHALL be `systemd/user/worktree-term-daemon.service` under `$XDG_CONFIG_HOME` when it is an absolute path, and under `~/.config` otherwise. Under the state directory, `hub-token` SHALL be the hub token, `hub.log` the log of a detached hub, `run/hub.lock` and `run/hub.json` the hub start lock and hub record, and `run/ssh-%C` the SSH control path, `%C` standing for the 40 hexadecimal digits SSH substitutes. A control path whose UTF-8 encoding, with `%C` substituted, exceeds 107 bytes SHALL be refused with an error naming the path. Files and directories created for them SHALL be owner-only, as for the daemon's. #### Scenario: XDG_CONFIG_HOME honoured - **WHEN** `XDG_CONFIG_HOME` is `/tmp/c` -- **THEN** the configuration file is `/tmp/c/worktree-term/config.json` +- **THEN** the configuration file is `/tmp/c/worktree-term/config.json` and the unit file is `/tmp/c/systemd/user/worktree-term-daemon.service` #### Scenario: Relative XDG_CONFIG_HOME ignored - **WHEN** `XDG_CONFIG_HOME` is `rel/dir` - **THEN** the configuration file is `~/.config/worktree-term/config.json` + +#### Scenario: Over-long control path refused +- **WHEN** the state directory is so long that the substituted control path encodes to 108 bytes +- **THEN** resolving the control path fails with an error naming the path + +### Requirement: SSH command line +The SSH command for an alias SHALL be the SSH program followed by `-o BatchMode=yes -o ConnectTimeout=10 -o ControlMaster=auto -o ControlPersist=10m -o ControlPath= -o ServerAliveInterval=15 --`, the alias, and the remote command as one argument. The SSH program SHALL be `$WTD_SSH` when set and non-empty, else `ssh`. An alias that is empty, longer than 255 characters, starts with `-`, or contains whitespace or a control character SHALL be refused with an error naming it. Every SSH process the hub or an installer starts SHALL use this command line. + +#### Scenario: Options precede the alias +- **WHEN** the SSH command for alias `box` and remote command `"$HOME/.local/bin/wtd" connect` is built +- **THEN** its arguments are the options above, `--`, `box` and `"$HOME/.local/bin/wtd" connect`, in that order + +#### Scenario: Option-like alias refused +- **WHEN** the SSH command for alias `-oProxyCommand=touch /tmp/x` is built +- **THEN** building fails with an error naming the alias and nothing is run + +### Requirement: Installed layout +An installation SHALL place under the data directory `~/.local/share/worktree-term` a release directory `versions/-<12 random lowercase hex digits>` holding `wtd.mjs`, the web bundle as `web/`, and `node_modules/@homebridge/node-pty-prebuilt-multiarch/` with only that package's `package.json`, `LICENSE`, `lib/` and `prebuilds/`. A release SHALL be assembled under a name starting with `.` in `versions/` and renamed into place once complete, and SHALL never be changed afterwards. The symbolic link `current` in the data directory SHALL then be replaced atomically to point to the new release. Afterwards every release other than the new one and the one `current` pointed to before SHALL be removed. The shim `~/.local/bin/wtd` SHALL be a POSIX shell script that runs the installation's Node, given by absolute path, with `$HOME/.local/share/worktree-term/current/wtd.mjs` and all its arguments, written atomically with mode 0700. The Node SHALL be an absolute path without NUL or newline, of version 20 or later, and the shim SHALL run it correctly whatever other characters its path holds. Directories created for the installation SHALL be owner-only; existing parent directories outside the data directory SHALL be left as they are. A path the installation owns — the data directory and everything below it, the shim, the launcher and the unit file — that exists but is a symbolic link where a directory or file is expected (other than `current`), is of the wrong type, or is owned by another user SHALL make the installation fail naming it, changing nothing. + +#### Scenario: Fresh layout +- **WHEN** an installation of version 0.2.0 runs in an empty home +- **THEN** `current` points to `versions/0.2.0-`, which holds `wtd.mjs`, `web/index.html` and the PTY package's `prebuilds/`, and `~/.local/bin/wtd --version` prints the version + +#### Scenario: Same version reinstalled +- **WHEN** version 0.2.0 is installed twice +- **THEN** `current` points to a different release directory than after the first installation, and the first release still exists + +#### Scenario: Old releases removed +- **WHEN** three installations run one after the other +- **THEN** only the releases of the second and third remain + +#### Scenario: Running release keeps working during a swap +- **WHEN** a process started through the shim keeps running while a new release is installed +- **THEN** that process keeps working and the shim's next run uses the new release + +#### Scenario: Unusual Node path +- **WHEN** the Node path contains a space, a single quote, `$` and `%` +- **THEN** the shim runs that Node + +#### Scenario: Planted symbolic link refused +- **WHEN** `~/.local/share/worktree-term/versions` is a symbolic link +- **THEN** the installation fails naming that path and nothing is written + +### Requirement: Local installation +A local installation SHALL install, as the installed layout states, the bundle of the running `wtd` — its `wtd.mjs`, the web bundle beside it and the PTY package it resolves — using the running Node. It SHALL then write the launcher `~/.local/share/applications/worktree-term.desktop`, owner-only, as a desktop entry of type `Application` named `worktree-term`, not run in a terminal, whose `Exec` runs the shim with `ui`, whose `Icon` is `icon.svg` of the web bundle under `current`, and whose `StartupWMClass` is the window class of the Chrome app window `wtd ui` opens; each value SHALL be escaped as desktop entries require. With the systemd option it SHALL also write the unit file, owner-only, running the shim with `daemon` (escaped as unit files require), with `Restart=on-failure` and installed for `default.target`; run the systemctl command with `--user daemon-reload` and then `--user enable worktree-term-daemon.service`; and start the unit as dialing does only when no daemon serves this host's socket, leaving a running daemon alone. A failing systemctl command SHALL fail the installation naming the command. + +#### Scenario: Launcher written +- **WHEN** a local installation runs in a home whose path contains a space +- **THEN** the launcher exists and its `Exec` line, unescaped as desktop entries define, runs the shim with `ui` + +#### Scenario: Unit installed and enabled +- **WHEN** a local installation runs with the systemd option and no daemon is running +- **THEN** the unit file exists, the systemctl command was run with daemon-reload, enable and start, and the unit's `ExecStart` runs the shim with `daemon` + +#### Scenario: Running daemon left alone +- **WHEN** a local installation runs with the systemd option while a daemon serves the socket +- **THEN** the unit is enabled but not started, and the running daemon keeps its instance + +### Requirement: Remote installation +A remote installation SHALL send the bundle of the running `wtd` as one tar archive to the SSH command for the alias, whose remote command SHALL be fixed and contain no quote inside its single-quoted part, so that every common login shell runs it alike. Every value the remote side needs — the version, the Node path given, the install script — SHALL travel inside the archive, never in the command. On the remote host it SHALL refuse a system other than Linux; take the given Node path, refusing it when it is older than 20; without one, take the first Node of version 20 or later among `node` on the command's PATH, the path `$SHELL -l -i -c 'command -v node'` prints between markers, and the Node the installed shim runs, in that order; and install as the installed layout states, without launcher or unit file. It SHALL succeed naming the release and the Node used, and fail with one line naming the cause: the remote installer's message, or else the last non-empty line SSH wrote to standard error. + +#### Scenario: Remote installed +- **WHEN** a remote installation runs to a host with Node 22 only in its login shell's PATH +- **THEN** the remote `~/.local/bin/wtd connect` bridges to a daemon of the installed version, run by that Node + +#### Scenario: Old Node on the command's PATH passed over +- **WHEN** the command's PATH holds Node 18 and the login shell's PATH holds Node 22 +- **THEN** the installation uses the login shell's Node 22 + +#### Scenario: Hostile Node path is data +- **WHEN** the given Node path is `/opt/n'; touch x; '/node` +- **THEN** no file `x` is created and the installation fails naming that path as not a usable Node + +#### Scenario: Missing Node reported +- **WHEN** the remote host has no Node of version 20 or later +- **THEN** the installation fails with a line naming the alias and the missing Node, and the remote layout is unchanged + +#### Scenario: Unreachable host reported +- **WHEN** SSH cannot reach the alias +- **THEN** the installation fails with SSH's last error line diff --git a/openspec/specs/protocol/spec.md b/openspec/specs/protocol/spec.md index 1ea4cc7..97f0e5e 100644 --- a/openspec/specs/protocol/spec.md +++ b/openspec/specs/protocol/spec.md @@ -6,7 +6,7 @@ The wire contract between the browser UI, the hub and every daemon: which messag ## Requirements ### Requirement: Protocol version and frozen handshake messages -The protocol SHALL define an integer `PROTOCOL_VERSION`, equal to 4. The `hello` message SHALL be `{t: "hello", protocol, version, instance}` where `protocol` is an integer ≥ 1, `version` is a string of 1–64 characters, and `instance` is a string of 1–64 characters from `[A-Za-z0-9_-]`. The `shutdown` message SHALL be `{t: "shutdown"}`. The shapes of `hello` and `shutdown` and the stream frame header SHALL be identical in every protocol version, so that peers of different versions can always exchange them. +The protocol SHALL define an integer `PROTOCOL_VERSION`, equal to 5. The `hello` message SHALL be `{t: "hello", protocol, version, instance}` where `protocol` is an integer ≥ 1, `version` is a string of 1–64 characters, and `instance` is a string of 1–64 characters from `[A-Za-z0-9_-]`. The `shutdown` message SHALL be `{t: "shutdown"}`. The shapes of `hello` and `shutdown` and the stream frame header SHALL be identical in every protocol version, so that peers of different versions can always exchange them. #### Scenario: Hello round-trips - **WHEN** a `hello` with `protocol` 1, `version` "0.1.0" and `instance` "a1B2_c3" is encoded and decoded @@ -75,7 +75,7 @@ A worktree SHALL be `{path, head, branch, detached, locked, prunable, bare, main ### Requirement: Browser-link message catalogue Browser-to-hub messages SHALL be exactly: `hello`; `host{host, m}` where `m` is any client-to-daemon message except `hello` and `shutdown`; `addRepo{req, host, repo}`; `removeRepo{req, host, repo}`; `discoverRepos{req, host}`; `restartDaemon{req, host}`; `reinstallDaemon{req, host}`. Hub-to-browser messages SHALL be exactly: `hello`; `token{token}`; `host{host, m}` where `m` is any daemon-to-client message except `hello`; `hosts{hosts}`; `presets{presets}`; `done{req}`; `error{req, host, code, message}` with `req` and `host` each nullable; `reposDiscovered{req, host, repos}`. -A host entry SHALL be `{idx, name, remote, status, daemonVersion, instance, repos}` with `status` one of `connecting`, `connected`, `reconnecting`, `down`, `outdated`, and `daemonVersion` and `instance` nullable; `instance` SHALL be the `hello.instance` of the daemon the host is `connected` to, and null in every other status. A preset SHALL be `{name, command}` with `command` nullable. +A host entry SHALL be `{idx, name, remote, status, reason, daemonVersion, instance, repos}` with `status` one of `connecting`, `connected`, `reconnecting`, `down`, `outdated`, and `reason`, `daemonVersion` and `instance` nullable; `instance` SHALL be the `hello.instance` of the daemon the host is `connected` to or found `outdated`, and null in every other status. A preset SHALL be `{name, command}` with `command` nullable. #### Scenario: Envelope cannot carry a handshake message - **WHEN** a browser `host` envelope whose `m` is a `hello` or a `shutdown` is decoded @@ -89,6 +89,14 @@ A host entry SHALL be `{idx, name, remote, status, daemonVersion, instance, repo - **WHEN** a `hosts` message whose entry lacks `instance` is decoded - **THEN** decoding fails with a protocol error +#### Scenario: Host entry without reason rejected +- **WHEN** a `hosts` message whose entry lacks `reason` is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Outdated host carries its instance +- **WHEN** a `hosts` entry with `status` "outdated" and a non-null `instance` is decoded +- **THEN** decoding succeeds and yields that instance + #### Scenario: Token message only from the hub - **WHEN** a `token` message is decoded as a browser-to-hub message - **THEN** decoding fails with a protocol error @@ -109,12 +117,28 @@ Every message that asks a peer to act and can fail SHALL carry `req`, an integer - **THEN** decoding succeeds ### Requirement: Value limits -Decoding and encoding SHALL enforce: paths are absolute, at most 4096 characters, without NUL; terminal ids are integers from 1 to 2^32−1; offsets are integers from 0 to 2^53−1; `cols` and `rows` are integers from 1 to 1000; host indices are integers from 0 to 65535; `depth` is 1–6; `head` is null or 40 or 64 lowercase hex digits; host names and tab ids are 1–64 characters; preset names are 1–64 characters with at least one non-whitespace character; `branch` and `command` are null or at most 4096 characters, and a preset's `command` is null or 1–4096 characters; `signal` is null or at most 32 characters; `version` is 1–64 characters; tokens and one-time codes are exactly 64 lowercase hex digits; error messages are at most 1024 characters; error codes are one of `bad-message`, `unknown-host`, `unknown-term`, `unknown-worktree`, `not-watched`, `busy`, `spawn-failed`, `version-mismatch`, `not-a-repo`, `host-unavailable`, `internal`. Collections SHALL hold at most: 1024 worktrees, terminals, checked paths or layout entries per message; 32 discovery roots (at least 1); 4096 discovered repos; 4096 visible terminal ids; 64 hosts; 256 repos per host; 64 presets. +Decoding and encoding SHALL enforce: paths are absolute, at most 4096 characters, without NUL; discovery roots are absolute paths, `~`, or `~/` followed by at least one character, at most 4096 characters, without NUL; terminal ids are integers from 1 to 2^32−1; offsets are integers from 0 to 2^53−1; `cols` and `rows` are integers from 1 to 1000; host indices are integers from 0 to 65535; `depth` is 1–6; `head` is null or 40 or 64 lowercase hex digits; host names and tab ids are 1–64 characters; preset names are 1–64 characters with at least one non-whitespace character; `branch` and `command` are null or at most 4096 characters, and a preset's `command` is null or 1–4096 characters; `signal` is null or at most 32 characters; `version` is 1–64 characters; tokens and one-time codes are exactly 64 lowercase hex digits; error messages and host reasons are at most 1024 characters; error codes are one of `bad-message`, `unknown-host`, `unknown-term`, `unknown-worktree`, `not-watched`, `busy`, `spawn-failed`, `version-mismatch`, `not-a-repo`, `host-unavailable`, `internal`. Collections SHALL hold at most: 1024 worktrees, terminals, checked paths or layout entries per message; 32 discovery roots (at least 1); 4096 discovered repos; 4096 visible terminal ids; 64 hosts; 256 repos per host; 64 presets. #### Scenario: Relative path rejected - **WHEN** a `watchRepo` whose `repo` is "repo/a" is decoded - **THEN** decoding fails with a protocol error +#### Scenario: Home-relative discovery roots accepted +- **WHEN** a `discoverRepos` whose `roots` are "~" and "~/GitHub" is decoded +- **THEN** decoding succeeds and yields both roots unchanged + +#### Scenario: Other relative discovery roots rejected +- **WHEN** a `discoverRepos` whose `roots` hold "GitHub", "~user/x" or "~/" is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Home-relative repo rejected +- **WHEN** a `watchRepo` or `addRepo` whose `repo` is "~/x" is decoded +- **THEN** decoding fails with a protocol error + +#### Scenario: Overlong host reason rejected +- **WHEN** a `hosts` entry whose `reason` has 1025 characters is decoded +- **THEN** decoding fails with a protocol error + #### Scenario: Collection over limit rejected - **WHEN** a `worktreesChanged` with 1025 worktrees is decoded - **THEN** decoding fails with a protocol error @@ -209,7 +233,7 @@ On a stream, an output or snapshot payload SHALL be a 4-byte terminal id, an 8-b - **THEN** decoding fails with a protocol error ### Requirement: Shared constants -The protocol SHALL export: `PROTOCOL_VERSION` = 4; `MAX_FRAME` = 16 MiB; `MAX_INPUT` = 64 KiB; `FLOW_HIGH` = 512 KiB; `FLOW_LOW` = 128 KiB; `ACK_EVERY` = 64 KiB; `LAG_EVICT_MS` = 2000. +The protocol SHALL export: `PROTOCOL_VERSION` = 5; `MAX_FRAME` = 16 MiB; `MAX_INPUT` = 64 KiB; `FLOW_HIGH` = 512 KiB; `FLOW_LOW` = 128 KiB; `ACK_EVERY` = 64 KiB; `LAG_EVICT_MS` = 2000. #### Scenario: Constants hold their values - **WHEN** the exported constants are read diff --git a/openspec/specs/web/spec.md b/openspec/specs/web/spec.md index 227bd27..f3be68a 100644 --- a/openspec/specs/web/spec.md +++ b/openspec/specs/web/spec.md @@ -24,7 +24,7 @@ The page SHALL send `hello` with its own protocol version and the package versio - **THEN** the page reloads once and then runs the bundle the new hub serves ### Requirement: Reconnect and restore -When its WebSocket closes, the page SHALL show that it is reconnecting and reconnect with backoff from 250 ms doubling up to 5 s. Requests pending to a host SHALL fail when the session closes or the host leaves `connected`. When a host becomes `connected`, the page SHALL compare the instance with the last instance it saw `connected` for that host: if it differs, every terminal of that host SHALL be disposed and its state cleared; in both cases the page SHALL then watch each repo of the host and, once that watch is done, re-attach every terminal of that repo it had attached that is still listed. +When its WebSocket closes, the page SHALL show that it is reconnecting and reconnect with backoff from 250 ms doubling up to 5 s. Requests pending to a host SHALL fail when the session closes or the host leaves `connected`. When a host becomes `connected`, the page SHALL compare the instance with the last instance it saw `connected` for that host: if it differs, every terminal of that host SHALL be disposed and its state cleared; in both cases the page SHALL then watch each repo of the host and, once that watch is done, re-attach every terminal of that repo it had attached that is still listed. While a host stays `connected`, a `hosts` message listing a repo of that host not listed before SHALL make the page watch it, and one no longer listing a repo SHALL make the page unwatch it, drop its state and remove its tab — except that a repo whose terminals the page holds with a live process SHALL keep its tab and state until none is left. #### Scenario: Hub restart keeps terminals - **WHEN** the hub restarts while the daemon keeps running @@ -34,8 +34,16 @@ When its WebSocket closes, the page SHALL show that it is reconnecting and recon - **WHEN** the local daemon is replaced by a new instance - **THEN** the old terminals disappear from the page and no output of the new daemon is written into them +#### Scenario: Remote link loss keeps terminals +- **WHEN** the SSH process of a connected remote host is killed and the host becomes `connected` again with the same instance +- **THEN** each attached terminal of that host shows the same screen in the same terminal object and receives new output + +#### Scenario: Repo added elsewhere is watched +- **WHEN** another page adds a repo to a connected host +- **THEN** this page shows the repo's tab with its worktrees + ### Requirement: Repo tabs -The page SHALL show one tab per repo of each host, in the order the hub lists them, labelled with the repo directory's name, adding leading path segments until labels of the same host are unique, and prefixed `:` for a remote host; the full path SHALL show on hover. A repo whose watch fails SHALL show the error and its path in its tab's area. +The page SHALL show one tab per repo of each host, in the order the hub lists them, labelled with the repo directory's name, adding leading path segments until labels of the same host are unique, and prefixed `:` for a remote host; the full path SHALL show on hover. A repo whose watch fails SHALL show the error and its path in its tab's area. A tab of a host that is not `connected` SHALL show the host's status. #### Scenario: Duplicate names disambiguated - **WHEN** the repos are `/a/x/app` and `/b/y/app` @@ -45,6 +53,10 @@ The page SHALL show one tab per repo of each host, in the order the hub lists th - **WHEN** a configured repo path is not a repository's main worktree - **THEN** its tab shows the `not-a-repo` error naming the path +#### Scenario: Remote tab shows host status +- **WHEN** a remote host `box` with repo `/srv/app` is `reconnecting` +- **THEN** the tab labelled `box:app` shows the status `reconnecting` + ### Requirement: Worktree sidebar The sidebar SHALL list the selected repo's worktrees in the daemon's order without bare entries, each labelled with its branch, or ` @ ` when detached, or the directory name when it has no head, with the path on hover. A prunable worktree SHALL be shown greyed and SHALL offer no new terminal. A worktree no longer listed that still has live terminals SHALL be listed after the others, marked as gone, without a checkbox, until its last terminal closes. Each listed worktree SHALL have a checkbox reflecting the daemon's checked state, and toggling it SHALL send `setChecked`. Worktree list changes SHALL show without user action. @@ -149,12 +161,28 @@ At most 8 terminals SHALL render with WebGL at any time: the most recently shown - **THEN** it keeps rendering with the DOM renderer and still shows its screen ### Requirement: Outdated host -For an `outdated` host the page SHALL show that its daemon is outdated, with an action to restart it that first asks for confirmation stating that every terminal on that host will be killed, and then sends `restartDaemon`. +For an `outdated` host the page SHALL show that its daemon is outdated, with an action — "Restart daemon" for the local host, "Reinstall & restart" for a remote host — that first asks for confirmation in the page and then sends `restartDaemon` or `reinstallDaemon`. For a `down` remote host it SHALL offer "Install", confirmed the same way, sending `reinstallDaemon`. The confirmation SHALL list the running terminals the page last saw on the daemon instance the host reports — each by worktree label and preset — with the time it last saw them; without such a record it SHALL say that every running terminal on the host will be killed. The page SHALL keep, per host, the running terminals it last saw with their daemon instance in browser storage, keyed separately for the local host and for each remote host name, at most 256 terminals per host; unavailable storage SHALL only lose the list. A failed action SHALL show its error on the page. #### Scenario: Restart from the page - **WHEN** the local host is `outdated` and the user confirms the restart - **THEN** the daemon is restarted and the host's repos are shown again +#### Scenario: Confirmation names the terminals +- **WHEN** the page saw terminals `claude` on worktree `feat` and `shell` on `main` running on a daemon instance, is reloaded, and that host is then `outdated` with the same instance +- **THEN** the confirmation lists both terminals before anything is sent + +#### Scenario: Unknown terminals stated +- **WHEN** a host is `outdated` with an instance the page has no record of +- **THEN** the confirmation says every running terminal on the host will be killed + +#### Scenario: Reinstall from the page +- **WHEN** a remote host is `outdated` and the user confirms "Reinstall & restart" +- **THEN** the page sends `reinstallDaemon` and shows the host's repos once it is `connected` + +#### Scenario: Cancel sends nothing +- **WHEN** the user cancels the confirmation +- **THEN** no request is sent + ### Requirement: Performance budgets With a repo of 58 worktrees and 2 terminals in each, the median of 20 measured switches SHALL be at most 16 ms between a worktree whose terminals are among the 8 WebGL terminals and at most 50 ms to a worktree whose terminals must start WebGL again, each measured from the input event to just after the first frame painted with the switch applied. The median of 20 switches to a worktree whose active tab holds 8 panes that must start WebGL again SHALL be at most 100 ms, measured the same way. While a divider of a tab holding 8 panes is dragged, the median time from a pointer move to the first frame painted with the new pane sizes SHALL be at most 16 ms. A worktree added or removed with git SHALL show in, or leave, the sidebar within 250 ms of the git command finishing. @@ -314,3 +342,40 @@ The page SHALL tell each host which of its terminals the page shows: every pane #### Scenario: Reconnect restores visibility - **WHEN** the hub restarts while the page shows a two-pane tab - **THEN** after the page reconnects the daemon counts both terminals as visible + +### Requirement: Host status display +For each host that is `down` or `outdated` the page SHALL show a banner naming the host, its status and its `reason` when present, with the action the outdated host requirement states. A `connecting` or `reconnecting` host SHALL show its status only on its repo tabs. + +#### Scenario: Down host banner +- **WHEN** a remote host `box` is `down` with reason `ssh: Could not resolve hostname box` +- **THEN** the page shows a banner naming `box`, `down` and that reason, with an "Install" action + +#### Scenario: Recovered host clears the banner +- **WHEN** a `down` host becomes `connected` +- **THEN** its banner disappears + +### Requirement: Add repo +The repo tab bar SHALL end with an "Add repo" control opening a dialog with a host selector — listing every host with its status, hosts that are not `connected` disabled, preselecting the host of the selected tab — a list of the repos discovered on the selected host minus those it lists, a filter narrowing that list by substring, and a field for typing an absolute path. Choosing a repo or submitting a path SHALL send `addRepo`; on `done` the dialog SHALL close and the added repo's tab SHALL be selected once the hub lists it; an error SHALL be shown in the dialog. Escape SHALL close the dialog without sending anything. + +#### Scenario: Discovered repo added +- **WHEN** the user opens "Add repo", picks a discovered repo of the local host +- **THEN** its tab appears, is selected, and shows its worktrees + +#### Scenario: Configured repos not offered +- **WHEN** the local host lists repo `/r/a` and discovery finds `/r/a` and `/r/b` +- **THEN** the dialog offers only `/r/b` + +#### Scenario: Typed path refused +- **WHEN** the user submits a typed path that is not a repository's main worktree +- **THEN** the dialog shows the `not-a-repo` error and stays open + +### Requirement: Remove repo +Each repo tab SHALL offer a "Remove repo" action that asks for confirmation in the page and then sends `removeRepo`. A `busy` reply SHALL tell the user to close the repo's terminals first; on `done` the tab SHALL disappear once the hub no longer lists the repo. + +#### Scenario: Repo removed +- **WHEN** the user removes a repo without terminals and confirms +- **THEN** its tab disappears + +#### Scenario: Busy repo kept +- **WHEN** the user removes a repo with a terminal and confirms +- **THEN** the tab stays and the page says to close the repo's terminals first