sovaria-cli 0.2.0__py3-none-any.whl

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 (46) hide show
  1. apps/cli/README.md +147 -0
  2. apps/cli/__init__.py +0 -0
  3. apps/cli/src/__init__.py +3 -0
  4. apps/cli/src/commands/__init__.py +1 -0
  5. apps/cli/src/commands/admin.py +160 -0
  6. apps/cli/src/commands/api.py +48 -0
  7. apps/cli/src/commands/audit.py +181 -0
  8. apps/cli/src/commands/auth_product.py +187 -0
  9. apps/cli/src/commands/billing.py +513 -0
  10. apps/cli/src/commands/chrono.py +248 -0
  11. apps/cli/src/commands/context.py +99 -0
  12. apps/cli/src/commands/data.py +272 -0
  13. apps/cli/src/commands/db.py +528 -0
  14. apps/cli/src/commands/deploy.py +1099 -0
  15. apps/cli/src/commands/governance.py +491 -0
  16. apps/cli/src/commands/iam.py +436 -0
  17. apps/cli/src/commands/init_cmd.py +177 -0
  18. apps/cli/src/commands/keys.py +120 -0
  19. apps/cli/src/commands/login.py +842 -0
  20. apps/cli/src/commands/org.py +157 -0
  21. apps/cli/src/commands/policy.py +317 -0
  22. apps/cli/src/commands/project.py +130 -0
  23. apps/cli/src/commands/realtime.py +364 -0
  24. apps/cli/src/commands/scope.py +42 -0
  25. apps/cli/src/commands/shell.py +415 -0
  26. apps/cli/src/commands/sim.py +178 -0
  27. apps/cli/src/commands/storage.py +405 -0
  28. apps/cli/src/commands/vector.py +204 -0
  29. apps/cli/src/main.py +169 -0
  30. apps/cli/src/utils/__init__.py +1 -0
  31. apps/cli/src/utils/api_client.py +329 -0
  32. apps/cli/src/utils/auth_store.py +299 -0
  33. apps/cli/src/utils/config.py +185 -0
  34. apps/cli/src/utils/display.py +62 -0
  35. apps/cli/src/utils/migrations.py +69 -0
  36. apps/cli/src/utils/project_context.py +124 -0
  37. apps/cli/tests/test_auth_store_profiles.py +103 -0
  38. apps/cli/tests/test_cli_security.py +110 -0
  39. apps/cli/tests/test_cli_smoke_paths.py +279 -0
  40. apps/cli/tests/test_config_profiles.py +55 -0
  41. apps/cli/tests/test_main_startup.py +29 -0
  42. apps/cli/tests/test_project_and_commands.py +196 -0
  43. sovaria_cli-0.2.0.dist-info/METADATA +170 -0
  44. sovaria_cli-0.2.0.dist-info/RECORD +46 -0
  45. sovaria_cli-0.2.0.dist-info/WHEEL +4 -0
  46. sovaria_cli-0.2.0.dist-info/entry_points.txt +2 -0
