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