Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
39 changes: 19 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |

Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions config/hottubctl.example.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"username": "your-smarttub-email@example.com",
"password": "replace-with-smarttub-password",
"spa_name": "",
"spa_id": "",
"temperature_unit": "F"
Expand Down
8 changes: 4 additions & 4 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 9 additions & 14 deletions hottubctl/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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]:
Expand All @@ -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


Expand Down
17 changes: 13 additions & 4 deletions script/install
Original file line number Diff line number Diff line change
Expand Up @@ -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"
11 changes: 8 additions & 3 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@
import pytest

from hottubctl.config import (
CONFIG_CANDIDATES,
ConfigError,
preferred_spa_selector,
preferred_unit,
smarttub_credentials,
)


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))
Expand All @@ -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):
Expand Down
Loading