sovaria-cli 0.2.8__tar.gz → 0.2.11__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.2.8 → sovaria_cli-0.2.11}/.gitignore +2 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/PKG-INFO +12 -3
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/README.md +9 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/__init__.py +1 -1
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/api.py +10 -3
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/audit.py +9 -13
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/auth_product.py +24 -3
- sovaria_cli-0.2.11/apps/cli/src/commands/builds.py +128 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/chrono.py +50 -0
- sovaria_cli-0.2.11/apps/cli/src/commands/crashes.py +92 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/db.py +438 -22
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/deploy.py +53 -112
- sovaria_cli-0.2.11/apps/cli/src/commands/fn_platform.py +779 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/governance.py +86 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/iam.py +9 -3
- sovaria_cli-0.2.11/apps/cli/src/commands/import_cmd.py +450 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/login.py +55 -19
- sovaria_cli-0.2.11/apps/cli/src/commands/logs.py +197 -0
- sovaria_cli-0.2.11/apps/cli/src/commands/mcp.py +208 -0
- sovaria_cli-0.2.11/apps/cli/src/commands/messaging.py +200 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/org.py +9 -0
- sovaria_cli-0.2.11/apps/cli/src/commands/org_key.py +96 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/policy.py +42 -9
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/realtime.py +34 -0
- sovaria_cli-0.2.11/apps/cli/src/commands/rules.py +226 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/shell.py +16 -15
- sovaria_cli-0.2.11/apps/cli/src/commands/sim.py +296 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/storage.py +6 -1
- sovaria_cli-0.2.11/apps/cli/src/commands/templates.py +162 -0
- sovaria_cli-0.2.11/apps/cli/src/commands/vector.py +341 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/main.py +42 -3
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/api_client.py +20 -7
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/auth_store.py +25 -4
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/config.py +6 -5
- sovaria_cli-0.2.11/apps/cli/src/utils/forge_local.py +664 -0
- sovaria_cli-0.2.11/apps/cli/src/utils/importers.py +354 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/premium_display.py +8 -3
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/project_context.py +37 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/schema_introspect.py +1 -1
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/pyproject.toml +8 -3
- sovaria_cli-0.2.8/.github/dependabot.yml +0 -12
- sovaria_cli-0.2.8/.github/workflows/ci.yml +0 -174
- sovaria_cli-0.2.8/.github/workflows/publish.yml +0 -37
- sovaria_cli-0.2.8/.gitleaks.toml +0 -17
- sovaria_cli-0.2.8/.python-version +0 -1
- sovaria_cli-0.2.8/Makefile +0 -44
- sovaria_cli-0.2.8/apps/cli/src/commands/logs.py +0 -103
- sovaria_cli-0.2.8/apps/cli/src/commands/mcp.py +0 -79
- sovaria_cli-0.2.8/apps/cli/src/commands/sim.py +0 -216
- sovaria_cli-0.2.8/apps/cli/src/commands/vector.py +0 -207
- sovaria_cli-0.2.8/apps/cli/tests/test_aug18_cli_tickets.py +0 -450
- sovaria_cli-0.2.8/apps/cli/tests/test_auth_store_profiles.py +0 -103
- sovaria_cli-0.2.8/apps/cli/tests/test_cli_security.py +0 -110
- sovaria_cli-0.2.8/apps/cli/tests/test_cli_smoke_paths.py +0 -301
- sovaria_cli-0.2.8/apps/cli/tests/test_config_profiles.py +0 -82
- sovaria_cli-0.2.8/apps/cli/tests/test_credential_file_permissions.py +0 -29
- sovaria_cli-0.2.8/apps/cli/tests/test_main_startup.py +0 -29
- sovaria_cli-0.2.8/apps/cli/tests/test_mcp_command.py +0 -31
- sovaria_cli-0.2.8/apps/cli/tests/test_phase4_commands.py +0 -91
- sovaria_cli-0.2.8/apps/cli/tests/test_phase5_commands.py +0 -97
- sovaria_cli-0.2.8/apps/cli/tests/test_phase6_commands.py +0 -133
- sovaria_cli-0.2.8/apps/cli/tests/test_premium_display.py +0 -132
- sovaria_cli-0.2.8/apps/cli/tests/test_project_and_commands.py +0 -257
- sovaria_cli-0.2.8/apps/cli/tests/test_scope_picker.py +0 -17
- sovaria_cli-0.2.8/apps/cli/tests/test_supabase_style_commands.py +0 -63
- sovaria_cli-0.2.8/pytest.ini +0 -3
- sovaria_cli-0.2.8/requirements-dev.txt +0 -4
- sovaria_cli-0.2.8/requirements.txt +0 -9
- sovaria_cli-0.2.8/scripts/cli_sdk_happy_path.sh +0 -163
- sovaria_cli-0.2.8/scripts/smoke_all_commands.sh +0 -107
- sovaria_cli-0.2.8/scripts/smoke_chunks.sh +0 -90
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/__init__.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/__init__.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/admin.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/billing.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/collections.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/connect.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/context.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/dashboard.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/data.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/gen.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/init_cmd.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/inspect.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/keys.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/link_cmd.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/oidc_providers.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/passkeys.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/project.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/schema.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/scope.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/secrets.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/test_cmd.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/__init__.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/api_helpers.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/display.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/local_function_server.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/log_tail.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/migrations.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/output_mode.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/scope_picker.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/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.2.
|
|
3
|
+
Version: 0.2.11
|
|
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.4.
|
|
15
|
+
Requires-Dist: sovaria-sdk==0.4.6
|
|
16
16
|
Requires-Dist: tomli-w>=1.2.0
|
|
17
17
|
Requires-Dist: typer>=0.17.4
|
|
18
18
|
Provides-Extra: dev
|
|
@@ -22,13 +22,22 @@ 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.4.
|
|
25
|
+
Requires-Dist: sovaria-sdk[mcp]==0.4.6; extra == 'mcp'
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
|
|
28
28
|
# Sovaria CLI
|
|
29
29
|
|
|
30
30
|
Operator CLI for gateway-backed Sovaria project and platform workflows.
|
|
31
31
|
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install -g @sovaria/cli # or: npx @sovaria/cli --help
|
|
36
|
+
pip install sovaria-cli # same CLI from PyPI
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Both need Python 3.11+. The npm package sets up a private environment in `~/.sovaria/npm-cli` on first run.
|
|
40
|
+
|
|
32
41
|
## Run
|
|
33
42
|
|
|
34
43
|
Examples below use `sov ...` command syntax.
|
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Operator CLI for gateway-backed Sovaria project and platform workflows.
|
|
4
4
|
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @sovaria/cli # or: npx @sovaria/cli --help
|
|
9
|
+
pip install sovaria-cli # same CLI from PyPI
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Both need Python 3.11+. The npm package sets up a private environment in `~/.sovaria/npm-cli` on first run.
|
|
13
|
+
|
|
5
14
|
## Run
|
|
6
15
|
|
|
7
16
|
Examples below use `sov ...` command syntax.
|
|
@@ -13,7 +13,7 @@ app = typer.Typer(help="Raw gateway API calls")
|
|
|
13
13
|
@app.command("call")
|
|
14
14
|
def call_command(
|
|
15
15
|
method: str = typer.Argument(..., help="HTTP method, e.g. GET/POST"),
|
|
16
|
-
path: str = typer.Argument(..., help="Gateway path, e.g. /api/v1/
|
|
16
|
+
path: str = typer.Argument(..., help="Gateway path, e.g. /api/v1/auth/me"),
|
|
17
17
|
body: str | None = typer.Option(None, "--body", help="JSON request body"),
|
|
18
18
|
query: str | None = typer.Option(None, "--query", help="JSON object query params"),
|
|
19
19
|
profile: str | None = typer.Option(None, "--profile"),
|
|
@@ -30,12 +30,19 @@ def call_command(
|
|
|
30
30
|
if parsed_query is not None and not isinstance(parsed_query, dict):
|
|
31
31
|
raise CLIRequestError("--query must be a JSON object")
|
|
32
32
|
|
|
33
|
+
# A query string written in the path (`/x?limit=4`) is kept: the HTTP client would
|
|
34
|
+
# otherwise replace it with --query's params.
|
|
35
|
+
from urllib.parse import parse_qsl
|
|
36
|
+
|
|
37
|
+
bare_path, _, inline = path.partition("?")
|
|
38
|
+
params = dict(parse_qsl(inline, keep_blank_values=True))
|
|
39
|
+
params.update({str(k): str(v) for k, v in (parsed_query or {}).items()})
|
|
33
40
|
result = call_api(
|
|
34
41
|
method=method,
|
|
35
|
-
path=
|
|
42
|
+
path=bare_path,
|
|
36
43
|
profile=profile,
|
|
37
44
|
body=parsed_body,
|
|
38
|
-
params=
|
|
45
|
+
params=params,
|
|
39
46
|
require_auth=not no_auth,
|
|
40
47
|
require_scope=require_scope,
|
|
41
48
|
org_id=org_id,
|
|
@@ -122,20 +122,16 @@ def sim_run(
|
|
|
122
122
|
if parsed_context is not None and not isinstance(parsed_context, dict):
|
|
123
123
|
raise CLIRequestError("--context must be a JSON object")
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
# The name/operations are accepted for older scripts; the run covers the whole project
|
|
126
|
+
# (started in the background and polled — a full run outlasts one request).
|
|
127
|
+
from apps.cli.src.commands.sim import run_simulation_api
|
|
128
|
+
from apps.cli.src.utils.display import print_json
|
|
129
|
+
from apps.cli.src.utils.premium_display import run_with_spinner
|
|
130
|
+
|
|
131
|
+
print_json(
|
|
132
|
+
run_with_spinner(f"Running simulation '{simulation_name}'…", lambda: run_simulation_api(profile=profile),
|
|
133
|
+
success="Completed"),
|
|
128
134
|
title="Simulation",
|
|
129
|
-
method="POST",
|
|
130
|
-
path="/api/v1/sim/run",
|
|
131
|
-
profile=profile,
|
|
132
|
-
body={
|
|
133
|
-
"simulation_name": simulation_name,
|
|
134
|
-
"operations": parsed_ops,
|
|
135
|
-
"echo_flag": True,
|
|
136
|
-
"include_execution_tree": include_execution_tree,
|
|
137
|
-
"context": parsed_context or {},
|
|
138
|
-
},
|
|
139
135
|
)
|
|
140
136
|
|
|
141
137
|
|
|
@@ -59,27 +59,48 @@ def _project_auth_path(profile: str | None, project_id: str | None, suffix: str)
|
|
|
59
59
|
|
|
60
60
|
@app.command("users")
|
|
61
61
|
def list_users(
|
|
62
|
+
search: str | None = typer.Option(None, "--search", "-s", help="Part of an email, or an exact user id"),
|
|
63
|
+
status: str | None = typer.Option(None, "--status", help="active or banned"),
|
|
64
|
+
limit: int = typer.Option(50, "--limit", min=1, max=500),
|
|
65
|
+
cursor: str | None = typer.Option(None, "--cursor", help="next_cursor from the previous page"),
|
|
62
66
|
profile: str | None = typer.Option(None, "--profile"),
|
|
63
67
|
org_id: str | None = typer.Option(None, "--org"),
|
|
64
68
|
project_id: str | None = typer.Option(None, "--project"),
|
|
65
69
|
env_id: str | None = typer.Option(None, "--env"),
|
|
66
70
|
) -> None:
|
|
67
|
-
"""
|
|
71
|
+
"""One page of app users (newest first). Search and status filter on the server."""
|
|
72
|
+
if status and status not in ("active", "banned"):
|
|
73
|
+
handle_cli_error(CLIRequestError("--status must be 'active' or 'banned'"))
|
|
74
|
+
params = {"limit": str(limit)}
|
|
75
|
+
if search:
|
|
76
|
+
params["q"] = search
|
|
77
|
+
if status:
|
|
78
|
+
params["status"] = status
|
|
79
|
+
if cursor:
|
|
80
|
+
params["cursor"] = cursor
|
|
81
|
+
paging: dict[str, str] = {}
|
|
68
82
|
try:
|
|
69
|
-
|
|
83
|
+
users = run_with_spinner(
|
|
70
84
|
"Loading app users…",
|
|
71
85
|
lambda: call_api(
|
|
72
86
|
method="GET",
|
|
73
87
|
path=_project_auth_path(profile, project_id, "/users"),
|
|
74
88
|
profile=profile,
|
|
89
|
+
params=params,
|
|
75
90
|
require_scope=True,
|
|
91
|
+
page_headers=paging,
|
|
76
92
|
**_scope_args(org_id, project_id, env_id),
|
|
77
93
|
),
|
|
78
94
|
success="Users loaded",
|
|
79
95
|
)
|
|
80
96
|
except CLIRequestError as exc:
|
|
81
97
|
handle_cli_error(exc)
|
|
82
|
-
|
|
98
|
+
total = paging.get("x-total-count")
|
|
99
|
+
print_json(
|
|
100
|
+
{"users": users, "total": int(total) if total and total.isdigit() else None,
|
|
101
|
+
"next_cursor": paging.get("x-next-cursor")},
|
|
102
|
+
title="App users",
|
|
103
|
+
)
|
|
83
104
|
|
|
84
105
|
|
|
85
106
|
@app.command("ban")
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""App builds (plan D3): check a build (schema, access rules, functions, rules tests) against the project and open
|
|
2
|
+
it as a pull request on the linked GitHub repository. Sovariq writes builds from a description; you can also
|
|
3
|
+
write one by hand as JSON or YAML::
|
|
4
|
+
|
|
5
|
+
title: Recipe sharing
|
|
6
|
+
description: People share recipes; authors edit their own.
|
|
7
|
+
migration:
|
|
8
|
+
name: recipes
|
|
9
|
+
up_sql: |
|
|
10
|
+
create table recipes (id uuid primary key default gen_random_uuid(), author_id uuid not null default auth.uid(), title text not null);
|
|
11
|
+
alter table recipes enable row level security;
|
|
12
|
+
alter table recipes force row level security;
|
|
13
|
+
create policy read_all on recipes for select using (auth.uid() is not null);
|
|
14
|
+
create policy own on recipes for all using (author_id = auth.uid()) with check (author_id = auth.uid());
|
|
15
|
+
down_sql: drop table recipes;
|
|
16
|
+
policies: [] # [{id, spl}] access rules (Guard), e.g. for functions app users call
|
|
17
|
+
functions: [] # [{name, runtime: python3|node, files: {main.py: ...}}]
|
|
18
|
+
tests: [] # rules test cases: {name, as, do, on, expect}
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from urllib.parse import quote
|
|
26
|
+
|
|
27
|
+
import typer
|
|
28
|
+
import yaml
|
|
29
|
+
|
|
30
|
+
from apps.cli.src.utils.api_client import CLIRequestError, call_api
|
|
31
|
+
from apps.cli.src.utils.display import handle_cli_error, print_json, print_success, print_warning
|
|
32
|
+
from apps.cli.src.utils.premium_display import run_with_spinner
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(help="App builds: check schema, rules, functions and tests, then open them as a pull request")
|
|
35
|
+
|
|
36
|
+
_ORG = typer.Option(None, "--org")
|
|
37
|
+
_PROJECT = typer.Option(None, "--project")
|
|
38
|
+
_ENV = typer.Option(None, "--env")
|
|
39
|
+
_PROFILE = typer.Option(None, "--profile")
|
|
40
|
+
_MARK = {"passed": "PASS", "warning": "WARN", "skipped": "SKIP", "failed": "FAIL"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _call(method: str, path: str, *, profile, org_id, project_id, env_id, body=None, params=None):
|
|
44
|
+
try:
|
|
45
|
+
return call_api(method=method, path=path, profile=profile, org_id=org_id, project_id=project_id, env_id=env_id,
|
|
46
|
+
require_scope=True, body=body, params=params)
|
|
47
|
+
except CLIRequestError as exc:
|
|
48
|
+
handle_cli_error(exc)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _print_checks(checks: list[dict]) -> None:
|
|
52
|
+
for c in checks:
|
|
53
|
+
typer.echo(f"{_MARK.get(c.get('status'), '?'):<5} {c.get('name')}: {c.get('detail', '')}")
|
|
54
|
+
for case in c.get("cases") or []:
|
|
55
|
+
if not case.get("passed"):
|
|
56
|
+
typer.echo(f" ✗ {case['name']}: expected {case['expect']}, got {case['got']} ({case.get('reason')})")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command("check")
|
|
60
|
+
def check(
|
|
61
|
+
path: Path = typer.Argument(..., help="Build file (.json, .yaml or .yml)"),
|
|
62
|
+
profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT, env_id: str | None = _ENV,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Check a build against the project — nothing changes. Exit 1 when a check fails."""
|
|
65
|
+
try:
|
|
66
|
+
raw = path.read_text(encoding="utf-8")
|
|
67
|
+
build = json.loads(raw) if path.suffix == ".json" else yaml.safe_load(raw)
|
|
68
|
+
except (OSError, ValueError, yaml.YAMLError) as exc:
|
|
69
|
+
print_warning(f"{path}: {exc}")
|
|
70
|
+
raise typer.Exit(2) from exc
|
|
71
|
+
out = run_with_spinner("Checking the build…", lambda: _call("POST", "/api/v1/fn/builds/check", body={"build": build},
|
|
72
|
+
profile=profile, org_id=org_id, project_id=project_id,
|
|
73
|
+
env_id=env_id)) or {}
|
|
74
|
+
_print_checks(out.get("checks") or [])
|
|
75
|
+
typer.echo(f"\nBuild {out.get('id')}: {len(out.get('files') or [])} file(s)")
|
|
76
|
+
for f in out.get("files") or []:
|
|
77
|
+
typer.echo(f" {f['path']}")
|
|
78
|
+
if not out.get("ok"):
|
|
79
|
+
print_warning("A check failed: fix the build and check again")
|
|
80
|
+
raise typer.Exit(1)
|
|
81
|
+
if out.get("can_open_pull_request"):
|
|
82
|
+
print_success(f"Ready. Open it as a pull request on {out.get('repository')}: sov builds pr {out.get('id')}")
|
|
83
|
+
else:
|
|
84
|
+
print_success("Ready. Link a GitHub repository (Functions › GitHub) to open it as a pull request.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command("pr")
|
|
88
|
+
def pull_request(
|
|
89
|
+
build_id: str = typer.Argument(..., help="Build id from sov builds check"),
|
|
90
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Don't ask"),
|
|
91
|
+
token: str | None = typer.Option(None, "--github-token", envvar="GITHUB_TOKEN",
|
|
92
|
+
help="A GitHub token with repo scope instead of the project's GitHub connection"),
|
|
93
|
+
profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT, env_id: str | None = _ENV,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Check the build again and open it as a pull request on the linked repository."""
|
|
96
|
+
if not yes and not typer.confirm(f"Open build {build_id} as a pull request?", default=False):
|
|
97
|
+
raise typer.Exit(1)
|
|
98
|
+
out = run_with_spinner("Opening the pull request…", lambda: _call(
|
|
99
|
+
"POST", f"/api/v1/fn/builds/{quote(build_id, safe='')}/pull-request", body={"token": token} if token else {},
|
|
100
|
+
profile=profile, org_id=org_id, project_id=project_id, env_id=env_id)) or {}
|
|
101
|
+
pr = out.get("pull_request") or {}
|
|
102
|
+
print_success(f"Pull request: {pr.get('url')}" + (" (already open)" if out.get("already_open") else ""))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command("list")
|
|
106
|
+
def list_builds(profile: str | None = _PROFILE, org_id: str | None = _ORG, project_id: str | None = _PROJECT,
|
|
107
|
+
env_id: str | None = _ENV) -> None:
|
|
108
|
+
"""Recent builds of the project."""
|
|
109
|
+
out = _call("GET", "/api/v1/fn/builds", profile=profile, org_id=org_id, project_id=project_id, env_id=env_id) or {}
|
|
110
|
+
for b in out.get("builds") or []:
|
|
111
|
+
typer.echo(f"{b['id']} {b['status']:<10} {b['title']} {b.get('pr_url') or ''}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command("show")
|
|
115
|
+
def show(build_id: str = typer.Argument(...), profile: str | None = _PROFILE, org_id: str | None = _ORG,
|
|
116
|
+
project_id: str | None = _PROJECT, env_id: str | None = _ENV) -> None:
|
|
117
|
+
"""One build: its files, checks and pull request."""
|
|
118
|
+
print_json(_call("GET", f"/api/v1/fn/builds/{quote(build_id, safe='')}", profile=profile, org_id=org_id,
|
|
119
|
+
project_id=project_id, env_id=env_id))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command("delete")
|
|
123
|
+
def delete(build_id: str = typer.Argument(...), profile: str | None = _PROFILE, org_id: str | None = _ORG,
|
|
124
|
+
project_id: str | None = _PROJECT, env_id: str | None = _ENV) -> None:
|
|
125
|
+
"""Forget a build (an opened pull request stays on GitHub)."""
|
|
126
|
+
_call("DELETE", f"/api/v1/fn/builds/{quote(build_id, safe='')}", profile=profile, org_id=org_id,
|
|
127
|
+
project_id=project_id, env_id=env_id)
|
|
128
|
+
print_success(f"Build {build_id} deleted")
|
|
@@ -7,6 +7,7 @@ from urllib.parse import quote
|
|
|
7
7
|
import typer
|
|
8
8
|
|
|
9
9
|
from apps.cli.src.utils.api_helpers import premium_api, premium_mutate
|
|
10
|
+
from apps.cli.src.utils.display import print_json
|
|
10
11
|
|
|
11
12
|
app = typer.Typer(help="Chrono timeline and snapshot operations")
|
|
12
13
|
|
|
@@ -230,3 +231,52 @@ def snapshot_restore(
|
|
|
230
231
|
path=f"/api/v1/chrono/snapshots/{snapshot_id}/restore",
|
|
231
232
|
profile=profile,
|
|
232
233
|
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@app.command("state")
|
|
237
|
+
def state(
|
|
238
|
+
at: str | None = typer.Option(None, "--at", help="Commit id or ISO time (default: now)"),
|
|
239
|
+
prefix: str = typer.Option("", "--prefix", help="Only this folder: policies/, schema/, functions/, settings/, storage/, keys/"),
|
|
240
|
+
timeline: str = typer.Option("main", "--timeline"),
|
|
241
|
+
names_only: bool = typer.Option(False, "--names-only", help="List files without their contents"),
|
|
242
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
243
|
+
) -> None:
|
|
244
|
+
"""The project's configuration as it was at a point in time (policies, schema, functions, settings…)."""
|
|
245
|
+
params = {"timeline": timeline, "prefix": prefix, "contents": "false" if names_only else "true"}
|
|
246
|
+
if at:
|
|
247
|
+
params["at"] = at
|
|
248
|
+
premium_api("Reading the timeline…", title=f"State {'at ' + at if at else 'now'}", method="GET",
|
|
249
|
+
path="/api/v1/chrono/state", profile=profile, params=params, require_scope=True)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@app.command("restore")
|
|
253
|
+
def restore(
|
|
254
|
+
at: str = typer.Argument(..., help="Commit id or ISO time to go back to"),
|
|
255
|
+
timeline: str = typer.Option("main", "--timeline"),
|
|
256
|
+
yes: bool = typer.Option(False, "--yes", help="Apply without asking (after showing the plan)"),
|
|
257
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Go back to an earlier point: shows what changes back (and who would gain or lose access), then applies it."""
|
|
260
|
+
from apps.cli.src.utils.output_mode import wants_json
|
|
261
|
+
from apps.cli.src.utils.premium_display import _console
|
|
262
|
+
|
|
263
|
+
plan = premium_api("Comparing then with now…", print=False, method="POST", path="/api/v1/chrono/restore-plan",
|
|
264
|
+
profile=profile, body={"at": at, "timeline": timeline}, require_scope=True)
|
|
265
|
+
if wants_json():
|
|
266
|
+
print_json(plan)
|
|
267
|
+
actions = plan.get("actions") or [] if isinstance(plan, dict) else []
|
|
268
|
+
if not actions:
|
|
269
|
+
_console.print("Nothing has changed since then.")
|
|
270
|
+
return
|
|
271
|
+
for action in actions:
|
|
272
|
+
mark = "[yellow]manual[/yellow]" if action.get("manual") else "[green]auto[/green] "
|
|
273
|
+
_console.print(f" {mark} {action.get('path')}" + (f" [dim]{action.get('reason')}[/dim]" if action.get("reason") else ""))
|
|
274
|
+
for v in ((plan.get("preview") or {}).get("violations") or [])[:10]:
|
|
275
|
+
_console.print(f" [red]{str(v.get('severity', '')).upper()}[/red] {v.get('reason')}")
|
|
276
|
+
if not plan.get("automated"):
|
|
277
|
+
_console.print("Nothing can be put back automatically.")
|
|
278
|
+
return
|
|
279
|
+
if not yes and not typer.confirm(f"Put back {plan.get('automated')} change(s)?", default=False):
|
|
280
|
+
raise typer.Exit(code=1)
|
|
281
|
+
premium_mutate("Going back…", success="Done", title="Restore", method="POST", path="/api/v1/chrono/restore",
|
|
282
|
+
profile=profile, body={"at": at, "timeline": timeline, "confirm": True}, require_scope=True)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""`sov crashes`: app crash reports (sov.crashes in the SDK), grouped into issues.
|
|
2
|
+
|
|
3
|
+
sov crashes list [--status open|resolved|ignored]
|
|
4
|
+
sov crashes show <fingerprint>
|
|
5
|
+
sov crashes resolve <fingerprint> (reopens by itself if it happens again)
|
|
6
|
+
sov crashes ignore <fingerprint>
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from apps.cli.src.utils.api_client import CLIRequestError, call_api
|
|
16
|
+
from apps.cli.src.utils.display import handle_cli_error, print_json, print_success
|
|
17
|
+
from apps.cli.src.utils.output_mode import wants_json
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(help="App crash reports grouped into issues (from sov.crashes in your app)")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _opts(profile, org_id, project_id, env_id) -> dict:
|
|
23
|
+
return {"profile": profile, "org_id": org_id, "project_id": project_id, "env_id": env_id, "require_scope": True}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command("list")
|
|
27
|
+
def crashes_list(
|
|
28
|
+
status: str | None = typer.Option(None, "--status", help="open | resolved | ignored"),
|
|
29
|
+
limit: int = typer.Option(50, "--limit", min=1, max=200),
|
|
30
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
31
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
32
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
33
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Crash issues, most recent first."""
|
|
36
|
+
if status and status not in ("open", "resolved", "ignored"):
|
|
37
|
+
raise typer.BadParameter("--status must be open, resolved or ignored")
|
|
38
|
+
params = {"limit": str(limit), **({"status": status} if status else {})}
|
|
39
|
+
try:
|
|
40
|
+
data = call_api(method="GET", path="/api/v1/crashes/issues", params=params, **_opts(profile, org_id, project_id, env_id))
|
|
41
|
+
except CLIRequestError as exc:
|
|
42
|
+
handle_cli_error(exc)
|
|
43
|
+
return
|
|
44
|
+
if wants_json():
|
|
45
|
+
print_json(data)
|
|
46
|
+
return
|
|
47
|
+
rows = [{"issue": i["fingerprint"][:12], "title": i["title"][:70], "events": i["events"], "users": i["users"],
|
|
48
|
+
"last seen": i["last_seen"][:19], "status": i["status"]} for i in data.get("issues", [])]
|
|
49
|
+
print_json(rows, title=f"Crash issues ({len(rows)})")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("show")
|
|
53
|
+
def crashes_show(
|
|
54
|
+
fingerprint: str,
|
|
55
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
56
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
57
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
58
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
59
|
+
) -> None:
|
|
60
|
+
"""One issue: stack, breadcrumbs, device and release of the latest report."""
|
|
61
|
+
try:
|
|
62
|
+
data = call_api(method="GET", path=f"/api/v1/crashes/issues/{quote(fingerprint, safe='')}", **_opts(profile, org_id, project_id, env_id))
|
|
63
|
+
except CLIRequestError as exc:
|
|
64
|
+
handle_cli_error(exc)
|
|
65
|
+
return
|
|
66
|
+
print_json(data)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _status(fingerprint: str, status: str, profile, org_id, project_id, env_id) -> None:
|
|
70
|
+
try:
|
|
71
|
+
call_api(method="POST", path=f"/api/v1/crashes/issues/{quote(fingerprint, safe='')}/status", body={"status": status},
|
|
72
|
+
**_opts(profile, org_id, project_id, env_id))
|
|
73
|
+
except CLIRequestError as exc:
|
|
74
|
+
handle_cli_error(exc)
|
|
75
|
+
return
|
|
76
|
+
print_success(f"Issue {fingerprint[:12]} {status}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command("resolve")
|
|
80
|
+
def crashes_resolve(fingerprint: str, profile: str | None = typer.Option(None, "--profile"),
|
|
81
|
+
org_id: str | None = typer.Option(None, "--org"), project_id: str | None = typer.Option(None, "--project"),
|
|
82
|
+
env_id: str | None = typer.Option(None, "--env")) -> None:
|
|
83
|
+
"""Mark resolved (it reopens if the crash happens again)."""
|
|
84
|
+
_status(fingerprint, "resolved", profile, org_id, project_id, env_id)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command("ignore")
|
|
88
|
+
def crashes_ignore(fingerprint: 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"),
|
|
90
|
+
env_id: str | None = typer.Option(None, "--env")) -> None:
|
|
91
|
+
"""Hide an issue you won't fix."""
|
|
92
|
+
_status(fingerprint, "ignored", profile, org_id, project_id, env_id)
|