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.
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/PKG-INFO +6 -4
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/README.md +3 -1
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/__init__.py +1 -1
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/alerts.py +4 -1
- sovaria_cli-0.5.0/apps/cli/src/commands/analytics.py +225 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/auth_enterprise.py +82 -7
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/auth_product.py +41 -0
- sovaria_cli-0.5.0/apps/cli/src/commands/auth_settings.py +317 -0
- sovaria_cli-0.5.0/apps/cli/src/commands/branches.py +543 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/chrono.py +28 -15
- sovaria_cli-0.5.0/apps/cli/src/commands/compliance.py +128 -0
- sovaria_cli-0.5.0/apps/cli/src/commands/crashes.py +303 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/db.py +219 -5
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/deploy.py +61 -1
- sovaria_cli-0.5.0/apps/cli/src/commands/distribute.py +143 -0
- sovaria_cli-0.5.0/apps/cli/src/commands/experiments.py +126 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/fn_platform.py +298 -6
- sovaria_cli-0.5.0/apps/cli/src/commands/hosting.py +502 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/iam.py +3 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/knowledge.py +20 -0
- sovaria_cli-0.5.0/apps/cli/src/commands/local.py +221 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/messaging.py +72 -3
- sovaria_cli-0.5.0/apps/cli/src/commands/perf.py +109 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/project.py +8 -11
- sovaria_cli-0.5.0/apps/cli/src/commands/push.py +101 -0
- sovaria_cli-0.5.0/apps/cli/src/commands/queues.py +308 -0
- sovaria_cli-0.5.0/apps/cli/src/commands/remote_config.py +213 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/sim.py +94 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/simulate.py +2 -1
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/vector.py +76 -2
- sovaria_cli-0.5.0/apps/cli/src/local/docker-compose.yml +373 -0
- sovaria_cli-0.5.0/apps/cli/src/local/initdb/001_axis_tables.sql +48 -0
- sovaria_cli-0.5.0/apps/cli/src/local/initdb/002_axis_base_schema.sql +36 -0
- sovaria_cli-0.5.0/apps/cli/src/local/initdb/003_guard_policies.sql +58 -0
- sovaria_cli-0.5.0/apps/cli/src/local/initdb/010_local_stack.sql +7 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/main.py +46 -2
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/api_client.py +43 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/forge_local.py +213 -22
- sovaria_cli-0.5.0/apps/cli/src/utils/local_stack.py +504 -0
- sovaria_cli-0.5.0/apps/cli/src/utils/macho_symbols.py +71 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/project_context.py +10 -2
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/pyproject.toml +3 -3
- sovaria_cli-0.3.0/apps/cli/src/commands/branches.py +0 -240
- sovaria_cli-0.3.0/apps/cli/src/commands/crashes.py +0 -92
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/.gitignore +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/__init__.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/__init__.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/admin.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/ai_data.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/api.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/audit.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/billing.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/builds.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/collections.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/connect.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/context.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/dashboard.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/data.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/env.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/gen.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/governance.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/import_cmd.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/init_cmd.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/inspect.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/keys.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/link_cmd.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/login.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/logs.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/mcp.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/metrics.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/oidc_providers.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/org.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/org_key.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/passkeys.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/policy.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/realtime.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/rules.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/schema.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/scope.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/secrets.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/shell.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/storage.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/templates.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/commands/test_cmd.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/__init__.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/api_helpers.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/auth_store.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/config.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/display.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/importers.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/local_function_server.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/log_tail.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/migrations.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/mobile_types.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/output_mode.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/premium_display.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/schema_introspect.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/scope_picker.py +0 -0
- {sovaria_cli-0.3.0 → sovaria_cli-0.5.0}/apps/cli/src/utils/sim_offline.py +0 -0
- {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
|
+
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.
|
|
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.
|
|
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`)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"),
|