sovaria-cli 0.2.4__tar.gz → 0.2.7__tar.gz

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 (87) hide show
  1. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/PKG-INFO +2 -1
  2. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/__init__.py +1 -1
  3. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/admin.py +14 -14
  4. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/api.py +4 -4
  5. sovaria_cli-0.2.7/apps/cli/src/commands/audit.py +172 -0
  6. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/auth_product.py +36 -32
  7. sovaria_cli-0.2.7/apps/cli/src/commands/billing.py +498 -0
  8. sovaria_cli-0.2.7/apps/cli/src/commands/chrono.py +232 -0
  9. sovaria_cli-0.2.7/apps/cli/src/commands/collections.py +38 -0
  10. sovaria_cli-0.2.7/apps/cli/src/commands/connect.py +129 -0
  11. sovaria_cli-0.2.7/apps/cli/src/commands/context.py +190 -0
  12. sovaria_cli-0.2.7/apps/cli/src/commands/dashboard.py +166 -0
  13. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/data.py +37 -39
  14. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/db.py +474 -96
  15. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/deploy.py +562 -351
  16. sovaria_cli-0.2.7/apps/cli/src/commands/gen.py +85 -0
  17. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/governance.py +223 -248
  18. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/iam.py +188 -208
  19. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/init_cmd.py +17 -14
  20. sovaria_cli-0.2.7/apps/cli/src/commands/inspect.py +53 -0
  21. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/keys.py +32 -27
  22. sovaria_cli-0.2.7/apps/cli/src/commands/link_cmd.py +108 -0
  23. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/login.py +321 -298
  24. sovaria_cli-0.2.7/apps/cli/src/commands/logs.py +103 -0
  25. sovaria_cli-0.2.7/apps/cli/src/commands/oidc_providers.py +199 -0
  26. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/org.py +18 -9
  27. sovaria_cli-0.2.7/apps/cli/src/commands/passkeys.py +82 -0
  28. sovaria_cli-0.2.7/apps/cli/src/commands/policy.py +299 -0
  29. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/project.py +10 -8
  30. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/realtime.py +139 -146
  31. sovaria_cli-0.2.7/apps/cli/src/commands/schema.py +61 -0
  32. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/scope.py +36 -17
  33. sovaria_cli-0.2.7/apps/cli/src/commands/secrets.py +192 -0
  34. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/sim.py +92 -54
  35. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/storage.py +54 -58
  36. sovaria_cli-0.2.7/apps/cli/src/commands/test_cmd.py +167 -0
  37. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/vector.py +43 -40
  38. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/main.py +35 -1
  39. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/utils/api_client.py +13 -2
  40. sovaria_cli-0.2.7/apps/cli/src/utils/api_helpers.py +52 -0
  41. sovaria_cli-0.2.7/apps/cli/src/utils/display.py +102 -0
  42. sovaria_cli-0.2.7/apps/cli/src/utils/local_function_server.py +148 -0
  43. sovaria_cli-0.2.7/apps/cli/src/utils/log_tail.py +53 -0
  44. sovaria_cli-0.2.7/apps/cli/src/utils/output_mode.py +18 -0
  45. sovaria_cli-0.2.7/apps/cli/src/utils/premium_display.py +1349 -0
  46. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/utils/project_context.py +21 -0
  47. sovaria_cli-0.2.7/apps/cli/src/utils/schema_introspect.py +223 -0
  48. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/utils/scope_picker.py +6 -24
  49. sovaria_cli-0.2.7/apps/cli/src/utils/theme.py +24 -0
  50. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/tests/test_cli_smoke_paths.py +10 -14
  51. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/tests/test_main_startup.py +1 -1
  52. sovaria_cli-0.2.7/apps/cli/tests/test_phase4_commands.py +91 -0
  53. sovaria_cli-0.2.7/apps/cli/tests/test_phase5_commands.py +97 -0
  54. sovaria_cli-0.2.7/apps/cli/tests/test_phase6_commands.py +133 -0
  55. sovaria_cli-0.2.7/apps/cli/tests/test_premium_display.py +132 -0
  56. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/tests/test_project_and_commands.py +56 -12
  57. sovaria_cli-0.2.7/apps/cli/tests/test_supabase_style_commands.py +53 -0
  58. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/pyproject.toml +2 -1
  59. sovaria_cli-0.2.7/scripts/smoke_all_commands.sh +107 -0
  60. sovaria_cli-0.2.7/scripts/smoke_chunks.sh +90 -0
  61. sovaria_cli-0.2.4/apps/cli/src/commands/audit.py +0 -181
  62. sovaria_cli-0.2.4/apps/cli/src/commands/billing.py +0 -513
  63. sovaria_cli-0.2.4/apps/cli/src/commands/chrono.py +0 -248
  64. sovaria_cli-0.2.4/apps/cli/src/commands/context.py +0 -208
  65. sovaria_cli-0.2.4/apps/cli/src/commands/policy.py +0 -317
  66. sovaria_cli-0.2.4/apps/cli/src/utils/display.py +0 -62
  67. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/.github/workflows/ci.yml +0 -0
  68. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/.github/workflows/publish.yml +0 -0
  69. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/.gitignore +0 -0
  70. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/.python-version +0 -0
  71. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/Makefile +0 -0
  72. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/README.md +0 -0
  73. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/__init__.py +0 -0
  74. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/__init__.py +0 -0
  75. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/commands/shell.py +0 -0
  76. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/utils/__init__.py +0 -0
  77. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/utils/auth_store.py +0 -0
  78. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/utils/config.py +0 -0
  79. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/src/utils/migrations.py +0 -0
  80. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/tests/test_auth_store_profiles.py +0 -0
  81. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/tests/test_cli_security.py +0 -0
  82. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/tests/test_config_profiles.py +0 -0
  83. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/apps/cli/tests/test_scope_picker.py +0 -0
  84. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/pytest.ini +0 -0
  85. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/requirements-dev.txt +0 -0
  86. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/requirements.txt +0 -0
  87. {sovaria_cli-0.2.4 → sovaria_cli-0.2.7}/scripts/cli_sdk_happy_path.sh +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sovaria-cli
