From d155af4b7059749e8ef3efb91968e93d615eb1f8 Mon Sep 17 00:00:00 2001 From: Assistant builder Date: Thu, 11 Jun 2026 22:48:46 +0000 Subject: [PATCH] Plan 3: pair CLI (POST /api/pair, write config.json) --- client/pair.py | 63 +++++++++++++++++++++++++++- client/tests/test_pair.py | 86 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 client/tests/test_pair.py diff --git a/client/pair.py b/client/pair.py index ceebfae..8258de7 100644 --- a/client/pair.py +++ b/client/pair.py @@ -1,8 +1,67 @@ -"""Pairing CLI; see Plan 3 Task 6 for the full implementation.""" +"""Pair this Pi with the backend. + +Usage: + python -m client.pair --backend https://backend.example.com [--code XYZ12345] + +Called from install.sh after it prompts the user for the code. +""" +import argparse +import socket + +import requests + +from client.config import Config, save_config +from client.log import get_logger + +_log = get_logger("pair") + +CLIENT_VERSION = "0.1.0" + + +def pair(*, backend: str, code: str | None, hostname: str) -> None: + backend = backend.rstrip("/") + if not code: + code = input("Pairing code: ").strip() + if not code: + raise SystemExit("no pairing code supplied") + + url = f"{backend}/api/pair" + payload = {"code": code, "hostname": hostname, "client_version": CLIENT_VERSION} + _log.info("POST %s code=%s hostname=%s", url, code, hostname) + + try: + resp = requests.post(url, json=payload, timeout=30) + except requests.RequestException as exc: + raise SystemExit(f"pair request failed: {exc}") from exc + + if resp.status_code != 200: + try: + err = resp.json().get("error", resp.text) + except ValueError: + err = resp.text + raise SystemExit(f"/api/pair returned {resp.status_code}: {err}") + + body = resp.json() + cfg = Config( + device_id=str(body["device_id"]), + device_token=str(body["device_token"]), + backend_ws=str(body["backend_ws"]), + ) + save_config(cfg) + _log.info("paired device %s; config saved", cfg.device_id) def main() -> None: - raise SystemExit("client.pair: not yet implemented (see Plan 3 Task 6).") + parser = argparse.ArgumentParser(description="Pair this Pi with the backend.") + parser.add_argument("--backend", required=True, help="Backend base URL (https://...)") + parser.add_argument("--code", default=None, help="Pairing code (prompted if omitted)") + parser.add_argument( + "--hostname", + default=socket.gethostname(), + help="Hostname reported to the backend (defaults to socket.gethostname())", + ) + args = parser.parse_args() + pair(backend=args.backend, code=args.code, hostname=args.hostname) if __name__ == "__main__": diff --git a/client/tests/test_pair.py b/client/tests/test_pair.py new file mode 100644 index 0000000..3f60367 --- /dev/null +++ b/client/tests/test_pair.py @@ -0,0 +1,86 @@ +import io + +import pytest +import responses + +from client.config import load_config +from client.pair import pair + + +@responses.activate +def test_pair_posts_to_api_pair_and_writes_config(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + responses.post( + "https://backend.test/api/pair", + json={ + "device_id": "00000000-0000-0000-0000-000000000001", + "device_token": "T0K3N", + "backend_ws": "wss://backend.test/device", + }, + status=200, + ) + + pair(backend="https://backend.test", code="ABC12345", hostname="testhost") + + cfg = load_config() + assert cfg.device_id == "00000000-0000-0000-0000-000000000001" + assert cfg.device_token == "T0K3N" + assert cfg.backend_ws == "wss://backend.test/device" + + body = responses.calls[0].request.body + assert b"ABC12345" in body + assert b"testhost" in body + + +@responses.activate +def test_pair_strips_trailing_slash_from_backend(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + responses.post( + "https://backend.test/api/pair", + json={"device_id": "x", "device_token": "y", "backend_ws": "wss://z"}, + status=200, + ) + pair(backend="https://backend.test/", code="ABC12345", hostname="h") + assert responses.calls[0].request.url == "https://backend.test/api/pair" + + +@responses.activate +def test_pair_raises_on_404(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + responses.post( + "https://backend.test/api/pair", + json={"error": "code invalid or expired"}, + status=404, + ) + with pytest.raises(SystemExit) as exc: + pair(backend="https://backend.test", code="WRONGCOD", hostname="h") + assert "code invalid or expired" in str(exc.value) + + +def test_prompt_used_when_code_not_provided(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr("sys.stdin", io.StringIO("FROMSTDIN\n")) + + called_with = {} + + def fake_post(url, json, timeout): + called_with["json"] = json + + class R: + status_code = 200 + + def json(self_): + return { + "device_id": "x", + "device_token": "y", + "backend_ws": "wss://z", + } + + def raise_for_status(self_): + pass + + return R() + + monkeypatch.setattr("client.pair.requests.post", fake_post) + pair(backend="https://backend.test", code=None, hostname="h") + assert called_with["json"]["code"] == "FROMSTDIN"