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