sovaria-cli 0.2.7__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 (90) hide show
  1. sovaria_cli-0.2.9/.github/dependabot.yml +12 -0
  2. sovaria_cli-0.2.9/.github/workflows/ci.yml +174 -0
  3. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/.github/workflows/publish.yml +5 -0
  4. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/.gitignore +4 -0
  5. sovaria_cli-0.2.9/.gitleaks.toml +17 -0
  6. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/PKG-INFO +42 -2
  7. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/README.md +37 -0
  8. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/__init__.py +1 -1
  9. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/auth_product.py +17 -2
  10. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/db.py +84 -20
  11. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/deploy.py +75 -9
  12. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/governance.py +19 -0
  13. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/login.py +279 -49
  14. sovaria_cli-0.2.9/apps/cli/src/commands/mcp.py +79 -0
  15. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/org.py +5 -0
  16. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/project.py +109 -0
  17. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/realtime.py +37 -10
  18. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/main.py +2 -0
  19. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/utils/api_client.py +109 -12
  20. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/utils/auth_store.py +86 -19
  21. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/utils/config.py +53 -0
  22. sovaria_cli-0.2.9/apps/cli/tests/test_aug18_cli_tickets.py +450 -0
  23. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/tests/test_cli_smoke_paths.py +26 -0
  24. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/tests/test_config_profiles.py +9 -0
  25. sovaria_cli-0.2.9/apps/cli/tests/test_credential_file_permissions.py +29 -0
  26. sovaria_cli-0.2.9/apps/cli/tests/test_db_migrations.py +62 -0
  27. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/tests/test_main_startup.py +1 -1
  28. sovaria_cli-0.2.9/apps/cli/tests/test_mcp_command.py +31 -0
  29. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/tests/test_supabase_style_commands.py +11 -1
  30. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/pyproject.toml +19 -2
  31. sovaria_cli-0.2.9/requirements-dev.txt +4 -0
  32. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/requirements.txt +3 -1
  33. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/scripts/cli_sdk_happy_path.sh +2 -2
  34. sovaria_cli-0.2.7/.github/workflows/ci.yml +0 -39
  35. sovaria_cli-0.2.7/requirements-dev.txt +0 -2
  36. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/.python-version +0 -0
  37. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/Makefile +0 -0
  38. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/__init__.py +0 -0
  39. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/__init__.py +0 -0
  40. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/admin.py +0 -0
  41. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/api.py +0 -0
  42. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/audit.py +0 -0
  43. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/billing.py +0 -0
  44. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/chrono.py +0 -0
  45. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/collections.py +0 -0
  46. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/connect.py +0 -0
  47. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/context.py +0 -0
  48. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/dashboard.py +0 -0
  49. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/data.py +0 -0
  50. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/gen.py +0 -0
  51. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/iam.py +0 -0
  52. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/init_cmd.py +0 -0
  53. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/inspect.py +0 -0
  54. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/keys.py +0 -0
  55. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/link_cmd.py +0 -0
  56. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/logs.py +0 -0
  57. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/oidc_providers.py +0 -0
  58. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/passkeys.py +0 -0
  59. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/policy.py +0 -0
  60. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/schema.py +0 -0
  61. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/scope.py +0 -0
  62. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/secrets.py +0 -0
  63. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/shell.py +0 -0
  64. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/sim.py +0 -0
  65. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/storage.py +0 -0
  66. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/test_cmd.py +0 -0
  67. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/commands/vector.py +0 -0
  68. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/utils/__init__.py +0 -0
  69. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/utils/api_helpers.py +0 -0
  70. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/utils/display.py +0 -0
  71. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/utils/local_function_server.py +0 -0
  72. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/utils/log_tail.py +0 -0
  73. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/utils/migrations.py +0 -0
  74. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/utils/output_mode.py +0 -0
  75. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/utils/premium_display.py +0 -0
  76. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/utils/project_context.py +0 -0
  77. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/utils/schema_introspect.py +0 -0
  78. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/utils/scope_picker.py +0 -0
  79. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/src/utils/theme.py +0 -0
  80. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/tests/test_auth_store_profiles.py +0 -0
  81. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/tests/test_cli_security.py +0 -0
  82. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/tests/test_phase4_commands.py +0 -0
  83. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/tests/test_phase5_commands.py +0 -0
  84. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/tests/test_phase6_commands.py +0 -0
  85. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/tests/test_premium_display.py +0 -0
  86. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/tests/test_project_and_commands.py +0 -0
  87. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/apps/cli/tests/test_scope_picker.py +0 -0
  88. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/pytest.ini +0 -0
  89. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/scripts/smoke_all_commands.sh +0 -0
  90. {sovaria_cli-0.2.7 → sovaria_cli-0.2.9}/scripts/smoke_chunks.sh +0 -0
