Files
Panama/config/dot/quickshell/scripts/panama-home-assistant-config
T
Gabriel Brown 1f694b00b6 Keep .bashrc a bootstrap, and secrets out of the checkout for real
.bashrc is back to its one job: export the Panama paths and source
what it finds. The personal-env block moves into config/bash/shell —
first, because the tmux guard below it reads that file — and the
cargo source that was duplicated between the two files lives only in
shell now.

The real fix is behind that tidying: both Home Assistant helpers
defaulted to the IN-REPO config/bash/env, and the writer rebuilt it
with only its own three lines — which read as "my env vars vanished"
to the person who thought that file was hand-maintained. Both now
prefer ~/.config/panama/env, the migrated home outside the checkout,
with the repo path kept only as a read fallback for unmigrated
machines. The stale legacy copy on this machine is retired; the
working credentials were merged into the migrated file first, after
probing both sets against the live Home Assistant.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 08:49:40 -04:00

269 lines
8.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Read and atomically update Panama's private Home Assistant settings.
Secret values are accepted only as a single JSON object on stdin and are never
returned. The command line therefore remains safe to inspect with ps(1).
"""
from __future__ import annotations
import fcntl
import json
import os
import pathlib
import re
import shlex
import sys
import tempfile
import urllib.parse
from collections.abc import Mapping, Sequence
from typing import Any
KEY_URL = "PANAMA_HOME_ASSISTANT_URL"
KEY_TOKEN = "PANAMA_HOME_ASSISTANT_TOKEN"
KEY_ENTITIES = "PANAMA_HOME_ASSISTANT_ENTITIES"
TARGET_KEYS = (KEY_URL, KEY_TOKEN, KEY_ENTITIES)
ASSIGNMENT = re.compile(
r"^(?P<prefix>\s*(?:export\s+)?)(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=(?P<value>.*)$"
)
ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")
# Personal environment lives OUTSIDE the checkout (~/.config/panama/env),
# where agents, backup tools and `panama update` never walk. The in-repo
# config/bash/env is only the legacy location for unmigrated machines — and
# writing there once rebuilt a file the owner thought was hand-maintained.
LEGACY_ENV = pathlib.Path(__file__).resolve().parents[3] / "bash/env"
def default_env() -> pathlib.Path:
config_home = pathlib.Path(
os.environ.get("XDG_CONFIG_HOME", str(pathlib.Path.home() / ".config")))
migrated = config_home / "panama" / "env"
if migrated.exists() or not LEGACY_ENV.exists():
return migrated
return LEGACY_ENV
class ConfigError(RuntimeError):
"""An error code safe to display without including submitted values."""
def env_path() -> pathlib.Path:
override = os.environ.get("PANAMA_HOME_ASSISTANT_ENV_FILE", "")
return pathlib.Path(override) if override else default_env()
def parse_assignment(raw: str) -> str | None:
try:
parsed = shlex.split(raw, comments=True, posix=True)
except ValueError:
return None
return parsed[0] if len(parsed) == 1 else None
def read_values(path: pathlib.Path) -> dict[str, str]:
try:
lines = path.read_text(encoding="utf-8").splitlines()
except FileNotFoundError:
return {}
except OSError as error:
raise ConfigError("read-failed") from error
values: dict[str, str] = {}
for line in lines:
match = ASSIGNMENT.match(line)
if not match or match.group("key") not in TARGET_KEYS:
continue
value = parse_assignment(match.group("value"))
if value is not None:
values[match.group("key")] = value
return values
def normalize_url(value: Any) -> str:
if not isinstance(value, str):
raise ConfigError("invalid-url")
normalized = value.strip().rstrip("/")
if not normalized:
return ""
parsed = urllib.parse.urlsplit(normalized)
if (
parsed.scheme not in {"http", "https"}
or not parsed.hostname
or parsed.username
or parsed.password
):
raise ConfigError("invalid-url")
return normalized
def normalize_token(value: Any) -> str:
if not isinstance(value, str) or "\x00" in value or "\n" in value or "\r" in value:
raise ConfigError("invalid-token")
return value.strip()
def normalize_entities(value: Any) -> tuple[str, ...]:
if isinstance(value, str):
candidates: Sequence[Any] = re.split(r"[,\n]", value)
elif isinstance(value, list):
candidates = value
else:
raise ConfigError("invalid-entities")
entities: list[str] = []
for candidate in candidates:
if not isinstance(candidate, str):
raise ConfigError("invalid-entities")
entity_id = candidate.strip()
if not entity_id:
continue
if not ENTITY_ID.fullmatch(entity_id):
raise ConfigError("invalid-entities")
if entity_id not in entities:
entities.append(entity_id)
return tuple(entities)
def public_state(values: Mapping[str, str]) -> dict[str, object]:
try:
url = normalize_url(values.get(KEY_URL, ""))
entities = list(normalize_entities(values.get(KEY_ENTITIES, "")))
error = ""
except ConfigError as config_error:
url = ""
entities = []
error = str(config_error)
token_configured = bool(values.get(KEY_TOKEN, "").strip())
return {
"ok": error == "",
"configured": bool(url and token_configured),
"tokenConfigured": token_configured,
"url": url,
"entities": entities,
"error": error,
}
def render_updated(original: str, updates: Mapping[str, str]) -> str:
lines = original.splitlines(keepends=True)
rendered: list[str] = []
replaced: set[str] = set()
for line in lines:
content = line.rstrip("\r\n")
ending = line[len(content) :]
match = ASSIGNMENT.match(content)
key = match.group("key") if match else ""
if key not in updates:
rendered.append(line)
continue
if key in replaced:
continue
rendered.append(f"export {key}={shlex.quote(updates[key])}{ending or os.linesep}")
replaced.add(key)
if rendered and not rendered[-1].endswith(("\n", "\r")):
rendered[-1] += os.linesep
for key in TARGET_KEYS:
if key in updates and key not in replaced:
rendered.append(f"export {key}={shlex.quote(updates[key])}{os.linesep}")
return "".join(rendered)
def atomic_write(path: pathlib.Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix=".env.", dir=path.parent)
temporary = pathlib.Path(temporary_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
stream.write(text)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
os.chmod(path, 0o600)
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
try:
temporary.unlink()
except FileNotFoundError:
pass
def read_payload() -> dict[str, Any]:
line = sys.stdin.buffer.readline(1_048_577)
if not line or len(line) > 1_048_576:
raise ConfigError("invalid-payload")
try:
payload = json.loads(line)
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise ConfigError("invalid-payload") from error
if not isinstance(payload, dict):
raise ConfigError("invalid-payload")
return payload
def write_payload(path: pathlib.Path, payload: Mapping[str, Any]) -> dict[str, object]:
allowed = {"url", "token", "entities"}
if not set(payload).issubset(allowed) or not payload:
raise ConfigError("invalid-payload")
updates: dict[str, str] = {}
if "url" in payload:
updates[KEY_URL] = normalize_url(payload["url"])
if "token" in payload:
updates[KEY_TOKEN] = normalize_token(payload["token"])
if "entities" in payload:
updates[KEY_ENTITIES] = ",".join(normalize_entities(payload["entities"]))
lock_path = path.with_name("." + path.name + ".lock")
path.parent.mkdir(parents=True, exist_ok=True)
lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
try:
os.fchmod(lock_fd, 0o600)
fcntl.flock(lock_fd, fcntl.LOCK_EX)
try:
original = path.read_text(encoding="utf-8")
except FileNotFoundError:
original = ""
except OSError as error:
raise ConfigError("read-failed") from error
atomic_write(path, render_updated(original, updates))
finally:
os.close(lock_fd)
result = public_state(read_values(path))
result["ok"] = True
result["error"] = ""
return result
def compact_json(value: Mapping[str, object]) -> str:
return json.dumps(value, separators=(",", ":"))
def main() -> None:
command = sys.argv[1] if len(sys.argv) > 1 else "status"
if len(sys.argv) > 2 or command not in {"status", "write"}:
raise ConfigError("usage")
path = env_path()
result = public_state(read_values(path)) if command == "status" else write_payload(path, read_payload())
print(compact_json(result))
if __name__ == "__main__":
try:
main()
except ConfigError as error:
print(compact_json({"ok": False, "error": str(error)}))
raise SystemExit(1) from error
except OSError:
print(compact_json({"ok": False, "error": "write-failed"}))
raise SystemExit(1)