sovaria-cli 0.2.1__tar.gz → 0.2.3__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 (56) hide show
  1. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/PKG-INFO +1 -1
  2. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/__init__.py +1 -1
  3. sovaria_cli-0.2.3/apps/cli/src/commands/context.py +208 -0
  4. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/init_cmd.py +4 -62
  5. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/login.py +34 -11
  6. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/scope.py +30 -0
  7. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/main.py +6 -1
  8. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/utils/config.py +13 -0
  9. sovaria_cli-0.2.3/apps/cli/src/utils/scope_picker.py +237 -0
  10. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/tests/test_config_profiles.py +7 -0
  11. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/tests/test_main_startup.py +1 -1
  12. sovaria_cli-0.2.3/apps/cli/tests/test_scope_picker.py +17 -0
  13. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/pyproject.toml +1 -1
  14. sovaria_cli-0.2.1/apps/cli/src/commands/context.py +0 -99
  15. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/.github/workflows/ci.yml +0 -0
  16. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/.github/workflows/publish.yml +0 -0
  17. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/.gitignore +0 -0
  18. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/.python-version +0 -0
  19. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/Makefile +0 -0
  20. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/README.md +0 -0
  21. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/__init__.py +0 -0
  22. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/__init__.py +0 -0
  23. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/admin.py +0 -0
  24. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/api.py +0 -0
  25. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/audit.py +0 -0
  26. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/auth_product.py +0 -0
  27. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/billing.py +0 -0
  28. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/chrono.py +0 -0
  29. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/data.py +0 -0
  30. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/db.py +0 -0
  31. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/deploy.py +0 -0
  32. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/governance.py +0 -0
  33. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/iam.py +0 -0
  34. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/keys.py +0 -0
  35. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/org.py +0 -0
  36. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/policy.py +0 -0
  37. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/project.py +0 -0
  38. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/realtime.py +0 -0
  39. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/shell.py +0 -0
  40. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/sim.py +0 -0
  41. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/storage.py +0 -0
  42. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/vector.py +0 -0
  43. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/utils/__init__.py +0 -0
  44. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/utils/api_client.py +0 -0
  45. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/utils/auth_store.py +0 -0
  46. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/utils/display.py +0 -0
  47. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/utils/migrations.py +0 -0
  48. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/utils/project_context.py +0 -0
  49. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/tests/test_auth_store_profiles.py +0 -0
  50. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/tests/test_cli_security.py +0 -0
  51. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/tests/test_cli_smoke_paths.py +0 -0
  52. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/tests/test_project_and_commands.py +0 -0
  53. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/pytest.ini +0 -0
  54. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/requirements-dev.txt +0 -0
  55. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/requirements.txt +0 -0
  56. {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/scripts/cli_sdk_happy_path.sh +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sovaria-cli
3
- Version: 0.2.1
3
+ Version: 0.2.3
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
@@ -1,3 +1,3 @@
1
1
  """Sovaria CLI — sov command."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.3"
@@ -0,0 +1,208 @@
1
+ """
2
+ apps.cli.src.commands.context — Show current user/org context.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import httpx
8
+ import typer
9
+
10
+ from apps.cli.src.utils.api_client import CLIRequestError
11
+ from apps.cli.src.utils.auth_store import load_tokens
12
+ from apps.cli.src.utils.config import get_api_url, get_scope
13
+ from apps.cli.src.utils.project_context import project_status
14
+ from apps.cli.src.utils.scope_picker import (
15
+ fetch_orgs,
16
+ fetch_projects,
17
+ interactive_pick_scope,
18
+ print_access_catalog,
19
+ _node_id,
20
+ _node_name,
21
+ )
22
+
23
+ app = typer.Typer()
24
+
25
+
26
+ def _resolve_user_id(data: dict) -> str:
27
+ for key in ("user_id", "id", "sub"):
28
+ value = str(data.get(key) or "").strip()
29
+ if value:
30
+ return value
31
+ return "-"
32
+
33
+
34
+ @app.command("whoami")
35
+ def whoami(
36
+ profile: str | None = typer.Option(None, "--profile"),
37
+ ) -> None:
38
+ """Print the authenticated user, active scope, and accessible orgs/projects."""
39
+ tokens = load_tokens(profile=profile)
40
+ if not tokens:
41
+ typer.echo("Not logged in. Run: sov login", err=True)
42
+ raise typer.Exit(1)
43
+
44
+ api_url = get_api_url(profile)
45
+ try:
46
+ resp = httpx.get(
47
+ f"{api_url}/api/v1/auth/me",
48
+ headers={"Authorization": f"Bearer {tokens['access_token']}"},
49
+ timeout=10,
50
+ )
51
+ resp.raise_for_status()
52
+ data = resp.json()
53
+ typer.echo(f"user_id : {_resolve_user_id(data)}")
54
+ typer.echo(f"email : {data.get('email', '-')}")
55
+ typer.echo(f"roles : {', '.join(data.get('roles', []))}")
56
+ typer.echo(f"lane : {data.get('actor_lane', '-')}")
57
+ typer.echo(f"subject : {data.get('auth_subject_type', '-')}")
58
+
59
+ scope = get_scope(profile)
60
+ status = project_status()
61
+ linked_scope = status.get("scope") if isinstance(status.get("scope"), dict) else {}
62
+ org_id = str(scope.get("org_id") or linked_scope.get("org_id") or data.get("org_id") or "-")
63
+ project_id = str(
64
+ scope.get("project_id") or linked_scope.get("project_id") or data.get("project_id") or "-"
65
+ )
66
+ env_id = str(scope.get("env_id") or linked_scope.get("env_id") or data.get("env_id") or "-")
67
+ typer.echo(f"scope : org={org_id} project={project_id} env={env_id}")
68
+
69
+ typer.echo("")
70
+ print_access_catalog(profile)
71
+ except httpx.HTTPStatusError as exc:
72
+ if exc.response.status_code == 401:
73
+ typer.echo("Session expired - run: sov login", err=True)
74
+ else:
75
+ typer.echo(f"Error: {exc.response.text}", err=True)
76
+ raise typer.Exit(1) from exc
77
+
78
+
79
+ @app.command("list")
80
+ def list_access(
81
+ profile: str | None = typer.Option(None, "--profile"),
82
+ ) -> None:
83
+ """List organisations and projects you can use with the CLI."""
84
+ if not load_tokens(profile=profile):
85
+ typer.echo("Not logged in. Run: sov login", err=True)
86
+ raise typer.Exit(1)
87
+ print_access_catalog(profile)
88
+
89
+
90
+ @app.command("orgs")
91
+ def list_orgs(
92
+ profile: str | None = typer.Option(None, "--profile"),
93
+ ) -> None:
94
+ """List organisations visible to the authenticated user."""
95
+ if not load_tokens(profile=profile):
96
+ typer.echo("Not logged in. Run: sov login", err=True)
97
+ raise typer.Exit(1)
98
+ orgs = fetch_orgs(profile)
99
+ if not orgs:
100
+ typer.echo("No organisations found.")
101
+ return
102
+ for index, org in enumerate(orgs, start=1):
103
+ typer.echo(f"{index}. {_node_name(org)} org_id={_node_id(org)}")
104
+
105
+
106
+ @app.command("projects")
107
+ def list_projects(
108
+ org_id: str | None = typer.Argument(None, help="Organisation ID (auto-picked when only one org)"),
109
+ profile: str | None = typer.Option(None, "--profile"),
110
+ ) -> None:
111
+ """List projects under an organisation."""
112
+ if not load_tokens(profile=profile):
113
+ typer.echo("Not logged in. Run: sov login", err=True)
114
+ raise typer.Exit(1)
115
+
116
+ resolved_org = org_id
117
+ if not resolved_org:
118
+ orgs = fetch_orgs(profile)
119
+ if not orgs:
120
+ typer.echo("No organisations found.", err=True)
121
+ raise typer.Exit(1)
122
+ if len(orgs) == 1:
123
+ resolved_org = _node_id(orgs[0])
124
+ else:
125
+ typer.echo("Multiple organisations found — pass org_id:", err=True)
126
+ for index, org in enumerate(orgs, start=1):
127
+ typer.echo(f" {index}. {_node_name(org)} org_id={_node_id(org)}")
128
+ raise typer.Exit(1)
129
+
130
+ projects = fetch_projects(resolved_org, profile)
131
+ if not projects:
132
+ typer.echo("No projects found.")
133
+ return
134
+ for index, project in enumerate(projects, start=1):
135
+ env_hint = str(project.get("env_name") or project.get("env_id") or "").strip()
136
+ suffix = f" env={env_hint}" if env_hint else ""
137
+ typer.echo(
138
+ f"{index}. {_node_name(project)} project_id={_node_id(project)}{suffix}"
139
+ )
140
+
141
+
142
+ @app.command("pick")
143
+ def pick_scope(
144
+ profile: str | None = typer.Option(None, "--profile"),
145
+ ) -> None:
146
+ """Interactively pick default org/project scope by index."""
147
+ if not load_tokens(profile=profile):
148
+ typer.echo("Not logged in. Run: sov login", err=True)
149
+ raise typer.Exit(1)
150
+ try:
151
+ scope = interactive_pick_scope(profile)
152
+ except CLIRequestError as exc:
153
+ typer.echo(str(exc), err=True)
154
+ raise typer.Exit(1) from exc
155
+ if scope:
156
+ typer.echo(
157
+ f"Default scope saved: org={scope['org_id']} project={scope['project_id']}"
158
+ + (f" env={scope['env_id']}" if scope.get("env_id") else "")
159
+ )
160
+
161
+
162
+ @app.command("context")
163
+ def context(
164
+ resource_id: str = typer.Argument(..., help="Node ID of the resource (ORG / PROJECT / ENV)"),
165
+ profile: str | None = typer.Option(None, "--profile"),
166
+ ) -> None:
167
+ """
168
+ Show the access roles the current user has on a specific resource.
169
+
170
+ Example:
171
+ sov context proj-abc123
172
+ """
173
+ tokens = load_tokens(profile=profile)
174
+ if not tokens:
175
+ typer.echo("Not logged in. Run: sov login", err=True)
176
+ raise typer.Exit(1)
177
+
178
+ api_url = get_api_url(profile)
179
+ try:
180
+ me = httpx.get(
181
+ f"{api_url}/api/v1/auth/me",
182
+ headers={"Authorization": f"Bearer {tokens['access_token']}"},
183
+ timeout=10,
184
+ )
185
+ me.raise_for_status()
186
+ me_data = me.json()
187
+ user_id = _resolve_user_id(me_data)
188
+ if user_id == "-":
189
+ typer.echo("Could not determine user ID from /auth/me response", err=True)
190
+ raise typer.Exit(1)
191
+
192
+ resp = httpx.get(
193
+ f"{api_url}/api/v1/context/{user_id}/{resource_id}",
194
+ headers={"Authorization": f"Bearer {tokens['access_token']}"},
195
+ timeout=10,
196
+ )
197
+ resp.raise_for_status()
198
+ data = resp.json()
199
+ roles = data.get("roles", [])
200
+ if roles:
201
+ typer.echo(f"You have the following roles on {resource_id}:")
202
+ for role in roles:
203
+ typer.echo(f" • {role}")
204
+ else:
205
+ typer.echo(f"No access to resource {resource_id}")
206
+ except httpx.HTTPStatusError as exc:
207
+ typer.echo(f"Error: {exc.response.text}", err=True)
208
+ raise typer.Exit(1) from exc
@@ -13,69 +13,11 @@ from apps.cli.src.utils.auth_store import load_tokens
13
13
  from apps.cli.src.utils.config import get_api_url, set_scope
14
14
  from apps.cli.src.utils.display import print_banner, print_error, print_success, print_warning
15
15
  from apps.cli.src.utils.project_context import link_project_to_profile
16
+ from apps.cli.src.utils.scope_picker import pick_org, pick_project
16
17
 
17
18
  app = typer.Typer(help="Interactive Sovaria project bootstrap", invoke_without_command=True)
18
19
 
19
20
 
20
- def _pick_org(profile: str | None) -> str:
21
- orgs = call_api(method="GET", path="/api/v1/org", profile=profile)
22
- if not isinstance(orgs, list) or not orgs:
23
- raise CLIRequestError("No organisations found. Create one in the console first.")
24
-
25
- typer.echo("\nOrganisations:")
26
- for index, org in enumerate(orgs, start=1):
27
- name = org.get("name") or org.get("label") or org.get("node_id") or "?"
28
- typer.echo(f" {index}. {name} ({org.get('node_id', '?')})")
29
-
30
- if len(orgs) == 1:
31
- chosen = orgs[0]
32
- typer.echo(f"\nUsing sole organisation: {chosen.get('name') or chosen.get('node_id')}")
33
- else:
34
- selection = typer.prompt("Select organisation number", type=int)
35
- if selection < 1 or selection > len(orgs):
36
- raise CLIRequestError("Invalid organisation selection")
37
- chosen = orgs[selection - 1]
38
-
39
- org_id = str(chosen.get("node_id") or chosen.get("id") or "").strip()
40
- if not org_id:
41
- raise CLIRequestError("Selected organisation is missing node_id")
42
- return org_id
43
-
44
-
45
- def _pick_project(org_id: str, profile: str | None) -> str:
46
- from urllib.parse import quote
47
-
48
- projects = call_api(
49
- method="GET",
50
- path=f"/api/v1/org/{quote(org_id, safe='')}/children",
51
- profile=profile,
52
- params={"relation": "OWNS"},
53
- )
54
- if not isinstance(projects, list) or not projects:
55
- raise CLIRequestError(
56
- "No projects under this organisation. Create one in the console or run `sov org create`."
57
- )
58
-
59
- typer.echo("\nProjects:")
60
- for index, project in enumerate(projects, start=1):
61
- name = project.get("name") or project.get("label") or project.get("node_id") or "?"
62
- typer.echo(f" {index}. {name} ({project.get('node_id', '?')})")
63
-
64
- if len(projects) == 1:
65
- chosen = projects[0]
66
- typer.echo(f"\nUsing sole project: {chosen.get('name') or chosen.get('node_id')}")
67
- else:
68
- selection = typer.prompt("Select project number", type=int)
69
- if selection < 1 or selection > len(projects):
70
- raise CLIRequestError("Invalid project selection")
71
- chosen = projects[selection - 1]
72
-
73
- project_id = str(chosen.get("node_id") or chosen.get("id") or "").strip()
74
- if not project_id:
75
- raise CLIRequestError("Selected project is missing node_id")
76
- return project_id
77
-
78
-
79
21
  @app.callback()
80
22
  def init_wizard(
81
23
  name: str = typer.Option("sovaria-app", "--name", "-n", help="Project name for config"),
@@ -115,13 +57,13 @@ def init_wizard(
115
57
  raise typer.Exit(1) from exc
116
58
 
117
59
  try:
118
- org_id = _pick_org(profile)
119
- project_id = _pick_project(org_id, profile)
60
+ org_id, _ = pick_org(profile)
61
+ project_id, env_from_project, _ = pick_project(org_id, profile)
120
62
  except CLIRequestError as exc:
121
63
  print_error(str(exc))
122
64
  raise typer.Exit(1) from exc
123
65
 
124
- env_id = typer.prompt("Environment ID (optional, press Enter to skip)", default="").strip()
66
+ env_id = env_from_project or typer.prompt("Environment ID (optional, press Enter to skip)", default="").strip()
125
67
 
126
68
  if link:
127
69
  try:
@@ -7,7 +7,7 @@ Phase 1 §9 Sprint 3.2:
7
7
 
8
8
  Flow:
9
9
  1. Generate code_verifier (43-128 random chars) + code_challenge = BASE64URL(SHA-256(verifier))
10
- 2. Open https://{api}/oauth/authorize?response_type=code&...&code_challenge=...
10
+ 2. Open https://{console}/cli/oauth/authorize?response_type=code&...&code_challenge=...
11
11
  3. Start a temporary HTTP callback server (configured via env variables).
12
12
  4. Exchange authorization_code + verifier for token pair.
13
13
  5. Persist tokens via auth_store.save_tokens().
@@ -33,7 +33,8 @@ import typer
33
33
 
34
34
  from apps.cli.src.utils.api_client import CLIMfaRequiredError, _apply_login_response
35
35
  from apps.cli.src.utils.auth_store import clear_mfa_pending, clear_tokens, load_mfa_pending, load_tokens, save_tokens
36
- from apps.cli.src.utils.config import get_api_url
36
+ from apps.cli.src.utils.config import get_api_url, get_console_url
37
+ from apps.cli.src.utils.scope_picker import maybe_setup_scope_after_login
37
38
 
38
39
  app = typer.Typer()
39
40
 
@@ -168,6 +169,11 @@ def login(
168
169
  org_id: str | None = typer.Option(None, "--org", help="Organization ID scope"),
169
170
  project_id: str | None = typer.Option(None, "--project", help="Project ID scope"),
170
171
  env_id: str | None = typer.Option(None, "--env", help="Environment ID scope"),
172
+ pick_scope: bool | None = typer.Option(
173
+ None,
174
+ "--pick-scope/--no-pick-scope",
175
+ help="After login, list orgs/projects and pick default scope (default: on in a TTY)",
176
+ ),
171
177
  ) -> None:
172
178
  """
173
179
  Authenticate with Sovaria.
@@ -182,9 +188,12 @@ def login(
182
188
  import getpass
183
189
 
184
190
  password = getpass.getpass("Password: ")
185
- _password_login(api_url, email, password, org_id, project_id, env_id, profile=profile)
191
+ _password_login(
192
+ api_url, email, password, org_id, project_id, env_id,
193
+ profile=profile, pick_scope=pick_scope,
194
+ )
186
195
  else:
187
- _pkce_login(api_url, profile=profile)
196
+ _pkce_login(api_url, profile=profile, pick_scope=pick_scope)
188
197
 
189
198
 
190
199
  def _password_login(
@@ -195,6 +204,7 @@ def _password_login(
195
204
  project_id: str | None = None,
196
205
  env_id: str | None = None,
197
206
  profile: str | None = None,
207
+ pick_scope: bool | None = None,
198
208
  ) -> None:
199
209
  """Direct credential exchange — bypasses PKCE.
200
210
 
@@ -203,11 +213,10 @@ def _password_login(
203
213
  lane. End-users of customer projects must use the project login on the
204
214
  web — they have no business running ``sov`` against the platform.
205
215
  """
206
- if any((org_id, project_id, env_id)):
216
+ if any((org_id, project_id, env_id)) and not all((org_id, project_id)):
207
217
  typer.echo(
208
- "Note: --org/--project/--env are ignored on `sov login`. "
209
- "The CLI authenticates as a Sovaria platform user; scope is "
210
- "selected per-command via `sov scope use ...`.",
218
+ "Note: pass both --org and --project to set default scope on login, "
219
+ "or omit both to pick interactively after login.",
211
220
  err=True,
212
221
  )
213
222
 
@@ -220,7 +229,14 @@ def _password_login(
220
229
  )
221
230
  resp.raise_for_status()
222
231
  _apply_login_response(resp.json(), profile=profile)
223
- typer.echo(typer.style("Logged in successfully", fg=typer.colors.GREEN))
232
+ typer.echo(typer.style("✓ Logged in successfully", fg=typer.colors.GREEN))
233
+ maybe_setup_scope_after_login(
234
+ profile,
235
+ org_id=org_id,
236
+ project_id=project_id,
237
+ env_id=env_id,
238
+ pick_scope=pick_scope,
239
+ )
224
240
  except CLIMfaRequiredError as exc:
225
241
  typer.echo(typer.style(str(exc), fg=typer.colors.YELLOW), err=True)
226
242
  raise typer.Exit(2) from exc
@@ -234,13 +250,18 @@ def _password_login(
234
250
  raise typer.Exit(1) from exc
235
251
 
236
252
 
237
- def _pkce_login(api_url: str, *, profile: str | None = None) -> None:
253
+ def _pkce_login(
254
+ api_url: str,
255
+ *,
256
+ profile: str | None = None,
257
+ pick_scope: bool | None = None,
258
+ ) -> None:
238
259
  """Full OAuth2 PKCE browser flow."""
239
260
  code_verifier, code_challenge = _generate_pkce_pair()
240
261
  state = secrets.token_hex(16)
241
262
  redirect_uri = _redirect_uri()
242
263
 
243
- auth_url = f"{api_url}/oauth/authorize?" + urlencode(
264
+ auth_url = f"{get_console_url(profile)}/cli/oauth/authorize?" + urlencode(
244
265
  {
245
266
  "response_type": "code",
246
267
  "client_id": "sovaria-cli",
@@ -279,6 +300,7 @@ def _pkce_login(api_url: str, *, profile: str | None = None) -> None:
279
300
  resp.raise_for_status()
280
301
  _apply_login_response(resp.json(), profile=profile)
281
302
  typer.echo(typer.style("✓ Logged in successfully", fg=typer.colors.GREEN))
303
+ maybe_setup_scope_after_login(profile, pick_scope=pick_scope)
282
304
  except CLIMfaRequiredError as exc:
283
305
  typer.echo(typer.style(str(exc), fg=typer.colors.YELLOW), err=True)
284
306
  raise typer.Exit(2) from exc
@@ -343,6 +365,7 @@ def mfa_verify(
343
365
  _apply_login_response(resp.json(), profile=profile)
344
366
  clear_mfa_pending(profile=profile)
345
367
  typer.echo(typer.style("MFA verified — full session established.", fg=typer.colors.GREEN))
368
+ maybe_setup_scope_after_login(profile)
346
369
  except CLIMfaRequiredError as exc:
347
370
  typer.echo(str(exc), err=True)
348
371
  raise typer.Exit(2) from exc
@@ -4,7 +4,10 @@ from __future__ import annotations
4
4
 
5
5
  import typer
6
6
 
7
+ from apps.cli.src.utils.api_client import CLIRequestError
8
+ from apps.cli.src.utils.auth_store import load_tokens
7
9
  from apps.cli.src.utils.config import clear_scope, get_scope, set_scope
10
+ from apps.cli.src.utils.scope_picker import interactive_pick_scope, print_access_catalog
8
11
 
9
12
  app = typer.Typer(help="Manage default org/project/env scope")
10
13
 
@@ -40,3 +43,30 @@ def clear_scope_cmd(profile: str | None = typer.Option(None, "--profile")) -> No
40
43
  """Clear default scope for the active profile."""
41
44
  clear_scope(profile)
42
45
  typer.echo("Default scope cleared.")
46
+
47
+
48
+ @app.command("list")
49
+ def list_scope_access(profile: str | None = typer.Option(None, "--profile")) -> None:
50
+ """List organisations and projects available to your account."""
51
+ if not load_tokens(profile=profile):
52
+ typer.echo("Not logged in. Run: sov login", err=True)
53
+ raise typer.Exit(1)
54
+ print_access_catalog(profile)
55
+
56
+
57
+ @app.command("pick")
58
+ def pick_scope_cmd(profile: str | None = typer.Option(None, "--profile")) -> None:
59
+ """Pick default org/project interactively by index."""
60
+ if not load_tokens(profile=profile):
61
+ typer.echo("Not logged in. Run: sov login", err=True)
62
+ raise typer.Exit(1)
63
+ try:
64
+ scope = interactive_pick_scope(profile)
65
+ except CLIRequestError as exc:
66
+ typer.echo(str(exc), err=True)
67
+ raise typer.Exit(1) from exc
68
+ if scope:
69
+ typer.echo(
70
+ f"Default scope saved: org={scope['org_id']} project={scope['project_id']}"
71
+ + (f" env={scope['env_id']}" if scope.get("env_id") else "")
72
+ )
@@ -103,6 +103,8 @@ app.add_typer(shell_mod.app, name="shell", help="Interactive Sovaria REPL")
103
103
  app.command("login")(login_mod.login)
104
104
  app.command("logout")(login_mod.logout)
105
105
  app.command("whoami")(context_mod.whoami)
106
+ app.command("orgs")(context_mod.list_orgs)
107
+ app.command("projects")(context_mod.list_projects)
106
108
  app.command("context")(context_mod.context)
107
109
  app.command("deploy")(deploy_mod.deploy)
108
110
 
@@ -117,7 +119,10 @@ def _version_callback(value: bool) -> None:
117
119
 
118
120
 
119
121
  _PLAN_GATE_BYPASS_COMMANDS = frozenset(
120
- {"login", "auth", "logout", "scope", "whoami", "context", "init", "project", None}
122
+ {
123
+ "login", "auth", "logout", "scope", "whoami", "context",
124
+ "orgs", "projects", "init", "project", None,
125
+ }
121
126
  )
122
127
 
123
128
 
@@ -76,6 +76,19 @@ def _profile_env_default(profile: str) -> str:
76
76
  return _DEFAULT_API_URL or DEFAULT_CLOUD_API_URL
77
77
 
78
78
 
79
+ def get_console_url(profile: str | None = None) -> str:
80
+ """Console origin used for browser OAuth (PKCE) flows."""
81
+ explicit = os.getenv("SOVARIA_CLI_CONSOLE_URL", "").strip()
82
+ if explicit:
83
+ return explicit.rstrip("/")
84
+
85
+ api_url = get_api_url(profile)
86
+ if "://api." in api_url:
87
+ return api_url.replace("://api.", "://console.", 1).rstrip("/")
88
+
89
+ return "https://console.sovaria.in"
90
+
91
+
79
92
  def get_api_url(profile: str | None = None) -> str:
80
93
  cfg = _load_yaml()
81
94
  prof = profile or get_active_profile()
@@ -0,0 +1,237 @@
1
+ """Interactive org/project scope selection for the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Any
7
+ from urllib.parse import quote
8
+
9
+ import typer
10
+
11
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api
12
+ from apps.cli.src.utils.config import get_scope, set_scope
13
+ from apps.cli.src.utils.project_context import find_project_config, link_project_to_profile
14
+
15
+
16
+ def _node_id(item: dict[str, Any]) -> str:
17
+ for key in ("node_id", "id"):
18
+ value = str(item.get(key) or "").strip()
19
+ if value:
20
+ return value
21
+ return ""
22
+
23
+
24
+ def _node_name(item: dict[str, Any]) -> str:
25
+ return str(item.get("name") or item.get("label") or _node_id(item) or "?")
26
+
27
+
28
+ def fetch_orgs(profile: str | None = None) -> list[dict[str, Any]]:
29
+ result = call_api(method="GET", path="/api/v1/org", profile=profile)
30
+ if not isinstance(result, list):
31
+ return []
32
+ return [item for item in result if isinstance(item, dict)]
33
+
34
+
35
+ def fetch_projects(org_id: str, profile: str | None = None) -> list[dict[str, Any]]:
36
+ result = call_api(
37
+ method="GET",
38
+ path=f"/api/v1/org/{quote(org_id, safe='')}/children",
39
+ profile=profile,
40
+ params={"relation": "OWNS"},
41
+ )
42
+ if not isinstance(result, list):
43
+ return []
44
+ return [item for item in result if isinstance(item, dict)]
45
+
46
+
47
+ def print_access_catalog(profile: str | None = None) -> None:
48
+ """Print organisations and projects the signed-in user can access."""
49
+ orgs = fetch_orgs(profile)
50
+ if not orgs:
51
+ typer.echo("No organisations found. Create one in the console first.")
52
+ return
53
+
54
+ typer.echo("Your organisations and projects:\n")
55
+ for org_index, org in enumerate(orgs, start=1):
56
+ org_id = _node_id(org)
57
+ typer.echo(f" [{org_index}] {_node_name(org)}")
58
+ typer.echo(f" org_id: {org_id or '?'}")
59
+ if not org_id:
60
+ continue
61
+ projects = fetch_projects(org_id, profile)
62
+ if not projects:
63
+ typer.echo(" (no projects)")
64
+ continue
65
+ for project_index, project in enumerate(projects, start=1):
66
+ project_id = _node_id(project)
67
+ env_id = str(project.get("env_id") or "").strip()
68
+ env_label = str(project.get("env_name") or env_id or "").strip()
69
+ typer.echo(f" [{project_index}] {_node_name(project)}")
70
+ typer.echo(f" project_id: {project_id or '?'}")
71
+ if env_label:
72
+ typer.echo(f" env: {env_label}" + (f" ({env_id})" if env_id else ""))
73
+
74
+
75
+ def pick_org(profile: str | None = None) -> tuple[str, dict[str, Any]]:
76
+ orgs = fetch_orgs(profile)
77
+ if not orgs:
78
+ raise CLIRequestError("No organisations found. Create one in the console first.")
79
+
80
+ typer.echo("\nOrganisations:")
81
+ for index, org in enumerate(orgs, start=1):
82
+ typer.echo(f" {index}. {_node_name(org)} ({_node_id(org) or '?'})")
83
+
84
+ if len(orgs) == 1:
85
+ chosen = orgs[0]
86
+ typer.echo(f"\nUsing sole organisation: {_node_name(chosen)}")
87
+ else:
88
+ selection = typer.prompt("Select organisation number", type=int)
89
+ if selection < 1 or selection > len(orgs):
90
+ raise CLIRequestError("Invalid organisation selection")
91
+ chosen = orgs[selection - 1]
92
+
93
+ org_id = _node_id(chosen)
94
+ if not org_id:
95
+ raise CLIRequestError("Selected organisation is missing an ID")
96
+ return org_id, chosen
97
+
98
+
99
+ def pick_project(org_id: str, profile: str | None = None) -> tuple[str, str | None, dict[str, Any]]:
100
+ projects = fetch_projects(org_id, profile)
101
+ if not projects:
102
+ raise CLIRequestError(
103
+ "No projects under this organisation. Create one in the console first."
104
+ )
105
+
106
+ typer.echo("\nProjects:")
107
+ for index, project in enumerate(projects, start=1):
108
+ env_hint = str(project.get("env_name") or project.get("env_id") or "").strip()
109
+ suffix = f" env={env_hint}" if env_hint else ""
110
+ typer.echo(f" {index}. {_node_name(project)} ({_node_id(project) or '?'}){suffix}")
111
+
112
+ if len(projects) == 1:
113
+ chosen = projects[0]
114
+ typer.echo(f"\nUsing sole project: {_node_name(chosen)}")
115
+ else:
116
+ selection = typer.prompt("Select project number", type=int)
117
+ if selection < 1 or selection > len(projects):
118
+ raise CLIRequestError("Invalid project selection")
119
+ chosen = projects[selection - 1]
120
+
121
+ project_id = _node_id(chosen)
122
+ if not project_id:
123
+ raise CLIRequestError("Selected project is missing an ID")
124
+ env_id = str(chosen.get("env_id") or "").strip() or None
125
+ return project_id, env_id, chosen
126
+
127
+
128
+ def apply_scope(
129
+ *,
130
+ org_id: str,
131
+ project_id: str,
132
+ env_id: str | None = None,
133
+ profile: str | None = None,
134
+ link_config: bool = True,
135
+ ) -> None:
136
+ """Persist scope to profile and local sovaria.config.json when present."""
137
+ set_scope(org_id=org_id, project_id=project_id, env_id=env_id, profile=profile)
138
+ config_path = find_project_config()
139
+ if link_config and config_path is not None:
140
+ link_project_to_profile(
141
+ org_id=org_id,
142
+ project_id=project_id,
143
+ env_id=env_id,
144
+ profile=profile,
145
+ config_path=config_path,
146
+ )
147
+
148
+
149
+ def interactive_pick_scope(
150
+ profile: str | None = None,
151
+ *,
152
+ link_config: bool = True,
153
+ ) -> dict[str, str] | None:
154
+ """Prompt for org/project by index and save as default scope."""
155
+ org_id, _ = pick_org(profile)
156
+ project_id, env_id, _ = pick_project(org_id, profile)
157
+ apply_scope(
158
+ org_id=org_id,
159
+ project_id=project_id,
160
+ env_id=env_id,
161
+ profile=profile,
162
+ link_config=link_config,
163
+ )
164
+ return {
165
+ "org_id": org_id,
166
+ "project_id": project_id,
167
+ "env_id": env_id or "",
168
+ }
169
+
170
+
171
+ def scope_is_configured(profile: str | None = None) -> bool:
172
+ scope = get_scope(profile)
173
+ local_path = find_project_config()
174
+ if local_path is not None:
175
+ from apps.cli.src.utils.project_context import linked_scope
176
+
177
+ local = linked_scope(profile)
178
+ if local.get("org_id") and local.get("project_id"):
179
+ return True
180
+ return bool(scope.get("org_id") and scope.get("project_id"))
181
+
182
+
183
+ def maybe_setup_scope_after_login(
184
+ profile: str | None = None,
185
+ *,
186
+ org_id: str | None = None,
187
+ project_id: str | None = None,
188
+ env_id: str | None = None,
189
+ pick_scope: bool | None = None,
190
+ ) -> None:
191
+ """After auth, show accessible orgs/projects and optionally pick default scope."""
192
+ if org_id and project_id:
193
+ apply_scope(
194
+ org_id=org_id,
195
+ project_id=project_id,
196
+ env_id=env_id,
197
+ profile=profile,
198
+ )
199
+ typer.echo(
200
+ f"Active scope: org={org_id} project={project_id}"
201
+ + (f" env={env_id}" if env_id else "")
202
+ )
203
+ return
204
+
205
+ if scope_is_configured(profile):
206
+ scope = get_scope(profile)
207
+ typer.echo(
208
+ f"Active scope: org={scope.get('org_id')} project={scope.get('project_id')}"
209
+ + (f" env={scope.get('env_id')}" if scope.get("env_id") else "")
210
+ )
211
+ return
212
+
213
+ typer.echo("")
214
+ print_access_catalog(profile)
215
+
216
+ should_pick = pick_scope if pick_scope is not None else sys.stdin.isatty()
217
+ if not should_pick:
218
+ typer.echo("\nSet default scope with: sov scope pick")
219
+ return
220
+
221
+ if not typer.confirm("\nPick a default org/project for CLI commands now?", default=True):
222
+ typer.echo("Skipped. Run `sov scope pick` or `sov init` when ready.")
223
+ return
224
+
225
+ try:
226
+ scope = interactive_pick_scope(profile)
227
+ except CLIRequestError as exc:
228
+ typer.echo(str(exc), err=True)
229
+ return
230
+
231
+ if scope:
232
+ typer.echo(
233
+ typer.style("✓ Default scope saved.", fg=typer.colors.GREEN)
234
+ + f" org={scope['org_id']} project={scope['project_id']}"
235
+ + (f" env={scope['env_id']}" if scope.get("env_id") else "")
236
+ )
237
+ typer.echo("Try: sov db tables")
@@ -45,6 +45,13 @@ def test_get_api_url_defaults_to_cloud_when_unconfigured(
45
45
  assert config.get_api_url("prod") == config.DEFAULT_CLOUD_API_URL
46
46
 
47
47
 
48
+ def test_get_console_url_defaults_from_api(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
49
+ monkeypatch.setattr(config, "_CONFIG_FILE", tmp_path / "missing.yaml")
50
+ monkeypatch.delenv("SOVARIA_CLI_CONSOLE_URL", raising=False)
51
+ monkeypatch.delenv("SOVARIA_CLI_DEFAULT_API_URL", raising=False)
52
+ assert config.get_console_url() == "https://console.sovaria.in"
53
+
54
+
48
55
  def test_get_scope_coerces_string_profile(
49
56
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
50
57
  ) -> None:
@@ -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.1" in result.stdout
29
+ assert "sov version 0.2.3" in result.stdout
@@ -0,0 +1,17 @@
1
+ """Tests for interactive scope picker helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from apps.cli.src.utils import scope_picker
6
+
7
+
8
+ def test_node_id_prefers_node_id_field() -> None:
9
+ assert scope_picker._node_id({"node_id": "org-1", "id": "legacy"}) == "org-1"
10
+
11
+
12
+ def test_node_id_falls_back_to_id() -> None:
13
+ assert scope_picker._node_id({"id": "proj-1"}) == "proj-1"
14
+
15
+
16
+ def test_node_name_prefers_name() -> None:
17
+ assert scope_picker._node_name({"name": "Acme", "node_id": "org-1"}) == "Acme"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sovaria-cli"
7
- version = "0.2.1"
7
+ version = "0.2.3"
8
8
  description = "Sovaria BaaS command-line interface (sov)"
9
9
  readme = "apps/cli/README.md"
10
10
  requires-python = ">=3.11"
@@ -1,99 +0,0 @@
1
- """
2
- apps.cli.src.commands.context — Show current user/org context.
3
-
4
- Phase 1 §9 Sprint 3.2: `sov context` prints who you are and what
5
- resources you have access to, by querying the Axis context endpoint
6
- through the Gateway.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import httpx
12
- import typer
13
-
14
- from apps.cli.src.utils.auth_store import load_tokens
15
- from apps.cli.src.utils.config import get_api_url
16
-
17
- app = typer.Typer()
18
-
19
-
20
- @app.command("whoami")
21
- def whoami(
22
- profile: str | None = typer.Option(None, "--profile"),
23
- ) -> None:
24
- """Print the currently authenticated user."""
25
- tokens = load_tokens(profile=profile)
26
- if not tokens:
27
- typer.echo("Not logged in. Run: sov login", err=True)
28
- raise typer.Exit(1)
29
-
30
- api_url = get_api_url(profile)
31
- try:
32
- resp = httpx.get(
33
- f"{api_url}/api/v1/auth/me",
34
- headers={"Authorization": f"Bearer {tokens['access_token']}"},
35
- timeout=10,
36
- )
37
- resp.raise_for_status()
38
- data = resp.json()
39
- typer.echo(f"user_id : {data.get('user_id', '-')}")
40
- typer.echo(f"email : {data.get('email', '-')}")
41
- typer.echo(f"roles : {', '.join(data.get('roles', []))}")
42
- typer.echo(f"lane : {data.get('actor_lane', '-')}")
43
- typer.echo(f"subject : {data.get('auth_subject_type', '-')}")
44
- except httpx.HTTPStatusError as exc:
45
- if exc.response.status_code == 401:
46
- typer.echo("Session expired - run: sov login", err=True)
47
- else:
48
- typer.echo(f"Error: {exc.response.text}", err=True)
49
- raise typer.Exit(1) from exc
50
-
51
-
52
- @app.command("context")
53
- def context(
54
- resource_id: str = typer.Argument(..., help="Node ID of the resource (ORG / PROJECT / ENV)"),
55
- profile: str | None = typer.Option(None, "--profile"),
56
- ) -> None:
57
- """
58
- Show the access roles the current user has on a specific resource.
59
-
60
- Example:
61
- sov context proj-abc123
62
- """
63
- tokens = load_tokens(profile=profile)
64
- if not tokens:
65
- typer.echo("Not logged in. Run: sov login", err=True)
66
- raise typer.Exit(1)
67
-
68
- api_url = get_api_url(profile)
69
- try:
70
- # First resolve user_id from the access token by calling whoami.
71
- me = httpx.get(
72
- f"{api_url}/api/v1/auth/me",
73
- headers={"Authorization": f"Bearer {tokens['access_token']}"},
74
- timeout=10,
75
- )
76
- me.raise_for_status()
77
- me_data = me.json()
78
- user_id = me_data.get("user_id") or me_data.get("sub") or me_data.get("id")
79
- if not user_id:
80
- typer.echo("Could not determine user ID from /auth/me response", err=True)
81
- raise typer.Exit(1)
82
-
83
- resp = httpx.get(
84
- f"{api_url}/api/v1/context/{user_id}/{resource_id}",
85
- headers={"Authorization": f"Bearer {tokens['access_token']}"},
86
- timeout=10,
87
- )
88
- resp.raise_for_status()
89
- data = resp.json()
90
- roles = data.get("roles", [])
91
- if roles:
92
- typer.echo(f"You have the following roles on {resource_id}:")
93
- for role in roles:
94
- typer.echo(f" • {role}")
95
- else:
96
- typer.echo(f"No access to resource {resource_id}")
97
- except httpx.HTTPStatusError as exc:
98
- typer.echo(f"Error: {exc.response.text}", err=True)
99
- raise typer.Exit(1) from exc
File without changes
File without changes
File without changes
File without changes