From 6799a57405afc7326c5d5eb7d5dd88662df01c48 Mon Sep 17 00:00:00 2001 From: veg Date: Sat, 11 Jul 2026 12:24:31 +0000 Subject: [PATCH] feat: party knock asks an invite-only party's host for an invite party list already shows invite-only parties; knock closes the loop. The knocker can't run any tmux command against the server, so the channel is write(1), the same one invite uses toward guests, except here delivery failure is surfaced: the ping is the whole feature. --- README.md | 1 + ROADMAP.md | 4 --- party | 65 ++++++++++++++++++++++++++++++++++++ party.1 | 16 +++++++++ tests/65-knock.bats | 81 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 tests/65-knock.bats diff --git a/README.md b/README.md index 741ab15..13e067c 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ Three honest caveats, with the full detail in `man party`: | `party close` | Tear down the party server and its roster entry. Host-only. | | `party join [name] [--passive]` | Join a party. Auto-attaches when one is running; picker otherwise. `--passive` attaches read-only to the host's view (watcher mode). Read-only invitees always join passive. | | `party leave` | Detach and clean up the per-guest session. | +| `party knock ` | Ask the host of an invite-only party for an invite (write(1) ping with the exact invite command). | | `party role [active\|passive\|switch]` | Flip your clients between guest and host session. No arg prints the current role. | | `party --help` | Help text. | diff --git a/ROADMAP.md b/ROADMAP.md index 3b8d123..ae11b01 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,10 +12,6 @@ Direction, not promises. Two rules govern everything below (README ## Next: small, high value -- **`party knock `:** an uninvited group member pings the host - via `write(1)`: "veg wants to join fiesta". `party list` already - shows invite-only parties; knock completes that loop. The feature - most likely to cause spontaneous pairing. - **`party log`:** per-party transcript via `tmux pipe-pane` into the per-party dir (group-readable so every attendee can grab a copy); `party log --stop` ends it. "What did we do last night?" for diff --git a/party b/party index 05b6519..326453e 100755 --- a/party +++ b/party @@ -55,6 +55,7 @@ Joining: --passive lands in the host's session (mirrored view). Read-only invitees always join passive. leave Detach from the party you joined. + knock [name] Ask an invite-only party's host for an invite. role [active|passive|switch] Flip your clients between guest session and host session. @@ -1099,6 +1100,69 @@ cmd_leave() { [ "$found" -gt 0 ] || { echo "party: not joined to any party." >&2; exit 1; } } + +# Send the knock ping. Factored out of cmd_knock so the write(1) +# interaction is testable: the CLI path to it needs a party hosted by +# a DIFFERENT user, and roster trust anchors on dir ownership, which a +# single-uid test run cannot fake. +knock_send() { + write "$1" 2>/dev/null <&2; exit 2 ;; + *) + if [ -z "$name" ]; then name="$1"; shift + else echo "party knock: unexpected arg '$1'" >&2; exit 2; fi + ;; + esac + done + + if [ -n "$name" ]; then + find_party_by_name "$name" && rc=0 || rc=$? + case $rc in + 0) ;; + 1) echo "party: '$name' not found." >&2; exit 1 ;; + 2) exit 1 ;; # find_party_by_name printed the ambiguity hint + esac + else + pick_live_party || exit 1 + fi + + if [ "$RR_HOST_USER" = "$USER" ]; then + echo "party knock: '$RR_PARTY_NAME' is your own party." >&2 + exit 1 + fi + if [ "$(party_conn_state "$RR_SOCKET")" = ok ]; then + echo "party knock: you are already invited. Run: party join $RR_PARTY_NAME" + exit 0 + fi + + # Unlike cmd_invite's courtesy ping, delivery failure is surfaced: + # the ping IS the feature. write(1) exits nonzero when the target + # has no tty or has messages disabled (mesg n). + if knock_send "$RR_HOST_USER" "$RR_PARTY_NAME"; then + echo "Knocked: $RR_HOST_USER was pinged about '$RR_PARTY_NAME'." + else + echo "party knock: could not reach $RR_HOST_USER (no tty, or messages disabled); ask them directly." >&2 + exit 1 + fi +} + # Resolve the party the caller hosts. If exactly one matches, sets RR_*; # if zero or multiple, error. Argument: optional party name to disambiguate. resolve_authoritative_party() { @@ -1485,6 +1549,7 @@ dispatch() { close) cmd_close "$@" ;; join) cmd_join "$@" ;; leave) cmd_leave "$@" ;; + knock) cmd_knock "$@" ;; invite) cmd_invite "$@" ;; voice|rw) cmd_voice "$@" ;; mute|ro) cmd_mute "$@" ;; diff --git a/party.1 b/party.1 index a14144e..da119df 100644 --- a/party.1 +++ b/party.1 @@ -20,6 +20,9 @@ .Nm .Cm leave .Nm +.Cm knock +.Op Ar name +.Nm .Cm invite .Ar user .Op Fl r @@ -156,6 +159,19 @@ refuses session creation from read-only clients, so an active join is not possible. .It Cm leave Detach from the current party and clean up the per-guest session. +.It Cm knock Op Ar name +Ask the host of an invite-only party for an invite. +Sends a +.Xr write 1 +message to the host naming the caller and the exact +.Cm invite +command. +Works for parties +.Cm list +shows as invite-only: the caller shares the party's group but is not +on its allowlist. +Fails when the host has no tty or has messages disabled with +.Xr mesg 1 . .It Cm invite Ar user Op Fl r Op Fl -party Ar name Add .Ar user diff --git a/tests/65-knock.bats b/tests/65-knock.bats new file mode 100644 index 0000000..140bcf1 --- /dev/null +++ b/tests/65-knock.bats @@ -0,0 +1,81 @@ +#!/usr/bin/env bats +# +# party knock: ask a party's host for an invite via write(1). The +# unauthorized-party resolution path needs a foreign-owned dir, which a +# single-uid run can't create (roster_read anchors on dir ownership), +# so the send is tested through knock_send with a stubbed write(1) and +# the CLI paths through parties we can fake: our own. + +load 'helpers' + +setup() { + setup_party_sandbox + load_party_lib +} + +teardown() { teardown_party_sandbox; } + +# Put a stub write(1) on PATH. $1 = exit status. Records its user +# argument and stdin under $PARTY_TMP. +stub_write() { + mkdir -p "$PARTY_TMP/bin" + cat > "$PARTY_TMP/bin/write" < "$PARTY_TMP/write.user" +cat > "$PARTY_TMP/write.msg" +exit $1 +EOF + chmod +x "$PARTY_TMP/bin/write" + PATH="$PARTY_TMP/bin:$PATH" +} + +@test "knock_send pings the host with the exact invite command" { + stub_write 0 + knock_send alice fiesta + [ "$(cat "$PARTY_TMP/write.user")" = alice ] + grep -q "$USER wants to join 'fiesta'" "$PARTY_TMP/write.msg" + grep -q "party invite $USER --party fiesta" "$PARTY_TMP/write.msg" +} + +@test "knock_send propagates write(1) failure" { + stub_write 1 + run knock_send alice fiesta + [ "$status" -ne 0 ] +} + +@test "knock on an unknown party fails with not-found" { + run "$PARTY_BIN" knock nowhere + [ "$status" -eq 1 ] + [[ "$output" == *"not found"* ]] +} + +@test "knock rejects unknown flags and extra args" { + run "$PARTY_BIN" knock --frob + [ "$status" -eq 2 ] + run "$PARTY_BIN" knock a b + [ "$status" -eq 2 ] +} + +@test "knock on your own party is refused" { + # Live-looking own party via an always-ok tmux stub (rc 0, silent). + cat > "$PARTY_TMP/tmux-ok" <<'EOF' +#!/bin/sh +exit 0 +EOF + chmod +x "$PARTY_TMP/tmux-ok" + export PARTY_TMUX="$PARTY_TMP/tmux-ok" + ensure_party_dir "$USER" mine + d="$PARTY_SOCKET_DIR/party-$USER:mine.d" + cat > "$d/roster" <