From cda25b063e3c2f366ebe31bd6e057754459375d3 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:10:41 +0200 Subject: [PATCH 1/4] feat(cli): add `pty-relay completions ` (bash/fish/zsh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pty-relay shipped no completions at all. Add a generator that emits all three shells from ONE declarative spec of the command tree, so they can't drift apart — the same design as `pty completions` and `st completions`, extended with nested verbs for pty-relay's `server`/`client`/`local`/ `clients` groups. The spec is derived from the `switch` dispatch in cli.ts (including CLIENT_PASSTHROUGH_COMMANDS), not from the prose `--help` text. Peer/host names are deliberately not completed dynamically: peers live in the encrypted secret store, so there is no cheap reliable source at completion time. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01T1xLDkqUDCYUMMADdMqVgP agent-session-id: 312caff5-3274-4d97-baa4-8ff06ab03fc5 agent-tool: Claude Code agent-tool-version: 2.1.215 agent-model: claude-opus-4-8 agent-runtime-profile: /nix/store/acr8a3l2v366jgmwiq8xdrhgz1py0db5-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/sj1v5j91h8v8d1w9lca4040302lwrd6v-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@unknown-dirty --- src/cli.ts | 13 +- src/completions.ts | 710 +++++++++++++++++++++++++++++++++++++++ test/completions.test.ts | 185 ++++++++++ 3 files changed, 907 insertions(+), 1 deletion(-) create mode 100644 src/completions.ts create mode 100644 test/completions.test.ts diff --git a/src/cli.ts b/src/cli.ts index 1d14fc8..4a7376c 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -87,6 +87,7 @@ Commands: server --help Show public-relay subcommands client signin --email Register this device as an account-wide client client --help Show client subcommands + completions Print a shell completion script (bash|fish|zsh) version Print the pty-relay version Options: @@ -243,12 +244,14 @@ async function main(): Promise { // "--help" deeper in the argv (e.g. `pty-relay send h s "see --help"`) // isn't swallowed. // Namespaced commands like `server` handle their own subcommand help; - // short-circuiting here would hide per-subcommand usage. + // short-circuiting here would hide per-subcommand usage. `completions` + // is excluded for the same reason — it documents its own shell list. if ( command && command !== "server" && command !== "client" && command !== "local" && + command !== "completions" && (args[1] === "--help" || args[1] === "-h") ) { usage(); @@ -784,6 +787,14 @@ async function main(): Promise { break; } + case "completions": { + const { cmdCompletions } = await import("./completions.ts"); + // Set exitCode rather than process.exit() so the generated script is + // fully flushed when stdout is a pipe or a redirect. + process.exitCode = cmdCompletions(args.slice(1)); + break; + } + case "psk-gen": { // Print a fresh 32-byte PSK to stdout as 43-char URL-safe-base64. // Output is the bare base64 string — no banner, no newline-fence diff --git a/src/completions.ts b/src/completions.ts new file mode 100644 index 0000000..bae2024 --- /dev/null +++ b/src/completions.ts @@ -0,0 +1,710 @@ +// completions.ts — print shell completion scripts for `pty-relay`. +// +// pty-relay dispatches subcommands via hand-written `switch` statements in +// cli.ts and its `--help` output is prose, so there is no machine-readable +// command table to derive completions from. Instead this module owns a small +// declarative spec of the command tree (groups → verbs → flags, plus +// enum-valued flags and positionals) and generates fish / bash / zsh from +// that ONE spec, so the three scripts can't drift apart. +// +// This mirrors `pty completions` (src/completions.ts) and `st completions` +// (src/commands/completions.ts) so the three sibling CLIs stay consistent. +// pty-relay's shape differs from pty's flat surface in one way: it has +// namespaced groups (`server`, `client`, `local`, `clients`) that dispatch +// their own verbs, so the spec carries `verbs` like `st`'s does. +// +// Deliberately NOT dynamic: pty completes live session names off disk, but +// pty-relay's peers live in the encrypted secret store (the plaintext +// ~/.config/pty-relay/peers file is optional and usually absent), so there is +// no cheap, reliable source for host-label completion at completion time. +// +// This module must stay free of top-level side effects — cli.ts runs `main()` +// on import, so tests import THIS file, not cli.ts. It also runs under Node's +// --experimental-strip-types, so keep to type annotations and `as const`. +// +// Keep the spec in sync with the `switch` dispatch in cli.ts. + +// ─── Spec ────────────────────────────────────────────────────────────────── + +/** A `--flag`. `values` (when present) is the closed set of completions for + * the flag's argument; absence means a boolean flag or a free-form value. */ +interface FlagSpec { + readonly name: string; + readonly desc: string; + /** Short spelling, e.g. `d` for `--detach`. */ + readonly short?: string; + readonly values?: readonly string[]; +} + +/** A command node: a top-level subcommand, a group, or a verb under a group. */ +interface CommandSpec { + readonly name: string; + readonly desc: string; + /** Aliases for the command name (e.g. `ls` for `list`). */ + readonly aliases?: readonly string[]; + /** Nested verbs (e.g. `signin` under `server`). */ + readonly verbs?: readonly CommandSpec[]; + /** Flags accepted directly by this command/verb. */ + readonly flags?: readonly FlagSpec[]; + /** A closed set of values for a positional argument (e.g. `show|code` in + * `server totp `). */ + readonly positionalValues?: readonly string[]; + /** Offer file/path completion for a positional (e.g. `rsync `). */ + readonly takesPath?: boolean; +} + +/** Storage backends accepted by `init --backend`. */ +const BACKEND_VALUES = ["keychain", "passphrase"] as const; + +/** Key roles accepted by `server rotate --role`. */ +const ROLE_VALUES = ["daemon", "client"] as const; + +const SHELL_VALUES = ["bash", "fish", "zsh"] as const; + +const JSON_FLAG: FlagSpec = { name: "json", desc: "Emit JSON" }; +const FORCE_FLAG: FlagSpec = { name: "force", desc: "Skip confirmation" }; +const YES_FLAG: FlagSpec = { name: "yes", short: "y", desc: "Skip the y/N prompt" }; +const CONFIG_DIR_FLAG: FlagSpec = { name: "config-dir", desc: "Config directory" }; +const PASSPHRASE_FILE_FLAG: FlagSpec = { + name: "passphrase-file", + desc: "Read passphrase from file (non-interactive)", +}; +const RELAY_FLAG: FlagSpec = { name: "relay", desc: "Relay origin URL" }; +const LABEL_FLAG: FlagSpec = { name: "label", desc: "Label for this device" }; +const DETACH_FLAG: FlagSpec = { + name: "detach", + short: "d", + desc: "Run detached in a 'pty' session", +}; +const NAME_FLAG: FlagSpec = { name: "name", desc: "Name for the wrapped pty session" }; + +/** + * Flags every command accepts because they're parsed off the full argv in + * cli.ts rather than per-command. Appended to each node so `--config-dir` + * completes wherever it is actually honored. + */ +const COMMON_FLAGS: readonly FlagSpec[] = [CONFIG_DIR_FLAG, PASSPHRASE_FILE_FLAG]; + +/** + * Flags accepted before the subcommand. `--verbose` is stripped from argv + * before positional parsing, so it is genuinely position-independent. + */ +const GLOBAL_FLAGS: readonly FlagSpec[] = [ + { name: "verbose", desc: "Print timing + internal state to stderr" }, + CONFIG_DIR_FLAG, + PASSPHRASE_FILE_FLAG, + { name: "psk-file", desc: "Load a 32-byte PSK from " }, + { name: "help", short: "h", desc: "Show usage" }, + { name: "version", short: "v", desc: "Print the pty-relay version" }, +]; + +/** + * The pty-relay command tree. Keep in sync with the `switch` dispatch in + * src/cli.ts (`main`, `dispatchServer`, `dispatchClient`, `dispatchLocal`). + */ +const COMMANDS: readonly CommandSpec[] = [ + { + name: "init", + desc: "Initialize secret storage (first-time setup)", + flags: [{ name: "backend", desc: "Storage backend", values: BACKEND_VALUES }, FORCE_FLAG], + }, + { + name: "reset", + desc: "Delete all saved credentials (start over)", + flags: [FORCE_FLAG], + }, + { + name: "peers", + desc: "List known peers (terse — no session fanout)", + flags: [JSON_FLAG], + }, + { + name: "list", + aliases: ["ls"], + desc: "List known peers and their sessions (fans out)", + flags: [JSON_FLAG, { name: "filter-tag", desc: "Filter sessions by k=v (repeatable)" }], + }, + { + name: "peek", + desc: "Print the current screen of a remote session", + flags: [ + { name: "plain", desc: "Plain text (no ANSI)" }, + { name: "full", desc: "Include full scrollback" }, + { name: "wait", desc: "Poll until text appears (repeatable)" }, + { name: "timeout", short: "t", desc: "Timeout (seconds) for --wait" }, + ], + }, + { + name: "send", + desc: "Send text or key events to a remote session", + flags: [ + { name: "seq", desc: "Ordered chunk / key event (repeatable)" }, + { name: "with-delay", desc: "Delay between --seq items (seconds)" }, + { name: "paste", desc: "Wrap payload in bracketed-paste markers" }, + ], + }, + { + name: "kill", + desc: "Terminate a session on a remote ssh:// peer", + }, + { + name: "tag", + desc: "Show / set / remove tags on a remote session", + flags: [{ name: "rm", desc: "Remove tag key (repeatable)" }, JSON_FLAG], + }, + { + name: "events", + desc: "Follow events from a remote daemon", + flags: [{ name: "session", desc: "Filter to a single session" }, JSON_FLAG], + }, + { + name: "rename", + desc: "Rename a saved peer", + }, + { + name: "forget", + desc: "Remove a saved peer", + }, + { + name: "add", + desc: "Add an ssh-reachable peer", + flags: [LABEL_FLAG], + }, + { + name: "connect", + desc: "Connect to a remote pty session (or list sessions)", + flags: [ + { name: "spawn", desc: "Spawn a new remote session" }, + { name: "cwd", desc: "Working directory for the spawned session" }, + { name: "session", desc: "Attach a named session" }, + { name: "tag", desc: "Tag the spawned session (k=v, repeatable)" }, + { name: "psk-file", desc: "Opt into Noise_NKpsk2 with the PSK in " }, + ], + }, + { + name: "exec", + desc: "Run a non-PTY command on a remote daemon", + }, + { + name: "rsync", + desc: "Run rsync over an exec channel", + takesPath: true, + }, + { + name: "local", + desc: "Run a self-hosted relay on this machine", + verbs: [ + { + name: "start", + desc: "Run a self-hosted relay (default port: 8099)", + flags: [ + { name: "port", desc: "Listen port (default 8099)" }, + { name: "bind", desc: "Bind address" }, + { name: "tailscale", desc: "Enable Tailscale HTTPS via 'tailscale serve'" }, + { name: "auto-approve", desc: "Skip the per-client approval TUI" }, + { name: "psk-file", desc: "Require Noise_NKpsk2 using the PSK in " }, + { name: "allow-new-sessions", desc: "Let remote clients spawn new pty sessions" }, + { + name: "skip-allow-new-sessions-confirmation", + desc: "Don't prompt before enabling remote spawn", + }, + { name: "allow-exec", desc: "Let remote clients spawn non-PTY processes" }, + { + name: "skip-allow-exec-confirmation", + desc: "Don't prompt before enabling --allow-exec", + }, + { name: "latency-stats", desc: "Enable web-UI latency telemetry" }, + { name: "mosh", desc: "(BETA) Predictive local echo in the web UI" }, + { name: "skip-osc8-confirm", desc: "Open OSC 8 links without confirming" }, + DETACH_FLAG, + NAME_FLAG, + ], + }, + { + name: "status", + desc: "Show daemon pid, label, pubkey, approved-client count", + flags: [ + { name: "show-token", desc: "Also print the token URL" }, + { name: "port", desc: "Probe the given port for liveness" }, + JSON_FLAG, + ], + }, + { + name: "reset", + desc: "Wipe just self-hosted daemon state", + flags: [FORCE_FLAG], + }, + ], + }, + { + name: "server", + desc: "Public-relay account management", + flags: [RELAY_FLAG], + verbs: [ + { + name: "signin", + desc: "Register this daemon on a public relay", + flags: [{ name: "email", desc: "Account email address" }, LABEL_FLAG, RELAY_FLAG], + }, + { + name: "mint", + desc: "Mint a one-time preauth to invite a device", + flags: [ + { name: "ttl-seconds", desc: "Preauth lifetime in seconds" }, + { name: "totp-code", desc: "Non-interactive TOTP code" }, + ], + }, + { + name: "start", + desc: "Run the daemon attached to a public relay", + flags: [ + { name: "allow-new-sessions", desc: "Let remote clients spawn new pty sessions" }, + DETACH_FLAG, + NAME_FLAG, + ], + }, + { + name: "status", + desc: "Show this device's enrollment info", + flags: [JSON_FLAG], + }, + { + name: "hosts", + desc: "List registered keys on this account", + flags: [{ name: "merge", desc: "Add peer daemons to known_hosts" }, JSON_FLAG], + }, + { + name: "totp", + desc: "Show the TOTP secret / current code", + positionalValues: ["show", "code"], + }, + { + name: "rotate", + desc: "Two-step Ed25519 key rotation (per role)", + flags: [ + { name: "role", desc: "Key role to rotate", values: ROLE_VALUES }, + { name: "complete", desc: "Complete a started rotation" }, + ], + }, + { + name: "revoke", + desc: "Revoke a peer device's key", + flags: [{ name: "force", desc: "Allow revoking THIS device's key" }, YES_FLAG], + }, + { + name: "add-email", + desc: "Add a secondary email to the account", + flags: [ + { name: "email", desc: "Email address to add" }, + { name: "email-code", desc: "Non-interactive verification code" }, + ], + }, + { + name: "delete-account", + desc: "Permanently delete the account", + flags: [YES_FLAG], + }, + { + name: "reset", + desc: "Request an account-key reset email", + flags: [{ name: "email", desc: "Account email address" }, RELAY_FLAG], + }, + ], + }, + { + name: "client", + desc: "Use sessions exposed by daemons (public or self-hosted)", + flags: [RELAY_FLAG], + verbs: [ + { + name: "signin", + desc: "Register this device as an account-wide client", + flags: [{ name: "email", desc: "Account email address" }, LABEL_FLAG, RELAY_FLAG], + }, + { + name: "join", + desc: "Claim a one-time preauth URL", + flags: [LABEL_FLAG, { name: "totp-code", desc: "Non-interactive TOTP code" }], + }, + // The rest are CLIENT_PASSTHROUGH_COMMANDS in cli.ts: `client ` + // strips the group and re-dispatches to the top-level handler, so the + // full top-level flag surface applies. Descriptions are kept terse + // here; the canonical spec lives on the top-level entry. + { name: "peers", desc: "List known peers (terse)" }, + { name: "list", aliases: ["ls"], desc: "List known peers and their sessions" }, + { name: "connect", desc: "Attach to a remote pty session" }, + { name: "peek", desc: "Print a remote session's screen" }, + { name: "send", desc: "Send input to a remote session" }, + { name: "tag", desc: "Show / set tags on a remote session" }, + { name: "events", desc: "Follow events from a remote daemon" }, + { name: "rename", desc: "Rename a saved known-host entry" }, + { name: "forget", desc: "Remove a saved host" }, + ], + }, + { + name: "clients", + desc: "Interactive client approval TUI", + verbs: [ + { name: "list", desc: "List client tokens", flags: [JSON_FLAG] }, + { name: "approve", desc: "Approve a pending client" }, + { name: "revoke", desc: "Revoke a client token", flags: [YES_FLAG] }, + { name: "invite", desc: "Generate a pre-approved invite URL", flags: [LABEL_FLAG] }, + ], + flags: [JSON_FLAG], + }, + { + name: "set-name", + desc: "Set a custom name for this daemon", + }, + { + name: "doctor", + desc: "Print environment info for troubleshooting", + }, + { + name: "psk-gen", + desc: "Print a fresh 32-byte PSK to stdout", + }, + { + name: "version", + desc: "Print the pty-relay version", + }, + { + name: "help", + desc: "Show usage", + }, + { + name: "completions", + desc: "Print a shell completion script", + positionalValues: SHELL_VALUES, + }, +]; + +/** Every spelling (name + aliases) of a node. */ +const spellings = (c: CommandSpec): readonly string[] => [c.name, ...(c.aliases ?? [])]; + +/** Every spelling of every top-level subcommand. */ +const allCommandNames = (): readonly string[] => COMMANDS.flatMap(spellings); + +/** A node's own flags plus the argv-wide ones cli.ts honors everywhere. */ +const flagsOf = (c: CommandSpec): readonly FlagSpec[] => { + const own = c.flags ?? []; + const ownNames = new Set(own.map((f) => f.name)); + return [...own, ...COMMON_FLAGS.filter((f) => !ownNames.has(f.name))]; +}; + +/** All `--flag` (and `-s`) spellings of a node, for bash/zsh candidate lists. */ +const flagWords = (c: CommandSpec): string => + flagsOf(c) + .map((f) => (f.short ? `-${f.short} --${f.name}` : `--${f.name}`)) + .join(" "); + +// ─── fish ────────────────────────────────────────────────────────────────── + +/** Single-quote a string for fish (fish only special-cases `'` and `\`). */ +function q(s: string): string { + return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`; +} + +/** All name spellings of a node, space-joined, for fish guards. */ +const fishNames = (c: CommandSpec): string => spellings(c).join(" "); + +function fishScript(): string { + const out: string[] = []; + out.push("# fish completions for pty-relay — generated by `pty-relay completions fish`."); + out.push( + "# Regenerate with: pty-relay completions fish > ~/.config/fish/completions/pty-relay.fish" + ); + out.push("# (kept in sync with src/cli.ts; see src/completions.ts)"); + out.push(""); + out.push("complete -c pty-relay -e"); + out.push("complete -c pty-relay -f"); + out.push(""); + out.push("# ── Global flags ───────────────────────────────────────────────────────"); + for (const f of GLOBAL_FLAGS) { + out.push( + `complete -c pty-relay -n __fish_use_subcommand -l ${f.name}${f.short ? ` -s ${f.short}` : ""} -d ${q(f.desc)}` + ); + } + out.push(""); + out.push("# ── Subcommands ────────────────────────────────────────────────────────"); + for (const c of COMMANDS) { + for (const name of spellings(c)) { + out.push( + `complete -c pty-relay -n __fish_use_subcommand -a ${name} -d ${q(c.desc)}` + ); + } + } + + for (const c of COMMANDS) { + const verbs = c.verbs ?? []; + if (verbs.length > 0) { + out.push(""); + out.push(`# ${c.name} verbs`); + // A verb is offered only once the group is seen and no verb yet is. + const verbNames = verbs.flatMap(spellings).join(" "); + const guard = `__fish_seen_subcommand_from ${fishNames(c)}; and not __fish_seen_subcommand_from ${verbNames}`; + for (const v of verbs) { + for (const name of spellings(v)) { + out.push(`complete -c pty-relay -n ${q(guard)} -a ${name} -d ${q(v.desc)}`); + } + } + // Verb names like `start`/`status`/`reset` collide across groups, so + // guard on BOTH the verb and its group. + for (const v of verbs) { + fishEmitForNode(out, v, [ + `__fish_seen_subcommand_from ${fishNames(v)}`, + `__fish_seen_subcommand_from ${fishNames(c)}`, + ]); + } + } + // Flags that live directly on the group/command (no verb). + fishEmitForNode(out, c, [`__fish_seen_subcommand_from ${fishNames(c)}`]); + } + + return out.join("\n") + "\n"; +} + +/** Emit fish `complete` lines for a node's flags and positional values, + * gated by `guards` (all must hold). */ +function fishEmitForNode( + out: string[], + node: CommandSpec, + guards: readonly string[] +): void { + const cond = guards.join("; and "); + for (const f of flagsOf(node)) { + const short = f.short ? ` -s ${f.short}` : ""; + if (f.values) { + out.push( + `complete -c pty-relay -n ${q(cond)} -l ${f.name}${short} -x -a ${q(f.values.join(" "))} -d ${q(f.desc)}` + ); + } else { + out.push(`complete -c pty-relay -n ${q(cond)} -l ${f.name}${short} -d ${q(f.desc)}`); + } + } + if (node.positionalValues) { + out.push( + `complete -c pty-relay -n ${q(cond)} -x -a ${q(node.positionalValues.join(" "))} -d ${q("Value")}` + ); + } + if (node.takesPath) { + out.push(`complete -c pty-relay -n ${q(cond)} -F`); + } +} + +// ─── bash ──────────────────────────────────────────────────────────────────── +// +// A flat completer: at depth 1 offer subcommands (or global flags); once a +// known group is the first word, offer its verbs and flags; at depth ≥ 3 +// inside a group, offer the matched verb's flags. Behavioral parity with fish +// is a non-goal — this gives useful subcommand + flag completion and is +// syntactically sourceable (`bash -n`). + +function bashScript(): string { + const lines: string[] = []; + lines.push("# bash completion for pty-relay — generated by `pty-relay completions bash`."); + lines.push("# Regenerate with: pty-relay completions bash > /etc/bash_completion.d/pty-relay"); + lines.push("_pty_relay() {"); + lines.push(" local cur prev cmd verb words"); + lines.push(" COMPREPLY=()"); + lines.push(' cur="${COMP_WORDS[COMP_CWORD]}"'); + lines.push(' prev="${COMP_WORDS[COMP_CWORD-1]}"'); + lines.push(' cmd="${COMP_WORDS[1]}"'); + lines.push(' verb="${COMP_WORDS[2]}"'); + lines.push(""); + lines.push(" # Enum-valued flags complete their value set."); + lines.push(...bashEnumCases()); + lines.push(""); + lines.push(" if [[ ${COMP_CWORD} -eq 1 ]]; then"); + lines.push(' if [[ "${cur}" == -* ]]; then'); + lines.push( + ` COMPREPLY=($(compgen -W "${GLOBAL_FLAGS.map((f) => `--${f.name}`).join(" ")}" -- "\${cur}"))` + ); + lines.push(" else"); + lines.push( + ` COMPREPLY=($(compgen -W "${allCommandNames().join(" ")}" -- "\${cur}"))` + ); + lines.push(" fi"); + lines.push(" return 0"); + lines.push(" fi"); + lines.push(""); + lines.push(" words=\"\""); + lines.push(' case "${cmd}" in'); + for (const c of COMMANDS) { + const names = spellings(c).join("|"); + const verbs = c.verbs ?? []; + if (verbs.length === 0) { + lines.push(` ${names}) words="${flagWords(c)}" ;;`); + continue; + } + // Group: depth 2 offers verbs + group flags; deeper offers verb flags. + lines.push(` ${names})`); + lines.push(" if [[ ${COMP_CWORD} -eq 2 ]]; then"); + lines.push( + ` words="${[...verbs.flatMap(spellings), ...flagsOf(c).map((f) => `--${f.name}`)].join(" ")}"` + ); + lines.push(" else"); + lines.push(' case "${verb}" in'); + for (const v of verbs) { + const vWords = [ + flagWords(v), + ...(v.positionalValues ? [v.positionalValues.join(" ")] : []), + ] + .filter(Boolean) + .join(" "); + lines.push(` ${spellings(v).join("|")}) words="${vWords}" ;;`); + } + lines.push(" esac"); + lines.push(" fi"); + lines.push(" ;;"); + } + lines.push(" esac"); + lines.push(""); + lines.push(' if [[ -n "${words}" ]]; then'); + lines.push(' COMPREPLY=($(compgen -W "${words}" -- "${cur}"))'); + lines.push(" fi"); + lines.push(" return 0"); + lines.push("}"); + lines.push("complete -F _pty_relay pty-relay"); + return lines.join("\n") + "\n"; +} + +/** The bash `case "$prev"` block completing enum flag values. Enum flag names + * (`--backend`, `--role`) are globally unambiguous in pty-relay, so no + * per-command scoping is needed. */ +function bashEnumCases(): string[] { + const lines: string[] = []; + lines.push(' case "${prev}" in'); + for (const [flag, values] of enumFlags()) { + lines.push( + ` --${flag}) COMPREPLY=($(compgen -W "${values.join(" ")}" -- "\${cur}")); return 0 ;;` + ); + } + lines.push(" esac"); + return lines; +} + +/** Every enum-valued flag in the tree, as `[flag-name, values]`. */ +function enumFlags(): readonly (readonly [string, readonly string[]])[] { + const seen = new Map(); + const walk = (nodes: readonly CommandSpec[]): void => { + for (const c of nodes) { + for (const f of c.flags ?? []) { + if (f.values) seen.set(f.name, f.values); + } + if (c.verbs) walk(c.verbs); + } + }; + walk(COMMANDS); + return [...seen.entries()]; +} + +// ─── zsh ───────────────────────────────────────────────────────────────────── +// +// A `#compdef`-style function. Like bash, behavioral parity with fish is a +// non-goal — this offers subcommands, verbs, flags and enum value sets, and +// is syntactically sourceable (`zsh -n`). + +function zshScript(): string { + const lines: string[] = []; + lines.push("#compdef pty-relay"); + lines.push("# zsh completion for pty-relay — generated by `pty-relay completions zsh`."); + lines.push('# Regenerate with: pty-relay completions zsh > "${fpath[1]}/_pty-relay"'); + lines.push("_pty_relay() {"); + lines.push(' local cmd="${words[2]}" verb="${words[3]}" prev="${words[CURRENT-1]}"'); + lines.push(""); + lines.push(' case "${prev}" in'); + for (const [flag, values] of enumFlags()) { + lines.push(` --${flag}) compadd ${values.join(" ")}; return ;;`); + } + lines.push(" esac"); + lines.push(""); + lines.push(' if [[ "${CURRENT}" -eq 2 ]]; then'); + lines.push(` compadd ${allCommandNames().join(" ")}; return`); + lines.push(" fi"); + lines.push(""); + lines.push(' case "${cmd}" in'); + for (const c of COMMANDS) { + const names = spellings(c).join("|"); + const verbs = c.verbs ?? []; + if (verbs.length === 0) { + lines.push(` ${names}) compadd ${flagWords(c)} ;;`); + continue; + } + lines.push(` ${names})`); + lines.push(' if [[ "${CURRENT}" -eq 3 ]]; then'); + lines.push( + ` compadd ${[...verbs.flatMap(spellings), ...flagsOf(c).map((f) => `--${f.name}`)].join(" ")}` + ); + lines.push(" else"); + lines.push(' case "${verb}" in'); + for (const v of verbs) { + const vWords = [ + flagWords(v), + ...(v.positionalValues ? [v.positionalValues.join(" ")] : []), + ] + .filter(Boolean) + .join(" "); + lines.push(` ${spellings(v).join("|")}) compadd ${vWords} ;;`); + } + lines.push(" esac"); + lines.push(" fi"); + lines.push(" ;;"); + } + lines.push(" esac"); + lines.push("}"); + lines.push(""); + lines.push('_pty_relay "$@"'); + return lines.join("\n") + "\n"; +} + +// ─── Dispatch ──────────────────────────────────────────────────────────────── + +const GENERATORS: Record string> = { + bash: bashScript, + fish: fishScript, + zsh: zshScript, +}; + +const SHELLS = Object.keys(GENERATORS); + +function usageText(): string { + return ( + "usage: pty-relay completions \n\n" + + "Print a shell completion script to stdout.\n\n" + + "Shells:\n" + + SHELLS.map((s) => ` ${s}`).join("\n") + + "\n\nExamples:\n" + + " pty-relay completions fish > ~/.config/fish/completions/pty-relay.fish\n" + + " pty-relay completions bash > /etc/bash_completion.d/pty-relay\n" + + ' pty-relay completions zsh > "${fpath[1]}/_pty-relay"\n' + ); +} + +/** + * `pty-relay completions ` — write a completion script for `shell` to + * stdout. Unknown or missing shell prints usage to stderr and returns 2; + * `--help`/`-h` prints usage to stdout and returns 0. + */ +export function cmdCompletions(args: readonly string[]): number { + const shell = args[0]; + if (shell === "--help" || shell === "-h") { + console.log(usageText()); + return 0; + } + if (shell === undefined) { + console.error(usageText()); + return 2; + } + const gen = GENERATORS[shell]; + if (gen === undefined) { + console.error(`pty-relay completions: unknown shell: ${shell}\n`); + console.error(usageText()); + return 2; + } + process.stdout.write(gen()); + return 0; +} + +// Exposed for tests. +export { COMMANDS, SHELLS, fishScript, bashScript, zshScript }; diff --git a/test/completions.test.ts b/test/completions.test.ts new file mode 100644 index 0000000..41019ef --- /dev/null +++ b/test/completions.test.ts @@ -0,0 +1,185 @@ +// completions.test.ts — coverage for `pty-relay completions `. +// +// Two layers: the generated-script surface (imported directly from +// src/completions.ts, which is side-effect free) and the CLI dispatch path +// (spawned, like test/cli.test.ts, because importing src/cli.ts runs main()). + +import { describe, it, expect } from "vitest"; +import * as path from "node:path"; +import * as os from "node:os"; +import { spawnSync } from "node:child_process"; + +import { + COMMANDS, + SHELLS, + bashScript, + fishScript, + zshScript, +} from "../src/completions.ts"; + +const CLI_ENTRY = path.resolve(import.meta.dirname, "../src/cli.ts"); + +function runCli(args: string[]): { stdout: string; stderr: string; exitCode: number } { + const result = spawnSync("node", [CLI_ENTRY, ...args], { + encoding: "utf-8", + timeout: 10000, + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + PTY_SESSION_DIR: path.join(os.tmpdir(), `pty-relay-completions-${Date.now()}`), + }, + }); + return { + stdout: result.stdout || "", + stderr: result.stderr || "", + exitCode: result.status ?? 1, + }; +} + +/** True when `bin` is on PATH — the shell syntax checks are opt-in on it. */ +function hasBinary(bin: string): boolean { + return spawnSync("sh", ["-c", `command -v ${bin}`], { stdio: "ignore" }).status === 0; +} + +describe("pty-relay completions — dispatch", () => { + it.each(SHELLS)("%s → non-empty script, exit 0", (shell) => { + const { stdout, exitCode } = runCli(["completions", shell]); + expect(exitCode).toBe(0); + expect(stdout.length).toBeGreaterThan(100); + }); + + it("missing shell → usage on stderr, exit 2", () => { + const { stdout, stderr, exitCode } = runCli(["completions"]); + expect(exitCode).toBe(2); + expect(stdout).toBe(""); + expect(stderr).toContain("usage: pty-relay completions"); + }); + + it("unknown shell → usage on stderr, exit 2", () => { + const { stderr, exitCode } = runCli(["completions", "powershell"]); + expect(exitCode).toBe(2); + expect(stderr).toContain("unknown shell: powershell"); + expect(stderr).toContain("usage: pty-relay completions"); + }); + + it("--help → its own usage on stdout, exit 0", () => { + const { stdout, exitCode } = runCli(["completions", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("usage: pty-relay completions"); + }); + + it("top-level usage advertises the completions subcommand", () => { + const { stdout } = runCli(["notacommand"]); + expect(stdout).toContain("completions "); + }); +}); + +describe("pty-relay completions — spec covers real dispatch", () => { + const names = new Set(COMMANDS.flatMap((c) => [c.name, ...(c.aliases ?? [])])); + + // Every `case` label in cli.ts's top-level switch that a user can type. + it.each([ + "connect", + "exec", + "rsync", + "list", + "ls", + "peers", + "peek", + "send", + "kill", + "events", + "tag", + "rename", + "add", + "forget", + "clients", + "set-name", + "init", + "reset", + "help", + "version", + "doctor", + "server", + "client", + "local", + "psk-gen", + "completions", + ])("spec has an entry for `%s`", (cmd) => { + expect(names.has(cmd)).toBe(true); + }); + + it("groups carry the verbs their dispatchers accept", () => { + const verbsOf = (name: string) => + new Set( + COMMANDS.find((c) => c.name === name)?.verbs?.flatMap((v) => [ + v.name, + ...(v.aliases ?? []), + ]) ?? [] + ); + + for (const v of ["signin", "mint", "start", "status", "hosts", "totp", "rotate", + "revoke", "add-email", "delete-account", "reset"]) { + expect(verbsOf("server")).toContain(v); + } + for (const v of ["start", "status", "reset"]) { + expect(verbsOf("local")).toContain(v); + } + for (const v of ["list", "approve", "revoke", "invite"]) { + expect(verbsOf("clients")).toContain(v); + } + // CLIENT_PASSTHROUGH_COMMANDS plus the two client-only verbs. + for (const v of ["signin", "join", "ls", "peers", "connect", "peek", "send", + "tag", "events", "rename", "forget"]) { + expect(verbsOf("client")).toContain(v); + } + }); +}); + +describe("pty-relay completions fish — surface", () => { + const script = fishScript(); + + it("offers every top-level subcommand", () => { + for (const c of COMMANDS) { + expect(script).toContain(`-a ${c.name} -d`); + } + }); + + it("binds the backend enum to `init --backend`", () => { + expect(script).toContain("-l backend -x -a 'keychain passphrase'"); + }); + + it("binds the role enum to `server rotate --role`", () => { + expect(script).toContain("-l role -x -a 'daemon client'"); + }); + + it("guards colliding verbs on both the verb and its group", () => { + // `start` exists under both `local` and `server`. + expect(script).toContain( + "__fish_seen_subcommand_from start; and __fish_seen_subcommand_from local" + ); + expect(script).toContain( + "__fish_seen_subcommand_from start; and __fish_seen_subcommand_from server" + ); + }); +}); + +describe("pty-relay completions — generated scripts are syntactically valid", () => { + it.runIf(hasBinary("bash"))("bash -n accepts the bash script", () => { + const r = spawnSync("bash", ["-n"], { input: bashScript(), encoding: "utf-8" }); + expect(r.stderr).toBe(""); + expect(r.status).toBe(0); + }); + + it.runIf(hasBinary("fish"))("fish -n accepts the fish script", () => { + const r = spawnSync("fish", ["-n"], { input: fishScript(), encoding: "utf-8" }); + expect(r.stderr).toBe(""); + expect(r.status).toBe(0); + }); + + it.runIf(hasBinary("zsh"))("zsh -n accepts the zsh script", () => { + const r = spawnSync("zsh", ["-n"], { input: zshScript(), encoding: "utf-8" }); + expect(r.stderr).toBe(""); + expect(r.status).toBe(0); + }); +}); From 1463d20d996f8bf8024f37a4c0da23b18ba39b8f Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:12:46 +0200 Subject: [PATCH 2/4] feat(nix): add a self-contained flake building pty-relay from source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packaging lived out-of-tree (in a dotfiles wrapper) and therefore had to fetchFromGitHub a pinned rev. In-repo it builds from `self`, so the flake tracks the checkout it ships with. Inputs are nixpkgs, flake-utils and the sibling `pty` flake only — no private tooling — so `nix build` works standalone. Notes on what changed versus the out-of-tree wrapper: - The npm name is `@compoundingtech/pty`, and it is now a `file:../pty` link dependency rather than a peerDependency. `npm ci` resolves it to a dangling symlink in the sandbox, so no `--legacy-peer-deps` is needed; installPhase replaces the link with the store path from the `pty` flake. The mapping is a declarative npm-name -> store-path attrset. - One `nodejs` binding feeds both the derivation and the bin shim, so a build and a run cannot disagree on the interpreter. - Completions are generated at build time from the just-built binary and installed via installShellCompletion, so they cannot lag the CLI. Verified: `nix build .#default` and `nix flake check` pass, and `pty-relay --help`, `pty-relay completions fish` and `pty-relay doctor` (which resolves the sibling pty link) all work from the built output. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01T1xLDkqUDCYUMMADdMqVgP agent-session-id: 312caff5-3274-4d97-baa4-8ff06ab03fc5 agent-tool: Claude Code agent-tool-version: 2.1.215 agent-model: claude-opus-4-8 agent-runtime-profile: /nix/store/acr8a3l2v366jgmwiq8xdrhgz1py0db5-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/sj1v5j91h8v8d1w9lca4040302lwrd6v-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@unknown-dirty --- .gitignore | 2 + flake.lock | 82 ++++++++++++++++++++++++++++++++ flake.nix | 136 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.gitignore b/.gitignore index 44d7e4a..edf660d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ dist/ !browser/dist/ *.tsbuildinfo test-results/ +result +result-* .env* .claude/ pty.toml diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..f1ba415 --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1784497964, + "narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "pty": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1784556029, + "narHash": "sha256-52SqV7ANR/r+gqGNo/e2JUmQImpmCgmMUScd0KZlTW0=", + "owner": "compoundingtech", + "repo": "pty", + "rev": "0a9be8c06bd9e3f0c1acbb34784762075ef53eb3", + "type": "github" + }, + "original": { + "owner": "compoundingtech", + "repo": "pty", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "pty": "pty" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..fb271d3 --- /dev/null +++ b/flake.nix @@ -0,0 +1,136 @@ +{ + description = "pty-relay — remote access to pty sessions over an end-to-end encrypted WebSocket tunnel"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + pty.url = "github:compoundingtech/pty"; + pty.inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + pty, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + + # Node runtime for both the derivation's npm steps and the bin shim, + # so a build and a run can never disagree on the interpreter. + nodejs = pkgs.nodejs_24; + + # Unpublished sibling packages, resolved from their own flakes and + # linked into node_modules at install time. npm records + # `@compoundingtech/pty` as `file:../pty`, which is a dangling link + # inside the sandbox; these store paths are what actually resolve. + # TODO(rust): the Rust rewrite links this natively — drop the map. + siblingPackages = { + "@compoundingtech/pty" = "${pty.packages.${system}.default}/lib/pty"; + }; + + linkSiblings = pkgs.lib.concatStringsSep "\n" ( + pkgs.lib.mapAttrsToList (name: path: '' + mkdir -p "$out/lib/pty-relay/node_modules/${builtins.dirOf name}" + rm -rf "$out/lib/pty-relay/node_modules/${name}" + ln -s ${path} "$out/lib/pty-relay/node_modules/${name}" + '') siblingPackages + ); + + # Single source of truth: package.json. Build identity beyond the + # semver (commit rev, build date) is the org's shared build-identity + # contract, not something this flake invents. + version = (builtins.fromJSON (builtins.readFile ./package.json)).version; + + pty-relay = pkgs.buildNpmPackage { + pname = "pty-relay"; + inherit version nodejs; + + src = self; + + # TODO(rust): cargo's lockfile is content-addressed; this vendoring + # hash disappears with the npm dependency tree. + # Regenerate with: nix run nixpkgs#prefetch-npm-deps -- package-lock.json + npmDepsHash = "sha256-wDKiIRJivnTFd0dXCdKw+GoLJA6T53a/5sDCsbxvkUU="; + + # pty-relay ships as raw TypeScript executed by Node with native + # type stripping — no compile step. Only the browser bundle has one, + # and the daemon/CLI don't need it. + dontNpmBuild = true; + + nativeBuildInputs = [ pkgs.installShellFiles ]; + + # Installed outside node_modules so Node's type-stripping works on + # src/cli.ts (Node refuses to strip types inside node_modules). + installPhase = '' + runHook preInstall + + mkdir -p $out/lib/pty-relay + cp -r . $out/lib/pty-relay + + # TODO(rust): sibling linking is an npm-workspace workaround. + ${linkSiblings} + + # TODO(rust): a compiled binary needs no interpreter shim. + mkdir -p $out/bin + cat > $out/bin/pty-relay < /dev/null + touch $out + ''; + + completions = pkgs.runCommand "pty-relay-completions" { } '' + export HOME=$(mktemp -d) + for shell in bash zsh fish; do + ${pty-relay}/bin/pty-relay completions $shell > script + test -s script || { echo "empty $shell completions"; exit 1; } + done + touch $out + ''; + }; + + devShells.default = pkgs.mkShell { + packages = [ + nodejs + pty.packages.${system}.default + ]; + }; + } + ); +} From c8c197e593fcf04b893ac87077e70841ea028a89 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:15:17 +0200 Subject: [PATCH 3/4] chore(test): exclude the nix `result` symlink from vitest collection `nix build` drops a `result` symlink holding a full copy of the source tree, so `vitest run` collected and ran every test twice. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01T1xLDkqUDCYUMMADdMqVgP agent-session-id: 312caff5-3274-4d97-baa4-8ff06ab03fc5 agent-tool: Claude Code agent-tool-version: 2.1.215 agent-model: claude-opus-4-8 agent-runtime-profile: /nix/store/acr8a3l2v366jgmwiq8xdrhgz1py0db5-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/sj1v5j91h8v8d1w9lca4040302lwrd6v-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@unknown-dirty --- vitest.config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/vitest.config.ts b/vitest.config.ts index b08afcb..8f9c51c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,9 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, - exclude: ["integration/**", "node_modules/**"], + // `result` is the nix build symlink; it contains a full copy of the + // source tree, so without this every test would be collected twice. + exclude: ["integration/**", "node_modules/**", "result/**"], setupFiles: ["./test/setup.ts"], globalSetup: ["./test/setup/vitest-global.ts"], }, From 892c3202b63f5c366d952e658f867a7c99ff9d11 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:51:26 +0200 Subject: [PATCH 4/4] ci: gate on `nix flake check` (first CI for this repo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pty-relay had no CI. Rather than a bespoke Node workflow — awkward while `@compoundingtech/pty` is a `file:../pty` link dependency with no sibling checkout on the runner — the flake is the gate: its inputs resolve the sibling from the `pty` flake. Modeled on compoundingtech/pty's .github/workflows/nix.yml, but running `nix flake check` rather than `nix build` so checks.* actually gate. Also adds checks.typecheck, running the repo's own `tsc --noEmit` against the built tree (where the sibling resolves; it cannot pass against a bare checkout). The vitest suite is deliberately not gated: under the nix sandbox test/daemon-runtime.test.ts hangs indefinitely at 0/14 and blocks two more files, while the other 69/72 pass. The same suite is fully green against the built tree outside the sandbox. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01T1xLDkqUDCYUMMADdMqVgP agent-session-id: 312caff5-3274-4d97-baa4-8ff06ab03fc5 agent-tool: Claude Code agent-tool-version: 2.1.215 agent-model: claude-opus-4-8 agent-runtime-profile: /nix/store/acr8a3l2v366jgmwiq8xdrhgz1py0db5-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/sj1v5j91h8v8d1w9lca4040302lwrd6v-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@unknown-dirty --- .github/workflows/nix.yml | 15 +++++++++++++++ flake.nix | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 .github/workflows/nix.yml diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000..a0aed28 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,15 @@ +name: Nix +on: + pull_request: + push: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: DeterminateSystems/determinate-nix-action@v3 + # `nix flake check` rather than `nix build`, so the checks.* gate too. + - run: nix flake check --print-build-logs diff --git a/flake.nix b/flake.nix index fb271d3..452d3cb 100644 --- a/flake.nix +++ b/flake.nix @@ -109,6 +109,25 @@ }; checks = { + # The repo's own `tsc --noEmit`. It only passes once + # @compoundingtech/pty resolves, which is exactly what the built + # output provides — so run it against that rather than the raw src. + typecheck = pkgs.runCommand "pty-relay-typecheck" { } '' + export HOME=$(mktemp -d) + cp -r ${pty-relay}/lib/pty-relay tree + chmod -R u+w tree + cd tree + ${nodejs}/bin/node node_modules/typescript/bin/tsc --noEmit + touch $out + ''; + + # NOTE: the vitest suite is deliberately NOT a check. It runs + # green against this same built tree outside the sandbox, but under + # the nix sandbox test/daemon-runtime.test.ts hangs indefinitely + # (0/14, blocking session-list-view and terminal); the other 69/72 + # files pass. Gating `npm test` needs that file made sandbox-safe + # first, so CI covers typecheck + the CLI smoke checks only. + help = pkgs.runCommand "pty-relay-help" { } '' export HOME=$(mktemp -d) ${pty-relay}/bin/pty-relay --help > /dev/null