seizu-cli 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- seizu_cli/__init__.py +0 -0
- seizu_cli/__main__.py +4 -0
- seizu_cli/auth.py +365 -0
- seizu_cli/client.py +62 -0
- seizu_cli/commands/__init__.py +0 -0
- seizu_cli/commands/auth.py +84 -0
- seizu_cli/commands/reports.py +223 -0
- seizu_cli/commands/scheduled_queries.py +157 -0
- seizu_cli/commands/seed.py +852 -0
- seizu_cli/commands/skillsets.py +371 -0
- seizu_cli/commands/toolsets.py +506 -0
- seizu_cli/config.py +38 -0
- seizu_cli/main.py +181 -0
- seizu_cli/schema.py +31 -0
- seizu_cli/state.py +29 -0
- seizu_cli-2.0.0.dist-info/METADATA +13 -0
- seizu_cli-2.0.0.dist-info/RECORD +22 -0
- seizu_cli-2.0.0.dist-info/WHEEL +5 -0
- seizu_cli-2.0.0.dist-info/entry_points.txt +2 -0
- seizu_cli-2.0.0.dist-info/top_level.txt +2 -0
- seizu_schema/__init__.py +1 -0
- seizu_schema/reporting_config.py +939 -0
seizu_cli/__init__.py
ADDED
|
File without changes
|
seizu_cli/__main__.py
ADDED
seizu_cli/auth.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Token storage and Device Authorization Grant flow (RFC 8628).
|
|
2
|
+
|
|
3
|
+
Token storage uses the OS-native secret store (macOS Keychain, Windows
|
|
4
|
+
Credential Manager, Linux SecretService/KWallet) via the ``keyring`` library.
|
|
5
|
+
If no usable system keyring is detected, commands that require credential
|
|
6
|
+
storage raise a ``RuntimeError`` with an OS-specific message explaining how
|
|
7
|
+
to fix the problem. Passing ``--credentials-file PATH`` on any command
|
|
8
|
+
forces file-based storage and bypasses the keyring requirement entirely.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
err_console = Console(stderr=True)
|
|
23
|
+
|
|
24
|
+
#: Default file path used when no system keyring is available and
|
|
25
|
+
#: ``--credentials-file`` has not been supplied.
|
|
26
|
+
DEFAULT_CREDENTIALS_FILE = Path.home() / ".config" / "seizu" / "credentials.json"
|
|
27
|
+
|
|
28
|
+
#: Keyring service name — one entry per API URL lives under this service.
|
|
29
|
+
_KEYRING_SERVICE = "seizu"
|
|
30
|
+
|
|
31
|
+
#: OAuth2 grant type identifier for the Device Authorization Grant.
|
|
32
|
+
DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Token-store abstraction
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TokenStore(ABC):
|
|
41
|
+
"""Abstract base for credential backends."""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def load_token(self, api_url: str) -> str | None:
|
|
45
|
+
"""Return the stored access token for *api_url*, or ``None``."""
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def save_token(self, api_url: str, token: str) -> None:
|
|
49
|
+
"""Persist *token* for *api_url*."""
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def clear_token(self, api_url: str) -> bool:
|
|
53
|
+
"""Remove credentials for *api_url*. Returns ``True`` if anything was removed."""
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def description(self) -> str:
|
|
57
|
+
"""Human-readable description shown in success messages."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class KeyringStore(TokenStore):
|
|
61
|
+
"""Stores tokens in the OS-native secret store via the ``keyring`` library.
|
|
62
|
+
|
|
63
|
+
On macOS this is the Keychain; on Windows, the Credential Manager; on
|
|
64
|
+
Linux, the SecretService/D-Bus backend (GNOME Keyring or KWallet).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def load_token(self, api_url: str) -> str | None:
|
|
68
|
+
import keyring
|
|
69
|
+
|
|
70
|
+
return keyring.get_password(_KEYRING_SERVICE, api_url)
|
|
71
|
+
|
|
72
|
+
def save_token(self, api_url: str, token: str) -> None:
|
|
73
|
+
import keyring
|
|
74
|
+
|
|
75
|
+
keyring.set_password(_KEYRING_SERVICE, api_url, token)
|
|
76
|
+
|
|
77
|
+
def clear_token(self, api_url: str) -> bool:
|
|
78
|
+
import keyring
|
|
79
|
+
import keyring.errors
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
keyring.delete_password(_KEYRING_SERVICE, api_url)
|
|
83
|
+
return True
|
|
84
|
+
except keyring.errors.PasswordDeleteError:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def description(self) -> str:
|
|
88
|
+
import keyring
|
|
89
|
+
|
|
90
|
+
return f"OS keyring ({type(keyring.get_keyring()).__name__})"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class FileStore(TokenStore):
|
|
94
|
+
"""Stores tokens in a local JSON file, keyed by API URL."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, path: Path) -> None:
|
|
97
|
+
self.path = path
|
|
98
|
+
|
|
99
|
+
def _load_all(self) -> dict[str, Any]:
|
|
100
|
+
if not self.path.exists():
|
|
101
|
+
return {}
|
|
102
|
+
try:
|
|
103
|
+
with open(self.path) as f:
|
|
104
|
+
return json.load(f)
|
|
105
|
+
except (json.JSONDecodeError, OSError):
|
|
106
|
+
return {}
|
|
107
|
+
|
|
108
|
+
def _save_all(self, data: dict[str, Any]) -> None:
|
|
109
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
with open(self.path, "w") as f:
|
|
111
|
+
json.dump(data, f, indent=2)
|
|
112
|
+
|
|
113
|
+
def load_token(self, api_url: str) -> str | None:
|
|
114
|
+
return self._load_all().get(api_url, {}).get("access_token")
|
|
115
|
+
|
|
116
|
+
def save_token(self, api_url: str, token: str) -> None:
|
|
117
|
+
data = self._load_all()
|
|
118
|
+
data[api_url] = {"access_token": token}
|
|
119
|
+
self._save_all(data)
|
|
120
|
+
|
|
121
|
+
def clear_token(self, api_url: str) -> bool:
|
|
122
|
+
data = self._load_all()
|
|
123
|
+
if api_url in data:
|
|
124
|
+
del data[api_url]
|
|
125
|
+
self._save_all(data)
|
|
126
|
+
return True
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
def description(self) -> str:
|
|
130
|
+
return f"credentials file ({self.path})"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
_NO_KEYRING_HINT = "Pass [bold]--credentials-file PATH[/bold] to store credentials in a plain JSON file instead."
|
|
134
|
+
|
|
135
|
+
_OS_KEYRING_NAMES = {
|
|
136
|
+
"darwin": "macOS Keychain",
|
|
137
|
+
"win32": "Windows Credential Manager",
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_OS_UNLOCK_HINTS = {
|
|
141
|
+
"darwin": (
|
|
142
|
+
"The macOS Keychain should be available automatically. "
|
|
143
|
+
"If it is locked, run [bold]security unlock-keychain[/bold] "
|
|
144
|
+
"or unlock it via Keychain Access.app."
|
|
145
|
+
),
|
|
146
|
+
"win32": (
|
|
147
|
+
"The Windows Credential Manager should be available automatically. "
|
|
148
|
+
"Check that it is accessible and that your account has permission to use it."
|
|
149
|
+
),
|
|
150
|
+
"linux": (
|
|
151
|
+
"Install and start a keyring daemon — for GNOME desktops run "
|
|
152
|
+
"[bold]gnome-keyring-daemon --start[/bold]; "
|
|
153
|
+
"for KDE run [bold]kwallet-query[/bold] or unlock KWallet via the "
|
|
154
|
+
"system tray. On headless systems, consider using "
|
|
155
|
+
"[bold]--credentials-file PATH[/bold] instead."
|
|
156
|
+
),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _os_keyring_name() -> str:
|
|
161
|
+
return _OS_KEYRING_NAMES.get(sys.platform, "OS keyring")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _os_unlock_hint() -> str:
|
|
165
|
+
platform = sys.platform if sys.platform in _OS_UNLOCK_HINTS else "linux"
|
|
166
|
+
return _OS_UNLOCK_HINTS[platform]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_store(
|
|
170
|
+
credentials_file: Path | None = None,
|
|
171
|
+
) -> KeyringStore | FileStore:
|
|
172
|
+
"""Return the appropriate token store.
|
|
173
|
+
|
|
174
|
+
If *credentials_file* is given, returns ``FileStore(credentials_file)``.
|
|
175
|
+
Otherwise requires a usable OS keyring and returns ``KeyringStore``.
|
|
176
|
+
|
|
177
|
+
Raises ``RuntimeError`` with a human-readable, OS-specific message if no
|
|
178
|
+
keyring is available and *credentials_file* was not supplied.
|
|
179
|
+
"""
|
|
180
|
+
if credentials_file is not None:
|
|
181
|
+
return FileStore(credentials_file)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
import keyring
|
|
185
|
+
import keyring.backends.fail
|
|
186
|
+
|
|
187
|
+
backend = keyring.get_keyring()
|
|
188
|
+
if isinstance(backend, keyring.backends.fail.Keyring):
|
|
189
|
+
raise RuntimeError(
|
|
190
|
+
f"No usable {_os_keyring_name()} found "
|
|
191
|
+
f"(detected backend: {type(backend).__name__}). "
|
|
192
|
+
f"{_os_unlock_hint()}\n{_NO_KEYRING_HINT}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return KeyringStore()
|
|
196
|
+
except ImportError:
|
|
197
|
+
raise RuntimeError(
|
|
198
|
+
"The [bold]keyring[/bold] package is not installed. "
|
|
199
|
+
"Install it with [bold]pip install keyring[/bold] to enable "
|
|
200
|
+
f"secure {_os_keyring_name()} storage.\n{_NO_KEYRING_HINT}"
|
|
201
|
+
) from None
|
|
202
|
+
except RuntimeError:
|
|
203
|
+
raise
|
|
204
|
+
except Exception as exc:
|
|
205
|
+
raise RuntimeError(
|
|
206
|
+
f"Could not initialise {_os_keyring_name()}: {exc}\n{_os_unlock_hint()}\n{_NO_KEYRING_HINT}"
|
|
207
|
+
) from exc
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# Convenience wrapper used by main.py to auto-load a stored token.
|
|
211
|
+
# Returns None (rather than raising) if no keyring is configured, so that
|
|
212
|
+
# commands which don't need authentication still work without a --credentials-file.
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def load_token(api_url: str, credentials_file: Path | None = None) -> str | None:
|
|
216
|
+
"""Load the stored token for *api_url* using the auto-selected store.
|
|
217
|
+
|
|
218
|
+
Returns ``None`` if the store is unavailable (no keyring and no
|
|
219
|
+
*credentials_file*); callers that need a token will surface a 401 error
|
|
220
|
+
naturally.
|
|
221
|
+
"""
|
|
222
|
+
try:
|
|
223
|
+
return get_store(credentials_file).load_token(api_url)
|
|
224
|
+
except RuntimeError:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# OIDC discovery helpers
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _oidc_discovery(authority: str) -> dict[str, Any]:
|
|
234
|
+
resp = requests.get(
|
|
235
|
+
f"{authority}/.well-known/openid-configuration",
|
|
236
|
+
timeout=10,
|
|
237
|
+
)
|
|
238
|
+
resp.raise_for_status()
|
|
239
|
+
return resp.json()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _api_oidc_config(api_url: str) -> dict[str, Any]:
|
|
243
|
+
"""Return the ``oidc`` block from ``GET /api/v1/config``."""
|
|
244
|
+
resp = requests.get(f"{api_url}/api/v1/config", timeout=10)
|
|
245
|
+
resp.raise_for_status()
|
|
246
|
+
body = resp.json()
|
|
247
|
+
if not body.get("auth_required"):
|
|
248
|
+
raise ValueError(
|
|
249
|
+
"Authentication is not required on this server. "
|
|
250
|
+
"Use the API without a token or set DEVELOPMENT_ONLY_REQUIRE_AUTH=true."
|
|
251
|
+
)
|
|
252
|
+
oidc = body.get("oidc")
|
|
253
|
+
if not oidc:
|
|
254
|
+
raise ValueError(
|
|
255
|
+
"This server has auth_required=true but has not published OIDC configuration "
|
|
256
|
+
"(OIDC_AUTHORITY is not set). Contact your administrator."
|
|
257
|
+
)
|
|
258
|
+
return oidc
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
# Device Authorization Grant flow
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def device_authorize(api_url: str) -> str:
|
|
267
|
+
"""Perform the Device Authorization Grant flow and return the access token.
|
|
268
|
+
|
|
269
|
+
1. Fetches OIDC config from ``GET {api_url}/api/v1/config``.
|
|
270
|
+
2. Discovers the device authorization and token endpoints.
|
|
271
|
+
3. Requests a device code and displays the user code + verification URL.
|
|
272
|
+
4. Polls the token endpoint until the user authorises or the code expires.
|
|
273
|
+
|
|
274
|
+
Raises ``ValueError`` with a human-readable message on any failure.
|
|
275
|
+
"""
|
|
276
|
+
oidc = _api_oidc_config(api_url)
|
|
277
|
+
authority = oidc["authority"]
|
|
278
|
+
client_id = oidc["client_id"]
|
|
279
|
+
scope = oidc.get("scope", "openid email profile")
|
|
280
|
+
|
|
281
|
+
discovery = _oidc_discovery(authority)
|
|
282
|
+
device_endpoint: str | None = discovery.get("device_authorization_endpoint")
|
|
283
|
+
token_endpoint: str = discovery["token_endpoint"]
|
|
284
|
+
|
|
285
|
+
if not device_endpoint:
|
|
286
|
+
raise ValueError(
|
|
287
|
+
f"The OIDC provider at {authority!r} does not advertise a "
|
|
288
|
+
"device_authorization_endpoint. Make sure the Device Authorization Grant "
|
|
289
|
+
"is enabled on the provider (set device_code_flow in the Authentik blueprint)."
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------------------
|
|
293
|
+
# Step 1: request a device code
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
resp = requests.post(
|
|
296
|
+
device_endpoint,
|
|
297
|
+
data={"client_id": client_id, "scope": scope},
|
|
298
|
+
timeout=10,
|
|
299
|
+
)
|
|
300
|
+
resp.raise_for_status()
|
|
301
|
+
device: dict[str, Any] = resp.json()
|
|
302
|
+
|
|
303
|
+
user_code: str = device["user_code"]
|
|
304
|
+
verification_uri: str = device["verification_uri"]
|
|
305
|
+
verification_uri_complete: str | None = device.get("verification_uri_complete")
|
|
306
|
+
device_code: str = device["device_code"]
|
|
307
|
+
expires_in: int = int(device.get("expires_in", 300))
|
|
308
|
+
interval: int = int(device.get("interval", 5))
|
|
309
|
+
|
|
310
|
+
# ------------------------------------------------------------------
|
|
311
|
+
# Step 2: instruct the user
|
|
312
|
+
# ------------------------------------------------------------------
|
|
313
|
+
console.print()
|
|
314
|
+
console.print("[bold]Device authorization required[/bold]")
|
|
315
|
+
console.print()
|
|
316
|
+
if verification_uri_complete:
|
|
317
|
+
console.print(
|
|
318
|
+
" Open this URL in your browser to authorize —\n\n"
|
|
319
|
+
f" [bold cyan]{verification_uri_complete}[/bold cyan]\n"
|
|
320
|
+
)
|
|
321
|
+
else:
|
|
322
|
+
console.print(
|
|
323
|
+
f" 1. Open: [cyan]{verification_uri}[/cyan]\n 2. Enter code: [bold cyan]{user_code}[/bold cyan]\n"
|
|
324
|
+
)
|
|
325
|
+
console.print("Waiting for authorization", end="")
|
|
326
|
+
|
|
327
|
+
# ------------------------------------------------------------------
|
|
328
|
+
# Step 3: poll the token endpoint
|
|
329
|
+
# ------------------------------------------------------------------
|
|
330
|
+
deadline = time.monotonic() + expires_in
|
|
331
|
+
while time.monotonic() < deadline:
|
|
332
|
+
time.sleep(interval)
|
|
333
|
+
console.print(".", end="", highlight=False)
|
|
334
|
+
|
|
335
|
+
token_resp = requests.post(
|
|
336
|
+
token_endpoint,
|
|
337
|
+
data={
|
|
338
|
+
"grant_type": DEVICE_GRANT_TYPE,
|
|
339
|
+
"client_id": client_id,
|
|
340
|
+
"device_code": device_code,
|
|
341
|
+
},
|
|
342
|
+
timeout=10,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
if token_resp.status_code == 200:
|
|
346
|
+
console.print() # newline after the dots
|
|
347
|
+
return token_resp.json()["access_token"]
|
|
348
|
+
|
|
349
|
+
error_body = token_resp.json()
|
|
350
|
+
error = error_body.get("error", "")
|
|
351
|
+
|
|
352
|
+
if error == "authorization_pending":
|
|
353
|
+
continue
|
|
354
|
+
if error == "slow_down":
|
|
355
|
+
interval += 5
|
|
356
|
+
continue
|
|
357
|
+
if error == "expired_token":
|
|
358
|
+
raise ValueError("Device code expired. Please run 'seizu login' again.")
|
|
359
|
+
if error == "access_denied":
|
|
360
|
+
raise ValueError("Authorization was denied by the user.")
|
|
361
|
+
|
|
362
|
+
description = error_body.get("error_description", "")
|
|
363
|
+
raise ValueError(f"Token error ({error}): {description}")
|
|
364
|
+
|
|
365
|
+
raise ValueError("Device code expired (timed out). Please run 'seizu login' again.")
|
seizu_cli/client.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""HTTP client wrapper for the Seizu REST API.
|
|
2
|
+
|
|
3
|
+
Handles Bearer token auth so commands do not need to think about it.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class APIError(Exception):
|
|
12
|
+
"""Raised when the API returns a non-2xx response."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, status_code: int, message: str) -> None:
|
|
15
|
+
self.status_code = status_code
|
|
16
|
+
super().__init__(f"HTTP {status_code}: {message}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SeizuClient:
|
|
20
|
+
"""Thin requests wrapper that handles Bearer auth."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, base_url: str, token: str | None = None) -> None:
|
|
23
|
+
self.base_url = base_url.rstrip("/")
|
|
24
|
+
self._session = requests.Session()
|
|
25
|
+
if token:
|
|
26
|
+
self._session.headers.update({"Authorization": f"Bearer {token}"})
|
|
27
|
+
|
|
28
|
+
def _raise_for_status(self, resp: requests.Response) -> None:
|
|
29
|
+
if not resp.ok:
|
|
30
|
+
if resp.status_code == 401:
|
|
31
|
+
raise APIError(
|
|
32
|
+
401,
|
|
33
|
+
"Unauthorized. Run 'seizu login' to authenticate, or pass --token / set SEIZU_TOKEN.",
|
|
34
|
+
)
|
|
35
|
+
try:
|
|
36
|
+
body = resp.json()
|
|
37
|
+
message = body.get("error") or body.get("message") or resp.text
|
|
38
|
+
except Exception:
|
|
39
|
+
message = resp.text
|
|
40
|
+
raise APIError(resp.status_code, str(message))
|
|
41
|
+
|
|
42
|
+
def get(self, path: str, **kwargs: Any) -> Any:
|
|
43
|
+
resp = self._session.get(f"{self.base_url}{path}", timeout=30, **kwargs)
|
|
44
|
+
self._raise_for_status(resp)
|
|
45
|
+
return resp.json()
|
|
46
|
+
|
|
47
|
+
def post(self, path: str, **kwargs: Any) -> Any:
|
|
48
|
+
resp = self._session.post(f"{self.base_url}{path}", timeout=30, **kwargs)
|
|
49
|
+
self._raise_for_status(resp)
|
|
50
|
+
return resp.json()
|
|
51
|
+
|
|
52
|
+
def put(self, path: str, **kwargs: Any) -> Any:
|
|
53
|
+
resp = self._session.put(f"{self.base_url}{path}", timeout=30, **kwargs)
|
|
54
|
+
self._raise_for_status(resp)
|
|
55
|
+
return resp.json()
|
|
56
|
+
|
|
57
|
+
def delete(self, path: str, **kwargs: Any) -> Any:
|
|
58
|
+
resp = self._session.delete(f"{self.base_url}{path}", timeout=30, **kwargs)
|
|
59
|
+
self._raise_for_status(resp)
|
|
60
|
+
if resp.content:
|
|
61
|
+
return resp.json()
|
|
62
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""CLI commands for authentication (login / logout / whoami)."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from seizu_cli import auth, state
|
|
9
|
+
from seizu_cli.client import APIError
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Authenticate with the Seizu API.", no_args_is_help=True)
|
|
12
|
+
console = Console()
|
|
13
|
+
err_console = Console(stderr=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _die(exc: Exception) -> None:
|
|
17
|
+
if isinstance(exc, APIError):
|
|
18
|
+
err_console.print(f"[red]Error {exc.status_code}[/red]: {exc}")
|
|
19
|
+
else:
|
|
20
|
+
err_console.print(f"[red]Error[/red]: {exc}")
|
|
21
|
+
sys.exit(1)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command("login")
|
|
25
|
+
def login() -> None:
|
|
26
|
+
"""Authenticate via the Device Authorization Grant (RFC 8628).
|
|
27
|
+
|
|
28
|
+
Opens a browser-friendly URL for you to authorize, then stores the
|
|
29
|
+
access token in the OS-native keyring (macOS Keychain, Windows Credential
|
|
30
|
+
Manager, Linux SecretService/KWallet). Pass --credentials-file PATH to
|
|
31
|
+
store in a plain JSON file instead.
|
|
32
|
+
|
|
33
|
+
\b
|
|
34
|
+
Example:
|
|
35
|
+
seizu login
|
|
36
|
+
seizu --api-url https://seizu.example.com login
|
|
37
|
+
seizu --credentials-file ~/seizu-creds.json login
|
|
38
|
+
"""
|
|
39
|
+
api_url = state.api_url
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
store = auth.get_store(state.credentials_file)
|
|
43
|
+
except Exception as exc:
|
|
44
|
+
_die(exc)
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
console.print(f"Authenticating with [bold]{api_url}[/bold]")
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
token = auth.device_authorize(api_url)
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
_die(exc)
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
store.save_token(api_url, token)
|
|
56
|
+
console.print(f"\n[green]Logged in.[/green] Token saved to [dim]{store.description()}[/dim]")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command("logout")
|
|
60
|
+
def logout() -> None:
|
|
61
|
+
"""Remove stored credentials for the current API URL."""
|
|
62
|
+
api_url = state.api_url
|
|
63
|
+
store = auth.get_store(state.credentials_file)
|
|
64
|
+
removed = store.clear_token(api_url)
|
|
65
|
+
if removed:
|
|
66
|
+
console.print(f"[yellow]Logged out[/yellow] from [bold]{api_url}[/bold]")
|
|
67
|
+
else:
|
|
68
|
+
console.print(f"No stored credentials found for [bold]{api_url}[/bold]")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command("whoami")
|
|
72
|
+
def whoami() -> None:
|
|
73
|
+
"""Show the currently authenticated user."""
|
|
74
|
+
try:
|
|
75
|
+
data = state.get_client().get("/api/v1/me")
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
_die(exc)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
console.print(f"[bold]User ID[/bold]: {data['user_id']}")
|
|
81
|
+
if data.get("display_name"):
|
|
82
|
+
console.print(f"[bold]Name[/bold]: {data['display_name']}")
|
|
83
|
+
console.print(f"[bold]Email[/bold]: {data['email']}")
|
|
84
|
+
console.print(f"[bold]Last login[/bold]: {data.get('last_login', '')}")
|