add yml config file, client now does not depends on .env

This commit is contained in:
2026-06-24 04:42:00 +03:00
parent 25ae509d52
commit fe2db1b6e9
13 changed files with 325 additions and 167 deletions
+3 -31
View File
@@ -1,5 +1,5 @@
# nexus-sync environment example # nexus-sync server environment example
# Copy this file to .env and replace example secrets before running. # Copy this file to .env and replace example secrets before running the server.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Server configuration # Server configuration
@@ -15,33 +15,5 @@
# better to set this explicitly. # better to set this explicitly.
NEXUS_SYNC_CLIENT_TOKENS="dev-client:dev-token" 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" 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"
+37 -2
View File
@@ -2,7 +2,7 @@
a utility that allows you to manage your computers. 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. server can send to every client "what to do". Centralized control for all clients.
## Development ## Development
@@ -11,6 +11,42 @@ server can send to every client "what to do". Centralized control for all client
pip install -e .[dev] 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 ## Current design
- API contract: [docs/api.md](docs/api.md) - API contract: [docs/api.md](docs/api.md)
@@ -23,7 +59,6 @@ pip install -e .[dev]
make [client|server] make [client|server]
``` ```
### To test ### To test
``` ```
+19 -8
View File
@@ -32,15 +32,26 @@ client -> server: hello!, i'm $(hostname), uuid= , ts=, stdout= , stderr= , ...
## Разрешённые команды ## Разрешённые команды
Клиент исполняет только локально разрешённые command presets. Базовая Клиент исполняет только команды, описанные в локальном YAML config-файле.
настройка задаётся переменной окружения `NEXUS_SYNC_ALLOWED_COMMANDS`. Серверу отправляются только `name` и `description`; поле `cmd` остаётся только
на клиенте и не управляется сервером.
Примеры: Пример:
```bash ```yaml
NEXUS_SYNC_ALLOWED_COMMANDS=hostname,network_interfaces server_url: "http://127.0.0.1:5852"
NEXUS_SYNC_ALLOWED_COMMANDS=full_access 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. Это Файл ищется как `nexus.yml`/`nexus.yaml` в текущей директории, затем в
не разрешает выполнение произвольных shell-строк от сервера. `$XDG_CONFIG_HOME`, затем в `~/.config`, затем как
`~/.config/nexus/config.yml`/`.yaml`.
+2
View File
@@ -12,6 +12,7 @@ requires-python = ">=3.11"
dependencies = [ dependencies = [
"fastapi>=0.115.0", "fastapi>=0.115.0",
"pydantic>=2.10.0", "pydantic>=2.10.0",
"PyYAML>=6.0.0",
"sqlalchemy>=2.0.0", "sqlalchemy>=2.0.0",
"uvicorn[standard]>=0.34.0", "uvicorn[standard]>=0.34.0",
] ]
@@ -23,6 +24,7 @@ dev = [
"black>=24.0", "black>=24.0",
"pyinstaller>=6.0", "pyinstaller>=6.0",
"mypy>=1.0", "mypy>=1.0",
"types-PyYAML>=6.0.12",
"pre-commit>=4.0" "pre-commit>=4.0"
] ]
-8
View File
@@ -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 ( from nexus_sync.client.execute import (
CommandAccessPolicy, CommandAccessPolicy,
execute_command, execute_command,
) )
__all__ = [ __all__ = [
"COMMAND_ACCESS_ENV",
"CommandAccessPolicy", "CommandAccessPolicy",
"FULL_ACCESS_VALUE",
"execute_command", "execute_command",
"load_command_access_policy",
] ]
-25
View File
@@ -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)
+136 -17
View File
@@ -2,21 +2,25 @@ import json
import logging import logging
import os import os
import platform import platform
import shlex
import socket import socket
import urllib.error import urllib.error
import urllib.request 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 datetime import UTC, datetime
from pathlib import Path
from types import TracebackType from types import TracebackType
from typing import Callable, Mapping, Protocol, Self from typing import Callable, Mapping, Protocol, Self
import yaml # type: ignore[import-untyped]
from pydantic import ValidationError from pydantic import ValidationError
from nexus_sync.client.config import load_command_access_policy
from nexus_sync.client.execute import ( from nexus_sync.client.execute import (
DEFAULT_PRESET_DESCRIPTIONS, DEFAULT_PRESET_DESCRIPTIONS,
DEFAULT_PRESETS, DEFAULT_PRESETS,
CommandAccessPolicy, CommandAccessPolicy,
PresetBuilder,
execute_command, execute_command,
) )
from nexus_sync.common import ( from nexus_sync.common import (
@@ -29,9 +33,6 @@ from nexus_sync.common import (
HeartbeatResponse, 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" CLIENT_VERSION = "0.1.0"
HEARTBEAT_PATH = "/api/v1/client/heartbeat" HEARTBEAT_PATH = "/api/v1/client/heartbeat"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -67,17 +68,75 @@ class ClientConfig:
client_id: str client_id: str
token: str token: str
command_access_policy: CommandAccessPolicy 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: def find_client_config_path(
server_url = _required_env(env, SERVER_URL_ENV).rstrip("/") *,
client_id = _required_env(env, CLIENT_ID_ENV) env: Mapping[str, str] = os.environ,
token = _required_env(env, CLIENT_TOKEN_ENV) 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( return ClientConfig(
server_url=server_url, server_url=server_url,
client_id=client_id, client_id=client_id,
token=token, 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(), local_time=datetime.now().astimezone(),
uptime_seconds=None, 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, last_command_result=last_command_result,
) )
def list_available_commands( def list_available_commands(
access_policy: CommandAccessPolicy, access_policy: CommandAccessPolicy,
*,
presets: Mapping[str, PresetBuilder] = DEFAULT_PRESETS,
descriptions: Mapping[str, str] = DEFAULT_PRESET_DESCRIPTIONS,
) -> list[ClientCommandCapability]: ) -> list[ClientCommandCapability]:
return [ return [
ClientCommandCapability( ClientCommandCapability(
name=name, 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) if access_policy.allows(name)
] ]
@@ -171,6 +237,7 @@ def run_once(
return executor( return executor(
response.command, response.command,
access_policy=config.command_access_policy, access_policy=config.command_access_policy,
presets=config.command_presets,
) )
@@ -198,10 +265,62 @@ def main(argv: list[str] | None = None) -> int:
return 0 return 0
def _required_env(env: Mapping[str, str], name: str) -> str: @dataclass(frozen=True)
value = env.get(name) class _ConfiguredCommands:
if value is None or not value.strip(): presets: dict[str, PresetBuilder]
raise ClientConfigError(f"{name} is required") 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() return value.strip()
+1 -1
View File
@@ -6,7 +6,7 @@ from nexus_sync.utils import configure_logging
def main() -> None: def main() -> None:
configure_logging() 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__": if __name__ == "__main__":
+12
View File
@@ -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"
+12
View File
@@ -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"
+12
View File
@@ -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"
-42
View File
@@ -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"})
+91 -33
View File
@@ -7,13 +7,11 @@ import pytest
from nexus_sync.client.execute import CommandAccessPolicy from nexus_sync.client.execute import CommandAccessPolicy
from nexus_sync.client.runtime import ( from nexus_sync.client.runtime import (
CLIENT_ID_ENV,
CLIENT_TOKEN_ENV,
SERVER_URL_ENV,
ClientConfig, ClientConfig,
ClientConfigError, ClientConfigError,
HeartbeatError, HeartbeatError,
build_heartbeat_request, build_heartbeat_request,
find_client_config_path,
list_available_commands, list_available_commands,
load_client_config, load_client_config,
main, 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: def test_find_client_config_path_prefers_current_directory(tmp_path) -> None:
config = load_client_config( cwd_config = tmp_path / "nexus.yaml"
{ cwd_config.write_text("client_id: current\n")
SERVER_URL_ENV: "https://nexus.example.test/", xdg_config = tmp_path / "xdg" / "nexus.yml"
CLIENT_ID_ENV: "macbook-pro-01", xdg_config.parent.mkdir()
CLIENT_TOKEN_ENV: "client-token", xdg_config.write_text("client_id: xdg\n")
"NEXUS_SYNC_ALLOWED_COMMANDS": "hostname",
} 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.server_url == "https://nexus.example.test"
assert config.client_id == "macbook-pro-01" assert config.client_id == "macbook-pro-01"
assert config.token == "client-token" assert config.token == "client-token"
assert config.command_access_policy.allows("hostname") 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]) @pytest.mark.parametrize("missing_name", ["server_url", "client_id", "client_token"])
def test_load_client_config_requires_env_values(missing_name: str) -> None: def test_load_client_config_requires_yaml_values(tmp_path, missing_name: str) -> None:
env = { values = {
SERVER_URL_ENV: "https://nexus.example.test", "server_url": '"https://nexus.example.test"',
CLIENT_ID_ENV: "macbook-pro-01", "client_id": '"macbook-pro-01"',
CLIENT_TOKEN_ENV: "client-token", "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): 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: 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: def fake_executor(command: Command, **kwargs) -> CommandResult:
seen["command"] = command seen["command"] = command
seen["access_policy"] = kwargs["access_policy"] seen["access_policy"] = kwargs["access_policy"]
seen["presets"] = kwargs["presets"]
return CommandResult( return CommandResult(
command_id=command.id, command_id=command.id,
status=CommandResultStatus.SUCCEEDED, status=CommandResultStatus.SUCCEEDED,
@@ -283,6 +325,7 @@ def test_run_once_executes_command_with_configured_access_policy() -> None:
assert result.status == CommandResultStatus.SUCCEEDED assert result.status == CommandResultStatus.SUCCEEDED
assert seen["command"].name == "hostname" assert seen["command"].name == "hostname"
assert seen["access_policy"] == policy assert seen["access_policy"] == policy
assert "hostname" in seen["presets"]
def test_run_once_sends_previous_command_result() -> None: 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 assert seen["last_command_result"] == previous_result
def test_main_returns_non_zero_for_missing_config(monkeypatch, caplog) -> None: def test_main_returns_non_zero_for_missing_config(monkeypatch, caplog, tmp_path) -> None:
monkeypatch.delenv(SERVER_URL_ENV, raising=False) monkeypatch.chdir(tmp_path)
monkeypatch.delenv(CLIENT_ID_ENV, raising=False) monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
monkeypatch.delenv(CLIENT_TOKEN_ENV, raising=False)
exit_code = main([]) exit_code = main([])
assert exit_code == 1 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") caplog.set_level("INFO")
monkeypatch.setenv(SERVER_URL_ENV, "https://nexus.example.test") monkeypatch.chdir(tmp_path)
monkeypatch.setenv(CLIENT_ID_ENV, "macbook-pro-01") (tmp_path / "nexus.yml").write_text(
monkeypatch.setenv(CLIENT_TOKEN_ENV, "client-token") "\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) monkeypatch.setattr("nexus_sync.client.runtime.run_once", lambda _config: None)
exit_code = main([]) 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 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") caplog.set_level("INFO")
monkeypatch.setenv(SERVER_URL_ENV, "https://nexus.example.test") monkeypatch.chdir(tmp_path)
monkeypatch.setenv(CLIENT_ID_ENV, "macbook-pro-01") (tmp_path / "nexus.yml").write_text(
monkeypatch.setenv(CLIENT_TOKEN_ENV, "client-token") "\n".join(
[
'server_url: "https://nexus.example.test"',
'client_id: "macbook-pro-01"',
'client_token: "client-token"',
"allowed_commands: []",
]
)
)
monkeypatch.setattr( monkeypatch.setattr(
"nexus_sync.client.runtime.run_once", "nexus_sync.client.runtime.run_once",
lambda _config: CommandResult( lambda _config: CommandResult(