diff --git a/.secrets.baseline b/.secrets.baseline index 94b3a85..0a9c6cc 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -139,18 +139,18 @@ { "type": "Secret Keyword", "filename": "README.md", - "hashed_secret": "f2a55d23be06429885e6cd59eb41bd62b4f98686", + "hashed_secret": "3fffbb63d7e23233f1fed39165801d80352655e8", "is_verified": false, - "line_number": 54 + "line_number": 55 } ], - "hottubctl/config.py": [ + "config/hottubctl.example.json": [ { "type": "Secret Keyword", - "filename": "hottubctl/config.py", - "hashed_secret": "57eff6be42474cbf7bceb102e62eaebe12719e7d", + "filename": "config/hottubctl.example.json", + "hashed_secret": "3fffbb63d7e23233f1fed39165801d80352655e8", "is_verified": false, - "line_number": 10 + "line_number": 3 } ], "tests/test_config.py": [ @@ -159,9 +159,9 @@ "filename": "tests/test_config.py", "hashed_secret": "38d1ccbbd66e78e3ed1fe512fbc3514e02c821bc", "is_verified": false, - "line_number": 26 + "line_number": 31 } ] }, - "generated_at": "2026-08-16T20:15:01Z" + "generated_at": "2026-08-17T03:33:54Z" } diff --git a/AGENTS.md b/AGENTS.md index 58bed46..79a7d50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,6 @@ separate, and be precise about whether data is live or last-known. ## Development -Prefer `pipx` for daily installed use and `.venv` for development. Run the full +Use the private home-ops bootstrap for installed use and `.venv` for development. Run the full format, lint, secret-scan, and test sequence documented in `README.md` before publishing. Never perform a live spa write as part of an automated test. diff --git a/README.md b/README.md index 87ace56..9ea6eb9 100644 --- a/README.md +++ b/README.md @@ -29,37 +29,36 @@ Python 3.13 or newer is required by the current `python-smarttub` release. ## Install ```bash -git clone https://github.com/cnberry/hottubctl.git -cd hottubctl -./script/install +cd /path/to/private/home-ops +./bin/bootstrap-ctls hottubctl ``` -`script/install` is the stable repository contract used by private deployment -automation. Today it installs the Python package with `pipx`; it can be replaced -by a Rust or binary installer later without changing callers. `just install` -uses the same contract. +The private `home-ops` bootstrap is the canonical installer: it populates the +real spa inventory, calls this repository's stable `script/install` contract, +and creates `/usr/local/bin/hottubctl` backed by an isolated system environment +under `/usr/local/lib/home-ops/ctls`. ## Configure private credentials Install the sanitized example outside the repository, then replace its values: ```bash -mkdir -p ~/.config/hottubctl -install -m 600 config/hottubctl.example.json ~/.config/hottubctl/hottubctl.json +sudo install -d -m 700 /usr/local/config/hottubctl +sudo install -m 600 config/hottubctl.example.json /usr/local/config/hottubctl/config.json ``` -Set the password in the process environment before running a command: +Store the username and password directly in that mode-`0600` config: -```bash -export HOTTUBCTL_PASSWORD='read-from-your-password-manager' -hottubctl temp get +```json +{ + "username": "your-smarttub-email@example.com", + "password": "replace-with-smarttub-password" +} ``` -The username may come from config or `HOTTUBCTL_USERNAME`; the password may come -from `HOTTUBCTL_PASSWORD` or, for backward compatibility, a mode-`0600` config -field. Optional `spa_name` or `spa_id` selects one spa when the account has -several, and `temperature_unit` accepts `F` or `C`. Set -`HOTTUBCTL_CONFIG=/path/to/hottubctl.json` to use another private file. +Optional `spa_name` or `spa_id` selects one spa when the account has several, +and `temperature_unit` accepts `F` or `C`. Set +`HOTTUBCTL_CONFIG=/path/to/config.json` to use another private file. Credentials, spa identifiers, and account-specific names belong in a private configuration repository. Never put them in a public fork, issue, log, or @@ -92,8 +91,8 @@ reached that temperature. See [operations](docs/operations.md). | Data | Default path | Git policy | | --- | --- | --- | -| Account/spa config | `~/.config/hottubctl/hottubctl.json` | Private config repo only | -| Password | `HOTTUBCTL_PASSWORD` or legacy config field | Never commit | +| Account/spa config | `/usr/local/config/hottubctl/config.json` | Private config repo only | +| Password | `/usr/local/config/hottubctl/config.json` | Private config repo only | | Legacy config | `~/.hottubctl/hottubctl.json` | Private; migrate when practical | | API responses | Memory and standard output only | Review JSON before sharing | diff --git a/SECURITY.md b/SECURITY.md index 8cb5e03..8d1312d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,7 +1,7 @@ # Security policy `hottubctl` handles a cloud username and password and can change heated water -equipment. Prefer supplying `HOTTUBCTL_PASSWORD` from a password manager. Report +equipment. Keep credentials in a private mode-`0600` config. Report vulnerabilities privately through GitHub's security-advisory feature instead of opening a public issue with credentials, spa IDs, raw responses, or exploit details. diff --git a/config/hottubctl.example.json b/config/hottubctl.example.json index bdb16d3..ed9a88e 100644 --- a/config/hottubctl.example.json +++ b/config/hottubctl.example.json @@ -1,5 +1,6 @@ { "username": "your-smarttub-email@example.com", + "password": "replace-with-smarttub-password", "spa_name": "", "spa_id": "", "temperature_unit": "F" diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c577881..cda6c37 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2,10 +2,10 @@ ## Config is not found -Create `~/.config/hottubctl/hottubctl.json` from the public example, keep it mode -`0600`, set `HOTTUBCTL_PASSWORD` from a password manager, or set -`HOTTUBCTL_CONFIG` to an explicit private file. Repository-local credential -files are intentionally unsupported. +Create `/usr/local/config/hottubctl/config.json` from the public example, put +both username and password in it, and keep it mode `0600`. Set +`HOTTUBCTL_CONFIG` only to select another private file. Repository-local public +credential files are intentionally unsupported. ## Multiple spas are found diff --git a/hottubctl/config.py b/hottubctl/config.py index 885d0b1..495bcd6 100644 --- a/hottubctl/config.py +++ b/hottubctl/config.py @@ -6,12 +6,10 @@ from typing import Any ENV_CONFIG_PATH = "HOTTUBCTL_CONFIG" -ENV_USERNAME = "HOTTUBCTL_USERNAME" -ENV_PASSWORD = "HOTTUBCTL_PASSWORD" -DEFAULT_CONFIG_BASENAME = "hottubctl.json" -SEARCH_DIRS = [ - Path.home() / ".config" / "hottubctl", - Path.home() / ".hottubctl", +CONFIG_CANDIDATES = [ + Path("/usr/local/config/hottubctl/config.json"), + Path.home() / ".config" / "hottubctl" / "hottubctl.json", + Path.home() / ".hottubctl" / "hottubctl.json", ] EXAMPLE_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "hottubctl.example.json" @@ -24,11 +22,10 @@ def config_path() -> Path: override = os.environ.get(ENV_CONFIG_PATH) if override: return Path(override).expanduser() - for directory in SEARCH_DIRS: - candidate = directory / DEFAULT_CONFIG_BASENAME + for candidate in CONFIG_CANDIDATES: if candidate.exists(): return candidate - return SEARCH_DIRS[0] / DEFAULT_CONFIG_BASENAME + return CONFIG_CANDIDATES[0] def load_config() -> dict[str, Any]: @@ -43,12 +40,10 @@ def load_config() -> dict[str, Any]: def smarttub_credentials() -> tuple[str, str]: config = load_config() - username = os.environ.get(ENV_USERNAME) or config.get("username") - password = os.environ.get(ENV_PASSWORD) or config.get("password") + username = config.get("username") + password = config.get("password") if not username or not password: - raise ConfigError( - f"credentials require username and password in config or {ENV_USERNAME}/{ENV_PASSWORD}" - ) + raise ConfigError("credentials require username and password in config") return username, password diff --git a/script/install b/script/install index 43e025a..baf8938 100755 --- a/script/install +++ b/script/install @@ -2,11 +2,20 @@ set -eu repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +name=hottubctl +install_prefix=${CTL_INSTALL_PREFIX:-/usr/local} +venv_root=${CTL_VENV_ROOT:-$install_prefix/lib/home-ops/ctls} +bin_dir=${CTL_BIN_DIR:-$install_prefix/bin} +venv="$venv_root/$name" +python=${PYTHON:-python3} -if ! command -v pipx >/dev/null 2>&1; then - echo "hottubctl: the current Python implementation requires pipx" >&2 - echo "hottubctl: install pipx, or replace script/install when a binary implementation ships" >&2 +if ! command -v "$python" >/dev/null 2>&1; then + echo "$name: Python 3 is required" >&2 exit 1 fi -exec pipx install --force "$repo_root" +install -d -m 755 "$install_prefix/lib" "$install_prefix/lib/home-ops" "$venv_root" "$bin_dir" +"$python" -m venv --clear "$venv" +"$venv/bin/python" -m pip install --disable-pip-version-check "$repo_root" +chmod -R a+rX "$venv" +ln -sfn "$venv/bin/$name" "$bin_dir/$name" diff --git a/tests/test_config.py b/tests/test_config.py index 9dbc42e..a9c9244 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,6 +3,7 @@ import pytest from hottubctl.config import ( + CONFIG_CANDIDATES, ConfigError, preferred_spa_selector, preferred_unit, @@ -10,6 +11,10 @@ ) +def test_system_config_path_is_the_primary_default(): + assert CONFIG_CANDIDATES[0].as_posix() == "/usr/local/config/hottubctl/config.json" + + def write_config(monkeypatch, tmp_path, payload): path = tmp_path / "hottubctl.json" path.write_text(json.dumps(payload)) @@ -33,10 +38,10 @@ def test_loads_private_config_override(monkeypatch, tmp_path): assert preferred_unit() == "F" -def test_password_can_come_from_environment(monkeypatch, tmp_path): +def test_rejects_missing_password(monkeypatch, tmp_path): write_config(monkeypatch, tmp_path, {"username": "user@example.com"}) - monkeypatch.setenv("HOTTUBCTL_PASSWORD", "environment-only") - assert smarttub_credentials() == ("user@example.com", "environment-only") + with pytest.raises(ConfigError, match="username and password in config"): + smarttub_credentials() def test_rejects_invalid_temperature_unit(monkeypatch, tmp_path):