From 6e9afe3e54ab6fb0162393150b7892655b4561d4 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 24 Sep 2026 18:19:13 +0200 Subject: 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 --- packages/meshbay-node/src/meshbay_node/cli/api.py | 131 ++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 packages/meshbay-node/src/meshbay_node/cli/api.py (limited to 'packages/meshbay-node/src/meshbay_node/cli/api.py') diff --git a/packages/meshbay-node/src/meshbay_node/cli/api.py b/packages/meshbay-node/src/meshbay_node/cli/api.py new file mode 100644 index 0000000..a11ab0a --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/cli/api.py @@ -0,0 +1,131 @@ +"""What every verb needs: the daemon's loopback API, the group an argument +names, and systemd for the lifecycle verbs.""" + +import sys + +from meshbay_node.config import DEFAULT_CONFIG_PATH, Config + + +def _daemon_api(cfg: Config, path: str, method: str = "GET", + timeout: int = 30, body: dict | None = None) -> dict: + """ + Call the daemon's loopback API. + + The daemon owns the roster, the hub session and the live group contexts, so + the CLI asks it to act rather than opening its databases behind its back. It + also means every operator action goes through the control API's per-run + session token (11.5.3), the same gate the desktop client's Node page passes. + """ + import json as _json + import urllib.error + import urllib.parse + import urllib.request + + token_file = cfg.data_dir / "ui-token" + if not token_file.exists(): + print("Node is not running — start it with: meshbay-node") + sys.exit(1) + + sep = "&" if "?" in path else "?" + url = (f"http://127.0.0.1:{cfg.node.ui_port}{path}" + f"{sep}t={token_file.read_text(encoding="utf-8").strip()}") + try: + 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: + raw = e.read().decode()[:600] + try: + 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 = raw + print(f"failed: {detail}") + sys.exit(1) + except Exception as e: + print(f"failed: {e}") + sys.exit(1) + + +def _resolve_group(cfg: Config, group: str | None) -> str: + """ + The group argument as an id, or the only configured one. + + Accepts a name as well, because node.toml already gives every group one and + nobody remembers a UUID. A name that matches nothing configured says so, and + lists what is — silently passing it through produced a 404 from the daemon + that read like the group did not exist on the hub. + """ + if group: + by_id = [g for g in cfg.groups if g.id == group] + if by_id: + return by_id[0].id + by_name = [g for g in cfg.groups if g.name == group and g.id] + if len(by_name) == 1: + return by_name[0].id + if len(by_name) > 1: + print(f"several groups in node.toml are named {group!r} — use the id") + sys.exit(1) + # An id this node does not host is still worth passing on: the daemon + # gives the better error, naming the group it does host. + if "-" in group and len(group) == 36: + return group + print(f"no group named {group!r} in {DEFAULT_CONFIG_PATH}") + if cfg.groups: + print("configured groups:") + for g in cfg.groups: + print(f" {g.name or '(unnamed)':<24} {g.id or '(no id yet)'}") + sys.exit(1) + configured = [g.id for g in cfg.groups if g.id] + if len(configured) == 1: + return configured[0] + print("--group is required (several groups configured)" + if configured else "no group configured in node.toml") + sys.exit(1) + + +def _systemctl_user(verb: str, unit: str, *, not_running_hint: str, + success: str, watch: str | None) -> None: + """ + Run `systemctl --user ` and report the result. + + The lifecycle authority is the unit, not this process: systemd already + knows which PID it started, restarts it on failure (`Restart=on-failure` + in the unit) and reloads it correctly (`ExecReload=`). Anything this CLI + did instead — finding a process by pattern-matching its command line, + signalling it, respawning it — is a second, worse implementation of what + systemd is already doing, and pattern-matching a process list has already + hit a real developer's real running node by accident. + + Reloads the user manager's view of unit files first. The package + installers (deb postinst, rpm %post) run as root and can only reload the + *system* manager — a different process from any signed-in user's *user* + manager, which is the one that actually owns this unit — so a package + upgrade leaves that manager still holding the old unit file and prints a + warning naming the exact fix. Doing it here runs it under the right + privilege, the user's own, right before the command that would otherwise + act on a stale definition. Best-effort and unchecked: a reload the + manager did not need must never block what the operator actually asked + for, and a genuine problem still surfaces from the verb below. + """ + import subprocess + + subprocess.run(["systemctl", "--user", "daemon-reload"], + capture_output=True, text=True) + + result = subprocess.run(["systemctl", "--user", verb, unit], + capture_output=True, text=True) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + print(detail or not_running_hint) + sys.exit(1) + print(success) + if watch: + print(watch) -- cgit v1.2.3