3
- Version: 0.2.4
3
+ Version: 0.2.7
4
4
  Summary: Sovaria BaaS command-line interface (sov)
5
5
  Project-URL: Homepage, https://sovaria.in
6
6
  Project-URL: Documentation, https://docs.sovaria.in/cli
@@ -11,6 +11,7 @@ Requires-Python: >=3.11
11
11
  Requires-Dist: httpx>=0.28.1
12
12
  Requires-Dist: keyring>=25.0
13
13
  Requires-Dist: pyyaml>=6.0
14
+ Requires-Dist: rich>=13.7.0
14
15
  Requires-Dist: sovaria-sdk>=0.2.0
15
16
  Requires-Dist: tomli-w>=1.2.0
16
17
  Requires-Dist: typer>=0.17.4
@@ -1,3 +1,3 @@
1
1
  """Sovaria CLI — sov command."""
2
2
 
3
- __version__ = "0.2.4"
3
+ __version__ = "0.2.7"
@@ -7,7 +7,8 @@ from urllib.parse import quote, urlencode
7
7
  import typer
8
8
 
9
9
  from apps.cli.src.utils.api_client import CLIRequestError, call_api
10
- from apps.cli.src.utils.display import print_json
10
+ from apps.cli.src.utils.display import handle_cli_error, handle_command_error, print_json
11
+ from apps.cli.src.utils.project_context import resolve_org_id
11
12
 
12
13
  app = typer.Typer(help="Platform admin: error traces, usage, transparency")
13
14
 
@@ -52,8 +53,7 @@ def error_traces_list(
52
53
  params=params,
53
54
  )
54
55
  except CLIRequestError as exc:
55
- typer.echo(str(exc), err=True)
56
- raise typer.Exit(1) from exc
56
+ handle_cli_error(exc)
57
57
  print_json(result)
58
58
 
59
59
 
@@ -71,8 +71,7 @@ def error_traces_get(
71
71
  require_scope=False,
72
72
  )
73
73
  except CLIRequestError as exc:
74
- typer.echo(str(exc), err=True)
75
- raise typer.Exit(1) from exc
74
+ handle_cli_error(exc)
76
75
  print_json(result)
77
76
 
78
77
 
@@ -92,8 +91,7 @@ def error_traces_resolve(
92
91
  body={"resolved": resolved},
93
92
  )
94
93
  except CLIRequestError as exc:
95
- typer.echo(str(exc), err=True)
96
- raise typer.Exit(1) from exc
94
+ handle_cli_error(exc)
97
95
  print_json(result)
98
96
 
99
97
 
@@ -118,14 +116,13 @@ def usage_overview(
118
116
  params=params or None,
119
117
  )
120
118
  except CLIRequestError as exc:
121
- typer.echo(str(exc), err=True)
122
- raise typer.Exit(1) from exc
119
+ handle_cli_error(exc)
123
120
  print_json(result)
124
121
 
125
122
 