apps/cli/src/main.py ADDED
@@ -0,0 +1,169 @@
1
+ """apps.cli.src.main — ``sov`` CLI entrypoint.
2
+
3
+ The CLI is a platform tool for Sovaria customers on plans that unlock CLI
4
+ access (``ultra`` and ``enterprise`` by default — see plan_service in axis).
5
+ It authenticates against the platform console lane and never against
6
+ project end-user credentials.
7
+
8
+ Top-level commands cover the day-to-day operations a platform user runs:
9
+
10
+ sov auth sessions List/revoke login sessions
11
+ sov auth mfa-enroll Enroll TOTP MFA
12
+ sov fn logs|get|delete Function logs and lifecycle
13
+ sov logout Revoke and clear local tokens
14
+ sov whoami Show current user
15
+ sov auth-product ... Manage product users (project scope)
16
+ sov scope ... Manage default org/project/env scope
17
+ sov project ... Scaffold + manage projects
18
+ sov init Interactive project bootstrap wizard
19
+ sov shell Interactive REPL (SQL, sim, keys, policy)
20
+ sov sim ... Simulation discover / runs / compare
21
+ sov keys ... Project API keys
22
+ sov iam ... Organisations, members, settings
23
+ sov org ... Organisation membership / create
24
+ sov db ... Atlas-Rel SQL operations
25
+ sov db tx begin|commit|rollback
26
+ sov db migrations push|pull|list
27
+ sov db column add|drop
28
+ sov db fk add|drop
29
+ sov data ... Atlas-Doc + Atlas-Rel data plane
30
+ sov storage ... Vault buckets/objects
31
+ sov fn ... Forge function management
32
+ sov chrono ... Timelines + snapshots
33
+ sov audit ... Echo audit trail + simulations
34
+ sov policy ... Guard policy admin
35
+ sov realtime ... Pulse channels + E2EE keys
36
+ sov api ... Raw gateway API call (escape hatch)
37
+ """
38
+
39
+ import typer
40
+
41
+ from apps.cli.src.commands import api as api_mod
42
+ from apps.cli.src.commands import admin as admin_mod
43
+ from apps.cli.src.commands import audit as audit_mod
44
+ from apps.cli.src.commands import auth_product as auth_product_mod
45
+ from apps.cli.src.commands import billing as billing_mod
46
+ from apps.cli.src.commands import chrono as chrono_mod
47
+ from apps.cli.src.commands import context as context_mod
48
+ from apps.cli.src.commands import data as data_mod
49
+ from apps.cli.src.commands import db as db_mod
50
+ from apps.cli.src.commands import deploy as deploy_mod
51
+ from apps.cli.src.commands import governance as governance_mod
52
+ from apps.cli.src.commands import iam as iam_mod
53
+ from apps.cli.src.commands import init_cmd as init_mod
54
+ from apps.cli.src.commands import keys as keys_mod
55
+ from apps.cli.src.commands import login as login_mod
56
+ from apps.cli.src.commands import org as org_mod
57
+ from apps.cli.src.commands import policy as policy_mod
58
+ from apps.cli.src.commands import project as project_mod
59
+ from apps.cli.src.commands import realtime as realtime_mod
60
+ from apps.cli.src.commands import scope as scope_mod
61
+ from apps.cli.src.commands import shell as shell_mod
62
+ from apps.cli.src.commands import sim as sim_mod
63
+ from apps.cli.src.commands import storage as storage_mod
64
+ from apps.cli.src.commands import vector as vector_mod
65
+ from apps.cli.src.utils.config import write_default_config
66
+
67
+ app = typer.Typer(
68
+ name="sov",
69
+ help="Sovaria BaaS command-line interface",
70
+ add_completion=True,
71
+ pretty_exceptions_enable=False, # let Python print raw tracebacks in dev
72
+ )
73
+
74
+ # ── Sub-app registration ──────────────────────────────────────────────────────
75
+ app.add_typer(login_mod.app, name="auth", help="Authentication commands")
76
+ app.add_typer(auth_product_mod.app, name="auth-product", help="Product auth admin commands")
77
+ app.add_typer(context_mod.app, name="context", help="Context and identity commands")
78
+ app.add_typer(scope_mod.app, name="scope", help="Manage default org/project/env scope")
79
+ app.add_typer(project_mod.app, name="project", help="Project scaffold commands")
80
+ app.add_typer(org_mod.app, name="org", help="Organisation management")
81
+ app.add_typer(db_mod.app, name="db", help="Database (SQL) management")
82
+ app.add_typer(data_mod.app, name="data", help="Atlas-Doc and Atlas-Rel data plane")
83
+ app.add_typer(storage_mod.app, name="storage", help="Object storage management")
84
+ app.add_typer(deploy_mod.app, name="fn", help="Cloud function management")
85
+ app.add_typer(chrono_mod.app, name="chrono", help="Timeline and snapshot operations")
86
+ app.add_typer(audit_mod.app, name="audit", help="Echo audit trail and simulations")
87
+ app.add_typer(policy_mod.app, name="policy", help="Guard policy engine commands")
88
+ app.add_typer(realtime_mod.app, name="realtime", help="Realtime stream and E2EE")
89
+ app.add_typer(billing_mod.app, name="billing", help="Billing plans, subscriptions, and usage")
90
+ app.add_typer(governance_mod.app, name="governance", help="Platform governance and tenant databases")
91
+ app.add_typer(admin_mod.app, name="admin", help="Platform admin: error traces and usage")
92
+ app.add_typer(vector_mod.app, name="vector", help="Vector embeddings and semantic search")
93
+ app.add_typer(sim_mod.app, name="sim", help="Scenario discovery, runs, and comparisons")
94
+ app.add_typer(keys_mod.app, name="keys", help="Project API keys")
95
+ app.add_typer(iam_mod.app, name="iam", help="Organisations, members, and project settings")
96
+ app.add_typer(api_mod.app, name="api", help="Raw gateway API calls")
97
+
98
+ # Top-level interactive / bootstrap commands
99
+ app.add_typer(init_mod.app, name="init", help="Interactive project bootstrap")
100
+ app.add_typer(shell_mod.app, name="shell", help="Interactive Sovaria REPL")
101
+
102
+ # Convenience top-level aliases for the most common verbs.
103
+ app.command("login")(login_mod.login)
104
+ app.command("logout")(login_mod.logout)
105
+ app.command("whoami")(context_mod.whoami)
106
+ app.command("context")(context_mod.context)
107
+ app.command("deploy")(deploy_mod.deploy)
108
+
109
+
110
+ # ── Version flag ──────────────────────────────────────────────────────────────
111
+ def _version_callback(value: bool) -> None:
112
+ if value:
113
+ from apps.cli.src import __version__
114
+
115
+ typer.echo(f"sov version {__version__}")
116
+ raise typer.Exit()
117
+
118
+
119
+ _PLAN_GATE_BYPASS_COMMANDS = frozenset(
120
+ {"login", "auth", "logout", "scope", "whoami", "context", "init", "project", None}
121
+ )
122
+
123
+
124
+ @app.callback(invoke_without_command=True)
125
+ def main(
126
+ ctx: typer.Context,
127
+ version: bool = typer.Option(
128
+ False, "--version", "-V", callback=_version_callback, is_eager=True
129
+ ),
130
+ ) -> None:
131
+ """Sovaria BaaS CLI — manage your identity graph, deploy functions, and more."""
132
+ # Ensure ~/.sovaria/config.yaml exists on first run
133
+ write_default_config()
134
+
135
+ invoked = ctx.invoked_subcommand
136
+ if invoked is None:
137
+ typer.echo(ctx.get_help())
138
+ return
139
+
140
+ # Plan-gate every command that actually talks to the API, except the
141
+ # auth-bootstrap commands themselves (you can't check your plan before
142
+ # you've logged in) and the version flag.
143
+ if invoked not in _PLAN_GATE_BYPASS_COMMANDS:
144
+ from apps.cli.src.utils.api_client import (
145
+ CLIPlanError,
146
+ CLIRequestError,
147
+ assert_cli_access,
148
+ )
149
+ from apps.cli.src.utils.auth_store import load_tokens
150
+
151
+ if load_tokens():
152
+ try:
153
+ assert_cli_access()
154
+ except CLIPlanError as exc:
155
+ typer.echo(typer.style(str(exc), fg=typer.colors.RED), err=True)
156
+ raise typer.Exit(2) from exc
157
+ except CLIRequestError:
158
+ # Network/auth errors are not fatal here — the actual command
159
+ # will surface a more actionable error if relevant.
160
+ pass
161
+
162
+
163
+ def cli_entry() -> None:
164
+ """Console script entry point for the ``sov`` command."""
165
+ app()
166
+
167
+
168
+ if __name__ == "__main__":
169
+ cli_entry()
@@ -0,0 +1 @@
1
+ """CLI utilities package."""
@@ -0,0 +1,329 @@
1
+ """Shared HTTP helpers for CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ import uuid
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from apps.cli.src.utils.auth_store import load_mfa_pending, load_tokens, save_tokens
14
+ from apps.cli.src.utils.config import get_api_url, get_scope
15
+ from apps.cli.src.utils.project_context import resolve_scope as resolve_project_scope
16
+
17
+ _MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
18
+ _REFRESH_MARGIN_SECONDS = 60
19
+
20
+
21
+ class CLIRequestError(RuntimeError):
22
+ """Raised when a CLI API request fails."""
23
+
24
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
25
+ super().__init__(message)
26
+ self.status_code = status_code
27
+
28
+
29
+ class CLIPlanError(CLIRequestError):
30
+ """Raised when the user's plan does not unlock CLI access."""
31
+
32
+
33
+ class CLIMfaRequiredError(CLIRequestError):
34
+ """Raised when login returned a pre-MFA challenge instead of a full session."""
35
+
36
+
37
+ def _plan_check_enabled() -> bool:
38
+ """Return True if the CLI should enforce plan-gating before commands run."""
39
+ return (os.getenv("SOV_DISABLE_PLAN_CHECK", "") or "").strip().lower() not in {
40
+ "1",
41
+ "true",
42
+ "yes",
43
+ }
44
+
45
+
46
+ def assert_cli_access(profile: str | None = None) -> dict[str, Any]:
47
+ """Fetch /auth/me, return the response, and refuse if cli_access is False."""
48
+ me = call_api(method="GET", path="/api/v1/auth/me", profile=profile, _skip_plan_check=True)
49
+ if not isinstance(me, dict):
50
+ return {}
51
+ if not _plan_check_enabled():
52
+ return me
53
+ plan = str(me.get("plan") or "").strip().lower() or "free"
54
+ features = me.get("plan_features") or {}
55
+ if isinstance(features, dict) and not features.get("cli_access", False):
56
+ raise CLIPlanError(
57
+ f"Your '{plan}' plan does not include CLI access. "
58
+ "Upgrade to 'ultra' or 'enterprise' to use the Sovaria CLI: "
59
+ "https://sovaria.in/pricing"
60
+ )
61
+ return me
62
+
63
+
64
+ def _resolve_scope(
65
+ *,
66
+ profile: str | None,
67
+ org_id: str | None,
68
+ project_id: str | None,
69
+ env_id: str | None,
70
+ require_scope: bool = False,
71
+ ) -> tuple[str, str, str]:
72
+ try:
73
+ return resolve_project_scope(
74
+ profile=profile,
75
+ org_id=org_id,
76
+ project_id=project_id,
77
+ env_id=env_id,
78
+ require=require_scope,
79
+ )
80
+ except ValueError as exc:
81
+ raise CLIRequestError(str(exc)) from exc
82
+
83
+
84
+ def _scope_headers(org_id: str, project_id: str, env_id: str) -> dict[str, str]:
85
+ headers: dict[str, str] = {}
86
+ if org_id:
87
+ headers["X-Sovaria-Org-ID"] = org_id
88
+ if project_id:
89
+ headers["X-Sovaria-Project-ID"] = project_id
90
+ if env_id:
91
+ headers["X-Sovaria-Env-ID"] = env_id
92
+ return headers
93
+
94
+
95
+ def _token_needs_refresh(tokens: dict[str, Any]) -> bool:
96
+ refresh_token = str(tokens.get("refresh_token") or "").strip()
97
+ if not refresh_token:
98
+ return False
99
+ expires_at = tokens.get("expires_at")
100
+ if expires_at is None:
101
+ return False
102
+ try:
103
+ return time.time() >= float(expires_at) - _REFRESH_MARGIN_SECONDS
104
+ except (TypeError, ValueError):
105
+ return False
106
+
107
+
108
+ def _refresh_access_token(profile: str | None) -> dict[str, Any]:
109
+ tokens = load_tokens(profile=profile) or {}
110
+ refresh_token = str(tokens.get("refresh_token") or "").strip()
111
+ if not refresh_token:
112
+ return tokens
113
+
114
+ api_url = get_api_url(profile).rstrip("/")
115
+ try:
116
+ response = httpx.post(
117
+ f"{api_url}/api/v1/auth/refresh",
118
+ json={"refresh_token": refresh_token},
119
+ timeout=15,
120
+ )
121
+ response.raise_for_status()
122
+ except httpx.HTTPError as exc:
123
+ raise CLIRequestError(
124
+ "Session expired and refresh failed. Run: sov auth login",
125
+ status_code=getattr(getattr(exc, "response", None), "status_code", None),
126
+ ) from exc
127
+
128
+ data = response.json()
129
+ access_token = str(data.get("access_token") or "").strip()
130
+ new_refresh = str(data.get("refresh_token") or refresh_token).strip()
131
+ expires_in = int(data.get("expires_in") or 3600)
132
+ if not access_token:
133
+ raise CLIRequestError("Refresh response did not include an access token")
134
+ save_tokens(access_token, new_refresh, profile=profile, expires_in=expires_in)
135
+ return load_tokens(profile=profile) or {}
136
+
137
+
138
+ def _ensure_fresh_tokens(profile: str | None) -> dict[str, Any]:
139
+ tokens = load_tokens(profile=profile) or {}
140
+ if _token_needs_refresh(tokens):
141
+ tokens = _refresh_access_token(profile)
142
+ return tokens
143
+
144
+
145
+ def _apply_login_response(data: dict[str, Any], *, profile: str | None) -> None:
146
+ if data.get("mfa_required"):
147
+ access_token = str(data.get("access_token") or "").strip()
148
+ if not access_token:
149
+ raise CLIRequestError("MFA required but gateway did not return a challenge token")
150
+ expires_in = int(data.get("expires_in") or 300)
151
+ from apps.cli.src.utils.auth_store import save_mfa_pending
152
+
153
+ save_mfa_pending(access_token, profile=profile, expires_in=expires_in)
154
+ raise CLIMfaRequiredError(
155
+ "Multi-factor authentication required. Complete login with: "
156
+ "sov auth mfa-verify --code <totp-or-backup-code>"
157
+ )
158
+
159
+ access_token = str(data.get("access_token") or "").strip()
160
+ refresh_token = str(data.get("refresh_token") or "").strip()
161
+ if not access_token or not refresh_token:
162
+ raise CLIRequestError("Login response did not include a full token pair")
163
+ expires_in = int(data.get("expires_in") or 3600)
164
+ save_tokens(access_token, refresh_token, profile=profile, expires_in=expires_in)
165
+
166
+
167
+ def call_api(
168
+ *,
169
+ method: str,
170
+ path: str,
171
+ profile: str | None = None,
172
+ body: dict[str, Any] | list[Any] | None = None,
173
+ params: dict[str, str] | None = None,
174
+ require_auth: bool = True,
175
+ require_scope: bool = False,
176
+ org_id: str | None = None,
177
+ project_id: str | None = None,
178
+ env_id: str | None = None,
179
+ extra_headers: dict[str, str] | None = None,
180
+ timeout: float = 30.0,
181
+ _skip_plan_check: bool = False,
182
+ _allow_mfa_token: bool = False,
183
+ ) -> Any:
184
+ """Execute an API request against the configured gateway endpoint."""
185
+ normalized_method = method.upper()
186
+ url = get_api_url(profile).rstrip("/") + (path if path.startswith("/") else f"/{path}")
187
+ headers: dict[str, str] = {"Content-Type": "application/json"}
188
+
189
+ access_token = ""
190
+ if require_auth:
191
+ tokens = _ensure_fresh_tokens(profile)
192
+ access_token = str(tokens.get("access_token") or "").strip()
193
+ if not access_token:
194
+ pending = load_mfa_pending(profile=profile)
195
+ if pending and _allow_mfa_token:
196
+ access_token = str(pending.get("access_token") or "").strip()
197
+ if not access_token:
198
+ if pending:
199
+ raise CLIMfaRequiredError(
200
+ "MFA verification pending. Run: sov auth mfa-verify --code <totp>"
201
+ )
202
+ raise CLIRequestError("Not logged in. Run: sov auth login")
203
+ headers["Authorization"] = f"Bearer {access_token}"
204
+
205
+ resolved_org, resolved_project, resolved_env = _resolve_scope(
206
+ profile=profile,
207
+ org_id=org_id,
208
+ project_id=project_id,
209
+ env_id=env_id,
210
+ require_scope=require_scope,
211
+ )
212
+ if require_scope and (not resolved_org or not resolved_project):
213
+ raise CLIRequestError(
214
+ "Missing required scope. Set org/project using --org/--project or: "
215
+ "sov scope set <org_id> <project_id>"
216
+ )
217
+ headers.update(_scope_headers(resolved_org, resolved_project, resolved_env))
218
+
219
+ if extra_headers:
220
+ headers.update(extra_headers)
221
+
222
+ if normalized_method in _MUTATING_METHODS and "Idempotency-Key" not in headers:
223
+ headers["Idempotency-Key"] = str(uuid.uuid4())
224
+
225
+ def _execute(auth_header: str | None = None) -> httpx.Response:
226
+ request_headers = dict(headers)
227
+ if auth_header is not None:
228
+ request_headers["Authorization"] = f"Bearer {auth_header}"
229
+ return httpx.request(
230
+ method=normalized_method,
231
+ url=url,
232
+ headers=request_headers,
233
+ json=body,
234
+ params=params,
235
+ timeout=timeout,
236
+ )
237
+
238
+ try:
239
+ response = _execute()
240
+ if response.status_code == 401 and require_auth and not _allow_mfa_token:
241
+ refreshed = _refresh_access_token(profile)
242
+ retry_token = str(refreshed.get("access_token") or "").strip()
243
+ if retry_token and retry_token != access_token:
244
+ response = _execute(retry_token)
245
+ response.raise_for_status()
246
+ except httpx.HTTPStatusError as exc:
247
+ detail: Any
248
+ try:
249
+ detail = exc.response.json()
250
+ except Exception: # noqa: BLE001
251
+ detail = exc.response.text
252
+ raise CLIRequestError(
253
+ f"{normalized_method} {path} failed ({exc.response.status_code}): {detail}",
254
+ status_code=exc.response.status_code,
255
+ ) from exc
256
+ except httpx.RequestError as exc:
257
+ raise CLIRequestError(f"Network error while calling {path}: {exc}") from exc
258
+
259
+ if response.status_code == 204:
260
+ return {"status": "ok"}
261
+
262
+ content_type = (response.headers.get("content-type") or "").lower()
263
+ if "application/json" in content_type:
264
+ return response.json()
265
+
266
+ text = response.text.strip()
267
+ return {"text": text} if text else {"status": "ok"}
268
+
269
+
270
+ def parse_json_arg(value: str | None, *, label: str) -> dict[str, Any] | list[Any] | None:
271
+ """Parse a JSON CLI argument and raise a readable error when invalid."""
272
+ if value is None:
273
+ return None
274
+ try:
275
+ parsed = json.loads(value)
276
+ except json.JSONDecodeError as exc:
277
+ raise CLIRequestError(f"Invalid JSON for {label}: {exc}") from exc
278
+ if not isinstance(parsed, dict | list):
279
+ raise CLIRequestError(f"{label} must be a JSON object or array")
280
+ return parsed
281
+
282
+
283
+ def print_json(payload: Any) -> str:
284
+ """Return pretty JSON text for terminal output."""
285
+ return json.dumps(payload, indent=2, sort_keys=True, default=str)
286
+
287
+
288
+ def build_request_context(
289
+ *,
290
+ profile: str | None = None,
291
+ org_id: str | None = None,
292
+ project_id: str | None = None,
293
+ env_id: str | None = None,
294
+ require_scope: bool = False,
295
+ require_auth: bool = True,
296
+ extra_headers: dict[str, str] | None = None,
297
+ ) -> tuple[str, dict[str, str]]:
298
+ """Return (api_base_url, headers) for raw httpx calls with refresh + scope."""
299
+ api_url = get_api_url(profile).rstrip("/")
300
+ headers: dict[str, str] = {}
301
+
302
+ if require_auth:
303
+ tokens = _ensure_fresh_tokens(profile)
304
+ access_token = str(tokens.get("access_token") or "").strip()
305
+ if not access_token:
306
+ pending = load_mfa_pending(profile=profile)
307
+ if pending:
308
+ raise CLIMfaRequiredError(
309
+ "MFA verification pending. Run: sov auth mfa-verify --code <totp>"
310
+ )
311
+ raise CLIRequestError("Not logged in. Run: sov auth login")
312
+ headers["Authorization"] = f"Bearer {access_token}"
313
+
314
+ resolved_org, resolved_project, resolved_env = _resolve_scope(
315
+ profile=profile,
316
+ org_id=org_id,
317
+ project_id=project_id,
318
+ env_id=env_id,
319
+ require_scope=require_scope,
320
+ )
321
+ if require_scope and (not resolved_org or not resolved_project):
322
+ raise CLIRequestError(
323
+ "Missing required scope. Set org/project using --org/--project or: "
324
+ "sov project link"
325
+ )
326
+ headers.update(_scope_headers(resolved_org, resolved_project, resolved_env))
327
+ if extra_headers:
328
+ headers.update(extra_headers)
329
+ return api_url, headers