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,1099 @@
1
+ """CLI commands for Forge deploy/versioning/invoke/simulate/history workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import time
8
+ import zipfile
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import httpx
13
+ import typer
14
+
15
+ from apps.cli.src.utils.auth_store import load_tokens
16
+ from apps.cli.src.utils.config import get_api_url
17
+
18
+ app = typer.Typer()
19
+
20
+ _POLL_INTERVAL = 2 # seconds
21
+ _POLL_TIMEOUT = 120 # seconds
22
+ _ASYNC_POLL_INTERVAL = 2 # seconds
23
+ _ASYNC_POLL_TIMEOUT = 600 # seconds
24
+
25
+
26
+ @app.command("deploy")
27
+ def deploy(
28
+ directory: Path = typer.Argument(
29
+ Path("."),
30
+ exists=True,
31
+ file_okay=False,
32
+ dir_okay=True,
33
+ readable=True,
34
+ help="Path to the function directory to deploy",
35
+ ),
36
+ name: str | None = typer.Option(None, "--name", "-n", help="Function name"),
37
+ project_id: str | None = typer.Option(None, "--project", "-p", help="Target project node ID"),
38
+ note: str = typer.Option("", "--note", help="Version change note for deployment history"),
39
+ profile: str | None = typer.Option(None, "--profile"),
40
+ ) -> None:
41
+ """
42
+ Deploy a cloud function to the Forge engine.
43
+
44
+ Example:
45
+ sov deploy ./my-function --name greet --project proj-abc123
46
+ """
47
+ tokens = load_tokens(profile=profile)
48
+ if not tokens:
49
+ typer.echo("Not logged in. Run: sov login", err=True)
50
+ raise typer.Exit(1)
51
+
52
+ api_url = get_api_url(profile)
53
+ fn_name = name or directory.resolve().name
54
+
55
+ typer.echo(f"Packaging {directory.resolve()} …")
56
+ zip_bytes = _zip_directory(directory)
57
+ typer.echo(f" Compressed size: {len(zip_bytes):,} bytes")
58
+
59
+ headers = {"Authorization": f"Bearer {tokens['access_token']}"}
60
+
61
+ # Upload
62
+ try:
63
+ resp = httpx.post(
64
+ f"{api_url}/api/v1/fn/deploy",
65
+ headers=headers,
66
+ files={"bundle": (f"{fn_name}.zip", zip_bytes, "application/zip")},
67
+ data={"name": fn_name, "project_id": project_id or "", "change_note": note},
68
+ timeout=60,
69
+ )
70
+ resp.raise_for_status()
71
+ except httpx.HTTPStatusError as exc:
72
+ typer.echo(f"Upload failed: {exc.response.text}", err=True)
73
+ raise typer.Exit(1) from exc
74
+
75
+ deploy_data = resp.json()
76
+ deploy_id = deploy_data.get("deploy_id")
77
+ deploy_version = deploy_data.get("version")
78
+ resolved_project_id = project_id or deploy_data.get("project_id")
79
+ if not deploy_id:
80
+ typer.echo(f"Unexpected response: {resp.text}", err=True)
81
+ raise typer.Exit(1)
82
+
83
+ if deploy_version:
84
+ typer.echo(f"Version: v{deploy_version}")
85
+
86
+ typer.echo(f"Deploy started (id={deploy_id}). Waiting for build…")
87
+
88
+ # Poll for status
89
+ deadline = time.time() + _POLL_TIMEOUT
90
+ while time.time() < deadline:
91
+ try:
92
+ poll = httpx.get(
93
+ f"{api_url}/api/v1/fn/{deploy_id}/status",
94
+ headers=headers,
95
+ params={"project_id": resolved_project_id} if resolved_project_id else None,
96
+ timeout=10,
97
+ )
98
+ poll.raise_for_status()
99
+ status_data = poll.json()
100
+ status = status_data.get("status", "UNKNOWN")
101
+
102
+ if status == "READY":
103
+ invoke_url = status_data.get("invoke_url", "<unknown>")
104
+ typer.echo(
105
+ typer.style(f"✓ Deployed! Invoke URL: {invoke_url}", fg=typer.colors.GREEN)
106
+ )
107
+ return
108
+ elif status == "FAILED":
109
+ typer.echo(f"Deploy failed: {status_data.get('error', 'unknown error')}", err=True)
110
+ raise typer.Exit(1)
111
+ else:
112
+ typer.echo(f" Status: {status} …", nl=False)
113
+ typer.echo("\r", nl=False)
114
+ except httpx.HTTPStatusError as exc:
115
+ typer.echo(f"Poll error: {exc.response.text}", err=True)
116
+ raise typer.Exit(1) from exc
117
+
118
+ time.sleep(_POLL_INTERVAL)
119
+
120
+ typer.echo("Deploy timed out", err=True)
121
+ raise typer.Exit(1)
122
+
123
+
124
+ @app.command("status")
125
+ def status(
126
+ deploy_id: str = typer.Argument(..., help="Deployment id returned by `sov fn deploy`"),
127
+ project_id: str | None = typer.Option(
128
+ None,
129
+ "--project",
130
+ "-p",
131
+ help="Optional project scope (recommended for strict sandbox isolation)",
132
+ ),
133
+ profile: str | None = typer.Option(None, "--profile"),
134
+ ) -> None:
135
+ """Check status of a Forge deployment."""
136
+ tokens = load_tokens(profile=profile)
137
+ if not tokens:
138
+ typer.echo("Not logged in. Run: sov login", err=True)
139
+ raise typer.Exit(1)
140
+
141
+ api_url = get_api_url(profile)
142
+ headers = {"Authorization": f"Bearer {tokens['access_token']}"}
143
+
144
+ try:
145
+ resp = httpx.get(
146
+ f"{api_url}/api/v1/fn/{deploy_id}/status",
147
+ headers=headers,
148
+ params={"project_id": project_id} if project_id else None,
149
+ timeout=15,
150
+ )
151
+ resp.raise_for_status()
152
+ except httpx.HTTPStatusError as exc:
153
+ typer.echo(f"Status request failed: {exc.response.text}", err=True)
154
+ raise typer.Exit(1) from exc
155
+
156
+ data = resp.json()
157
+ typer.echo(json.dumps(data, indent=2, sort_keys=True))
158
+
159
+
160
+ @app.command("invoke")
161
+ def invoke(
162
+ function_name: str = typer.Argument(..., help="Function name/path (e.g. my-fn)"),
163
+ project_id: str | None = typer.Option(
164
+ None,
165
+ "--project",
166
+ "-p",
167
+ help="Project scope for invocation sandbox",
168
+ ),
169
+ deploy_id: str | None = typer.Option(
170
+ None,
171
+ "--deploy-id",
172
+ help="Invoke a specific deployment id instead of latest",
173
+ ),
174
+ execution_profile: str = typer.Option(
175
+ "standard",
176
+ "--execution-profile",
177
+ help="Execution profile: standard or heavy",
178
+ ),
179
+ async_mode: bool = typer.Option(
180
+ False,
181
+ "--async",
182
+ help="Queue async invocation (recommended for heavy functions)",
183
+ ),
184
+ wait: bool = typer.Option(
185
+ True,
186
+ "--wait/--no-wait",
187
+ help="When --async is used, poll until completion",
188
+ ),
189
+ simulate: bool = typer.Option(
190
+ False,
191
+ "--simulate",
192
+ help="Run simulation mode and store simulation history",
193
+ ),
194
+ data: str | None = typer.Option(
195
+ None,
196
+ "--data",
197
+ help="Inline JSON payload passed to the function",
198
+ ),
199
+ data_file: Path | None = typer.Option(
200
+ None,
201
+ "--data-file",
202
+ exists=True,
203
+ file_okay=True,
204
+ dir_okay=False,
205
+ readable=True,
206
+ help="Path to JSON payload file",
207
+ ),
208
+ profile: str | None = typer.Option(None, "--profile"),
209
+ ) -> None:
210
+ """Invoke a deployed function via Forge sandbox runtime."""
211
+ tokens = load_tokens(profile=profile)
212
+ if not tokens:
213
+ typer.echo("Not logged in. Run: sov login", err=True)
214
+ raise typer.Exit(1)
215
+
216
+ api_url = get_api_url(profile)
217
+ headers = {"Authorization": f"Bearer {tokens['access_token']}"}
218
+ payload = _load_json_payload(data=data, data_file=data_file)
219
+
220
+ query: dict[str, str] = {}
221
+ if project_id:
222
+ query["project_id"] = project_id
223
+ if deploy_id:
224
+ query["deploy_id"] = deploy_id
225
+
226
+ query["execution_profile"] = execution_profile
227
+
228
+ if async_mode:
229
+ query["simulation"] = "true" if simulate else "false"
230
+ try:
231
+ resp = httpx.post(
232
+ f"{api_url}/api/v1/fn/{function_name}/invoke/async",
233
+ headers=headers,
234
+ params=query,
235
+ json=payload,
236
+ timeout=30,
237
+ )
238
+ resp.raise_for_status()
239
+ except httpx.HTTPStatusError as exc:
240
+ typer.echo(f"Async invocation failed: {exc.response.text}", err=True)
241
+ raise typer.Exit(1) from exc
242
+
243
+ accepted = resp.json()
244
+ typer.echo(json.dumps(accepted, indent=2, sort_keys=True))
245
+
246
+ invocation_id = accepted.get("invocation_id")
247
+ if wait and invocation_id:
248
+ final_status = _poll_async_invocation(
249
+ api_url=api_url,
250
+ headers=headers,
251
+ invocation_id=str(invocation_id),
252
+ project_id=project_id,
253
+ )
254
+ typer.echo(json.dumps(final_status, indent=2, sort_keys=True))
255
+ if final_status.get("status") not in {"SUCCEEDED"}:
256
+ raise typer.Exit(1)
257
+ return
258
+
259
+ endpoint = "simulate" if simulate else "invoke"
260
+ try:
261
+ resp = httpx.post(
262
+ f"{api_url}/api/v1/fn/{function_name}/{endpoint}",
263
+ headers=headers,
264
+ params=query if query else None,
265
+ json=payload,
266
+ timeout=60,
267
+ )
268
+ resp.raise_for_status()
269
+ except httpx.HTTPStatusError as exc:
270
+ typer.echo(f"Invocation failed: {exc.response.text}", err=True)
271
+ raise typer.Exit(1) from exc
272
+
273
+ typer.echo(json.dumps(resp.json(), indent=2, sort_keys=True))
274
+
275
+
276
+ @app.command("versions")
277
+ def versions(
278
+ function_name: str = typer.Argument(..., help="Function name/path (e.g. my-fn)"),
279
+ project_id: str | None = typer.Option(
280
+ None,
281
+ "--project",
282
+ "-p",
283
+ help="Project scope for version history",
284
+ ),
285
+ limit: int = typer.Option(50, "--limit", min=1, max=500),
286
+ offset: int = typer.Option(0, "--offset", min=0),
287
+ profile: str | None = typer.Option(None, "--profile"),
288
+ ) -> None:
289
+ """List deployment versions for one function."""
290
+ tokens = load_tokens(profile=profile)
291
+ if not tokens:
292
+ typer.echo("Not logged in. Run: sov login", err=True)
293
+ raise typer.Exit(1)
294
+
295
+ api_url = get_api_url(profile)
296
+ headers = {"Authorization": f"Bearer {tokens['access_token']}"}
297
+ params: dict[str, str | int] = {"limit": limit, "offset": offset}
298
+ if project_id:
299
+ params["project_id"] = project_id
300
+
301
+ try:
302
+ resp = httpx.get(
303
+ f"{api_url}/api/v1/fn/{function_name}/versions",
304
+ headers=headers,
305
+ params=params,
306
+ timeout=30,
307
+ )
308
+ resp.raise_for_status()
309
+ except httpx.HTTPStatusError as exc:
310
+ typer.echo(f"Version history request failed: {exc.response.text}", err=True)
311
+ raise typer.Exit(1) from exc
312
+
313
+ typer.echo(json.dumps(resp.json(), indent=2, sort_keys=True))
314
+
315
+
316
+ @app.command("history")
317
+ def history(
318
+ function_name: str = typer.Argument(..., help="Function name/path (e.g. my-fn)"),
319
+ project_id: str | None = typer.Option(
320
+ None,
321
+ "--project",
322
+ "-p",
323
+ help="Project scope for invocation history",
324
+ ),
325
+ mode: str | None = typer.Option(
326
+ None,
327
+ "--mode",
328
+ help="Optional mode filter: invoke or simulate",
329
+ ),
330
+ limit: int = typer.Option(50, "--limit", min=1, max=500),
331
+ offset: int = typer.Option(0, "--offset", min=0),
332
+ profile: str | None = typer.Option(None, "--profile"),
333
+ ) -> None:
334
+ """List invocation history for one function."""
335
+ tokens = load_tokens(profile=profile)
336
+ if not tokens:
337
+ typer.echo("Not logged in. Run: sov login", err=True)
338
+ raise typer.Exit(1)
339
+
340
+ api_url = get_api_url(profile)
341
+ headers = {"Authorization": f"Bearer {tokens['access_token']}"}
342
+ params: dict[str, str | int] = {"limit": limit, "offset": offset}
343
+ if project_id:
344
+ params["project_id"] = project_id
345
+ if mode:
346
+ params["mode"] = mode
347
+
348
+ try:
349
+ resp = httpx.get(
350
+ f"{api_url}/api/v1/fn/{function_name}/history/invocations",
351
+ headers=headers,
352
+ params=params,
353
+ timeout=30,
354
+ )
355
+ resp.raise_for_status()
356
+ except httpx.HTTPStatusError as exc:
357
+ typer.echo(f"Invocation history request failed: {exc.response.text}", err=True)
358
+ raise typer.Exit(1) from exc
359
+
360
+ typer.echo(json.dumps(resp.json(), indent=2, sort_keys=True))
361
+
362
+
363
+ @app.command("functions")
364
+ def functions(
365
+ project_id: str | None = typer.Option(
366
+ None,
367
+ "--project",
368
+ "-p",
369
+ help="Project scope for function listing",
370
+ ),
371
+ limit: int = typer.Option(100, "--limit", min=1, max=500),
372
+ offset: int = typer.Option(0, "--offset", min=0),
373
+ profile: str | None = typer.Option(None, "--profile"),
374
+ ) -> None:
375
+ """List Forge functions with active versions and queue summary."""
376
+ tokens = load_tokens(profile=profile)
377
+ if not tokens:
378
+ typer.echo("Not logged in. Run: sov login", err=True)
379
+ raise typer.Exit(1)
380
+
381
+ api_url = get_api_url(profile)
382
+ headers = {"Authorization": f"Bearer {tokens['access_token']}"}
383
+ params: dict[str, str | int] = {"limit": limit, "offset": offset}
384
+ if project_id:
385
+ params["project_id"] = project_id
386
+
387
+ try:
388
+ resp = httpx.get(
389
+ f"{api_url}/api/v1/fn/functions",
390
+ headers=headers,
391
+ params=params,
392
+ timeout=30,
393
+ )
394
+ resp.raise_for_status()
395
+ except httpx.HTTPStatusError as exc:
396
+ typer.echo(f"Function listing failed: {exc.response.text}", err=True)
397
+ raise typer.Exit(1) from exc
398
+
399
+ typer.echo(json.dumps(resp.json(), indent=2, sort_keys=True))
400
+
401
+
402
+ @app.command("jobs")
403
+ def jobs(
404
+ function_name: str = typer.Argument(..., help="Function name/path (e.g. my-fn)"),
405
+ project_id: str | None = typer.Option(
406
+ None,
407
+ "--project",
408
+ "-p",
409
+ help="Project scope for durable async jobs",
410
+ ),
411
+ status: str | None = typer.Option(None, "--status", help="Optional status filter"),
412
+ limit: int = typer.Option(100, "--limit", min=1, max=500),
413
+ offset: int = typer.Option(0, "--offset", min=0),
414
+ profile: str | None = typer.Option(None, "--profile"),
415
+ ) -> None:
416
+ """List durable async jobs for a function."""
417
+ tokens = load_tokens(profile=profile)
418
+ if not tokens:
419
+ typer.echo("Not logged in. Run: sov login", err=True)
420
+ raise typer.Exit(1)
421
+
422
+ api_url = get_api_url(profile)
423
+ headers = {"Authorization": f"Bearer {tokens['access_token']}"}
424
+ params: dict[str, str | int] = {"limit": limit, "offset": offset}
425
+ if project_id:
426
+ params["project_id"] = project_id
427
+ if status:
428
+ params["status"] = status
429
+
430
+ try:
431
+ resp = httpx.get(
432
+ f"{api_url}/api/v1/fn/{function_name}/jobs",
433
+ headers=headers,
434
+ params=params,
435
+ timeout=30,
436
+ )
437
+ resp.raise_for_status()
438
+ except httpx.HTTPStatusError as exc:
439
+ typer.echo(f"Job listing failed: {exc.response.text}", err=True)
440
+ raise typer.Exit(1) from exc
441
+
442
+ typer.echo(json.dumps(resp.json(), indent=2, sort_keys=True))
443
+
444
+
445
+ @app.command("sim-diff")
446
+ def sim_diff(
447
+ function_name: str = typer.Argument(..., help="Function name/path (e.g. my-fn)"),
448
+ left_invocation_id: str = typer.Argument(..., help="Left simulation invocation id"),
449
+ right_invocation_id: str = typer.Argument(..., help="Right simulation invocation id"),
450
+ project_id: str | None = typer.Option(
451
+ None,
452
+ "--project",
453
+ "-p",
454
+ help="Project scope for simulation diff",
455
+ ),
456
+ profile: str | None = typer.Option(None, "--profile"),
457
+ ) -> None:
458
+ """Show unified diff between two simulation invocations."""
459
+ tokens = load_tokens(profile=profile)
460
+ if not tokens:
461
+ typer.echo("Not logged in. Run: sov login", err=True)
462
+ raise typer.Exit(1)
463
+
464
+ api_url = get_api_url(profile)
465
+ headers = {"Authorization": f"Bearer {tokens['access_token']}"}
466
+ params: dict[str, str] = {
467
+ "left_invocation_id": left_invocation_id,
468
+ "right_invocation_id": right_invocation_id,
469
+ }
470
+ if project_id:
471
+ params["project_id"] = project_id
472
+
473
+ try:
474
+ resp = httpx.get(
475
+ f"{api_url}/api/v1/fn/{function_name}/history/simulation-diff",
476
+ headers=headers,
477
+ params=params,
478
+ timeout=30,
479
+ )
480
+ resp.raise_for_status()
481
+ except httpx.HTTPStatusError as exc:
482
+ typer.echo(f"Simulation diff request failed: {exc.response.text}", err=True)
483
+ raise typer.Exit(1) from exc
484
+
485
+ typer.echo(json.dumps(resp.json(), indent=2, sort_keys=True))
486
+
487
+
488
+ @app.command("promote")
489
+ def promote(
490
+ function_name: str = typer.Argument(..., help="Function name/path (e.g. my-fn)"),
491
+ project_id: str | None = typer.Option(
492
+ None,
493
+ "--project",
494
+ "-p",
495
+ help="Project scope for version promotion",
496
+ ),
497
+ version: int | None = typer.Option(None, "--version", min=1, help="Target version to promote"),
498
+ deploy_id: str | None = typer.Option(None, "--deploy-id", help="Target deploy id to promote"),
499
+ reason: str = typer.Option("", "--reason", help="Promotion reason"),
500
+ profile: str | None = typer.Option(None, "--profile"),
501
+ ) -> None:
502
+ """Promote a deployment/version to become active."""
503
+ if version is None and not deploy_id:
504
+ typer.echo("Provide either --version or --deploy-id", err=True)
505
+ raise typer.Exit(1)
506
+
507
+ tokens = load_tokens(profile=profile)
508
+ if not tokens:
509
+ typer.echo("Not logged in. Run: sov login", err=True)
510
+ raise typer.Exit(1)
511
+
512
+ api_url = get_api_url(profile)
513
+ headers = {"Authorization": f"Bearer {tokens['access_token']}"}
514
+ params: dict[str, str] = {}
515
+ if project_id:
516
+ params["project_id"] = project_id
517
+
518
+ body: dict[str, object] = {"reason": reason}
519
+ if version is not None:
520
+ body["version"] = version
521
+ if deploy_id:
522
+ body["deploy_id"] = deploy_id
523
+
524
+ try:
525
+ resp = httpx.post(
526
+ f"{api_url}/api/v1/fn/{function_name}/promote",
527
+ headers=headers,
528
+ params=params if params else None,
529
+ json=body,
530
+ timeout=30,
531
+ )
532
+ resp.raise_for_status()
533
+ except httpx.HTTPStatusError as exc:
534
+ typer.echo(f"Promote request failed: {exc.response.text}", err=True)
535
+ raise typer.Exit(1) from exc
536
+
537
+ typer.echo(json.dumps(resp.json(), indent=2, sort_keys=True))
538
+
539
+
540
+ @app.command("rollback")
541
+ def rollback(
542
+ function_name: str = typer.Argument(..., help="Function name/path (e.g. my-fn)"),
543
+ project_id: str | None = typer.Option(
544
+ None,
545
+ "--project",
546
+ "-p",
547
+ help="Project scope for rollback",
548
+ ),
549
+ version: int | None = typer.Option(
550
+ None,
551
+ "--version",
552
+ min=1,
553
+ help="Optional explicit target version (defaults to previous active)",
554
+ ),
555
+ reason: str = typer.Option("", "--reason", help="Rollback reason"),
556
+ profile: str | None = typer.Option(None, "--profile"),
557
+ ) -> None:
558
+ """Rollback active function deployment to a previous version."""
559
+ tokens = load_tokens(profile=profile)
560
+ if not tokens:
561
+ typer.echo("Not logged in. Run: sov login", err=True)
562
+ raise typer.Exit(1)
563
+
564
+ api_url = get_api_url(profile)
565
+ headers = {"Authorization": f"Bearer {tokens['access_token']}"}
566
+ params: dict[str, str] = {}
567
+ if project_id:
568
+ params["project_id"] = project_id
569
+
570
+ body: dict[str, object] = {"reason": reason}
571
+ if version is not None:
572
+ body["version"] = version
573
+
574
+ try:
575
+ resp = httpx.post(
576
+ f"{api_url}/api/v1/fn/{function_name}/rollback",
577
+ headers=headers,
578
+ params=params if params else None,
579
+ json=body,
580
+ timeout=30,
581
+ )
582
+ resp.raise_for_status()
583
+ except httpx.HTTPStatusError as exc:
584
+ typer.echo(f"Rollback request failed: {exc.response.text}", err=True)
585
+ raise typer.Exit(1) from exc
586
+
587
+ typer.echo(json.dumps(resp.json(), indent=2, sort_keys=True))
588
+
589
+
590
+ @app.command("logs")
591
+ def logs(
592
+ function_name: str = typer.Argument(..., help="Function name"),
593
+ limit: int = typer.Option(50, "--limit", min=1, max=200),
594
+ offset: int = typer.Option(0, "--offset", min=0),
595
+ status: str | None = typer.Option(None, "--status", help="Filter: SUCCEEDED, FAILED, TIMEOUT"),
596
+ profile: str | None = typer.Option(None, "--profile"),
597
+ org_id: str | None = typer.Option(None, "--org"),
598
+ project_id: str | None = typer.Option(None, "--project"),
599
+ env_id: str | None = typer.Option(None, "--env"),
600
+ ) -> None:
601
+ """Fetch recent invocation logs for a function."""
602
+ from urllib.parse import quote
603
+
604
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
605
+
606
+ params: dict[str, str] = {"limit": str(limit), "offset": str(offset)}
607
+ if status:
608
+ params["status"] = status
609
+ safe_name = quote(function_name, safe="")
610
+ try:
611
+ result = call_api(
612
+ method="GET",
613
+ path=f"/api/v1/fn/functions/{safe_name}/logs",
614
+ profile=profile,
615
+ org_id=org_id,
616
+ project_id=project_id,
617
+ env_id=env_id,
618
+ require_scope=True,
619
+ params=params,
620
+ )
621
+ except CLIRequestError as exc:
622
+ typer.echo(str(exc), err=True)
623
+ raise typer.Exit(1) from exc
624
+ typer.echo(print_json(result))
625
+
626
+
627
+ @app.command("get")
628
+ def get_function(
629
+ function_name: str = typer.Argument(..., help="Function name"),
630
+ profile: str | None = typer.Option(None, "--profile"),
631
+ org_id: str | None = typer.Option(None, "--org"),
632
+ project_id: str | None = typer.Option(None, "--project"),
633
+ env_id: str | None = typer.Option(None, "--env"),
634
+ ) -> None:
635
+ """Get function metadata."""
636
+ from urllib.parse import quote
637
+
638
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
639
+
640
+ safe_name = quote(function_name, safe="")
641
+ try:
642
+ result = call_api(
643
+ method="GET",
644
+ path=f"/api/v1/fn/functions/{safe_name}",
645
+ profile=profile,
646
+ org_id=org_id,
647
+ project_id=project_id,
648
+ env_id=env_id,
649
+ require_scope=True,
650
+ )
651
+ except CLIRequestError as exc:
652
+ typer.echo(str(exc), err=True)
653
+ raise typer.Exit(1) from exc
654
+ typer.echo(print_json(result))
655
+
656
+
657
+ @app.command("delete")
658
+ def delete_function(
659
+ function_name: str = typer.Argument(..., help="Function name"),
660
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
661
+ profile: str | None = typer.Option(None, "--profile"),
662
+ org_id: str | None = typer.Option(None, "--org"),
663
+ project_id: str | None = typer.Option(None, "--project"),
664
+ env_id: str | None = typer.Option(None, "--env"),
665
+ ) -> None:
666
+ """Delete a deployed function."""
667
+ from urllib.parse import quote
668
+
669
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
670
+
671
+ if not yes and not typer.confirm(f"Delete function '{function_name}'?"):
672
+ raise typer.Exit(0)
673
+
674
+ safe_name = quote(function_name, safe="")
675
+ try:
676
+ result = call_api(
677
+ method="DELETE",
678
+ path=f"/api/v1/fn/functions/{safe_name}",
679
+ profile=profile,
680
+ org_id=org_id,
681
+ project_id=project_id,
682
+ env_id=env_id,
683
+ require_scope=True,
684
+ )
685
+ except CLIRequestError as exc:
686
+ typer.echo(str(exc), err=True)
687
+ raise typer.Exit(1) from exc
688
+ typer.echo(print_json(result))
689
+ typer.echo(typer.style(f"Deleted function '{function_name}'", fg=typer.colors.GREEN))
690
+
691
+
692
+ @app.command("source")
693
+ def function_source(
694
+ function_name: str = typer.Argument(..., help="Function name"),
695
+ profile: str | None = typer.Option(None, "--profile"),
696
+ org_id: str | None = typer.Option(None, "--org"),
697
+ project_id: str | None = typer.Option(None, "--project"),
698
+ env_id: str | None = typer.Option(None, "--env"),
699
+ ) -> None:
700
+ """Fetch function source code."""
701
+ from urllib.parse import quote
702
+
703
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
704
+
705
+ safe_name = quote(function_name, safe="")
706
+ try:
707
+ result = call_api(
708
+ method="GET",
709
+ path=f"/api/v1/fn/functions/{safe_name}/source",
710
+ profile=profile,
711
+ org_id=org_id,
712
+ project_id=project_id,
713
+ env_id=env_id,
714
+ require_scope=True,
715
+ )
716
+ except CLIRequestError as exc:
717
+ typer.echo(str(exc), err=True)
718
+ raise typer.Exit(1) from exc
719
+ typer.echo(print_json(result))
720
+
721
+
722
+ @app.command("update")
723
+ def update_function(
724
+ function_name: str = typer.Argument(..., help="Function name"),
725
+ source: str | None = typer.Option(None, "--source", help="Inline source code"),
726
+ source_file: Path | None = typer.Option(None, "--source-file", exists=True, readable=True),
727
+ runtime: str | None = typer.Option(None, "--runtime"),
728
+ entrypoint: str | None = typer.Option(None, "--entrypoint"),
729
+ region: str | None = typer.Option(None, "--region"),
730
+ description: str | None = typer.Option(None, "--description"),
731
+ verify_jwt: bool | None = typer.Option(None, "--verify-jwt/--no-verify-jwt"),
732
+ env_vars_json: str | None = typer.Option(None, "--env-vars", help="JSON object"),
733
+ profile: str | None = typer.Option(None, "--profile"),
734
+ org_id: str | None = typer.Option(None, "--org"),
735
+ project_id: str | None = typer.Option(None, "--project"),
736
+ env_id: str | None = typer.Option(None, "--env"),
737
+ ) -> None:
738
+ """Patch function metadata and/or source."""
739
+ from urllib.parse import quote
740
+
741
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, parse_json_arg, print_json
742
+
743
+ if source and source_file:
744
+ raise typer.BadParameter("Use either --source or --source-file, not both")
745
+ body: dict[str, object] = {}
746
+ if source_file:
747
+ body["source"] = source_file.read_text(encoding="utf-8")
748
+ elif source is not None:
749
+ body["source"] = source
750
+ if runtime is not None:
751
+ body["runtime"] = runtime
752
+ if entrypoint is not None:
753
+ body["entrypoint"] = entrypoint
754
+ if region is not None:
755
+ body["region"] = region
756
+ if description is not None:
757
+ body["description"] = description
758
+ if verify_jwt is not None:
759
+ body["verify_jwt"] = verify_jwt
760
+ if env_vars_json is not None:
761
+ env_vars = parse_json_arg(env_vars_json, label="env-vars")
762
+ if env_vars is None or not isinstance(env_vars, dict):
763
+ raise typer.BadParameter("--env-vars must be a JSON object")
764
+ body["env_vars"] = env_vars
765
+ if not body:
766
+ raise typer.BadParameter("Provide at least one field to update")
767
+ safe_name = quote(function_name, safe="")
768
+ try:
769
+ result = call_api(
770
+ method="PATCH",
771
+ path=f"/api/v1/fn/functions/{safe_name}",
772
+ profile=profile,
773
+ org_id=org_id,
774
+ project_id=project_id,
775
+ env_id=env_id,
776
+ require_scope=True,
777
+ body=body,
778
+ )
779
+ except CLIRequestError as exc:
780
+ typer.echo(str(exc), err=True)
781
+ raise typer.Exit(1) from exc
782
+ typer.echo(print_json(result))
783
+
784
+
785
+ @app.command("policy-get")
786
+ def policy_get(
787
+ function_name: str = typer.Argument(..., help="Function name"),
788
+ profile: str | None = typer.Option(None, "--profile"),
789
+ org_id: str | None = typer.Option(None, "--org"),
790
+ project_id: str | None = typer.Option(None, "--project"),
791
+ env_id: str | None = typer.Option(None, "--env"),
792
+ ) -> None:
793
+ """Fetch function invoke policy."""
794
+ from urllib.parse import quote
795
+
796
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
797
+
798
+ safe_name = quote(function_name, safe="")
799
+ try:
800
+ result = call_api(
801
+ method="GET",
802
+ path=f"/api/v1/fn/functions/{safe_name}/policy",
803
+ profile=profile,
804
+ org_id=org_id,
805
+ project_id=project_id,
806
+ env_id=env_id,
807
+ require_scope=True,
808
+ )
809
+ except CLIRequestError as exc:
810
+ typer.echo(str(exc), err=True)
811
+ raise typer.Exit(1) from exc
812
+ typer.echo(print_json(result))
813
+
814
+
815
+ @app.command("policy-set")
816
+ def policy_set(
817
+ function_name: str = typer.Argument(..., help="Function name"),
818
+ policy_json: str = typer.Argument(..., help="JSON policy object"),
819
+ profile: str | None = typer.Option(None, "--profile"),
820
+ org_id: str | None = typer.Option(None, "--org"),
821
+ project_id: str | None = typer.Option(None, "--project"),
822
+ env_id: str | None = typer.Option(None, "--env"),
823
+ ) -> None:
824
+ """Replace function invoke policy."""
825
+ from urllib.parse import quote
826
+
827
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, parse_json_arg, print_json
828
+
829
+ policy = parse_json_arg(policy_json, label="policy")
830
+ if policy is None or not isinstance(policy, dict):
831
+ raise typer.BadParameter("policy must be a JSON object")
832
+ safe_name = quote(function_name, safe="")
833
+ try:
834
+ result = call_api(
835
+ method="PUT",
836
+ path=f"/api/v1/fn/functions/{safe_name}/policy",
837
+ profile=profile,
838
+ org_id=org_id,
839
+ project_id=project_id,
840
+ env_id=env_id,
841
+ require_scope=True,
842
+ body=policy,
843
+ )
844
+ except CLIRequestError as exc:
845
+ typer.echo(str(exc), err=True)
846
+ raise typer.Exit(1) from exc
847
+ typer.echo(print_json(result))
848
+
849
+
850
+ @app.command("bindings-get")
851
+ def bindings_get(
852
+ function_name: str = typer.Argument(..., help="Function name"),
853
+ profile: str | None = typer.Option(None, "--profile"),
854
+ org_id: str | None = typer.Option(None, "--org"),
855
+ project_id: str | None = typer.Option(None, "--project"),
856
+ env_id: str | None = typer.Option(None, "--env"),
857
+ ) -> None:
858
+ """Fetch function secret bindings."""
859
+ from urllib.parse import quote
860
+
861
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
862
+
863
+ safe_name = quote(function_name, safe="")
864
+ try:
865
+ result = call_api(
866
+ method="GET",
867
+ path=f"/api/v1/fn/functions/{safe_name}/bindings",
868
+ profile=profile,
869
+ org_id=org_id,
870
+ project_id=project_id,
871
+ env_id=env_id,
872
+ require_scope=True,
873
+ )
874
+ except CLIRequestError as exc:
875
+ typer.echo(str(exc), err=True)
876
+ raise typer.Exit(1) from exc
877
+ typer.echo(print_json(result))
878
+
879
+
880
+ @app.command("bindings-set")
881
+ def bindings_set(
882
+ function_name: str = typer.Argument(..., help="Function name"),
883
+ bindings_json: str = typer.Argument(..., help='JSON array, e.g. [{"name":"API_KEY","value":"..."}]'),
884
+ profile: str | None = typer.Option(None, "--profile"),
885
+ org_id: str | None = typer.Option(None, "--org"),
886
+ project_id: str | None = typer.Option(None, "--project"),
887
+ env_id: str | None = typer.Option(None, "--env"),
888
+ ) -> None:
889
+ """Replace function secret bindings."""
890
+ from urllib.parse import quote
891
+
892
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, parse_json_arg, print_json
893
+
894
+ bindings = parse_json_arg(bindings_json, label="bindings")
895
+ if bindings is None or not isinstance(bindings, list):
896
+ raise typer.BadParameter("bindings must be a JSON array")
897
+ safe_name = quote(function_name, safe="")
898
+ try:
899
+ result = call_api(
900
+ method="PUT",
901
+ path=f"/api/v1/fn/functions/{safe_name}/bindings",
902
+ profile=profile,
903
+ org_id=org_id,
904
+ project_id=project_id,
905
+ env_id=env_id,
906
+ require_scope=True,
907
+ body={"bindings": bindings},
908
+ )
909
+ except CLIRequestError as exc:
910
+ typer.echo(str(exc), err=True)
911
+ raise typer.Exit(1) from exc
912
+ typer.echo(print_json(result))
913
+
914
+
915
+ @app.command("github-repos")
916
+ def github_repos(
917
+ token: str | None = typer.Option(None, "--token", help="Optional GitHub PAT override"),
918
+ limit: int = typer.Option(30, "--limit", min=1, max=100),
919
+ profile: str | None = typer.Option(None, "--profile"),
920
+ org_id: str | None = typer.Option(None, "--org"),
921
+ project_id: str | None = typer.Option(None, "--project"),
922
+ env_id: str | None = typer.Option(None, "--env"),
923
+ ) -> None:
924
+ """List GitHub repositories available for import."""
925
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
926
+
927
+ body: dict[str, object] = {"limit": limit}
928
+ if token:
929
+ body["token"] = token
930
+ try:
931
+ result = call_api(
932
+ method="POST",
933
+ path="/api/v1/fn/github/repositories",
934
+ profile=profile,
935
+ org_id=org_id,
936
+ project_id=project_id,
937
+ env_id=env_id,
938
+ require_scope=True,
939
+ body=body,
940
+ )
941
+ except CLIRequestError as exc:
942
+ typer.echo(str(exc), err=True)
943
+ raise typer.Exit(1) from exc
944
+ typer.echo(print_json(result))
945
+
946
+
947
+ @app.command("github-import")
948
+ def github_import(
949
+ function_name: str = typer.Option(..., "--name", help="Function name to create/update"),
950
+ repository: str = typer.Option(..., "--repo", help="owner/repo"),
951
+ path: str = typer.Option(..., "--path", help="Path within the repository"),
952
+ ref: str = typer.Option("main", "--ref"),
953
+ token: str | None = typer.Option(None, "--token"),
954
+ profile: str | None = typer.Option(None, "--profile"),
955
+ org_id: str | None = typer.Option(None, "--org"),
956
+ project_id: str | None = typer.Option(None, "--project"),
957
+ env_id: str | None = typer.Option(None, "--env"),
958
+ ) -> None:
959
+ """Import a function from a GitHub repository path."""
960
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
961
+
962
+ body: dict[str, object] = {
963
+ "function_name": function_name,
964
+ "repository": repository,
965
+ "path": path,
966
+ "ref": ref,
967
+ }
968
+ if token:
969
+ body["token"] = token
970
+ try:
971
+ result = call_api(
972
+ method="POST",
973
+ path="/api/v1/fn/github/import",
974
+ profile=profile,
975
+ org_id=org_id,
976
+ project_id=project_id,
977
+ env_id=env_id,
978
+ require_scope=True,
979
+ body=body,
980
+ )
981
+ except CLIRequestError as exc:
982
+ typer.echo(str(exc), err=True)
983
+ raise typer.Exit(1) from exc
984
+ typer.echo(print_json(result))
985
+
986
+
987
+ @app.command("github-commit")
988
+ def github_commit(
989
+ function_name: str = typer.Option(..., "--name", help="Local function name"),
990
+ repository: str = typer.Option(..., "--repo", help="owner/repo"),
991
+ path: str = typer.Option(..., "--path", help="Target path in the repository"),
992
+ message: str = typer.Option(..., "--message", help="Commit message"),
993
+ ref: str = typer.Option("main", "--ref"),
994
+ token: str | None = typer.Option(None, "--token"),
995
+ committer_name: str | None = typer.Option(None, "--committer-name"),
996
+ committer_email: str | None = typer.Option(None, "--committer-email"),
997
+ profile: str | None = typer.Option(None, "--profile"),
998
+ org_id: str | None = typer.Option(None, "--org"),
999
+ project_id: str | None = typer.Option(None, "--project"),
1000
+ env_id: str | None = typer.Option(None, "--env"),
1001
+ ) -> None:
1002
+ """Commit local function source back to GitHub."""
1003
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
1004
+
1005
+ body: dict[str, object] = {
1006
+ "function_name": function_name,
1007
+ "repository": repository,
1008
+ "path": path,
1009
+ "message": message,
1010
+ "ref": ref,
1011
+ }
1012
+ if token:
1013
+ body["token"] = token
1014
+ if committer_name:
1015
+ body["committer_name"] = committer_name
1016
+ if committer_email:
1017
+ body["committer_email"] = committer_email
1018
+ try:
1019
+ result = call_api(
1020
+ method="POST",
1021
+ path="/api/v1/fn/github/commit",
1022
+ profile=profile,
1023
+ org_id=org_id,
1024
+ project_id=project_id,
1025
+ env_id=env_id,
1026
+ require_scope=True,
1027
+ body=body,
1028
+ )
1029
+ except CLIRequestError as exc:
1030
+ typer.echo(str(exc), err=True)
1031
+ raise typer.Exit(1) from exc
1032
+ typer.echo(print_json(result))
1033
+
1034
+
1035
+ def _zip_directory(directory: Path) -> bytes:
1036
+ """Recursively zip all files in *directory* and return raw zip bytes."""
1037
+ buf = io.BytesIO()
1038
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
1039
+ for path in sorted(directory.rglob("*")):
1040
+ if path.is_file() and not _should_exclude(path):
1041
+ zf.write(path, path.relative_to(directory))
1042
+ return buf.getvalue()
1043
+
1044
+
1045
+ def _load_json_payload(*, data: str | None, data_file: Path | None) -> Any:
1046
+ if data and data_file:
1047
+ raise typer.BadParameter("Use either --data or --data-file, not both")
1048
+ if data_file:
1049
+ return json.loads(data_file.read_text(encoding="utf-8"))
1050
+ if data:
1051
+ return json.loads(data)
1052
+ return {}
1053
+
1054
+
1055
+ def _poll_async_invocation(
1056
+ *,
1057
+ api_url: str,
1058
+ headers: dict[str, str],
1059
+ invocation_id: str,
1060
+ project_id: str | None,
1061
+ ) -> dict[str, Any]:
1062
+ """Poll async invocation status until terminal state or timeout."""
1063
+ deadline = time.time() + _ASYNC_POLL_TIMEOUT
1064
+
1065
+ while time.time() < deadline:
1066
+ params = {"project_id": project_id} if project_id else None
1067
+ resp = httpx.get(
1068
+ f"{api_url}/api/v1/fn/invocations/{invocation_id}",
1069
+ headers=headers,
1070
+ params=params,
1071
+ timeout=15,
1072
+ )
1073
+ if resp.status_code >= 400:
1074
+ typer.echo(f"Async status request failed: {resp.text}", err=True)
1075
+ raise typer.Exit(1)
1076
+
1077
+ body = resp.json()
1078
+ if not isinstance(body, dict):
1079
+ typer.echo("Async status response was not a JSON object", err=True)
1080
+ raise typer.Exit(1)
1081
+ status = str(body.get("status", "UNKNOWN")).upper()
1082
+ if status in {"SUCCEEDED", "FAILED", "TIMEOUT"}:
1083
+ return body
1084
+
1085
+ time.sleep(_ASYNC_POLL_INTERVAL)
1086
+
1087
+ typer.echo("Async invocation polling timed out", err=True)
1088
+ raise typer.Exit(1)
1089
+
1090
+
1091
+ _EXCLUDE_NAMES = {".git", "__pycache__", ".venv", "node_modules", ".DS_Store"}
1092
+ _EXCLUDE_SUFFIXES = {".pyc", ".pyo", ".egg-info"}
1093
+
1094
+
1095
+ def _should_exclude(path: Path) -> bool:
1096
+ for part in path.parts:
1097
+ if part in _EXCLUDE_NAMES:
1098
+ return True
1099
+ return path.suffix in _EXCLUDE_SUFFIXES