summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/commands
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/commands')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/browser_access.py72
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/context.py2
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/decline.py43
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/devices.py96
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/groups.py15
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/invitations.py66
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/join.py139
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/leave.py55
8 files changed, 486 insertions, 2 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/browser_access.py b/packages/meshbay-cli/src/meshbay_cli/commands/browser_access.py
new file mode 100644
index 0000000..e2b0b8e
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/commands/browser_access.py
@@ -0,0 +1,72 @@
+"""
+`browser-access`: whether this machine leaves, on each node, a copy of its keys
+a browser can open with the passphrase — the application's Profile → Browser
+access, kept by this machine (docs/MESHBAY_DESIGN.md §3.7).
+
+`on`: each group opened here leaves a copy sealed under the passphrase-derived
+key, when its node holds none or an outdated one. `off`: the copy a node holds
+is withdrawn. Never set: nothing is written or withdrawn, so a CLI nobody
+configured never undoes what the application of another computer does.
+
+Set here and nowhere else. The hub keeps a mirror of it, written here as the
+application writes it, but a mirror anyone with a hub session can write must
+never make this machine upload keys: read, it only produces a message.
+"""
+
+import json
+
+from ..client.hub import HubError
+from ..errors import AUTH, CliError
+from ..output import err, sentence
+from .context import Context
+
+HELP = "whether a browser may open your groups: leave a copy of your keys on nodes, or not"
+
+EFFECT = {
+ "on": "Each group opened here from now on leaves a copy of your keys on its node, "
+ "sealed under your passphrase, so that a browser can open it.",
+ "off": "Each group opened here from now on withdraws the copy of your keys its node "
+ "holds; a browser will no longer open it with your passphrase.",
+}
+
+
+def add_parser(sub, parent) -> None:
+ p = sub.add_parser("browser-access", parents=[parent], help=HELP,
+ description=sentence(HELP) + ". As the application's Browser "
+ "access, for this machine. With no argument, shows the setting.")
+ p.add_argument("state", nargs="?", choices=("on", "off"))
+
+
+async def run(ctx: Context) -> int:
+ a = ctx.args
+ account = ctx.account()
+ if not account.remembered:
+ raise CliError(AUTH, "This setting is kept with the saved session; run "
+ "`meshbay-cli login` first.")
+ if a.state is None:
+ try:
+ mirror = (await account.call(account.hub.preferences)).get("browser_access")
+ except (HubError, CliError):
+ mirror = None
+ mine = account.browser_access
+ if a.json:
+ print(json.dumps({"this_machine": mine, "hub": mirror}))
+ else:
+ print(f"This machine: {mine or 'not set (leaves the nodes as they are)'}")
+ print(f"The hub's copy: {mirror or 'none'} (informative: it sets nothing here)")
+ return 0
+
+ def put(state: dict, secrets: dict) -> None:
+ state["browser_access"] = a.state
+ await account.update(put)
+ try:
+ await account.call(account.hub.set_preference, "browser_access", a.state)
+ except (HubError, CliError) as e:
+ err(f"meshbay-cli: warning: the hub's copy of the setting was not updated "
+ f"({getattr(e, 'detail', None) or getattr(e, 'message', e)}); a browser may "
+ "explain a closed group less well.")
+ if a.json:
+ print(json.dumps({"this_machine": a.state}))
+ else:
+ print(f"Browser access: {a.state}. {EFFECT[a.state]}")
+ return 0
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/context.py b/packages/meshbay-cli/src/meshbay_cli/commands/context.py
index c11533b..827c101 100644
--- a/packages/meshbay-cli/src/meshbay_cli/commands/context.py
+++ b/packages/meshbay-cli/src/meshbay_cli/commands/context.py
@@ -71,6 +71,8 @@ class Context:
"this group:\n"
" the group's Settings → \"Your devices on this node\" → enter "
f"{pending.link_code} → Approve\n"
+ "or from a machine already linked:\n"
+ f" meshbay-cli devices approve {handle(group)} {pending.link_code}\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 "
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/decline.py b/packages/meshbay-cli/src/meshbay_cli/commands/decline.py
new file mode 100644
index 0000000..23c7bb8
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/commands/decline.py
@@ -0,0 +1,43 @@
+"""`decline`: answer an invitation no. Asked first; `--yes` answers for a script."""
+
+import json
+
+from ..client.hub import HubError
+from ..client.paths import handle
+from ..errors import NOT_FOUND, CliError
+from ..output import confirm, safe, sentence
+from .context import Context
+from .invitations import find_invitation
+
+HELP = "decline an invitation"
+
+
+def add_parser(sub, parent) -> None:
+ p = sub.add_parser("decline", parents=[parent], help=HELP, description=sentence(HELP)
+ + ". Only a new invitation from the group's owner brings it back.")
+ p.add_argument("invitation", metavar="INVITATION",
+ help="the group, as `invitations` lists it")
+ p.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation")
+
+
+async def run(ctx: Context) -> int:
+ await ctx.hub.check_version()
+ account = ctx.account()
+ try:
+ group = find_invitation(await account.call(account.hub.invitations),
+ ctx.args.invitation)
+ name = handle(group)
+ confirm(f"Decline the invitation to {safe(name)}?", ctx.args.yes)
+ try:
+ await account.call(account.hub.decline_invitation, group["id"])
+ except HubError as e:
+ if e.status == 404:
+ raise CliError(NOT_FOUND, "That invitation is no longer waiting.") from e
+ raise
+ finally:
+ await account.close()
+ if ctx.args.json:
+ print(json.dumps({"declined": True, "path": name, "id": group["id"]}))
+ else:
+ print(f"Declined the invitation to {safe(name)}.")
+ return 0
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/devices.py b/packages/meshbay-cli/src/meshbay_cli/commands/devices.py
new file mode 100644
index 0000000..9fab241
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/commands/devices.py
@@ -0,0 +1,96 @@
+"""
+`devices`: this account's devices on the node serving a group — what the
+application's "Your devices on this node" shows and does.
+
+Identities are per node, so a group names the node. `approve` admits the device
+showing a code; `revoke` retires one, asked first (`--yes` for a script). A node
+where this machine is the only linked device admits no other without this.
+"""
+
+import json
+import sys
+
+from ..client import devices
+from ..client.groups import my_groups, open_group
+from ..client.paths import group_arg, handle, resolve_group
+from ..errors import REFUSED, USAGE, CliError
+from ..output import confirm, err, safe, sentence
+from .context import Context
+
+HELP = "your devices on the node serving a group: list, approve a new one, revoke one"
+
+
+def add_parser(sub, parent) -> None:
+ p = sub.add_parser("devices", parents=[parent], help=HELP, description=sentence(HELP)
+ + ". A new device shows a code; approve it here, from a machine "
+ "already linked to that node.")
+ p.add_argument("verb", choices=("list", "approve", "revoke"))
+ p.add_argument("group", metavar="GROUP", help="the group, as `groups` lists it")
+ p.add_argument("arg", nargs="?", metavar="CODE|DEVICE",
+ help="approve: the code the new device shows; revoke: the device, by "
+ "the first characters of its key as `devices list` shows it")
+ p.add_argument("-y", "--yes", action="store_true",
+ help="revoke without asking for confirmation")
+
+
+def _when(iso: str) -> str:
+ return (iso or "")[:16].replace("T", " ")
+
+
+async def run(ctx: Context) -> int:
+ a = ctx.args
+ if a.verb != "list" and not a.arg:
+ raise CliError(USAGE, f"Name the {'code' if a.verb == 'approve' else 'device'}: "
+ f"meshbay-cli devices {a.verb} GROUP "
+ f"{'CODE' if a.verb == 'approve' else 'DEVICE'}")
+ await ctx.hub.check_version()
+ account = ctx.account()
+ session = None
+ try:
+ group = resolve_group(await my_groups(account), group_arg(a.group))
+ name = handle(group)
+ session = await open_group(account, group)
+ if a.verb == "list":
+ return _print(ctx, name, await devices.list_devices(session))
+ if a.verb == "approve":
+ pk = await devices.approve(session, account, a.arg)
+ print(json.dumps({"approved": pk}) if a.json else
+ f"Approved the device {pk[:16]} on the node serving {safe(name)}.")
+ return 0
+ device = devices.find_device((await devices.list_devices(session))["devices"],
+ a.arg)
+ if device.get("is_this_one"):
+ raise CliError(REFUSED, "That is this machine. Revoke it from another of your "
+ "devices.")
+ label = device.get("label") or "no label"
+ confirm(f"Revoke the device {device['pk_ed25519'][:16]} ({safe(label)}) on the "
+ f"node serving {safe(name)}? It will no longer open this node's groups.",
+ a.yes)
+ await devices.revoke(session, account, device["pk_ed25519"])
+ print(json.dumps({"revoked": device["pk_ed25519"]}) if a.json else
+ f"Revoked the device {device['pk_ed25519'][:16]}.")
+ return 0
+ finally:
+ if session is not None:
+ await session.close()
+ await account.close()
+
+
+def _print(ctx: Context, name: str, listed: dict) -> int:
+ rows = listed["devices"]
+ if ctx.args.json:
+ json.dump(listed, sys.stdout, indent=1)
+ print()
+ return 0
+ print(f" {'KEY':<18}{'ADDED':<18}{'VIA':<12}LABEL")
+ for d in rows:
+ mark = "*" if d.get("is_this_one") else " "
+ print(f"{mark} {d['pk_ed25519'][:16]:<18}{_when(d.get('pinned_at')):<18}"
+ f"{safe(d.get('pinned_via') or ''):<12}{safe(d.get('label') or '')}")
+ if not ctx.args.quiet:
+ err("* this machine")
+ if listed["pending"]:
+ n = listed["pending"]
+ err(f"{n} device{'s' if n != 1 else ''} waiting for approval: "
+ f"meshbay-cli devices approve {name} CODE")
+ return 0
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/groups.py b/packages/meshbay-cli/src/meshbay_cli/commands/groups.py
index 7595e2b..04395dd 100644
--- a/packages/meshbay-cli/src/meshbay_cli/commands/groups.py
+++ b/packages/meshbay-cli/src/meshbay_cli/commands/groups.py
@@ -7,12 +7,15 @@ it. "owner" is the account that created the group on the hub — a node's
operator is another notion.
"""
+import contextlib
import json
import sys
from datetime import datetime
from ..client.groups import my_groups
+from ..client.hub import HubError
from ..client.paths import handle
+from ..errors import CliError
from ..output import safe, sentence
from .context import Context
@@ -31,7 +34,7 @@ def node_state(g: dict) -> str:
return "up" if g.get("node_online") else "down"
-def _when(iso: str | None) -> str:
+def local_time(iso: str | None) -> str:
if not iso:
return ""
try:
@@ -45,6 +48,11 @@ async def run(ctx: Context) -> int:
account = ctx.account()
try:
groups = await my_groups(account)
+ waiting = 0
+ if not ctx.args.json and not ctx.args.quiet:
+ # A hub without invitations answers nothing useful; the list stands.
+ with contextlib.suppress(HubError, CliError):
+ waiting = len(await account.call(account.hub.invitations))
finally:
await account.close()
groups.sort(key=lambda g: (g["name"].lower(), g.get("owner_username") or ""))
@@ -58,6 +66,9 @@ async def run(ctx: Context) -> int:
for g in groups], sys.stdout, indent=1)
print()
return 0
+ if waiting:
+ print(f"{waiting} invitation{'s' if waiting != 1 else ''} waiting for your answer: "
+ "meshbay-cli invitations", file=sys.stderr)
if not groups:
print("You belong to no group yet.", file=sys.stderr)
return 0
@@ -65,5 +76,5 @@ async def run(ctx: Context) -> int:
for g in groups:
role = "owner" if g.get("is_admin") else "member"
print(f"{node_state(g):<6}{role:<8}{safe(g.get('visibility') or ''):<12}"
- f"{_when(g.get('last_activity_at')):<18}{safe(handle(g))}")
+ f"{local_time(g.get('last_activity_at')):<18}{safe(handle(g))}")
return 0
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/invitations.py b/packages/meshbay-cli/src/meshbay_cli/commands/invitations.py
new file mode 100644
index 0000000..df2a23e
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/commands/invitations.py
@@ -0,0 +1,66 @@
+"""
+`invitations`: the groups somebody added this account to, waiting for an answer.
+
+Until it is accepted an invitation is not a membership: the group is not in
+`groups`, and its nodes cannot be reached. `join` accepts one, `decline` refuses
+it.
+"""
+
+import json
+import sys
+
+from ..client.paths import group_arg, handle, resolve_group
+from ..errors import NOT_FOUND, CliError
+from ..output import safe, sentence
+from .context import Context
+from .groups import local_time
+
+HELP = "list the invitations waiting for your answer"
+
+
+def add_parser(sub, parent) -> None:
+ sub.add_parser("invitations", parents=[parent], help=HELP, description=sentence(HELP)
+ + ". The GROUP column is what `join` and `decline` take.")
+
+
+def as_group(inv: dict) -> dict:
+ """An invitation row in the shape `groups` rows have, for `handle` and lookups."""
+ return {"id": inv["group_id"], "name": inv["name"],
+ "owner_username": inv.get("owner_username") or "", "invitation": inv}
+
+
+def find_invitation(invitations: list[dict], spec: str) -> dict:
+ try:
+ return resolve_group([as_group(i) for i in invitations], group_arg(spec))
+ except CliError as e:
+ if e.code != NOT_FOUND:
+ raise
+ raise CliError(NOT_FOUND, f"No invitation to a group called {spec!r} is "
+ "waiting; `meshbay-cli invitations` lists them.") from None
+
+
+async def run(ctx: Context) -> int:
+ await ctx.hub.check_version()
+ account = ctx.account()
+ try:
+ invitations = await account.call(account.hub.invitations)
+ finally:
+ await account.close()
+ rows = sorted((as_group(i) for i in invitations),
+ key=lambda g: (g["name"].lower(), g["owner_username"]))
+ if ctx.args.json:
+ json.dump([{"group": g["name"], "owner": g["owner_username"], "id": g["id"],
+ "path": handle(g), "invited_by": g["invitation"].get("invited_by") or "",
+ "created_at": g["invitation"].get("created_at")}
+ for g in rows], sys.stdout, indent=1)
+ print()
+ return 0
+ if not rows:
+ print("No invitation is waiting.", file=sys.stderr)
+ return 0
+ print(f"{'INVITED BY':<22}{'DATE':<18}GROUP")
+ for g in rows:
+ inv = g["invitation"]
+ print(f"{safe(inv.get('invited_by') or ''):<22}{local_time(inv.get('created_at')):<18}"
+ f"{safe(handle(g))}")
+ return 0
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/join.py b/packages/meshbay-cli/src/meshbay_cli/commands/join.py
new file mode 100644
index 0000000..ee75c96
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/commands/join.py
@@ -0,0 +1,139 @@
+"""
+`join`: become a member of a group, and be admitted by its node.
+
+Two halves, as in the application (docs/MESHBAY_DESIGN.md §3.4): the hub makes
+the account a member — an invitation accepted, or an invitation link redeemed —
+and the node admits it, wrapping the group key for a key pair made here and
+kept. The node is dialled at once, so the group can be used as soon as this
+returns. A link's code goes to the node the link names and to no other; a node
+that is away gets the code at the next connection, kept until then.
+"""
+
+import json
+import sys
+
+from ..client.groups import my_groups, open_group
+from ..client.hub import HubError
+from ..client.invite import parse_link
+from ..client.paths import group_arg, handle, resolve_group
+from ..client.session import DeviceLinkPending, NodeRefusal
+from ..errors import AUTH, NODE, NOT_FOUND, USAGE, CliError
+from ..output import err, safe, sentence
+from .context import Context
+from .invitations import find_invitation
+
+HELP = "join a group: accept an invitation, or use an invitation link"
+
+BROWSER_HINT = ("A browser will not open {group} with your passphrase: this machine "
+ "leaves no copy of your keys on its node. To leave one, as the "
+ "application does with Browser access on: meshbay-cli browser-access on")
+
+
+def add_parser(sub, parent) -> None:
+ p = sub.add_parser("join", parents=[parent], help=HELP, description=sentence(HELP)
+ + ". The group's node is reached at once and admits this "
+ "machine with its own keys.")
+ p.add_argument("target", metavar="INVITATION|LINK",
+ help="an invitation as `invitations` lists it, an invitation link as "
+ "it was sent, or a group of yours whose node asked for a code")
+ p.add_argument("--code", help="the invitation's code (XXXX-XXXX), when the node asks "
+ "for one")
+
+
+async def _membership(ctx: Context, account, target: str) -> tuple[str, str | None,
+ str | None]:
+ """The hub's half: the group's id, and the code and node a link carries."""
+ link = parse_link(target)
+ if link is not None:
+ if link.origin and link.origin.rstrip("/") != ctx.hub_url:
+ raise CliError(USAGE, f"This link was made on {link.origin}; run the command "
+ f"with --hub {link.origin}.")
+ try:
+ preview = await account.call(account.hub.preview_link, link.ticket)
+ if not preview.get("already_member"):
+ await account.call(account.hub.redeem_link, link.ticket)
+ except HubError as e:
+ if e.status in (403, 404):
+ raise CliError(NOT_FOUND, "This invitation link no longer works: used by "
+ "another account, cancelled, or expired. Ask "
+ "for a new one.") from e
+ raise
+ if not ctx.args.quiet and not ctx.args.json:
+ err(f"Joining {safe(preview.get('group_name') or '')}, invited by "
+ f"{safe(preview.get('inviter') or '?')}.")
+ return link.group_id, link.code, link.node_pk
+
+ invitations = await account.call(account.hub.invitations)
+ try:
+ group = find_invitation(invitations, target)
+ except CliError as e:
+ if e.code != NOT_FOUND:
+ raise
+ # Already a member: only the node's half is left to do.
+ group = resolve_group(await my_groups(account), group_arg(target))
+ return group["id"], None, None
+ try:
+ await account.call(account.hub.accept_invitation, group["id"])
+ except HubError as e:
+ if e.status == 404:
+ raise CliError(NOT_FOUND, "That invitation is no longer waiting.") from e
+ raise
+ return group["id"], None, None
+
+
+async def run(ctx: Context) -> int:
+ a = ctx.args
+ await ctx.hub.check_version()
+ account = ctx.account()
+ if not account.remembered:
+ raise CliError(AUTH, "Joining needs a saved session: the keys made for the group "
+ "are kept on this machine. Run `meshbay-cli login` first.")
+ group_id, link_code, node_pk = await _membership(ctx, account, a.target)
+ group = next((g for g in await my_groups(account) if g["id"] == group_id), None)
+ if group is None:
+ raise CliError(NOT_FOUND, "The hub did not make this account a member.")
+ name = handle(group)
+ code = (a.code or link_code or "").strip().upper() or None
+
+ while True:
+ try:
+ session = await open_group(account, group, join_code=code, node_pk=node_pk)
+ break
+ except NodeRefusal as e:
+ if e.refusal_code != "code_required" or code:
+ raise
+ if not sys.stdin.isatty():
+ raise CliError(NODE, f"You are a member of {safe(name)}; its node has not "
+ "admitted you yet and needs your invitation's code:\n"
+ f" meshbay-cli join {name} --code CODE") from e
+ sys.stderr.write(f"The node asks for your invitation's code to {name} "
+ "(XXXX-XXXX): ")
+ sys.stderr.flush()
+ code = sys.stdin.readline().strip().upper() or None
+ if not code:
+ raise CliError(USAGE, "No code given.") from e
+ except CliError as e:
+ if e.code != NODE or not code or isinstance(e, NodeRefusal | DeviceLinkPending):
+ raise
+ await account.keep_join_code(group_id, code, node_pk)
+ raise CliError(NODE, f"You are a member of {safe(name)}, but its node cannot "
+ f"be reached now ({e.message}). The code is kept and "
+ "sent at the next connection, such as:\n"
+ f" meshbay-cli ls {name}/") from e
+ await session.close()
+
+ if a.json:
+ print(json.dumps({"joined": True, "group": group["name"],
+ "owner": group.get("owner_username"), "id": group_id,
+ "path": name}))
+ else:
+ print(f"Joined {safe(name)}. Its files: meshbay-cli ls {safe(name)}/")
+ if account.browser_access is None and not a.quiet:
+ try:
+ prefs = await account.call(account.hub.preferences)
+ except (HubError, CliError):
+ prefs = {}
+ # The hub's word can only produce this line, never a copy of the keys.
+ if prefs.get("browser_access") != "off":
+ err(BROWSER_HINT.format(group=safe(name)))
+ return 0
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/leave.py b/packages/meshbay-cli/src/meshbay_cli/commands/leave.py
new file mode 100644
index 0000000..e9a529e
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/commands/leave.py
@@ -0,0 +1,55 @@
+"""
+`leave`: give up a membership. Asked first; `--yes` answers for a script.
+
+The hub's half only, as in the application: the account stops being a member,
+so its nodes can no longer be reached. The node keeps what it holds — the
+identity it pinned, its copy of the keys, the files uploaded — until its
+operator unpins them, and the group key already served opens what it opened
+until the operator rotates it. The owner cannot leave a group.
+"""
+
+import json
+
+from ..client.groups import my_groups
+from ..client.hub import HubError
+from ..client.paths import group_arg, handle, resolve_group
+from ..errors import REFUSED, CliError
+from ..output import confirm, safe, sentence
+from .context import Context
+
+HELP = "leave a group"
+
+
+def add_parser(sub, parent) -> None:
+ p = sub.add_parser("leave", parents=[parent], help=HELP, description=sentence(HELP)
+ + ". Its files and its chat are no longer reachable from this "
+ "account; only a new invitation brings it back. The owner cannot "
+ "leave a group.")
+ p.add_argument("group", metavar="GROUP", help="the group, as `groups` lists it")
+ p.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation")
+
+
+async def run(ctx: Context) -> int:
+ await ctx.hub.check_version()
+ account = ctx.account()
+ try:
+ group = resolve_group(await my_groups(account), group_arg(ctx.args.group))
+ name = handle(group)
+ if group.get("is_admin"):
+ raise CliError(REFUSED, f"You own {safe(name)}, so you cannot leave it: it "
+ "would be left with nobody able to manage it.")
+ confirm(f"Leave {safe(name)}? Its files and chat will no longer be reachable "
+ "from your account.", ctx.args.yes)
+ try:
+ await account.call(account.hub.leave_group, group["id"])
+ except HubError as e:
+ if e.status == 409:
+ raise CliError(REFUSED, e.detail) from e
+ raise
+ finally:
+ await account.close()
+ if ctx.args.json:
+ print(json.dumps({"left": True, "path": name, "id": group["id"]}))
+ else:
+ print(f"Left {safe(name)}.")
+ return 0