aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/commands/context.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/commands/context.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/commands/context.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/context.py82
1 files changed, 82 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/context.py b/packages/meshbay-cli/src/meshbay_cli/commands/context.py
new file mode 100644
index 0000000..c11533b
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/commands/context.py
@@ -0,0 +1,82 @@
+"""What every action needs: the hub, the account name, the passphrase, the store."""
+
+import argparse
+import getpass
+import os
+import sys
+import time
+from pathlib import Path
+
+from ..client.account import Account, NoPassphrase
+from ..client.hub import DEFAULT_HUB, Hub
+from ..client.paths import handle
+from ..client.store import Store
+from ..errors import USAGE, CliError
+from ..output import safe
+
+
+class Context:
+ def __init__(self, args: argparse.Namespace):
+ self.args = args
+ self.store = Store()
+ stored = self.store.peek()
+ explicit_hub = args.hub or os.environ.get("MESHBAY_HUB")
+ self.hub_url = (explicit_hub or stored.get("hub") or DEFAULT_HUB).rstrip("/")
+ user = args.user or os.environ.get("MESHBAY_USER")
+ self.username = user.strip() if user else None
+ self.stored_username = stored.get("username") if stored.get("hub") == self.hub_url \
+ else None
+ self.hub = Hub(self.hub_url)
+ self._passphrase: str | None = None
+
+ def passphrase(self) -> str:
+ """--password, --password-file, MESHBAY_PASSWORD, then a prompt on a terminal."""
+ if self._passphrase is not None:
+ return self._passphrase
+ a = self.args
+ value = a.password
+ if value is None and a.password_file:
+ try:
+ text = sys.stdin.read() if a.password_file == "-" \
+ else Path(a.password_file).read_text(encoding="utf-8")
+ except OSError as e:
+ raise CliError(USAGE, f"Cannot read {a.password_file}: {e.strerror}") from e
+ value = text.splitlines()[0] if text else ""
+ if value is None:
+ value = os.environ.get("MESHBAY_PASSWORD")
+ if value is None:
+ if not sys.stdin.isatty():
+ raise NoPassphrase("A passphrase is needed and there is no terminal to ask "
+ "on; pass --password-file or set MESHBAY_PASSWORD.")
+ value = getpass.getpass(f"Passphrase for {self.username or self.stored_username}"
+ f" on {self.hub_url}: ")
+ if not value:
+ raise NoPassphrase("No passphrase given.")
+ self._passphrase = value
+ return value
+
+ def account(self) -> Account:
+ account = Account.open(self.hub, self.username, self.passphrase, self.store)
+ account.on_device_code = self._device_code
+ return account
+
+ def _device_code(self, group: dict, pending, *, first: bool) -> bool:
+ """Show the code once. Wait only where somebody is there to approve it."""
+ waiting = sys.stderr.isatty() and not self.args.quiet
+ if first:
+ minutes = max(1, int((pending.expires_at - time.time()) // 60))
+ print(f"This machine is not yet a device of yours on the node hosting "
+ f"{safe(handle(group))}.\n"
+ "Approve it from the MeshBay application or a browser where you use "
+ "this group:\n"
+ " the group's Settings → \"Your devices on this node\" → enter "
+ f"{pending.link_code} → Approve\n"
+ + (f"Waiting for the approval (the code expires in {minutes} min; "
+ "Ctrl-C to stop)..." if waiting else
+ f"Run the command again once it is approved (the code expires in "
+ f"{minutes} min)."),
+ file=sys.stderr, flush=True)
+ return waiting
+
+ async def aclose(self) -> None:
+ await self.hub.aclose()