aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/cli/api.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-24 18:19:13 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-24 18:19:13 +0200
commit6e9afe3e54ab6fb0162393150b7892655b4561d4 (patch)
treef1cf20d891bf0d75dd206953bcec781f47ab7b15 /packages/meshbay-node/src/meshbay_node/cli/api.py
parenta408a25af7b91abf729ec5fac7e5506437a86049 (diff)
downloadmeshbay-6e9afe3e54ab6fb0162393150b7892655b4561d4.tar.gz
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/cli/api.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/cli/api.py131
1 files changed, 131 insertions, 0 deletions
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 <verb> <unit>` 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)