summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/cli/groups.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/cli/groups.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/cli/groups.py247
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