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
2 changes: 1 addition & 1 deletion .secrets.baseline

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

5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
25 changes: 12 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,25 +29,24 @@ 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

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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
4 changes: 2 additions & 2 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

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=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"
2 changes: 1 addition & 1 deletion src/gatectl/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 4 additions & 2 deletions src/gatectl/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
14 changes: 13 additions & 1 deletion tests/test_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading