diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index faf06c2..70aa6b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,7 @@ jobs: - run: pip install -e ".[dev]" - run: pyinstaller --onefile src/nexus_sync/client/__main__.py --name nexus-sync-client - run: pyinstaller --onefile src/nexus_sync/server/__main__.py --name nexus-sync-server + - run: pyinstaller --onefile src/nexus_sync/cli/__main__.py --name nexus-cli - uses: actions/upload-artifact@v4 with: name: nexus-sync-${{ matrix.os }} diff --git a/Makefile b/Makefile index 1cb8927..0948247 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ -.PHONY: all client server test clean +.PHONY: all client server cli test clean -all: client server +all: client server cli client: pyinstaller --onefile src/nexus_sync/client/__main__.py --name nexus-sync-client @@ -8,6 +8,9 @@ client: server: pyinstaller --onefile src/nexus_sync/server/__main__.py --name nexus-sync-server +cli: + pyinstaller --onefile src/nexus_sync/cli/__main__.py --name nexus-cli + test: pytest diff --git a/README.md b/README.md index 1575543..fcebc49 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ stays local to the client config. - API contract: [docs/api.md](docs/api.md) - Client behavior notes: [docs/client.md](docs/client.md) - Server behavior notes: [docs/server.md](docs/server.md) +- CLI usage: [docs/cli.md](docs/cli.md) ## Server API @@ -84,10 +85,34 @@ Queue command example: Clients receive queued commands on their next heartbeat and report results in a later heartbeat. +## CLI + +Install the package in editable mode to use the server API CLI: + +```bash +pip install -e . +nexus-cli --help +``` + +Common commands: + +```bash +nexus-cli --list +nexus-cli --server-url http://127.0.0.1:5852 --list +nexus-cli client linux-client +nexus-cli client linux-client --run-command hostname +nexus-cli client linux-client --run-command hostname --timeout-seconds 30 +nexus-cli command cmd_123 +nexus-cli --json command cmd_123 +``` + +The CLI uses only server-side API handlers. Queued commands are delivered to the +client on its next heartbeat. + ### To build ``` -make [client|server] +make [client|server|cli] ``` ## systemd templates @@ -143,7 +168,7 @@ sudo systemctl enable --now nexus-sync-client.timer Install the client YAML config separately, for example: ```bash -sudo install -Dm600 template/linux.yaml /root/.config/nexus/config.yaml +sudo install -Dm600 template/linux-config.yaml /root/.config/nexus/config.yaml sudo editor /root/.config/nexus/config.yaml ``` diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..115c0e4 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,70 @@ +# nexus-cli + +`nexus-cli` is a small non-interactive CLI for the nexus-sync server API. + +## Install for development + +```bash +pip install -e . +nexus-cli --help +``` + +The default server URL is `http://127.0.0.1:5852`. Override it with +`--server-url` or `-s`. + +## List clients + +```bash +nexus-cli --list +``` + +## Show client info + +```bash +nexus-cli client linux-client +``` + +The output includes basic client metadata and advertised `available_commands`. + +## Queue a command for a client + +```bash +nexus-cli client linux-client --run-command hostname +nexus-cli client linux-client --run-command hostname --timeout-seconds 30 +``` + +Payload shape: + +```json +{ + "name": "hostname", + "args": {}, + "timeout_seconds": 30 +} +``` + +## Show command execution info + +```bash +nexus-cli command cmd_123 +``` + +If the client has reported a result, stdout and stderr are printed. + +## JSON output + +Use `--json` for machine-readable output: + +```bash +nexus-cli --json --list +nexus-cli --json client linux-client +nexus-cli --json command cmd_123 +``` + +## Build standalone binary + +```bash +make cli +``` + +This creates `dist/nexus-cli` through PyInstaller. diff --git a/pyproject.toml b/pyproject.toml index c41b1a1..d512409 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,9 @@ dev = [ "pre-commit>=4.0" ] +[project.scripts] +nexus-cli = "nexus_sync.cli.__main__:main" + [tool.setuptools.packages.find] where = ["src"] diff --git a/src/nexus_sync/cli/__init__.py b/src/nexus_sync/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/nexus_sync/cli/__main__.py b/src/nexus_sync/cli/__main__.py new file mode 100644 index 0000000..0e1010e --- /dev/null +++ b/src/nexus_sync/cli/__main__.py @@ -0,0 +1,254 @@ +import argparse +import json +import sys +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Callable +from types import TracebackType +from typing import Any, Protocol, Self + +DEFAULT_SERVER_URL = "http://127.0.0.1:5852" +API_PREFIX = "/api/v1" +JsonObject = dict[str, Any] +Requester = Callable[[str, str, JsonObject | None], JsonObject] + + +class HTTPResponse(Protocol): + def __enter__(self) -> Self: ... + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: ... + + def read(self) -> bytes: ... + + +Opener = Callable[[urllib.request.Request], HTTPResponse] + + +class CLIError(RuntimeError): + pass + + +def request_json( + method: str, + url: str, + payload: JsonObject | None = None, + *, + opener: Opener = urllib.request.urlopen, +) -> JsonObject: + data = None + headers = {"Accept": "application/json"} + if payload is not None: + data = json.dumps(payload).encode() + headers["Content-Type"] = "application/json" + + request = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with opener(request) as response: + body = response.read() + except urllib.error.HTTPError as error: + detail = error.read().decode(errors="replace") + raise CLIError(f"HTTP {error.code}: {detail}") from error + except urllib.error.URLError as error: + raise CLIError(f"request failed: {error.reason}") from error + except OSError as error: + raise CLIError(f"request failed: {error}") from error + + try: + result = json.loads(body.decode()) + except json.JSONDecodeError as error: + raise CLIError(f"server returned invalid JSON: {error}") from error + if not isinstance(result, dict): + raise CLIError("server returned JSON that is not an object") + return result + + +def format_clients(payload: JsonObject) -> str: + clients = payload.get("clients", []) + if not isinstance(clients, list) or not clients: + return "clients:\n- none" + + lines = ["clients:"] + for item in clients: + if not isinstance(item, dict): + continue + parts = [ + str(item.get("id", "")), + str(item.get("platform", "unknown")), + str(item.get("hostname", "unknown")), + f"version={item.get('version', 'unknown')}", + f"last_seen={item.get('last_seen_at', 'unknown')}", + ] + lines.append(f"- {' '.join(parts)}") + commands = _command_names(item.get("available_commands", [])) + if commands: + lines.append(f" commands: {', '.join(commands)}") + return "\n".join(lines) + + +def format_client(payload: JsonObject) -> str: + lines = [ + f"id: {payload.get('id', '')}", + f"hostname: {payload.get('hostname', 'unknown')}", + f"platform: {payload.get('platform', 'unknown')}", + f"version: {payload.get('version', 'unknown')}", + f"created_at: {payload.get('created_at', 'unknown')}", + f"last_seen_at: {payload.get('last_seen_at', 'unknown')}", + "available_commands:", + ] + commands = payload.get("available_commands", []) + if not isinstance(commands, list) or not commands: + lines.append("- none") + return "\n".join(lines) + + for command in commands: + if isinstance(command, dict): + name = command.get("name", "") + description = command.get("description", "") + suffix = f" - {description}" if description else "" + lines.append(f"- {name}{suffix}") + return "\n".join(lines) + + +def format_command(payload: JsonObject, *, queued: bool = False) -> str: + lines = ["queued command:" if queued else "command:"] + for key in ( + "id", + "client_id", + "kind", + "name", + "status", + "timeout_seconds", + "created_at", + "delivered_at", + "finished_at", + ): + if key in payload: + lines.append(f"{key}: {payload.get(key)}") + + result = payload.get("result") + if isinstance(result, dict): + lines.append("result:") + if "status" in result: + lines.append(f" status: {result.get('status')}") + if "return_code" in result: + lines.append(f" return_code: {result.get('return_code')}") + lines.append(" stdout:") + lines.extend(_indent_block(str(result.get("stdout", "")))) + lines.append(" stderr:") + lines.extend(_indent_block(str(result.get("stderr", "")))) + return "\n".join(lines) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="nexus-cli", description="CLI for nexus-sync server API") + parser.add_argument( + "--server-url", "-s", default=DEFAULT_SERVER_URL, help="nexus-sync server URL" + ) + parser.add_argument("--list", action="store_true", help="list clients") + parser.add_argument("--json", action="store_true", help="print raw JSON response") + + subparsers = parser.add_subparsers(dest="resource") + client = subparsers.add_parser("client", help="show client info or queue a command") + client.add_argument("id", help="client id") + client.add_argument("--run-command", metavar="NAME", help="queue a command for this client") + client.add_argument( + "--timeout-seconds", + type=int, + default=30, + help="command timeout in seconds for --run-command", + ) + + command = subparsers.add_parser("command", help="show command execution info") + command.add_argument("id", help="command id") + return parser + + +def main( + argv: list[str] | None = None, + *, + requester: Requester = request_json, +) -> int: + parser = build_parser() + args = parser.parse_args(argv) + server_url = str(args.server_url).rstrip("/") + + try: + if args.list: + payload = requester("GET", f"{server_url}{API_PREFIX}/server/clients", None) + _print_payload(payload, raw_json=args.json, formatter=format_clients) + return 0 + + if args.resource == "client": + client_id = urllib.parse.quote(str(args.id), safe="") + if args.run_command: + payload = requester( + "POST", + f"{server_url}{API_PREFIX}/server/clients/{client_id}/commands", + { + "name": args.run_command, + "args": {}, + "timeout_seconds": args.timeout_seconds, + }, + ) + _print_payload( + payload, + raw_json=args.json, + formatter=lambda value: format_command(value, queued=True), + ) + return 0 + + payload = requester("GET", f"{server_url}{API_PREFIX}/server/clients/{client_id}", None) + _print_payload(payload, raw_json=args.json, formatter=format_client) + return 0 + + if args.resource == "command": + command_id = urllib.parse.quote(str(args.id), safe="") + payload = requester( + "GET", f"{server_url}{API_PREFIX}/server/commands/{command_id}", None + ) + _print_payload(payload, raw_json=args.json, formatter=format_command) + return 0 + except CLIError as error: + print(f"nexus-cli error: {error}", file=sys.stderr) + return 1 + + parser.print_help() + return 2 + + +def _print_payload( + payload: JsonObject, + *, + raw_json: bool, + formatter: Callable[[JsonObject], str], +) -> None: + if raw_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) + return + print(formatter(payload)) + + +def _command_names(commands: object) -> list[str]: + if not isinstance(commands, list): + return [] + names = [] + for command in commands: + if isinstance(command, dict) and command.get("name"): + names.append(str(command["name"])) + return names + + +def _indent_block(value: str) -> list[str]: + if not value: + return [" "] + return [f" {line}" if line else "" for line in value.rstrip("\n").splitlines()] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..af7dbf7 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,232 @@ +import io +import json +import urllib.request +from typing import Any + +import pytest + +from nexus_sync.cli.__main__ import ( + DEFAULT_SERVER_URL, + format_client, + format_clients, + format_command, + main, + request_json, +) + + +class _Response: + def __init__(self, payload: dict[str, Any]) -> None: + self.payload = payload + + def __enter__(self) -> "_Response": + return self + + def __exit__(self, *args: object) -> None: + return None + + def read(self) -> bytes: + return json.dumps(self.payload).encode() + + +def test_request_json_sends_get_request() -> None: + captured: dict[str, Any] = {} + + def fake_opener(request: urllib.request.Request) -> _Response: + captured["url"] = request.full_url + captured["method"] = request.get_method() + captured["data"] = request.data + return _Response({"clients": []}) + + result = request_json("GET", "http://server.test/api/v1/server/clients", opener=fake_opener) + + assert result == {"clients": []} + assert captured == { + "url": "http://server.test/api/v1/server/clients", + "method": "GET", + "data": None, + } + + +def test_request_json_sends_post_json_request() -> None: + captured: dict[str, Any] = {} + + def fake_opener(request: urllib.request.Request) -> _Response: + captured["url"] = request.full_url + captured["method"] = request.get_method() + captured["content_type"] = request.get_header("Content-type") + captured["payload"] = json.loads((request.data or b"").decode()) + return _Response({"id": "cmd_123", "status": "pending"}) + + result = request_json( + "POST", + "http://server.test/api/v1/server/clients/linux-client/commands", + payload={"name": "hostname", "args": {}, "timeout_seconds": 30}, + opener=fake_opener, + ) + + assert result == {"id": "cmd_123", "status": "pending"} + assert captured == { + "url": "http://server.test/api/v1/server/clients/linux-client/commands", + "method": "POST", + "content_type": "application/json", + "payload": {"name": "hostname", "args": {}, "timeout_seconds": 30}, + } + + +def test_main_lists_clients_as_text(capsys: pytest.CaptureFixture[str]) -> None: + calls: list[tuple[str, str, dict[str, Any] | None]] = [] + + def fake_request( + method: str, url: str, payload: dict[str, Any] | None = None + ) -> dict[str, Any]: + calls.append((method, url, payload)) + return { + "clients": [ + { + "id": "linux-client", + "hostname": "box", + "platform": "linux", + "version": "0.1.0", + "last_seen_at": "2026-06-24T12:00:00Z", + "available_commands": [{"name": "hostname", "description": "Return hostname"}], + } + ] + } + + exit_code = main(["--server-url", "http://server.test", "--list"], requester=fake_request) + + assert exit_code == 0 + assert calls == [("GET", "http://server.test/api/v1/server/clients", None)] + output = capsys.readouterr().out + assert "clients:" in output + assert "linux-client" in output + assert "box" in output + assert "hostname" in output + + +def test_main_shows_client_as_json(capsys: pytest.CaptureFixture[str]) -> None: + def fake_request( + method: str, url: str, payload: dict[str, Any] | None = None + ) -> dict[str, Any]: + assert method == "GET" + assert url == f"{DEFAULT_SERVER_URL}/api/v1/server/clients/linux-client" + assert payload is None + return {"id": "linux-client", "hostname": "box", "available_commands": []} + + exit_code = main(["--json", "client", "linux-client"], requester=fake_request) + + assert exit_code == 0 + assert json.loads(capsys.readouterr().out) == { + "id": "linux-client", + "hostname": "box", + "available_commands": [], + } + + +def test_main_queues_client_command(capsys: pytest.CaptureFixture[str]) -> None: + calls: list[tuple[str, str, dict[str, Any] | None]] = [] + + def fake_request( + method: str, url: str, payload: dict[str, Any] | None = None + ) -> dict[str, Any]: + calls.append((method, url, payload)) + return { + "id": "cmd_123", + "client_id": "linux-client", + "name": "hostname", + "status": "pending", + "created_at": "2026-06-24T12:00:00Z", + } + + exit_code = main( + [ + "--server-url", + "http://server.test/", + "client", + "linux-client", + "--run-command", + "hostname", + "--timeout-seconds", + "5", + ], + requester=fake_request, + ) + + assert exit_code == 0 + assert calls == [ + ( + "POST", + "http://server.test/api/v1/server/clients/linux-client/commands", + {"name": "hostname", "args": {}, "timeout_seconds": 5}, + ) + ] + output = capsys.readouterr().out + assert "queued command:" in output + assert "cmd_123" in output + assert "pending" in output + + +def test_main_shows_command_result(capsys: pytest.CaptureFixture[str]) -> None: + def fake_request( + method: str, url: str, payload: dict[str, Any] | None = None + ) -> dict[str, Any]: + assert method == "GET" + assert url == "http://server.test/api/v1/server/commands/cmd_123" + assert payload is None + return { + "id": "cmd_123", + "client_id": "linux-client", + "name": "hostname", + "status": "succeeded", + "result": {"return_code": 0, "stdout": "box\n", "stderr": ""}, + } + + exit_code = main(["-s", "http://server.test", "command", "cmd_123"], requester=fake_request) + + assert exit_code == 0 + output = capsys.readouterr().out + assert "cmd_123" in output + assert "succeeded" in output + assert "stdout:" in output + assert "box" in output + + +def test_formatters_include_core_fields() -> None: + clients_text = format_clients( + { + "clients": [ + { + "id": "linux-client", + "hostname": "box", + "platform": "linux", + "version": "0.1.0", + "last_seen_at": "now", + "available_commands": [{"name": "hostname", "description": "Return hostname"}], + } + ] + } + ) + client_text = format_client( + { + "id": "linux-client", + "hostname": "box", + "platform": "linux", + "available_commands": [{"name": "hostname", "description": "Return hostname"}], + } + ) + command_text = format_command( + { + "id": "cmd_123", + "client_id": "linux-client", + "name": "hostname", + "status": "succeeded", + "result": {"return_code": 0, "stdout": "box\n", "stderr": ""}, + }, + queued=True, + ) + + assert "linux-client" in clients_text + assert "hostname" in client_text + assert "queued command:" in command_text + assert "box" in command_text