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.
- apps/cli/README.md +147 -0
- apps/cli/__init__.py +0 -0
- apps/cli/src/__init__.py +3 -0
- apps/cli/src/commands/__init__.py +1 -0
- apps/cli/src/commands/admin.py +160 -0
- apps/cli/src/commands/api.py +48 -0
- apps/cli/src/commands/audit.py +181 -0
- apps/cli/src/commands/auth_product.py +187 -0
- apps/cli/src/commands/billing.py +513 -0
- apps/cli/src/commands/chrono.py +248 -0
- apps/cli/src/commands/context.py +99 -0
- apps/cli/src/commands/data.py +272 -0
- apps/cli/src/commands/db.py +528 -0
- apps/cli/src/commands/deploy.py +1099 -0
- apps/cli/src/commands/governance.py +491 -0
- apps/cli/src/commands/iam.py +436 -0
- apps/cli/src/commands/init_cmd.py +177 -0
- apps/cli/src/commands/keys.py +120 -0
- apps/cli/src/commands/login.py +842 -0
- apps/cli/src/commands/org.py +157 -0
- apps/cli/src/commands/policy.py +317 -0
- apps/cli/src/commands/project.py +130 -0
- apps/cli/src/commands/realtime.py +364 -0
- apps/cli/src/commands/scope.py +42 -0
- apps/cli/src/commands/shell.py +415 -0
- apps/cli/src/commands/sim.py +178 -0
- apps/cli/src/commands/storage.py +405 -0
- apps/cli/src/commands/vector.py +204 -0
- apps/cli/src/main.py +169 -0
- apps/cli/src/utils/__init__.py +1 -0
- apps/cli/src/utils/api_client.py +329 -0
- apps/cli/src/utils/auth_store.py +299 -0
- apps/cli/src/utils/config.py +185 -0
- apps/cli/src/utils/display.py +62 -0
- apps/cli/src/utils/migrations.py +69 -0
- apps/cli/src/utils/project_context.py +124 -0
- apps/cli/tests/test_auth_store_profiles.py +103 -0
- apps/cli/tests/test_cli_security.py +110 -0
- apps/cli/tests/test_cli_smoke_paths.py +279 -0
- apps/cli/tests/test_config_profiles.py +55 -0
- apps/cli/tests/test_main_startup.py +29 -0
- apps/cli/tests/test_project_and_commands.py +196 -0
- sovaria_cli-0.2.0.dist-info/METADATA +170 -0
- sovaria_cli-0.2.0.dist-info/RECORD +46 -0
- sovaria_cli-0.2.0.dist-info/WHEEL +4 -0
- 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")
|