diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-24 18:19:13 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-24 18:19:13 +0200 |
| commit | 6e9afe3e54ab6fb0162393150b7892655b4561d4 (patch) | |
| tree | f1cf20d891bf0d75dd206953bcec781f47ab7b15 /packages/meshbay-node/src/meshbay_node/cli/groups.py | |
| parent | a408a25af7b91abf729ec5fac7e5506437a86049 (diff) | |
| download | meshbay-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/groups.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/cli/groups.py | 247 |
1 files changed, 247 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/cli/groups.py b/packages/meshbay-node/src/meshbay_node/cli/groups.py new file mode 100644 index 0000000..3fab4b9 --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/cli/groups.py @@ -0,0 +1,247 @@ +"""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 <name> --dir <path>") + 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 <name>") + 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 <name>") + sys.exit(1) + if not args.target or not args.dir: + print("usage: meshbay-node group add <name> --dir <path> " + "[--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 <path> [--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 <path> [--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 <name> [--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 <name> " + "[--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 <name> [--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 <name> [--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 |