@@ -0,0 +1,12 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: pip
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ open-pull-requests-limit: 5
8
+ - package-ecosystem: github-actions
9
+ directory: /
10
+ schedule:
11
+ interval: weekly
12
+ open-pull-requests-limit: 5
@@ -0,0 +1,174 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ workflow_call:
9
+
10
+ jobs:
11
+ secret-scan:
12
+ name: Secret scan
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ with:
17
+ fetch-depth: 0
18
+ - name: Install Gitleaks
19
+ run: |
20
+ curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.24.2/gitleaks_8.24.2_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
21
+ tar -xzf /tmp/gitleaks.tar.gz -C /tmp
22
+ sudo install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks
23
+ - name: Run Gitleaks
24
+ run: |
25
+ if [[ "${{ github.event_name }}" == "pull_request" ]]; then
26
+ RANGE="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
27
+ else
28
+ RANGE="HEAD~1..HEAD"
29
+ fi
30
+ echo "Scanning commit range: $RANGE"
31
+ gitleaks detect --source . --redact --no-banner --config .gitleaks.toml --log-opts="$RANGE"
32
+
33
+ lint:
34
+ name: Ruff
35
+ runs-on: ubuntu-latest
36
+ needs: secret-scan
37
+ steps:
38
+ - uses: actions/checkout@v4
39
+ - name: Set up Python
40
+ uses: actions/setup-python@v5
41
+ with:
42
+ python-version: "3.12"
43
+ - name: Install ruff
44
+ run: pip install "ruff>=0.9"
45
+ - name: Ruff check
46
+ run: |
47
+ ruff check \
48
+ apps/cli/src/commands/login.py \
49
+ apps/cli/src/commands/deploy.py \
50
+ apps/cli/src/commands/realtime.py \
51
+ apps/cli/src/utils/api_client.py \
52
+ apps/cli/src/utils/auth_store.py \
53
+ apps/cli/src/utils/config.py \
54
+ apps/cli/tests/test_aug18_cli_tickets.py \
55
+ apps/cli/tests/test_config_profiles.py
56
+
57
+ typecheck:
58
+ name: Mypy
59
+ runs-on: ubuntu-latest
60
+ needs: secret-scan
61
+ env:
62
+ # Fine-grained token with contents:read on sovaria/sovaria-sdk. Without it, CI installs the
63
+ # pinned SDK release from PyPI instead.
64
+ SDK_TOKEN: ${{ secrets.SOVARIA_SDK_READ_TOKEN }}
65
+ steps:
66
+ - uses: actions/checkout@v4
67
+ - name: Check out the matching SDK (when SOVARIA_SDK_READ_TOKEN is set)
68
+ if: env.SDK_TOKEN != ''
69
+ uses: actions/checkout@v4
70
+ with:
71
+ repository: sovaria/sovaria-sdk
72
+ path: sovaria-sdk
73
+ token: ${{ env.SDK_TOKEN }}
74
+ - name: Set up Python
75
+ uses: actions/setup-python@v5
76
+ with:
77
+ python-version: "3.12"
78
+ - name: Install SDK + CLI
79
+ run: |
80
+ set -euo pipefail
81
+ if [ -f ../sovaria-sdk/apps/sdk/python/pyproject.toml ]; then
82
+ pip install -e ../sovaria-sdk/apps/sdk/python
83
+ elif [ -f sovaria-sdk/apps/sdk/python/pyproject.toml ]; then
84
+ pip install -e sovaria-sdk/apps/sdk/python
85
+ else
86
+ pip install "sovaria-sdk==0.4.0"
87
+ fi
88
+ pip install -e ".[dev]"
89
+ - name: Mypy
90
+ run: |
91
+ mypy \
92
+ apps/cli/src/commands/login.py \
93
+ apps/cli/src/commands/deploy.py \
94
+ apps/cli/src/commands/realtime.py \
95
+ apps/cli/src/utils/api_client.py \
96
+ apps/cli/src/utils/auth_store.py \
97
+ apps/cli/src/utils/config.py
98
+
99
+ install-requirements:
100
+ name: requirements.txt install
101
+ runs-on: ubuntu-latest
102
+ needs: secret-scan
103
+ env:
104
+ # Fine-grained token with contents:read on sovaria/sovaria-sdk. Without it, CI installs the
105
+ # pinned SDK release from PyPI instead.
106
+ SDK_TOKEN: ${{ secrets.SOVARIA_SDK_READ_TOKEN }}
107
+ steps:
108
+ - uses: actions/checkout@v4
109
+ - name: Check out the matching SDK (when SOVARIA_SDK_READ_TOKEN is set)
110
+ if: env.SDK_TOKEN != ''
111
+ uses: actions/checkout@v4
112
+ with:
113
+ repository: sovaria/sovaria-sdk
114
+ path: sovaria-sdk
115
+ token: ${{ env.SDK_TOKEN }}
116
+ - name: Set up Python
117
+ uses: actions/setup-python@v5
118
+ with:
119
+ python-version: "3.12"
120
+ - name: Install from requirements.txt
121
+ run: |
122
+ set -euo pipefail
123
+ if [ -f ../sovaria-sdk/apps/sdk/python/pyproject.toml ]; then
124
+ pip install -e ../sovaria-sdk/apps/sdk/python
125
+ elif [ -f sovaria-sdk/apps/sdk/python/pyproject.toml ]; then
126
+ pip install -e sovaria-sdk/apps/sdk/python
127
+ else
128
+ pip install "sovaria-sdk==0.4.0"
129
+ fi
130
+ pip install -r requirements.txt
131
+ pip install --no-deps .
132
+ - name: sov --help
133
+ run: sov --help
134
+
135
+ test:
136
+ runs-on: ubuntu-latest
137
+ needs: secret-scan
138
+ env:
139
+ SDK_TOKEN: ${{ secrets.SOVARIA_SDK_READ_TOKEN }}
140
+ steps:
141
+ - uses: actions/checkout@v4
142
+ - name: Check out the matching SDK (when SOVARIA_SDK_READ_TOKEN is set)
143
+ if: env.SDK_TOKEN != ''
144
+ uses: actions/checkout@v4
145
+ with:
146
+ repository: sovaria/sovaria-sdk
147
+ path: sovaria-sdk
148
+ token: ${{ env.SDK_TOKEN }}
149
+
150
+ - name: Set up Python
151
+ uses: actions/setup-python@v5
152
+ with:
153
+ python-version: "3.12"
154
+
155
+ - name: Install SDK + CLI
156
+ run: |
157
+ set -euo pipefail
158
+ if [ -f ../sovaria-sdk/apps/sdk/python/pyproject.toml ]; then
159
+ pip install -e ../sovaria-sdk/apps/sdk/python
160
+ elif [ -f sovaria-sdk/apps/sdk/python/pyproject.toml ]; then
161
+ pip install -e sovaria-sdk/apps/sdk/python
162
+ else
163
+ pip install "sovaria-sdk==0.4.0"
164
+ fi
165
+ pip install -e ".[dev]"
166
+
167
+ - name: Run tests
168
+ run: pytest apps/cli/tests -q
169
+
170
+ - name: Build wheel
171
+ run: python -m build
172
+
173
+ - name: Validate wheel metadata
174
+ run: python -m twine check dist/*
@@ -8,8 +8,13 @@ on:
8
8
  required: true
9
9
 
10
10
  jobs:
11
+ test:
12
+ if: github.event.inputs.confirm == 'publish'
13
+ uses: ./.github/workflows/ci.yml
14
+
11
15
  publish:
12
16
  if: github.event.inputs.confirm == 'publish'
17
+ needs: test
13
18
  runs-on: ubuntu-latest
14
19
  environment: pypi
15
20
  permissions:
@@ -4,3 +4,7 @@ __pycache__/
4
4
  .mypy_cache/
5
5
  dist/
6
6
  *.egg-info/
7
+ .env
8
+ .env.*
9
+ !.env.example
10
+ !.env.*.example
@@ -0,0 +1,17 @@
1
+ title = "Sovaria CLI gitleaks config"
2
+
3
+ [extend]
4
+ useDefault = true
5
+
6
+ [allowlist]
7
+ description = "Placeholder credentials and CLI test fixtures"
8
+ paths = [
9
+ '''\.env\.example$''',
10
+ '''apps/cli/tests/''',
11
+ ]
12
+ regexes = [
13
+ '''change-me''',
14
+ '''CHANGE_ME''',
15
+ '''do-not-use-in-prod''',
16
+ '''replace-with''',
17
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sovaria-cli
3
- Version: 0.2.7
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,14 +12,17 @@ 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.2.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
19
19
  Requires-Dist: build>=1.2; extra == 'dev'
20
+ Requires-Dist: mypy>=1.13; extra == 'dev'
20
21
  Requires-Dist: pytest>=8.0; extra == 'dev'
21
22
  Requires-Dist: ruff>=0.9; extra == 'dev'
22
23
  Requires-Dist: twine>=6.0; extra == 'dev'
24
+ Provides-Extra: mcp
25
+ Requires-Dist: sovaria-sdk[mcp]==0.4.1; extra == 'mcp'
23
26
  Description-Content-Type: text/markdown
24
27
 
25
28
  # Sovaria CLI
@@ -61,6 +64,43 @@ scripts/cli_sdk_happy_path.sh
61
64
 
62
65
  If you already have valid CLI tokens, omit `SOVARIA_EMAIL` and `SOVARIA_PASSWORD`.
63
66
 
67
+ ## Authentication
68
+
69
+ ```bash
70
+ sov login
71
+ sov login --email you@example.com
72
+ sov login --file-storage # headless: tokens in ~/.sovaria/credentials.toml
73
+ sov logout
74
+ sov logout --force # clear local tokens if server revoke fails
75
+ ```
76
+
77
+ - **Browser (PKCE):** authorize opens the console origin (`get_console_url`); the token exchange POSTs to the gateway `api_url` `/oauth/token` proxy. That split is intentional.
78
+ - **Password:** never pass `--password`. Operator login prompts with `getpass`, or reads `SOVARIA_PASSWORD`.
79
+ - **Product user create:** `sov auth-product create --email ...` prompts for the new user's password, or reads `SOVARIA_USER_PASSWORD`. Do not reuse `SOVARIA_PASSWORD` (that is the operator login secret).
80
+ - **Magic link:** `sov auth magic-link verify` reads the token from stdin or `SOVARIA_MAGIC_TOKEN`.
81
+ - **GitHub deploy PAT:** set `SOVARIA_GITHUB_TOKEN` or use `gh auth token`. There is no `--token` flag.
82
+ - **OAuth callback:** binds `127.0.0.1` by default. Non-loopback `SOVARIA_CLI_CALLBACK_HOST` requires `--insecure-callback`.
83
+
84
+ ### Token refresh contract
85
+
86
+ Axis supports two refresh modes. Do not mix them:
87
+
88
+ | Client | How refresh is sent |
89
+ |---|---|
90
+ | **CLI / Python SDK** | JSON body: `POST /api/v1/auth/refresh` with `{"refresh_token": "<stored>"}`. The CLI keeps the refresh token in OS keyring (or `credentials.toml` with `--file-storage`). |
91
+ | **Console / browser** | Empty JSON body `{}` plus the HttpOnly refresh cookie. The browser never puts the refresh token in the request body. |
92
+
93
+ Copying the console cookie pattern into a CLI or SDK client will fail silently (no cookie jar). Copying the CLI JSON-body pattern into a browser app bypasses HttpOnly cookie storage.
94
+
95
+ ### Pulse WebSocket URLs
96
+
97
+ `sov realtime ws-url` issues an opaque `ticket=` query parameter. Never put a JWT in `?token=` — production gateways disable `GATEWAY_WS_ALLOW_QUERY_TOKEN`. Local `.env.local` may still enable the legacy query-token path; do not copy that into clients.
98
+
99
+ ```bash
100
+ sov realtime ws-url my-channel --org <org_id> --project <project_id>
101
+ # wss://api.sovaria.in/api/v1/rt/ws/my-channel?ticket=...&org_id=...&project_id=...
102
+ ```
103
+
64
104
  ## Core Command Groups
65
105
 
66
106
  - `sov auth ...` auth login/logout
@@ -37,6 +37,43 @@ scripts/cli_sdk_happy_path.sh
37
37
 
38
38
  If you already have valid CLI tokens, omit `SOVARIA_EMAIL` and `SOVARIA_PASSWORD`.
39
39
 
40
+ ## Authentication
41
+
42
+ ```bash
43
+ sov login
44
+ sov login --email you@example.com
45
+ sov login --file-storage # headless: tokens in ~/.sovaria/credentials.toml
46
+ sov logout
47
+ sov logout --force # clear local tokens if server revoke fails
48
+ ```
49
+
50
+ - **Browser (PKCE):** authorize opens the console origin (`get_console_url`); the token exchange POSTs to the gateway `api_url` `/oauth/token` proxy. That split is intentional.
51
+ - **Password:** never pass `--password`. Operator login prompts with `getpass`, or reads `SOVARIA_PASSWORD`.
52
+ - **Product user create:** `sov auth-product create --email ...` prompts for the new user's password, or reads `SOVARIA_USER_PASSWORD`. Do not reuse `SOVARIA_PASSWORD` (that is the operator login secret).
53
+ - **Magic link:** `sov auth magic-link verify` reads the token from stdin or `SOVARIA_MAGIC_TOKEN`.
54
+ - **GitHub deploy PAT:** set `SOVARIA_GITHUB_TOKEN` or use `gh auth token`. There is no `--token` flag.
55
+ - **OAuth callback:** binds `127.0.0.1` by default. Non-loopback `SOVARIA_CLI_CALLBACK_HOST` requires `--insecure-callback`.
56
+
57
+ ### Token refresh contract
58
+
59
+ Axis supports two refresh modes. Do not mix them:
60
+
61
+ | Client | How refresh is sent |
62
+ |---|---|
63
+ | **CLI / Python SDK** | JSON body: `POST /api/v1/auth/refresh` with `{"refresh_token": "<stored>"}`. The CLI keeps the refresh token in OS keyring (or `credentials.toml` with `--file-storage`). |
64
+ | **Console / browser** | Empty JSON body `{}` plus the HttpOnly refresh cookie. The browser never puts the refresh token in the request body. |
65
+
66
+ Copying the console cookie pattern into a CLI or SDK client will fail silently (no cookie jar). Copying the CLI JSON-body pattern into a browser app bypasses HttpOnly cookie storage.
67
+
68
+ ### Pulse WebSocket URLs
69
+
70
+ `sov realtime ws-url` issues an opaque `ticket=` query parameter. Never put a JWT in `?token=` — production gateways disable `GATEWAY_WS_ALLOW_QUERY_TOKEN`. Local `.env.local` may still enable the legacy query-token path; do not copy that into clients.
71
+
72
+ ```bash
73
+ sov realtime ws-url my-channel --org <org_id> --project <project_id>
74
+ # wss://api.sovaria.in/api/v1/rt/ws/my-channel?ticket=...&org_id=...&project_id=...
75
+ ```
76
+
40
77
  ## Core Command Groups
41
78
 
42
79
  - `sov auth ...` auth login/logout
@@ -1,3 +1,3 @@
1
1
  """Sovaria CLI — sov command."""
2
2
 
3
- __version__ = "0.2.7"
3
+ __version__ = "0.2.9"
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import getpass
6
+ import os
5
7
  from typing import TypedDict
6
8
  from urllib.parse import quote
7
9
 
@@ -9,7 +11,7 @@ import typer
9
11
 
10
12
  from apps.cli.src.utils.api_client import CLIRequestError, call_api
11
13
  from apps.cli.src.utils.display import handle_cli_error, print_json
12
- from apps.cli.src.utils.premium_display import run_with_spinner
14
+ from apps.cli.src.utils.premium_display import print_error_panel, run_with_spinner
13
15
  from apps.cli.src.utils.config import get_scope
14
16
 
15
17
  app = typer.Typer(help="Manage product users within an org/project scope")
@@ -33,6 +35,19 @@ def _scope_args(
33
35
  }
34
36
 
35
37
 
38
+ def _read_new_user_password() -> str:
39
+ env = (os.getenv("SOVARIA_USER_PASSWORD") or "").strip()
40
+ if env:
41
+ return env
42
+ password = getpass.getpass("New user password: ")
43
+ if not password:
44
+ print_error_panel(
45
+ "Password is required. Enter it at the prompt or set SOVARIA_USER_PASSWORD."
46
+ )
47
+ raise typer.Exit(1)
48
+ return password
49
+
50
+
36
51
  def _project_auth_path(profile: str | None, project_id: str | None, suffix: str) -> str:
37
52
  resolved_project = (project_id or get_scope(profile).get("project_id") or "").strip()
38
53
  if not resolved_project:
@@ -164,7 +179,6 @@ def set_roles(
164
179
  @app.command("create")
165
180
  def create_user(
166
181
  email: str = typer.Option(..., "--email"),
167
- password: str = typer.Option(..., "--password"),
168
182
  name: str | None = typer.Option(None, "--name", help="Display name (defaults to email local-part)"),
169
183
  profile: str | None = typer.Option(None, "--profile"),
170
184
  org_id: str | None = typer.Option(None, "--org"),
@@ -173,6 +187,7 @@ def create_user(
173
187
  ) -> None:
174
188
  """Create a product user in the current project scope."""
175
189
  display_name = name or email.split("@", maxsplit=1)[0]
190
+ password = _read_new_user_password()
176
191
  try:
177
192
  result = run_with_spinner(
178
193
  f"Creating user {email}…",
@@ -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")