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.
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/PKG-INFO +1 -1
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/__init__.py +1 -1
- sovaria_cli-0.2.3/apps/cli/src/commands/context.py +208 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/init_cmd.py +4 -62
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/login.py +34 -11
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/scope.py +30 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/main.py +6 -1
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/utils/config.py +13 -0
- sovaria_cli-0.2.3/apps/cli/src/utils/scope_picker.py +237 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/tests/test_config_profiles.py +7 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/tests/test_main_startup.py +1 -1
- sovaria_cli-0.2.3/apps/cli/tests/test_scope_picker.py +17 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/pyproject.toml +1 -1
- sovaria_cli-0.2.1/apps/cli/src/commands/context.py +0 -99
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/.github/workflows/ci.yml +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/.github/workflows/publish.yml +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/.gitignore +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/.python-version +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/Makefile +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/README.md +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/__init__.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/__init__.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/admin.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/api.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/audit.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/auth_product.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/billing.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/chrono.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/data.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/db.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/deploy.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/governance.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/iam.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/keys.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/org.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/policy.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/project.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/realtime.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/shell.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/sim.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/storage.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/commands/vector.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/utils/__init__.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/utils/api_client.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/utils/auth_store.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/utils/display.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/utils/migrations.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/src/utils/project_context.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/tests/test_auth_store_profiles.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/tests/test_cli_security.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/tests/test_cli_smoke_paths.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/apps/cli/tests/test_project_and_commands.py +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/pytest.ini +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/requirements-dev.txt +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/requirements.txt +0 -0
- {sovaria_cli-0.2.1 → sovaria_cli-0.2.3}/scripts/cli_sdk_happy_path.sh +0 -0
|
@@ -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 =
|
|
119
|
-
project_id =
|
|
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://{
|
|
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(
|
|
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
|
|
209
|
-
"
|
|
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(
|
|
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"{
|
|
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
|
-
{
|
|
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:
|
|
@@ -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"
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|