sovaria-cli 0.2.8__tar.gz → 0.2.9__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.9}/PKG-INFO +3 -3
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/__init__.py +1 -1
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/db.py +84 -20
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/login.py +48 -12
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/org.py +5 -0
- sovaria_cli-0.2.9/apps/cli/tests/test_db_migrations.py +62 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_main_startup.py +1 -1
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/pyproject.toml +3 -3
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/requirements.txt +1 -1
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/.github/dependabot.yml +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/.github/workflows/ci.yml +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/.github/workflows/publish.yml +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/.gitignore +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/.gitleaks.toml +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/.python-version +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/Makefile +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/README.md +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/__init__.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/__init__.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/admin.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/api.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/audit.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/auth_product.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/billing.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/chrono.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/collections.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/connect.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/context.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/dashboard.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/data.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/deploy.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/gen.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/governance.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/iam.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/init_cmd.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/inspect.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/keys.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/link_cmd.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/logs.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/mcp.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/oidc_providers.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/passkeys.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/policy.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/project.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/realtime.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/schema.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/scope.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/secrets.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/shell.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/sim.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/storage.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/test_cmd.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/vector.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/main.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/__init__.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/api_client.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/api_helpers.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/auth_store.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/config.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/display.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/local_function_server.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/log_tail.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/migrations.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/output_mode.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/premium_display.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/project_context.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/schema_introspect.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/scope_picker.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/theme.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_aug18_cli_tickets.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_auth_store_profiles.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_cli_security.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_cli_smoke_paths.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_config_profiles.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_credential_file_permissions.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_mcp_command.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_phase4_commands.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_phase5_commands.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_phase6_commands.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_premium_display.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_project_and_commands.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_scope_picker.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_supabase_style_commands.py +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/pytest.ini +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/requirements-dev.txt +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/scripts/cli_sdk_happy_path.sh +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/scripts/smoke_all_commands.sh +0 -0
- {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/scripts/smoke_chunks.sh +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.9
|
|
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.1
|
|
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.4.
|
|
25
|
+
Requires-Dist: sovaria-sdk[mcp]==0.4.1; extra == 'mcp'
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
|
|
28
28
|
# Sovaria CLI
|
|
@@ -612,6 +612,34 @@ def migrations_list(
|
|
|
612
612
|
print_json(result)
|
|
613
613
|
|
|
614
614
|
|
|
615
|
+
def _split_migration(path: Path) -> tuple[str, str | None]:
|
|
616
|
+
"""Up and optional down SQL: a ``-- migrate:down`` section in the file, or a sibling ``<name>.down.sql``."""
|
|
617
|
+
import re as _re
|
|
618
|
+
|
|
619
|
+
text = path.read_text(encoding="utf-8")
|
|
620
|
+
parts = _re.split(r"^\s*--\s*migrate:down\s*$", text, maxsplit=1, flags=_re.MULTILINE | _re.IGNORECASE)
|
|
621
|
+
up = _re.sub(r"^\s*--\s*migrate:up\s*$", "", parts[0], flags=_re.MULTILINE | _re.IGNORECASE).strip()
|
|
622
|
+
down = parts[1].strip() if len(parts) > 1 else None
|
|
623
|
+
sibling = path.with_name(f"{path.stem}.down.sql")
|
|
624
|
+
if not down and sibling.exists():
|
|
625
|
+
down = sibling.read_text(encoding="utf-8").strip() or None
|
|
626
|
+
return up, down
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _remote_migration_names(scope: dict[str, str | None]) -> set[str]:
|
|
630
|
+
"""Names of migrations currently applied on the server (pages through the whole history)."""
|
|
631
|
+
names: set[str] = set()
|
|
632
|
+
offset, page = 0, 500 # server maximum
|
|
633
|
+
while True:
|
|
634
|
+
result = call_api(method="GET", path="/api/v1/data/rel/migrations",
|
|
635
|
+
params={"limit": str(page), "offset": str(offset)}, require_scope=True, **scope)
|
|
636
|
+
rows = result.get("migrations", []) if isinstance(result, dict) else []
|
|
637
|
+
names |= {str(r.get("name")) for r in rows if isinstance(r, dict) and r.get("status", "applied") == "applied"}
|
|
638
|
+
if len(rows) < page:
|
|
639
|
+
return names
|
|
640
|
+
offset += page
|
|
641
|
+
|
|
642
|
+
|
|
615
643
|
@migrations_app.command("push")
|
|
616
644
|
def migrations_push(
|
|
617
645
|
directory: str | None = typer.Option(
|
|
@@ -619,60 +647,96 @@ def migrations_push(
|
|
|
619
647
|
"--dir",
|
|
620
648
|
help="Migration directory (default: sovaria/migrations under linked project)",
|
|
621
649
|
),
|
|
622
|
-
dry_run: bool = typer.Option(
|
|
650
|
+
dry_run: bool = typer.Option(
|
|
651
|
+
False, "--dry-run", help="Run each pending migration in a transaction and roll it back (nothing is kept)"
|
|
652
|
+
),
|
|
623
653
|
profile: str | None = typer.Option(None, "--profile"),
|
|
624
654
|
org_id: str | None = typer.Option(None, "--org"),
|
|
625
655
|
project_id: str | None = typer.Option(None, "--project"),
|
|
626
656
|
env_id: str | None = typer.Option(None, "--env"),
|
|
627
657
|
) -> None:
|
|
628
|
-
"""Apply pending
|
|
658
|
+
"""Apply pending .sql migrations through the project's migration history.
|
|
659
|
+
|
|
660
|
+
Each file is applied atomically and recorded on the server, so every teammate and CI run sees the
|
|
661
|
+
same history. Put rollback SQL under a ``-- migrate:down`` line (or in ``<name>.down.sql``) to make
|
|
662
|
+
a migration reversible with ``sov db migrations rollback``.
|
|
663
|
+
"""
|
|
629
664
|
from apps.cli.src.utils.migrations import (
|
|
630
665
|
list_local_migrations,
|
|
631
666
|
load_applied_state,
|
|
632
|
-
migration_name,
|
|
633
667
|
migrations_dir,
|
|
634
668
|
save_applied_state,
|
|
635
669
|
)
|
|
636
670
|
from apps.cli.src.utils.premium_display import _console
|
|
637
671
|
|
|
638
672
|
folder = Path(directory) if directory else migrations_dir()
|
|
639
|
-
files = list_local_migrations(folder)
|
|
673
|
+
files = [p for p in list_local_migrations(folder) if not p.name.endswith(".down.sql")]
|
|
640
674
|
if not files:
|
|
641
675
|
print_warning(f"No .sql files in {folder}. Create migrations there first.")
|
|
642
676
|
raise typer.Exit(0)
|
|
643
677
|
|
|
644
|
-
|
|
645
|
-
|
|
678
|
+
scope = _scope_opts(profile, org_id, project_id, env_id)
|
|
679
|
+
try:
|
|
680
|
+
remote = run_with_spinner("Reading migration history…", lambda: _remote_migration_names(scope))
|
|
681
|
+
except CLIRequestError as exc:
|
|
682
|
+
handle_cli_error(exc)
|
|
683
|
+
pending = [path for path in files if path.stem not in remote]
|
|
646
684
|
if not pending:
|
|
647
|
-
print_success("
|
|
685
|
+
print_success("Database is up to date — every local migration is in the project history.")
|
|
648
686
|
raise typer.Exit(0)
|
|
649
687
|
|
|
650
688
|
_console.print(f"\n[bold]Pending migrations ({len(pending)}):[/bold]")
|
|
651
689
|
for path in pending:
|
|
652
690
|
_console.print(f" • {path.name}")
|
|
653
691
|
|
|
654
|
-
if dry_run
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
scope = _scope_opts(profile, org_id, project_id, env_id)
|
|
692
|
+
endpoint = "/api/v1/data/rel/migrations/dry-run" if dry_run else "/api/v1/data/rel/migrations/apply"
|
|
693
|
+
applied_local = load_applied_state()
|
|
658
694
|
for path in pending:
|
|
659
|
-
|
|
660
|
-
if not
|
|
695
|
+
up, down = _split_migration(path)
|
|
696
|
+
if not up:
|
|
661
697
|
print_warning(f"Skipping empty file: {path.name}")
|
|
662
|
-
applied.add(migration_name(path))
|
|
663
698
|
continue
|
|
699
|
+
body = {"name": path.stem[:120], "up_sql": up, **({"down_sql": down} if down else {})}
|
|
664
700
|
try:
|
|
665
701
|
run_with_spinner(
|
|
666
|
-
f"Applying {path.name}…",
|
|
667
|
-
lambda
|
|
668
|
-
success=f"Applied {path.name}",
|
|
702
|
+
f"{'Checking' if dry_run else 'Applying'} {path.name}…",
|
|
703
|
+
lambda b=body: call_api(method="POST", path=endpoint, body=b, require_scope=True, **scope),
|
|
704
|
+
success=f"{'Would apply' if dry_run else 'Applied'} {path.name}",
|
|
669
705
|
)
|
|
670
706
|
except CLIRequestError as exc:
|
|
671
707
|
handle_cli_error(exc)
|
|
672
|
-
|
|
708
|
+
if not dry_run:
|
|
709
|
+
applied_local.add(path.name)
|
|
710
|
+
|
|
711
|
+
if dry_run:
|
|
712
|
+
print_success(f"All {len(pending)} pending migration(s) apply cleanly. Nothing was changed.")
|
|
713
|
+
return
|
|
714
|
+
save_applied_state(applied_local)
|
|
715
|
+
print_success(f"Applied {len(pending)} migration(s).")
|
|
673
716
|
|
|
674
|
-
|
|
675
|
-
|
|
717
|
+
|
|
718
|
+
@migrations_app.command("rollback")
|
|
719
|
+
def migrations_rollback(
|
|
720
|
+
version: str | None = typer.Option(None, "--version", help="Roll back this version (needs --force if it isn't the latest)"),
|
|
721
|
+
force: bool = typer.Option(False, "--force", help="Allow rolling back a migration that isn't the most recent"),
|
|
722
|
+
profile: str | None = typer.Option(None, "--profile"),
|
|
723
|
+
org_id: str | None = typer.Option(None, "--org"),
|
|
724
|
+
project_id: str | None = typer.Option(None, "--project"),
|
|
725
|
+
env_id: str | None = typer.Option(None, "--env"),
|
|
726
|
+
) -> None:
|
|
727
|
+
"""Undo the most recent migration using its down SQL."""
|
|
728
|
+
scope = _scope_opts(profile, org_id, project_id, env_id)
|
|
729
|
+
params = {**({"version": version} if version else {}), **({"force": "true"} if force else {})}
|
|
730
|
+
try:
|
|
731
|
+
result = run_with_spinner(
|
|
732
|
+
"Rolling back…",
|
|
733
|
+
lambda: call_api(method="POST", path="/api/v1/data/rel/migrations/rollback", params=params,
|
|
734
|
+
require_scope=True, **scope),
|
|
735
|
+
success="Rolled back",
|
|
736
|
+
)
|
|
737
|
+
except CLIRequestError as exc:
|
|
738
|
+
handle_cli_error(exc)
|
|
739
|
+
print_json(result)
|
|
676
740
|
|
|
677
741
|
|
|
678
742
|
@migrations_app.command("pull")
|
|
@@ -228,6 +228,36 @@ def _generate_pkce_pair() -> tuple[str, str]:
|
|
|
228
228
|
|
|
229
229
|
# ── Local callback server ─────────────────────────────────────────────────────
|
|
230
230
|
|
|
231
|
+
_PAGE_COPY = {
|
|
232
|
+
"ok": ("Signed in to the Sovaria CLI", "You're all set. Return to your terminal — this tab can be closed.", "#15803d", "✓"),
|
|
233
|
+
"denied": ("Sign-in cancelled", "Nothing was authorized. Run <code>sov login</code> again if you change your mind.", "#6b625a", "–"),
|
|
234
|
+
"error": ("Sign-in couldn't be completed", "The response didn't match this login attempt, so it was ignored. Run <code>sov login</code> again.", "#b4231f", "!"),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _callback_page(kind: str) -> bytes:
|
|
239
|
+
"""Small self-contained page (no external assets) shown in the browser after authorization."""
|
|
240
|
+
title, body, color, glyph = _PAGE_COPY[kind]
|
|
241
|
+
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
242
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{title} · Sovaria</title>
|
|
243
|
+
<style>
|
|
244
|
+
:root{{--bg:#faf9f7;--card:#fff;--text:#1c1917;--muted:#6b625a;--border:#ece6e0;--accent:{color}}}
|
|
245
|
+
@media (prefers-color-scheme:dark){{:root{{--bg:#141210;--card:#1c1a17;--text:#f5f2ee;--muted:#a39b92;--border:#2e2a26}}}}
|
|
246
|
+
*{{box-sizing:border-box}}body{{margin:0;min-height:100vh;display:flex;flex-direction:column;background:var(--bg);color:var(--text);
|
|
247
|
+
font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Arial,sans-serif}}
|
|
248
|
+
header{{padding:16px 24px;font-weight:700;font-size:15px;letter-spacing:-.01em}}
|
|
249
|
+
header span{{display:inline-block;width:10px;height:10px;border-radius:3px;background:#c2610c;margin-right:8px}}
|
|
250
|
+
main{{flex:1;display:flex;align-items:center;justify-content:center;padding:24px 16px 64px}}
|
|
251
|
+
.card{{width:min(420px,100%);padding:28px;border:1px solid var(--border);border-radius:12px;background:var(--card)}}
|
|
252
|
+
.glyph{{width:32px;height:32px;display:grid;place-items:center;margin-bottom:16px;border-radius:8px;font-weight:700;
|
|
253
|
+
color:var(--accent);background:color-mix(in srgb,var(--accent) 14%,transparent)}}
|
|
254
|
+
.kicker{{margin:0 0 6px;font-size:12px;color:var(--muted)}}h1{{margin:0;font-size:20px;line-height:1.3;font-weight:600}}
|
|
255
|
+
p.body{{margin:8px 0 0;color:var(--muted)}}code{{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text)}}
|
|
256
|
+
</style></head><body><header><span></span>Sovaria</header><main><section class="card">
|
|
257
|
+
<div class="glyph" aria-hidden="true">{glyph}</div><p class="kicker">Sovaria CLI</p><h1>{title}</h1>
|
|
258
|
+
<p class="body">{body}</p></section></main></body></html>""".encode()
|
|
259
|
+
|
|
260
|
+
|
|
231
261
|
|
|
232
262
|
def _make_callback_handler(
|
|
233
263
|
result_queue: queue.Queue[tuple[str, str | None]], expected_state: str
|
|
@@ -256,23 +286,25 @@ def _make_callback_handler(
|
|
|
256
286
|
# CSRF / state check — reject responses whose state doesn't match
|
|
257
287
|
# what we sent to the auth server (RFC 6749 §10.12).
|
|
258
288
|
if state != expected_state:
|
|
259
|
-
self._respond(
|
|
260
|
-
400,
|
|
261
|
-
b"<html><body><h2>State mismatch - possible CSRF attack.</h2></body></html>",
|
|
262
|
-
)
|
|
289
|
+
self._respond(400, _callback_page("error"))
|
|
263
290
|
result_queue.put(("csrf", None))
|
|
264
291
|
return
|
|
265
292
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
293
|
+
if qs.get("error", [None])[0] == "access_denied":
|
|
294
|
+
self._respond(200, _callback_page("denied"))
|
|
295
|
+
result_queue.put(("denied", None))
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
self._respond(200 if code else 400, _callback_page("ok" if code else "error"))
|
|
271
299
|
result_queue.put(("ok", code) if code else ("missing_code", None))
|
|
272
300
|
|
|
273
301
|
def _respond(self, status: int, body: bytes) -> None:
|
|
274
302
|
self.send_response(status)
|
|
275
|
-
self.send_header("Content-Type", "text/html")
|
|
303
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
304
|
+
self.send_header("Cache-Control", "no-store")
|
|
305
|
+
self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
|
|
306
|
+
self.send_header("X-Frame-Options", "DENY")
|
|
307
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
276
308
|
self.end_headers()
|
|
277
309
|
self.wfile.write(body)
|
|
278
310
|
|
|
@@ -303,8 +335,8 @@ def _serve_callback(expected_state: str) -> tuple[str | None, str | None]:
|
|
|
303
335
|
return code, None
|
|
304
336
|
if status == "csrf":
|
|
305
337
|
return None, "csrf"
|
|
306
|
-
if status
|
|
307
|
-
return None,
|
|
338
|
+
if status in ("missing_code", "denied"):
|
|
339
|
+
return None, status
|
|
308
340
|
return None, "timeout"
|
|
309
341
|
finally:
|
|
310
342
|
server.server_close()
|
|
@@ -468,6 +500,10 @@ def _pkce_login(
|
|
|
468
500
|
if not code:
|
|
469
501
|
if callback_error == "csrf":
|
|
470
502
|
print_error_panel("Login failed: callback state mismatch (possible CSRF).")
|
|
503
|
+
elif callback_error == "denied":
|
|
504
|
+
print_error_panel("Login cancelled in the browser. Nothing was authorized.")
|
|
505
|
+
elif callback_error == "missing_code":
|
|
506
|
+
print_error_panel("Login failed: the browser returned without an authorization code. Run: sov login")
|
|
471
507
|
else:
|
|
472
508
|
print_error_panel(
|
|
473
509
|
"No authorization code received — login timed out.\n"
|
|
@@ -93,6 +93,11 @@ def create_org(
|
|
|
93
93
|
raise typer.Exit(1) from exc
|
|
94
94
|
|
|
95
95
|
node_id = data.get("node_id")
|
|
96
|
+
from apps.cli.src.utils.output_mode import wants_json
|
|
97
|
+
|
|
98
|
+
if wants_json():
|
|
99
|
+
typer.echo(json.dumps(data, default=str))
|
|
100
|
+
return
|
|
96
101
|
typer.echo(
|
|
97
102
|
typer.style("✓ Created", fg=typer.colors.GREEN)
|
|
98
103
|
+ f" {node_type} '{name}' node_id={node_id}"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""`sov db migrations push/rollback` go through the server's migration history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from typer.testing import CliRunner
|
|
8
|
+
|
|
9
|
+
from apps.cli.src.commands import db
|
|
10
|
+
|
|
11
|
+
runner = CliRunner()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _fake_api(calls: list, remote: list[str]):
|
|
15
|
+
def call_api(**kw):
|
|
16
|
+
calls.append(kw)
|
|
17
|
+
if kw["method"] == "GET":
|
|
18
|
+
return {"migrations": [{"name": n, "status": "applied"} for n in remote]}
|
|
19
|
+
return {"status": "applied"}
|
|
20
|
+
return call_api
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_split_migration_sections_and_sibling(tmp_path: Path):
|
|
24
|
+
f = tmp_path / "001_notes.sql"
|
|
25
|
+
f.write_text("-- migrate:up\ncreate table notes (id int);\n-- migrate:down\ndrop table notes;\n")
|
|
26
|
+
assert db._split_migration(f) == ("create table notes (id int);", "drop table notes;")
|
|
27
|
+
g = tmp_path / "002_idx.sql"
|
|
28
|
+
g.write_text("create index i on notes (id);")
|
|
29
|
+
(tmp_path / "002_idx.down.sql").write_text("drop index i;")
|
|
30
|
+
assert db._split_migration(g) == ("create index i on notes (id);", "drop index i;")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_push_applies_only_files_missing_from_server_history(tmp_path: Path, monkeypatch):
|
|
34
|
+
(tmp_path / "001_a.sql").write_text("create table a (id int);")
|
|
35
|
+
(tmp_path / "002_b.sql").write_text("create table b (id int);\n-- migrate:down\ndrop table b;")
|
|
36
|
+
(tmp_path / "002_b.down.sql").write_text("ignored because the file has its own down section")
|
|
37
|
+
calls: list = []
|
|
38
|
+
monkeypatch.setattr(db, "call_api", _fake_api(calls, remote=["001_a"]))
|
|
39
|
+
monkeypatch.setattr("apps.cli.src.utils.migrations.save_applied_state", lambda s: tmp_path / "state.json")
|
|
40
|
+
monkeypatch.setattr("apps.cli.src.utils.migrations.load_applied_state", lambda: set())
|
|
41
|
+
result = runner.invoke(db.app, ["migrations", "push", "--dir", str(tmp_path)])
|
|
42
|
+
assert result.exit_code == 0, result.output
|
|
43
|
+
posts = [c for c in calls if c["method"] == "POST"]
|
|
44
|
+
assert [c["body"]["name"] for c in posts] == ["002_b"]
|
|
45
|
+
assert posts[0]["path"].endswith("/migrations/apply") and posts[0]["body"]["down_sql"] == "drop table b;"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_dry_run_uses_server_dry_run(tmp_path: Path, monkeypatch):
|
|
49
|
+
(tmp_path / "001_a.sql").write_text("create table a (id int);")
|
|
50
|
+
calls: list = []
|
|
51
|
+
monkeypatch.setattr(db, "call_api", _fake_api(calls, remote=[]))
|
|
52
|
+
result = runner.invoke(db.app, ["migrations", "push", "--dir", str(tmp_path), "--dry-run"])
|
|
53
|
+
assert result.exit_code == 0, result.output
|
|
54
|
+
assert [c["path"] for c in calls if c["method"] == "POST"] == ["/api/v1/data/rel/migrations/dry-run"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_rollback_passes_version_and_force(monkeypatch):
|
|
58
|
+
calls: list = []
|
|
59
|
+
monkeypatch.setattr(db, "call_api", _fake_api(calls, remote=[]))
|
|
60
|
+
result = runner.invoke(db.app, ["migrations", "rollback", "--version", "20261013", "--force"])
|
|
61
|
+
assert result.exit_code == 0, result.output
|
|
62
|
+
assert calls[-1]["path"].endswith("/migrations/rollback") and calls[-1]["params"] == {"version": "20261013", "force": "true"}
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sovaria-cli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.9"
|
|
8
8
|
description = "Sovaria BaaS command-line interface (sov)"
|
|
9
9
|
readme = "apps/cli/README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -12,7 +12,7 @@ license = { text = "MIT" }
|
|
|
12
12
|
authors = [{ name = "Sovaria Team" }]
|
|
13
13
|
# Keep in sync with requirements.txt. Pin sovaria-sdk exactly and publish CLI+SDK together.
|
|
14
14
|
dependencies = [
|
|
15
|
-
"sovaria-sdk==0.4.
|
|
15
|
+
"sovaria-sdk==0.4.1",
|
|
16
16
|
"typer>=0.17.4",
|
|
17
17
|
"httpx>=0.28.1",
|
|
18
18
|
"keyring>=25.0",
|
|
@@ -31,7 +31,7 @@ sov = "apps.cli.src.main:cli_entry"
|
|
|
31
31
|
|
|
32
32
|
[project.optional-dependencies]
|
|
33
33
|
mcp = [
|
|
34
|
-
"sovaria-sdk[mcp]==0.4.
|
|
34
|
+
"sovaria-sdk[mcp]==0.4.1",
|
|
35
35
|
]
|
|
36
36
|
dev = [
|
|
37
37
|
"pytest>=8.0",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|