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.
Files changed (88) hide show
  1. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/PKG-INFO +3 -3
  2. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/__init__.py +1 -1
  3. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/db.py +84 -20
  4. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/login.py +48 -12
  5. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/org.py +5 -0
  6. sovaria_cli-0.2.9/apps/cli/tests/test_db_migrations.py +62 -0
  7. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_main_startup.py +1 -1
  8. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/pyproject.toml +3 -3
  9. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/requirements.txt +1 -1
  10. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/.github/dependabot.yml +0 -0
  11. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/.github/workflows/ci.yml +0 -0
  12. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/.github/workflows/publish.yml +0 -0
  13. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/.gitignore +0 -0
  14. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/.gitleaks.toml +0 -0
  15. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/.python-version +0 -0
  16. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/Makefile +0 -0
  17. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/README.md +0 -0
  18. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/__init__.py +0 -0
  19. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/__init__.py +0 -0
  20. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/admin.py +0 -0
  21. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/api.py +0 -0
  22. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/audit.py +0 -0
  23. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/auth_product.py +0 -0
  24. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/billing.py +0 -0
  25. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/chrono.py +0 -0
  26. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/collections.py +0 -0
  27. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/connect.py +0 -0
  28. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/context.py +0 -0
  29. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/dashboard.py +0 -0
  30. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/data.py +0 -0
  31. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/deploy.py +0 -0
  32. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/gen.py +0 -0
  33. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/governance.py +0 -0
  34. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/iam.py +0 -0
  35. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/init_cmd.py +0 -0
  36. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/inspect.py +0 -0
  37. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/keys.py +0 -0
  38. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/link_cmd.py +0 -0
  39. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/logs.py +0 -0
  40. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/mcp.py +0 -0
  41. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/oidc_providers.py +0 -0
  42. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/passkeys.py +0 -0
  43. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/policy.py +0 -0
  44. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/project.py +0 -0
  45. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/realtime.py +0 -0
  46. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/schema.py +0 -0
  47. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/scope.py +0 -0
  48. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/secrets.py +0 -0
  49. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/shell.py +0 -0
  50. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/sim.py +0 -0
  51. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/storage.py +0 -0
  52. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/test_cmd.py +0 -0
  53. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/commands/vector.py +0 -0
  54. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/main.py +0 -0
  55. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/__init__.py +0 -0
  56. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/api_client.py +0 -0
  57. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/api_helpers.py +0 -0
  58. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/auth_store.py +0 -0
  59. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/config.py +0 -0
  60. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/display.py +0 -0
  61. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/local_function_server.py +0 -0
  62. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/log_tail.py +0 -0
  63. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/migrations.py +0 -0
  64. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/output_mode.py +0 -0
  65. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/premium_display.py +0 -0
  66. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/project_context.py +0 -0
  67. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/schema_introspect.py +0 -0
  68. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/scope_picker.py +0 -0
  69. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/src/utils/theme.py +0 -0
  70. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_aug18_cli_tickets.py +0 -0
  71. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_auth_store_profiles.py +0 -0
  72. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_cli_security.py +0 -0
  73. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_cli_smoke_paths.py +0 -0
  74. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_config_profiles.py +0 -0
  75. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_credential_file_permissions.py +0 -0
  76. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_mcp_command.py +0 -0
  77. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_phase4_commands.py +0 -0
  78. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_phase5_commands.py +0 -0
  79. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_phase6_commands.py +0 -0
  80. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_premium_display.py +0 -0
  81. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_project_and_commands.py +0 -0
  82. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_scope_picker.py +0 -0
  83. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/apps/cli/tests/test_supabase_style_commands.py +0 -0
  84. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/pytest.ini +0 -0
  85. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/requirements-dev.txt +0 -0
  86. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/scripts/cli_sdk_happy_path.sh +0 -0
  87. {sovaria_cli-0.2.8 → sovaria_cli-0.2.9}/scripts/smoke_all_commands.sh +0 -0
  88. {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.8
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.0
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.0; extra == 'mcp'
25
+ Requires-Dist: sovaria-sdk[mcp]==0.4.1; extra == 'mcp'
26
26
  Description-Content-Type: text/markdown
27
27
 
28
28
  # Sovaria CLI
@@ -1,3 +1,3 @@
1
1
  """Sovaria CLI — sov command."""
2
2
 
3
- __version__ = "0.2.8"
3
+ __version__ = "0.2.9"
@@ -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(False, "--dry-run", help="Show pending files without executing"),
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 local .sql migration files in sorted order."""
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
- applied = load_applied_state()
645
- pending = [path for path in files if migration_name(path) not in applied]
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("All local migrations already applied.")
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
- raise typer.Exit(0)
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
- sql = path.read_text(encoding="utf-8").strip()
660
- if not sql:
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 s=sql: _run_sql_query(s, **scope),
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
- applied.add(migration_name(path))
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
- state_file = save_applied_state(applied)
675
- print_success(f"Applied {len(pending)} migration(s). State: {state_file}")
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", "&#10003;"),
233
+ "denied": ("Sign-in cancelled", "Nothing was authorized. Run <code>sov login</code> again if you change your mind.", "#6b625a", "&#8211;"),
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
- html = (
267
- b"<html><body><h2>Sovaria Login Successful</h2>"
268
- b"<p>You can close this tab and return to the terminal.</p></body></html>"
269
- )
270
- self._respond(200, html)
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 == "missing_code":
307
- return None, "missing_code"
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"}
@@ -26,4 +26,4 @@ def test_sov_console_script_help() -> None:
26
26
  )
27
27
 
28
28
  assert result.returncode == 0
29
- assert "sov version 0.2.8" in result.stdout
29
+ assert "sov version 0.2.9" in result.stdout
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sovaria-cli"
7
- version = "0.2.8"
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.0",
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.0",
34
+ "sovaria-sdk[mcp]==0.4.1",
35
35
  ]
36
36
  dev = [
37
37
  "pytest>=8.0",
@@ -1,6 +1,6 @@
1
1
  # Runtime dependencies for sovaria-cli. Prefer: pip install sovaria-cli
2
2
  # Keep in sync with [project].dependencies in pyproject.toml.
3
- sovaria-sdk==0.4.0
3
+ sovaria-sdk==0.4.1
4
4
  typer>=0.17.4
5
5
  httpx>=0.28.1
6
6
  keyring>=25.0
File without changes
File without changes
File without changes
File without changes
File without changes