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,248 @@
1
+ """Chrono timeline engine 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, print_json
10
+
11
+ app = typer.Typer(help="Chrono timeline and snapshot operations")
12
+
13
+
14
+ @app.command("commit")
15
+ def commit(
16
+ timeline: str = typer.Option("main", "--timeline"),
17
+ message: str = typer.Option(..., "--message"),
18
+ author: str = typer.Option(..., "--author"),
19
+ event_type: str = typer.Option("update", "--event-type"),
20
+ profile: str | None = typer.Option(None, "--profile"),
21
+ ) -> None:
22
+ """Create a new chronon commit."""
23
+ try:
24
+ result = call_api(
25
+ method="POST",
26
+ path="/api/v1/chrono/commit",
27
+ profile=profile,
28
+ body={
29
+ "timeline": timeline,
30
+ "message": message,
31
+ "author": author,
32
+ "event_type": event_type,
33
+ },
34
+ )
35
+ except CLIRequestError as exc:
36
+ typer.echo(str(exc), err=True)
37
+ raise typer.Exit(1) from exc
38
+ typer.echo(print_json(result))
39
+
40
+
41
+ @app.command("timeline-list")
42
+ def timeline_list(profile: str | None = typer.Option(None, "--profile")) -> None:
43
+ """List timelines."""
44
+ try:
45
+ result = call_api(method="GET", path="/api/v1/chrono/timelines", profile=profile)
46
+ except CLIRequestError as exc:
47
+ typer.echo(str(exc), err=True)
48
+ raise typer.Exit(1) from exc
49
+ typer.echo(print_json(result))
50
+
51
+
52
+ @app.command("timeline-spawn")
53
+ def timeline_spawn(
54
+ name: str = typer.Argument(...),
55
+ source_hex_id: str | None = typer.Option(None, "--source-hex-id"),
56
+ profile: str | None = typer.Option(None, "--profile"),
57
+ ) -> None:
58
+ """Spawn a new timeline from a source head."""
59
+ try:
60
+ result = call_api(
61
+ method="POST",
62
+ path="/api/v1/chrono/timelines/spawn",
63
+ profile=profile,
64
+ body={"name": name, "source_hex_id": source_hex_id},
65
+ )
66
+ except CLIRequestError as exc:
67
+ typer.echo(str(exc), err=True)
68
+ raise typer.Exit(1) from exc
69
+ typer.echo(print_json(result))
70
+
71
+
72
+ @app.command("timeline-delete")
73
+ def timeline_delete(
74
+ name: str = typer.Argument(...),
75
+ profile: str | None = typer.Option(None, "--profile"),
76
+ ) -> None:
77
+ """Delete a timeline."""
78
+ try:
79
+ result = call_api(
80
+ method="DELETE",
81
+ path=f"/api/v1/chrono/timelines/{quote(name, safe='')}",
82
+ profile=profile,
83
+ )
84
+ except CLIRequestError as exc:
85
+ typer.echo(str(exc), err=True)
86
+ raise typer.Exit(1) from exc
87
+ typer.echo(print_json(result))
88
+
89
+
90
+ @app.command("timeline-jump")
91
+ def timeline_jump(
92
+ name: str = typer.Argument(...),
93
+ target_hex: str = typer.Option(..., "--target-hex"),
94
+ profile: str | None = typer.Option(None, "--profile"),
95
+ ) -> None:
96
+ """Force-jump a timeline to a specific head."""
97
+ try:
98
+ result = call_api(
99
+ method="POST",
100
+ path=f"/api/v1/chrono/timelines/{quote(name, safe='')}/jump",
101
+ profile=profile,
102
+ body={"target_hex": target_hex},
103
+ )
104
+ except CLIRequestError as exc:
105
+ typer.echo(str(exc), err=True)
106
+ raise typer.Exit(1) from exc
107
+ typer.echo(print_json(result))
108
+
109
+
110
+ @app.command("timeline-history")
111
+ def timeline_history(
112
+ name: str = typer.Argument(...),
113
+ limit: int = typer.Option(50, "--limit"),
114
+ offset: int = typer.Option(0, "--offset"),
115
+ profile: str | None = typer.Option(None, "--profile"),
116
+ ) -> None:
117
+ """Show timeline history."""
118
+ try:
119
+ result = call_api(
120
+ method="GET",
121
+ path=f"/api/v1/chrono/timelines/{quote(name, safe='')}/history",
122
+ profile=profile,
123
+ params={"limit": str(limit), "offset": str(offset)},
124
+ )
125
+ except CLIRequestError as exc:
126
+ typer.echo(str(exc), err=True)
127
+ raise typer.Exit(1) from exc
128
+ typer.echo(print_json(result))
129
+
130
+
131
+ @app.command("topology")
132
+ def topology(
133
+ timeline: str = typer.Option("main", "--timeline"),
134
+ limit: int = typer.Option(50, "--limit"),
135
+ profile: str | None = typer.Option(None, "--profile"),
136
+ ) -> None:
137
+ """Show timeline topology graph."""
138
+ try:
139
+ result = call_api(
140
+ method="GET",
141
+ path="/api/v1/chrono/timelines/topology",
142
+ profile=profile,
143
+ params={"timeline": timeline, "limit": str(limit)},
144
+ )
145
+ except CLIRequestError as exc:
146
+ typer.echo(str(exc), err=True)
147
+ raise typer.Exit(1) from exc
148
+ typer.echo(print_json(result))
149
+
150
+
151
+ @app.command("converge")
152
+ def converge(
153
+ target: str = typer.Option(..., "--target"),
154
+ source: str = typer.Option(..., "--source"),
155
+ author: str = typer.Option(..., "--author"),
156
+ message: str = typer.Option(..., "--message"),
157
+ profile: str | None = typer.Option(None, "--profile"),
158
+ ) -> None:
159
+ """Converge source timeline into target."""
160
+ try:
161
+ result = call_api(
162
+ method="POST",
163
+ path="/api/v1/chrono/timelines/converge",
164
+ profile=profile,
165
+ body={
166
+ "target": target,
167
+ "source": source,
168
+ "author": author,
169
+ "message": message,
170
+ },
171
+ )
172
+ except CLIRequestError as exc:
173
+ typer.echo(str(exc), err=True)
174
+ raise typer.Exit(1) from exc
175
+ typer.echo(print_json(result))
176
+
177
+
178
+ @app.command("variance")
179
+ def variance(
180
+ old_chronon_hex: str = typer.Option(..., "--old"),
181
+ new_chronon_hex: str = typer.Option(..., "--new"),
182
+ domain_filter: str | None = typer.Option(None, "--domain-filter"),
183
+ profile: str | None = typer.Option(None, "--profile"),
184
+ ) -> None:
185
+ """Calculate variance between chronons."""
186
+ try:
187
+ result = call_api(
188
+ method="POST",
189
+ path="/api/v1/chrono/variance",
190
+ profile=profile,
191
+ body={
192
+ "old_chronon_hex": old_chronon_hex,
193
+ "new_chronon_hex": new_chronon_hex,
194
+ "domain_filter": domain_filter,
195
+ },
196
+ )
197
+ except CLIRequestError as exc:
198
+ typer.echo(str(exc), err=True)
199
+ raise typer.Exit(1) from exc
200
+ typer.echo(print_json(result))
201
+
202
+
203
+ @app.command("snapshot-create")
204
+ def snapshot_create(
205
+ message: str = typer.Option(..., "--message"),
206
+ profile: str | None = typer.Option(None, "--profile"),
207
+ ) -> None:
208
+ """Create a Chrono snapshot."""
209
+ try:
210
+ result = call_api(
211
+ method="POST",
212
+ path="/api/v1/chrono/snapshots/create",
213
+ profile=profile,
214
+ body={"message": message},
215
+ )
216
+ except CLIRequestError as exc:
217
+ typer.echo(str(exc), err=True)
218
+ raise typer.Exit(1) from exc
219
+ typer.echo(print_json(result))
220
+
221
+
222
+ @app.command("snapshot-list")
223
+ def snapshot_list(profile: str | None = typer.Option(None, "--profile")) -> None:
224
+ """List Chrono snapshots."""
225
+ try:
226
+ result = call_api(method="GET", path="/api/v1/chrono/snapshots", profile=profile)
227
+ except CLIRequestError as exc:
228
+ typer.echo(str(exc), err=True)
229
+ raise typer.Exit(1) from exc
230
+ typer.echo(print_json(result))
231
+
232
+
233
+ @app.command("snapshot-restore")
234
+ def snapshot_restore(
235
+ snapshot_id: int = typer.Argument(...),
236
+ profile: str | None = typer.Option(None, "--profile"),
237
+ ) -> None:
238
+ """Restore a snapshot by id."""
239
+ try:
240
+ result = call_api(
241
+ method="POST",
242
+ path=f"/api/v1/chrono/snapshots/{snapshot_id}/restore",
243
+ profile=profile,
244
+ )
245
+ except CLIRequestError as exc:
246
+ typer.echo(str(exc), err=True)
247
+ raise typer.Exit(1) from exc
248
+ typer.echo(print_json(result))
@@ -0,0 +1,99 @@
1
+ """
2
+ apps.cli.src.commands.context — Show current user/org context.
3
+
4
+ Phase 1 §9 Sprint 3.2: `sov context` prints who you are and what
5
+ resources you have access to, by querying the Axis context endpoint
6
+ through the Gateway.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import httpx
12
+ import typer
13
+
14
+ from apps.cli.src.utils.auth_store import load_tokens
15
+ from apps.cli.src.utils.config import get_api_url
16
+
17
+ app = typer.Typer()
18
+
19
+
20
+ @app.command("whoami")
21
+ def whoami(
22
+ profile: str | None = typer.Option(None, "--profile"),
23
+ ) -> None:
24
+ """Print the currently authenticated user."""
25
+ tokens = load_tokens(profile=profile)
26
+ if not tokens:
27
+ typer.echo("Not logged in. Run: sov login", err=True)
28
+ raise typer.Exit(1)
29
+
30
+ api_url = get_api_url(profile)
31
+ try:
32
+ resp = httpx.get(
33
+ f"{api_url}/api/v1/auth/me",
34
+ headers={"Authorization": f"Bearer {tokens['access_token']}"},
35
+ timeout=10,
36
+ )
37
+ resp.raise_for_status()
38
+ data = resp.json()
39
+ typer.echo(f"user_id : {data.get('user_id', '-')}")
40
+ typer.echo(f"email : {data.get('email', '-')}")
41
+ typer.echo(f"roles : {', '.join(data.get('roles', []))}")
42
+ typer.echo(f"lane : {data.get('actor_lane', '-')}")
43
+ typer.echo(f"subject : {data.get('auth_subject_type', '-')}")
44
+ except httpx.HTTPStatusError as exc:
45
+ if exc.response.status_code == 401:
46
+ typer.echo("Session expired - run: sov login", err=True)
47
+ else:
48
+ typer.echo(f"Error: {exc.response.text}", err=True)
49
+ raise typer.Exit(1) from exc
50
+
51
+
52
+ @app.command("context")
53
+ def context(
54
+ resource_id: str = typer.Argument(..., help="Node ID of the resource (ORG / PROJECT / ENV)"),
55
+ profile: str | None = typer.Option(None, "--profile"),
56
+ ) -> None:
57
+ """
58
+ Show the access roles the current user has on a specific resource.
59
+
60
+ Example:
61
+ sov context proj-abc123
62
+ """
63
+ tokens = load_tokens(profile=profile)
64
+ if not tokens:
65
+ typer.echo("Not logged in. Run: sov login", err=True)
66
+ raise typer.Exit(1)
67
+
68
+ api_url = get_api_url(profile)
69
+ try:
70
+ # First resolve user_id from the access token by calling whoami.
71
+ me = httpx.get(
72
+ f"{api_url}/api/v1/auth/me",
73
+ headers={"Authorization": f"Bearer {tokens['access_token']}"},
74
+ timeout=10,
75
+ )
76
+ me.raise_for_status()
77
+ me_data = me.json()
78
+ user_id = me_data.get("user_id") or me_data.get("sub") or me_data.get("id")
79
+ if not user_id:
80
+ typer.echo("Could not determine user ID from /auth/me response", err=True)
81
+ raise typer.Exit(1)
82
+
83
+ resp = httpx.get(
84
+ f"{api_url}/api/v1/context/{user_id}/{resource_id}",
85
+ headers={"Authorization": f"Bearer {tokens['access_token']}"},
86
+ timeout=10,
87
+ )
88
+ resp.raise_for_status()
89
+ data = resp.json()
90
+ roles = data.get("roles", [])
91
+ if roles:
92
+ typer.echo(f"You have the following roles on {resource_id}:")
93
+ for role in roles:
94
+ typer.echo(f" • {role}")
95
+ else:
96
+ typer.echo(f"No access to resource {resource_id}")
97
+ except httpx.HTTPStatusError as exc:
98
+ typer.echo(f"Error: {exc.response.text}", err=True)
99
+ raise typer.Exit(1) from exc
@@ -0,0 +1,272 @@
1
+ """Data engine commands (Atlas-Doc + Atlas-Rel)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from urllib.parse import quote
7
+
8
+ import typer
9
+
10
+ from apps.cli.src.utils.api_client import CLIRequestError, call_api, parse_json_arg, print_json
11
+
12
+ app = typer.Typer(help="Atlas-Doc and Atlas-Rel operations")
13
+
14
+
15
+ def _safe_doc_path(collection: str, document_id: str) -> str:
16
+ return f"{quote(collection.strip('/'), safe='/')}/{quote(document_id, safe='')}"
17
+
18
+
19
+ @app.command("doc-put")
20
+ def doc_put(
21
+ collection: str = typer.Argument(..., help="Collection path"),
22
+ document_id: str = typer.Argument(..., help="Document key"),
23
+ data: str = typer.Argument(..., help="JSON object document body"),
24
+ profile: str | None = typer.Option(None, "--profile"),
25
+ org_id: str | None = typer.Option(None, "--org"),
26
+ project_id: str | None = typer.Option(None, "--project"),
27
+ env_id: str | None = typer.Option(None, "--env"),
28
+ ) -> None:
29
+ """Write a plain JSON document."""
30
+ try:
31
+ payload = parse_json_arg(data, label="data")
32
+ if not isinstance(payload, dict):
33
+ raise CLIRequestError("data must be a JSON object")
34
+ path = _safe_doc_path(collection, document_id)
35
+ result = call_api(
36
+ method="POST",
37
+ path=f"/api/v1/data/doc/{path}",
38
+ profile=profile,
39
+ body={"data": payload},
40
+ require_scope=False,
41
+ org_id=org_id,
42
+ project_id=project_id,
43
+ env_id=env_id,
44
+ )
45
+ except CLIRequestError as exc:
46
+ typer.echo(str(exc), err=True)
47
+ raise typer.Exit(1) from exc
48
+ typer.echo(print_json(result))
49
+
50
+
51
+ @app.command("doc-get")
52
+ def doc_get(
53
+ collection: str = typer.Argument(...),
54
+ document_id: str = typer.Argument(...),
55
+ txid: int = typer.Option(0, "--txid", help="Optional historical txid"),
56
+ profile: str | None = typer.Option(None, "--profile"),
57
+ org_id: str | None = typer.Option(None, "--org"),
58
+ project_id: str | None = typer.Option(None, "--project"),
59
+ env_id: str | None = typer.Option(None, "--env"),
60
+ ) -> None:
61
+ """Read a plain JSON document."""
62
+ try:
63
+ path = _safe_doc_path(collection, document_id)
64
+ params = {"txid": str(txid)} if txid > 0 else None
65
+ result = call_api(
66
+ method="GET",
67
+ path=f"/api/v1/data/doc/{path}",
68
+ profile=profile,
69
+ params=params,
70
+ require_scope=False,
71
+ org_id=org_id,
72
+ project_id=project_id,
73
+ env_id=env_id,
74
+ )
75
+ except CLIRequestError as exc:
76
+ typer.echo(str(exc), err=True)
77
+ raise typer.Exit(1) from exc
78
+ typer.echo(print_json(result))
79
+
80
+
81
+ @app.command("doc-delete")
82
+ def doc_delete(
83
+ collection: str = typer.Argument(...),
84
+ document_id: str = typer.Argument(...),
85
+ profile: str | None = typer.Option(None, "--profile"),
86
+ org_id: str | None = typer.Option(None, "--org"),
87
+ project_id: str | None = typer.Option(None, "--project"),
88
+ env_id: str | None = typer.Option(None, "--env"),
89
+ ) -> None:
90
+ """Delete a document."""
91
+ try:
92
+ path = _safe_doc_path(collection, document_id)
93
+ result = call_api(
94
+ method="DELETE",
95
+ path=f"/api/v1/data/doc/{path}",
96
+ profile=profile,
97
+ require_scope=False,
98
+ org_id=org_id,
99
+ project_id=project_id,
100
+ env_id=env_id,
101
+ )
102
+ except CLIRequestError as exc:
103
+ typer.echo(str(exc), err=True)
104
+ raise typer.Exit(1) from exc
105
+ typer.echo(print_json(result))
106
+
107
+
108
+ @app.command("doc-scan")
109
+ def doc_scan(
110
+ prefix: str | None = typer.Option(None, "--prefix"),
111
+ start_key: str | None = typer.Option(None, "--start-key"),
112
+ end_key: str | None = typer.Option(None, "--end-key"),
113
+ limit: int = typer.Option(100, "--limit"),
114
+ profile: str | None = typer.Option(None, "--profile"),
115
+ org_id: str | None = typer.Option(None, "--org"),
116
+ project_id: str | None = typer.Option(None, "--project"),
117
+ env_id: str | None = typer.Option(None, "--env"),
118
+ ) -> None:
119
+ """Scan Atlas-Doc by prefix/range."""
120
+ body: dict[str, Any] = {"limit": limit}
121
+ if prefix is not None:
122
+ body["prefix"] = prefix
123
+ if start_key is not None:
124
+ body["start_key"] = start_key
125
+ if end_key is not None:
126
+ body["end_key"] = end_key
127
+
128
+ try:
129
+ result = call_api(
130
+ method="POST",
131
+ path="/api/v1/data/query/scan",
132
+ profile=profile,
133
+ body=body,
134
+ require_scope=False,
135
+ org_id=org_id,
136
+ project_id=project_id,
137
+ env_id=env_id,
138
+ )
139
+ except CLIRequestError as exc:
140
+ typer.echo(str(exc), err=True)
141
+ raise typer.Exit(1) from exc
142
+ typer.echo(print_json(result))
143
+
144
+
145
+ @app.command("sql")
146
+ def sql_query(
147
+ sql: str = typer.Argument(..., help="SQL statement"),
148
+ txid: int = typer.Option(0, "--txid"),
149
+ profile: str | None = typer.Option(None, "--profile"),
150
+ org_id: str | None = typer.Option(None, "--org"),
151
+ project_id: str | None = typer.Option(None, "--project"),
152
+ env_id: str | None = typer.Option(None, "--env"),
153
+ ) -> None:
154
+ """Run SQL query through Atlas-Rel."""
155
+ try:
156
+ result = call_api(
157
+ method="POST",
158
+ path="/api/v1/sql/query",
159
+ profile=profile,
160
+ body={"sql": sql, "txid": txid},
161
+ require_scope=True,
162
+ org_id=org_id,
163
+ project_id=project_id,
164
+ env_id=env_id,
165
+ )
166
+ except CLIRequestError as exc:
167
+ typer.echo(str(exc), err=True)
168
+ raise typer.Exit(1) from exc
169
+ typer.echo(print_json(result))
170
+
171
+
172
+ @app.command("table-create")
173
+ def table_create(
174
+ table_name: str = typer.Argument(...),
175
+ columns: str = typer.Argument(..., help="JSON array of column definitions"),
176
+ foreign_keys: str | None = typer.Option(None, "--foreign-keys", help="JSON array"),
177
+ profile: str | None = typer.Option(None, "--profile"),
178
+ org_id: str | None = typer.Option(None, "--org"),
179
+ project_id: str | None = typer.Option(None, "--project"),
180
+ env_id: str | None = typer.Option(None, "--env"),
181
+ ) -> None:
182
+ """Create a relational table."""
183
+ try:
184
+ parsed_columns = parse_json_arg(columns, label="columns")
185
+ parsed_fk = parse_json_arg(foreign_keys, label="--foreign-keys") if foreign_keys else []
186
+ if not isinstance(parsed_columns, list):
187
+ raise CLIRequestError("columns must be a JSON array")
188
+ if parsed_fk is not None and not isinstance(parsed_fk, list):
189
+ raise CLIRequestError("--foreign-keys must be a JSON array")
190
+
191
+ result = call_api(
192
+ method="POST",
193
+ path="/api/v1/data/rel/create",
194
+ profile=profile,
195
+ body={
196
+ "table_name": table_name,
197
+ "columns": parsed_columns,
198
+ "foreign_keys": parsed_fk or [],
199
+ },
200
+ require_scope=True,
201
+ org_id=org_id,
202
+ project_id=project_id,
203
+ env_id=env_id,
204
+ )
205
+ except CLIRequestError as exc:
206
+ typer.echo(str(exc), err=True)
207
+ raise typer.Exit(1) from exc
208
+
209
+ typer.echo(print_json(result))
210
+
211
+
212
+ @app.command("table-drop")
213
+ def table_drop(
214
+ table_name: str = typer.Argument(...),
215
+ profile: str | None = typer.Option(None, "--profile"),
216
+ org_id: str | None = typer.Option(None, "--org"),
217
+ project_id: str | None = typer.Option(None, "--project"),
218
+ env_id: str | None = typer.Option(None, "--env"),
219
+ ) -> None:
220
+ """Drop a relational table."""
221
+ try:
222
+ result = call_api(
223
+ method="POST",
224
+ path="/api/v1/data/rel/drop",
225
+ profile=profile,
226
+ body={"table_name": table_name},
227
+ require_scope=True,
228
+ org_id=org_id,
229
+ project_id=project_id,
230
+ env_id=env_id,
231
+ )
232
+ except CLIRequestError as exc:
233
+ typer.echo(str(exc), err=True)
234
+ raise typer.Exit(1) from exc
235
+
236
+ typer.echo(print_json(result))
237
+
238
+
239
+ @app.command("table-migrations")
240
+ def table_migrations(
241
+ table_name: str | None = typer.Option(None, "--table"),
242
+ limit: int = typer.Option(100, "--limit"),
243
+ offset: int = typer.Option(0, "--offset"),
244
+ profile: str | None = typer.Option(None, "--profile"),
245
+ org_id: str | None = typer.Option(None, "--org"),
246
+ project_id: str | None = typer.Option(None, "--project"),
247
+ env_id: str | None = typer.Option(None, "--env"),
248
+ ) -> None:
249
+ """List migration log entries."""
250
+ params = {
251
+ "limit": str(limit),
252
+ "offset": str(offset),
253
+ }
254
+ if table_name:
255
+ params["table_name"] = table_name
256
+
257
+ try:
258
+ result = call_api(
259
+ method="GET",
260
+ path="/api/v1/data/rel/migrations",
261
+ profile=profile,
262
+ params=params,
263
+ require_scope=True,
264
+ org_id=org_id,
265
+ project_id=project_id,
266
+ env_id=env_id,
267
+ )
268
+ except CLIRequestError as exc:
269
+ typer.echo(str(exc), err=True)
270
+ raise typer.Exit(1) from exc
271
+
272
+ typer.echo(print_json(result))