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,364 @@
1
+ """Pulse realtime commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TypedDict
6
+ from urllib.parse import quote, urlencode
7
+
8
+ import typer
9
+
10
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, parse_json_arg, print_json
11
+ from apps.cli.src.utils.config import get_api_url, get_scope
12
+
13
+ app = typer.Typer(help="Realtime stream and E2EE operations")
14
+
15
+
16
+ class ScopeOpts(TypedDict):
17
+ org_id: str | None
18
+ project_id: str | None
19
+ env_id: str | None
20
+
21
+
22
+ def _scope_opts(
23
+ org_id: str | None,
24
+ project_id: str | None,
25
+ env_id: str | None,
26
+ ) -> ScopeOpts:
27
+ return {
28
+ "org_id": org_id,
29
+ "project_id": project_id,
30
+ "env_id": env_id,
31
+ }
32
+
33
+
34
+ @app.command("subscribe")
35
+ def subscribe(
36
+ channel: str = typer.Argument(...),
37
+ client_id: str = typer.Argument(...),
38
+ profile: str | None = typer.Option(None, "--profile"),
39
+ org_id: str | None = typer.Option(None, "--org"),
40
+ project_id: str | None = typer.Option(None, "--project"),
41
+ env_id: str | None = typer.Option(None, "--env"),
42
+ ) -> None:
43
+ """Subscribe a client to a channel."""
44
+ try:
45
+ result = call_api(
46
+ method="POST",
47
+ path="/api/v1/rt/stream/subscribe",
48
+ profile=profile,
49
+ body={"channel": channel, "client_id": client_id},
50
+ require_scope=True,
51
+ **_scope_opts(org_id, project_id, env_id),
52
+ )
53
+ except CLIRequestError as exc:
54
+ typer.echo(str(exc), err=True)
55
+ raise typer.Exit(1) from exc
56
+ typer.echo(print_json(result))
57
+
58
+
59
+ @app.command("unsubscribe")
60
+ def unsubscribe(
61
+ channel: str = typer.Argument(...),
62
+ client_id: str = typer.Argument(...),
63
+ profile: str | None = typer.Option(None, "--profile"),
64
+ org_id: str | None = typer.Option(None, "--org"),
65
+ project_id: str | None = typer.Option(None, "--project"),
66
+ env_id: str | None = typer.Option(None, "--env"),
67
+ ) -> None:
68
+ """Unsubscribe a client from a channel."""
69
+ try:
70
+ result = call_api(
71
+ method="POST",
72
+ path="/api/v1/rt/stream/unsubscribe",
73
+ profile=profile,
74
+ body={"channel": channel, "client_id": client_id},
75
+ require_scope=True,
76
+ **_scope_opts(org_id, project_id, env_id),
77
+ )
78
+ except CLIRequestError as exc:
79
+ typer.echo(str(exc), err=True)
80
+ raise typer.Exit(1) from exc
81
+ typer.echo(print_json(result))
82
+
83
+
84
+ @app.command("presence")
85
+ def presence(
86
+ channel: str = typer.Argument(...),
87
+ profile: str | None = typer.Option(None, "--profile"),
88
+ org_id: str | None = typer.Option(None, "--org"),
89
+ project_id: str | None = typer.Option(None, "--project"),
90
+ env_id: str | None = typer.Option(None, "--env"),
91
+ ) -> None:
92
+ """Show online clients for a channel."""
93
+ try:
94
+ result = call_api(
95
+ method="GET",
96
+ path=f"/api/v1/rt/stream/presence/{quote(channel, safe='')}",
97
+ profile=profile,
98
+ require_scope=True,
99
+ **_scope_opts(org_id, project_id, env_id),
100
+ )
101
+ except CLIRequestError as exc:
102
+ typer.echo(str(exc), err=True)
103
+ raise typer.Exit(1) from exc
104
+ typer.echo(print_json(result))
105
+
106
+
107
+ @app.command("channels")
108
+ def channels(
109
+ profile: str | None = typer.Option(None, "--profile"),
110
+ org_id: str | None = typer.Option(None, "--org"),
111
+ project_id: str | None = typer.Option(None, "--project"),
112
+ env_id: str | None = typer.Option(None, "--env"),
113
+ ) -> None:
114
+ """List active channels in current scope."""
115
+ try:
116
+ result = call_api(
117
+ method="GET",
118
+ path="/api/v1/rt/stream/channels",
119
+ profile=profile,
120
+ require_scope=True,
121
+ **_scope_opts(org_id, project_id, env_id),
122
+ )
123
+ except CLIRequestError as exc:
124
+ typer.echo(str(exc), err=True)
125
+ raise typer.Exit(1) from exc
126
+ typer.echo(print_json(result))
127
+
128
+
129
+ @app.command("replay")
130
+ def replay(
131
+ channel: str = typer.Argument(...),
132
+ after: int = typer.Option(0, "--after"),
133
+ limit: int = typer.Option(200, "--limit"),
134
+ profile: str | None = typer.Option(None, "--profile"),
135
+ org_id: str | None = typer.Option(None, "--org"),
136
+ project_id: str | None = typer.Option(None, "--project"),
137
+ env_id: str | None = typer.Option(None, "--env"),
138
+ ) -> None:
139
+ """Replay channel events from a sequence index."""
140
+ try:
141
+ result = call_api(
142
+ method="GET",
143
+ path=f"/api/v1/rt/stream/replay/{quote(channel, safe='')}",
144
+ profile=profile,
145
+ params={"after": str(after), "limit": str(limit)},
146
+ require_scope=True,
147
+ **_scope_opts(org_id, project_id, env_id),
148
+ )
149
+ except CLIRequestError as exc:
150
+ typer.echo(str(exc), err=True)
151
+ raise typer.Exit(1) from exc
152
+ typer.echo(print_json(result))
153
+
154
+
155
+ @app.command("publish")
156
+ def publish(
157
+ channel: str = typer.Argument(...),
158
+ payload: str = typer.Argument(..., help="JSON object payload"),
159
+ clock: str | None = typer.Option(None, "--clock", help="JSON object clock"),
160
+ clock_delta: str | None = typer.Option(None, "--clock-delta", help="JSON object clock delta"),
161
+ profile: str | None = typer.Option(None, "--profile"),
162
+ org_id: str | None = typer.Option(None, "--org"),
163
+ project_id: str | None = typer.Option(None, "--project"),
164
+ env_id: str | None = typer.Option(None, "--env"),
165
+ ) -> None:
166
+ """Publish a payload on a channel."""
167
+ try:
168
+ parsed_payload = parse_json_arg(payload, label="payload")
169
+ if not isinstance(parsed_payload, dict):
170
+ raise CLIRequestError("payload must be a JSON object")
171
+ parsed_clock = parse_json_arg(clock, label="--clock") if clock else None
172
+ parsed_delta = parse_json_arg(clock_delta, label="--clock-delta") if clock_delta else None
173
+ if parsed_clock is not None and not isinstance(parsed_clock, dict):
174
+ raise CLIRequestError("--clock must be a JSON object")
175
+ if parsed_delta is not None and not isinstance(parsed_delta, dict):
176
+ raise CLIRequestError("--clock-delta must be a JSON object")
177
+
178
+ body = {"payload": parsed_payload}
179
+ if parsed_clock:
180
+ body["clock"] = parsed_clock
181
+ if parsed_delta:
182
+ body["clock_delta"] = parsed_delta
183
+
184
+ result = call_api(
185
+ method="POST",
186
+ path=f"/api/v1/rt/stream/publish/{quote(channel, safe='')}",
187
+ profile=profile,
188
+ body=body,
189
+ require_scope=True,
190
+ **_scope_opts(org_id, project_id, env_id),
191
+ )
192
+ except CLIRequestError as exc:
193
+ typer.echo(str(exc), err=True)
194
+ raise typer.Exit(1) from exc
195
+
196
+ typer.echo(print_json(result))
197
+
198
+
199
+ @app.command("e2ee-bootstrap")
200
+ def e2ee_bootstrap(
201
+ channel: str = typer.Argument(...),
202
+ ttl_seconds: int = typer.Option(86_400, "--ttl"),
203
+ force_new: bool = typer.Option(False, "--force-new"),
204
+ profile: str | None = typer.Option(None, "--profile"),
205
+ org_id: str | None = typer.Option(None, "--org"),
206
+ project_id: str | None = typer.Option(None, "--project"),
207
+ env_id: str | None = typer.Option(None, "--env"),
208
+ ) -> None:
209
+ """Create or fetch active E2EE key material."""
210
+ try:
211
+ result = call_api(
212
+ method="POST",
213
+ path="/api/v1/rt/e2ee/keys/bootstrap",
214
+ profile=profile,
215
+ body={"channel": channel, "ttl_seconds": ttl_seconds, "force_new": force_new},
216
+ require_scope=True,
217
+ **_scope_opts(org_id, project_id, env_id),
218
+ )
219
+ except CLIRequestError as exc:
220
+ typer.echo(str(exc), err=True)
221
+ raise typer.Exit(1) from exc
222
+ typer.echo(print_json(result))
223
+
224
+
225
+ @app.command("e2ee-rotate")
226
+ def e2ee_rotate(
227
+ channel: str = typer.Argument(...),
228
+ ttl_seconds: int = typer.Option(86_400, "--ttl"),
229
+ profile: str | None = typer.Option(None, "--profile"),
230
+ org_id: str | None = typer.Option(None, "--org"),
231
+ project_id: str | None = typer.Option(None, "--project"),
232
+ env_id: str | None = typer.Option(None, "--env"),
233
+ ) -> None:
234
+ """Rotate E2EE key material."""
235
+ try:
236
+ result = call_api(
237
+ method="POST",
238
+ path="/api/v1/rt/e2ee/keys/rotate",
239
+ profile=profile,
240
+ body={"channel": channel, "ttl_seconds": ttl_seconds},
241
+ require_scope=True,
242
+ **_scope_opts(org_id, project_id, env_id),
243
+ )
244
+ except CLIRequestError as exc:
245
+ typer.echo(str(exc), err=True)
246
+ raise typer.Exit(1) from exc
247
+ typer.echo(print_json(result))
248
+
249
+
250
+ @app.command("e2ee-revoke")
251
+ def e2ee_revoke(
252
+ channel: str = typer.Argument(...),
253
+ key_id: str | None = typer.Option(None, "--key-id"),
254
+ reason: str | None = typer.Option(None, "--reason"),
255
+ profile: str | None = typer.Option(None, "--profile"),
256
+ org_id: str | None = typer.Option(None, "--org"),
257
+ project_id: str | None = typer.Option(None, "--project"),
258
+ env_id: str | None = typer.Option(None, "--env"),
259
+ ) -> None:
260
+ """Revoke an E2EE key."""
261
+ try:
262
+ result = call_api(
263
+ method="POST",
264
+ path="/api/v1/rt/e2ee/keys/revoke",
265
+ profile=profile,
266
+ body={"channel": channel, "key_id": key_id, "reason": reason},
267
+ require_scope=True,
268
+ **_scope_opts(org_id, project_id, env_id),
269
+ )
270
+ except CLIRequestError as exc:
271
+ typer.echo(str(exc), err=True)
272
+ raise typer.Exit(1) from exc
273
+ typer.echo(print_json(result))
274
+
275
+
276
+ @app.command("e2ee-list")
277
+ def e2ee_list(
278
+ channel: str = typer.Argument(...),
279
+ profile: str | None = typer.Option(None, "--profile"),
280
+ org_id: str | None = typer.Option(None, "--org"),
281
+ project_id: str | None = typer.Option(None, "--project"),
282
+ env_id: str | None = typer.Option(None, "--env"),
283
+ ) -> None:
284
+ """List E2EE keys for a channel."""
285
+ try:
286
+ result = call_api(
287
+ method="GET",
288
+ path=f"/api/v1/rt/e2ee/keys/{quote(channel, safe='')}",
289
+ profile=profile,
290
+ require_scope=True,
291
+ **_scope_opts(org_id, project_id, env_id),
292
+ )
293
+ except CLIRequestError as exc:
294
+ typer.echo(str(exc), err=True)
295
+ raise typer.Exit(1) from exc
296
+ typer.echo(print_json(result))
297
+
298
+
299
+ @app.command("ws-url")
300
+ def ws_url(
301
+ channel: str = typer.Argument(...),
302
+ profile: str | None = typer.Option(None, "--profile"),
303
+ ticket: str | None = typer.Option(None, "--ticket", help="Use an already issued WS ticket"),
304
+ org_id: str | None = typer.Option(None, "--org"),
305
+ project_id: str | None = typer.Option(None, "--project"),
306
+ env_id: str | None = typer.Option(None, "--env"),
307
+ ) -> None:
308
+ """Generate websocket URL for Pulse via gateway."""
309
+ base = get_api_url(profile).rstrip("/")
310
+ if base.startswith("https://"):
311
+ ws_base = "wss://" + base[len("https://") :]
312
+ elif base.startswith("http://"):
313
+ ws_base = "ws://" + base[len("http://") :]
314
+ else:
315
+ ws_base = f"ws://{base}"
316
+
317
+ scope = get_scope(profile)
318
+ resolved_org = (org_id or scope.get("org_id") or "").strip()
319
+ resolved_project = (project_id or scope.get("project_id") or "").strip()
320
+ resolved_env = (env_id or scope.get("env_id") or "").strip()
321
+
322
+ if not resolved_org or not resolved_project:
323
+ typer.echo(
324
+ "Missing scope. Set --org/--project or run: sov scope set <org_id> <project_id>",
325
+ err=True,
326
+ )
327
+ raise typer.Exit(1)
328
+
329
+ resolved_ticket = (ticket or "").strip()
330
+ if not resolved_ticket:
331
+ try:
332
+ result = call_api(
333
+ method="POST",
334
+ path="/api/v1/auth/ws-ticket",
335
+ profile=profile,
336
+ body={
337
+ "channel": channel,
338
+ "org_id": resolved_org,
339
+ "project_id": resolved_project,
340
+ **({"env_id": resolved_env} if resolved_env else {}),
341
+ },
342
+ require_scope=True,
343
+ org_id=resolved_org,
344
+ project_id=resolved_project,
345
+ env_id=resolved_env or None,
346
+ )
347
+ except CLIRequestError as exc:
348
+ typer.echo(str(exc), err=True)
349
+ raise typer.Exit(1) from exc
350
+ resolved_ticket = str(result.get("ticket") or "").strip() if isinstance(result, dict) else ""
351
+ if not resolved_ticket:
352
+ typer.echo("Gateway did not return a websocket ticket.", err=True)
353
+ raise typer.Exit(1)
354
+
355
+ query = {
356
+ "ticket": resolved_ticket,
357
+ "org_id": resolved_org,
358
+ "project_id": resolved_project,
359
+ }
360
+ if resolved_env:
361
+ query["env_id"] = resolved_env
362
+
363
+ safe_channel = quote(channel, safe="")
364
+ typer.echo(f"{ws_base}/api/v1/rt/ws/{safe_channel}?{urlencode(query)}")
@@ -0,0 +1,42 @@
1
+ """Scope management commands for org/project/env defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from apps.cli.src.utils.config import clear_scope, get_scope, set_scope
8
+
9
+ app = typer.Typer(help="Manage default org/project/env scope")
10
+
11
+
12
+ @app.command("show")
13
+ def show_scope(profile: str | None = typer.Option(None, "--profile")) -> None:
14
+ """Show currently configured default scope."""
15
+ scope = get_scope(profile)
16
+ typer.echo(f"org_id : {scope.get('org_id') or '-'}")
17
+ typer.echo(f"project_id : {scope.get('project_id') or '-'}")
18
+ typer.echo(f"env_id : {scope.get('env_id') or '-'}")
19
+
20
+
21
+ @app.command("set")
22
+ def set_scope_cmd(
23
+ org_id: str = typer.Argument(..., help="Organization UUID"),
24
+ project_id: str = typer.Argument(..., help="Project UUID"),
25
+ env_id: str | None = typer.Option(None, "--env", help="Environment UUID"),
26
+ profile: str | None = typer.Option(None, "--profile"),
27
+ ) -> None:
28
+ """Persist default scope for future CLI calls."""
29
+ set_scope(
30
+ org_id=org_id,
31
+ project_id=project_id,
32
+ env_id=env_id,
33
+ profile=profile,
34
+ )
35
+ typer.echo("Default scope saved.")
36
+
37
+
38
+ @app.command("clear")
39
+ def clear_scope_cmd(profile: str | None = typer.Option(None, "--profile")) -> None:
40
+ """Clear default scope for the active profile."""
41
+ clear_scope(profile)
42
+ typer.echo("Default scope cleared.")