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.
Files changed (101) hide show
  1. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/.gitignore +2 -0
  2. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/PKG-INFO +12 -3
  3. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/README.md +9 -0
  4. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/__init__.py +1 -1
  5. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/api.py +10 -3
  6. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/audit.py +9 -13
  7. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/auth_product.py +24 -3
  8. sovaria_cli-0.2.11/apps/cli/src/commands/builds.py +128 -0
  9. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/chrono.py +50 -0
  10. sovaria_cli-0.2.11/apps/cli/src/commands/crashes.py +92 -0
  11. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/db.py +438 -22
  12. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/deploy.py +53 -112
  13. sovaria_cli-0.2.11/apps/cli/src/commands/fn_platform.py +779 -0
  14. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/governance.py +86 -0
  15. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/iam.py +9 -3
  16. sovaria_cli-0.2.11/apps/cli/src/commands/import_cmd.py +450 -0
  17. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/login.py +55 -19
  18. sovaria_cli-0.2.11/apps/cli/src/commands/logs.py +197 -0
  19. sovaria_cli-0.2.11/apps/cli/src/commands/mcp.py +208 -0
  20. sovaria_cli-0.2.11/apps/cli/src/commands/messaging.py +200 -0
  21. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/org.py +9 -0
  22. sovaria_cli-0.2.11/apps/cli/src/commands/org_key.py +96 -0
  23. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/policy.py +42 -9
  24. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/realtime.py +34 -0
  25. sovaria_cli-0.2.11/apps/cli/src/commands/rules.py +226 -0
  26. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/shell.py +16 -15
  27. sovaria_cli-0.2.11/apps/cli/src/commands/sim.py +296 -0
  28. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/storage.py +6 -1
  29. sovaria_cli-0.2.11/apps/cli/src/commands/templates.py +162 -0
  30. sovaria_cli-0.2.11/apps/cli/src/commands/vector.py +341 -0
  31. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/main.py +42 -3
  32. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/api_client.py +20 -7
  33. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/auth_store.py +25 -4
  34. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/config.py +6 -5
  35. sovaria_cli-0.2.11/apps/cli/src/utils/forge_local.py +664 -0
  36. sovaria_cli-0.2.11/apps/cli/src/utils/importers.py +354 -0
  37. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/premium_display.py +8 -3
  38. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/project_context.py +37 -0
  39. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/schema_introspect.py +1 -1
  40. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/pyproject.toml +8 -3
  41. sovaria_cli-0.2.8/.github/dependabot.yml +0 -12
  42. sovaria_cli-0.2.8/.github/workflows/ci.yml +0 -174
  43. sovaria_cli-0.2.8/.github/workflows/publish.yml +0 -37
  44. sovaria_cli-0.2.8/.gitleaks.toml +0 -17
  45. sovaria_cli-0.2.8/.python-version +0 -1
  46. sovaria_cli-0.2.8/Makefile +0 -44
  47. sovaria_cli-0.2.8/apps/cli/src/commands/logs.py +0 -103
  48. sovaria_cli-0.2.8/apps/cli/src/commands/mcp.py +0 -79
  49. sovaria_cli-0.2.8/apps/cli/src/commands/sim.py +0 -216
  50. sovaria_cli-0.2.8/apps/cli/src/commands/vector.py +0 -207
  51. sovaria_cli-0.2.8/apps/cli/tests/test_aug18_cli_tickets.py +0 -450
  52. sovaria_cli-0.2.8/apps/cli/tests/test_auth_store_profiles.py +0 -103
  53. sovaria_cli-0.2.8/apps/cli/tests/test_cli_security.py +0 -110
  54. sovaria_cli-0.2.8/apps/cli/tests/test_cli_smoke_paths.py +0 -301
  55. sovaria_cli-0.2.8/apps/cli/tests/test_config_profiles.py +0 -82
  56. sovaria_cli-0.2.8/apps/cli/tests/test_credential_file_permissions.py +0 -29
  57. sovaria_cli-0.2.8/apps/cli/tests/test_main_startup.py +0 -29
  58. sovaria_cli-0.2.8/apps/cli/tests/test_mcp_command.py +0 -31
  59. sovaria_cli-0.2.8/apps/cli/tests/test_phase4_commands.py +0 -91
  60. sovaria_cli-0.2.8/apps/cli/tests/test_phase5_commands.py +0 -97
  61. sovaria_cli-0.2.8/apps/cli/tests/test_phase6_commands.py +0 -133
  62. sovaria_cli-0.2.8/apps/cli/tests/test_premium_display.py +0 -132
  63. sovaria_cli-0.2.8/apps/cli/tests/test_project_and_commands.py +0 -257
  64. sovaria_cli-0.2.8/apps/cli/tests/test_scope_picker.py +0 -17
  65. sovaria_cli-0.2.8/apps/cli/tests/test_supabase_style_commands.py +0 -63
  66. sovaria_cli-0.2.8/pytest.ini +0 -3
  67. sovaria_cli-0.2.8/requirements-dev.txt +0 -4
  68. sovaria_cli-0.2.8/requirements.txt +0 -9
  69. sovaria_cli-0.2.8/scripts/cli_sdk_happy_path.sh +0 -163
  70. sovaria_cli-0.2.8/scripts/smoke_all_commands.sh +0 -107
  71. sovaria_cli-0.2.8/scripts/smoke_chunks.sh +0 -90
  72. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/__init__.py +0 -0
  73. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/__init__.py +0 -0
  74. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/admin.py +0 -0
  75. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/billing.py +0 -0
  76. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/collections.py +0 -0
  77. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/connect.py +0 -0
  78. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/context.py +0 -0
  79. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/dashboard.py +0 -0
  80. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/data.py +0 -0
  81. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/gen.py +0 -0
  82. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/init_cmd.py +0 -0
  83. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/inspect.py +0 -0
  84. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/keys.py +0 -0
  85. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/link_cmd.py +0 -0
  86. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/oidc_providers.py +0 -0
  87. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/passkeys.py +0 -0
  88. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/project.py +0 -0
  89. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/schema.py +0 -0
  90. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/scope.py +0 -0
  91. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/secrets.py +0 -0
  92. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/commands/test_cmd.py +0 -0
  93. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/__init__.py +0 -0
  94. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/api_helpers.py +0 -0
  95. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/display.py +0 -0
  96. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/local_function_server.py +0 -0
  97. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/log_tail.py +0 -0
  98. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/migrations.py +0 -0
  99. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/output_mode.py +0 -0
  100. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/scope_picker.py +0 -0
  101. {sovaria_cli-0.2.8 → sovaria_cli-0.2.11}/apps/cli/src/utils/theme.py +0 -0
@@ -8,3 +8,5 @@ dist/
8
8
  .env.*
9
9
  !.env.example
10
10
  !.env.*.example
11
+
12
+ .venv/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sovaria-cli
3
- Version: 0.2.8
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.0
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.0; extra == 'mcp'
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.
@@ -1,3 +1,3 @@
1
1
  """Sovaria CLI — sov command."""
2
2
 
3
- __version__ = "0.2.8"
3
+ __version__ = "0.2.11"
@@ -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/system/engines/health"),
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=path,
42
+ path=bare_path,
36
43
  profile=profile,
37
44
  body=parsed_body,
38
- params={str(k): str(v) for k, v in (parsed_query or {}).items()},
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
- premium_api(
126
- f"Running simulation '{simulation_name}'…",
127
- success="Completed",
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
- """List product users for the resolved org/project scope."""
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
- result = run_with_spinner(
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
- print_json(result, title="App users")
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)