sovaria-cli 0.2.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.
Files changed (46) hide show
  1. apps/cli/README.md +147 -0
  2. apps/cli/__init__.py +0 -0
  3. apps/cli/src/__init__.py +3 -0
  4. apps/cli/src/commands/__init__.py +1 -0
  5. apps/cli/src/commands/admin.py +160 -0
  6. apps/cli/src/commands/api.py +48 -0
  7. apps/cli/src/commands/audit.py +181 -0
  8. apps/cli/src/commands/auth_product.py +187 -0
  9. apps/cli/src/commands/billing.py +513 -0
  10. apps/cli/src/commands/chrono.py +248 -0
  11. apps/cli/src/commands/context.py +99 -0
  12. apps/cli/src/commands/data.py +272 -0
  13. apps/cli/src/commands/db.py +528 -0
  14. apps/cli/src/commands/deploy.py +1099 -0
  15. apps/cli/src/commands/governance.py +491 -0
  16. apps/cli/src/commands/iam.py +436 -0
  17. apps/cli/src/commands/init_cmd.py +177 -0
  18. apps/cli/src/commands/keys.py +120 -0
  19. apps/cli/src/commands/login.py +842 -0
  20. apps/cli/src/commands/org.py +157 -0
  21. apps/cli/src/commands/policy.py +317 -0
  22. apps/cli/src/commands/project.py +130 -0
  23. apps/cli/src/commands/realtime.py +364 -0
  24. apps/cli/src/commands/scope.py +42 -0
  25. apps/cli/src/commands/shell.py +415 -0
  26. apps/cli/src/commands/sim.py +178 -0
  27. apps/cli/src/commands/storage.py +405 -0
  28. apps/cli/src/commands/vector.py +204 -0
  29. apps/cli/src/main.py +169 -0
  30. apps/cli/src/utils/__init__.py +1 -0
  31. apps/cli/src/utils/api_client.py +329 -0
  32. apps/cli/src/utils/auth_store.py +299 -0
  33. apps/cli/src/utils/config.py +185 -0
  34. apps/cli/src/utils/display.py +62 -0
  35. apps/cli/src/utils/migrations.py +69 -0
  36. apps/cli/src/utils/project_context.py +124 -0
  37. apps/cli/tests/test_auth_store_profiles.py +103 -0
  38. apps/cli/tests/test_cli_security.py +110 -0
  39. apps/cli/tests/test_cli_smoke_paths.py +279 -0
  40. apps/cli/tests/test_config_profiles.py +55 -0
  41. apps/cli/tests/test_main_startup.py +29 -0
  42. apps/cli/tests/test_project_and_commands.py +196 -0
  43. sovaria_cli-0.2.0.dist-info/METADATA +170 -0
  44. sovaria_cli-0.2.0.dist-info/RECORD +46 -0
  45. sovaria_cli-0.2.0.dist-info/WHEEL +4 -0
  46. sovaria_cli-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,842 @@
