diff --git a/README.md b/README.md index 812e0c4..9174511 100644 --- a/README.md +++ b/README.md @@ -4,32 +4,64 @@ A fast, worktree-centric terminal manager for coding agents. One tab per repo (l The CLI is `wtd`: a daemon per host owns the PTYs and git watches, and a local hub serves the browser UI and reaches every daemon over one protocol. See `architecture/` for the structure and its principles. +## Installation + +Requires Linux and Node 20 or later. From a checkout, `pnpm build`, then: + +```sh +node dist/wtd.mjs install-local # this machine +node dist/wtd.mjs install-local --systemd # also run the daemon as a systemd user unit +``` + +An installed `wtd` reinstalls itself the same way: `wtd install-local` or `wtd install-local --systemd`. + +`wtd install-local` copies the bundle into a new release under `~/.local/share/worktree-term/versions/`, points `~/.local/share/worktree-term/current` at it, writes the shim `~/.local/bin/wtd` (bound to the Node that ran the installer) and a desktop launcher, `~/.local/share/applications/worktree-term.desktop`, that runs `wtd ui`. Reinstalling keeps the previous release, so running daemons and hubs keep working; older releases are removed. Run it again after moving Node. + +With `--systemd` it also writes `~/.config/systemd/user/worktree-term-daemon.service`, enables it and starts it unless a daemon already runs; from then on every auto-start of the daemon goes through `systemctl --user start`. A unit's daemon stops when your last session ends unless lingering is on: `loginctl enable-linger`. + ## Configuration The hub reads `$XDG_CONFIG_HOME/worktree-term/config.json` (by default `~/.config/worktree-term/config.json`); every key is optional, and a new browser session picks up edits. `presets` lists the terminals a new tab or split can start, in order: `command` runs as `$SHELL -l -i -c `, and `null` starts the plain login shell. Without `presets`, the only preset is a plain `shell`. +`repos` and `roots` belong to this machine: `repos` are the repo tabs (absolute or `~/…`), `roots` are where "Add repo" looks for repositories, 3 levels deep (default `["~"]`). `hosts` lists remote hosts, each shown after this machine's repos: `name` labels its tabs (`name:repo`), `ssh` is the SSH alias, `repos` must be absolute paths on that host, and its `roots` (`~` meaning the remote home) default to `["~"]`. "Add repo" and "Remove repo" on the page edit `repos` here, keeping everything else as written. + ```json { "port": 7417, "repos": ["~/src/app", "/srv/lib"], + "roots": ["~/src"], "presets": [ { "name": "claude", "command": "claude" }, { "name": "shell", "command": null } - ] + ], + "hosts": [{ "name": "devbox", "ssh": "devbox", "repos": ["/home/me/src/api"], "roots": ["~/src"] }] } ``` +## Remote hosts + +A remote host needs only Linux, Node 20 or later and an SSH alias that logs in without a prompt (keys or an agent: the hub runs SSH with `BatchMode=yes`). Install wtd there with + +```sh +wtd install-remote devbox # finds Node on the remote PATH or in its login shell +wtd install-remote devbox --node /opt/node22/bin/node +``` + +or with "Install" on the page while the host is down. The remote gets the same layout as `wtd install-local`, without launcher or unit. Its shim runs the Node found at install time, so `"$HOME/.local/bin/wtd" connect` works over plain `ssh` with no dotfile changes. + +Each browser session holds one `ssh "$HOME/.local/bin/wtd" connect` per remote host, multiplexed over one SSH control master per host (`ControlPersist=10m`); a lost link reconnects to the same daemon and keeps its terminals. A host that keeps failing shows as down with SSH's last error line. sshd's `MaxSessions` (default 10) caps the sessions of one master: each open page uses one per host, plus a brief one for each "Add repo" or "Remove repo". When a host's daemon speaks an older protocol, the page offers "Restart daemon" (this machine) or "Reinstall & restart" (a remote host), naming the terminals that will be killed. `WTD_SSH` replaces the `ssh` program. + ## 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/archive/2026-10-07-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 new file mode 100644 index 0000000..aa32566 --- /dev/null +++ b/openspec/changes/archive/2026-10-07-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/archive/2026-10-07-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 new file mode 100644 index 0000000..3fa3b55 --- /dev/null +++ b/openspec/changes/archive/2026-10-07-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 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. Failing those, the Node the existing shim runs, so a host installed with `--node` can be reinstalled from the page. + +### 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/archive/2026-10-07-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 new file mode 100644 index 0000000..20dde0e --- /dev/null +++ b/openspec/changes/archive/2026-10-07-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/archive/2026-10-07-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 new file mode 100644 index 0000000..28587c7 --- /dev/null +++ b/openspec/changes/archive/2026-10-07-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/archive/2026-10-07-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 new file mode 100644 index 0000000..d50d386 --- /dev/null +++ b/openspec/changes/archive/2026-10-07-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/archive/2026-10-07-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 new file mode 100644 index 0000000..921ae9c --- /dev/null +++ b/openspec/changes/archive/2026-10-07-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/archive/2026-10-07-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 new file mode 100644 index 0000000..3fbcf71 --- /dev/null +++ b/openspec/changes/archive/2026-10-07-dev-2054-worktree-term-pr5-remote-hosts-over-ssh-install/specs/platform/spec.md @@ -0,0 +1,121 @@ +## 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, 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/changes/archive/2026-10-07-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 new file mode 100644 index 0000000..365c0a1 --- /dev/null +++ b/openspec/changes/archive/2026-10-07-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/archive/2026-10-07-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 new file mode 100644 index 0000000..3f3f678 --- /dev/null +++ b/openspec/changes/archive/2026-10-07-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/archive/2026-10-07-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 new file mode 100644 index 0000000..b2dec54 --- /dev/null +++ b/openspec/changes/archive/2026-10-07-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) + +- [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) + +- [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) + +- [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 + +- [x] 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 + +- [x] 5.1 `daemon.worktrees` discovery expands `~` roots against the daemon's home before resolving real paths (design D2); verify 3.2 passes + +## 6. Platform + +- [x] 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 +- [x] 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 + +- [x] 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 +- [x] 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 +- [x] 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 + +- [x] 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 + +- [x] 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 +- [x] 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 + +- [x] 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 + +- [x] 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 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 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..6d696e6 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 { @@ -21,35 +22,39 @@ Commands: hub Run the hub in the foreground daemon Run this host's daemon in the foreground connect Bridge stdio to this host's daemon, starting it if needed - install-local Install wtd for the current user - install-remote Install wtd on an SSH host + install-local [--systemd] + Install wtd for the current user, with a desktop launcher; + --systemd also installs and starts a systemd user unit for the daemon + install-remote [--node ] + Install wtd on an SSH host; --node names the remote Node to use Options: --help Show this help --version Show the version `; -const OPTIONS = { help: { type: 'boolean' }, version: { type: 'boolean' } } as const; - -const NOT_IMPLEMENTED = 'not implemented'; +const OPTIONS = { + help: { type: 'boolean' }, + version: { type: 'boolean' }, + systemd: { type: 'boolean' }, + node: { type: 'string' }, +} as const; + +/** Options a command was given. */ +interface CommandOptions { + systemd: boolean; + node: string | null; +} interface Command { /** Positional arguments after the verb. */ args: readonly string[]; + /** Options the command takes besides `--help` and `--version`. */ + options: readonly (keyof CommandOptions)[]; /** Runs the command and resolves with its exit code. */ - run: (io: CliIo) => Promise; + run: (io: CliIo, args: readonly string[], options: CommandOptions) => 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)); @@ -125,14 +130,48 @@ const connect = async (io: CliIo): Promise => { return 1; }; -// The installers are implemented by DEV-2054. -const COMMANDS: ReadonlyMap = new Map([ - ['ui', { args: [], run: ui }], - ['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) }], +const installLocalCommand = async (io: CliIo, _args: readonly string[], { systemd }: CommandOptions): Promise => { + try { + const bundle = wtdCommand()[1] ?? ''; + const installed = await installLocal({ + home: homedir(), + paths: currentHostPaths(), + bundle, + version: pkg.version, + node: process.execPath, + systemd, + }); + io.stdout(`installed wtd ${installed.version} in ${installed.dataDir} (release ${installed.release})\n`); + return 0; + } catch (error) { + io.stderr(`wtd install-local: ${message(error)}\n`); + return 1; + } +}; + +/** How long `wtd install-remote` waits for the remote installer. */ +const REMOTE_INSTALL_MS = 10 * 60 * 1000; + +const installRemoteCommand = async (io: CliIo, [alias = '']: readonly string[], { node }: CommandOptions): Promise => { + try { + const bundle = wtdCommand()[1] ?? ''; + const paths = currentHostPaths(); + const installed = await installRemote({ paths, alias, bundle, version: pkg.version, node, timeoutMs: REMOTE_INSTALL_MS }); + io.stdout(`installed wtd ${installed.version} on ${alias} (release ${installed.release}, Node ${installed.node})\n`); + return 0; + } catch (error) { + io.stderr(`wtd install-remote: ${message(error)}\n`); + return 1; + } +}; + +const COMMANDS: ReadonlyMap = new Map([ + ['ui', { args: [], options: [], run: ui }], + ['hub', { args: [], options: [], run: hub }], + ['daemon', { args: [], options: [], run: daemon }], + ['connect', { args: [], options: [], run: connect }], + ['install-local', { args: [], options: ['systemd'], run: installLocalCommand }], + ['install-remote', { args: ['alias'], options: ['node'], run: installRemoteCommand }], ]); class UsageError extends Error { @@ -143,24 +182,38 @@ interface Invocation { help: boolean; version: boolean; positionals: string[]; + options: CommandOptions; + /** Command options given, by their written name, to check against the command. */ + given: { name: keyof CommandOptions; rawName: string }[]; } +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 }); - let help = false; - let version = false; - for (const token of tokens) { - if (token.kind !== 'option') continue; - if (token.name !== 'help' && token.name !== 'version') throw new UsageError(`unknown option ${token.rawName}`); - if (token.value !== undefined) throw new UsageError(`option ${token.rawName} takes no value`); - if (token.name === 'help') help = true; - else version = true; - } - return { help, version, positionals }; + const invocation: Invocation = { help: false, version: false, positionals, options: { systemd: false, node: null }, given: [] }; + for (const token of tokens) if (token.kind === 'option') applyOption(invocation, token); + return invocation; }; const dispatch = async (argv: readonly string[], io: CliIo): Promise => { - const { help, version, positionals } = parse(argv); + const { help, version, positionals, options, given } = parse(argv); if (help) { io.stdout(USAGE); return 0; @@ -173,19 +226,13 @@ const dispatch = async (argv: readonly string[], io: CliIo): Promise => if (verb === undefined) throw new UsageError('missing command'); const command = COMMANDS.get(verb); if (command === undefined) throw new UsageError(`unknown command '${verb}'`); + const foreign = given.find((option) => !command.options.includes(option.name)); + if (foreign !== undefined) throw new UsageError(`${verb} takes no option ${foreign.rawName}`); const missing = command.args[args.length]; if (missing !== undefined) throw new UsageError(`${verb}: ${missing} is required`); const extra = args[command.args.length]; if (extra !== undefined) throw new UsageError(`${verb}: unexpected argument '${extra}'`); - try { - return await command.run(io); - } catch (error) { - if (error instanceof Error && error.message === NOT_IMPLEMENTED) { - io.stderr(`wtd ${verb}: ${NOT_IMPLEMENTED}\n`); - return 2; - } - throw error; - } + return command.run(io, args, options); }; /** Runs `wtd` with `argv` (without node and script) and returns the exit code. */ 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/daemon/worktrees/discover.ts b/src/daemon/worktrees/discover.ts index 84c6314..82565f5 100644 --- a/src/daemon/worktrees/discover.ts +++ b/src/daemon/worktrees/discover.ts @@ -1,4 +1,5 @@ import { lstat, readdir, realpath, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; import { join } from 'node:path'; /** Most repositories one discovery reports. */ @@ -52,6 +53,12 @@ const realDirectory = async (path: string): Promise => { } }; +/** `root` with a leading `~` expanded against the home directory. */ +const expandHome = (root: string): string => { + if (root === '~') return homedir(); + return root.startsWith('~/') ? join(homedir(), root.slice(2)) : root; +}; + /** * Repositories at most `depth` levels below each root (the root being level 0), sorted, at most * the first 4096 in visiting order. @@ -68,7 +75,7 @@ export const discoverRepos = async (roots: readonly string[], depth: number): Pr for (const name of await children(dir)) await visit(join(dir, name), level + 1); // NOSONAR(S9382) — ordered walk keeps the cap deterministic }; for (const root of roots) { - const real = await realDirectory(root); // NOSONAR(S9382) — ordered walk + const real = await realDirectory(expandHome(root)); // NOSONAR(S9382) — ordered walk if (real !== null) await visit(real, 0); // NOSONAR(S9382) — ordered walk } return [...found].sort(byCodeUnits); 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/config.ts b/src/hub/config/config.ts new file mode 100644 index 0000000..8b375f5 --- /dev/null +++ b/src/hub/config/config.ts @@ -0,0 +1,151 @@ +import { isAbsolute, join } from 'node:path'; +import { z } from 'zod'; +import { readFileIfExists } from '../../platform/files/index.js'; +import { discoveryRootSchema, presetSchema, type Preset } from '../../protocol/index.js'; + +export const DEFAULT_PORT = 7417; + +export const MAX_REPOS = 256; +const MAX_PATH = 4096; +const MAX_PRESETS = 64; +const MAX_ROOTS = 32; +const MAX_REMOTE_HOSTS = 63; +const DEFAULT_PRESETS: readonly Preset[] = [{ name: 'shell', command: null }]; +const DEFAULT_ROOTS: readonly string[] = ['~']; + +/** A configured remote host. */ +export interface RemoteHostConfig { + name: string; + /** The SSH alias. */ + ssh: string; + repos: string[]; + roots: string[]; +} + +/** The hub configuration, local `~/` repos already expanded. */ +export interface HubConfig { + port: number; + /** The local host's repos and discovery roots. */ + repos: string[]; + roots: string[]; + presets: Preset[]; + hosts: RemoteHostConfig[]; +} + +/** An unusable configuration; the message is one line. */ +export class ConfigError extends Error { + override readonly name = 'ConfigError'; +} + +// eslint-disable-next-line no-control-regex -- NUL is the character paths must not contain +const NO_NUL = /^[^\u0000]*$/; + +const repoPath = z + .string() + .max(MAX_PATH) + .regex(NO_NUL, 'must not contain NUL') + .refine((p) => isAbsolute(p) || p.startsWith('~/'), 'must be absolute or start with ~/'); + +const absolutePath = z.string().max(MAX_PATH).regex(NO_NUL, 'must not contain NUL').refine(isAbsolute, 'must be absolute'); + +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-\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 -'), + ssh: z.string().max(255).regex(SSH_ALIAS, 'must not be empty, start with - or contain whitespace or control characters'), + repos: z.array(absolutePath).max(MAX_REPOS).optional(), + roots: roots.optional(), +}); + +const configSchema = z.strictObject({ + port: z.int().min(1024).max(65535).optional(), + repos: z.array(repoPath).max(MAX_REPOS).optional(), + roots: roots.optional(), + hosts: z + .array(remoteHost) + .max(MAX_REMOTE_HOSTS) + .refine((list) => new Set(list.map((h) => h.name)).size === list.length, 'host names must be unique') + .optional(), + presets: z + .array(presetSchema) + .min(1) + .max(MAX_PRESETS) + .refine((list) => new Set(list.map((p) => p.name)).size === list.length, 'preset names must be unique') + .optional(), +}); + +const oneLine = (text: string): string => text.replace(/\s+/g, (run) => (run.includes('\n') ? ' ' : run)); + +const parseJson = (text: string): unknown => { + try { + return JSON.parse(text); + } catch (error) { + throw new ConfigError(`invalid JSON: ${oneLine(error instanceof Error ? error.message : String(error))}`, { cause: error }); + } +}; + +/** `path` with a leading `~/` expanded against `home`. */ +export const expandHome = (path: string, home: string): string => (path.startsWith('~/') ? join(home, path.slice(2)) : path); + +const expand = (path: string, home: string): string => { + if (!path.startsWith('~/')) return path; + const expanded = expandHome(path, home); + if (expanded.length > MAX_PATH) throw new ConfigError(`repos: ${path} is longer than ${String(MAX_PATH)} characters once expanded`); + return expanded; +}; + +/** Parses `config.json` text (null for a missing file); throws ConfigError naming the first problem. */ +export const parseConfig = (text: string | null, home: string): HubConfig => { + if (text === null) return { port: DEFAULT_PORT, repos: [], roots: [...DEFAULT_ROOTS], presets: [...DEFAULT_PRESETS], hosts: [] }; + const result = configSchema.safeParse(parseJson(text)); + if (!result.success) { + const [issue] = result.error.issues; + const where = issue === undefined || issue.path.length === 0 ? '' : `${issue.path.map(String).join('.')}: `; + throw new ConfigError(oneLine(`${where}${issue?.message ?? 'invalid configuration'}`)); + } + return { + port: result.data.port ?? DEFAULT_PORT, + repos: (result.data.repos ?? []).map((repo) => expand(repo, home)), + roots: result.data.roots ?? [...DEFAULT_ROOTS], + presets: result.data.presets ?? [...DEFAULT_PRESETS], + hosts: (result.data.hosts ?? []).map((h) => ({ name: h.name, ssh: h.ssh, repos: h.repos ?? [], roots: h.roots ?? [...DEFAULT_ROOTS] })), + }; +}; + +/** Reads and parses the configuration at `path`; throws ConfigError naming the file and the problem. */ +export const loadConfig = async (path: string, home: string): Promise => { + try { + return parseConfig(await readFileIfExists(path), home); + } catch (error) { + if (error instanceof ConfigError) throw new ConfigError(`${path}: ${error.message}`, { cause: error }); + throw new ConfigError(`${path}: ${oneLine(error instanceof Error ? error.message : String(error))}`, { cause: error }); + } +}; + +/** A configuration read for one session, with the problem of an invalid file. */ +export interface ConfigSnapshot { + config: HubConfig; + problem: string | null; +} + +/** Reads the configuration per session, falling back to the last valid one. */ +export class ConfigSource { + constructor( + private readonly path: string, + private readonly home: string, + private last: HubConfig, + ) {} + + async snapshot(): Promise { + try { + this.last = await loadConfig(this.path, this.home); + return { config: this.last, problem: null }; + } catch (error) { + if (!(error instanceof ConfigError)) throw error; + return { config: this.last, problem: error.message }; + } + } +} diff --git a/src/hub/config/editor.test.ts b/src/hub/config/editor.test.ts new file mode 100644 index 0000000..3303454 --- /dev/null +++ b/src/hub/config/editor.test.ts @@ -0,0 +1,207 @@ +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 () => { + const { port, ...others } = OTHERS; + write({ port, 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, 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(); + const edits = editor(); + await expect(edits.addRepo(null, '/r/new')).rejects.toBeInstanceOf(ConfigError); + await expect(edits.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); + const edits = editor(); + await expect(edits.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(); + const edits = editor(); + await expect(edits.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']); + }); +}); + +describe('ConfigEditor lists', () => { + it('tells whether a host lists a repo, as given or by a ~/ form for the local host', async () => { + write({ repos: ['~/a', '/r/b'], hosts: [{ name: 'box', ssh: 'box', repos: ['/srv/a'] }] }); + expect(await editor().lists(null, '/home/u/a')).toBe(true); + expect(await editor().lists(null, '/r/b')).toBe(true); + expect(await editor().lists(null, '/srv/a')).toBe(false); + expect(await editor().lists('box', '/srv/a')).toBe(true); + expect(await editor().lists('box', '/r/b')).toBe(false); + }); + + it('lists nothing for an absent file', async () => { + expect(await editor().lists(null, '/r/a')).toBe(false); + }); + + it('refuses an invalid file or a host it no longer lists', async () => { + const edits = editor(); + write({ theme: 'dark' }); + await expect(edits.lists(null, '/r/a')).rejects.toThrow('theme'); + write({ hosts: [] }); + await expect(edits.lists('box', '/r/a')).rejects.toThrow('box'); + }); +}); diff --git a/src/hub/config/editor.ts b/src/hub/config/editor.ts new file mode 100644 index 0000000..53e5ad9 --- /dev/null +++ b/src/hub/config/editor.ts @@ -0,0 +1,100 @@ +import { dirname } from 'node:path'; +import { fileIdentity, makePrivateDirs, readFileIfExists, writeFileAtomic } from '../../platform/files/index.js'; +import { ConfigError, expandHome, MAX_REPOS, parseConfig } from './config.js'; + +const ATTEMPTS = 3; + +/** The outcome of a repo edit: whether the file changed, and the host's repos afterwards, `~/` expanded. */ +export interface RepoEdit { + changed: boolean; + repos: string[]; +} + +export interface EditorHooks { + /** Runs after an attempt computed its result and before it checks the file and replaces it. */ + beforeReplace?: (attempt: number) => void | Promise; +} + +type JsonObject = Record; + +const isObject = (value: unknown): value is JsonObject => typeof value === 'object' && value !== null && !Array.isArray(value); + +const stringsOf = (value: unknown): string[] => (Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []); + +/** Edits the repos of one host in `config.json`, keeping everything else as written; one edit at a time. */ +export class ConfigEditor { + private queue: Promise = Promise.resolve(); + + constructor( + private readonly path: string, + private readonly home: string, + private readonly hooks: EditorHooks = {}, + ) {} + + /** Adds `repo` to the local host (`host` null) or the named remote host unless it is listed already. */ + addRepo(host: string | null, repo: string): Promise { + return this.edit(host, (repos) => (repos.some((entry) => this.names(host, entry, repo)) ? null : [...repos, repo])); + } + + /** Whether the host's repos name `repo`, as given or by a `~/` form; throws ConfigError for an unusable file. */ + async lists(host: string | null, repo: string): Promise { + const text = await readFileIfExists(this.path); + parseConfig(text, this.home); + const raw: unknown = text === null ? {} : JSON.parse(text); + if (!isObject(raw)) throw new ConfigError('the configuration is not an object'); + return stringsOf(this.ownerOf(raw, host)['repos']).some((entry) => this.names(host, entry, repo)); + } + + /** Removes every entry naming `repo`, as given or by a `~/` form, from the host's repos. */ + removeRepo(host: string | null, repo: string): Promise { + return this.edit(host, (repos) => { + const kept = repos.filter((entry) => !this.names(host, entry, repo)); + return kept.length === repos.length ? null : kept; + }); + } + + private names(host: string | null, entry: string, repo: string): boolean { + return entry === repo || (host === null && expandHome(entry, this.home) === repo); + } + + private expanded(host: string | null, repos: readonly string[]): string[] { + return host === null ? repos.map((repo) => expandHome(repo, this.home)) : [...repos]; + } + + private edit(host: string | null, change: (repos: string[]) => string[] | null): Promise { + const run = this.queue.then(() => this.attempt(host, change, 1)); + this.queue = run.catch(() => undefined); + return run; + } + + private async attempt(host: string | null, change: (repos: string[]) => string[] | null, attempt: number): Promise { + const identity = await fileIdentity(this.path); + const text = await readFileIfExists(this.path); + parseConfig(text, this.home); + const raw: unknown = text === null ? {} : JSON.parse(text); + if (!isObject(raw)) throw new ConfigError('the configuration is not an object'); + const owner = this.ownerOf(raw, host); + const current = stringsOf(owner['repos']); + const next = change(current); + if (next === null) return { changed: false, repos: this.expanded(host, current) }; + if (next.length > MAX_REPOS) throw new ConfigError(`${host ?? 'the local host'} would list more than ${String(MAX_REPOS)} repos`); + owner['repos'] = next; + await this.hooks.beforeReplace?.(attempt); + if ((await fileIdentity(this.path)) !== identity || (await readFileIfExists(this.path)) !== text) { + if (attempt >= ATTEMPTS) throw new ConfigError(`${this.path} kept changing while being edited`); + return this.attempt(host, change, attempt + 1); + } + if (text === null) await makePrivateDirs(dirname(this.path)); + await writeFileAtomic(this.path, `${JSON.stringify(raw, null, 2)}\n`); + return { changed: true, repos: this.expanded(host, next) }; + } + + /** The object holding the edited host's `repos`: the configuration itself, or the named host's entry. */ + private ownerOf(raw: JsonObject, host: string | null): JsonObject { + if (host === null) return raw; + const hosts: unknown = raw['hosts']; + const entry = Array.isArray(hosts) ? hosts.find((h): h is JsonObject => isObject(h) && h['name'] === host) : undefined; + if (entry === undefined) throw new ConfigError(`host ${host} is no longer configured`); + return entry; + } +} diff --git a/src/hub/config/index.ts b/src/hub/config/index.ts index ed55e43..93aba2a 100644 --- a/src/hub/config/index.ts +++ b/src/hub/config/index.ts @@ -1,111 +1,12 @@ -import { isAbsolute, join } from 'node:path'; -import { z } from 'zod'; -import { readFileIfExists } from '../../platform/files/index.js'; -import { presetSchema, type Preset } from '../../protocol/index.js'; - -export const DEFAULT_PORT = 7417; - -const MAX_REPOS = 256; -const MAX_PATH = 4096; -const MAX_PRESETS = 64; -const DEFAULT_PRESETS: readonly Preset[] = [{ name: 'shell', command: null }]; - -/** The hub configuration, `~/` already expanded. */ -export interface HubConfig { - port: number; - repos: string[]; - presets: Preset[]; -} - -/** An unusable configuration; the message is one line. */ -export class ConfigError extends Error { - override readonly name = 'ConfigError'; -} - -// eslint-disable-next-line no-control-regex -- NUL is the character paths must not contain -const NO_NUL = /^[^\u0000]*$/; - -const repoPath = z - .string() - .max(MAX_PATH) - .regex(NO_NUL, 'must not contain NUL') - .refine((p) => isAbsolute(p) || p.startsWith('~/'), 'must be absolute or start with ~/'); - -const configSchema = z.strictObject({ - port: z.int().min(1024).max(65535).optional(), - repos: z.array(repoPath).max(MAX_REPOS).optional(), - presets: z - .array(presetSchema) - .min(1) - .max(MAX_PRESETS) - .refine((list) => new Set(list.map((p) => p.name)).size === list.length, 'preset names must be unique') - .optional(), -}); - -const oneLine = (text: string): string => text.replace(/\s+/g, (run) => (run.includes('\n') ? ' ' : run)); - -const parseJson = (text: string): unknown => { - try { - return JSON.parse(text); - } catch (error) { - throw new ConfigError(`invalid JSON: ${oneLine(error instanceof Error ? error.message : String(error))}`, { cause: error }); - } -}; - -const expand = (path: string, home: string): string => { - if (!path.startsWith('~/')) return path; - const expanded = join(home, path.slice(2)); - if (expanded.length > MAX_PATH) throw new ConfigError(`repos: ${path} is longer than ${String(MAX_PATH)} characters once expanded`); - return expanded; -}; - -/** Parses `config.json` text (null for a missing file); throws ConfigError naming the first problem. */ -export const parseConfig = (text: string | null, home: string): HubConfig => { - if (text === null) return { port: DEFAULT_PORT, repos: [], presets: [...DEFAULT_PRESETS] }; - const result = configSchema.safeParse(parseJson(text)); - if (!result.success) { - const [issue] = result.error.issues; - const where = issue === undefined || issue.path.length === 0 ? '' : `${issue.path.map(String).join('.')}: `; - throw new ConfigError(oneLine(`${where}${issue?.message ?? 'invalid configuration'}`)); - } - return { - port: result.data.port ?? DEFAULT_PORT, - repos: (result.data.repos ?? []).map((repo) => expand(repo, home)), - presets: result.data.presets ?? [...DEFAULT_PRESETS], - }; -}; - -/** Reads and parses the configuration at `path`; throws ConfigError naming the file and the problem. */ -export const loadConfig = async (path: string, home: string): Promise => { - try { - return parseConfig(await readFileIfExists(path), home); - } catch (error) { - if (error instanceof ConfigError) throw new ConfigError(`${path}: ${error.message}`, { cause: error }); - throw new ConfigError(`${path}: ${oneLine(error instanceof Error ? error.message : String(error))}`, { cause: error }); - } -}; - -/** A configuration read for one session, with the problem of an invalid file. */ -export interface ConfigSnapshot { - config: HubConfig; - problem: string | null; -} - -/** Reads the configuration per session, falling back to the last valid one. */ -export class ConfigSource { - constructor( - private readonly path: string, - private readonly home: string, - private last: HubConfig, - ) {} - - async snapshot(): Promise { - try { - this.last = await loadConfig(this.path, this.home); - return { config: this.last, problem: null }; - } catch (error) { - if (!(error instanceof ConfigError)) throw error; - return { config: this.last, problem: error.message }; - } - } -} +export { + ConfigError, + ConfigSource, + DEFAULT_PORT, + expandHome, + loadConfig, + parseConfig, + type ConfigSnapshot, + type HubConfig, + type RemoteHostConfig, +} from './config.js'; +export { ConfigEditor, type EditorHooks, type RepoEdit } from './editor.js'; diff --git a/src/hub/links/index.ts b/src/hub/links/index.ts index a3b6173..0600e12 100644 --- a/src/hub/links/index.ts +++ b/src/hub/links/index.ts @@ -1,10 +1,16 @@ +import { spawn, type ChildProcessByStdio } from 'node:child_process'; import { connect, type Socket } from 'node:net'; -import { dial, spawnDetached } from '../../platform/dialer/index.js'; +import type { Readable, Writable } from 'node:stream'; +import { dial, spawnDetached, sshCommand, StderrTail } from '../../platform/dialer/index.js'; +import { installRemote, type InstalledRemote } from '../../platform/install/index.js'; import { ProtocolError, StreamDecoder, type Frame } from '../../protocol/index.js'; /** The host files a local link needs. */ export type DaemonPaths = Parameters[0]; +/** The remote command a remote link runs. */ +export const CONNECT_COMMAND = '"$HOME/.local/bin/wtd" connect'; + export interface LinkEvents { /** A frame from the daemon. */ frame: (frame: Frame) => void; @@ -135,6 +141,176 @@ class SocketLink implements Link { } } +/** How long a lost remote link waits for the rest of the SSH process's standard error. */ +const STDERR_GRACE_MS = 250; + +type SshProcess = ChildProcessByStdio; + +/** A link over an SSH process's standard input and output. */ +class ProcessLink implements Link { + private readonly child: SshProcess; + private readonly decoder = new StreamDecoder(); + private readonly tail = new StderrTail(); + private exit: string | null = null; + private stderrDone = false; + private grace: ReturnType | null = null; + private closed = false; + private finished = false; + + constructor( + private readonly events: LinkEvents, + command: readonly string[], + ) { + const [program = 'ssh', ...args] = command; + this.child = spawn(program, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + this.child.stdin.on('error', ignore); + this.child.stdout.on('data', (chunk: Buffer) => { + this.receive(chunk); + }); + this.child.stdout.once('end', () => { + this.lost(); + }); + 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()); + }); + this.child.once('error', (error) => { + this.exit ??= `cannot run ${program}: ${error.message}`; + this.finish(new Error(this.exit)); + }); + this.child.once('exit', (code, signal) => { + this.exit = code === null ? `${program} was killed by ${String(signal)}` : `${program} exited with status ${String(code)}`; + if (this.stderrDone) this.finish(this.cause()); + else this.lost(); + }); + } + + send(bytes: Uint8Array): void { + if (!this.closed) this.child.stdin.write(bytes); + } + + pause(): void { + this.child.stdout.pause(); + } + + resume(): void { + this.child.stdout.resume(); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.terminate(); + } + + /** 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(); + 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.running()) this.child.kill(signal); + }, ms).unref(); + } + + private running(): boolean { + return this.child.exitCode === null && this.child.signalCode === null; + } + + private receive(chunk: Uint8Array): void { + let frames: Frame[]; + try { + frames = this.decoder.push(chunk); + } catch (error) { + if (!(error instanceof ProtocolError)) throw error; + this.finish(error); + return; + } + for (const frame of frames) { + if (this.closed || this.finished) return; + this.events.frame(frame); + } + } + + /** The process exited or its output ended: finish once its standard error is read, or after a grace period. */ + private lost(): void { + this.grace ??= setTimeout(() => { + this.finish(this.cause()); + }, STDERR_GRACE_MS); + } + + private cause(): Error { + return new Error(this.tail.reason() ?? this.exit ?? 'the SSH process closed its output'); + } + + private finish(error: Error): void { + if (this.finished) return; + this.finished = true; + if (this.grace !== null) clearTimeout(this.grace); + this.terminate(); + if (!this.closed) this.events.ended(error); + } +} + +/** Links to a remote host's daemon by running `wtd connect` there over SSH. */ +export class RemoteDaemon { + constructor( + private readonly paths: DaemonPaths, + readonly alias: string, + ) {} + + /** Opens a link; `wtd connect` always starts a daemon when none runs, so there is no `start` option. */ + open(events: LinkEvents): Link { + let command: string[]; + try { + command = sshCommand(this.paths, this.alias, CONNECT_COMMAND); + } catch (error) { + return new FailedLink(events, error instanceof Error ? error : new Error(String(error))); + } + return new ProcessLink(events, command); + } +} + +/** A link that could not be opened; it ends on the next turn. */ +class FailedLink implements Link { + private closed = false; + + constructor(events: LinkEvents, error: Error) { + setImmediate(() => { + if (!this.closed) events.ended(error); + }); + } + + send(): void { + // Nothing to send to. + } + + pause(): void { + // Nothing to read. + } + + resume(): void { + // Nothing to read. + } + + close(): void { + this.closed = true; + } +} + +/** Something links to a daemon can be opened to. */ +export interface DaemonEndpoint { + open: (events: LinkEvents, options?: { start?: boolean }) => Link; +} + /** Links to this host's daemon over its unix socket. */ export class LocalDaemon { constructor( @@ -153,3 +329,12 @@ 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 bundle at `bundle` on the SSH host `alias`, finding its Node there. */ +export const installOnRemote = ( + paths: Parameters[0]['paths'], + alias: string, + bundle: string, + version: string, + timeoutMs: number, +): Promise => installRemote({ paths, alias, bundle, version, node: null, timeoutMs }); 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..e7a917b --- /dev/null +++ b/src/hub/links/tail.test.ts @@ -0,0 +1,81 @@ +import { once } from 'node:events'; +import { PassThrough } from 'node:stream'; +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'); + }); + + 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/hub/links/tail.ts b/src/hub/links/tail.ts new file mode 100644 index 0000000..40316e8 --- /dev/null +++ b/src/hub/links/tail.ts @@ -0,0 +1 @@ +export { StderrTail } from '../../platform/dialer/index.js'; diff --git a/src/hub/main/index.ts b/src/hub/main/index.ts index 8eecbd2..4cc7588 100644 --- a/src/hub/main/index.ts +++ b/src/hub/main/index.ts @@ -66,6 +66,7 @@ export const runHub = async ({ version, paths, wtd, webDir, home }: HubRun): Pro version, instance, hostName: hostname(), + bundle: wtd[1] ?? '', report: (error) => { console.error(error); }, diff --git a/src/hub/router/coordinator.test.ts b/src/hub/router/coordinator.test.ts new file mode 100644 index 0000000..ba30b89 --- /dev/null +++ b/src/hub/router/coordinator.test.ts @@ -0,0 +1,183 @@ +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(); + }); + + it('gives the running or queued reinstall to share, and none otherwise', async () => { + const { coordinator, settle } = setup(); + expect(coordinator.reinstalling()).toBeNull(); + const restart = coordinator.restart(); + await tick(); + expect(coordinator.reinstalling()).toBeNull(); + const queued = coordinator.reinstall(); + expect(coordinator.reinstalling()).toBe(queued); + settle('restart'); + await restart; + await tick(); + expect(coordinator.reinstalling()).not.toBeNull(); + settle('reinstall'); + await queued; + expect(coordinator.reinstalling()).toBeNull(); + }); +}); diff --git a/src/hub/router/coordinator.ts b/src/hub/router/coordinator.ts new file mode 100644 index 0000000..70225a1 --- /dev/null +++ b/src/hub/router/coordinator.ts @@ -0,0 +1,65 @@ +type Kind = 'restart' | 'reinstall'; + +interface Run { + kind: Kind; + promise: Promise; +} + +/** One host's restart and reinstall operations. */ +export interface HostOperations { + restart: () => Promise; + reinstall: () => Promise; +} + +/** + * Runs one host's restarts and reinstalls one at a time, across sessions: identical requests share + * a run, a restart shares a running or queued reinstall, and other requests wait for the running one. + */ +export class HostCoordinator { + private running: Run | null = null; + private queued: Run | null = null; + + constructor(private readonly operations: HostOperations) {} + + restart(): Promise { + return this.request('restart'); + } + + /** The running or queued reinstall, which a restart shares whatever the host's status; null without one. */ + reinstalling(): Promise | null { + return [this.running, this.queued].find((run) => run?.kind === 'reinstall')?.promise ?? null; + } + + reinstall(): Promise { + return this.request('reinstall'); + } + + private request(kind: Kind): Promise { + const shared = [this.running, this.queued].find((run) => run !== null && (run.kind === kind || run.kind === 'reinstall')); + if (shared !== undefined && shared !== null) return shared.promise; + if (this.running === null) return this.start(kind).promise; + const before = this.running.promise; + const queued: Run = { + kind, + promise: before + .catch(() => undefined) + .then(() => { + this.queued = null; + return this.start(kind).promise; + }), + }; + this.queued = queued; + return queued.promise; + } + + private start(kind: Kind): Run { + const run: Run = { + kind, + promise: this.operations[kind]().finally(() => { + if (this.running === run) this.running = null; + }), + }; + this.running = run; + return run; + } +} 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/hub/router/hosts.ts b/src/hub/router/hosts.ts index 2179ade..1d94d1b 100644 --- a/src/hub/router/hosts.ts +++ b/src/hub/router/hosts.ts @@ -3,29 +3,37 @@ import { PROTOCOL_VERSION, type HostEntry } from '../../protocol/index.js'; /** The status of one host as one session sees it. */ export interface HostState { status: HostEntry['status']; + /** The cause of the latest failure while reconnecting or down. */ + reason: string | null; daemonVersion: string | null; instance: string | null; /** Consecutive failed dials or lost links. */ failures: number; } -export type HostEvent = { kind: 'hello'; protocol: number; version: string; instance: string } | { kind: 'failed' }; +export type HostEvent = { kind: 'hello'; protocol: number; version: string; instance: string } | { kind: 'failed'; reason: string }; const DOWN_AFTER = 3; const FIRST_RETRY_MS = 250; const MAX_RETRY_MS = 5000; const MAX_NAME = 64; +const MAX_REASON = 1024; -export const initialHostState: HostState = { status: 'connecting', daemonVersion: null, instance: null, failures: 0 }; +export const initialHostState: HostState = { status: 'connecting', reason: null, daemonVersion: null, instance: null, failures: 0 }; export const hostTransition = (state: HostState, event: HostEvent): HostState => { if (event.kind === 'hello') { - return event.protocol === PROTOCOL_VERSION - ? { status: 'connected', daemonVersion: event.version, instance: event.instance, failures: 0 } - : { status: 'outdated', daemonVersion: event.version, instance: null, failures: 0 }; + const status = event.protocol === PROTOCOL_VERSION ? 'connected' : 'outdated'; + return { status, reason: null, daemonVersion: event.version, instance: event.instance, failures: 0 }; } const failures = state.failures + 1; - return { status: failures >= DOWN_AFTER ? 'down' : 'reconnecting', daemonVersion: null, instance: null, failures }; + return { + status: failures >= DOWN_AFTER ? 'down' : 'reconnecting', + reason: event.reason.slice(0, MAX_REASON), + daemonVersion: null, + instance: null, + failures, + }; }; /** Delay before the next dial after `failures` consecutive failures. */ diff --git a/src/hub/router/index.ts b/src/hub/router/index.ts index 4b8d33b..640e8fa 100644 --- a/src/hub/router/index.ts +++ b/src/hub/router/index.ts @@ -1,9 +1,10 @@ import { PROTOCOL_VERSION, type DataFrame, type MessageOf } from '../../protocol/index.js'; -import { ConfigSource, type ConfigSnapshot, type HubConfig } from '../config/index.js'; -import { LocalDaemon, type DaemonPaths } from '../links/index.js'; +import { ConfigEditor, ConfigSource, type ConfigSnapshot, type HubConfig } from '../config/index.js'; +import { installOnRemote, LocalDaemon, RemoteDaemon, type DaemonEndpoint, type DaemonPaths } from '../links/index.js'; +import { HostCoordinator } from './coordinator.js'; import { localHostName } from './hosts.js'; import { DaemonRestarter } from './restart.js'; -import { Session, type BrowserChannel, type SessionContext } from './session.js'; +import { Session, type BrowserChannel, type SessionContext, type SessionHost } from './session.js'; export { hubError, type BrowserChannel } from './session.js'; @@ -23,6 +24,8 @@ export interface RouterOptions { instance: string; /** This host's name as the OS reports it. */ hostName: string; + /** The running `wtd.mjs`, installed on remote hosts by `reinstallDaemon`. */ + bundle: string; /** Reports an unexpected failure. */ report: (error: unknown) => void; } @@ -37,23 +40,84 @@ export interface RoutedSession { close: () => void; } +/** How long a reinstall's installation step may take. */ +const INSTALL_MS = 120_000; + +const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + /** Routes every browser session to its own daemon links. */ export class Router { private readonly context: SessionContext; private readonly config: ConfigSource; + private readonly editor: ConfigEditor; + private readonly local: LocalDaemon; + private readonly hello: SessionContext['hello']; + private readonly sessions = new Set(); + private readonly coordinators = new Map(); constructor(private readonly options: RouterOptions) { - const hello = { t: 'hello', protocol: PROTOCOL_VERSION, version: options.version, instance: options.instance } as const; - const daemon = new LocalDaemon(options.paths, options.daemonCommand); + this.hello = { t: 'hello', protocol: PROTOCOL_VERSION, version: options.version, instance: options.instance }; + this.local = new LocalDaemon(options.paths, options.daemonCommand); this.config = new ConfigSource(options.configPath, options.home, options.config); + this.editor = new ConfigEditor(options.configPath, options.home); this.context = { - daemon, - restarter: new DaemonRestarter(daemon, hello), - hello, - hostName: localHostName(options.hostName), + hello: this.hello, + endpoint: (host) => this.endpoint(host), + coordinator: (host) => this.coordinator(host), + listsRepo: (host, repo) => this.editor.lists(host.ssh === null ? null : host.name, repo), + editRepos: (host, change, repo) => { + const name = host.ssh === null ? null : host.name; + return change === 'add' ? this.editor.addRepo(name, repo) : this.editor.removeRepo(name, repo); + }, + reposEdited: (host, repos) => { + for (const session of this.sessions) session.showRepos(host, repos); + }, }; } + /** The hosts of a session reading `config`: the local host, then the remote hosts in order. */ + private hostsOf(config: HubConfig): SessionHost[] { + const local: SessionHost = { + idx: 0, + name: localHostName(this.options.hostName), + ssh: null, + repos: [...config.repos], + roots: config.roots, + }; + return [ + local, + ...config.hosts.map((host, i) => ({ idx: i + 1, name: host.name, ssh: host.ssh, repos: [...host.repos], roots: host.roots })), + ]; + } + + private endpoint(host: SessionHost): DaemonEndpoint { + return host.ssh === null ? this.local : new RemoteDaemon(this.options.paths, host.ssh); + } + + private coordinator(host: SessionHost): HostCoordinator { + const key = host.ssh === null ? 'local' : `remote:${host.name}:${host.ssh}`; + let coordinator = this.coordinators.get(key); + if (coordinator === undefined) { + const restarter = new DaemonRestarter(this.endpoint(host), this.hello); + const alias = host.ssh; + coordinator = new HostCoordinator({ + restart: () => restarter.restart(), + reinstall: async () => { + if (alias === null) throw new Error('the local daemon is restarted, not reinstalled'); + const { paths, bundle, version } = this.options; + await installOnRemote(paths, alias, bundle, version, INSTALL_MS).catch((error: unknown) => { + throw new Error(`install: ${messageOf(error)}`, { cause: error }); + }); + await restarter.replaceOther(version).catch((error: unknown) => { + throw new Error(`restart: ${messageOf(error)}`, { cause: error }); + }); + }, + }); + this.coordinators.set(key, coordinator); + } + return coordinator; + } + /** Starts a session for `channel`, reading its configuration snapshot now. */ open(channel: BrowserChannel): RoutedSession { let session: Session | null = null; @@ -63,8 +127,9 @@ export class Router { const pending: ((s: Session) => void)[] = []; const start = (): void => { if (closed || snapshot === null || !greeted || session !== null) return; - const started = new Session(channel, this.context, snapshot); + const started = new Session(channel, this.context, snapshot, this.hostsOf(snapshot.config)); session = started; + this.sessions.add(started); started.start(); for (const action of pending.splice(0)) action(started); }; @@ -100,7 +165,10 @@ export class Router { close: () => { closed = true; pending.length = 0; - session?.close(); + if (session !== null) { + this.sessions.delete(session); + session.close(); + } }, }; } diff --git a/src/hub/router/oneshot.ts b/src/hub/router/oneshot.ts new file mode 100644 index 0000000..a6d3b36 --- /dev/null +++ b/src/hub/router/oneshot.ts @@ -0,0 +1,93 @@ +import { decodeMessage, FrameKind, PROTOCOL_VERSION, ProtocolError, type Frame, type MessageOf } from '../../protocol/index.js'; +import type { DaemonEndpoint, Link } from '../links/index.js'; +import { controlFrame } from './restart.js'; + +type DaemonMessage = MessageOf<'daemonToClient'>; +type ErrorCode = Extract['code']; +type Hello = Extract, { t: 'hello' }>; +type Request = Extract, { t: 'watchRepo' | 'discoverRepos' }>; + +/** The reply to a one-shot request and the daemon's messages before it. */ +export interface OneShotReply { + reply: Exclude; + before: DaemonMessage[]; +} + +/** A one-shot request failed with `code`. */ +export class OneShotError extends Error { + override readonly name = 'OneShotError'; + + constructor( + readonly code: ErrorCode, + message: string, + ) { + super(message); + } +} + +const REPLY_MS = 30_000; + +const decode = (frame: Frame): DaemonMessage | null => { + if (frame.kind !== FrameKind.control) return null; + try { + return decodeMessage('daemonToClient', frame.payload); + } catch (error) { + if (error instanceof ProtocolError) return null; + throw error; + } +}; + +/** + * Sends `request` over a link of the hub's own to `daemon` and resolves with the reply to it, + * closing the link afterwards; rejects with the daemon's error code, or `host-unavailable`. + */ +export const requestOnce = (daemon: DaemonEndpoint, hello: Hello, request: Request): Promise => + new Promise((resolve, reject) => { + const before: DaemonMessage[] = []; + let greeted = false; + let settled = false; + let link: Link | null = null; + const settle = (action: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + link?.close(); + action(); + }; + const fail = (code: ErrorCode, message: string): void => { + settle(() => { + reject(new OneShotError(code, message)); + }); + }; + const timer = setTimeout(() => { + fail('internal', `the daemon did not answer within ${String(REPLY_MS / 1000)} s`); + }, REPLY_MS); + link = daemon.open({ + frame: (frame) => { + const message = decode(frame); + if (!greeted) { + greeted = true; + if (message?.t !== 'hello') { + fail('internal', 'the daemon did not greet'); + } else if (message.protocol === PROTOCOL_VERSION) { + link?.send(controlFrame(hello)); + link?.send(controlFrame(request)); + } else { + fail('version-mismatch', `the daemon speaks protocol ${String(message.protocol)}`); + } + return; + } + if (message === null) return; + if (!('req' in message) || message.req !== request.req) before.push(message); + else if (message.t === 'error') fail(message.code, message.message); + else { + settle(() => { + resolve({ reply: message, before }); + }); + } + }, + ended: (error) => { + fail('host-unavailable', error?.message ?? 'the link to the daemon ended'); + }, + }); + }); diff --git a/src/hub/router/restart.ts b/src/hub/router/restart.ts index 8bbead3..c6c74ad 100644 --- a/src/hub/router/restart.ts +++ b/src/hub/router/restart.ts @@ -8,7 +8,7 @@ import { type Frame, type MessageOf, } from '../../protocol/index.js'; -import type { Link, LocalDaemon } from '../links/index.js'; +import type { DaemonEndpoint, Link } from '../links/index.js'; type Hello = Extract, { t: 'hello' }>; @@ -35,45 +35,54 @@ const helloOf = (frame: Frame): Hello | null => { } }; -/** Restarts the local daemon; concurrent requests share one restart. */ -export class DaemonRestarter { - private running: Promise | null = null; +/** Whether to send a daemon answering a probe `shutdown`. */ +type ShutdownIf = (hello: Hello) => boolean; + +const always: ShutdownIf = () => true; +const never: ShutdownIf = () => false; +/** Replaces the daemon of one host; only the frozen `hello` of a daemon being replaced is decoded. */ +export class DaemonRestarter { constructor( - private readonly daemon: LocalDaemon, + private readonly daemon: DaemonEndpoint, /** Our hello to a daemon. */ private readonly hello: Extract, { t: 'hello' }>, ) {} /** Resolves once a new daemon instance of our protocol serves; rejects after 10 s. */ - restart(): Promise { - this.running ??= this.run().finally(() => { - this.running = null; - }); - return this.running; - } - - private async run(): Promise { + async restart(): Promise { const deadline = Date.now() + RESTART_MS; - const old = await this.probe({ start: false, shutdown: true }, deadline); + const old = await this.probe({ start: false, shutdown: always }, deadline); if (old !== null) { for (;;) { - const current = await this.probe({ start: false, shutdown: false }, deadline); // NOSONAR(S9382) — polls until the old daemon is gone + const current = await this.probe({ start: false, shutdown: never }, deadline); // NOSONAR(S9382) — polls until the old daemon is gone if (current?.instance !== old.instance) break; if (Date.now() >= deadline) throw new Error('the daemon did not exit within 10 s'); await sleep(POLL_MS); // NOSONAR(S9382) — polling loop } } for (;;) { - const fresh = await this.probe({ start: true, shutdown: false }, deadline); // NOSONAR(S9382) — polls until a new daemon serves + const fresh = await this.probe({ start: true, shutdown: never }, deadline); // NOSONAR(S9382) — polls until a new daemon serves if (fresh !== null && fresh.instance !== old?.instance && fresh.protocol === PROTOCOL_VERSION) return; if (Date.now() >= deadline) throw new Error('no new daemon served within 10 s'); await sleep(POLL_MS); // NOSONAR(S9382) — polling loop } } - /** The hello of the daemon serving the socket (sending it `shutdown` if asked), or null when none answers in time. */ - private probe({ start, shutdown }: { start: boolean; shutdown: boolean }, deadline: number): Promise { + /** Dials until a daemon of `version` and our protocol answers, shutting down any other; rejects after 10 s. */ + async replaceOther(version: string): Promise { + const deadline = Date.now() + RESTART_MS; + const other: ShutdownIf = (hello) => hello.version !== version || hello.protocol !== PROTOCOL_VERSION; + for (;;) { + const hello = await this.probe({ start: true, shutdown: other }, deadline); // NOSONAR(S9382) — polls until the new daemon serves + if (hello !== null && !other(hello)) return; + if (Date.now() >= deadline) throw new Error(`no daemon of version ${version} served within 10 s`); + await sleep(POLL_MS); // NOSONAR(S9382) — polling loop + } + } + + /** The hello of the daemon answering (sending it `shutdown` if asked), or null when none answers in time. */ + private probe({ start, shutdown }: { start: boolean; shutdown: ShutdownIf }, deadline: number): Promise { return new Promise((resolve) => { let link: Link | null = null; const done = (hello: Hello | null): void => { @@ -91,7 +100,7 @@ export class DaemonRestarter { { frame: (frame) => { const hello = helloOf(frame); - if (hello !== null && shutdown) { + if (hello !== null && shutdown(hello)) { link?.send(controlFrame(this.hello)); link?.send(controlFrame({ t: 'shutdown' })); } diff --git a/src/hub/router/session.ts b/src/hub/router/session.ts index 187ffe4..470019a 100644 --- a/src/hub/router/session.ts +++ b/src/hub/router/session.ts @@ -10,10 +10,12 @@ import { type HostEntry, type MessageOf, } from '../../protocol/index.js'; -import type { ConfigSnapshot } from '../config/index.js'; -import type { Link, LocalDaemon } from '../links/index.js'; +import { ConfigError, type ConfigSnapshot, type RepoEdit } from '../config/index.js'; +import type { DaemonEndpoint, Link } from '../links/index.js'; +import type { HostCoordinator } from './coordinator.js'; import { hostTransition, initialHostState, retryDelay, type HostEvent, type HostState } from './hosts.js'; -import { controlFrame, type DaemonRestarter } from './restart.js'; +import { OneShotError, requestOnce } from './oneshot.js'; +import { controlFrame } from './restart.js'; type BrowserMessage = MessageOf<'browserToHub'>; type HubMessage = MessageOf<'hubToBrowser'>; @@ -27,15 +29,36 @@ export interface BrowserChannel { unsent: () => number; } +/** One host of a session's configuration snapshot. */ +export interface SessionHost { + idx: number; + name: string; + /** The SSH alias of a remote host; null for the local host. */ + ssh: string | null; + repos: string[]; + roots: string[]; +} + /** What a session shares with the rest of the hub. */ export interface SessionContext { - daemon: LocalDaemon; - restarter: DaemonRestarter; /** Our hello to a daemon. */ hello: Extract, { t: 'hello' }>; - hostName: string; + endpoint: (host: SessionHost) => DaemonEndpoint; + /** The coordinator of the host's restarts and reinstalls, shared by every session. */ + coordinator: (host: SessionHost) => HostCoordinator; + /** Whether the configuration lists `repo` for `host`. */ + listsRepo: (host: SessionHost, repo: string) => Promise; + /** Adds or removes a repo of `host` in the configuration. */ + editRepos: (host: SessionHost, change: 'add' | 'remove', repo: string) => Promise; + /** Shows the host's repos after an edit to every session listing it. */ + reposEdited: (host: SessionHost, repos: string[]) => void; } +/** Depth of hub-level repo discovery. */ +const DISCOVERY_DEPTH = 3; +/** Request id of the hub's one-shot requests, each on a link of its own. */ +const ONE_SHOT_REQ = 1; + const KiB = 1024; /** Unsent bytes at which the session's links stop being read. */ const PAUSE_AT = 1024 * KiB; @@ -64,16 +87,21 @@ class HostLink { private closed = false; constructor( - readonly idx: number, + readonly host: SessionHost, private readonly session: Session, - private readonly context: SessionContext, + readonly daemon: DaemonEndpoint, + private readonly hello: SessionContext['hello'], ) {} + get idx(): number { + return this.host.idx; + } + connect(): void { if (this.closed) return; this.retry = null; let greeted = false; - const link = this.context.daemon.open({ + const link = this.daemon.open({ frame: (frame) => { if (this.link !== link) return; if (!greeted) { @@ -81,8 +109,8 @@ class HostLink { this.greet(frame); } else if (this.state.status === 'connected') this.relay(frame); }, - ended: () => { - if (this.link === link) this.lost(); + ended: (error) => { + if (this.link === link) this.lost(error?.message ?? 'the link to the daemon ended'); }, }); this.link = link; @@ -113,10 +141,10 @@ class HostLink { private greet(frame: Frame): void { const hello = this.decode(frame); if (hello?.t !== 'hello') { - this.fail(); + this.fail('the daemon did not greet'); return; } - this.link?.send(controlFrame(this.context.hello)); + this.link?.send(controlFrame(this.hello)); this.transition({ kind: 'hello', protocol: hello.protocol, version: hello.version, instance: hello.instance }); } @@ -124,14 +152,14 @@ class HostLink { if (frame.kind === FrameKind.control) { const message = this.decode(frame); if (message === null || message.t === 'hello') { - this.fail(); + this.fail('the daemon sent an invalid message'); return; } this.session.forwardText(this.idx, utf8.decode(frame.payload)); return; } if (frame.kind === FrameKind.input || !this.validData(frame)) { - this.fail(); + this.fail('the daemon sent an invalid data frame'); return; } const message = new Uint8Array(WS_HEADER + frame.payload.length); @@ -162,15 +190,15 @@ class HostLink { } /** The daemon broke the protocol: drop the link and reconnect. */ - private fail(): void { + private fail(reason: string): void { this.link?.close(); - this.lost(); + this.lost(reason); } - private lost(): void { + private lost(reason: string): void { this.link = null; if (this.closed) return; - this.transition({ kind: 'failed' }); + this.transition({ kind: 'failed', reason }); this.retry = setTimeout(() => { this.connect(); }, retryDelay(this.state.failures)); @@ -192,8 +220,9 @@ export class Session { private readonly channel: BrowserChannel, private readonly context: SessionContext, private readonly snapshot: ConfigSnapshot, + hosts: readonly SessionHost[], ) { - this.hosts = [new HostLink(0, this, context)]; + this.hosts = hosts.map((host) => new HostLink(host, this, context.endpoint(host), context.hello)); } /** Sends the configuration problem, if any, the presets and the hosts, then connects every host. */ @@ -214,15 +243,29 @@ export class Session { case 'restartDaemon': this.restart(message.req, message.host); return; + case 'reinstallDaemon': + this.reinstall(message.req, message.host); + return; + case 'discoverRepos': + this.discover(message.req, message.host); + return; case 'addRepo': + this.addRepo(message.req, message.host, message.repo); + return; case 'removeRepo': - case 'discoverRepos': - case 'reinstallDaemon': - this.error(message.req, message.host, 'internal', `${message.t} is not implemented`); + this.removeRepo(message.req, message.host, message.repo); return; } } + /** Replaces the repos of the session's matching host after an edit and sends `hosts`. */ + showRepos(host: SessionHost, repos: string[]): void { + const target = this.hosts.find((h) => (host.ssh === null ? h.host.ssh === null : h.host.ssh !== null && h.host.name === host.name)); + if (target === undefined || this.closed) return; + target.host.repos = repos; + this.hostsChanged(); + } + /** Routes browser input for a terminal of `host`. */ input(host: number, frame: DataFrame): void { if (this.closed) return; @@ -258,14 +301,15 @@ export class Session { } hostsChanged(): void { - const hosts: HostEntry[] = this.hosts.map((host) => ({ + const hosts: HostEntry[] = this.hosts.map(({ host, state }) => ({ idx: host.idx, - name: this.context.hostName, - remote: false, - status: host.state.status, - daemonVersion: host.state.daemonVersion, - instance: host.state.instance, - repos: this.snapshot.config.repos, + name: host.name, + remote: host.ssh !== null, + status: state.status, + reason: state.reason, + daemonVersion: state.daemonVersion, + instance: state.instance, + repos: host.repos, })); this.message({ t: 'hosts', hosts }); } @@ -284,18 +328,102 @@ export class Session { } } + /** The connected host `idx`, or null after answering as routing does. */ + private connectedHost(req: number, idx: number): HostLink | null { + const host = this.hosts[idx]; + if (host === undefined) { + this.error(req, idx, 'unknown-host', `no host ${String(idx)}`); + return null; + } + const { status } = host.state; + if (status === 'connected') return host; + this.error(req, idx, status === 'outdated' ? 'version-mismatch' : 'host-unavailable', `host ${String(idx)} is ${status}`); + return null; + } + private restart(req: number, idx: number): void { const host = this.hosts[idx]; if (host === undefined) { this.error(req, idx, 'unknown-host', `no host ${String(idx)}`); return; } + const reinstall = this.context.coordinator(host.host).reinstalling(); + if (reinstall !== null) { + this.reply(req, idx, reinstall); + return; + } const { status } = host.state; if (status !== 'connected' && status !== 'outdated') { this.error(req, idx, 'host-unavailable', `host ${String(idx)} is ${status}`); return; } - this.context.restarter.restart().then( + this.reply(req, idx, this.context.coordinator(host.host).restart()); + } + + private reinstall(req: number, idx: number): void { + const host = this.hosts[idx]; + if (host === undefined) this.error(req, idx, 'unknown-host', `no host ${String(idx)}`); + else if (host.host.ssh === null) this.error(req, idx, 'internal', 'the local daemon is restarted, not reinstalled'); + else this.reply(req, idx, this.context.coordinator(host.host).reinstall()); + } + + private discover(req: number, idx: number): void { + const host = this.connectedHost(req, idx); + if (host === null) return; + const request = { t: 'discoverRepos', req: ONE_SHOT_REQ, roots: host.host.roots, depth: DISCOVERY_DEPTH } as const; + this.oneShot(req, idx, async () => { + const { reply } = await requestOnce(host.daemon, this.context.hello, request); + if (reply.t !== 'reposDiscovered') throw new OneShotError('internal', `unexpected reply ${reply.t}`); + this.message({ t: 'reposDiscovered', req, host: idx, repos: reply.repos }); + }); + } + + private addRepo(req: number, idx: number, repo: string): void { + const host = this.connectedHost(req, idx); + if (host === null) return; + this.oneShot(req, idx, async () => { + if (!(await this.context.listsRepo(host.host, repo))) { + await requestOnce(host.daemon, this.context.hello, { t: 'watchRepo', req: ONE_SHOT_REQ, repo }); + } + this.edited(req, host.host, await this.context.editRepos(host.host, 'add', repo)); + }); + } + + private removeRepo(req: number, idx: number, repo: string): void { + const host = this.connectedHost(req, idx); + if (host === null) return; + this.oneShot(req, idx, async () => { + try { + const { before } = await requestOnce(host.daemon, this.context.hello, { t: 'watchRepo', req: ONE_SHOT_REQ, repo }); + const state = before.find((m) => m.t === 'repoState' && m.repo === repo); + if (state?.t === 'repoState' && state.terminals.length > 0) { + throw new OneShotError('busy', `${repo} has terminals; close them first`); + } + } catch (error) { + if (!(error instanceof OneShotError) || error.code !== 'not-a-repo') throw error; + } + this.edited(req, host.host, await this.context.editRepos(host.host, 'remove', repo)); + }); + } + + /** Answers `done` for an edit, then shows its repos to every session listing the host. */ + private edited(req: number, host: SessionHost, edit: RepoEdit): void { + this.message({ t: 'done', req }); + if (edit.changed) this.context.reposEdited(host, edit.repos); + } + + /** Runs a hub-level request, answering its failure with the daemon's code or `internal`. */ + private oneShot(req: number, idx: number, run: () => Promise): void { + run().catch((error: unknown) => { + if (error instanceof OneShotError) this.error(req, idx, error.code, error.message); + else if (error instanceof ConfigError) this.error(req, idx, 'internal', `cannot edit the configuration: ${error.message}`); + else this.error(req, idx, 'internal', error instanceof Error ? error.message : String(error)); + }); + } + + /** Answers `done` when `operation` succeeds, else `internal` naming its failure. */ + private reply(req: number, idx: number, operation: Promise): void { + operation.then( () => { this.message({ t: 'done', req }); }, 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/dialer/index.ts b/src/platform/dialer/index.ts index e432e1e..7bcd13e 100644 --- a/src/platform/dialer/index.ts +++ b/src/platform/dialer/index.ts @@ -1,6 +1,6 @@ -import { spawn } from 'node:child_process'; +import { spawn, type ChildProcess } from 'node:child_process'; import { connect, type Socket } from 'node:net'; -import { openLog, preparePrivateDirs, type HostPaths } from '../files/index.js'; +import { openLog, pathExists, preparePrivateDirs, sshControlPath, UNIT_NAME, type HostPaths } from '../files/index.js'; const RETRY_MS = 50; const START_WAIT_MS = 5000; @@ -47,6 +47,53 @@ export const spawnDetached = async (command: readonly string[], log: string | nu } }; +const nonEmpty = (value: string | undefined): string | undefined => (value === undefined || value === '' ? undefined : value); + +/** The systemctl program: `$WTD_SYSTEMCTL` when set and non-empty, else `systemctl`. */ +export const systemctlProgram = (): string => nonEmpty(process.env['WTD_SYSTEMCTL']) ?? 'systemctl'; + +/** Whether `command` ran and exited 0; its output is discarded. */ +const succeeds = (command: readonly string[]): Promise => + new Promise((resolve) => { + const [file, ...args] = command; + if (file === undefined) { + resolve(false); + return; + } + const child = spawn(file, args, { stdio: 'ignore' }); + child.once('error', () => { + resolve(false); + }); + child.once('exit', (code) => { + resolve(code === 0); + }); + }); + +/** Starts the daemon: through its systemd user unit when installed and that works, else `command` detached. */ +const startDaemon = async (paths: HostPaths, command: readonly string[]): Promise => { + if ((await pathExists(paths.unitFile)) && (await succeeds([systemctlProgram(), '--user', 'start', UNIT_NAME]))) return; + await spawnDetached(command, paths.log); +}; + +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-\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 => { + if (alias === '' || alias.length > MAX_ALIAS || alias.startsWith('-') || BAD_ALIAS_CHARACTER.test(alias)) { + throw new Error(`invalid SSH alias '${alias}'`); + } +}; + +/** The SSH command line running `remote` on `alias`, sharing a control master per host. */ +export const sshCommand = (paths: Pick, alias: string, remote: string): string[] => { + checkSshAlias(alias); + const options = [...SSH_OPTIONS, `ControlPath=${sshControlPath(paths)}`, 'ServerAliveInterval=15'].flatMap((o) => ['-o', o]); + return [nonEmpty(process.env['WTD_SSH']) ?? 'ssh', ...options, '--', alias, remote]; +}; + const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); /** The connected socket, or the error when no daemon serves `path`; other failures throw. */ @@ -59,13 +106,13 @@ const connectIfServed = async (path: string): Promise => { } }; -/** Connects to this host's daemon, starting `command` first when no daemon serves the socket. */ +/** Connects to this host's daemon, starting it first when no daemon serves the socket. */ export const dial = async (paths: HostPaths, command: readonly string[]): Promise => { const running = await connectIfServed(paths.socket); if (!(running instanceof Error)) return running; await preparePrivateDirs(paths); try { - await spawnDetached(command, paths.log); + await startDaemon(paths, command); } catch (error) { throw new Error(`daemon did not start; see ${paths.log} (${error instanceof Error ? error.message : String(error)})`, { cause: error }); } @@ -79,3 +126,15 @@ 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 new file mode 100644 index 0000000..216a9d9 --- /dev/null +++ b/src/platform/dialer/tail.ts @@ -0,0 +1,54 @@ +import type { Readable } from 'node:stream'; + +const MAX_TAIL = 4096; +const MAX_REASON = 1024; +/** 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 +const CONTROL = /[\u0000-\u0009\u000b-\u001f\u007f]/g; + +/** The last 4 KiB a process wrote to standard error, giving its last non-empty line. */ +export class StderrTail { + private bytes = new Uint8Array(0); + + get size(): number { + return this.bytes.length; + } + + /** + * 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) return; + stream.destroy(); + full?.(); + }); + } + + push(chunk: Uint8Array): void { + if (chunk.length >= MAX_TAIL) { + this.bytes = chunk.slice(chunk.length - MAX_TAIL); + return; + } + const keep = Math.min(this.bytes.length, MAX_TAIL - chunk.length); + const next = new Uint8Array(keep + chunk.length); + next.set(this.bytes.subarray(this.bytes.length - keep), 0); + next.set(chunk, keep); + this.bytes = next; + } + + /** The last non-empty line without control characters, cut to 1024 characters; null when there is none. */ + reason(): string | null { + const text = new TextDecoder().decode(this.bytes); + const lines = text.split('\n').map((line) => line.replace(CONTROL, '').trim()); + const last = lines.findLast((line) => line !== ''); + return last === undefined ? null : last.slice(0, MAX_REASON); + } +} diff --git a/src/platform/files/files.test.ts b/src/platform/files/files.test.ts index cdae772..f2f797f 100644 --- a/src/platform/files/files.test.ts +++ b/src/platform/files/files.test.ts @@ -1,5 +1,6 @@ import { spawnSync } from 'node:child_process'; import { + chmodSync, mkdirSync, mkdtempSync, readdirSync, @@ -24,13 +25,17 @@ import { fileIdentity, hostPaths, makeOwnerOnly, + makePrivateDirs, openLog, + ownedEntry, + pathExists, preparePrivateDirs, readFileIfExists, readPrivateFile, readTree, removeFile, renameFile, + sshControlPath, writeFileAtomic, } from './index.js'; @@ -169,6 +174,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 +182,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 +190,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 +238,31 @@ 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); + const paths = pathsUnder(stateHome); + expect(() => sshControlPath(paths)).toThrow(path); + }); +}); + describe('current host paths', () => { it('derives the paths from the environment, home and host name', () => { const paths = currentHostPaths(); @@ -382,6 +416,73 @@ describe('file helpers', () => { }); }); +describe('private directories and existence', () => { + it('creates missing directories owner-only, leaving an existing parent as it is', async () => { + mkdirSync(join(dir, 'open'), { mode: 0o755 }); + chmodSync(join(dir, 'open'), 0o755); + await makePrivateDirs(join(dir, 'open', 'a', 'b')); + expect(statSync(join(dir, 'open')).mode & 0o777).toBe(0o755); + expect(statSync(join(dir, 'open', 'a')).mode & 0o777).toBe(0o700); + expect(statSync(join(dir, 'open', 'a', 'b')).mode & 0o777).toBe(0o700); + await makePrivateDirs(join(dir, 'open', 'a', 'b')); + }); + + it('tells whether a path exists, following symbolic links', async () => { + writeFileSync(join(dir, 'f'), ''); + symlinkSync(join(dir, 'f'), join(dir, 'link')); + symlinkSync(join(dir, 'missing'), join(dir, 'dangling')); + expect(await pathExists(join(dir, 'f'))).toBe(true); + expect(await pathExists(join(dir, 'link'))).toBe(true); + expect(await pathExists(join(dir, 'dangling'))).toBe(false); + expect(await pathExists(join(dir, 'f', 'below'))).toBe(false); + }); +}); + +describe('atomic writes with a mode', () => { + it('writes an owner-only file with the given owner bits', async () => { + await writeFileAtomic(join(dir, 'run'), '#!/bin/sh\n', 0o755); + expect(statSync(join(dir, 'run')).mode & 0o777).toBe(0o700); + }); +}); + +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/files/index.ts b/src/platform/files/index.ts index 1453f1c..4defb9f 100644 --- a/src/platform/files/index.ts +++ b/src/platform/files/index.ts @@ -1,5 +1,5 @@ import { randomBytes } from 'node:crypto'; -import { constants } from 'node:fs'; +import { constants, type Stats } from 'node:fs'; import { link, lstat, mkdir, open, readdir, readFile, rename, stat, unlink, chmod, type FileHandle } from 'node:fs/promises'; import { homedir, hostname } from 'node:os'; import { dirname, isAbsolute, join } from 'node:path'; @@ -18,6 +18,7 @@ export interface HostPaths { hubLog: string; hubLock: string; hubRecord: string; + unitFile: string; } export interface HostIdentity { @@ -26,6 +27,9 @@ export interface HostIdentity { host: string; } +/** The daemon's systemd user unit. */ +export const UNIT_NAME = 'worktree-term-daemon.service'; + /** Longest unix socket path, in bytes, that fits `sockaddr_un`. */ const MAX_SOCKET_PATH = 107; const LOG_ROTATE_BYTES = 1024 * 1024; @@ -39,7 +43,8 @@ const xdgBase = (value: string | undefined, fallback: string): string => (value /** Paths for `identity`; throws if the socket path does not fit a unix socket address. */ export const hostPaths = ({ env, home, host }: HostIdentity): HostPaths => { const stateDir = join(xdgBase(env['XDG_STATE_HOME'], join(home, '.local', 'state')), 'worktree-term'); - const configDir = join(xdgBase(env['XDG_CONFIG_HOME'], join(home, '.config')), 'worktree-term'); + const configHome = xdgBase(env['XDG_CONFIG_HOME'], join(home, '.config')); + const configDir = join(configHome, 'worktree-term'); const runDir = join(stateDir, 'run'); const name = host.replace(/[^A-Za-z0-9._-]/g, '_'); const socket = join(runDir, `${name}.sock`); @@ -59,9 +64,19 @@ export const hostPaths = ({ env, home, host }: HostIdentity): HostPaths => { hubLog: join(stateDir, 'hub.log'), hubLock: join(runDir, 'hub.lock'), hubRecord: join(runDir, 'hub.json'), + unitFile: join(configHome, 'systemd', 'user', UNIT_NAME), }; }; +/** The SSH control path in `run/`; throws naming it if, with `%C` substituted, it does not fit a unix socket address. */ +export const sshControlPath = (paths: Pick): string => { + const path = join(paths.runDir, 'ssh-%C'); + if (Buffer.byteLength(path.replace('%C', '0'.repeat(40))) > MAX_SOCKET_PATH) { + throw new Error(`SSH control path ${path} exceeds ${String(MAX_SOCKET_PATH)} bytes`); + } + return path; +}; + /** Paths of the current process's user and host. */ export const currentHostPaths = (): HostPaths => hostPaths({ env: process.env, home: homedir(), host: hostname() }); @@ -77,6 +92,46 @@ const privateDir = async (path: string): Promise => { if ((stats.mode & 0o777) !== 0o700) await chmod(path, 0o700); }; +export type EntryKind = 'directory' | 'file' | 'symlink'; + +const isKind = (stats: Stats, kind: EntryKind): boolean => { + if (kind === 'directory') return stats.isDirectory(); + return kind === 'file' ? stats.isFile() : stats.isSymbolicLink(); +}; + +/** + * Whether `path` exists, without following a final symbolic link; throws naming it when it is not + * of `kind` or is owned by another user. + */ +export const ownedEntry = async (path: string, kind: EntryKind): Promise => { + let stats: Stats; + try { + stats = await lstat(path); + } catch (error) { + if (hasCode(error, 'ENOENT')) return false; + throw error; + } + if (!isKind(stats, kind)) throw new Error(`${path} is not a ${kind === 'symlink' ? 'symbolic link' : kind}`); + if (stats.uid !== process.getuid?.()) throw new Error(`${path} is owned by another user`); + return true; +}; + +/** Whether anything exists at `path`, following symbolic links. */ +export const pathExists = async (path: string): Promise => { + try { + await stat(path); + return true; + } catch (error) { + if (hasCode(error, 'ENOENT') || hasCode(error, 'ENOTDIR')) return false; + throw error; + } +}; + +/** Creates `path` and any missing parents owner-only, leaving existing directories as they are. */ +export const makePrivateDirs = async (path: string): Promise => { + await mkdir(path, { recursive: true, mode: 0o700 }); +}; + /** Creates the state directory and `run/` owner-only, tightening looser modes. */ export const preparePrivateDirs = async (paths: Pick): Promise => { await mkdir(dirname(paths.stateDir), { recursive: true }); @@ -97,10 +152,10 @@ const syncDir = async (dir: string): Promise => { } }; -/** Writes a new owner-only file at `path`, complete and synced; returns its path. */ -const writeTemporary = async (path: string, data: string): Promise => { +/** Writes a new file at `path` with `mode`, complete and synced; returns its path. */ +const writeTemporary = async (path: string, data: string, mode = 0o600): Promise => { const temporary = temporaryOf(path); - const handle = await open(temporary, 'wx', 0o600); + const handle = await open(temporary, 'wx', mode); try { await handle.writeFile(data); await handle.sync(); @@ -115,11 +170,11 @@ const writeTemporary = async (path: string, data: string): Promise => { }; /** - * Replaces `path` with `data` entirely or not at all, durably, as an owner-only file. Once the file - * is replaced the write has happened, so a failure to sync the directory does not fail it. + * Replaces `path` with `data` entirely or not at all, durably, as an owner-only file (0600, or `mode`). + * Once the file is replaced the write has happened, so a failure to sync the directory does not fail it. */ -export const writeFileAtomic = async (path: string, data: string): Promise => { - const temporary = await writeTemporary(path, data); +export const writeFileAtomic = async (path: string, data: string, mode = 0o600): Promise => { + const temporary = await writeTemporary(path, data, mode & 0o700); try { await rename(temporary, path); } catch (error) { diff --git a/src/platform/install/formats.ts b/src/platform/install/formats.ts new file mode 100644 index 0000000..5ddf772 --- /dev/null +++ b/src/platform/install/formats.ts @@ -0,0 +1,78 @@ +/** Window class of the Chrome app window `wtd ui` opens. */ +const WM_CLASS = 'chrome-127.0.0.1__-Default'; + +/** Throws naming `path` unless it is an absolute Node path without NUL or line breaks. */ +export const checkNodePath = (path: string): void => { + if (!path.startsWith('/') || /[\0\n\r]/.test(path)) { + throw new Error(`invalid Node path '${path}': it must be absolute, without NUL or line breaks`); + } +}; + +const QUOTED_QUOTE = String.raw`'\''`; + +/** POSIX single-quoted form of `value`. */ +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 => { + checkNodePath(node); + return `#!/bin/sh\nexec ${shellQuote(node)} "$HOME/.local/share/worktree-term/current/wtd.mjs" "$@"\n`; +}; + +/** A Desktop Entry string value. */ +const desktopString = (value: string): string => + 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, 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 => { + const exec = `${execArgument(shim)} ui`; + return [ + '[Desktop Entry]', + 'Type=Application', + 'Name=worktree-term', + 'Comment=Worktree-centric terminal manager', + `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('\\', String.raw`\\`) + .replaceAll('"', String.raw`\"`) + .replaceAll('\n', String.raw`\n`) + .replaceAll('\t', String.raw`\t`) + .replaceAll('\r', String.raw`\r`) + .replaceAll('%', '%%') + .replaceAll('$', '$$$$'); + return `"${escaped}"`; +}; + +/** The daemon's systemd user unit: runs `shim daemon`, restarting it only on failure. */ +export const systemdUnit = (shim: string): string => + [ + '[Unit]', + 'Description=worktree-term daemon', + '', + '[Service]', + `ExecStart=${unitArgument(shim)} daemon`, + 'Restart=on-failure', + '', + '[Install]', + 'WantedBy=default.target', + '', + ].join('\n'); diff --git a/src/platform/install/index.ts b/src/platform/install/index.ts new file mode 100644 index 0000000..184b774 --- /dev/null +++ b/src/platform/install/index.ts @@ -0,0 +1,5 @@ +export { checkNodePath, desktopEntry, shimScript, systemdUnit } from './formats.js'; +export { installLocal, type Installed, type LocalInstallation } from './local.js'; +export { releaseName, releasesToRemove } from './release.js'; +export { installRemote, REMOTE_INSTALL_COMMAND, type InstalledRemote, type RemoteInstallation } from './remote.js'; +export { tarArchive, type TarEntry } from './tar.js'; diff --git a/src/platform/install/install.test.ts b/src/platform/install/install.test.ts new file mode 100644 index 0000000..6cc4e8c --- /dev/null +++ b/src/platform/install/install.test.ts @@ -0,0 +1,385 @@ +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'))).toHaveLength(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) => { + const entries = [{ path, mode: 0o644, data: bytes('') }]; + expect(() => tarArchive(entries)).toThrow(path); + }); + + it.each([['/abs'], ['../up'], ['a/../b'], ['']])('refuses the unsafe path %j', (path) => { + const entries = [{ path, mode: 0o644, data: bytes('') }]; + expect(() => tarArchive(entries)).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/platform/install/local.ts b/src/platform/install/local.ts new file mode 100644 index 0000000..ee95bce --- /dev/null +++ b/src/platform/install/local.ts @@ -0,0 +1,70 @@ +import { spawn } from 'node:child_process'; +import { dirname, join } from 'node:path'; +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'; + +export interface LocalInstallation { + home: string; + /** This host's files: the unit file and the daemon socket. */ + paths: HostPaths; + /** The running `wtd.mjs`. */ + bundle: string; + version: string; + /** The Node the shim runs, by absolute path. */ + node: string; + /** Also install, enable and start the daemon's systemd user unit. */ + systemd: boolean; +} + +export interface Installed { + version: string; + release: string; + 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) => { + const command = [systemctlProgram(), '--user', ...args]; + const tail = new StderrTail(); + const fail = (cause: string): void => { + reject(new Error(`${command.join(' ')} failed: ${cause}`)); + }; + const child = spawn(command[0] ?? 'systemctl', command.slice(1), { stdio: ['ignore', 'ignore', 'pipe'] }); + tail.follow(child.stderr, () => { + stopProcess(child, KILL_GRACE_MS); + }); + child.once('error', (error) => { + fail(error.message); + }); + child.once('close', (code, signal) => { + if (code === 0) resolve(); + else fail(tail.reason() ?? `exit status ${String(code ?? signal)}`); + }); + }); + +/** Installs the running bundle for this user, with a launcher and optionally the systemd unit. */ +export const installLocal = async ({ home, paths, bundle, version, node, systemd }: LocalInstallation): Promise => { + checkNodePath(node); + const layout = layoutOf(home); + const others: [string, EntryKind][] = [[layout.launcher, 'file']]; + if (systemd) others.push([paths.unitFile, 'file']); + await checkOwned(layout, others); + const entries = await releaseEntries(bundle); + for (const dir of [layout.versions, layout.bin, dirname(layout.launcher)]) await makePrivateDirs(dir); // NOSONAR(S9382) — a few directories + const release = await installRelease(layout, version, entries); + await writeFileAtomic(layout.shim, shimScript(node), 0o700); + await writeFileAtomic(layout.launcher, desktopEntry({ shim: layout.shim, icon: join(layout.current, 'web', 'icon.svg') })); + if (systemd) { + await makePrivateDirs(dirname(paths.unitFile)); + await writeFileAtomic(paths.unitFile, systemdUnit(layout.shim)); + await systemctl('daemon-reload'); + await systemctl('enable', UNIT_NAME); + (await dial(paths, [layout.shim, 'daemon'])).destroy(); + } + return { version, release, dataDir: layout.dataDir }; +}; diff --git a/src/platform/install/release.ts b/src/platform/install/release.ts new file mode 100644 index 0000000..b20d1bc --- /dev/null +++ b/src/platform/install/release.ts @@ -0,0 +1,127 @@ +import { randomBytes } from 'node:crypto'; +import { mkdir, readdir, readFile, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; +import { ownedEntry, pathExists, readTree, type EntryKind } from '../files/index.js'; +import type { TarEntry } from './tar.js'; + +const PTY_PACKAGE = 'node_modules/@homebridge/node-pty-prebuilt-multiarch'; +const PTY_FILES = ['package.json', 'LICENSE']; +const PTY_TREES = ['lib', 'prebuilds']; +const DIR_MODE = 0o700; +const FILE_MODE = 0o600; + +/** Where an installation in `home` puts its files. */ +export interface Layout { + dataDir: string; + versions: string; + current: string; + bin: string; + shim: string; + launcher: string; +} + +export const layoutOf = (home: string): Layout => { + const dataDir = join(home, '.local', 'share', 'worktree-term'); + return { + dataDir, + versions: join(dataDir, 'versions'), + current: join(dataDir, 'current'), + bin: join(home, '.local', 'bin'), + shim: join(home, '.local', 'bin', 'wtd'), + launcher: join(home, '.local', 'share', 'applications', 'worktree-term.desktop'), + }; +}; + +/** A release directory name: the version and 12 random lowercase hex digits. */ +export const releaseName = (version: string): string => `${version}-${randomBytes(6).toString('hex')}`; + +/** Releases to delete once `fresh` is current: all but it and `previous`, never one still being assembled. */ +export const releasesToRemove = (names: readonly string[], fresh: string, previous: string | null): string[] => + names.filter((name) => !name.startsWith('.') && name !== fresh && name !== previous); + +/** The PTY package directory the bundle at `bundle` resolves, searching `node_modules` upwards. */ +const ptyPackageOf = async (bundle: string): Promise => { + for (let dir = dirname(bundle); ; dir = dirname(dir)) { + const candidate = join(dir, PTY_PACKAGE); + if (await pathExists(join(candidate, 'package.json'))) return candidate; // NOSONAR(S9382) — nearest node_modules wins, as Node resolves + if (dirname(dir) === dir) throw new Error(`cannot find ${PTY_PACKAGE} for ${bundle}`); + } +}; + +const directoriesOf = (files: readonly string[]): string[] => { + const dirs = new Set(); + for (const file of files) { + for (let dir = dirname(file); dir !== '.'; dir = dirname(dir)) dirs.add(dir); + } + return [...dirs].sort((a, b) => a.split('/').length - b.split('/').length || (a < b ? -1 : 1)); +}; + +/** The release of the running bundle: `wtd.mjs`, `web/` beside it and the PTY package's runtime files. */ +export const releaseEntries = async (bundle: string): Promise => { + const pty = await ptyPackageOf(bundle); + const files: { path: string; data: Uint8Array }[] = [{ path: 'wtd.mjs', data: await readFile(bundle) }]; + for (const file of await readTree(join(dirname(bundle), 'web'))) files.push({ path: `web/${file.path}`, data: file.data }); + for (const name of PTY_FILES) files.push({ path: `${PTY_PACKAGE}/${name}`, data: await readFile(join(pty, name)) }); // NOSONAR(S9382) — a few small files + for (const tree of PTY_TREES) { + for (const file of await readTree(join(pty, tree))) files.push({ path: `${PTY_PACKAGE}/${tree}/${file.path}`, data: file.data }); // NOSONAR(S9382) — two trees + } + const dirs = directoriesOf(files.map((f) => f.path)).map((path): TarEntry => ({ path, mode: DIR_MODE })); + return [...dirs, ...files.map((f): TarEntry => ({ path: f.path, mode: FILE_MODE, data: f.data }))]; +}; + +/** Throws naming the first path the installation owns that is of the wrong type or another user's. */ +export const checkOwned = async (layout: Layout, others: readonly (readonly [string, EntryKind])[]): Promise => { + const checks: (readonly [string, EntryKind])[] = [ + [layout.dataDir, 'directory'], + [layout.versions, 'directory'], + [layout.current, 'symlink'], + [layout.shim, 'file'], + ...others, + ]; + for (const [path, kind] of checks) await ownedEntry(path, kind); // NOSONAR(S9382) — the first failure is reported + if (!(await pathExists(layout.versions))) return; + for (const name of await readdir(layout.versions)) await ownedEntry(join(layout.versions, name), 'directory'); // NOSONAR(S9382) — the first failure is reported +}; + +const hex = (): string => randomBytes(6).toString('hex'); + +/** The release `current` points to, or null. */ +const currentRelease = async (layout: Layout): Promise => { + try { + return basename(await readlink(layout.current)); + } catch { + return null; + } +}; + +/** Writes `entries` as a new release of `version`, points `current` to it and prunes old releases; returns its name. */ +export const installRelease = async (layout: Layout, version: string, entries: readonly TarEntry[]): Promise => { + const name = releaseName(version); + const staging = join(layout.versions, `.${name}`); + try { + await mkdir(staging, { mode: DIR_MODE }); + for (const entry of entries) { + const path = join(staging, entry.path); + const writing = + entry.data === undefined ? mkdir(path, { mode: DIR_MODE }) : writeFile(path, entry.data, { mode: entry.mode & 0o700, flag: 'wx' }); + await writing; // NOSONAR(S9382) — parents before children + } + await rename(staging, join(layout.versions, name)); + } catch (error) { + await rm(staging, { recursive: true, force: true }); + throw error; + } + const previous = await currentRelease(layout); + const link = join(layout.dataDir, `.current-${hex()}`); + await symlink(join('versions', name), link); + try { + await rename(link, layout.current); + } catch (error) { + await rm(link, { force: true }); + throw error; + } + for (const old of releasesToRemove(await readdir(layout.versions), name, previous)) { + await rm(join(layout.versions, old), { recursive: true, force: true }); // NOSONAR(S9382) — few releases + } + return name; +}; diff --git a/src/platform/install/remote.ts b/src/platform/install/remote.ts new file mode 100644 index 0000000..d5e9687 --- /dev/null +++ b/src/platform/install/remote.ts @@ -0,0 +1,206 @@ +import { spawn } from 'node:child_process'; +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'; +import { tarArchive, type TarEntry } from './tar.js'; + +/** + * The fixed remote command: unpacks the archive on stdin into a temporary directory and runs its + * `install.sh` from the home directory. Its quoted part holds no quote or backslash, so every common + * login shell parses it alike; `IFS=` and `set -f` keep the unquoted temporary path one word. + */ +export const REMOTE_INSTALL_COMMAND = + "exec sh -c 'set -f; IFS=; cd && T=$(mktemp -d) && { tar -x -f - -C $T && exec sh $T/install.sh $T; rm -rf $T; exit 1; }'"; + +const RESULT = '@@wtd-install@@'; + +/** + * Runs remotely as `sh install.sh ` from the home directory, `` holding `version`, + * optionally `node`, and `release/`. Prints one result line starting with the marker. + */ +const INSTALL_SCRIPT = String.raw`T=$1 +M='${RESULT}' +trap 'rm -rf "$T"' EXIT +result() { printf '%s %s\n' "$M" "$*"; } +fail() { result "error $*"; exit 1; } + +D=$HOME/.local/share/worktree-term +V=$D/versions +C=$D/current +B=$HOME/.local/bin +W=$B/wtd + +S=$(uname -s) +[ "$S" = Linux ] || fail "unsupported system $S: only Linux is supported" +VERSION=$(cat "$T/version") || fail "the archive holds no version" + +major_ok() { + v=$("$1" -p process.versions.node 2>/dev/null) || return 1 + m=$(printf '%s\n' "$v" | head -n 1 | cut -d. -f1) + case $m in ''|*[!0-9]*) return 1 ;; esac + [ "$m" -ge 20 ] +} +usable() { [ -f "$1" ] && [ -x "$1" ] && major_ok "$1"; } + +if [ -f "$T/node" ]; then + N=$(cat "$T/node") + usable "$N" || fail "$N is not a usable Node: version 20 or later is needed" +else + N= + c=$(command -v node 2>/dev/null) || c= + case $c in /*) usable "$c" && N=$c ;; esac + if [ -z "$N" ] && [ -n "$SHELL" ]; then + c=$("$SHELL" -l -i -c 'printf "%s\n" @@wtd-node@@; command -v node; printf "%s\n" @@wtd-node-end@@' /dev/null | sed -n '/^@@wtd-node@@$/,/^@@wtd-node-end@@$/p' | sed -n 2p) + case $c in /*) usable "$c" && N=$c ;; esac + fi + if [ -z "$N" ] && [ -f "$W" ]; then + c=$(sed -n "2s/^exec '\(.*\)' \"\$HOME\/.*$/\1/p" "$W" | sed "s/'\\\\''/'/g") + case $c in /*) usable "$c" && N=$c ;; esac + fi + [ -n "$N" ] || fail "no Node 20 or later on the PATH, in the login shell or in the installed shim" +fi + +owned() { [ -n "$(find "$1" -maxdepth 0 -user "$(id -u)" 2>/dev/null)" ]; } +check() { + if [ -L "$1" ]; then + [ "$2" = link ] || fail "$1 is a symbolic link" + elif [ -e "$1" ]; then + case $2 in + dir) [ -d "$1" ] || fail "$1 is not a directory" ;; + file) [ -f "$1" ] || fail "$1 is not a regular file" ;; + link) fail "$1 is not a symbolic link" ;; + esac + else + return 0 + fi + owned "$1" || fail "$1 is owned by another user" +} +check "$D" dir +check "$V" dir +check "$C" link +check "$W" file +if [ -d "$V" ]; then + for e in "$V"/* "$V"/.[!.]*; do + if [ -e "$e" ] || [ -L "$e" ]; then check "$e" dir; fi + done +fi + +mkd() { [ -d "$1" ] || mkdir -m 700 "$1" || fail "cannot create $1"; } +mkd "$HOME/.local" +mkd "$HOME/.local/share" +mkd "$D" +mkd "$V" +mkd "$B" + +hex() { od -An -N6 -tx1 /dev/urandom | tr -d ' \n'; } +R=$VERSION-$(hex) +mv "$T/release" "$V/.$R" || { rm -rf "$V/.$R"; fail "cannot copy the release into $V"; } +mv "$V/.$R" "$V/$R" || { rm -rf "$V/.$R"; fail "cannot rename the release in $V"; } + +P=$(readlink "$C" 2>/dev/null) || P= +[ -z "$P" ] || P=$(basename "$P") +L=$D/.current-$(hex) +ln -s "versions/$R" "$L" || fail "cannot create $L" +"$N" -e 'require("node:fs").renameSync(process.argv[1], process.argv[2])' "$L" "$C" || { rm -f "$L"; fail "cannot replace $C"; } + +for e in "$V"/*; do + n=$(basename "$e") + if [ "$n" != "$R" ] && [ "$n" != "$P" ] && [ -d "$e" ]; then rm -rf "$e"; fi +done + +Q=$(printf '%s\n' "$N" | sed "s/'/'\\\\''/g") +X=$B/.wtd-$(hex) +{ printf '#!/bin/sh\nexec %s "$HOME/.local/share/worktree-term/current/wtd.mjs" "$@"\n' "'$Q'" > "$X" && chmod 700 "$X" && mv -f "$X" "$W"; } || { rm -f "$X"; fail "cannot write $W"; } +result "ok $R $N" +`; + +export interface RemoteInstallation { + /** This host's files, for the SSH control path. */ + paths: HostPaths; + alias: string; + /** The running `wtd.mjs`. */ + bundle: string; + version: string; + /** The remote Node to use; null finds one there. */ + node: string | null; + /** Gives up after this long. */ + timeoutMs: number; +} + +export interface InstalledRemote { + version: string; + release: string; + node: string; +} + +const encoder = new TextEncoder(); +const MAX_STDOUT = 64 * 1024; +const EXIT_GRACE_MS = 1000; + +const archiveOf = async ({ bundle, version, node }: RemoteInstallation): Promise => { + const entries: TarEntry[] = [ + { path: 'install.sh', mode: 0o600, data: encoder.encode(INSTALL_SCRIPT) }, + { path: 'version', mode: 0o600, data: encoder.encode(version) }, + ...(node === null ? [] : [{ path: 'node', mode: 0o600, data: encoder.encode(node) }]), + { path: 'release', mode: 0o700 }, + ...(await releaseEntries(bundle)).map((entry) => ({ ...entry, path: `release/${entry.path}` })), + ]; + return tarArchive(entries); +}; + +/** The last result line the remote installer printed, without its marker, or null. */ +const resultOf = (stdout: string): string | null => { + const line = stdout.split('\n').findLast((l) => l.startsWith(`${RESULT} `)); + return line === undefined ? null : line.slice(RESULT.length + 1); +}; + +/** Installs the running bundle on the SSH host `alias`; throws one line naming the cause. */ +export const installRemote = async (installation: RemoteInstallation): Promise => { + const { paths, alias, version, node, timeoutMs } = installation; + if (node !== null) checkNodePath(node); + const [program, ...args] = sshCommand(paths, alias, REMOTE_INSTALL_COMMAND); + await preparePrivateDirs(paths); + const archive = await archiveOf(installation); + const { stdout, code, tail } = await new Promise<{ stdout: string; code: number | string; tail: StderrTail }>((resolve, reject) => { + const child = spawn(program ?? 'ssh', args, { stdio: ['pipe', 'pipe', 'pipe'] }); + const tail = new StderrTail(); + let out = ''; + const timer = setTimeout(() => { + 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) => { + out = (out + chunk).slice(-MAX_STDOUT); + }); + tail.follow(child.stderr, () => { + stopProcess(child, EXIT_GRACE_MS); + }); + child.stdin.on('error', () => undefined); + child.once('error', (error) => { + clearTimeout(timer); + reject(new Error(`cannot run ${program ?? 'ssh'}: ${error.message}`, { cause: error })); + }); + const done = (exit: number | null, signal: NodeJS.Signals | null): void => { + clearTimeout(timer); + resolve({ stdout: out, code: exit ?? signal ?? 'unknown', tail }); + }; + child.once('close', done); + // A control master forked by this SSH may hold its output open; the exit is what counts. + child.once('exit', (exit, signal) => { + setTimeout(() => { + done(exit, signal); + }, EXIT_GRACE_MS).unref(); + }); + child.stdin.end(archive); + }); + const result = resultOf(stdout); + if (result?.startsWith('ok ')) { + const [release = '', ...rest] = result.slice(3).split(' '); + return { version, release, node: rest.join(' ') }; + } + if (result?.startsWith('error ')) throw new Error(`${alias}: ${result.slice(6)}`); + throw new Error(tail.reason() ?? `${alias}: ssh exited with status ${String(code)}`); +}; diff --git a/src/platform/install/tar.ts b/src/platform/install/tar.ts new file mode 100644 index 0000000..c975f37 --- /dev/null +++ b/src/platform/install/tar.ts @@ -0,0 +1,65 @@ +/** A tar member: a directory when `data` is absent, else a regular file. */ +export interface TarEntry { + /** Relative path with `/` separators. */ + path: string; + mode: number; + data?: Uint8Array; +} + +const BLOCK = 512; +const MAX_NAME = 100; +const MAX_PREFIX = 155; + +const encoder = new TextEncoder(); + +const isSafe = (path: string): boolean => + path !== '' && !path.startsWith('/') && path.split('/').every((part) => part !== '' && part !== '.' && part !== '..'); + +/** `path` as ustar name and prefix fields; throws naming it when it does not fit. */ +const splitName = (path: string): { name: Uint8Array; prefix: Uint8Array } => { + const whole = encoder.encode(path); + if (whole.length <= MAX_NAME) return { name: whole, prefix: new Uint8Array() }; + for (let at = path.indexOf('/'); at !== -1; at = path.indexOf('/', at + 1)) { + const prefix = encoder.encode(path.slice(0, at)); + const name = encoder.encode(path.slice(at + 1)); + if (prefix.length <= MAX_PREFIX && name.length <= MAX_NAME) return { name, prefix }; + } + throw new Error(`tar: cannot store the name ${path}`); +}; + +const octal = (value: number, width: number): Uint8Array => encoder.encode(`${value.toString(8).padStart(width - 1, '0')}\0`); + +const header = (entry: TarEntry, mtime: number): Uint8Array => { + if (!isSafe(entry.path)) throw new Error(`tar: unsafe path '${entry.path}'`); + const directory = entry.data === undefined; + const { name, prefix } = splitName(directory ? `${entry.path}/` : entry.path); + const block = new Uint8Array(BLOCK); + block.set(name, 0); + block.set(octal(entry.mode & 0o7777, 8), 100); + block.set(octal(0, 8), 108); + block.set(octal(0, 8), 116); + block.set(octal(entry.data?.length ?? 0, 12), 124); + block.set(octal(mtime, 12), 136); + block.fill(0x20, 148, 156); + block[156] = directory ? 0x35 : 0x30; + block.set(encoder.encode('ustar\x0000'), 257); + block.set(prefix, 345); + const sum = block.reduce((total, byte) => total + byte, 0); + block.set(encoder.encode(`${sum.toString(8).padStart(6, '0')}\0 `), 148); + return block; +}; + +/** A ustar archive holding `entries` in order, owned by uid 0 and stamped with the current time. */ +export const tarArchive = (entries: readonly TarEntry[]): Uint8Array => { + const mtime = Math.floor(Date.now() / 1000); + const parts = entries.map((entry) => ({ header: header(entry, mtime), data: entry.data ?? new Uint8Array() })); + const size = parts.reduce((total, part) => total + BLOCK + Math.ceil(part.data.length / BLOCK) * BLOCK, 2 * BLOCK); + const archive = new Uint8Array(size); + let at = 0; + for (const part of parts) { + archive.set(part.header, at); + archive.set(part.data, at + BLOCK); + at += BLOCK + Math.ceil(part.data.length / BLOCK) * BLOCK; + } + return archive; +}; 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/constants.ts b/src/protocol/constants.ts index feee79b..47fdd53 100644 --- a/src/protocol/constants.ts +++ b/src/protocol/constants.ts @@ -1,7 +1,7 @@ const KiB = 1024; const MiB = 1024 * KiB; -export const PROTOCOL_VERSION = 4; +export const PROTOCOL_VERSION = 5; /** Largest stream frame payload. */ export const MAX_FRAME = 16 * MiB; diff --git a/src/protocol/index.ts b/src/protocol/index.ts index a59877e..476e22c 100644 --- a/src/protocol/index.ts +++ b/src/protocol/index.ts @@ -12,6 +12,7 @@ export { type Frame, } from './frames.js'; export { layout as layoutSchema, type Layout } from './layout.js'; +export { root as discoveryRootSchema } from './values.js'; export { decodeCodeResponse, decodeMessage, 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/messages.ts b/src/protocol/messages.ts index d5ee8bd..f1281e3 100644 --- a/src/protocol/messages.ts +++ b/src/protocol/messages.ts @@ -21,7 +21,9 @@ import { path, presetCommand, presetName, + reason, req, + root, secret, signal, size, @@ -66,7 +68,7 @@ export const preset = z.strictObject({ name: presetName, command: presetCommand const daemonRequests = [ message('watchRepo', { req, repo: path }), message('unwatchRepo', { req, repo: path }), - message('discoverRepos', { req, roots: z.array(path).min(1).max(MAX_ROOTS), depth }), + message('discoverRepos', { req, roots: z.array(root).min(1).max(MAX_ROOTS), depth }), message('createTerm', { req, worktree: path, preset: name, command: longText, cols: size, rows: size }), message('attach', { req, termId }), message('detach', { req, termId }), @@ -104,6 +106,7 @@ const hostEntry = z.strictObject({ name, remote: z.boolean(), status: z.enum(['connecting', 'connected', 'reconnecting', 'down', 'outdated']), + reason, daemonVersion: version.nullable(), instance: instance.nullable(), repos: z.array(path).max(MAX_HOST_REPOS), 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/values.ts b/src/protocol/values.ts index 6300cdb..009ff0e 100644 --- a/src/protocol/values.ts +++ b/src/protocol/values.ts @@ -13,6 +13,10 @@ export const depth = z.int().min(1).max(6); const ABSOLUTE_WITHOUT_NUL = /^\/[^\u0000]*$/; export const path = z.string().max(4096).regex(ABSOLUTE_WITHOUT_NUL); +// eslint-disable-next-line no-control-regex -- NUL is the character roots must not contain +const ROOT = /^(?:\/[^\u0000]*|~|~\/[^\u0000]+)$/; +/** A discovery root: an absolute path, `~`, or `~/` and a rest, expanded by the daemon. */ +export const root = z.string().max(4096).regex(ROOT); export const head = z .string() .regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/) @@ -27,6 +31,7 @@ export const instance = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/); /** A hub token or one-time code. */ export const secret = z.string().regex(/^[0-9a-f]{64}$/); export const errorMessage = z.string().max(1024); +export const reason = errorMessage.nullable(); export const errorCode = z.enum([ 'bad-message', 'unknown-host', diff --git a/src/protocol/wire.golden.json b/src/protocol/wire.golden.json index 7a67ba3..59bfb3e 100644 --- a/src/protocol/wire.golden.json +++ b/src/protocol/wire.golden.json @@ -1,5 +1,5 @@ { - "protocolVersion": 4, + "protocolVersion": 5, "constants": { "MAX_FRAME": 16777216, "MAX_INPUT": 65536, @@ -124,7 +124,7 @@ "items": { "type": "string", "maxLength": 4096, - "pattern": "^\\/[^\\u0000]*$" + "pattern": "^(?:\\/[^\\u0000]*|~|~\\/[^\\u0000]+)$" } }, "depth": { @@ -1416,7 +1416,7 @@ "items": { "type": "string", "maxLength": 4096, - "pattern": "^\\/[^\\u0000]*$" + "pattern": "^(?:\\/[^\\u0000]*|~|~\\/[^\\u0000]+)$" } }, "depth": { @@ -2765,6 +2765,17 @@ "outdated" ] }, + "reason": { + "anyOf": [ + { + "type": "string", + "maxLength": 1024 + }, + { + "type": "null" + } + ] + }, "daemonVersion": { "anyOf": [ { @@ -2803,6 +2814,7 @@ "name", "remote", "status", + "reason", "daemonVersion", "instance", "repos" @@ -3057,6 +3069,11 @@ "json": "{\"t\":\"discoverRepos\",\"req\":3,\"roots\":[\"/home/u/GitHub\",\"/srv\"],\"depth\":3}", "valid": true }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"discoverRepos\",\"req\":11,\"roots\":[\"~\",\"~/GitHub\"],\"depth\":3}", + "valid": true + }, { "dir": "clientToDaemon", "json": "{\"t\":\"createTerm\",\"req\":4,\"worktree\":\"/home/u/repo.worktrees/feat\",\"preset\":\"claude\",\"command\":\"claude --resume\",\"cols\":80,\"rows\":24}", @@ -3222,6 +3239,11 @@ "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"discoverRepos\",\"req\":3,\"roots\":[\"/home/u/GitHub\",\"/srv\"],\"depth\":3}}", "valid": false }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"discoverRepos\",\"req\":11,\"roots\":[\"~\",\"~/GitHub\"],\"depth\":3}}", + "valid": false + }, { "dir": "clientToDaemon", "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"createTerm\",\"req\":4,\"worktree\":\"/home/u/repo.worktrees/feat\",\"preset\":\"claude\",\"command\":\"claude --resume\",\"cols\":80,\"rows\":24}}", @@ -3404,7 +3426,7 @@ }, { "dir": "clientToDaemon", - "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/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\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/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\":[\"/home/u/repo.worktrees/feat\"]},{\"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\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", "valid": false }, { @@ -3647,6 +3669,41 @@ "json": "{\"t\":\"setLayout\",\"req\":1,\"worktree\":\"/home/u/repo.worktrees/feat\",\"layout\":{\"tabs\":[{\"id\":\"a\",\"root\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":9},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":8},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":7},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":6},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":5},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":4},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":3},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":2},\"b\":{\"term\":1}}}}}}}}}}],\"active\":0}}", "valid": false }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"discoverRepos\",\"req\":1,\"roots\":[\"GitHub\"],\"depth\":3}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"discoverRepos\",\"req\":1,\"roots\":[\"~user/x\"],\"depth\":3}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"discoverRepos\",\"req\":1,\"roots\":[\"~/\"],\"depth\":3}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"watchRepo\",\"req\":1,\"repo\":\"~/x\"}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"addRepo\",\"req\":1,\"host\":0,\"repo\":\"~/x\"}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[]}]}", + "valid": false + }, + { + "dir": "clientToDaemon", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", + "valid": false + }, { "dir": "daemonToClient", "json": "{\"t\":\"hello\",\"protocol\":1,\"version\":\"0.1.0\",\"instance\":\"a1B2_c3\"}", @@ -3672,6 +3729,11 @@ "json": "{\"t\":\"discoverRepos\",\"req\":3,\"roots\":[\"/home/u/GitHub\",\"/srv\"],\"depth\":3}", "valid": false }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"discoverRepos\",\"req\":11,\"roots\":[\"~\",\"~/GitHub\"],\"depth\":3}", + "valid": false + }, { "dir": "daemonToClient", "json": "{\"t\":\"createTerm\",\"req\":4,\"worktree\":\"/home/u/repo.worktrees/feat\",\"preset\":\"claude\",\"command\":\"claude --resume\",\"cols\":80,\"rows\":24}", @@ -3837,6 +3899,11 @@ "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"discoverRepos\",\"req\":3,\"roots\":[\"/home/u/GitHub\",\"/srv\"],\"depth\":3}}", "valid": false }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"discoverRepos\",\"req\":11,\"roots\":[\"~\",\"~/GitHub\"],\"depth\":3}}", + "valid": false + }, { "dir": "daemonToClient", "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"createTerm\",\"req\":4,\"worktree\":\"/home/u/repo.worktrees/feat\",\"preset\":\"claude\",\"command\":\"claude --resume\",\"cols\":80,\"rows\":24}}", @@ -4019,7 +4086,7 @@ }, { "dir": "daemonToClient", - "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/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\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/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\":[\"/home/u/repo.worktrees/feat\"]},{\"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\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", "valid": false }, { @@ -4262,6 +4329,41 @@ "json": "{\"t\":\"setLayout\",\"req\":1,\"worktree\":\"/home/u/repo.worktrees/feat\",\"layout\":{\"tabs\":[{\"id\":\"a\",\"root\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":9},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":8},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":7},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":6},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":5},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":4},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":3},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":2},\"b\":{\"term\":1}}}}}}}}}}],\"active\":0}}", "valid": false }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"discoverRepos\",\"req\":1,\"roots\":[\"GitHub\"],\"depth\":3}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"discoverRepos\",\"req\":1,\"roots\":[\"~user/x\"],\"depth\":3}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"discoverRepos\",\"req\":1,\"roots\":[\"~/\"],\"depth\":3}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"watchRepo\",\"req\":1,\"repo\":\"~/x\"}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"addRepo\",\"req\":1,\"host\":0,\"repo\":\"~/x\"}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[]}]}", + "valid": false + }, + { + "dir": "daemonToClient", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", + "valid": false + }, { "dir": "browserToHub", "json": "{\"t\":\"hello\",\"protocol\":1,\"version\":\"0.1.0\",\"instance\":\"a1B2_c3\"}", @@ -4287,6 +4389,11 @@ "json": "{\"t\":\"discoverRepos\",\"req\":3,\"roots\":[\"/home/u/GitHub\",\"/srv\"],\"depth\":3}", "valid": false }, + { + "dir": "browserToHub", + "json": "{\"t\":\"discoverRepos\",\"req\":11,\"roots\":[\"~\",\"~/GitHub\"],\"depth\":3}", + "valid": false + }, { "dir": "browserToHub", "json": "{\"t\":\"createTerm\",\"req\":4,\"worktree\":\"/home/u/repo.worktrees/feat\",\"preset\":\"claude\",\"command\":\"claude --resume\",\"cols\":80,\"rows\":24}", @@ -4452,6 +4559,11 @@ "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"discoverRepos\",\"req\":3,\"roots\":[\"/home/u/GitHub\",\"/srv\"],\"depth\":3}}", "valid": true }, + { + "dir": "browserToHub", + "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"discoverRepos\",\"req\":11,\"roots\":[\"~\",\"~/GitHub\"],\"depth\":3}}", + "valid": true + }, { "dir": "browserToHub", "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"createTerm\",\"req\":4,\"worktree\":\"/home/u/repo.worktrees/feat\",\"preset\":\"claude\",\"command\":\"claude --resume\",\"cols\":80,\"rows\":24}}", @@ -4634,7 +4746,7 @@ }, { "dir": "browserToHub", - "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/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\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/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\":[\"/home/u/repo.worktrees/feat\"]},{\"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\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", "valid": false }, { @@ -4877,6 +4989,41 @@ "json": "{\"t\":\"setLayout\",\"req\":1,\"worktree\":\"/home/u/repo.worktrees/feat\",\"layout\":{\"tabs\":[{\"id\":\"a\",\"root\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":9},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":8},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":7},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":6},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":5},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":4},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":3},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":2},\"b\":{\"term\":1}}}}}}}}}}],\"active\":0}}", "valid": false }, + { + "dir": "browserToHub", + "json": "{\"t\":\"discoverRepos\",\"req\":1,\"roots\":[\"GitHub\"],\"depth\":3}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"discoverRepos\",\"req\":1,\"roots\":[\"~user/x\"],\"depth\":3}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"discoverRepos\",\"req\":1,\"roots\":[\"~/\"],\"depth\":3}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"watchRepo\",\"req\":1,\"repo\":\"~/x\"}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"addRepo\",\"req\":1,\"host\":0,\"repo\":\"~/x\"}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[]}]}", + "valid": false + }, + { + "dir": "browserToHub", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", + "valid": false + }, { "dir": "hubToBrowser", "json": "{\"t\":\"hello\",\"protocol\":1,\"version\":\"0.1.0\",\"instance\":\"a1B2_c3\"}", @@ -4902,6 +5049,11 @@ "json": "{\"t\":\"discoverRepos\",\"req\":3,\"roots\":[\"/home/u/GitHub\",\"/srv\"],\"depth\":3}", "valid": false }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"discoverRepos\",\"req\":11,\"roots\":[\"~\",\"~/GitHub\"],\"depth\":3}", + "valid": false + }, { "dir": "hubToBrowser", "json": "{\"t\":\"createTerm\",\"req\":4,\"worktree\":\"/home/u/repo.worktrees/feat\",\"preset\":\"claude\",\"command\":\"claude --resume\",\"cols\":80,\"rows\":24}", @@ -5067,6 +5219,11 @@ "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"discoverRepos\",\"req\":3,\"roots\":[\"/home/u/GitHub\",\"/srv\"],\"depth\":3}}", "valid": false }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"discoverRepos\",\"req\":11,\"roots\":[\"~\",\"~/GitHub\"],\"depth\":3}}", + "valid": false + }, { "dir": "hubToBrowser", "json": "{\"t\":\"host\",\"host\":1,\"m\":{\"t\":\"createTerm\",\"req\":4,\"worktree\":\"/home/u/repo.worktrees/feat\",\"preset\":\"claude\",\"command\":\"claude --resume\",\"cols\":80,\"rows\":24}}", @@ -5249,7 +5406,7 @@ }, { "dir": "hubToBrowser", - "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/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\":[\"/home/u/repo.worktrees/feat\"]},{\"idx\":3,\"name\":\"old\",\"remote\":true,\"status\":\"reconnecting\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]},{\"idx\":65535,\"name\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"reason\":null,\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[\"/home/u/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\":[\"/home/u/repo.worktrees/feat\"]},{\"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\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", "valid": true }, { @@ -5491,6 +5648,41 @@ "dir": "hubToBrowser", "json": "{\"t\":\"setLayout\",\"req\":1,\"worktree\":\"/home/u/repo.worktrees/feat\",\"layout\":{\"tabs\":[{\"id\":\"a\",\"root\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":9},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":8},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":7},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":6},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":5},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":4},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":3},\"b\":{\"split\":\"down\",\"ratio\":0.5,\"a\":{\"term\":2},\"b\":{\"term\":1}}}}}}}}}}],\"active\":0}}", "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"discoverRepos\",\"req\":1,\"roots\":[\"GitHub\"],\"depth\":3}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"discoverRepos\",\"req\":1,\"roots\":[\"~user/x\"],\"depth\":3}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"discoverRepos\",\"req\":1,\"roots\":[\"~/\"],\"depth\":3}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"watchRepo\",\"req\":1,\"repo\":\"~/x\"}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"addRepo\",\"req\":1,\"host\":0,\"repo\":\"~/x\"}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":0,\"name\":\"local\",\"remote\":false,\"status\":\"connected\",\"daemonVersion\":\"0.1.0\",\"instance\":\"d_1-A\",\"repos\":[]}]}", + "valid": false + }, + { + "dir": "hubToBrowser", + "json": "{\"t\":\"hosts\",\"hosts\":[{\"idx\":1,\"name\":\"box\",\"remote\":true,\"status\":\"down\",\"reason\":\"rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr\",\"daemonVersion\":null,\"instance\":null,\"repos\":[]}]}", + "valid": false } ] } 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/connection.ts b/src/web/client/connection.ts index 33297c4..18b5610 100644 --- a/src/web/client/connection.ts +++ b/src/web/client/connection.ts @@ -12,6 +12,7 @@ import { type MessageOf, } from '../../protocol/index.js'; import { reconnectDelay } from './backoff.js'; +import { hostKey, TerminalMemory, type TerminalStorage } from './memory.js'; import { PendingRequests } from './requests.js'; import { checkBundle } from './stale.js'; import { HubStore } from './store.js'; @@ -61,6 +62,16 @@ const randomInstance = (): string => { .replaceAll('/', '_'); }; +/** The page's `localStorage`, or null when the browser refuses it. */ +const localStorageOrNull = (): TerminalStorage | null => { + try { + return localStorage; + } catch (error) { + console.warn('local storage is unavailable', error); + return null; + } +}; + /** Whether the hub answers HTTP at all, as opposed to being down or starting. */ const hubAnswers = async (): Promise => { try { @@ -74,6 +85,8 @@ const hubAnswers = async (): Promise => { /** The page's one WebSocket session with the hub: authentication, reconnects, requests and dispatch. */ export class HubClient { readonly store = new HubStore(); + /** The running terminals last seen per host and daemon instance, for confirmations. */ + readonly memory = new TerminalMemory(localStorageOrNull()); private readonly pending = new PendingRequests(); private readonly dataListeners = new Set<(host: number, frame: DataFrame) => void>(); private readonly eventListeners = new Set<(host: number, m: DaemonEvent) => void>(); @@ -82,8 +95,8 @@ export class HubClient { private readonly encoder = new TextEncoder(); private credential: Credential | null = null; private ws: WebSocket | null = null; - /** Each host's status and instance in the current session. */ - private hostStates = new Map(); + /** Each host's status, instance and repos in the current session. */ + private hostStates = new Map(); private attempts = 0; /** A failed upgrade while the hub answered HTTP; a second one means our credential is refused. */ private refusedOnce = false; @@ -132,6 +145,28 @@ export class HubClient { await this.exchange(null, (req) => ({ t: 'restartDaemon', req, host })); } + /** Asks the hub to install its bundle on remote `host` and restart its daemon. */ + async reinstallDaemon(host: number): Promise { + await this.exchange(null, (req) => ({ t: 'reinstallDaemon', req, host })); + } + + /** The repos the hub discovers on `host` below its configured roots. */ + async discoverRepos(host: number): Promise { + const m = await this.exchange(null, (req) => ({ t: 'discoverRepos', req, host })); + if (m.t !== 'reposDiscovered') throw new Error(`unexpected hub reply ${m.t}`); + return m.repos; + } + + /** Asks the hub to add `repo` to `host`'s configured repos. */ + async addRepo(host: number, repo: string): Promise { + await this.exchange(null, (req) => ({ t: 'addRepo', req, host, repo })); + } + + /** Asks the hub to remove `repo` from `host`'s configured repos. */ + async removeRepo(host: number, repo: string): Promise { + await this.exchange(null, (req) => ({ t: 'removeRepo', req, host, repo })); + } + private async exchange(host: number | null, message: (req: number) => BrowserMessage): Promise['m']> { if (this.ws?.readyState !== WebSocket.OPEN) throw new Error('not connected to the hub'); const { req, reply } = this.pending.add(host); @@ -292,8 +327,9 @@ export class HubClient { private hosts(hosts: readonly HostEntry[]): void { this.attempts = 0; const previous = this.hostStates; - this.hostStates = new Map(hosts.map((h) => [h.idx, { status: h.status, instance: h.instance }])); + this.hostStates = new Map(hosts.map((h) => [h.idx, { status: h.status, instance: h.instance, repos: h.repos }])); const connected: HostEntry[] = []; + const following: [HostEntry, readonly string[]][] = []; batch(() => { this.store.hosts.value = hosts; for (const host of hosts) { @@ -301,12 +337,56 @@ export class HubClient { const same = before?.status === 'connected' && host.status === 'connected' && before.instance === host.instance; if (before?.status === 'connected' && !same) this.pending.failHost(host.idx, `host ${String(host.idx)} is ${host.status}`); if (host.status === 'connected' && !same) connected.push(host); + if (same) following.push([host, before.repos]); } }); + for (const [host, before] of following) this.followRepos(host.idx, before, host.repos); for (const host of connected) { if (host.instance === null) continue; - for (const listener of this.connectedListeners) listener(host.idx, host.instance, host.repos); + const repos = [...host.repos, ...(this.store.kept.value.get(host.idx) ?? []).filter((r) => !host.repos.includes(r))]; + for (const listener of this.connectedListeners) listener(host.idx, host.instance, repos); + } + } + + /** The hub's repos of a connected host changed: watches added repos and lets go of removed ones. */ + private followRepos(host: number, before: readonly string[], after: readonly string[]): void { + for (const repo of after) { + if (before.includes(repo)) continue; + this.store.keep(host, repo, false); + void this.watch(host, repo); } + for (const repo of before) { + if (after.includes(repo)) continue; + if (this.store.hasRunning(host, repo)) this.store.keep(host, repo, true); + else this.letGo(host, repo); + } + } + + private async watch(host: number, repo: string): Promise { + try { + await this.request(host, { t: 'watchRepo', repo }); + } catch (error) { + if (error instanceof RequestError) this.store.repoError(host, repo, { code: error.code, message: error.message }); + else console.warn(`watching ${repo} failed`, error); + } + } + + /** Stops watching `repo` and forgets its state. */ + private letGo(host: number, repo: string): void { + this.store.dropRepo(host, repo); + this.request(host, { t: 'unwatchRepo', repo }).catch((error: unknown) => { + console.warn(`unwatching ${repo} failed`, error); + }); + } + + /** Lets go of kept repos of `host` without running terminals, and remembers its running terminals. */ + private terminalsChanged(host: number): void { + for (const repo of this.store.kept.value.get(host) ?? []) { + if (!this.store.hasRunning(host, repo)) this.letGo(host, repo); + } + const entry = this.store.hosts.value.find((h) => h.idx === host); + if (entry?.status !== 'connected' || entry.instance === null) return; + this.memory.remember(hostKey(entry), entry.instance, this.store.running(host), Date.now()); } private daemonEvent(host: number, m: DaemonEvent): void { @@ -317,6 +397,7 @@ export class HubClient { break; case 'termCreated': this.store.apply(host, m); + this.terminalsChanged(host); if (m.req !== null) this.pending.resolve(m.req, { ok: true, m }); break; case 'error': @@ -325,9 +406,12 @@ export class HubClient { } break; case 'repoState': - case 'worktreesChanged': case 'termExited': case 'termClosed': + this.store.apply(host, m); + this.terminalsChanged(host); + break; + case 'worktreesChanged': case 'detached': case 'activity': case 'checkedChanged': diff --git a/src/web/client/index.ts b/src/web/client/index.ts index 6503b2d..0c280ad 100644 --- a/src/web/client/index.ts +++ b/src/web/client/index.ts @@ -1,4 +1,5 @@ export { ACK_EVERY } from '../../protocol/index.js'; export { HubClient, RequestError, type DaemonEvent, type DaemonNotice, type DaemonReply, type DaemonRequestBody } from './connection.js'; export { Restorer, type RestoreDeps } from './restore.js'; -export { repoKey, type ConnectionStatus, type HubStore, type RepoState } from './store.js'; +export { hostKey, TerminalMemory, type Recalled, type RememberedTerminal, type TerminalStorage } from './memory.js'; +export { repoKey, worktreeLabel, type ConnectionStatus, type HubStore, type RepoState } from './store.js'; 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/client/memory.ts b/src/web/client/memory.ts new file mode 100644 index 0000000..89a827e --- /dev/null +++ b/src/web/client/memory.ts @@ -0,0 +1,73 @@ +/** The part of `localStorage` the terminal memory uses. */ +export interface TerminalStorage { + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void; +} + +/** A running terminal as a confirmation names it. */ +export interface RememberedTerminal { + worktree: string; + preset: string; +} + +/** The running terminals last seen on one daemon instance, and when. */ +export interface Recalled { + at: number; + terminals: RememberedTerminal[]; +} + +const PREFIX = 'wtd.terminals.'; +const MAX_TERMINALS = 256; + +/** The storage key part of a host: `local`, or `remote:`. */ +export const hostKey = (host: { remote: boolean; name: string }): string => (host.remote ? `remote:${host.name}` : 'local'); + +const isTerminal = (value: unknown): value is RememberedTerminal => + typeof value === 'object' && + value !== null && + 'worktree' in value && + 'preset' in value && + typeof value.worktree === 'string' && + typeof value.preset === 'string'; + +/** Parses a stored record, or null for anything else. */ +const parseRecord = (text: string): (Recalled & { instance: string }) | null => { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return null; + } + if (typeof value !== 'object' || value === null || !('instance' in value) || !('at' in value) || !('terminals' in value)) return null; + const { instance, at, terminals } = value; + if (typeof instance !== 'string' || typeof at !== 'number' || !Array.isArray(terminals) || !terminals.every(isTerminal)) return null; + return { instance, at, terminals: terminals.slice(0, MAX_TERMINALS) }; +}; + +/** Per host, the running terminals the page last saw on a daemon instance; storage failures lose only the list. */ +export class TerminalMemory { + constructor(private readonly storage: TerminalStorage | null) {} + + remember(host: string, instance: string, terminals: readonly RememberedTerminal[], at: number): void { + const record = { instance, at, terminals: terminals.slice(0, MAX_TERMINALS) }; + try { + this.storage?.setItem(PREFIX + host, JSON.stringify(record)); + } catch (error) { + console.warn('remembering terminals failed', error); + } + } + + /** What was remembered for `host` on `instance`, or null. */ + recall(host: string, instance: string | null): Recalled | null { + if (instance === null || this.storage === null) return null; + let text: string | null; + try { + text = this.storage.getItem(PREFIX + host); + } catch (error) { + console.warn('recalling terminals failed', error); + return null; + } + const record = text === null ? null : parseRecord(text); + return record?.instance === instance ? { at: record.at, terminals: record.terminals } : null; + } +} diff --git a/src/web/client/store.test.ts b/src/web/client/store.test.ts index 3fffaeb..fef7084 100644 --- a/src/web/client/store.test.ts +++ b/src/web/client/store.test.ts @@ -220,4 +220,49 @@ describe('HubStore', () => { expect(store.repo(0, REPO)).toBeNull(); expect(store.repo(1, REPO)).not.toBeNull(); }); + + it('tells whether a repo has a terminal whose process runs', () => { + const store = watched(); + expect(store.hasRunning(0, REPO)).toBe(true); + store.apply(0, { t: 'termExited', termId: 1, code: 0, signal: null }); + expect(store.hasRunning(0, REPO)).toBe(false); + expect(store.hasRunning(0, '/r/other')).toBe(false); + }); + + it('lists the running terminals of a host with their worktree labels', () => { + const store = new HubStore(); + const gone = '/r/app.worktrees/gone'; + const terminals = [terminal(1), { ...terminal(2, REPO), preset: 'claude' }, terminal(3, gone)]; + store.apply(0, { t: 'repoState', repo: REPO, worktrees: [worktree(REPO), worktree(WT)], terminals, checked: [], layouts: [] }); + store.apply(0, { t: 'termExited', termId: 1, code: 0, signal: null }); + expect(store.running(0)).toEqual([ + { worktree: 'b', preset: 'claude' }, + { worktree: 'gone', preset: 'shell' }, + ]); + expect(store.running(1)).toEqual([]); + }); + + it('keeps repos per host until told otherwise, and forgets them with the host', () => { + const store = watched(); + store.keep(0, REPO, true); + store.keep(0, REPO, true); + store.keep(1, '/x', true); + expect(store.kept.value.get(0)).toEqual([REPO]); + store.keep(0, REPO, false); + expect(store.kept.value.get(0)).toEqual([]); + store.keep(0, REPO, true); + store.clearHost(0); + expect(store.kept.value.has(0)).toBe(false); + expect(store.kept.value.get(1)).toEqual(['/x']); + }); + + it('drops one repo’s state and stops keeping it', () => { + const store = watched(); + store.apply(0, { t: 'repoState', repo: '/r/b', worktrees: [], terminals: [], checked: [], layouts: [] }); + store.keep(0, REPO, true); + store.dropRepo(0, REPO); + expect(store.repo(0, REPO)).toBeNull(); + expect(store.repo(0, '/r/b')).not.toBeNull(); + expect(store.kept.value.get(0)).toEqual([]); + }); }); diff --git a/src/web/client/store.ts b/src/web/client/store.ts index f1794ca..c4cb7fe 100644 --- a/src/web/client/store.ts +++ b/src/web/client/store.ts @@ -16,6 +16,17 @@ export interface RepoState { error: { code: string; message: string } | null; } +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; +}; + const EMPTY: RepoState = { worktrees: [], terminals: [], checked: [], layouts: new Map(), error: null }; export const repoKey = (host: number, repo: string): string => `${String(host)}:${repo}`; @@ -32,6 +43,8 @@ export class HubStore { readonly notice = signal(null); /** The presets of the current session; null until the hub sends them. */ readonly presets = signal(null); + /** Per host, repos the hub no longer lists that the page keeps while they have running terminals. */ + readonly kept = signal>(new Map()); /** The layouts each repo's daemon last reported, by `repoKey` and worktree. */ private readonly reported = new Map>(); /** Our `setLayout` requests not yet answered, by host and worktree. */ @@ -59,10 +72,52 @@ export class HubStore { this.update(host, repo, (state) => ({ ...state, error })); } + /** Whether the page holds a terminal of `repo` whose process runs. */ + hasRunning(host: number, repo: string): boolean { + return (this.repo(host, repo)?.terminals ?? []).some((t) => t.exit === null); + } + + /** Keeps `repo` of `host` shown although the hub no longer lists it, or stops keeping it. */ + keep(host: number, repo: string, kept: boolean): void { + const current = this.kept.value.get(host) ?? []; + if (current.includes(repo) === kept) return; + const next = new Map(this.kept.value); + next.set(host, kept ? [...current, repo] : current.filter((r) => r !== repo)); + this.kept.value = next; + } + + /** Forgets the state of one repo. */ + dropRepo(host: number, repo: string): void { + const key = repoKey(host, repo); + this.reported.delete(key); + this.repos.value = new Map([...this.repos.value].filter(([k]) => k !== key)); + this.keep(host, repo, false); + } + + /** The running terminals of `host` with their worktree labels. */ + running(host: number): { worktree: string; preset: string }[] { + const prefix = `${String(host)}:`; + const found: { worktree: string; preset: string }[] = []; + for (const [key, state] of this.repos.value) { + if (!key.startsWith(prefix)) continue; + for (const t of state.terminals) { + if (t.exit !== null) continue; + const worktree = state.worktrees.find((w) => w.path === t.worktree); + found.push({ worktree: worktree === undefined ? directoryName(t.worktree) : worktreeLabel(worktree), preset: t.preset }); + } + } + return found; + } + /** Forgets every repo state of `host`. */ clearHost(host: number): void { const prefix = `${String(host)}:`; this.repos.value = new Map([...this.repos.value].filter(([key]) => !key.startsWith(prefix))); + if (this.kept.value.has(host)) { + const kept = new Map(this.kept.value); + kept.delete(host); + this.kept.value = kept; + } for (const key of this.reported.keys()) if (key.startsWith(prefix)) this.reported.delete(key); for (const key of this.writes.keys()) if (key.startsWith(prefix)) this.writes.delete(key); } diff --git a/src/web/index.html b/src/web/index.html index 2a58fad..00eb49a 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -4,6 +4,7 @@ worktree-term + diff --git a/src/web/main/styles.css b/src/web/main/styles.css index 4ccdee5..f03bd35 100644 --- a/src/web/main/styles.css +++ b/src/web/main/styles.css @@ -222,7 +222,9 @@ button { .preset-picker, .preset-choices, -.close-dialog { +.close-dialog, +.confirm-dialog, +.add-repo-dialog { background: var(--panel); border: 1px solid var(--border); padding: 8px; @@ -232,7 +234,9 @@ button { /* Undo the user agent's centring and colours of an open dialog. */ dialog.preset-picker, -dialog.close-dialog { +dialog.close-dialog, +dialog.confirm-dialog, +dialog.add-repo-dialog { right: auto; margin: 0; color: inherit; @@ -247,7 +251,9 @@ dialog.close-dialog { z-index: 10; } -.close-dialog { +.close-dialog, +.confirm-dialog, +.add-repo-dialog { position: fixed; top: 30%; left: 50%; @@ -257,7 +263,9 @@ dialog.close-dialog { .preset-picker .title, .preset-choices .title, -.close-dialog .title { +.close-dialog .title, +.confirm-dialog .title, +.add-repo-dialog .title { margin-bottom: 6px; color: var(--muted); } @@ -282,13 +290,17 @@ dialog.close-dialog { color: var(--muted); } -.close-dialog .buttons { +.close-dialog .buttons, +.confirm-dialog .buttons, +.add-repo-dialog .buttons { display: flex; justify-content: flex-end; gap: 6px; } -.close-dialog .buttons button { +.close-dialog .buttons button, +.confirm-dialog .buttons button, +.add-repo-dialog .buttons button { border-color: var(--border); } @@ -325,3 +337,48 @@ dialog.close-dialog { visibility: hidden; pointer-events: none; } + +.repo-tab { + display: flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border: 1px solid transparent; + cursor: pointer; +} + +.repo-tab .close, +.add-repo { + padding: 0 4px; +} + +.remove-repo::before { + content: '×'; +} + +.host-status { + color: var(--muted); + font-style: italic; +} + +.add-repo-dialog { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 420px; +} + +.add-repo-options { + display: flex; + flex-direction: column; + max-height: 240px; + overflow-y: auto; +} + +.add-repo-options button { + text-align: left; +} + +.add-repo-dialog .error { + color: #f38ba8; +} diff --git a/src/web/public/icon.svg b/src/web/public/icon.svg new file mode 100644 index 0000000..a76bc25 --- /dev/null +++ b/src/web/public/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/web/ui/app.tsx b/src/web/ui/app.tsx index c80737f..86f8035 100644 --- a/src/web/ui/app.tsx +++ b/src/web/ui/app.tsx @@ -1,13 +1,15 @@ -import type { JSX } from 'preact'; +import type { JSX, TargetedKeyboardEvent } from 'preact'; import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'; import type { HostEntry, Preset, Terminal } from '../../protocol/index.js'; import { repoKey, type HubClient } from '../client/index.js'; import { termsOf, type Divider } from '../layout/index.js'; import { followArea, type TerminalManager } from '../terminals/index.js'; +import { hasBanner, hostAction } from './hosts.js'; import { aggregate, markTitle, type Aggregate } from './marks.js'; import { pickerKey } from './picker.js'; import type { SidebarEntry } from './sidebar.js'; -import { PANE_HEADER, type CloseRequest, type ShownPane, type ShownTab, type View } from './view.js'; +import type { RepoTab } from './repo-tabs.js'; +import { PANE_HEADER, type AddRepoDialog, type CloseRequest, type Confirmation, type ShownPane, type ShownTab, type View } from './view.js'; export interface AppProps { client: HubClient; @@ -35,17 +37,232 @@ const OutdatedUi = () => ( ); -const OutdatedHost = ({ host, client }: { host: HostEntry; client: HubClient }) => { - const restart = (): void => { - if (!window.confirm(`Restart the daemon on ${host.name}? Every terminal on ${host.name} will be killed.`)) return; - client.restartDaemon(host.idx).catch(report('restarting the daemon')); +/** The banner of a `down` or `outdated` host, with its action. */ +const HostBanner = ({ host, view }: { host: HostEntry; view: View }) => { + const action = hostAction(host); + const what = host.status === 'outdated' ? `outdated daemon ${host.daemonVersion ?? '(unknown version)'}` : 'down'; + return ( + + ); +}; + +/** Handles Enter and Escape on a dialog: Enter confirms unless a button has the focus, Escape cancels. */ +const dialogKeys = + (confirm: (() => void) | null, cancel: () => void) => + (event: TargetedKeyboardEvent): void => { + if (event.key !== 'Escape' && (event.key !== 'Enter' || confirm === null)) return; + // A focused control acts on its own. + if (event.key === 'Enter' && event.target !== event.currentTarget) return; + event.preventDefault(); + event.stopPropagation(); + if (event.key === 'Enter') confirm?.(); + else cancel(); }; + +/** What a confirmation asks, and what it lists. */ +const ConfirmBody = ({ confirmation }: { confirmation: Confirmation }) => { + if (confirmation.t === 'removeRepo') { + return
Remove {confirmation.label} from the repo tabs?
; + } + const { host, action, recalled } = confirmation; + const title = action.kind === 'restart' ? `Restart the daemon on ${host.name}?` : `${action.label} wtd on ${host.name}?`; + if (recalled === null) { + return ( + <> +
{title}
+

Every running terminal on {host.name} will be killed.

+ + ); + } return ( -