"""What the node hosts: groups, their directories (roots) and their keys.""" import sys from pathlib import Path from meshbay_node.cli.api import _daemon_api, _resolve_group from meshbay_node.config import DEFAULT_CONFIG_PATH, load_config def group(args) -> None: if args.subcommand in (None, "list"): # Milestone 14.2. cfg = load_config(args.config or DEFAULT_CONFIG_PATH) out = _daemon_api(cfg, "/api/groups") groups = out.get("groups", []) if not groups: print("no groups hosted — add one with: " "meshbay-node group add --dir ") return for g in groups: key = "GEK" if g.get("has_gek") else "NO KEY" print(f" {g['name']} [{g['visibility']}/{g.get('join_policy')}] " f"{key} {g['file_count']} file(s) " f"{g.get('peers', 0)} peer(s)") print(f" {g['id']}") for r in g.get("roots", []): flags = [] if r.get("writable"): flags.append("rw") else: flags.append("ro") if r.get("removable"): flags.append("removable") if r.get("ejected"): flags.append("ejected") flag_str = f" ({', '.join(flags)})" if flags else "" live = "" if r.get("available", True) else " [UNAVAILABLE]" print(f" root {r['name']}{flag_str}{live}") if not g.get("has_gek"): print(f" give it a key: meshbay-node gek init " f"--group {g['name']}") return if args.subcommand == "remove": if not args.target: print("usage: meshbay-node group remove ") sys.exit(1) cfg = load_config(args.config or DEFAULT_CONFIG_PATH) if not args.yes: answer = input(f"Remove group '{args.target}' from this node? [y/N] ") if answer.lower() not in ("y", "yes"): print("cancelled") return out = _daemon_api(cfg, "/api/groups/detach", method="POST", body={"name": args.target}) print(f"{out['name']} ({out['group_id'][:8]}) removed from {out['config']}") print() print("Restart the daemon to stop hosting it:") print(" meshbay-node restart-daemon") return if args.subcommand != "add": print("usage: meshbay-node group list|add|remove ") sys.exit(1) if not args.target or not args.dir: print("usage: meshbay-node group add --dir " "[--no-writable]") print() print("The group must already exist on the hub and be yours. This") print("only tells the node to host it, and picks its first") print("directory, which accepts uploads unless --no-writable.") print("Add more with: meshbay-node root add [--writable]") sys.exit(1) cfg = load_config(args.config or DEFAULT_CONFIG_PATH) # Writable unless the operator says otherwise: a brand-new group that # cannot receive a single file until its owner finds a second command # is not a working group. Every root added *later* is read-only by # default, which is the opposite rule and the right one there. writable = args.writable is not False body = {"name": args.target, "shared_dir": args.dir, "writable": writable} out = _daemon_api(cfg, "/api/groups/attach", method="POST", body=body) print(f"{out['name']} ({out['group_id'][:8]}) added to {out['config']}") print(f" shared_dir {out['shared_dir']}" f" ({'read-write' if writable else 'read-only'})") print() print("Tell the daemon to re-read its config, then give the group a key:") print(" meshbay-node reload") print(f" meshbay-node gek init --group {out['name']}") print() print("The key is this group's own — members of your other groups cannot") print("read it, and joining one says nothing about the other.") return def root(args) -> None: cfg = load_config(args.config or DEFAULT_CONFIG_PATH) sub = args.subcommand or "list" group_id = _resolve_group(cfg, args.group) if sub == "list": out = _daemon_api(cfg, "/api/groups") group = next((g for g in out.get("groups", []) if g["id"] == group_id), None) if not group: print(f"group {group_id[:8]} not hosted on this node") sys.exit(1) roots = group.get("roots", []) if not roots: print("no roots configured") print(f"add one: meshbay-node root add /path/to/dir --group {group_id}") return for r in roots: flags = [] if r.get("writable"): flags.append("rw") else: flags.append("ro") if r.get("removable"): flags.append("removable") if r.get("ejected"): flags.append("EJECTED") avail = "available" if r.get("available", True) else "UNAVAILABLE" flags.append(avail) print(f" {r['name']:<20} {', '.join(flags)}") print(f" {r.get('path', '?')}") return if sub == "add": path = args.target if not path: print("usage: meshbay-node root add [--name NAME] " "[--writable] [--removable] [--group NAME]") sys.exit(1) body = { "path": path, "name": args.name or Path(path).name, "writable": args.writable if args.writable is not None else True, "removable": bool(args.removable), } _daemon_api(cfg, f"/api/groups/{group_id}/roots", method="POST", body=body) w = "rw" if body["writable"] else "ro" rm = ", removable" if body["removable"] else "" print(f"added root {body['name']} → {path} ({w}{rm})") print("reload the daemon to start indexing:") print(" meshbay-node reload") return if sub == "remove": name = args.target if not name: print("usage: meshbay-node root remove [--group NAME]") sys.exit(1) if not args.yes: print(f"Remove root '{name}' from group {group_id[:8]}?") print("Files on disk are untouched; only the node config changes.") if input("remove? [y/N] ").strip().lower() not in ("y", "yes"): print("cancelled") return _daemon_api(cfg, f"/api/groups/{group_id}/roots/{name}", method="DELETE") print(f"removed root {name}") print("reload the daemon to apply:") print(" meshbay-node reload") return if sub == "set": name = args.target if not name: print("usage: meshbay-node root set " "[--writable|--no-writable] " "[--removable|--no-removable] [--group NAME]") sys.exit(1) body = {} if args.writable is not None: body["writable"] = args.writable if args.removable is not None: body["removable"] = args.removable if not body: print("nothing to change — pass --writable/--no-writable " "or --removable/--no-removable") sys.exit(1) _daemon_api(cfg, f"/api/groups/{group_id}/roots/{name}", method="PATCH", body=body) changes = ", ".join(f"{k}={v}" for k, v in body.items()) print(f"updated root {name}: {changes}") return if sub == "eject": name = args.target if not name: print("usage: meshbay-node root eject [--group NAME]") sys.exit(1) _daemon_api(cfg, f"/api/groups/{group_id}/roots/{name}/eject", method="PUT") print(f"ejected root {name} — files are hidden until plugged back") return if sub == "plug": name = args.target if not name: print("usage: meshbay-node root plug [--group NAME]") sys.exit(1) _daemon_api(cfg, f"/api/groups/{group_id}/roots/{name}/plug", method="PUT") print(f"plugged root {name} — files are visible again") return print("usage: meshbay-node root list|add|remove|set|eject|plug [name] " "[--group NAME]") sys.exit(1) def gek(args) -> None: # `gek-init` is the original spelling and still works. `gek rotate` is # the one that matters after a revocation: the ex-member holds the # current key and nothing else takes it from them. sub = "init" if args.command == "gek-init" else (args.subcommand or "init") if sub not in ("init", "rotate"): print("usage: meshbay-node gek init|rotate [--group NAME]") sys.exit(1) cfg = load_config(args.config or DEFAULT_CONFIG_PATH) group_id = _resolve_group(cfg, args.group) if sub == "rotate" and not args.yes: print("Rotating replaces this group's key.") print(" · every member re-receives it automatically on their next connect") print(" · anyone revoked keeps the OLD key and loses access to new content") print(" · content already downloaded stays readable to whoever has it") if input("rotate now? [y/N] ").strip().lower() not in ("y", "yes"): print("cancelled") return out = _daemon_api(cfg, f"/api/groups/{group_id}/gek" f"{'?rotate=true' if sub == 'rotate' else ''}", method="POST", timeout=60) verb = "rotated" if out.get("rotated") else "ready" print(f"GEK {verb} for {group_id}") print(f" {out.get('authorized_members', 0)} authorized member(s) — each " f"receives the key on connect") for err in out.get("errors") or []: print(f" ! {err}") return