aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client/groups.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-14 22:20:15 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-14 22:20:15 +0200
commit422647e7898b8df67cd11f8fb060aec23dec84aa (patch)
tree7ecca0e8df1bc8a7d26cf4b471bd44906dd9cfd1 /packages/meshbay-cli/src/meshbay_cli/client/groups.py
parent28c88908b88c58cba4de08710f6bb268ede02d57 (diff)
downloadmeshbay-422647e7898b8df67cd11f8fb060aec23dec84aa.tar.gz
feat(cli): meshbay-cli, a command-line client for members
Signs in to the hub, lists the account's groups and their folders, and downloads and uploads files directly with each group's node, end-to-end encrypted. With no argument it opens a two-pane interactive mode (Textual). Session and device keys are kept encrypted under the system's key storage, or a 0600 key file where there is none; node keys are pinned at first contact; a new machine links as a device. Packaged on its own on Linux (deb and rpm, on meshbay-common's venv, with a man page generated from the parser) and frozen next to meshbay-node in the full Windows installer, not the light one, with a self-test the build runs. client/keys.py ports keyderive.js and is LGPL like it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client/groups.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/groups.py68
1 files changed, 68 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/groups.py b/packages/meshbay-cli/src/meshbay_cli/client/groups.py
new file mode 100644
index 0000000..ac738f3
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/client/groups.py
@@ -0,0 +1,68 @@
+"""Opening a group: which of the nodes serving it to dial, and in which order."""
+
+import asyncio
+import logging
+import time
+
+from ..errors import NODE, CliError
+from .account import Account
+from .paths import handle
+from .session import DeviceLinkPending, GroupSession, NodeKeyChanged, connect
+
+# How often a machine waiting for its approval tries the node again. Each try is
+# one offer: well inside what the hub admits per account and per node.
+LINK_POLL = 10
+
+log = logging.getLogger(__name__)
+
+
+async def my_groups(account: Account) -> list[dict]:
+ return await account.call(account.hub.my_groups)
+
+
+async def open_group(account: Account, group: dict) -> GroupSession:
+ """
+ A session with a node serving `group`: the pinned one first, then the others
+ in the hub's order. A node whose key changed stops everything — that is an
+ alarm, not a reason to quietly try its neighbour.
+ """
+ nodes = await account.call(account.hub.group_nodes, group["id"])
+ if not nodes:
+ raise CliError(NODE, f"No node serving {handle(group)} is online.")
+ nodes = sorted(nodes, key=lambda n: account.pin(n["node_id"]) is None)
+ failures = []
+ for node in nodes:
+ try:
+ return await _connect_linking(account, group, node)
+ except (NodeKeyChanged, DeviceLinkPending):
+ raise
+ except CliError as e:
+ if e.code != NODE or len(nodes) == 1:
+ raise
+ log.info("node %s: %s", node["node_id"], e.message)
+ failures.append(f"{node['node_id']}: {e.message}")
+ raise CliError(NODE, f"No node serving {handle(group)} could be reached:\n "
+ + "\n ".join(failures))
+
+
+async def _connect_linking(account: Account, group: dict, node: dict) -> GroupSession:
+ """
+ `connect`, and — when this machine is waiting to be approved as a device on
+ that node — the wait: the code is shown once (`account.on_device_code`), and
+ the node is tried again until the approval lands or the request expires. The
+ code names this node, so no other node is tried meanwhile.
+ """
+ shown = False
+ while True:
+ try:
+ return await connect(account, group["id"], node)
+ except DeviceLinkPending as pending:
+ hook = account.on_device_code
+ wait = hook(group, pending, first=not shown) if hook else False
+ shown = True
+ if not wait:
+ raise
+ if pending.expires_at <= time.time():
+ raise CliError(NODE, "The device request expired before it was approved; "
+ "run the command again for a new code.") from None
+ await asyncio.sleep(LINK_POLL)