diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-15 10:17:02 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-15 10:17:02 +0200 |
| commit | 0503682c0e2add135b88c2a1fadfe07455680a71 (patch) | |
| tree | f4dce260c9875dc4573d04ad553834ad36df3305 /packages/meshbay-node/src/meshbay_node/daemon.py | |
| parent | 4c4fe55bc17ea2223a52b31d2be5b762af8d75bf (diff) | |
| download | meshbay-0503682c0e2add135b88c2a1fadfe07455680a71.tar.gz | |
feat(node): meshbay-node group add — host another of your groups
Attaching a group to a node meant hand-editing node.toml with a UUID
copied from a browser URL, restarting, and knowing that gek-init exists.
Nothing in the CLI said so, and on a node reached over SSH there is no
paste buffer to carry a UUID across in the first place.
meshbay-node group add grenet --dir ~/grenet-share
The name is resolved against the operator's groups on the hub by the
daemon, which is the process holding the session. The [[groups]] block is
appended to node.toml as text rather than round-tripped through a TOML
writer: the file is hand-written and its comments explain decisions worth
keeping. The directory is created, and the command says what remains —
restart, then gek-init for that group.
It refuses a name it cannot find by printing the groups it can, with
their ids. That listing is the useful half of the answer and it was
missing everywhere: _daemon_api now renders an `available` list from any
endpoint that offers one.
The key is per group and pairing is not, which is the part that reads as
a gap until it is written down: one paired browser covers every group the
node hosts, while each group's key admits only its own members. §4 of the
user guide now says all three of those in one place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 66 |
1 files changed, 54 insertions, 12 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 848fc9f..6fafc74 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -98,8 +98,9 @@ class _WsSender: # ── Daemon ──────────────────────────────────────────────────────────────────── class NodeDaemon: - def __init__(self, config: Config): + def __init__(self, config: Config, config_path: Path = DEFAULT_CONFIG_PATH): self._config = config + self._config_path = config_path self._state: dict = { "status": "starting", "hub_url": config.hub.url, @@ -135,6 +136,9 @@ class NodeDaemon: # 2. Start admin UI early (so operator can copy node key before hub login) self._state["pk_node_ed25519"] = keys.pk_ed25519_b64 self._state["config"] = self._config + # Where it came from, so `group add` appends to the file this process + # actually read rather than guessing at the default. + self._state["config_path"] = str(self._config_path) # Per-run token for the local admin UI (11.5.3). Not a password: it keeps # other local processes and rebound browser pages out of an API that can # re-initialise group keys. @@ -579,7 +583,7 @@ class NodeDaemon: # ── CLI helpers ─────────────────────────────────────────────────────────────── def _daemon_api(cfg: Config, path: str, method: str = "GET", - timeout: int = 30) -> dict: + timeout: int = 30, body: dict | None = None) -> dict: """ Call the daemon's loopback API. @@ -602,15 +606,23 @@ def _daemon_api(cfg: Config, path: str, method: str = "GET", url = (f"http://127.0.0.1:{cfg.node.ui_port}{path}" f"{sep}t={token_file.read_text().strip()}") try: - req = urllib.request.Request(url, method=method) + data = _json.dumps(body).encode() if body is not None else None + req = urllib.request.Request( + url, method=method, data=data, + headers={"Content-Type": "application/json"} if data else {}) with urllib.request.urlopen(req, timeout=timeout) as r: return _json.loads(r.read()) except urllib.error.HTTPError as e: - body = e.read().decode()[:300] + raw = e.read().decode()[:600] try: - detail = _json.loads(body).get("error", body) + parsed = _json.loads(raw) + detail = parsed.get("error", raw) + # Endpoints that refuse a name offer the ones that would work; a bare + # "no such group" leaves the operator guessing at a UUID. + for row in parsed.get("available", []): + detail += f"\n {row.get('name', ''):<24} {row.get('id', '')}" except Exception: - detail = body + detail = raw print(f"failed: {detail}") sys.exit(1) except Exception as e: @@ -663,15 +675,20 @@ def main() -> None: parser = argparse.ArgumentParser(description="MeshBay Node daemon") parser.add_argument("command", nargs="?", choices=["init", "status", "ui", "gek-init", "operator", - "member", "calibrate-argon2"], + "member", "group", "calibrate-argon2"], help="init: write example config | status: node state and keys " "| ui: print the admin UI URL | operator pair: pair a " "browser with this node | member list|invite|revoke|unpin " - "| calibrate-argon2: benchmark") + "| group add <name> --dir <path>: host another of your " + "groups | calibrate-argon2: benchmark") parser.add_argument("subcommand", nargs="?", - help="'pair' for operator; list|invite|revoke|unpin for member") + help="'pair' for operator; list|invite|revoke|unpin for " + "member; 'add' for group") parser.add_argument("target", nargs="?", - help="username, for member invite|revoke|unpin") + help="username for member invite|revoke|unpin; " + "group name for group add") + parser.add_argument("--dir", default=None, + help="shared directory, for group add") parser.add_argument("--config", type=Path, default=None, help="Config file path") parser.add_argument("--group", default=None, @@ -681,7 +698,8 @@ def main() -> None: args = parser.parse_args() # Query commands print a report; library logging would interleave with it. - quiet = args.command in ("status", "ui", "gek-init", "operator", "member") + quiet = args.command in ("status", "ui", "gek-init", "operator", "member", + "group") logging.basicConfig( level=logging.ERROR if quiet else getattr(logging, args.log_level), format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", @@ -868,6 +886,30 @@ def main() -> None: print(f" ! {err}") return + if args.command == "group": + if args.subcommand != "add": + print("usage: meshbay-node group add <name> --dir <path>") + sys.exit(1) + if not args.target or not args.dir: + print("usage: meshbay-node group add <name> --dir <path>") + print() + print("The group must already exist on the hub and be yours. This") + print("only tells the node to host it, and picks the directory.") + sys.exit(1) + + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + out = _daemon_api(cfg, "/api/groups/attach", method="POST", + body={"name": args.target, "shared_dir": args.dir}) + print(f"{out['name']} ({out['group_id'][:8]}) added to {out['config']}") + print(f" shared_dir {out['shared_dir']}") + print() + print("Restart the daemon so it picks the group up, then give it a key:") + 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 + if args.command == "operator": if args.subcommand != "pair": print("usage: meshbay-node operator pair") @@ -916,7 +958,7 @@ def main() -> None: print("Error: hub.username not set in config. Run: meshbay-node init") sys.exit(1) - daemon = NodeDaemon(cfg) + daemon = NodeDaemon(cfg, Path(args.config or DEFAULT_CONFIG_PATH)) asyncio.run(daemon.run()) |