From e76e27868b30a2b00b1ba42dd8e7ee6071e0c0d7 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 6 Sep 2026 16:05:39 +0200 Subject: feat: groups refactor Phase 1 — root RO/RW model + shared directories UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the upload boolean with per-root writable/removable/ejected flags. Backend: new ops (update_root, eject_root, plug_root), MNP 1.1 protocol messages, live RootSet updates so API always reflects current state, CLI root subcommand (add/remove/set/list/eject/plug). Frontend: SharedDirectoriesTable with optimistic toggle switches, eject/plug in Files and Settings, upload gated on root.writable, ejected-root filtering in all media apps, updated Create Group wizard, 10-locale i18n. Co-Authored-By: Claude Opus 4.6 --- packages/meshbay-node/src/meshbay_node/daemon.py | 185 +++++++++++++++++++++-- 1 file changed, 172 insertions(+), 13 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py') diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index ea13680..f45101f 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -1663,15 +1663,17 @@ def main() -> None: parser = argparse.ArgumentParser(description="MeshBay Node daemon") parser.add_argument("command", nargs="?", choices=["init", "reset", "status", "gek-init", - "gek", "operator", "member", "group", "file", - "video", "denylist", "stun", "reload", + "gek", "operator", "member", "group", "root", + "file", "video", "denylist", "stun", "reload", "restart-daemon", "autostart", "service", "calibrate-argon2"], help="init: provision config + keystore | reset: erase all " "node state | status: node state and keys " "| operator pair: pair a " "browser with this node | member list|invite|revoke|unpin " - "| group list|add|remove | gek init|rotate | file list|rm " + "| group list|add|remove " + "| root list|add|remove|set|eject|plug " + "| gek init|rotate | file list|rm " "| video rematch: re-resolve TMDB matches for a group's " "videos | denylist show|clear " "| stun list|add|remove|reset " @@ -1687,7 +1689,9 @@ def main() -> None: "| calibrate-argon2: benchmark") parser.add_argument("subcommand", nargs="?", help="'pair' for operator; list|invite|revoke|unpin for " - "member; list|add|remove for group; init|rotate for gek; " + "member; list|add|remove for group; " + "list|add|remove|set|eject|plug for root; " + "init|rotate for gek; " "list|rm for file; rematch for video; show|clear for " "denylist; list|add|remove|reset for stun; " "install|remove|start|stop|status for autostart and " @@ -1710,14 +1714,29 @@ def main() -> None: help="Config file path") parser.add_argument("--group", default=None, help="group id (optional if only one is configured)") + parser.add_argument("--writable", action="store_true", default=None, + dest="writable", + help="mark root as read-write (root set/add)") + parser.add_argument("--no-writable", action="store_false", + dest="writable", + help="mark root as read-only (root set)") + parser.add_argument("--removable", action="store_true", default=None, + dest="removable", + help="mark root as removable (root set/add)") + parser.add_argument("--no-removable", action="store_false", + dest="removable", + help="mark root as not removable (root set)") + parser.add_argument("--name", default=None, + help="root name (root add; defaults to directory basename)") parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"]) args = parser.parse_args() # Query commands print a report; library logging would interleave with it. quiet = args.command in ("status", "gek-init", "gek", "operator", - "member", "group", "file", "video", "denylist", - "stun", "reload", "restart-daemon", "reset") + "member", "group", "root", "file", "video", + "denylist", "stun", "reload", "restart-daemon", + "reset") logging.basicConfig( level=logging.ERROR if quiet else getattr(logging, args.log_level), format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", @@ -1961,9 +1980,16 @@ def main() -> None: print(" ") for r in g.roots: label = r.name or Path(r.path).name - flag = " (uploads)" if r.upload else "" + flags = [] + if getattr(r, 'writable', False) or getattr(r, 'upload', False): + flags.append("rw") + else: + flags.append("ro") + if getattr(r, 'removable', False): + flags.append("removable") + flag_str = f" ({', '.join(flags)})" if flags else "" live = "" if Path(r.path).expanduser().is_dir() else " [UNAVAILABLE]" - print(f" {label} → {r.path}{flag}{live}") + print(f" {label} → {r.path}{flag_str}{live}") # Node authority: the roster is the source of truth, node.toml the legacy # form. Read the DB directly so this reports correctly while the daemon is # stopped — the state an operator is most often in when checking. @@ -2408,11 +2434,18 @@ def main() -> None: f"{g.get('peers', 0)} peer(s)") print(f" {g['id']}") for r in g.get("roots", []): - flags = "" - if r.get("upload"): - flags = " (uploads, direct)" if r.get("direct") else " (uploads)" + 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']}{flags}{live}") + 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']}") @@ -2450,10 +2483,18 @@ def main() -> None: cfg = load_config(args.config or DEFAULT_CONFIG_PATH) body = {"name": args.target, "shared_dir": args.dir} if args.upload_dir: + import warnings + warnings.warn( + "--upload-dir is deprecated; the main root is writable by " + "default. Use 'meshbay-node root add' for additional roots.", + DeprecationWarning, stacklevel=1) + print("WARNING: --upload-dir is deprecated. The shared directory is " + "writable by default. Use 'meshbay-node root add' for " + "additional roots.") body["upload_dir"] = args.upload_dir 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']}") + print(f" shared_dir {out['shared_dir']} (writable)") if out.get("upload_dir"): print(f" upload_dir {out['upload_dir']}") print() @@ -2465,6 +2506,124 @@ def main() -> None: print("read it, and joining one says nothing about the other.") return + if args.command == "root": + 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, f"/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) + if args.command == "operator": if args.subcommand != "pair": print("usage: meshbay-node operator pair") -- cgit v1.2.3