From 9da44dd76f0834a58c3afcc505a9d0fd587c71b1 Mon Sep 17 00:00:00 2001 From: Chris Berry Date: Sun, 16 Aug 2026 20:39:45 -0700 Subject: [PATCH] Support system-wide home-ops installs --- .secrets.baseline | 2 +- AGENTS.md | 5 +++-- README.md | 25 ++++++++++++------------- docs/authentication.md | 2 +- docs/operations.md | 2 +- docs/troubleshooting.md | 4 ++-- script/install | 17 +++++++++++++---- src/gatectl/cli.py | 2 +- src/gatectl/storage.py | 6 ++++-- tests/test_storage.py | 14 +++++++++++++- 10 files changed, 51 insertions(+), 28 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 3b27009..e80b998 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -162,5 +162,5 @@ } ] }, - "generated_at": "2026-08-16T17:28:24Z" + "generated_at": "2026-08-17T03:33:54Z" } diff --git a/AGENTS.md b/AGENTS.md index 6d43b32..45246b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,8 @@ and CLI dispatch separate. Treat every physical-access write as safety-sensitive - Refuse offline, ambiguous, unsupported, or unsafe-to-operate devices. - Wait for terminal state and distinguish acceptance from completed movement. - Keep credentials, tokens, serials, account names, and device names outside the - public repository. + public repository. A private deployment repository may carry a mode-`0600` + recovery seed when explicitly required. - Preserve readable default output, redaction, and stable JSON output. - Test all protocol and control logic with mocked HTTP; never operate live access equipment from an automated test. @@ -36,7 +37,7 @@ and CLI dispatch separate. Treat every physical-access write as safety-sensitive ## 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. Live validation must be supervised and must not expose private account, device, callback, or serial data. diff --git a/README.md b/README.md index 2efc508..d045983 100644 --- a/README.md +++ b/README.md @@ -29,16 +29,15 @@ Python 3.11 or newer is required. There are no runtime package dependencies. ## Install ```bash -git clone https://github.com/cnberry/gatectl.git -cd gatectl -./script/install +cd /path/to/private/home-ops +./bin/bootstrap-ctls gatectl ``` -`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. For development without installation, prefix commands -with `PYTHONPATH=src python3 -m gatectl`. +The private `home-ops` bootstrap is the canonical installer: it populates the +real inventory, calls this repository's stable `script/install` contract, and +creates `/usr/local/bin/gatectl` backed by an isolated system environment under +`/usr/local/lib/home-ops/ctls`. For development without installation, prefix +commands with `PYTHONPATH=src python3 -m gatectl`. ## Configure private targets @@ -46,8 +45,8 @@ Copy the public example to the private runtime location and replace the sample names with exact values returned by `gatectl inspect`: ```bash -mkdir -p ~/.config/gatectl -install -m 600 config/targets.example.json ~/.config/gatectl/targets.json +sudo install -d -m 700 /usr/local/config/gatectl +sudo install -m 600 config/targets.example.json /usr/local/config/gatectl/targets.json ``` ```json @@ -73,7 +72,7 @@ gatectl login --email you@example.com --mfa email The password prompt does not echo. Enter the six-digit email or SMS code when asked. The password and MFA code are never stored; the resulting refreshable -session is written to `~/.config/gatectl/tokens.json` with mode `0600`. +session is written to `/usr/local/config/gatectl/tokens.json` with mode `0600`. If MyQ returns a browser-verification challenge, stop and retry later instead of repeatedly starting new logins. See [authentication](docs/authentication.md) @@ -117,8 +116,8 @@ full safety model and state behavior. | Data | Default path | Git policy | | --- | --- | --- | -| Target names | `~/.config/gatectl/targets.json` | Private config repo only | -| OAuth tokens | `~/.config/gatectl/tokens.json` | Never commit | +| Target names | `/usr/local/config/gatectl/targets.json` | Private config repo only | +| OAuth tokens | `/usr/local/config/gatectl/tokens.json` | Private config/recovery seed only | | Last observation | `~/.local/state/gatectl/last-observation.json` | Never commit | Passwords and MFA codes are held only for the active login request. Serial diff --git a/docs/authentication.md b/docs/authentication.md index 69c1773..885397a 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -30,7 +30,7 @@ separate browser tab does not complete a waiting CLI login. ## Stored data The password and verification code are not persisted. OAuth access and refresh -tokens are atomically written to `~/.config/gatectl/tokens.json` with mode +tokens are atomically written to `/usr/local/config/gatectl/tokens.json` with mode `0600`. Set `GATECTL_TOKEN_FILE` to override this path. `gatectl` refreshes an expiring access token before an API request. If MyQ diff --git a/docs/operations.md b/docs/operations.md index a4d8680..e9db7fb 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -6,7 +6,7 @@ Every `status`, `open`, and `close` request uses the configured account name. Device names are case-insensitive but otherwise exact. An operation proceeds only when discovery returns exactly one matching device in that account. -The target file defaults to `~/.config/gatectl/targets.json`: +The target file defaults to `/usr/local/config/gatectl/targets.json`: ```json { diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f2d8548..135365c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -5,8 +5,8 @@ Install and edit the example: ```bash -mkdir -p ~/.config/gatectl -install -m 600 config/targets.example.json ~/.config/gatectl/targets.json +sudo install -d -m 700 /usr/local/config/gatectl +sudo install -m 600 config/targets.example.json /usr/local/config/gatectl/targets.json gatectl inspect ``` diff --git a/script/install b/script/install index 5cdb1fb..55f3e86 100755 --- a/script/install +++ b/script/install @@ -2,11 +2,20 @@ set -eu repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +name=gatectl +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 "gatectl: the current Python implementation requires pipx" >&2 - echo "gatectl: 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/src/gatectl/cli.py b/src/gatectl/cli.py index d402dfb..988a3ec 100644 --- a/src/gatectl/cli.py +++ b/src/gatectl/cli.py @@ -27,7 +27,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "--config", type=Path, - help="Target config path (default: GATECTL_CONFIG or ~/.config/gatectl/targets.json)", + help="Target config path (default: GATECTL_CONFIG or /usr/local/config/gatectl/targets.json)", ) subparsers = parser.add_subparsers(dest="command", required=True) diff --git a/src/gatectl/storage.py b/src/gatectl/storage.py index 1fb1776..98ced4c 100644 --- a/src/gatectl/storage.py +++ b/src/gatectl/storage.py @@ -9,10 +9,12 @@ from .errors import MyQApiError, TokenStoreError from .models import OAuthTokens +CONFIG_ROOT = Path("/usr/local/config/gatectl") + def token_path() -> Path: override = os.environ.get("GATECTL_TOKEN_FILE") - return Path(override).expanduser() if override else Path.home() / ".config/gatectl/tokens.json" + return Path(override).expanduser() if override else CONFIG_ROOT / "tokens.json" def observation_path() -> Path: @@ -24,7 +26,7 @@ def observation_path() -> Path: def target_config_path() -> Path: override = os.environ.get("GATECTL_CONFIG") - return Path(override).expanduser() if override else Path.home() / ".config/gatectl/targets.json" + return Path(override).expanduser() if override else CONFIG_ROOT / "targets.json" def save_tokens(tokens: OAuthTokens, path: Path | None = None) -> Path: diff --git a/tests/test_storage.py b/tests/test_storage.py index 3c49d90..3b76f64 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -4,12 +4,24 @@ import tempfile import unittest from pathlib import Path +from unittest.mock import patch from gatectl.models import OAuthTokens -from gatectl.storage import load_tokens, save_tokens +from gatectl.storage import ( + CONFIG_ROOT, + load_tokens, + save_tokens, + target_config_path, + token_path, +) class StorageTests(unittest.TestCase): + def test_system_config_paths_are_the_defaults(self) -> None: + with patch.dict("os.environ", {}, clear=True): + self.assertEqual(target_config_path(), CONFIG_ROOT / "targets.json") + self.assertEqual(token_path(), CONFIG_ROOT / "tokens.json") + def test_tokens_round_trip_with_private_permissions(self) -> None: with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "private" / "tokens.json"