"""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)