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