1
+ """
2
+ apps.cli.src.commands.login — OAuth2 PKCE browser-based login for the CLI.
3
+
4
+ Phase 1 §9 Sprint 3.2:
5
+ "CLI performs OAuth2 PKCE flow: opens browser → local callback server
6
+ captures the code → exchanges for JWT token pair."
7
+
8
+ Flow:
9
+ 1. Generate code_verifier (43-128 random chars) + code_challenge = BASE64URL(SHA-256(verifier))
10
+ 2. Open https://{api}/oauth/authorize?response_type=code&...&code_challenge=...
11
+ 3. Start a temporary HTTP callback server (configured via env variables).
12
+ 4. Exchange authorization_code + verifier for token pair.
13
+ 5. Persist tokens via auth_store.save_tokens().
14
+
15
+ Note: The Axis engine Sprint 3.x roadmap adds the /oauth/* endpoints.
16
+ This CLI command is spec-complete and will work once those are live.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import hashlib
23
+ import os
24
+ import queue
25
+ import secrets
26
+ import time
27
+ import webbrowser
28
+ from http.server import BaseHTTPRequestHandler, HTTPServer
29
+ from urllib.parse import parse_qs, urlencode, urlparse
30
+
31
+ import httpx
32
+ import typer
33
+
34
+ from apps.cli.src.utils.api_client import CLIMfaRequiredError, _apply_login_response
35
+ from apps.cli.src.utils.auth_store import clear_mfa_pending, clear_tokens, load_mfa_pending, load_tokens, save_tokens
36
+ from apps.cli.src.utils.config import get_api_url
37
+
38
+ app = typer.Typer()
39
+
40
+ _CALLBACK_PATH = "/callback"
41
+
42
+
43
+ def _callback_host() -> str:
44
+ return os.getenv("SOVARIA_CLI_CALLBACK_HOST", "127.0.0.1").strip() or "127.0.0.1"
45
+
46
+
47
+ def _callback_port() -> int:
48
+ raw = os.getenv("SOVARIA_CLI_CALLBACK_PORT", "9876").strip()
49
+ if not raw:
50
+ raise RuntimeError("SOVARIA_CLI_CALLBACK_PORT must be set")
51
+ return int(raw)
52
+
53
+
54
+ def _redirect_uri() -> str:
55
+ return f"http://{_callback_host()}:{_callback_port()}{_CALLBACK_PATH}"
56
+
57
+
58
+ # ── PKCE helpers ──────────────────────────────────────────────────────────────
59
+
60
+
61
+ def _generate_pkce_pair() -> tuple[str, str]:
62
+ """Return (code_verifier, code_challenge)."""
63
+ code_verifier = secrets.token_urlsafe(64) # 86 chars — well within 43-128 range
64
+ digest = hashlib.sha256(code_verifier.encode()).digest()
65
+ code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
66
+ return code_verifier, code_challenge
67
+
68
+
69
+ # ── Local callback server ─────────────────────────────────────────────────────
70
+
71
+
72
+ def _make_callback_handler(
73
+ result_queue: queue.Queue[tuple[str, str | None]], expected_state: str
74
+ ) -> type[BaseHTTPRequestHandler]:
75
+ """
76
+ Factory that closes over a thread-safe queue and the expected CSRF state value.
77
+
78
+ Returns a per-request handler class so that concurrent ``login`` invocations
79
+ each get their own queue — eliminating the class-level ``code`` race condition
80
+ of a shared mutable class attribute.
81
+ """
82
+
83
+ class _CallbackHandler(BaseHTTPRequestHandler):
84
+ """One-shot HTTP handler that captures the ?code= + ?state= query parameters."""
85
+
86
+ def do_GET(self) -> None: # noqa: N802
87
+ parsed = urlparse(self.path)
88
+ if parsed.path != _CALLBACK_PATH:
89
+ self._respond(404, b"Not found")
90
+ return
91
+
92
+ qs = parse_qs(parsed.query)
93
+ code = qs.get("code", [None])[0]
94
+ state = qs.get("state", [None])[0]
95
+
96
+ # CSRF / state check — reject responses whose state doesn't match
97
+ # what we sent to the auth server (RFC 6749 §10.12).
98
+ if state != expected_state:
99
+ self._respond(
100
+ 400,
101
+ b"<html><body><h2>State mismatch - possible CSRF attack.</h2></body></html>",
102
+ )
103
+ result_queue.put(("csrf", None))
104
+ return
105
+
106
+ html = (
107
+ b"<html><body><h2>Sovaria Login Successful</h2>"
108
+ b"<p>You can close this tab and return to the terminal.</p></body></html>"
109
+ )
110
+ self._respond(200, html)
111
+ result_queue.put(("ok", code) if code else ("missing_code", None))
112
+
113
+ def _respond(self, status: int, body: bytes) -> None:
114
+ self.send_response(status)
115
+ self.send_header("Content-Type", "text/html")
116
+ self.end_headers()
117
+ self.wfile.write(body)
118
+
119
+ def log_message(self, *_: object) -> None: # suppress access log noise
120
+ pass
121
+
122
+ return _CallbackHandler
123
+
124
+
125
+ def _serve_callback(expected_state: str) -> tuple[str | None, str | None]:
126
+ """
127
+ Start the callback server and block until a code arrives or the 2-minute
128
+ timeout elapses. Returns (authorization code, error reason).
129
+ """
130
+ result_queue: queue.Queue[tuple[str, str | None]] = queue.Queue()
131
+ handler_class = _make_callback_handler(result_queue, expected_state)
132
+ server = HTTPServer((_callback_host(), _callback_port()), handler_class)
133
+ deadline = time.monotonic() + 120
134
+ try:
135
+ while time.monotonic() < deadline:
136
+ server.timeout = max(0.1, min(1.0, deadline - time.monotonic()))
137
+ server.handle_request()
138
+ try:
139
+ status, code = result_queue.get_nowait()
140
+ except queue.Empty:
141
+ continue
142
+ if status == "ok" and code:
143
+ return code, None
144
+ if status == "csrf":
145
+ return None, "csrf"
146
+ if status == "missing_code":
147
+ return None, "missing_code"
148
+ return None, "timeout"
149
+ finally:
150
+ server.server_close()
151
+
152
+
153
+ # ── Commands ──────────────────────────────────────────────────────────────────
154
+
155
+
156
+ @app.command("login")
157
+ def login(
158
+ email: str | None = typer.Option(
159
+ None, "--email", "-e", help="Email (skips PKCE, uses password)"
160
+ ),
161
+ password: str | None = typer.Option(
162
+ None,
163
+ "--password",
164
+ "-p",
165
+ help="Password (reads from stdin prompt if --email given without --password)",
166
+ ),
167
+ profile: str | None = typer.Option(None, "--profile", help="Config profile to use"),
168
+ org_id: str | None = typer.Option(None, "--org", help="Organization ID scope"),
169
+ project_id: str | None = typer.Option(None, "--project", help="Project ID scope"),
170
+ env_id: str | None = typer.Option(None, "--env", help="Environment ID scope"),
171
+ ) -> None:
172
+ """
173
+ Authenticate with Sovaria.
174
+
175
+ By default opens a browser (PKCE flow).
176
+ Pass --email to use password login (prompts securely if --password not given).
177
+ """
178
+ api_url = get_api_url(profile)
179
+
180
+ if email:
181
+ if not password:
182
+ import getpass
183
+
184
+ password = getpass.getpass("Password: ")
185
+ _password_login(api_url, email, password, org_id, project_id, env_id, profile=profile)
186
+ else:
187
+ _pkce_login(api_url, profile=profile)
188
+
189
+
190
+ def _password_login(
191
+ api_url: str,
192
+ email: str,
193
+ password: str,
194
+ org_id: str | None = None,
195
+ project_id: str | None = None,
196
+ env_id: str | None = None,
197
+ profile: str | None = None,
198
+ ) -> None:
199
+ """Direct credential exchange — bypasses PKCE.
200
+
201
+ The CLI is a platform tool (it manages orgs, projects, deployments,
202
+ audit, etc.) so it always authenticates against the platform console
203
+ lane. End-users of customer projects must use the project login on the
204
+ web — they have no business running ``sov`` against the platform.
205
+ """
206
+ if any((org_id, project_id, env_id)):
207
+ typer.echo(
208
+ "Note: --org/--project/--env are ignored on `sov login`. "
209
+ "The CLI authenticates as a Sovaria platform user; scope is "
210
+ "selected per-command via `sov scope use ...`.",
211
+ err=True,
212
+ )
213
+
214
+ try:
215
+ resp = httpx.post(
216
+ f"{api_url}/api/v1/auth/console/login",
217
+ headers={"Content-Type": "application/json"},
218
+ json={"email": email, "password": password},
219
+ timeout=15,
220
+ )
221
+ resp.raise_for_status()
222
+ _apply_login_response(resp.json(), profile=profile)
223
+ typer.echo(typer.style("Logged in successfully", fg=typer.colors.GREEN))
224
+ except CLIMfaRequiredError as exc:
225
+ typer.echo(typer.style(str(exc), fg=typer.colors.YELLOW), err=True)
226
+ raise typer.Exit(2) from exc
227
+ except httpx.HTTPStatusError as exc:
228
+ msg = exc.response.text
229
+ try:
230
+ detail = exc.response.json().get("detail", msg)
231
+ except Exception:
232
+ detail = msg
233
+ typer.echo(f"Login failed: {detail}", err=True)
234
+ raise typer.Exit(1) from exc
235
+
236
+
237
+ def _pkce_login(api_url: str, *, profile: str | None = None) -> None:
238
+ """Full OAuth2 PKCE browser flow."""
239
+ code_verifier, code_challenge = _generate_pkce_pair()
240
+ state = secrets.token_hex(16)
241
+ redirect_uri = _redirect_uri()
242
+
243
+ auth_url = f"{api_url}/oauth/authorize?" + urlencode(
244
+ {
245
+ "response_type": "code",
246
+ "client_id": "sovaria-cli",
247
+ "redirect_uri": redirect_uri,
248
+ "code_challenge": code_challenge,
249
+ "code_challenge_method": "S256",
250
+ "state": state,
251
+ }
252
+ )
253
+
254
+ typer.echo(f"Opening browser for authentication…\n{auth_url}")
255
+ webbrowser.open_new(auth_url)
256
+ typer.echo(f"Waiting for callback on port {_callback_port()}…")
257
+
258
+ code, callback_error = _serve_callback(expected_state=state)
259
+ if not code:
260
+ if callback_error == "csrf":
261
+ typer.echo("Login failed: callback state mismatch (possible CSRF).", err=True)
262
+ else:
263
+ typer.echo("No authorization code received — login timed out.", err=True)
264
+ raise typer.Exit(1)
265
+
266
+ # Exchange code for tokens
267
+ try:
268
+ resp = httpx.post(
269
+ f"{api_url}/oauth/token",
270
+ data={
271
+ "grant_type": "authorization_code",
272
+ "code": code,
273
+ "redirect_uri": redirect_uri,
274
+ "client_id": "sovaria-cli",
275
+ "code_verifier": code_verifier,
276
+ },
277
+ timeout=15,
278
+ )
279
+ resp.raise_for_status()
280
+ _apply_login_response(resp.json(), profile=profile)
281
+ typer.echo(typer.style("✓ Logged in successfully", fg=typer.colors.GREEN))
282
+ except CLIMfaRequiredError as exc:
283
+ typer.echo(typer.style(str(exc), fg=typer.colors.YELLOW), err=True)
284
+ raise typer.Exit(2) from exc
285
+ except httpx.HTTPStatusError as exc:
286
+ typer.echo(f"Token exchange failed: {exc.response.text}", err=True)
287
+ raise typer.Exit(1) from exc
288
+
289
+
290
+ @app.command("logout")
291
+ def logout(
292
+ profile: str | None = typer.Option(None, "--profile", help="Config profile to use"),
293
+ ) -> None:
294
+ """Revoke the session for one profile, then clear that profile's local credentials."""
295
+ tokens = load_tokens(profile=profile)
296
+ if tokens and tokens.get("access_token") and tokens.get("refresh_token"):
297
+ try:
298
+ httpx.post(
299
+ f"{get_api_url(profile)}/api/v1/auth/logout",
300
+ json={"refresh_token": tokens["refresh_token"]},
301
+ headers={"Authorization": f"Bearer {tokens['access_token']}"},
302
+ timeout=10,
303
+ )
304
+ except Exception: # pylint: disable=broad-exception-caught
305
+ pass # best-effort — always clear local tokens regardless
306
+ clear_tokens(profile=profile)
307
+ clear_mfa_pending(profile=profile)
308
+ typer.echo("Logged out.")
309
+
310
+
311
+ @app.command("mfa-verify")
312
+ def mfa_verify(
313
+ code: str = typer.Option(..., "--code", "-c", help="6-digit TOTP or backup code"),
314
+ method: str = typer.Option("totp", "--method", help="Verification method: totp or phone"),
315
+ profile: str | None = typer.Option(None, "--profile", help="Config profile to use"),
316
+ ) -> None:
317
+ """Complete MFA after login when the gateway returned mfa_required=true."""
318
+ pending = load_mfa_pending(profile=profile)
319
+ if not pending:
320
+ typer.echo(
321
+ "No pending MFA challenge found. Run `sov auth login` first.",
322
+ err=True,
323
+ )
324
+ raise typer.Exit(1)
325
+
326
+ challenge_token = str(pending.get("access_token") or "").strip()
327
+ if not challenge_token:
328
+ typer.echo("Stored MFA challenge token is missing or invalid.", err=True)
329
+ raise typer.Exit(1)
330
+
331
+ api_url = get_api_url(profile).rstrip("/")
332
+ try:
333
+ resp = httpx.post(
334
+ f"{api_url}/api/v1/auth/mfa/verify",
335
+ headers={
336
+ "Authorization": f"Bearer {challenge_token}",
337
+ "Content-Type": "application/json",
338
+ },
339
+ json={"code": code.strip(), "method": method.strip().lower()},
340
+ timeout=15,
341
+ )
342
+ resp.raise_for_status()
343
+ _apply_login_response(resp.json(), profile=profile)
344
+ clear_mfa_pending(profile=profile)
345
+ typer.echo(typer.style("MFA verified — full session established.", fg=typer.colors.GREEN))
346
+ except CLIMfaRequiredError as exc:
347
+ typer.echo(str(exc), err=True)
348
+ raise typer.Exit(2) from exc
349
+ except httpx.HTTPStatusError as exc:
350
+ detail = exc.response.text
351
+ try:
352
+ detail = exc.response.json().get("detail", detail)
353
+ except Exception:
354
+ pass
355
+ typer.echo(f"MFA verification failed: {detail}", err=True)
356
+ raise typer.Exit(1) from exc
357
+
358
+
359
+ @app.command("sessions")
360
+ def list_sessions(
361
+ profile: str | None = typer.Option(None, "--profile", help="Config profile to use"),
362
+ ) -> None:
363
+ """List active login sessions for the current user."""
364
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
365
+
366
+ try:
367
+ result = call_api(method="GET", path="/api/v1/auth/sessions", profile=profile)
368
+ except CLIRequestError as exc:
369
+ typer.echo(str(exc), err=True)
370
+ raise typer.Exit(1) from exc
371
+ typer.echo(print_json(result))
372
+
373
+
374
+ @app.command("sessions-revoke")
375
+ def revoke_session(
376
+ session_id: str = typer.Argument(..., help="Session id (refresh token JTI)"),
377
+ profile: str | None = typer.Option(None, "--profile"),
378
+ ) -> None:
379
+ """Revoke one active session."""
380
+ from urllib.parse import quote
381
+
382
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api
383
+
384
+ safe_id = quote(session_id, safe="")
385
+ try:
386
+ call_api(
387
+ method="DELETE",
388
+ path=f"/api/v1/auth/sessions/{safe_id}",
389
+ profile=profile,
390
+ )
391
+ except CLIRequestError as exc:
392
+ typer.echo(str(exc), err=True)
393
+ raise typer.Exit(1) from exc
394
+ typer.echo(typer.style(f"Revoked session {session_id}", fg=typer.colors.GREEN))
395
+
396
+
397
+ @app.command("sessions-revoke-others")
398
+ def revoke_other_sessions(
399
+ profile: str | None = typer.Option(None, "--profile"),
400
+ ) -> None:
401
+ """Revoke all sessions except the current one."""
402
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api
403
+
404
+ try:
405
+ call_api(
406
+ method="POST",
407
+ path="/api/v1/auth/sessions/revoke-others",
408
+ profile=profile,
409
+ body={},
410
+ )
411
+ except CLIRequestError as exc:
412
+ typer.echo(str(exc), err=True)
413
+ raise typer.Exit(1) from exc
414
+ typer.echo(typer.style("Other sessions revoked.", fg=typer.colors.GREEN))
415
+
416
+
417
+ @app.command("mfa-status")
418
+ def mfa_status(
419
+ profile: str | None = typer.Option(None, "--profile"),
420
+ ) -> None:
421
+ """Show MFA enrollment status for the current user."""
422
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
423
+
424
+ try:
425
+ result = call_api(method="GET", path="/api/v1/auth/mfa/status", profile=profile)
426
+ except CLIRequestError as exc:
427
+ typer.echo(str(exc), err=True)
428
+ raise typer.Exit(1) from exc
429
+ typer.echo(print_json(result))
430
+
431
+
432
+ @app.command("mfa-enroll")
433
+ def mfa_enroll(
434
+ profile: str | None = typer.Option(None, "--profile"),
435
+ ) -> None:
436
+ """Start TOTP MFA enrollment (shows secret, URI, and backup codes once)."""
437
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
438
+
439
+ try:
440
+ result = call_api(method="POST", path="/api/v1/auth/mfa/enroll", profile=profile, body={})
441
+ except CLIRequestError as exc:
442
+ typer.echo(str(exc), err=True)
443
+ raise typer.Exit(1) from exc
444
+ typer.echo(print_json(result))
445
+ typer.echo(
446
+ typer.style(
447
+ "\nScan the totp_uri in your authenticator app, then run:\n"
448
+ " sov auth mfa-verify --code <6-digit-code>",
449
+ fg=typer.colors.YELLOW,
450
+ )
451
+ )
452
+
453
+
454
+ @app.command("mfa-disable")
455
+ def mfa_disable(
456
+ code: str = typer.Option(..., "--code", "-c", help="Current TOTP code to confirm disable"),
457
+ profile: str | None = typer.Option(None, "--profile"),
458
+ ) -> None:
459
+ """Disable MFA (requires current TOTP code)."""
460
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
461
+
462
+ try:
463
+ result = call_api(
464
+ method="DELETE",
465
+ path="/api/v1/auth/mfa",
466
+ profile=profile,
467
+ body={"totp_code": code.strip()},
468
+ )
469
+ except CLIRequestError as exc:
470
+ typer.echo(str(exc), err=True)
471
+ raise typer.Exit(1) from exc
472
+ typer.echo(print_json(result))
473
+ typer.echo(typer.style("MFA disabled.", fg=typer.colors.GREEN))
474
+
475
+
476
+ magic_link_app = typer.Typer(help="Passwordless magic-link login")
477
+ verify_email_app = typer.Typer(help="Email verification")
478
+ sessions_config_app = typer.Typer(help="Session policy configuration")
479
+ providers_config_app = typer.Typer(help="Auth provider configuration")
480
+ event_hooks_app = typer.Typer(help="Auth event webhook subscriptions")
481
+ github_state_app = typer.Typer(help="GitHub OAuth connection state")
482
+
483
+ app.add_typer(magic_link_app, name="magic-link")
484
+ app.add_typer(verify_email_app, name="verify-email")
485
+ app.add_typer(sessions_config_app, name="sessions-config")
486
+ app.add_typer(providers_config_app, name="providers-config")
487
+ app.add_typer(event_hooks_app, name="event-hooks")
488
+ app.add_typer(github_state_app, name="github-state")
489
+
490
+
491
+ @magic_link_app.command("send")
492
+ def magic_link_send(
493
+ email: str = typer.Option(..., "--email"),
494
+ profile: str | None = typer.Option(None, "--profile"),
495
+ org_id: str | None = typer.Option(None, "--org"),
496
+ project_id: str | None = typer.Option(None, "--project"),
497
+ ) -> None:
498
+ """Send a magic-link sign-in email."""
499
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
500
+
501
+ body: dict[str, object] = {"email": email}
502
+ if org_id:
503
+ body["org_id"] = org_id
504
+ if project_id:
505
+ body["project_id"] = project_id
506
+ try:
507
+ result = call_api(
508
+ method="POST",
509
+ path="/api/v1/auth/magic-link/send",
510
+ profile=profile,
511
+ org_id=org_id,
512
+ project_id=project_id,
513
+ body=body,
514
+ )
515
+ except CLIRequestError as exc:
516
+ typer.echo(str(exc), err=True)
517
+ raise typer.Exit(1) from exc
518
+ typer.echo(print_json(result))
519
+
520
+
521
+ @magic_link_app.command("verify")
522
+ def magic_link_verify(
523
+ token: str = typer.Option(..., "--token"),
524
+ profile: str | None = typer.Option(None, "--profile"),
525
+ ) -> None:
526
+ """Exchange a magic-link token for a session."""
527
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, _apply_login_response
528
+
529
+ api_url = get_api_url(profile).rstrip("/")
530
+ try:
531
+ resp = httpx.post(
532
+ f"{api_url}/api/v1/auth/magic-link/verify",
533
+ headers={"Content-Type": "application/json"},
534
+ json={"token": token.strip()},
535
+ timeout=15,
536
+ )
537
+ resp.raise_for_status()
538
+ _apply_login_response(resp.json(), profile=profile)
539
+ typer.echo(typer.style("Magic link verified — session established.", fg=typer.colors.GREEN))
540
+ except CLIMfaRequiredError as exc:
541
+ typer.echo(typer.style(str(exc), fg=typer.colors.YELLOW), err=True)
542
+ raise typer.Exit(2) from exc
543
+ except httpx.HTTPStatusError as exc:
544
+ typer.echo(f"Magic link verification failed: {exc.response.text}", err=True)
545
+ raise typer.Exit(1) from exc
546
+
547
+
548
+ @verify_email_app.command("send")
549
+ def verify_email_send(
550
+ profile: str | None = typer.Option(None, "--profile"),
551
+ ) -> None:
552
+ """Send a verification email to the authenticated user."""
553
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
554
+
555
+ try:
556
+ result = call_api(
557
+ method="POST",
558
+ path="/api/v1/auth/verify-email/send",
559
+ profile=profile,
560
+ body={},
561
+ )
562
+ except CLIRequestError as exc:
563
+ typer.echo(str(exc), err=True)
564
+ raise typer.Exit(1) from exc
565
+ typer.echo(print_json(result))
566
+
567
+
568
+ @verify_email_app.command("confirm")
569
+ def verify_email_confirm(
570
+ token: str = typer.Option(..., "--token"),
571
+ profile: str | None = typer.Option(None, "--profile"),
572
+ ) -> None:
573
+ """Confirm an email address using a verification token."""
574
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
575
+
576
+ try:
577
+ result = call_api(
578
+ method="POST",
579
+ path="/api/v1/auth/verify-email/confirm",
580
+ profile=profile,
581
+ body={"token": token.strip()},
582
+ )
583
+ except CLIRequestError as exc:
584
+ typer.echo(str(exc), err=True)
585
+ raise typer.Exit(1) from exc
586
+ typer.echo(print_json(result))
587
+
588
+
589
+ @app.command("project-login")
590
+ def project_login(
591
+ email: str = typer.Option(..., "--email", "-e"),
592
+ password: str | None = typer.Option(None, "--password", "-p"),
593
+ profile: str | None = typer.Option(None, "--profile"),
594
+ org_id: str | None = typer.Option(None, "--org"),
595
+ project_id: str | None = typer.Option(None, "--project"),
596
+ env_id: str | None = typer.Option(None, "--env"),
597
+ ) -> None:
598
+ """Authenticate against the strict project login lane."""
599
+ if not password:
600
+ import getpass
601
+
602
+ password = getpass.getpass("Password: ")
603
+ api_url = get_api_url(profile).rstrip("/")
604
+ body: dict[str, object] = {"email": email, "password": password}
605
+ if org_id:
606
+ body["org_id"] = org_id
607
+ if project_id:
608
+ body["project_id"] = project_id
609
+ if env_id:
610
+ body["env_id"] = env_id
611
+ try:
612
+ resp = httpx.post(
613
+ f"{api_url}/api/v1/auth/project/login",
614
+ headers={"Content-Type": "application/json"},
615
+ json=body,
616
+ timeout=15,
617
+ )
618
+ resp.raise_for_status()
619
+ _apply_login_response(resp.json(), profile=profile)
620
+ typer.echo(typer.style("Project login successful", fg=typer.colors.GREEN))
621
+ except CLIMfaRequiredError as exc:
622
+ typer.echo(typer.style(str(exc), fg=typer.colors.YELLOW), err=True)
623
+ raise typer.Exit(2) from exc
624
+ except httpx.HTTPStatusError as exc:
625
+ typer.echo(f"Project login failed: {exc.response.text}", err=True)
626
+ raise typer.Exit(1) from exc
627
+
628
+
629
+ @sessions_config_app.command("get")
630
+ def sessions_config_get(
631
+ profile: str | None = typer.Option(None, "--profile"),
632
+ ) -> None:
633
+ """Fetch the authenticated user's session policy."""
634
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
635
+
636
+ try:
637
+ result = call_api(method="GET", path="/api/v1/auth/sessions/config", profile=profile)
638
+ except CLIRequestError as exc:
639
+ typer.echo(str(exc), err=True)
640
+ raise typer.Exit(1) from exc
641
+ typer.echo(print_json(result))
642
+
643
+
644
+ @sessions_config_app.command("set")
645
+ def sessions_config_set(
646
+ config_json: str = typer.Argument(..., help="JSON session policy patch"),
647
+ profile: str | None = typer.Option(None, "--profile"),
648
+ ) -> None:
649
+ """Update the authenticated user's session policy."""
650
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, parse_json_arg, print_json
651
+
652
+ body = parse_json_arg(config_json, label="config")
653
+ if body is None or not isinstance(body, dict):
654
+ raise typer.BadParameter("config must be a JSON object")
655
+ try:
656
+ result = call_api(
657
+ method="PUT",
658
+ path="/api/v1/auth/sessions/config",
659
+ profile=profile,
660
+ body=body,
661
+ )
662
+ except CLIRequestError as exc:
663
+ typer.echo(str(exc), err=True)
664
+ raise typer.Exit(1) from exc
665
+ typer.echo(print_json(result))
666
+
667
+
668
+ @providers_config_app.command("get")
669
+ def providers_config_get(
670
+ profile: str | None = typer.Option(None, "--profile"),
671
+ ) -> None:
672
+ """Fetch auth provider runtime configuration."""
673
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
674
+
675
+ try:
676
+ result = call_api(method="GET", path="/api/v1/auth/providers/config", profile=profile)
677
+ except CLIRequestError as exc:
678
+ typer.echo(str(exc), err=True)
679
+ raise typer.Exit(1) from exc
680
+ typer.echo(print_json(result))
681
+
682
+
683
+ @providers_config_app.command("set")
684
+ def providers_config_set(
685
+ config_json: str = typer.Argument(..., help="JSON provider config patch"),
686
+ profile: str | None = typer.Option(None, "--profile"),
687
+ ) -> None:
688
+ """Update auth provider toggles and external database link configuration."""
689
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, parse_json_arg, print_json
690
+
691
+ body = parse_json_arg(config_json, label="config")
692
+ if body is None or not isinstance(body, dict):
693
+ raise typer.BadParameter("config must be a JSON object")
694
+ try:
695
+ result = call_api(
696
+ method="PUT",
697
+ path="/api/v1/auth/providers/config",
698
+ profile=profile,
699
+ body=body,
700
+ )
701
+ except CLIRequestError as exc:
702
+ typer.echo(str(exc), err=True)
703
+ raise typer.Exit(1) from exc
704
+ typer.echo(print_json(result))
705
+
706
+
707
+ @event_hooks_app.command("list")
708
+ def event_hooks_list(
709
+ profile: str | None = typer.Option(None, "--profile"),
710
+ org_id: str | None = typer.Option(None, "--org"),
711
+ project_id: str | None = typer.Option(None, "--project"),
712
+ env_id: str | None = typer.Option(None, "--env"),
713
+ ) -> None:
714
+ """List auth event hooks for the active project."""
715
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
716
+
717
+ try:
718
+ result = call_api(
719
+ method="GET",
720
+ path="/api/v1/auth/event-hooks",
721
+ profile=profile,
722
+ org_id=org_id,
723
+ project_id=project_id,
724
+ env_id=env_id,
725
+ require_scope=True,
726
+ )
727
+ except CLIRequestError as exc:
728
+ typer.echo(str(exc), err=True)
729
+ raise typer.Exit(1) from exc
730
+ typer.echo(print_json(result))
731
+
732
+
733
+ @event_hooks_app.command("create")
734
+ def event_hooks_create(
735
+ name: str = typer.Option(..., "--name"),
736
+ url: str = typer.Option(..., "--url"),
737
+ events: list[str] = typer.Option(..., "--event", help="Repeat for each event type"),
738
+ secret: str = typer.Option("", "--secret"),
739
+ enabled: bool = typer.Option(True, "--enabled/--disabled"),
740
+ profile: str | None = typer.Option(None, "--profile"),
741
+ org_id: str | None = typer.Option(None, "--org"),
742
+ project_id: str | None = typer.Option(None, "--project"),
743
+ env_id: str | None = typer.Option(None, "--env"),
744
+ ) -> None:
745
+ """Create an auth event hook subscription."""
746
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
747
+
748
+ try:
749
+ result = call_api(
750
+ method="POST",
751
+ path="/api/v1/auth/event-hooks",
752
+ profile=profile,
753
+ org_id=org_id,
754
+ project_id=project_id,
755
+ env_id=env_id,
756
+ require_scope=True,
757
+ body={
758
+ "name": name,
759
+ "url": url,
760
+ "events": events,
761
+ "secret": secret,
762
+ "enabled": enabled,
763
+ },
764
+ )
765
+ except CLIRequestError as exc:
766
+ typer.echo(str(exc), err=True)
767
+ raise typer.Exit(1) from exc
768
+ typer.echo(print_json(result))
769
+
770
+
771
+ @event_hooks_app.command("delete")
772
+ def event_hooks_delete(
773
+ hook_id: str = typer.Argument(..., help="Auth event hook ID"),
774
+ profile: str | None = typer.Option(None, "--profile"),
775
+ org_id: str | None = typer.Option(None, "--org"),
776
+ project_id: str | None = typer.Option(None, "--project"),
777
+ env_id: str | None = typer.Option(None, "--env"),
778
+ ) -> None:
779
+ """Delete an auth event hook."""
780
+ from urllib.parse import quote
781
+
782
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api
783
+
784
+ safe_id = quote(hook_id, safe="")
785
+ try:
786
+ call_api(
787
+ method="DELETE",
788
+ path=f"/api/v1/auth/event-hooks/{safe_id}",
789
+ profile=profile,
790
+ org_id=org_id,
791
+ project_id=project_id,
792
+ env_id=env_id,
793
+ require_scope=True,
794
+ )
795
+ except CLIRequestError as exc:
796
+ typer.echo(str(exc), err=True)
797
+ raise typer.Exit(1) from exc
798
+ typer.echo(typer.style(f"Deleted auth event hook {hook_id}", fg=typer.colors.GREEN))
799
+
800
+
801
+ @github_state_app.command("get")
802
+ def github_state_get(
803
+ profile: str | None = typer.Option(None, "--profile"),
804
+ project_id: str | None = typer.Option(None, "--project"),
805
+ ) -> None:
806
+ """Fetch GitHub OAuth connection state."""
807
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
808
+
809
+ params = {"project_id": project_id} if project_id else None
810
+ try:
811
+ result = call_api(
812
+ method="GET",
813
+ path="/api/v1/auth/github/connection-state",
814
+ profile=profile,
815
+ params=params,
816
+ )
817
+ except CLIRequestError as exc:
818
+ typer.echo(str(exc), err=True)
819
+ raise typer.Exit(1) from exc
820
+ typer.echo(print_json(result))
821
+
822
+
823
+ @github_state_app.command("disconnect")
824
+ def github_state_disconnect(
825
+ profile: str | None = typer.Option(None, "--profile"),
826
+ project_id: str | None = typer.Option(None, "--project"),
827
+ ) -> None:
828
+ """Disconnect GitHub OAuth broker state."""
829
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
830
+
831
+ params = {"project_id": project_id} if project_id else None
832
+ try:
833
+ result = call_api(
834
+ method="DELETE",
835
+ path="/api/v1/auth/github/connection-state",
836
+ profile=profile,
837
+ params=params,
838
+ )
839
+ except CLIRequestError as exc:
840
+ typer.echo(str(exc), err=True)
841
+ raise typer.Exit(1) from exc
842
+ typer.echo(print_json(result))