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" <