v0.166.0
  1"""Credential storage for the plain-cloud CLI.
  2
  3Token in the OS keyring; api_url in a plain TOML file under ~/.plain/cloud/
  4(matching the ~/.plain/<package>/ layout established by plain-dev). Env vars
  5PLAIN_CLOUD_TOKEN and PLAIN_CLOUD_API_URL override stored values for
  6headless/CI use.
  7"""
  8
  9import os
 10import sys
 11import tomllib
 12from dataclasses import dataclass
 13from pathlib import Path
 14
 15import click
 16import keyring
 17from keyring.errors import KeyringError, NoKeyringError
 18
 19DEFAULT_API_URL = "https://plainframework.com"
 20SERVICE = "plain-cloud"
 21
 22
 23def config_path() -> Path:
 24    return Path.home() / ".plain" / "cloud" / "config.toml"
 25
 26
 27@dataclass
 28class Credentials:
 29    api_url: str
 30    token: str
 31
 32
 33class KeyringUnavailable(RuntimeError):
 34    """Raised when the OS keyring can't be reached and there's no env-var fallback."""
 35
 36
 37def _read_api_url() -> str | None:
 38    try:
 39        with config_path().open("rb") as f:
 40            data = tomllib.load(f)
 41    except FileNotFoundError:
 42        return None
 43    return data.get("api_url") or None
 44
 45
 46def _write_api_url(api_url: str) -> None:
 47    path = config_path()
 48    path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
 49    escaped = api_url.replace("\\", "\\\\").replace('"', '\\"')
 50    path.write_text(f'api_url = "{escaped}"\n')
 51
 52
 53def load() -> Credentials | None:
 54    if token := os.environ.get("PLAIN_CLOUD_TOKEN"):
 55        api_url = (
 56            os.environ.get("PLAIN_CLOUD_API_URL") or _read_api_url() or DEFAULT_API_URL
 57        )
 58        return Credentials(api_url=api_url, token=token)
 59    api_url = _read_api_url()
 60    if api_url is None:
 61        return None
 62    try:
 63        token = keyring.get_password(SERVICE, api_url)
 64    except KeyringError:
 65        return None
 66    if not token:
 67        return None
 68    return Credentials(api_url=api_url, token=token)
 69
 70
 71def save(creds: Credentials) -> str:
 72    """Persist credentials. Returns a human-readable description of where the token landed.
 73
 74    Disk first, keyring second: if disk write fails we want no token in the keyring
 75    (orphaned secrets are worse than orphaned config — the latter is overwritten on
 76    the next successful login).
 77    """
 78    _write_api_url(creds.api_url)
 79    try:
 80        keyring.set_password(SERVICE, creds.api_url, creds.token)
 81    except NoKeyringError as exc:
 82        raise KeyringUnavailable(
 83            "No OS keyring backend available. "
 84            "Set PLAIN_CLOUD_TOKEN (and optionally PLAIN_CLOUD_API_URL) instead."
 85        ) from exc
 86    except KeyringError as exc:
 87        raise KeyringUnavailable(f"Keyring error: {exc}") from exc
 88    return keyring.get_keyring().name
 89
 90
 91def clear() -> bool:
 92    cleared = False
 93    api_url = _read_api_url()
 94    if api_url:
 95        try:
 96            keyring.delete_password(SERVICE, api_url)
 97            cleared = True
 98        except KeyringError:
 99            pass
100    try:
101        config_path().unlink()
102        cleared = True
103    except FileNotFoundError:
104        pass
105    return cleared
106
107
108def require() -> Credentials:
109    creds = load()
110    if creds is None:
111        click.secho(
112            "Not logged in. Run `plain-cloud login` first.",
113            fg="red",
114            err=True,
115        )
116        sys.exit(1)
117    return creds