summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/cli/members.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-24 18:19:13 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-24 18:19:13 +0200
commit6e9afe3e54ab6fb0162393150b7892655b4561d4 (patch)
treef1cf20d891bf0d75dd206953bcec781f47ab7b15 /packages/meshbay-node/src/meshbay_node/cli/members.py
parenta408a25af7b91abf729ec5fac7e5506437a86049 (diff)
downloadmeshbay-6e9afe3e54ab6fb0162393150b7892655b4561d4.tar.gz
refactor(node): move the CLI out of daemon.py into cli/
Each `if args.command == ...` branch of main() becomes a function in cli/ (one module per family of verbs); the parser, the process setup and a VERBS table go to cli/parser.py and cli/dispatch.py. daemon.main runs the verb or starts the daemon. Tests patch the CLI through conftest.patch_cli; the CLI golden is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/cli/members.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/cli/members.py181
1 files changed, 181 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/cli/members.py b/packages/meshbay-node/src/meshbay_node/cli/members.py
new file mode 100644
index 0000000..ba68056
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/cli/members.py
@@ -0,0 +1,181 @@
+"""Who may use the node: members and the operator's own pairing."""
+
+import sys
+from urllib.parse import quote
+
+from meshbay_node.cli.api import _daemon_api, _resolve_group
+from meshbay_node.config import DEFAULT_CONFIG_PATH, load_config
+
+
+def member(args) -> None:
+ cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
+ sub = args.subcommand or "list"
+
+ if sub == "list":
+ group = args.group or ""
+ out = _daemon_api(
+ cfg, f"/api/roster?group_id={group}" if group else "/api/roster")
+ identities = {i["user_id"]: i for i in out.get("identities", [])}
+
+ members = out.get("members", [])
+ if not members:
+ print("no members admitted yet")
+ print("invite someone: meshbay-node member invite <username>")
+ for m in members:
+ ident = identities.get(m["user_id"], {})
+ scope = m["group_id"][:8] if m["group_id"] else "node-wide"
+ print(f"{(ident.get('username') or m['user_id'])[:20]:20} "
+ f"{m['role']:9} {m['status']:8} {scope:10} "
+ f"pinned {ident.get('pinned_at', '?')} "
+ f"({ident.get('pinned_via', '?')})")
+
+ invites = out.get("invites", [])
+ if invites:
+ print()
+ for i in invites:
+ # A link names nobody until it is used, so its handle is what
+ # identifies it — and what `cancel` takes.
+ who = (f"link {i['invite_id']}" if i.get("kind") == "link"
+ else f"user {i['user_id'][:12]}")
+ print(f"pending invite {who} "
+ f"group {(i['group_id'] or 'node-wide')[:8]} "
+ f"expires {i['expires_at']}")
+ return
+
+ # `member upload` is gone: whether uploads are accepted is `writable`
+ # on the root they would land in, not a per-group switch. Named
+ # explicitly rather than left to the usage line below, which offered a
+ # username for a verb that no longer takes one — an operator following
+ # it would have got "unknown subcommand" and no idea what replaced it.
+ if sub == "upload":
+ print("`member upload` is gone. Uploads are decided per directory "
+ "now:")
+ print()
+ print(" meshbay-node root list "
+ "# which are read-write")
+ print(" meshbay-node root set <name> --writable "
+ "# accept uploads there")
+ print(" meshbay-node root set <name> --no-writable # stop them")
+ print()
+ print("A group whose directories are all read-only accepts no "
+ "uploads at all,")
+ print("which is what turning the old switch off meant.")
+ sys.exit(1)
+
+ if not args.target:
+ print(f"usage: meshbay-node member {sub} <username>")
+ sys.exit(1)
+
+ if sub == "invite" and args.link:
+ # A link for someone who may have no account yet, bound to their
+ # address on the hub. The code and the ticket are both in it, so
+ # it goes to them and to nobody else — the CLI mails nothing.
+ group_id = _resolve_group(cfg, args.group)
+ out = _daemon_api(
+ cfg, f"/api/groups/{group_id}/invite-links?email={quote(args.target)}",
+ method="POST")
+ from meshbay_node.roster import write_code_file
+ write_code_file(cfg.data_dir, out["link"], out.get("expires_at", ""),
+ name="invite-link")
+ print(f"INVITATION LINK {out['link']}")
+ print(f"valid until {out.get('expires_at', '?')}")
+ print(f"cancel with meshbay-node member cancel {out['invite_id']}")
+ print()
+ print(f"Send it to {args.target} yourself. It works once, and only for an")
+ print("account registered with that address: they open it, create their")
+ print("account or sign in, and land in the group without typing a code.")
+ return
+
+ if sub == "invite":
+ group_id = _resolve_group(cfg, args.group)
+ out = _daemon_api(
+ cfg, f"/api/groups/{group_id}/invites?username={args.target}",
+ method="POST")
+ from meshbay_node.roster import write_code_file
+ path = write_code_file(cfg.data_dir, out["code"],
+ out.get("expires_at", ""), name="invite-code")
+ print(f"INVITATION CODE {out['code']}")
+ print(f"valid until {out.get('expires_at', '?')}")
+ print()
+ print(f"Send it to {args.target} however you normally talk. It works")
+ print("once, for that account only, and never passes through the hub.")
+ print("They enter it the first time they open the group — you do not")
+ print("need to be online then.")
+ print()
+ print(f"also written to {path}")
+ return
+
+ if sub == "cancel":
+ # Takes back a link that has not been used, on the node and the hub.
+ group_id = _resolve_group(cfg, args.group)
+ out = _daemon_api(
+ cfg, f"/api/groups/{group_id}/invite-links/{quote(args.target)}",
+ method="DELETE")
+ print(f"invitation link {args.target[:8]} cancelled")
+ if not out.get("hub", True):
+ print("The hub's half could not be reached; it expires on its own.")
+ return
+
+ # revoke and unpin both name a person; the daemon resolves the account.
+ # It tries its own roster first and falls back to the hub, so a node that
+ # pinned someone before invitations carried a name is still manageable.
+ match = _daemon_api(cfg, f"/api/resolve?username={args.target}")
+
+ if sub == "revoke":
+ group_id = _resolve_group(cfg, args.group)
+ out = _daemon_api(
+ cfg, f"/api/members/{match['user_id']}/revoke?group_id={group_id}",
+ method="POST")
+ print(f"{args.target} revoked from {group_id[:8]}")
+ if out.get("invites_dropped"):
+ print("Their unredeemed invitation was cancelled.")
+ # The node decides whether a rotation is warranted and says so in
+ # the reminder — somebody who never redeemed a code never held the
+ # key, and advising a rotation there is advice to ignore the next
+ # time it is real. Deciding it again here is the second
+ # implementation this file exists not to have.
+ if out.get("reminder"):
+ print("They stop receiving the group key on their next connection.")
+ print("They still hold the current one — rotate it:")
+ print(f" meshbay-node gek-init --group {group_id}")
+ return
+
+ if sub == "unpin":
+ _daemon_api(cfg, f"/api/members/{match['user_id']}/unpin", method="POST")
+ print(f"{args.target} unpinned — they can pair again with a new key")
+ print(f"issue a code: meshbay-node member invite {args.target}")
+ return
+
+ print("usage: meshbay-node member list|invite|cancel|revoke|unpin")
+ sys.exit(1)
+
+
+def operator(args) -> None:
+ if args.subcommand != "pair":
+ print("usage: meshbay-node operator pair")
+ sys.exit(1)
+ if args.group:
+ # Silently ignoring it invited the reading that a code belongs to a
+ # group, and then that pairing had not worked because the group did
+ # not change.
+ print("operator pair takes no --group: pairing is node-wide.")
+ print("One paired browser can invite to, and delete files in, every")
+ print("group this node hosts.")
+ sys.exit(1)
+
+ cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
+ out = _daemon_api(cfg, "/api/operator/pair", method="POST")
+
+ from meshbay_node.roster import write_code_file
+ path = write_code_file(cfg.data_dir, out["code"], out.get("expires_at", ""))
+
+ print(f"PAIRING CODE {out['code']}")
+ print(f"valid until {out.get('expires_at', '?')}")
+ print()
+ print("Sign in to the web app as this node's operator, open one of your")
+ print("groups, go to the Members tab and enter the code there.")
+ print("It works once, for that account only, and authorizes invites and")
+ print("file deletion from that browser.")
+ print()
+ print(f"also written to {path}")
+ return