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
@@ -0,0 +1,436 @@
1
+ """Identity and access management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import quote
6
+
7
+ import typer
8
+
9
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, parse_json_arg
10
+ from apps.cli.src.utils.display import print_json
11
+
12
+ app = typer.Typer(help="Organisations, members, and project listings")
13
+
14
+
15
+ @app.command("orgs")
16
+ def list_orgs(
17
+ profile: str | None = typer.Option(None, "--profile"),
18
+ ) -> None:
19
+ """List organisations visible to the authenticated user."""
20
+ try:
21
+ result = call_api(method="GET", path="/api/v1/org", profile=profile)
22
+ except CLIRequestError as exc:
23
+ typer.echo(str(exc), err=True)
24
+ raise typer.Exit(1) from exc
25
+ print_json(result)
26
+
27
+
28
+ @app.command("org")
29
+ def get_org(
30
+ org_id: str = typer.Argument(..., help="Organisation node ID"),
31
+ profile: str | None = typer.Option(None, "--profile"),
32
+ ) -> None:
33
+ """Fetch one organisation by ID."""
34
+ try:
35
+ result = call_api(
36
+ method="GET",
37
+ path=f"/api/v1/org/{quote(org_id, safe='')}",
38
+ profile=profile,
39
+ )
40
+ except CLIRequestError as exc:
41
+ typer.echo(str(exc), err=True)
42
+ raise typer.Exit(1) from exc
43
+ print_json(result)
44
+
45
+
46
+ @app.command("projects")
47
+ def list_projects(
48
+ org_id: str = typer.Argument(..., help="Parent organisation ID"),
49
+ relation: str = typer.Option("OWNS", "--relation", help="Edge relation filter"),
50
+ profile: str | None = typer.Option(None, "--profile"),
51
+ ) -> None:
52
+ """List child projects under an organisation."""
53
+ try:
54
+ result = call_api(
55
+ method="GET",
56
+ path=f"/api/v1/org/{quote(org_id, safe='')}/children",
57
+ profile=profile,
58
+ params={"relation": relation},
59
+ )
60
+ except CLIRequestError as exc:
61
+ typer.echo(str(exc), err=True)
62
+ raise typer.Exit(1) from exc
63
+ print_json(result)
64
+
65
+
66
+ @app.command("members")
67
+ def list_members(
68
+ org_id: str = typer.Argument(..., help="Organisation ID"),
69
+ profile: str | None = typer.Option(None, "--profile"),
70
+ ) -> None:
71
+ """List organisation members."""
72
+ try:
73
+ result = call_api(
74
+ method="GET",
75
+ path=f"/api/v1/org/{quote(org_id, safe='')}/children",
76
+ profile=profile,
77
+ params={"relation": "MEMBER_OF"},
78
+ )
79
+ except CLIRequestError as exc:
80
+ typer.echo(str(exc), err=True)
81
+ raise typer.Exit(1) from exc
82
+ print_json(result)
83
+
84
+
85
+ @app.command("member-add")
86
+ def member_add(
87
+ org_id: str = typer.Argument(..., help="Organisation ID"),
88
+ user_id: str = typer.Argument(..., help="User node ID"),
89
+ role: str = typer.Option("MEMBER", "--role"),
90
+ profile: str | None = typer.Option(None, "--profile"),
91
+ ) -> None:
92
+ """Add a member to an organisation."""
93
+ try:
94
+ result = call_api(
95
+ method="POST",
96
+ path=f"/api/v1/org/{quote(org_id, safe='')}/member",
97
+ profile=profile,
98
+ body={"user_id": user_id, "role": role},
99
+ )
100
+ except CLIRequestError as exc:
101
+ typer.echo(str(exc), err=True)
102
+ raise typer.Exit(1) from exc
103
+ print_json(result)
104
+
105
+
106
+ @app.command("settings-get")
107
+ def settings_get(
108
+ profile: str | None = typer.Option(None, "--profile"),
109
+ org_id: str | None = typer.Option(None, "--org"),
110
+ project_id: str | None = typer.Option(None, "--project"),
111
+ env_id: str | None = typer.Option(None, "--env"),
112
+ ) -> None:
113
+ """Fetch project settings for the linked scope."""
114
+ try:
115
+ result = call_api(
116
+ method="GET",
117
+ path="/api/v1/project-settings",
118
+ profile=profile,
119
+ org_id=org_id,
120
+ project_id=project_id,
121
+ env_id=env_id,
122
+ require_scope=True,
123
+ )
124
+ except CLIRequestError as exc:
125
+ typer.echo(str(exc), err=True)
126
+ raise typer.Exit(1) from exc
127
+ print_json(result)
128
+
129
+
130
+ @app.command("settings-set")
131
+ def settings_set(
132
+ settings_json: str = typer.Argument(..., help="JSON object of project settings"),
133
+ profile: str | None = typer.Option(None, "--profile"),
134
+ org_id: str | None = typer.Option(None, "--org"),
135
+ project_id: str | None = typer.Option(None, "--project"),
136
+ env_id: str | None = typer.Option(None, "--env"),
137
+ ) -> None:
138
+ """Update project settings."""
139
+ body = parse_json_arg(settings_json, label="settings")
140
+ if body is None or not isinstance(body, dict):
141
+ raise typer.BadParameter("settings must be a JSON object")
142
+ try:
143
+ result = call_api(
144
+ method="PUT",
145
+ path="/api/v1/project-settings",
146
+ profile=profile,
147
+ org_id=org_id,
148
+ project_id=project_id,
149
+ env_id=env_id,
150
+ require_scope=True,
151
+ body=body,
152
+ )
153
+ except CLIRequestError as exc:
154
+ typer.echo(str(exc), err=True)
155
+ raise typer.Exit(1) from exc
156
+ print_json(result)
157
+
158
+
159
+ @app.command("org-update")
160
+ def org_update(
161
+ org_id: str = typer.Argument(..., help="Organisation node ID"),
162
+ name: str | None = typer.Option(None, "--name"),
163
+ settings_json: str | None = typer.Option(None, "--settings", help="JSON settings object"),
164
+ profile: str | None = typer.Option(None, "--profile"),
165
+ ) -> None:
166
+ """Update organisation name and/or admin settings."""
167
+ body: dict[str, object] = {}
168
+ if name is not None:
169
+ body["name"] = name
170
+ if settings_json is not None:
171
+ settings = parse_json_arg(settings_json, label="settings")
172
+ if settings is None or not isinstance(settings, dict):
173
+ raise typer.BadParameter("--settings must be a JSON object")
174
+ body["settings"] = settings
175
+ if not body:
176
+ raise typer.BadParameter("Provide --name and/or --settings")
177
+ try:
178
+ result = call_api(
179
+ method="PUT",
180
+ path=f"/api/v1/org/{quote(org_id, safe='')}",
181
+ profile=profile,
182
+ body=body,
183
+ )
184
+ except CLIRequestError as exc:
185
+ typer.echo(str(exc), err=True)
186
+ raise typer.Exit(1) from exc
187
+ print_json(result)
188
+
189
+
190
+ @app.command("org-delete")
191
+ def org_delete(
192
+ org_id: str = typer.Argument(..., help="Organisation node ID"),
193
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
194
+ profile: str | None = typer.Option(None, "--profile"),
195
+ ) -> None:
196
+ """Delete an organisation (async teardown)."""
197
+ if not yes and not typer.confirm(f"Delete organisation '{org_id}'?"):
198
+ raise typer.Exit(0)
199
+ try:
200
+ result = call_api(
201
+ method="DELETE",
202
+ path=f"/api/v1/org/{quote(org_id, safe='')}",
203
+ profile=profile,
204
+ )
205
+ except CLIRequestError as exc:
206
+ typer.echo(str(exc), err=True)
207
+ raise typer.Exit(1) from exc
208
+ print_json(result)
209
+
210
+
211
+ @app.command("org-set-plan")
212
+ def org_set_plan(
213
+ org_id: str = typer.Argument(..., help="Organisation node ID"),
214
+ plan: str = typer.Option(..., "--plan", help="free | pro | ultra | team | enterprise"),
215
+ profile: str | None = typer.Option(None, "--profile"),
216
+ ) -> None:
217
+ """Set an organisation billing plan."""
218
+ try:
219
+ result = call_api(
220
+ method="PUT",
221
+ path=f"/api/v1/org/{quote(org_id, safe='')}/plan",
222
+ profile=profile,
223
+ body={"plan": plan},
224
+ )
225
+ except CLIRequestError as exc:
226
+ typer.echo(str(exc), err=True)
227
+ raise typer.Exit(1) from exc
228
+ print_json(result)
229
+
230
+
231
+ @app.command("member-remove")
232
+ def member_remove(
233
+ org_id: str = typer.Argument(..., help="Organisation ID"),
234
+ user_id: str = typer.Argument(..., help="User node ID"),
235
+ profile: str | None = typer.Option(None, "--profile"),
236
+ ) -> None:
237
+ """Remove a member from an organisation."""
238
+ try:
239
+ result = call_api(
240
+ method="DELETE",
241
+ path=f"/api/v1/org/{quote(org_id, safe='')}/member/{quote(user_id, safe='')}",
242
+ profile=profile,
243
+ )
244
+ except CLIRequestError as exc:
245
+ typer.echo(str(exc), err=True)
246
+ raise typer.Exit(1) from exc
247
+ print_json(result)
248
+
249
+
250
+ @app.command("member-update")
251
+ def member_update(
252
+ org_id: str = typer.Argument(..., help="Organisation ID"),
253
+ user_id: str = typer.Argument(..., help="User node ID"),
254
+ role: str = typer.Option(..., "--role"),
255
+ profile: str | None = typer.Option(None, "--profile"),
256
+ ) -> None:
257
+ """Update a member's organisation role."""
258
+ try:
259
+ result = call_api(
260
+ method="PUT",
261
+ path=f"/api/v1/org/{quote(org_id, safe='')}/member/{quote(user_id, safe='')}",
262
+ profile=profile,
263
+ body={"role": role},
264
+ )
265
+ except CLIRequestError as exc:
266
+ typer.echo(str(exc), err=True)
267
+ raise typer.Exit(1) from exc
268
+ print_json(result)
269
+
270
+
271
+ @app.command("invite-create")
272
+ def invite_create(
273
+ org_id: str = typer.Argument(..., help="Organisation ID"),
274
+ email: str = typer.Option(..., "--email"),
275
+ role: str = typer.Option(..., "--role"),
276
+ profile: str | None = typer.Option(None, "--profile"),
277
+ ) -> None:
278
+ """Create or refresh an organisation invite."""
279
+ try:
280
+ result = call_api(
281
+ method="POST",
282
+ path=f"/api/v1/org/{quote(org_id, safe='')}/invites",
283
+ profile=profile,
284
+ body={"email": email, "role": role},
285
+ )
286
+ except CLIRequestError as exc:
287
+ typer.echo(str(exc), err=True)
288
+ raise typer.Exit(1) from exc
289
+ print_json(result)
290
+
291
+
292
+ @app.command("invite-accept")
293
+ def invite_accept(
294
+ token: str = typer.Argument(..., help="Invite token"),
295
+ profile: str | None = typer.Option(None, "--profile"),
296
+ ) -> None:
297
+ """Accept an organisation invite."""
298
+ try:
299
+ result = call_api(
300
+ method="POST",
301
+ path="/api/v1/org/invites/accept",
302
+ profile=profile,
303
+ body={"token": token},
304
+ )
305
+ except CLIRequestError as exc:
306
+ typer.echo(str(exc), err=True)
307
+ raise typer.Exit(1) from exc
308
+ print_json(result)
309
+
310
+
311
+ @app.command("edge-create")
312
+ def edge_create(
313
+ source_id: str = typer.Option(..., "--source"),
314
+ target_id: str = typer.Option(..., "--target"),
315
+ relation_type: str = typer.Option(..., "--relation", help="OWNS | MEMBER_OF | HAS_ENV"),
316
+ role: str | None = typer.Option(None, "--role"),
317
+ profile: str | None = typer.Option(None, "--profile"),
318
+ ) -> None:
319
+ """Create a directed edge in the identity graph."""
320
+ body: dict[str, object] = {
321
+ "source_id": source_id,
322
+ "target_id": target_id,
323
+ "relation_type": relation_type,
324
+ }
325
+ if role is not None:
326
+ body["role"] = role
327
+ try:
328
+ result = call_api(
329
+ method="POST",
330
+ path="/api/v1/edge",
331
+ profile=profile,
332
+ body=body,
333
+ )
334
+ except CLIRequestError as exc:
335
+ typer.echo(str(exc), err=True)
336
+ raise typer.Exit(1) from exc
337
+ print_json(result)
338
+
339
+
340
+ roles_matrix_app = typer.Typer(help="Project role permission matrix")
341
+ app.add_typer(roles_matrix_app, name="roles-matrix")
342
+
343
+
344
+ @roles_matrix_app.command("get")
345
+ def roles_matrix_get(
346
+ profile: str | None = typer.Option(None, "--profile"),
347
+ org_id: str | None = typer.Option(None, "--org"),
348
+ project_id: str | None = typer.Option(None, "--project"),
349
+ env_id: str | None = typer.Option(None, "--env"),
350
+ ) -> None:
351
+ """Fetch the project role permission matrix."""
352
+ try:
353
+ result = call_api(
354
+ method="GET",
355
+ path="/api/v1/roles/matrix",
356
+ profile=profile,
357
+ org_id=org_id,
358
+ project_id=project_id,
359
+ env_id=env_id,
360
+ require_scope=True,
361
+ )
362
+ except CLIRequestError as exc:
363
+ typer.echo(str(exc), err=True)
364
+ raise typer.Exit(1) from exc
365
+ print_json(result)
366
+
367
+
368
+ @roles_matrix_app.command("set")
369
+ def roles_matrix_set(
370
+ matrix_json: str = typer.Argument(..., help="JSON role matrix object"),
371
+ profile: str | None = typer.Option(None, "--profile"),
372
+ org_id: str | None = typer.Option(None, "--org"),
373
+ project_id: str | None = typer.Option(None, "--project"),
374
+ env_id: str | None = typer.Option(None, "--env"),
375
+ ) -> None:
376
+ """Replace the project role permission matrix."""
377
+ matrix = parse_json_arg(matrix_json, label="matrix")
378
+ if matrix is None or not isinstance(matrix, dict):
379
+ raise typer.BadParameter("matrix must be a JSON object")
380
+ try:
381
+ result = call_api(
382
+ method="PUT",
383
+ path="/api/v1/roles/matrix",
384
+ profile=profile,
385
+ org_id=org_id,
386
+ project_id=project_id,
387
+ env_id=env_id,
388
+ require_scope=True,
389
+ body={"matrix": matrix},
390
+ )
391
+ except CLIRequestError as exc:
392
+ typer.echo(str(exc), err=True)
393
+ raise typer.Exit(1) from exc
394
+ print_json(result)
395
+
396
+
397
+ @app.command("roles-catalog")
398
+ def roles_catalog(
399
+ profile: str | None = typer.Option(None, "--profile"),
400
+ org_id: str | None = typer.Option(None, "--org"),
401
+ project_id: str | None = typer.Option(None, "--project"),
402
+ env_id: str | None = typer.Option(None, "--env"),
403
+ ) -> None:
404
+ """Fetch the project roles catalog."""
405
+ try:
406
+ result = call_api(
407
+ method="GET",
408
+ path="/api/v1/roles/catalog",
409
+ profile=profile,
410
+ org_id=org_id,
411
+ project_id=project_id,
412
+ env_id=env_id,
413
+ require_scope=True,
414
+ )
415
+ except CLIRequestError as exc:
416
+ typer.echo(str(exc), err=True)
417
+ raise typer.Exit(1) from exc
418
+ print_json(result)
419
+
420
+
421
+ @app.command("project-ref")
422
+ def project_ref(
423
+ project_id: str = typer.Argument(..., help="Project node ID"),
424
+ profile: str | None = typer.Option(None, "--profile"),
425
+ ) -> None:
426
+ """Fetch the public project ref slug."""
427
+ try:
428
+ result = call_api(
429
+ method="GET",
430
+ path=f"/api/v1/org/{quote(project_id, safe='')}/ref",
431
+ profile=profile,
432
+ )
433
+ except CLIRequestError as exc:
434
+ typer.echo(str(exc), err=True)
435
+ raise typer.Exit(1) from exc
436
+ print_json(result)
@@ -0,0 +1,177 @@
1
+ """Interactive project bootstrap wizard (`sov init`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from sovaria.project import DEFAULT_CONFIG_FILENAME, write_project_config_template
10
+
11
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api
12
+ from apps.cli.src.utils.auth_store import load_tokens
13
+ from apps.cli.src.utils.config import get_api_url, set_scope
14
+ from apps.cli.src.utils.display import print_banner, print_error, print_success, print_warning
15
+ from apps.cli.src.utils.project_context import link_project_to_profile
16
+
17
+ app = typer.Typer(help="Interactive Sovaria project bootstrap", invoke_without_command=True)
18
+
19
+
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
+ @app.callback()
80
+ def init_wizard(
81
+ name: str = typer.Option("sovaria-app", "--name", "-n", help="Project name for config"),
82
+ directory: Path = typer.Option(Path("."), "--dir", help="Directory to write config into"),
83
+ link: bool = typer.Option(True, "--link/--no-link", help="Persist scope to profile + config"),
84
+ ensure_keys: bool = typer.Option(
85
+ False,
86
+ "--ensure-keys",
87
+ help="Bootstrap default anon/service_role API keys after linking",
88
+ ),
89
+ profile: str | None = typer.Option(None, "--profile"),
90
+ force: bool = typer.Option(False, "--force", help="Overwrite existing sovaria.config.json"),
91
+ ) -> None:
92
+ """
93
+ Supabase-style interactive setup: login check → pick org/project → write sovaria.config.json.
94
+
95
+ After init, SDK and CLI commands auto-resolve scope from the local config file.
96
+ """
97
+ print_banner("Sovaria Init", "Link your terminal to a Sovaria project")
98
+
99
+ if not load_tokens(profile=profile):
100
+ print_error("Not logged in.")
101
+ typer.echo("Run: sov login")
102
+ raise typer.Exit(1)
103
+
104
+ config_path = (directory.resolve() / DEFAULT_CONFIG_FILENAME)
105
+ if config_path.exists() and not force:
106
+ print_warning(f"{config_path} already exists. Use --force to overwrite.")
107
+ if not typer.confirm("Continue and update scope on existing config?", default=True):
108
+ raise typer.Exit(0)
109
+ else:
110
+ try:
111
+ write_project_config_template(config_path, name=name, overwrite=force)
112
+ print_success(f"Created {config_path}")
113
+ except Exception as exc:
114
+ print_error(str(exc))
115
+ raise typer.Exit(1) from exc
116
+
117
+ try:
118
+ org_id = _pick_org(profile)
119
+ project_id = _pick_project(org_id, profile)
120
+ except CLIRequestError as exc:
121
+ print_error(str(exc))
122
+ raise typer.Exit(1) from exc
123
+
124
+ env_id = typer.prompt("Environment ID (optional, press Enter to skip)", default="").strip()
125
+
126
+ if link:
127
+ try:
128
+ path = link_project_to_profile(
129
+ org_id=org_id,
130
+ project_id=project_id,
131
+ env_id=env_id or None,
132
+ profile=profile,
133
+ config_path=config_path,
134
+ )
135
+ except (FileNotFoundError, ValueError) as exc:
136
+ print_error(str(exc))
137
+ raise typer.Exit(1) from exc
138
+ print_success(f"Linked scope in {path}")
139
+
140
+ # Sync gateway URL from active profile into config when missing
141
+ try:
142
+ import json
143
+
144
+ raw = json.loads(config_path.read_text())
145
+ gateway = raw.setdefault("gateway", {})
146
+ if isinstance(gateway, dict) and not str(gateway.get("url") or "").strip():
147
+ gateway["url"] = get_api_url(profile)
148
+ config_path.write_text(json.dumps(raw, indent=2, sort_keys=True) + "\n")
149
+ except Exception:
150
+ pass
151
+
152
+ set_scope(org_id=org_id, project_id=project_id, env_id=env_id or None, profile=profile)
153
+
154
+ if ensure_keys:
155
+ try:
156
+ keys = call_api(
157
+ method="POST",
158
+ path="/api/v1/apikeys/ensure-defaults",
159
+ profile=profile,
160
+ org_id=org_id,
161
+ project_id=project_id,
162
+ env_id=env_id or None,
163
+ require_scope=True,
164
+ body={},
165
+ )
166
+ print_success("Default API keys ensured.")
167
+ typer.echo(keys)
168
+ except CLIRequestError as exc:
169
+ print_warning(f"Could not ensure default keys: {exc}")
170
+
171
+ typer.echo("")
172
+ print_success("Project ready.")
173
+ typer.echo("Next steps:")
174
+ typer.echo(" sov shell # interactive REPL")
175
+ typer.echo(" sov data sql 'SELECT 1'")
176
+ typer.echo(" sov sim discover")
177
+ typer.echo(" sov project status")