126
123
  @transparency_app.command("list")
127
124
  def transparency_list(
128
- org_id: str = typer.Option(..., "--org", help="Organisation ID"),
125
+ org_id: str | None = typer.Option(None, "--org", help="Organisation ID (defaults to scoped org)"),
129
126
  project_id: str | None = typer.Option(None, "--project"),
130
127
  event_type: str | None = typer.Option(None, "--event-type"),
131
128
  since: str | None = typer.Option(None, "--since", help="ISO-8601 lower bound"),
@@ -134,8 +131,12 @@ def transparency_list(
134
131
  profile: str | None = typer.Option(None, "--profile"),
135
132
  ) -> None:
136
133
  """List tenant-visible staff access transparency events."""
134
+ try:
135
+ resolved_org = resolve_org_id(profile=profile, org_id=org_id, require=True)
136
+ except ValueError as exc:
137
+ handle_command_error(exc)
137
138
  query: dict[str, str] = {
138
- "org_id": org_id,
139
+ "org_id": resolved_org,
139
140
  "limit": str(limit),
140
141
  "offset": str(offset),
141
142
  }
@@ -151,10 +152,9 @@ def transparency_list(
151
152
  method="GET",
152
153
  path=path,
153
154
  profile=profile,
154
- org_id=org_id,
155
+ org_id=resolved_org,
155
156
  require_scope=False,
156
157
  )
157
158
  except CLIRequestError as exc:
158
- typer.echo(str(exc), err=True)
159
- raise typer.Exit(1) from exc
159
+ handle_cli_error(exc)
160
160
  print_json(result)
@@ -4,7 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  import typer
6
6
 
7
- from apps.cli.src.utils.api_client import CLIRequestError, call_api, parse_json_arg, print_json
7
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, parse_json_arg
8
+ from apps.cli.src.utils.display import handle_cli_error, print_json
8
9
 
9
10
  app = typer.Typer(help="Raw gateway API calls")
10
11
 
@@ -42,7 +43,6 @@ def call_command(
42
43
  env_id=env_id,
43
44
  )
44
45
  except CLIRequestError as exc:
45
- typer.echo(str(exc), err=True)
46
- raise typer.Exit(1) from exc
46
+ handle_cli_error(exc)
47
47
 
48
- typer.echo(print_json(result))
48
+ print_json(result)
@@ -0,0 +1,172 @@
1
+ """Echo audit and simulation commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from apps.cli.src.utils.api_client import CLIRequestError, parse_json_arg
8
+ from apps.cli.src.utils.api_helpers import premium_api, premium_mutate
9
+
10
+ app = typer.Typer(help="Echo audit trail and simulation operations")
11
+
12
+
13
+ @app.command("append")
14
+ def append_audit(
15
+ event_type: str = typer.Option(..., "--event-type"),
16
+ actor_id: str = typer.Option(..., "--actor-id"),
17
+ source: str = typer.Option(..., "--source"),
18
+ action: str = typer.Option(..., "--action"),
19
+ resource_id: str | None = typer.Option(None, "--resource-id"),
20
+ metadata: str | None = typer.Option(None, "--metadata", help="JSON object"),
21
+ ip_address: str | None = typer.Option(None, "--ip-address"),
22
+ profile: str | None = typer.Option(None, "--profile"),
23
+ ) -> None:
24
+ """Append an immutable audit entry."""
25
+ parsed_metadata = parse_json_arg(metadata, label="--metadata") if metadata else {}
26
+ if parsed_metadata is not None and not isinstance(parsed_metadata, dict):
27
+ raise CLIRequestError("--metadata must be a JSON object")
28
+ premium_mutate(
29
+ "Appending audit entry…",
30
+ success="Appended",
31
+ title="Audit entry",
32
+ method="POST",
33
+ path="/api/v1/audit/",
34
+ profile=profile,
35
+ body={
36
+ "event_type": event_type,
37
+ "actor_id": actor_id,
38
+ "resource_id": resource_id,
39
+ "source": source,
40
+ "action": action,
41
+ "metadata": parsed_metadata or {},
42
+ "ip_address": ip_address,
43
+ },
44
+ )
45
+
46
+
47
+ @app.command("list")
48
+ def list_audit(
49
+ actor_id: str | None = typer.Option(None, "--actor-id"),
50
+ event_type: str | None = typer.Option(None, "--event-type"),
51
+ source: str | None = typer.Option(None, "--source"),
52
+ resource_id: str | None = typer.Option(None, "--resource-id"),
53
+ limit: int = typer.Option(50, "--limit"),
54
+ offset: int = typer.Option(0, "--offset"),
55
+ profile: str | None = typer.Option(None, "--profile"),
56
+ ) -> None:
57
+ """Query audit log entries."""
58
+ params = {
59
+ "limit": str(limit),
60
+ "offset": str(offset),
61
+ }
62
+ if actor_id:
63
+ params["actor_id"] = actor_id
64
+ if event_type:
65
+ params["event_type"] = event_type
66
+ if source:
67
+ params["source"] = source
68
+ if resource_id:
69
+ params["resource_id"] = resource_id
70
+
71
+ premium_api(
72
+ "Loading audit entries…",
73
+ success="Loaded",
74
+ title="Audit log",
75
+ method="GET",
76
+ path="/api/v1/audit/",
77
+ profile=profile,
78
+ params=params,
79
+ )
80
+
81
+
82
+ @app.command("verify")
83
+ def verify_chain(profile: str | None = typer.Option(None, "--profile")) -> None:
84
+ """Verify audit hash-chain integrity."""
85
+ premium_api(
86
+ "Verifying audit chain…",
87
+ success="Verified",
88
+ title="Audit verification",
89
+ method="GET",
90
+ path="/api/v1/audit/verify",
91
+ profile=profile,
92
+ )
93
+
94
+
95
+ @app.command("count")
96
+ def audit_count(profile: str | None = typer.Option(None, "--profile")) -> None:
97
+ """Return audit log entry count."""
98
+ premium_api(
99
+ "Counting audit entries…",
100
+ success="Loaded",
101
+ title="Audit count",
102
+ method="GET",
103
+ path="/api/v1/audit/count",
104
+ profile=profile,
105
+ )
106
+
107
+
108
+ @app.command("sim-run")
109
+ def sim_run(
110
+ simulation_name: str = typer.Argument(...),
111
+ operations: str = typer.Argument(..., help="JSON array operations"),
112
+ context: str | None = typer.Option(None, "--context", help="JSON object"),
113
+ include_execution_tree: bool = typer.Option(False, "--include-execution-tree"),
114
+ profile: str | None = typer.Option(None, "--profile"),
115
+ ) -> None:
116
+ """Run an Echo phantom simulation."""
117
+ parsed_ops = parse_json_arg(operations, label="operations")
118
+ if not isinstance(parsed_ops, list):
119
+ raise CLIRequestError("operations must be a JSON array")
120
+
121
+ parsed_context = parse_json_arg(context, label="--context") if context else {}
122
+ if parsed_context is not None and not isinstance(parsed_context, dict):
123
+ raise CLIRequestError("--context must be a JSON object")
124
+
125
+ premium_api(
126
+ f"Running simulation '{simulation_name}'…",
127
+ success="Completed",
128
+ title="Simulation",
129
+ method="POST",
130
+ path="/api/v1/sim/run",
131
+ profile=profile,
132
+ body={
133
+ "simulation_name": simulation_name,
134
+ "operations": parsed_ops,
135
+ "echo_flag": True,
136
+ "include_execution_tree": include_execution_tree,
137
+ "context": parsed_context or {},
138
+ },
139
+ )
140
+
141
+
142
+ @app.command("sim-plan")
143
+ def sim_plan(
144
+ simulation_name: str = typer.Argument(...),
145
+ operations: str = typer.Argument(..., help="JSON array operations"),
146
+ context: str | None = typer.Option(None, "--context", help="JSON object"),
147
+ profile: str | None = typer.Option(None, "--profile"),
148
+ ) -> None:
149
+ """Preview an Echo simulation plan."""
150
+ parsed_ops = parse_json_arg(operations, label="operations")
151
+ if not isinstance(parsed_ops, list):
152
+ raise CLIRequestError("operations must be a JSON array")
153
+
154
+ parsed_context = parse_json_arg(context, label="--context") if context else {}
155
+ if parsed_context is not None and not isinstance(parsed_context, dict):
156
+ raise CLIRequestError("--context must be a JSON object")
157
+
158
+ premium_api(
159
+ f"Planning simulation '{simulation_name}'…",
160
+ success="Planned",
161
+ title="Simulation plan",
162
+ method="POST",
163
+ path="/api/v1/sim/plan",
164
+ profile=profile,
165
+ body={
166
+ "simulation_name": simulation_name,
167
+ "operations": parsed_ops,
168
+ "echo_flag": True,
169
+ "include_execution_tree": True,
170
+ "context": parsed_context or {},
171
+ },
172
+ )
@@ -7,7 +7,9 @@ from urllib.parse import quote
7
7
 
8
8
  import typer
9
9
 
10
- from apps.cli.src.utils.api_client import CLIRequestError, call_api, print_json
10
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api
11
+ from apps.cli.src.utils.display import handle_cli_error, print_json
12
+ from apps.cli.src.utils.premium_display import run_with_spinner
11
13
  from apps.cli.src.utils.config import get_scope
12
14
 
13
15
  app = typer.Typer(help="Manage product users within an org/project scope")
@@ -49,17 +51,20 @@ def list_users(
49
51
  ) -> None:
50
52
  """List product users for the resolved org/project scope."""
51
53
  try:
52
- result = call_api(
53
- method="GET",
54
- path=_project_auth_path(profile, project_id, "/users"),
55
- profile=profile,
56
- require_scope=True,
57
- **_scope_args(org_id, project_id, env_id),
54
+ result = run_with_spinner(
55
+ "Loading app users…",
56
+ lambda: call_api(
57
+ method="GET",
58
+ path=_project_auth_path(profile, project_id, "/users"),
59
+ profile=profile,
60
+ require_scope=True,
61
+ **_scope_args(org_id, project_id, env_id),
62
+ ),
63
+ success="Users loaded",
58
64
  )
59
65
  except CLIRequestError as exc:
60
- typer.echo(str(exc), err=True)
61
- raise typer.Exit(1) from exc
62
- typer.echo(print_json(result))
66
+ handle_cli_error(exc)
67
+ print_json(result, title="App users")
63
68
 
64
69
 
65
70
  @app.command("ban")
@@ -81,9 +86,8 @@ def ban_user(
81
86
  **_scope_args(org_id, project_id, env_id),
82
87
  )
83
88
  except CLIRequestError as exc:
84
- typer.echo(str(exc), err=True)
85
- raise typer.Exit(1) from exc
86
- typer.echo(print_json(result))
89
+ handle_cli_error(exc)
90
+ print_json(result)
87
91
 
88
92
 
89
93
  @app.command("delete")
@@ -105,9 +109,8 @@ def delete_user(
105
109
  **_scope_args(org_id, project_id, env_id),
106
110
  )
107
111
  except CLIRequestError as exc:
108
- typer.echo(str(exc), err=True)
109
- raise typer.Exit(1) from exc
110
- typer.echo(print_json(result))
112
+ handle_cli_error(exc)
113
+ print_json(result)
111
114
 
112
115
 
113
116
  @app.command("roles")
@@ -129,9 +132,8 @@ def get_roles(
129
132
  **_scope_args(org_id, project_id, env_id),
130
133
  )
131
134
  except CLIRequestError as exc:
132
- typer.echo(str(exc), err=True)
133
- raise typer.Exit(1) from exc
134
- typer.echo(print_json(result))
135
+ handle_cli_error(exc)
136
+ print_json(result)
135
137
 
136
138
 
137
139
  @app.command("roles-set")
@@ -155,9 +157,8 @@ def set_roles(
155
157
  **_scope_args(org_id, project_id, env_id),
156
158
  )
157
159
  except CLIRequestError as exc:
158
- typer.echo(str(exc), err=True)
159
- raise typer.Exit(1) from exc
160
- typer.echo(print_json(result))
160
+ handle_cli_error(exc)
161
+ print_json(result)
161
162
 
162
163
 
163
164
  @app.command("create")
@@ -173,15 +174,18 @@ def create_user(
173
174
  """Create a product user in the current project scope."""
174
175
  display_name = name or email.split("@", maxsplit=1)[0]
175
176
  try:
176
- result = call_api(
177
- method="POST",
178
- path=_project_auth_path(profile, project_id, "/users"),
179
- profile=profile,
180
- require_scope=True,
181
- **_scope_args(org_id, project_id, env_id),
182
- body={"name": display_name, "email": email, "password": password},
177
+ result = run_with_spinner(
178
+ f"Creating user {email}…",
179
+ lambda: call_api(
180
+ method="POST",
181
+ path=_project_auth_path(profile, project_id, "/users"),
182
+ profile=profile,
183
+ require_scope=True,
184
+ **_scope_args(org_id, project_id, env_id),
185
+ body={"name": display_name, "email": email, "password": password},
186
+ ),
187
+ success="User created",
183
188
  )
184
189
  except CLIRequestError as exc:
185
- typer.echo(str(exc), err=True)
186
- raise typer.Exit(1) from exc
187
- typer.echo(print_json(result))
190
+ handle_cli_error(exc)
191
+ print_json(result)