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/ui | |
| 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/ui')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 68 |
1 files changed, 68 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 28654df..b78f78d 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -23,6 +23,7 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query from fastapi.responses import HTMLResponse, JSONResponse from meshbay_node import __version__ +from meshbay_node.config import DEFAULT_CONFIG_PATH from meshbay_common.crypto import generate_gek, wrap_gek_aes from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR @@ -127,6 +128,73 @@ def create_ui_app(state: dict) -> FastAPI: }) return {"groups": result} + @app.post("/api/groups/attach") + async def attach_group(payload: dict): + """ + Write a new [[groups]] block into node.toml. + + The name-to-id lookup happens here because this process is the one logged + into the hub. Nothing is created on the hub: the group already exists, + this only tells the node to host it. + """ + name = (payload.get("name") or "").strip() + shared_dir = (payload.get("shared_dir") or "").strip() + if not name or not shared_dir: + return JSONResponse({"error": "name and shared_dir are required"}, 400) + + config = state.get("config") + if not config: + return JSONResponse({"error": "No config loaded"}, 503) + + hub = state.get("hub") + if not hub or not hub._session: + return JSONResponse({"error": "Hub not connected"}, 503) + try: + mine = await hub.list_my_groups() + except Exception as e: + return JSONResponse({"error": f"Could not list groups: {e}"}, 502) + + match = [g for g in mine if g["id"] == name or g["name"] == name] + if not match: + return JSONResponse({ + "error": f"No group of yours is called {name!r}", + "available": [{"name": g["name"], "id": g["id"]} for g in mine], + }, 404) + if len(match) > 1: + return JSONResponse({ + "error": f"Several of your groups are called {name!r} — use the id", + "available": [{"name": g["name"], "id": g["id"]} for g in match], + }, 409) + group = match[0] + + if any(g.id == group["id"] for g in config.groups): + return JSONResponse( + {"error": f"{group['name']!r} is already hosted by this node"}, 409) + + path = Path(shared_dir).expanduser() + try: + path.mkdir(parents=True, exist_ok=True) + except OSError as e: + return JSONResponse({"error": f"Cannot create {path}: {e}"}, 400) + + # Appended as text rather than re-serialised: node.toml is hand-written + # and full of comments explaining decisions, and a round trip through a + # TOML writer would throw all of that away. + conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH) + block = (f'\n[[groups]]\n' + f'id = "{group["id"]}"\n' + f'name = "{group["name"]}"\n' + f'shared_dir = "{path}"\n' + f'visibility = "{group.get("visibility", "private")}"\n') + try: + with conf_path.open("a") as f: + f.write(block) + except OSError as e: + return JSONResponse({"error": f"Cannot write {conf_path}: {e}"}, 500) + + return {"group_id": group["id"], "name": group["name"], + "shared_dir": str(path), "config": str(conf_path)} + @app.get("/api/groups/{group_id}/files") async def api_group_files(group_id: str): groups_ctx = state.get("groups_ctx", {}) |