From fe2db1b6e9b5c8aef782d73303f28b818a4ed696 Mon Sep 17 00:00:00 2001 From: Dmitrii Krosh Date: Wed, 24 Jun 2026 04:42:00 +0300 Subject: [PATCH] add yml config file, client now does not depends on .env --- .env.example | 34 +------ README.md | 39 +++++++- docs/client.md | 27 ++++-- pyproject.toml | 2 + src/nexus_sync/client/__init__.py | 8 -- src/nexus_sync/client/config.py | 25 ----- src/nexus_sync/client/runtime.py | 153 ++++++++++++++++++++++++++---- src/nexus_sync/server/__main__.py | 2 +- template/darwin.yaml | 12 +++ template/linux.yaml | 12 +++ template/windows.yaml | 12 +++ tests/test_client_config.py | 42 -------- tests/test_client_runtime.py | 124 +++++++++++++++++------- 13 files changed, 325 insertions(+), 167 deletions(-) delete mode 100644 src/nexus_sync/client/config.py create mode 100644 template/darwin.yaml create mode 100644 template/linux.yaml create mode 100644 template/windows.yaml delete mode 100644 tests/test_client_config.py diff --git a/.env.example b/.env.example index 4b8fdf7..8b7adfd 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ -# nexus-sync environment example -# Copy this file to .env and replace example secrets before running. +# nexus-sync server environment example +# Copy this file to .env and replace example secrets before running the server. # ----------------------------------------------------------------------------- # Server configuration @@ -15,33 +15,5 @@ # better to set this explicitly. NEXUS_SYNC_CLIENT_TOKENS="dev-client:dev-token" -# Logging level for both server and client: DEBUG, INFO, WARNING, ERROR, CRITICAL. +# Logging level for the server: DEBUG, INFO, WARNING, ERROR, CRITICAL. NEXUS_SYNC_LOG_LEVEL="INFO" - -# ----------------------------------------------------------------------------- -# Client configuration -# ----------------------------------------------------------------------------- - -# Base URL of the nexus-sync server. -NEXUS_SYNC_SERVER_URL="http://127.0.0.1:8000" - -# Stable ID of this machine/client. Must match one client_id from -# NEXUS_SYNC_CLIENT_TOKENS on the server. -NEXUS_SYNC_CLIENT_ID="dev-client" - -# Bearer token for this client. Must match the token paired with -# NEXUS_SYNC_CLIENT_ID on the server. -NEXUS_SYNC_CLIENT_TOKEN="dev-token" - -# Commands this client is allowed to execute when the server requests them. -# Supported presets currently implemented: -# - hostname -# - network_interfaces -# -# Safer explicit allowlist: -NEXUS_SYNC_ALLOWED_COMMANDS="hostname,network_interfaces" - -# Alternative: allow every locally registered preset. This still does NOT allow -# arbitrary shell strings from the server. Uncomment instead of the line above if -# desired. -# NEXUS_SYNC_ALLOWED_COMMANDS="full_access" diff --git a/README.md b/README.md index c6b7bcf..40432bf 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ a utility that allows you to manage your computers. -*Main Idea* +_Main Idea_ server can send to every client "what to do". Centralized control for all clients. ## Development @@ -11,6 +11,42 @@ server can send to every client "what to do". Centralized control for all client pip install -e .[dev] ``` +## Client config + +The client reads YAML config files. The server still use +`.env` for server-side settings such as `NEXUS_SYNC_CLIENT_TOKENS`. + +Client config is searched in this order: + +1. `$(pwd)/nexus.yml` +2. `$(pwd)/nexus.yaml` +3. `$XDG_CONFIG_HOME/nexus.yml` +4. `$XDG_CONFIG_HOME/nexus.yaml` +5. `~/.config/nexus.yml` +6. `~/.config/nexus.yaml` +7. `~/.config/nexus/config.yml` +8. `~/.config/nexus/config.yaml` + +Example: + +```yaml +server_url: "http://127.0.0.1:5852" +client_id: "linux-client" +client_token: "change-me-client-token" +allowed_commands: + - name: hostname + description: "Get the hostname of the client machine" + cmd: "hostname" + - name: network_interfaces + description: "Get network interface information" + cmd: "ip addr show" +logging_level: "INFO" +``` + +Template examples are available in `template/` for Linux, Windows, and Darwin. +The client reports only command `name` and `description` to the server; `cmd` +stays local to the client config. + ## Current design - API contract: [docs/api.md](docs/api.md) @@ -23,7 +59,6 @@ pip install -e .[dev] make [client|server] ``` - ### To test ``` diff --git a/docs/client.md b/docs/client.md index 5b08e6a..9f089f1 100644 --- a/docs/client.md +++ b/docs/client.md @@ -32,15 +32,26 @@ client -> server: hello!, i'm $(hostname), uuid= , ts=, stdout= , stderr= , ... ## Разрешённые команды -Клиент исполняет только локально разрешённые command presets. Базовая -настройка задаётся переменной окружения `NEXUS_SYNC_ALLOWED_COMMANDS`. +Клиент исполняет только команды, описанные в локальном YAML config-файле. +Серверу отправляются только `name` и `description`; поле `cmd` остаётся только +на клиенте и не управляется сервером. -Примеры: +Пример: -```bash -NEXUS_SYNC_ALLOWED_COMMANDS=hostname,network_interfaces -NEXUS_SYNC_ALLOWED_COMMANDS=full_access +```yaml +server_url: "http://127.0.0.1:5852" +client_id: "linux-client" +client_token: "change-me-client-token" +allowed_commands: + - name: hostname + description: "Get the hostname of the client machine" + cmd: "hostname" + - name: network_interfaces + description: "Get network interface information" + cmd: "ip addr show" +logging_level: "INFO" ``` -`full_access` означает доступ ко всем локально зарегистрированным presets. Это -не разрешает выполнение произвольных shell-строк от сервера. +Файл ищется как `nexus.yml`/`nexus.yaml` в текущей директории, затем в +`$XDG_CONFIG_HOME`, затем в `~/.config`, затем как +`~/.config/nexus/config.yml`/`.yaml`. diff --git a/pyproject.toml b/pyproject.toml index b7c7de9..c41b1a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ requires-python = ">=3.11" dependencies = [ "fastapi>=0.115.0", "pydantic>=2.10.0", + "PyYAML>=6.0.0", "sqlalchemy>=2.0.0", "uvicorn[standard]>=0.34.0", ] @@ -23,6 +24,7 @@ dev = [ "black>=24.0", "pyinstaller>=6.0", "mypy>=1.0", + "types-PyYAML>=6.0.12", "pre-commit>=4.0" ] diff --git a/src/nexus_sync/client/__init__.py b/src/nexus_sync/client/__init__.py index 7da3c39..31bc423 100644 --- a/src/nexus_sync/client/__init__.py +++ b/src/nexus_sync/client/__init__.py @@ -1,17 +1,9 @@ -from nexus_sync.client.config import ( - COMMAND_ACCESS_ENV, - FULL_ACCESS_VALUE, - load_command_access_policy, -) from nexus_sync.client.execute import ( CommandAccessPolicy, execute_command, ) __all__ = [ - "COMMAND_ACCESS_ENV", "CommandAccessPolicy", - "FULL_ACCESS_VALUE", "execute_command", - "load_command_access_policy", ] diff --git a/src/nexus_sync/client/config.py b/src/nexus_sync/client/config.py deleted file mode 100644 index a8300d4..0000000 --- a/src/nexus_sync/client/config.py +++ /dev/null @@ -1,25 +0,0 @@ -import os -from collections.abc import Mapping - -from nexus_sync.client.execute import CommandAccessPolicy - -COMMAND_ACCESS_ENV = "NEXUS_SYNC_ALLOWED_COMMANDS" -FULL_ACCESS_VALUE = "full_access" - - -def load_command_access_policy( - env: Mapping[str, str] = os.environ, - *, - default: CommandAccessPolicy | None = None, -) -> CommandAccessPolicy: - raw_value = env.get(COMMAND_ACCESS_ENV) - if raw_value is None or not raw_value.strip(): - return default or CommandAccessPolicy.deny_all() - - command_names = [item.strip() for item in raw_value.split(",") if item.strip()] - if len(command_names) == 1 and command_names[0].lower() == FULL_ACCESS_VALUE: - return CommandAccessPolicy.allow_all() - if any(command_name.lower() == FULL_ACCESS_VALUE for command_name in command_names): - raise ValueError(f"{FULL_ACCESS_VALUE} cannot be mixed with explicit command names") - - return CommandAccessPolicy.allow(command_names) diff --git a/src/nexus_sync/client/runtime.py b/src/nexus_sync/client/runtime.py index c2f0a62..c7effef 100644 --- a/src/nexus_sync/client/runtime.py +++ b/src/nexus_sync/client/runtime.py @@ -2,21 +2,25 @@ import json import logging import os import platform +import shlex import socket import urllib.error import urllib.request -from dataclasses import dataclass +from collections.abc import Mapping as MappingABC +from dataclasses import dataclass, field from datetime import UTC, datetime +from pathlib import Path from types import TracebackType from typing import Callable, Mapping, Protocol, Self +import yaml # type: ignore[import-untyped] from pydantic import ValidationError -from nexus_sync.client.config import load_command_access_policy from nexus_sync.client.execute import ( DEFAULT_PRESET_DESCRIPTIONS, DEFAULT_PRESETS, CommandAccessPolicy, + PresetBuilder, execute_command, ) from nexus_sync.common import ( @@ -29,9 +33,6 @@ from nexus_sync.common import ( HeartbeatResponse, ) -SERVER_URL_ENV = "NEXUS_SYNC_SERVER_URL" -CLIENT_ID_ENV = "NEXUS_SYNC_CLIENT_ID" -CLIENT_TOKEN_ENV = "NEXUS_SYNC_CLIENT_TOKEN" CLIENT_VERSION = "0.1.0" HEARTBEAT_PATH = "/api/v1/client/heartbeat" logger = logging.getLogger(__name__) @@ -67,17 +68,75 @@ class ClientConfig: client_id: str token: str command_access_policy: CommandAccessPolicy + command_presets: Mapping[str, PresetBuilder] = field(default_factory=lambda: DEFAULT_PRESETS) + command_descriptions: Mapping[str, str] = field( + default_factory=lambda: DEFAULT_PRESET_DESCRIPTIONS + ) + logging_level: str = "INFO" -def load_client_config(env: Mapping[str, str] = os.environ) -> ClientConfig: - server_url = _required_env(env, SERVER_URL_ENV).rstrip("/") - client_id = _required_env(env, CLIENT_ID_ENV) - token = _required_env(env, CLIENT_TOKEN_ENV) +def find_client_config_path( + *, + env: Mapping[str, str] = os.environ, + cwd: Path | None = None, + home: Path | None = None, +) -> Path | None: + cwd = cwd or Path.cwd() + home = home or Path.home() + candidates = [ + cwd / "nexus.yml", + cwd / "nexus.yaml", + ] + + xdg_config_home = env.get("XDG_CONFIG_HOME") + if xdg_config_home and xdg_config_home.strip(): + xdg_dir = Path(xdg_config_home).expanduser() + candidates.extend([xdg_dir / "nexus.yml", xdg_dir / "nexus.yaml"]) + + candidates.extend( + [ + home / ".config" / "nexus.yml", + home / ".config" / "nexus.yaml", + home / ".config" / "nexus" / "config.yml", + home / ".config" / "nexus" / "config.yaml", + ] + ) + + return next((path for path in candidates if path.is_file()), None) + + +def load_client_config( + env: Mapping[str, str] = os.environ, + *, + config_path: Path | str | None = None, +) -> ClientConfig: + path = Path(config_path) if config_path is not None else find_client_config_path(env=env) + if path is None: + raise ClientConfigError("client config file not found") + + try: + raw_config = yaml.safe_load(path.read_text()) + except OSError as error: + raise ClientConfigError(f"failed to read client config file {path}: {error}") from error + except yaml.YAMLError as error: + raise ClientConfigError(f"failed to parse client config file {path}: {error}") from error + + if not isinstance(raw_config, MappingABC): + raise ClientConfigError("client config file must contain a YAML mapping") + + server_url = _required_config_string(raw_config, "server_url").rstrip("/") + client_id = _required_config_string(raw_config, "client_id") + token = _required_config_string(raw_config, "client_token") + commands = _load_configured_commands(raw_config.get("allowed_commands", [])) + return ClientConfig( server_url=server_url, client_id=client_id, token=token, - command_access_policy=load_command_access_policy(env), + command_access_policy=CommandAccessPolicy.allow(commands.presets), + command_presets=commands.presets, + command_descriptions=commands.descriptions, + logging_level=str(raw_config.get("logging_level", "INFO")).strip() or "INFO", ) @@ -99,20 +158,27 @@ def build_heartbeat_request( local_time=datetime.now().astimezone(), uptime_seconds=None, ), - available_commands=list_available_commands(config.command_access_policy), + available_commands=list_available_commands( + config.command_access_policy, + presets=config.command_presets, + descriptions=config.command_descriptions, + ), last_command_result=last_command_result, ) def list_available_commands( access_policy: CommandAccessPolicy, + *, + presets: Mapping[str, PresetBuilder] = DEFAULT_PRESETS, + descriptions: Mapping[str, str] = DEFAULT_PRESET_DESCRIPTIONS, ) -> list[ClientCommandCapability]: return [ ClientCommandCapability( name=name, - description=DEFAULT_PRESET_DESCRIPTIONS.get(name, ""), + description=descriptions.get(name, ""), ) - for name in sorted(DEFAULT_PRESETS) + for name in sorted(presets) if access_policy.allows(name) ] @@ -171,6 +237,7 @@ def run_once( return executor( response.command, access_policy=config.command_access_policy, + presets=config.command_presets, ) @@ -198,10 +265,62 @@ def main(argv: list[str] | None = None) -> int: return 0 -def _required_env(env: Mapping[str, str], name: str) -> str: - value = env.get(name) - if value is None or not value.strip(): - raise ClientConfigError(f"{name} is required") +@dataclass(frozen=True) +class _ConfiguredCommands: + presets: dict[str, PresetBuilder] + descriptions: dict[str, str] + + +def _load_configured_commands(raw_commands: object) -> _ConfiguredCommands: + if raw_commands is None: + raw_commands = [] + if not isinstance(raw_commands, list): + raise ClientConfigError("allowed_commands must be a list") + + presets: dict[str, PresetBuilder] = {} + descriptions: dict[str, str] = {} + for index, raw_command in enumerate(raw_commands): + if not isinstance(raw_command, MappingABC): + raise ClientConfigError(f"allowed_commands[{index}] must be a mapping") + name = _required_config_string(raw_command, "name", prefix=f"allowed_commands[{index}]") + description = _required_config_string( + raw_command, + "description", + prefix=f"allowed_commands[{index}]", + ) + command_line = _required_config_string( + raw_command, "cmd", prefix=f"allowed_commands[{index}]" + ) + argv = shlex.split(command_line, posix=os.name != "nt") + if not argv: + raise ClientConfigError(f"allowed_commands[{index}].cmd must not be empty") + if name in presets: + raise ClientConfigError(f"duplicate allowed command name: {name}") + presets[name] = _static_preset(argv) + descriptions[name] = description + + return _ConfiguredCommands(presets=presets, descriptions=descriptions) + + +def _static_preset(argv: list[str]) -> PresetBuilder: + def build(args: Mapping[str, object]) -> list[str]: + if args: + raise ValueError("configured commands do not accept arguments") + return list(argv) + + return build + + +def _required_config_string( + config: MappingABC[object, object], + name: str, + *, + prefix: str | None = None, +) -> str: + value = config.get(name) + display_name = f"{prefix}.{name}" if prefix else name + if not isinstance(value, str) or not value.strip(): + raise ClientConfigError(f"{display_name} is required") return value.strip() diff --git a/src/nexus_sync/server/__main__.py b/src/nexus_sync/server/__main__.py index dfa106f..4c3f731 100644 --- a/src/nexus_sync/server/__main__.py +++ b/src/nexus_sync/server/__main__.py @@ -6,7 +6,7 @@ from nexus_sync.utils import configure_logging def main() -> None: configure_logging() - uvicorn.run(app, host="0.0.0.0", port=8000) + uvicorn.run(app, host="0.0.0.0", port=5852) if __name__ == "__main__": diff --git a/template/darwin.yaml b/template/darwin.yaml new file mode 100644 index 0000000..a08373a --- /dev/null +++ b/template/darwin.yaml @@ -0,0 +1,12 @@ +# nexus-sync client config template for macOS/Darwin +server_url: "http://127.0.0.1:5852" +client_id: "darwin-client" +client_token: "change-me-client-token" +allowed_commands: + - name: hostname + description: "Get the hostname of the client machine" + cmd: "hostname" + - name: network_interfaces + description: "Get network interface information" + cmd: "ifconfig" +logging_level: "INFO" diff --git a/template/linux.yaml b/template/linux.yaml new file mode 100644 index 0000000..e59732b --- /dev/null +++ b/template/linux.yaml @@ -0,0 +1,12 @@ +# nexus-sync client config template for Linux +server_url: "http://127.0.0.1:5852" +client_id: "linux-client" +client_token: "change-me-client-token" +allowed_commands: + - name: hostname + description: "Get the hostname of the client machine" + cmd: "hostname" + - name: network_interfaces + description: "Get network interface information" + cmd: "ip addr show" +logging_level: "INFO" diff --git a/template/windows.yaml b/template/windows.yaml new file mode 100644 index 0000000..4711872 --- /dev/null +++ b/template/windows.yaml @@ -0,0 +1,12 @@ +# nexus-sync client config template for Windows +server_url: "http://127.0.0.1:5852" +client_id: "windows-client" +client_token: "change-me-client-token" +allowed_commands: + - name: hostname + description: "Get the hostname of the client machine" + cmd: "hostname" + - name: network_interfaces + description: "Get network interface information" + cmd: "ipconfig /all" +logging_level: "INFO" diff --git a/tests/test_client_config.py b/tests/test_client_config.py deleted file mode 100644 index 3ce16d4..0000000 --- a/tests/test_client_config.py +++ /dev/null @@ -1,42 +0,0 @@ -import pytest - -from nexus_sync.client.config import COMMAND_ACCESS_ENV, load_command_access_policy -from nexus_sync.client.execute import CommandAccessPolicy - - -def test_load_command_access_policy_defaults_to_deny_all() -> None: - policy = load_command_access_policy({}) - - assert not policy.full_access - assert not policy.allows("hostname") - - -def test_load_command_access_policy_uses_default_when_env_is_missing() -> None: - default = CommandAccessPolicy.allow(["hostname"]) - - policy = load_command_access_policy({}, default=default) - - assert policy == default - - -def test_load_command_access_policy_supports_command_allowlist() -> None: - policy = load_command_access_policy( - {COMMAND_ACCESS_ENV: "hostname, network_interfaces"}, - ) - - assert policy.allows("hostname") - assert policy.allows("network_interfaces") - assert not policy.allows("unknown") - - -def test_load_command_access_policy_supports_full_access() -> None: - policy = load_command_access_policy({COMMAND_ACCESS_ENV: "full_access"}) - - assert policy.full_access - assert policy.allows("hostname") - assert policy.allows("future_registered_preset") - - -def test_load_command_access_policy_rejects_mixed_full_access() -> None: - with pytest.raises(ValueError, match="cannot be mixed"): - load_command_access_policy({COMMAND_ACCESS_ENV: "hostname,full_access"}) diff --git a/tests/test_client_runtime.py b/tests/test_client_runtime.py index e39b187..ecb56a6 100644 --- a/tests/test_client_runtime.py +++ b/tests/test_client_runtime.py @@ -7,13 +7,11 @@ import pytest from nexus_sync.client.execute import CommandAccessPolicy from nexus_sync.client.runtime import ( - CLIENT_ID_ENV, - CLIENT_TOKEN_ENV, - SERVER_URL_ENV, ClientConfig, ClientConfigError, HeartbeatError, build_heartbeat_request, + find_client_config_path, list_available_commands, load_client_config, main, @@ -39,34 +37,77 @@ def _config(policy: CommandAccessPolicy | None = None) -> ClientConfig: ) -def test_load_client_config_reads_required_env_and_normalizes_server_url() -> None: - config = load_client_config( - { - SERVER_URL_ENV: "https://nexus.example.test/", - CLIENT_ID_ENV: "macbook-pro-01", - CLIENT_TOKEN_ENV: "client-token", - "NEXUS_SYNC_ALLOWED_COMMANDS": "hostname", - } +def test_find_client_config_path_prefers_current_directory(tmp_path) -> None: + cwd_config = tmp_path / "nexus.yaml" + cwd_config.write_text("client_id: current\n") + xdg_config = tmp_path / "xdg" / "nexus.yml" + xdg_config.parent.mkdir() + xdg_config.write_text("client_id: xdg\n") + + path = find_client_config_path( + env={"XDG_CONFIG_HOME": str(xdg_config.parent)}, + cwd=tmp_path, + home=tmp_path / "home", ) + assert path == cwd_config + + +def test_find_client_config_path_checks_xdg_and_home_locations(tmp_path) -> None: + home = tmp_path / "home" + nested_config = home / ".config" / "nexus" / "config.yml" + nested_config.parent.mkdir(parents=True) + nested_config.write_text("client_id: nested\n") + + path = find_client_config_path(env={}, cwd=tmp_path, home=home) + + assert path == nested_config + + +def test_load_client_config_reads_yaml_file_and_normalizes_server_url(tmp_path) -> None: + config_path = tmp_path / "nexus.yml" + config_path.write_text( + "\n".join( + [ + 'server_url: "https://nexus.example.test/"', + 'client_id: "macbook-pro-01"', + 'client_token: "client-token"', + "allowed_commands:", + " - name: hostname", + ' description: "Configured hostname"', + ' cmd: "hostname"', + " - name: network_interfaces", + ' description: "Configured interfaces"', + ' cmd: "ip addr show"', + 'logging_level: "INFO"', + ] + ) + ) + + config = load_client_config(config_path=config_path) + assert config.server_url == "https://nexus.example.test" assert config.client_id == "macbook-pro-01" assert config.token == "client-token" assert config.command_access_policy.allows("hostname") - assert not config.command_access_policy.allows("network_interfaces") + assert config.command_access_policy.allows("network_interfaces") + assert config.command_descriptions["hostname"] == "Configured hostname" + assert config.command_presets["network_interfaces"]({}) == ["ip", "addr", "show"] -@pytest.mark.parametrize("missing_name", [SERVER_URL_ENV, CLIENT_ID_ENV, CLIENT_TOKEN_ENV]) -def test_load_client_config_requires_env_values(missing_name: str) -> None: - env = { - SERVER_URL_ENV: "https://nexus.example.test", - CLIENT_ID_ENV: "macbook-pro-01", - CLIENT_TOKEN_ENV: "client-token", +@pytest.mark.parametrize("missing_name", ["server_url", "client_id", "client_token"]) +def test_load_client_config_requires_yaml_values(tmp_path, missing_name: str) -> None: + values = { + "server_url": '"https://nexus.example.test"', + "client_id": '"macbook-pro-01"', + "client_token": '"client-token"', } - del env[missing_name] + del values[missing_name] + config_path = tmp_path / "nexus.yml" + config_path.write_text("\n".join(f"{key}: {value}" for key, value in values.items())) with pytest.raises(ClientConfigError, match=missing_name): - load_client_config(env) + load_client_config(config_path=config_path) def test_build_heartbeat_request_contains_client_state(monkeypatch) -> None: @@ -270,6 +311,7 @@ def test_run_once_executes_command_with_configured_access_policy() -> None: def fake_executor(command: Command, **kwargs) -> CommandResult: seen["command"] = command seen["access_policy"] = kwargs["access_policy"] + seen["presets"] = kwargs["presets"] return CommandResult( command_id=command.id, status=CommandResultStatus.SUCCEEDED, @@ -283,6 +325,7 @@ def test_run_once_executes_command_with_configured_access_policy() -> None: assert result.status == CommandResultStatus.SUCCEEDED assert seen["command"].name == "hostname" assert seen["access_policy"] == policy + assert "hostname" in seen["presets"] def test_run_once_sends_previous_command_result() -> None: @@ -313,22 +356,29 @@ def test_run_once_sends_previous_command_result() -> None: assert seen["last_command_result"] == previous_result -def test_main_returns_non_zero_for_missing_config(monkeypatch, caplog) -> None: - monkeypatch.delenv(SERVER_URL_ENV, raising=False) - monkeypatch.delenv(CLIENT_ID_ENV, raising=False) - monkeypatch.delenv(CLIENT_TOKEN_ENV, raising=False) +def test_main_returns_non_zero_for_missing_config(monkeypatch, caplog, tmp_path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) exit_code = main([]) assert exit_code == 1 - assert SERVER_URL_ENV in caplog.text + assert "client config file not found" in caplog.text -def test_main_logs_success_without_command(monkeypatch, caplog) -> None: +def test_main_logs_success_without_command(monkeypatch, caplog, tmp_path) -> None: caplog.set_level("INFO") - monkeypatch.setenv(SERVER_URL_ENV, "https://nexus.example.test") - monkeypatch.setenv(CLIENT_ID_ENV, "macbook-pro-01") - monkeypatch.setenv(CLIENT_TOKEN_ENV, "client-token") + monkeypatch.chdir(tmp_path) + (tmp_path / "nexus.yml").write_text( + "\n".join( + [ + 'server_url: "https://nexus.example.test"', + 'client_id: "macbook-pro-01"', + 'client_token: "client-token"', + "allowed_commands: []", + ] + ) + ) monkeypatch.setattr("nexus_sync.client.runtime.run_once", lambda _config: None) exit_code = main([]) @@ -337,11 +387,19 @@ def test_main_logs_success_without_command(monkeypatch, caplog) -> None: assert "heartbeat accepted; no command" in caplog.text -def test_main_logs_command_result(monkeypatch, caplog) -> None: +def test_main_logs_command_result(monkeypatch, caplog, tmp_path) -> None: caplog.set_level("INFO") - monkeypatch.setenv(SERVER_URL_ENV, "https://nexus.example.test") - monkeypatch.setenv(CLIENT_ID_ENV, "macbook-pro-01") - monkeypatch.setenv(CLIENT_TOKEN_ENV, "client-token") + monkeypatch.chdir(tmp_path) + (tmp_path / "nexus.yml").write_text( + "\n".join( + [ + 'server_url: "https://nexus.example.test"', + 'client_id: "macbook-pro-01"', + 'client_token: "client-token"', + "allowed_commands: []", + ] + ) + ) monkeypatch.setattr( "nexus_sync.client.runtime.run_once", lambda _config: CommandResult(