sovaria-cli 0.3.0__tar.gz → 0.5.0__tar.gz

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 (100) hide show
  1. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/PKG-INFO +6 -4
  2. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/README.md +3 -1
  3. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/__init__.py +1 -1
  4. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/alerts.py +4 -1
  5. sovaria_cli-0.5.0/apps/cli/src/commands/analytics.py +225 -0
  6. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/auth_enterprise.py +82 -7
  7. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/auth_product.py +41 -0
  8. sovaria_cli-0.5.0/apps/cli/src/commands/auth_settings.py +317 -0
  9. sovaria_cli-0.5.0/apps/cli/src/commands/branches.py +543 -0
  10. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/chrono.py +28 -15
  11. sovaria_cli-0.5.0/apps/cli/src/commands/compliance.py +128 -0
  12. sovaria_cli-0.5.0/apps/cli/src/commands/crashes.py +303 -0
  13. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/db.py +219 -5
  14. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/deploy.py +61 -1
  15. sovaria_cli-0.5.0/apps/cli/src/commands/distribute.py +143 -0
  16. sovaria_cli-0.5.0/apps/cli/src/commands/experiments.py +126 -0
  17. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/fn_platform.py +298 -6
  18. sovaria_cli-0.5.0/apps/cli/src/commands/hosting.py +502 -0
  19. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/iam.py +3 -0
  20. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/knowledge.py +20 -0
  21. sovaria_cli-0.5.0/apps/cli/src/commands/local.py +221 -0
  22. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/messaging.py +72 -3
  23. sovaria_cli-0.5.0/apps/cli/src/commands/perf.py +109 -0
  24. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/project.py +8 -11
  25. sovaria_cli-0.5.0/apps/cli/src/commands/push.py +101 -0
  26. sovaria_cli-0.5.0/apps/cli/src/commands/queues.py +308 -0
  27. sovaria_cli-0.5.0/apps/cli/src/commands/remote_config.py +213 -0
  28. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/sim.py +94 -0
  29. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/simulate.py +2 -1
  30. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/vector.py +76 -2
  31. sovaria_cli-0.5.0/apps/cli/src/local/docker-compose.yml +373 -0
  32. sovaria_cli-0.5.0/apps/cli/src/local/initdb/001_axis_tables.sql +48 -0
  33. sovaria_cli-0.5.0/apps/cli/src/local/initdb/002_axis_base_schema.sql +36 -0
  34. sovaria_cli-0.5.0/apps/cli/src/local/initdb/003_guard_policies.sql +58 -0
  35. sovaria_cli-0.5.0/apps/cli/src/local/initdb/010_local_stack.sql +7 -0
  36. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/main.py +46 -2
  37. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/api_client.py +43 -0
  38. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/forge_local.py +213 -22
  39. sovaria_cli-0.5.0/apps/cli/src/utils/local_stack.py +504 -0
  40. sovaria_cli-0.5.0/apps/cli/src/utils/macho_symbols.py +71 -0
  41. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/project_context.py +10 -2
  42. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/pyproject.toml +3 -3
  43. sovaria_cli-0.3.0/apps/cli/src/commands/branches.py +0 -240
  44. sovaria_cli-0.3.0/apps/cli/src/commands/crashes.py +0 -92
  45. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/.gitignore +0 -0
  46. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/__init__.py +0 -0
  47. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/__init__.py +0 -0
  48. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/admin.py +0 -0
  49. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/ai_data.py +0 -0
  50. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/api.py +0 -0
  51. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/audit.py +0 -0
  52. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/billing.py +0 -0
  53. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/builds.py +0 -0
  54. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/collections.py +0 -0
  55. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/connect.py +0 -0
  56. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/context.py +0 -0
  57. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/dashboard.py +0 -0
  58. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/data.py +0 -0
  59. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/env.py +0 -0
  60. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/gen.py +0 -0
  61. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/governance.py +0 -0
  62. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/import_cmd.py +0 -0
  63. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/init_cmd.py +0 -0
  64. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/inspect.py +0 -0
  65. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/keys.py +0 -0
  66. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/link_cmd.py +0 -0
  67. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/login.py +0 -0
  68. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/logs.py +0 -0
  69. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/mcp.py +0 -0
  70. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/metrics.py +0 -0
  71. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/oidc_providers.py +0 -0
  72. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/org.py +0 -0
  73. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/org_key.py +0 -0
  74. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/passkeys.py +0 -0
  75. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/policy.py +0 -0
  76. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/realtime.py +0 -0
  77. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/rules.py +0 -0
  78. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/schema.py +0 -0
  79. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/scope.py +0 -0
  80. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/secrets.py +0 -0
  81. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/shell.py +0 -0
  82. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/storage.py +0 -0
  83. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/templates.py +0 -0
  84. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/test_cmd.py +0 -0
  85. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/__init__.py +0 -0
  86. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/api_helpers.py +0 -0
  87. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/auth_store.py +0 -0
  88. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/config.py +0 -0
  89. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/display.py +0 -0
  90. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/importers.py +0 -0
  91. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/local_function_server.py +0 -0
  92. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/log_tail.py +0 -0
  93. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/migrations.py +0 -0
  94. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/mobile_types.py +0 -0
  95. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/output_mode.py +0 -0
  96. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/premium_display.py +0 -0
  97. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/schema_introspect.py +0 -0
  98. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/scope_picker.py +0 -0
  99. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/sim_offline.py +0 -0
  100. {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/theme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sovaria-cli
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Sovaria BaaS command-line interface (sov)
5
5
  Project-URL: Homepage, https://sovaria.in
6
6
  Project-URL: Documentation, https://docs.sovaria.in/cli
@@ -12,7 +12,7 @@ Requires-Dist: httpx>=0.28.1
12
12
  Requires-Dist: keyring>=25.0
13
13
  Requires-Dist: pyyaml>=6.0
14
14
  Requires-Dist: rich>=13.7.0
15
- Requires-Dist: sovaria-sdk==0.5.0
15
+ Requires-Dist: sovaria-sdk==0.7.0
16
16
  Requires-Dist: tomli-w>=1.2.0
17
17
  Requires-Dist: typer>=0.17.4
18
18
  Provides-Extra: dev
@@ -22,7 +22,7 @@ Requires-Dist: pytest>=8.0; extra == 'dev'
22
22
  Requires-Dist: ruff>=0.9; extra == 'dev'
23
23
  Requires-Dist: twine>=6.0; extra == 'dev'
24
24
  Provides-Extra: mcp
25
- Requires-Dist: sovaria-sdk[mcp]==0.5.0; extra == 'mcp'
25
+ Requires-Dist: sovaria-sdk[mcp]==0.7.0; extra == 'mcp'
26
26
  Description-Content-Type: text/markdown
27
27
 
28
28
  # Sovaria CLI
@@ -122,7 +122,9 @@ sov realtime ws-url my-channel --org <org_id> --project <project_id>
122
122
  - `sov fn ...` Forge function deploy/status/invoke
123
123
  - `sov project ...` project config template + scaffold creation
124
124
  - `sov api call ...` raw gateway API request
125
- - `sov branches list|create|get|update|use|delete` environments as branches (`create x --from staging`, `use x`)
125
+ - `sov branches list|create|get|update|use|delete` environments as branches (`create x --from staging --data sample`, `use x`)
126
+ - `sov branches diff|deploy|reset|restore|revert|protect|expire`: schema diff with production, deploy (checked first), reset from the parent,
127
+ point-in-time restore of a branch's database, revert a branch to a commit (`--include-schema`, `--include-database`), protection and end dates
126
128
  - `sov gen types typescript|python|dart|swift|kotlin -o <file>` row models from the live schema
127
129
  - `sov storage cp [-r] <src> <dst>` copy between local paths and `ss://bucket/path` (or between buckets)
128
130
  - `sov collections delete <path> [-r]` delete a collection or document (and its subcollections with `-r`)
@@ -95,7 +95,9 @@ sov realtime ws-url my-channel --org <org_id> --project <project_id>
95
95
  - `sov fn ...` Forge function deploy/status/invoke
96
96
  - `sov project ...` project config template + scaffold creation
97
97
  - `sov api call ...` raw gateway API request
98
- - `sov branches list|create|get|update|use|delete` environments as branches (`create x --from staging`, `use x`)
98
+ - `sov branches list|create|get|update|use|delete` environments as branches (`create x --from staging --data sample`, `use x`)
99
+ - `sov branches diff|deploy|reset|restore|revert|protect|expire`: schema diff with production, deploy (checked first), reset from the parent,
100
+ point-in-time restore of a branch's database, revert a branch to a commit (`--include-schema`, `--include-database`), protection and end dates
99
101
  - `sov gen types typescript|python|dart|swift|kotlin -o <file>` row models from the live schema
100
102
  - `sov storage cp [-r] <src> <dst>` copy between local paths and `ss://bucket/path` (or between buckets)
101
103
  - `sov collections delete <path> [-r]` delete a collection or document (and its subcollections with `-r`)
@@ -1,3 +1,3 @@
1
1
  """Sovaria CLI — sov command."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.5.0"
@@ -4,6 +4,7 @@ database connections cross a threshold (Observability › Alerts).
4
4
  sov alerts list
5
5
  sov alerts create --metric latency_p95_ms --threshold 800 [--window 15] [--target "GET /rest/v1/orders"]
6
6
  [--no-email] [--webhook https://hooks.example.com/sovaria]
7
+ sov alerts create --metric crash_new_issues --threshold 0 --window 15 (any new crash issue)
7
8
  sov alerts pause|resume <rule-id>
8
9
  sov alerts test <rule-id>
9
10
  sov alerts delete <rule-id>
@@ -22,7 +23,9 @@ from apps.cli.src.utils.output_mode import wants_json
22
23
 
23
24
  app = typer.Typer(help="SLO alerts: latency, 5xx rate, function failures, DB connections → email / signed webhook")
24
25
 
25
- METRICS = ("latency_p95_ms", "latency_p99_ms", "error_rate", "function_failures", "db_connections")
26
+ METRICS = ("latency_p95_ms", "latency_p99_ms", "error_rate", "function_failures", "db_connections",
27
+ # App crashes (Echo): a spike of events (target: an issue fingerprint or release:<version>), new or regressed issues.
28
+ "crash_events", "crash_new_issues", "crash_regressions")
26
29
  BASE = "/api/v1/observability/alerts"
27
30
 
28
31
 
@@ -0,0 +1,225 @@
1
+ """`sov analytics`: product analytics from your apps (sov.analytics in the SDK).
2
+
3
+ sov analytics overview [--days 30]
4
+ sov analytics events [--days 30]
5
+ sov analytics trend <event> [--days 30]
6
+ sov analytics funnel view_item add_to_cart purchase [--window 24] [--days 30]
7
+ sov analytics retention [--start signup] [--return purchase] [--period week|day] [--periods 8]
8
+ sov analytics users [--q prefix] / sov analytics user <user_key>
9
+ sov analytics live [--follow]
10
+ sov analytics export [--since 2026-10-01] [--until …] [--format csv|ndjson] [--out file] [--names a,b]
11
+ sov analytics export --to-db [--table sovaria_analytics.events] # then query events with SQL next to your tables
12
+ sov analytics erase <user_key> --yes
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ import time
20
+ from pathlib import Path
21
+ from typing import Any
22
+ from urllib.parse import quote
23
+
24
+ import typer
25
+
26
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api
27
+ from apps.cli.src.utils.display import handle_cli_error, print_json, print_success
28
+ from apps.cli.src.utils.output_mode import wants_json
29
+
30
+ app = typer.Typer(help="Product analytics from your apps: active users, events, funnels, retention, export")
31
+
32
+ _PROFILE = typer.Option(None, "--profile")
33
+ _ORG = typer.Option(None, "--org")
34
+ _PROJECT = typer.Option(None, "--project")
35
+ _ENV = typer.Option(None, "--env")
36
+ BASE = "/api/v1/analytics"
37
+ _TABLE = re.compile(r"^[a-z_][a-z0-9_]{0,62}(\.[a-z_][a-z0-9_]{0,62})?$")
38
+
39
+
40
+ def _call(method: str, path: str, *, profile, org_id, project_id, env_id, body=None, params=None, timeout: float = 60.0) -> Any:
41
+ try:
42
+ return call_api(method=method, path=path, profile=profile, org_id=org_id, project_id=project_id, env_id=env_id,
43
+ require_scope=True, body=body, params=params, timeout=timeout)
44
+ except CLIRequestError as exc:
45
+ handle_cli_error(exc)
46
+
47
+
48
+ @app.command("overview")
49
+ def overview(days: int = typer.Option(30, "--days", min=1, max=1825), profile: str | None = _PROFILE, org_id: str | None = _ORG,
50
+ project_id: str | None = _PROJECT, env_id: str | None = _ENV) -> None:
51
+ """DAU / WAU / MAU, events and top events, platforms, countries, usage."""
52
+ data = _call("GET", f"{BASE}/overview", params={"days": str(days)}, profile=profile, org_id=org_id, project_id=project_id,
53
+ env_id=env_id)
54
+ if wants_json():
55
+ print_json(data)
56
+ return
57
+ k = data.get("kpis", {})
58
+ print_json({"dau": k.get("dau"), "wau": k.get("wau"), "mau": k.get("mau"), "stickiness_pct": k.get("stickiness_pct"),
59
+ "events": k.get("events"), "top_events": data.get("top_events", [])[:10],
60
+ "usage": data.get("usage"), "limit": (data.get("limits") or {}).get("events_month")}, title=f"Analytics, last {days} days")
61
+
62
+
63
+ @app.command("events")
64
+ def events(days: int = typer.Option(30, "--days", min=1, max=1825), profile: str | None = _PROFILE, org_id: str | None = _ORG,
65
+ project_id: str | None = _PROJECT, env_id: str | None = _ENV) -> None:
66
+ """Event names with counts."""
67
+ data = _call("GET", f"{BASE}/events", params={"days": str(days)}, profile=profile, org_id=org_id, project_id=project_id,
68
+ env_id=env_id)
69
+ print_json(data if wants_json() else data.get("events", []), title="Events")
70
+
71
+
72
+ @app.command("trend")
73
+ def trend(name: str, days: int = typer.Option(30, "--days", min=1, max=1825), profile: str | None = _PROFILE,
74
+ org_id: str | None = _ORG, project_id: str | None = _PROJECT, env_id: str | None = _ENV) -> None:
75
+ """One event per day: count and distinct users."""
76
+ data = _call("GET", f"{BASE}/events/{quote(name, safe='')}/trend", params={"days": str(days)}, profile=profile,
77
+ org_id=org_id, project_id=project_id, env_id=env_id)
78
+ if wants_json():
79
+ print_json(data)
80
+ return
81
+ rows = [{"day": d, "count": c, "users": u} for d, c, u in zip(data["days"], data["count"], data["users"], strict=False)]
82
+ print_json(rows, title=f"{name} per day")
83
+
84
+
85
+ @app.command("funnel")
86
+ def funnel(steps: list[str] = typer.Argument(..., help="event names in order"),
87
+ window: int = typer.Option(24, "--window", help="hours from the first step"),
88
+ days: int = typer.Option(30, "--days", min=1, max=1825), profile: str | None = _PROFILE, org_id: str | None = _ORG,
89
+ project_id: str | None = _PROJECT, env_id: str | None = _ENV) -> None:
90
+ """Conversion through ordered steps."""
91
+ if len(steps) < 2:
92
+ raise typer.BadParameter("A funnel needs at least two steps")
93
+ data = _call("POST", f"{BASE}/funnel", body={"steps": [{"name": s} for s in steps], "window_hours": window, "days": days},
94
+ profile=profile, org_id=org_id, project_id=project_id, env_id=env_id)
95
+ print_json(data if wants_json() else data.get("steps", []), title="Funnel")
96
+
97
+
98
+ @app.command("retention")
99
+ def retention(start: str | None = typer.Option(None, "--start", help="cohort event (default: first seen)"),
100
+ ret: str | None = typer.Option(None, "--return", help="return event (default: any activity)"),
101
+ period: str = typer.Option("week", "--period", help="week | day"), periods: int = typer.Option(8, "--periods"),
102
+ profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
103
+ env_id: str | None = _ENV) -> None:
104
+ """Retention cohorts (finished periods only)."""
105
+ if period not in ("week", "day"):
106
+ raise typer.BadParameter("--period is week or day")
107
+ params = {"period": period, "periods": str(periods), **({"start_event": start} if start else {}),
108
+ **({"return_event": ret} if ret else {})}
109
+ data = _call("GET", f"{BASE}/retention", params=params, profile=profile, org_id=org_id, project_id=project_id, env_id=env_id)
110
+ print_json(data if wants_json() else data.get("cohorts", []), title=f"Retention by {period}")
111
+
112
+
113
+ @app.command("users")
114
+ def users(q: str = typer.Option("", "--q"), limit: int = typer.Option(50, "--limit", min=1, max=200), profile: str | None = _PROFILE,
115
+ org_id: str | None = _ORG, project_id: str | None = _PROJECT, env_id: str | None = _ENV) -> None:
116
+ """Recently seen app users and their properties."""
117
+ data = _call("GET", f"{BASE}/users", params={"q": q, "limit": str(limit)}, profile=profile, org_id=org_id,
118
+ project_id=project_id, env_id=env_id)
119
+ print_json(data if wants_json() else data.get("users", []), title="Users")
120
+
121
+
122
+ @app.command("user")
123
+ def user(user_key: str, limit: int = typer.Option(100, "--limit", min=1, max=500), profile: str | None = _PROFILE,
124
+ org_id: str | None = _ORG, project_id: str | None = _PROJECT, env_id: str | None = _ENV) -> None:
125
+ """One user's profile, consent and latest events."""
126
+ print_json(_call("GET", f"{BASE}/users/{quote(user_key, safe=':')}", params={"limit": str(limit)}, profile=profile,
127
+ org_id=org_id, project_id=project_id, env_id=env_id))
128
+
129
+
130
+ @app.command("live")
131
+ def live(follow: bool = typer.Option(False, "--follow", "-f", help="keep printing new events"),
132
+ profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
133
+ env_id: str | None = _ENV) -> None:
134
+ """Newest events as they arrive (debug view)."""
135
+ since: str | None = None
136
+ while True:
137
+ params = {"limit": "100", **({"since": since} if since else {})}
138
+ data = _call("GET", f"{BASE}/live", params=params, profile=profile, org_id=org_id, project_id=project_id, env_id=env_id)
139
+ for e in reversed(data.get("events", [])):
140
+ typer.echo(json.dumps({"at": e["received_at"][11:19], "event": e["name"], "user": e["user_key"],
141
+ "platform": e.get("platform"), "props": e.get("props")}))
142
+ events_ = data.get("events") or []
143
+ if events_:
144
+ since = events_[0]["received_at"]
145
+ if not follow:
146
+ return
147
+ time.sleep(2)
148
+
149
+
150
+ def _to_db(rows: list[dict[str, Any]], table: str, *, profile, org_id, project_id, env_id) -> int:
151
+ schema = table.split(".", 1)[0] if "." in table else None
152
+ if schema:
153
+ _call("POST", "/api/v1/sql/query", body={"sql": f'CREATE SCHEMA IF NOT EXISTS "{schema}"'}, profile=profile,
154
+ org_id=org_id, project_id=project_id, env_id=env_id)
155
+ quoted = ".".join(f'"{p}"' for p in table.split("."))
156
+ _call("POST", "/api/v1/sql/query", body={"sql": (
157
+ f"CREATE TABLE IF NOT EXISTS {quoted} (occurred_at timestamptz NOT NULL, received_at timestamptz NOT NULL, "
158
+ "name text NOT NULL, user_key text NOT NULL, user_id text, anon_id text, session_id text, platform text, "
159
+ "app_version text, country text, props jsonb, UNIQUE (user_key, name, occurred_at, received_at))")},
160
+ profile=profile, org_id=org_id, project_id=project_id, env_id=env_id)
161
+ done = 0
162
+ for i in range(0, len(rows), 500):
163
+ chunk = rows[i:i + 500]
164
+ sql = (f"INSERT INTO {quoted} SELECT * FROM jsonb_to_recordset($1::jsonb) AS x(occurred_at timestamptz, "
165
+ "received_at timestamptz, name text, user_key text, user_id text, anon_id text, session_id text, platform text, "
166
+ "app_version text, country text, props jsonb) ON CONFLICT DO NOTHING")
167
+ _call("POST", "/api/v1/sql/query", body={"sql": sql, "params": [json.dumps(chunk)]}, profile=profile, org_id=org_id,
168
+ project_id=project_id, env_id=env_id, timeout=120.0)
169
+ done += len(chunk)
170
+ return done
171
+
172
+
173
+ @app.command("export")
174
+ def export(since: str | None = typer.Option(None, "--since", help="ISO date (default: 7 days ago)"),
175
+ until: str | None = typer.Option(None, "--until"), fmt: str = typer.Option("csv", "--format", help="csv | ndjson"),
176
+ names: str | None = typer.Option(None, "--names", help="comma-separated event names"),
177
+ out: Path | None = typer.Option(None, "--out", help="file to write (default: stdout)"),
178
+ to_db: bool = typer.Option(False, "--to-db", help="copy into a table in the project's own Postgres"),
179
+ table: str = typer.Option("sovaria_analytics.events", "--table"),
180
+ limit: int = typer.Option(1_000_000, "--limit", min=1, max=5_000_000),
181
+ profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
182
+ env_id: str | None = _ENV) -> None:
183
+ """Raw events (analytics.export_sql). --to-db makes them SQL-queryable next to your app's tables."""
184
+ if fmt not in ("csv", "ndjson"):
185
+ raise typer.BadParameter("--format is csv or ndjson")
186
+ if to_db and not _TABLE.fullmatch(table):
187
+ raise typer.BadParameter("--table is [schema.]name in lower case")
188
+ params = {"format": "ndjson" if to_db else fmt, "limit": str(limit), **({"since": since} if since else {}),
189
+ **({"until": until} if until else {}), **({"names": names} if names else {})}
190
+ data = _call("GET", f"{BASE}/export", params=params, profile=profile, org_id=org_id, project_id=project_id, env_id=env_id,
191
+ timeout=600.0)
192
+ text = data.get("text", "") if isinstance(data, dict) else str(data)
193
+ if to_db:
194
+ rows = [json.loads(line) for line in text.splitlines() if line.strip()]
195
+ n = _to_db(rows, table, profile=profile, org_id=org_id, project_id=project_id, env_id=env_id)
196
+ print_success(f"Copied {n} events into {table} (duplicates skipped). Query them with: sov db query \"select … from {table}\"")
197
+ return
198
+ if out:
199
+ out.write_text(text + ("\n" if text and not text.endswith("\n") else ""), encoding="utf-8")
200
+ print_success(f"Wrote {len(text.splitlines()) - (1 if fmt == 'csv' else 0)} events to {out}")
201
+ else:
202
+ typer.echo(text)
203
+
204
+
205
+ @app.command("erase")
206
+ def erase(user_key: str, yes: bool = typer.Option(False, "--yes", help="confirm"), profile: str | None = _PROFILE,
207
+ org_id: str | None = _ORG, project_id: str | None = _PROJECT, env_id: str | None = _ENV) -> None:
208
+ """Erase one user's analytics (events, properties) and stop collecting theirs. Can't be undone."""
209
+ if not yes and not typer.confirm(f"Erase all analytics of {user_key}? This can't be undone"):
210
+ raise typer.Exit(1)
211
+ data = _call("DELETE", f"{BASE}/users/{quote(user_key, safe=':')}", profile=profile, org_id=org_id, project_id=project_id,
212
+ env_id=env_id)
213
+ print_success(f"Erased {data.get('erased_events', 0)} events of {user_key}")
214
+
215
+
216
+ @app.command("settings")
217
+ def settings(require_consent: bool | None = typer.Option(None, "--require-consent/--no-require-consent"),
218
+ profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
219
+ env_id: str | None = _ENV) -> None:
220
+ """Show (or set) whether only users who consented are recorded."""
221
+ if require_consent is None:
222
+ print_json(_call("GET", f"{BASE}/settings", profile=profile, org_id=org_id, project_id=project_id, env_id=env_id))
223
+ return
224
+ print_json(_call("PUT", f"{BASE}/settings", body={"require_consent": require_consent}, profile=profile, org_id=org_id,
225
+ project_id=project_id, env_id=env_id))
@@ -1,10 +1,11 @@
1
1
  """Enterprise auth for a project's app users: SCIM 2.0 provisioning and blocking auth hooks.
2
2
 
3
+ sov saml list | add NAME --domain acme.com --metadata idp.xml | enable ID | disable ID | remove ID [--yes]
3
4
  sov scim status | token [--yes] | revoke [--yes]
4
5
  sov auth-hooks list | set before-sign-in --url https://… [--fail open] [--timeout-ms 1500] | set before-create
5
6
  --function check-signup | test before-create [--email a@b.co] | delete before-create [--yes]
6
7
 
7
- Both need a project owner/admin; plan keys auth.scim (Team+) and auth.blocking_hooks (Pro+).
8
+ All need a project owner/admin; plan keys res.sso (Team+), auth.scim (Team+) and auth.blocking_hooks (Pro+).
8
9
  """
9
10
 
10
11
  from __future__ import annotations
@@ -15,11 +16,13 @@ from apps.cli.src.utils.api_client import CLIRequestError, call_api
15
16
  from apps.cli.src.utils.display import handle_cli_error, print_json
16
17
  from apps.cli.src.utils.premium_display import run_with_spinner
17
18
 
19
+ saml_app = typer.Typer(help="SAML single sign-on for your app's users: one connection per customer identity provider")
18
20
  scim_app = typer.Typer(help="SCIM 2.0 provisioning of app users and groups (Okta, Entra ID, Google Workspace …)")
19
21
  hooks_app = typer.Typer(help="Blocking auth hooks: before-create and before-sign-in (refuse or add custom claims)")
20
22
 
21
- _EVENTS = {"before-create": "before-create", "before_create": "before-create",
22
- "before-sign-in": "before-sign-in", "before_sign_in": "before-sign-in"}
23
+ _HOOK_EVENTS = ("before-create", "before-sign-in", "password-verification-attempt", "mfa-verification-attempt",
24
+ "send-email", "send-sms")
25
+ _EVENTS = {**{e: e for e in _HOOK_EVENTS}, **{e.replace("-", "_"): e for e in _HOOK_EVENTS}}
23
26
 
24
27
 
25
28
  def _call(method: str, path: str, message: str, *, body=None, profile=None, org_id=None, project_id=None):
@@ -34,10 +37,82 @@ def _call(method: str, path: str, message: str, *, body=None, profile=None, org_
34
37
  def _event(raw: str) -> str:
35
38
  event = _EVENTS.get((raw or "").strip().lower())
36
39
  if event is None:
37
- raise typer.BadParameter("event is before-create or before-sign-in")
40
+ raise typer.BadParameter("event is one of: " + ", ".join(_HOOK_EVENTS))
38
41
  return event
39
42
 
40
43
 
44
+ # ── SAML ──────────────────────────────────────────────────────────────────────
45
+
46
+
47
+ @saml_app.command("list")
48
+ def saml_list(
49
+ profile: str | None = typer.Option(None, "--profile"),
50
+ org_id: str | None = typer.Option(None, "--org"),
51
+ project_id: str | None = typer.Option(None, "--project"),
52
+ ) -> None:
53
+ """The project's SAML connections, with the ACS URL and entity ID to give each IdP admin."""
54
+ print_json(_call("GET", "/api/v1/auth/saml/connections", "Loading SAML connections…", profile=profile,
55
+ org_id=org_id, project_id=project_id))
56
+
57
+
58
+ @saml_app.command("add")
59
+ def saml_add(
60
+ name: str = typer.Argument(..., help='A name, like "Acme (Okta)"'),
61
+ domain: list[str] = typer.Option(..., "--domain", "-d", help="Email domain this IdP signs in (repeat for more)"),
62
+ metadata: typer.FileText | None = typer.Option(None, "--metadata", help="The IdP's metadata XML file"),
63
+ entity_id: str | None = typer.Option(None, "--entity-id", help="IdP entity ID (instead of --metadata)"),
64
+ sso_url: str | None = typer.Option(None, "--sso-url", help="IdP HTTP-Redirect sign-in URL (instead of --metadata)"),
65
+ certificate: typer.FileText | None = typer.Option(None, "--certificate", help="IdP signing certificate PEM file"),
66
+ profile: str | None = typer.Option(None, "--profile"),
67
+ org_id: str | None = typer.Option(None, "--org"),
68
+ project_id: str | None = typer.Option(None, "--project"),
69
+ ) -> None:
70
+ """Connect a customer's identity provider (metadata XML, or entity ID + sign-in URL + certificate)."""
71
+ body: dict = {"name": name, "domains": domain}
72
+ if metadata is not None:
73
+ body["metadata_xml"] = metadata.read()
74
+ elif entity_id and sso_url and certificate is not None:
75
+ body.update(idp_entity_id=entity_id, idp_sso_url=sso_url, idp_certificate_pem=certificate.read())
76
+ else:
77
+ raise typer.BadParameter("give --metadata, or --entity-id, --sso-url and --certificate")
78
+ print_json(_call("POST", "/api/v1/auth/saml/connections", "Adding the connection…", body=body, profile=profile,
79
+ org_id=org_id, project_id=project_id))
80
+
81
+
82
+ def _toggle(connection_id: str, enabled: bool, profile, org_id, project_id) -> None:
83
+ print_json(_call("PATCH", f"/api/v1/auth/saml/connections/{connection_id}", "Saving…", body={"enabled": enabled},
84
+ profile=profile, org_id=org_id, project_id=project_id))
85
+
86
+
87
+ @saml_app.command("enable")
88
+ def saml_enable(connection_id: str, profile: str | None = typer.Option(None, "--profile"),
89
+ org_id: str | None = typer.Option(None, "--org"), project_id: str | None = typer.Option(None, "--project")) -> None:
90
+ """Turn a connection on."""
91
+ _toggle(connection_id, True, profile, org_id, project_id)
92
+
93
+
94
+ @saml_app.command("disable")
95
+ def saml_disable(connection_id: str, profile: str | None = typer.Option(None, "--profile"),
96
+ org_id: str | None = typer.Option(None, "--org"), project_id: str | None = typer.Option(None, "--project")) -> None:
97
+ """Turn a connection off (its users keep their accounts)."""
98
+ _toggle(connection_id, False, profile, org_id, project_id)
99
+
100
+
101
+ @saml_app.command("remove")
102
+ def saml_remove(
103
+ connection_id: str,
104
+ yes: bool = typer.Option(False, "--yes", "-y"),
105
+ profile: str | None = typer.Option(None, "--profile"),
106
+ org_id: str | None = typer.Option(None, "--org"),
107
+ project_id: str | None = typer.Option(None, "--project"),
108
+ ) -> None:
109
+ """Remove a connection: its domains can no longer sign in with SSO (accounts stay)."""
110
+ if not yes:
111
+ typer.confirm("Remove this SAML connection? Its users can no longer sign in with SSO.", abort=True)
112
+ _call("DELETE", f"/api/v1/auth/saml/connections/{connection_id}", "Removing…", profile=profile,
113
+ org_id=org_id, project_id=project_id)
114
+
115
+
41
116
  # ── SCIM ──────────────────────────────────────────────────────────────────────
42
117
 
43
118
 
@@ -99,7 +174,7 @@ def hooks_list(
99
174
 
100
175
  @hooks_app.command("set")
101
176
  def hooks_set(
102
- event: str = typer.Argument(..., help="before-create or before-sign-in"),
177
+ event: str = typer.Argument(..., help="before-create, before-sign-in, password-verification-attempt, mfa-verification-attempt, send-email or send-sms"),
103
178
  url: str | None = typer.Option(None, "--url", help="https webhook"),
104
179
  function: str | None = typer.Option(None, "--function", help="A project function's name"),
105
180
  fail: str = typer.Option("closed", "--fail", help="closed (refuse when the hook fails) or open (let it through)"),
@@ -130,7 +205,7 @@ def hooks_set(
130
205
 
131
206
  @hooks_app.command("test")
132
207
  def hooks_test(
133
- event: str = typer.Argument(..., help="before-create or before-sign-in"),
208
+ event: str = typer.Argument(..., help="before-create, before-sign-in, password-verification-attempt, mfa-verification-attempt, send-email or send-sms"),
134
209
  email: str | None = typer.Option(None, "--email"),
135
210
  profile: str | None = typer.Option(None, "--profile"),
136
211
  org_id: str | None = typer.Option(None, "--org"),
@@ -143,7 +218,7 @@ def hooks_test(
143
218
 
144
219
  @hooks_app.command("delete")
145
220
  def hooks_delete(
146
- event: str = typer.Argument(..., help="before-create or before-sign-in"),
221
+ event: str = typer.Argument(..., help="before-create, before-sign-in, password-verification-attempt, mfa-verification-attempt, send-email or send-sms"),
147
222
  yes: bool = typer.Option(False, "--yes", "-y"),
148
223
  profile: str | None = typer.Option(None, "--profile"),
149
224
  org_id: str | None = typer.Option(None, "--org"),
@@ -197,6 +197,47 @@ def set_roles(
197
197
  print_json(result)
198
198
 
199
199
 
200
+ @app.command("update")
201
+ def update_user(
202
+ user_id: str = typer.Argument(..., help="The app user's id"),
203
+ app_metadata: str | None = typer.Option(None, "--app-metadata", help='JSON object, e.g. \'{"tier": "gold"}\''),
204
+ user_metadata: str | None = typer.Option(None, "--user-metadata", help="JSON object"),
205
+ claims: str | None = typer.Option(None, "--claims", help="Custom claims (JSON object) in every token"),
206
+ email_verified: bool | None = typer.Option(None, "--email-verified/--email-unverified"),
207
+ profile: str | None = typer.Option(None, "--profile"),
208
+ org_id: str | None = typer.Option(None, "--org"),
209
+ project_id: str | None = typer.Option(None, "--project"),
210
+ env_id: str | None = typer.Option(None, "--env"),
211
+ ) -> None:
212
+ """Set an app user's app_metadata, user_metadata, custom claims or email_verified (null in JSON removes a key)."""
213
+ import json as _json
214
+
215
+ body: dict = {}
216
+ for key, raw in (("app_metadata", app_metadata), ("user_metadata", user_metadata), ("custom_claims", claims)):
217
+ if raw is not None:
218
+ try:
219
+ value = _json.loads(raw)
220
+ except ValueError as exc:
221
+ raise typer.BadParameter(f"--{key.replace('_', '-')} must be a JSON object") from exc
222
+ if not isinstance(value, dict):
223
+ raise typer.BadParameter(f"--{key.replace('_', '-')} must be a JSON object")
224
+ body[key] = value
225
+ if email_verified is not None:
226
+ body["email_verified"] = email_verified
227
+ if not body:
228
+ raise typer.BadParameter("Nothing to change")
229
+ try:
230
+ result = run_with_spinner(
231
+ "Updating the user…",
232
+ lambda: call_api(method="PATCH", path=_project_auth_path(profile, project_id, f"/users/{quote(user_id, safe='')}"),
233
+ profile=profile, require_scope=True, **_scope_args(org_id, project_id, env_id), body=body),
234
+ success="User updated",
235
+ )
236
+ except CLIRequestError as exc:
237
+ handle_cli_error(exc)
238
+ print_json(result)
239
+
240
+
200
241
  @app.command("create")
201
242
  def create_user(
202
243
  email: str = typer.Option(..., "--email"),