makea-cli 0.1.1__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.
makea_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
makea_cli/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m makea_cli`` (used by the optional npm wrapper)."""
2
+
3
+ from makea_cli.main import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1,376 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import PurePosixPath
5
+ from typing import Any
6
+ from urllib.parse import unquote
7
+
8
+ import httpx
9
+
10
+ from makea_cli.config import api_base_url
11
+ from makea_cli.auth_pkce import ensure_fresh_id_token
12
+
13
+
14
+ def _headers() -> dict[str, str]:
15
+ token = ensure_fresh_id_token()
16
+ return {"Authorization": f"Bearer {token}", "Accept": "application/json"}
17
+
18
+
19
+ def _filename_from_content_disposition(header: str | None) -> str | None:
20
+ """Best-effort parse of ``Content-Disposition`` filename / filename*."""
21
+ if not header:
22
+ return None
23
+ for part in header.split(";"):
24
+ part = part.strip()
25
+ low = part.lower()
26
+ if low.startswith("filename*="):
27
+ _, _, value = part.partition("=")
28
+ value = value.strip().strip('"')
29
+ if value.lower().startswith("utf-8''"):
30
+ value = value[7:]
31
+ return unquote(value)
32
+ if low.startswith("filename="):
33
+ _, _, value = part.partition("=")
34
+ return value.strip().strip('"')
35
+ return None
36
+
37
+
38
+ def get_json(
39
+ path: str, *, params: dict[str, Any] | None = None, timeout: float = 60.0
40
+ ) -> Any:
41
+ url = f"{api_base_url()}{path}"
42
+ with httpx.Client(timeout=timeout) as client:
43
+ r = client.get(url, headers=_headers(), params=params)
44
+ if r.status_code >= 400:
45
+ try:
46
+ detail = r.json()
47
+ except json.JSONDecodeError:
48
+ detail = r.text
49
+ raise RuntimeError(f"HTTP {r.status_code}: {detail}")
50
+ return r.json()
51
+
52
+
53
+ def post_json(path: str, body: Any, *, timeout: float = 60.0) -> Any:
54
+ url = f"{api_base_url()}{path}"
55
+ headers = {**_headers(), "Content-Type": "application/json"}
56
+ with httpx.Client(timeout=timeout) as client:
57
+ r = client.post(url, headers=headers, json=body)
58
+ if r.status_code >= 400:
59
+ try:
60
+ detail = r.json()
61
+ except json.JSONDecodeError:
62
+ detail = r.text
63
+ raise RuntimeError(f"HTTP {r.status_code}: {detail}")
64
+ return r.json()
65
+
66
+
67
+ def send_admin_raw_email(body: dict[str, Any]) -> Any:
68
+ """
69
+ POST /admin/email/send — queue HTML email (from must be @makea.co).
70
+
71
+ Body keys: ``from`` (or ``from_email``), ``to`` (non-empty list), ``subject`` (or ``title``),
72
+ ``html`` (or ``html_body``); optional ``cc`` (list; duplicates of ``to`` are dropped server-side);
73
+ optional ``text`` (plain MIME alternative).
74
+ """
75
+ return post_json("/admin/email/send", body)
76
+
77
+
78
+ def post_multipart_simple(
79
+ path: str, form_fields: dict[str, str], *, timeout: float = 120.0
80
+ ) -> Any:
81
+ """
82
+ POST multipart/form-data with text-only parts (value sent as a part without filename).
83
+ """
84
+ url = f"{api_base_url()}{path}"
85
+ headers = _headers()
86
+ files = {key: (None, value) for key, value in form_fields.items()}
87
+ with httpx.Client(timeout=timeout) as client:
88
+ r = client.post(url, headers=headers, files=files)
89
+ if r.status_code >= 400:
90
+ try:
91
+ detail = r.json()
92
+ except json.JSONDecodeError:
93
+ detail = r.text
94
+ raise RuntimeError(f"HTTP {r.status_code}: {detail}")
95
+ return r.json()
96
+
97
+
98
+ def get_all_users(*, limit: int | None, offset: int) -> Any:
99
+ params: dict[str, Any] = {"offset": offset}
100
+ if limit is not None:
101
+ params["limit"] = limit
102
+ return get_json("/admin/users", params=params)
103
+
104
+
105
+ def get_all_suppliers() -> Any:
106
+ return get_json("/admin/supplier/get_all_suppliers")
107
+
108
+
109
+ def add_supplier(body: dict[str, Any]) -> Any:
110
+ """POST /admin/supplier/add_supplier — body must be {\"supplier\": {...}}."""
111
+ return post_json("/admin/supplier/add_supplier", body)
112
+
113
+
114
+ def backfill_supplier_id_with_quote_requests(
115
+ *,
116
+ user_id: str,
117
+ old_supplier_id: str,
118
+ dry_run: bool = True,
119
+ skip_s3_documents: bool = False,
120
+ skip_quote_request_backfill: bool = False,
121
+ quote_request_only: bool = False,
122
+ delete_old_supplier_at_end: bool = False,
123
+ confirm_delete_old_supplier: bool = False,
124
+ ) -> Any:
125
+ """POST /admin/supplier/backfill_supplier_id_with_quote_requests."""
126
+ body = {
127
+ "user_id": user_id,
128
+ "old_supplier_id": old_supplier_id,
129
+ "dry_run": dry_run,
130
+ "skip_s3_documents": skip_s3_documents,
131
+ "skip_quote_request_backfill": skip_quote_request_backfill,
132
+ "quote_request_only": quote_request_only,
133
+ "delete_old_supplier_at_end": delete_old_supplier_at_end,
134
+ "confirm_delete_old_supplier": confirm_delete_old_supplier,
135
+ }
136
+ return post_json("/admin/supplier/backfill_supplier_id_with_quote_requests", body)
137
+
138
+
139
+ def get_all_product_orders(
140
+ *,
141
+ limit: int | None,
142
+ offset: int,
143
+ user_id: str | None,
144
+ searchby: str | None,
145
+ ) -> Any:
146
+ params: dict[str, Any] = {"offset": offset}
147
+ if limit is not None:
148
+ params["limit"] = limit
149
+ if user_id is not None:
150
+ params["user_id"] = user_id
151
+ if searchby is not None:
152
+ params["searchby"] = searchby
153
+ return get_json("/admin/designer/get_all_product_orders", params=params)
154
+
155
+
156
+ def get_product_orders_by_user(designer_user_id: str) -> Any:
157
+ """GET /admin/designer/get_all_product_orders/{user_id}/orders — one designer's products."""
158
+ return get_json(f"/admin/designer/get_all_product_orders/{designer_user_id}/orders")
159
+
160
+
161
+ def get_quote_requests(*, product_id: str | None = None) -> Any:
162
+ """
163
+ GET /admin/quote_requests — admin quote requests list (optionally filtered by product_id).
164
+ """
165
+ params: dict[str, Any] | None = None
166
+ if product_id is not None:
167
+ params = {"product_id": product_id}
168
+ return get_json("/admin/quote_requests", params=params)
169
+
170
+
171
+ def get_quote_requests_by_supplier_id(
172
+ *,
173
+ supplier_id: str,
174
+ status: str | None = None,
175
+ limit: int | None = None,
176
+ ) -> Any:
177
+ """
178
+ GET /admin/quote_requests/supplier/{supplier_id} — direct supplier filter on backend.
179
+ """
180
+ params: dict[str, Any] = {}
181
+ if status is not None:
182
+ params["status"] = status
183
+ if limit is not None:
184
+ params["limit"] = limit
185
+ return get_json(
186
+ f"/admin/quote_requests/supplier/{supplier_id}",
187
+ params=params or None,
188
+ )
189
+
190
+
191
+ def get_linked_suppliers_for_product(
192
+ *,
193
+ product_id: str,
194
+ link_type: str | None = None,
195
+ ) -> Any:
196
+ """
197
+ GET /admin/product/linked_suppliers — requires product_id, optional link_type.
198
+ """
199
+ params: dict[str, Any] = {"product_id": product_id}
200
+ if link_type is not None:
201
+ params["link_type"] = link_type
202
+ return get_json("/admin/product/linked_suppliers", params=params)
203
+
204
+
205
+ def get_supplier_links_by_supplier_id(
206
+ *,
207
+ supplier_id: str,
208
+ link_type: str | None = None,
209
+ ) -> Any:
210
+ """
211
+ GET /admin/product/linked_suppliers/by_supplier/{supplier_id} — direct supplier filter.
212
+ """
213
+ params: dict[str, Any] | None = None
214
+ if link_type is not None:
215
+ params = {"link_type": link_type}
216
+ return get_json(
217
+ f"/admin/product/linked_suppliers/by_supplier/{supplier_id}",
218
+ params=params,
219
+ )
220
+
221
+
222
+ def effective_user_id_for_document_download(
223
+ product_row: dict[str, Any], document_entry: dict[str, Any]
224
+ ) -> str:
225
+ """
226
+ ``user_id`` query param for GET /admin/document/download — same rule as server
227
+ ``download_all_product_documents`` (prefer ``admin_user_id`` when present).
228
+ """
229
+ owner = (product_row.get("user_id") or "").strip()
230
+ admin_uid = (document_entry.get("admin_user_id") or "").strip()
231
+ if admin_uid:
232
+ return admin_uid
233
+ return owner
234
+
235
+
236
+ def get_all_sampling_orders(
237
+ *,
238
+ limit: int | None,
239
+ offset: int,
240
+ include_document_metadata: bool | None = None,
241
+ ) -> Any:
242
+ """GET /admin/sampling_orders/get_all — admin global sampling order list."""
243
+ params: dict[str, Any] = {"offset": offset}
244
+ if limit is not None:
245
+ params["limit"] = limit
246
+ if include_document_metadata is not None:
247
+ params["include_document_metadata"] = (
248
+ "true" if include_document_metadata else "false"
249
+ )
250
+ return get_json("/admin/sampling_orders/get_all", params=params)
251
+
252
+
253
+ def get_all_production_orders(
254
+ *,
255
+ limit: int | None,
256
+ offset: int,
257
+ order_type: str | None,
258
+ ) -> Any:
259
+ """
260
+ GET /admin/production_orders — admin global production order list.
261
+ ``order_type`` is PRODUCTION, RESTOCK, or None for all (per server behavior).
262
+ """
263
+ params: dict[str, Any] = {"offset": offset}
264
+ if limit is not None:
265
+ params["limit"] = limit
266
+ if order_type:
267
+ params["type"] = order_type
268
+ return get_json("/admin/production_orders", params=params)
269
+
270
+
271
+ def get_sampling_orders_by_product(product_reference_id: str) -> Any:
272
+ return get_json(f"/admin/products/{product_reference_id}/sampling_orders")
273
+
274
+
275
+ def get_production_orders_by_product(
276
+ product_reference_id: str,
277
+ *,
278
+ limit: int | None,
279
+ offset: int,
280
+ ) -> Any:
281
+ params: dict[str, Any] = {"offset": offset}
282
+ if limit is not None:
283
+ params["limit"] = limit
284
+ return get_json(
285
+ f"/admin/products/{product_reference_id}/production_orders",
286
+ params=params,
287
+ )
288
+
289
+
290
+ def get_cumulative_payment_and_platform_revenue(
291
+ *,
292
+ start_date: str,
293
+ end_date: str,
294
+ granularity: str | None = None,
295
+ currency: str | None = None,
296
+ order_type: str | None = None,
297
+ ) -> Any:
298
+ """
299
+ GET /admin/business-metrics/cumulative-payment-and-platform-revenue —
300
+ platform-wide cumulative payment received and platform revenue (service fee +
301
+ transaction fee) within a date range, bucketed by day/week/month.
302
+ """
303
+ params: dict[str, Any] = {"start_date": start_date, "end_date": end_date}
304
+ if granularity is not None:
305
+ params["granularity"] = granularity
306
+ if currency is not None:
307
+ params["currency"] = currency
308
+ if order_type is not None:
309
+ params["order_type"] = order_type
310
+ return get_json(
311
+ "/admin/business-metrics/cumulative-payment-and-platform-revenue",
312
+ params=params,
313
+ )
314
+
315
+
316
+ def extract_ai_tech_specifications(
317
+ product_reference_id: str, *, timeout: float = 300.0
318
+ ) -> Any:
319
+ """
320
+ GET /admin/products/{id}/extract-tech-specifications — synchronous AI extraction;
321
+ allow a longer client timeout than normal GETs.
322
+ """
323
+ return get_json(
324
+ f"/admin/products/{product_reference_id}/extract-tech-specifications",
325
+ timeout=timeout,
326
+ )
327
+
328
+
329
+ def download_admin_document(
330
+ *,
331
+ user_id: str,
332
+ document_id: str,
333
+ timeout: float = 120.0,
334
+ ) -> tuple[bytes, str | None]:
335
+ """
336
+ GET /admin/document/download — binary file or redirect to presigned URL.
337
+
338
+ The server requires ``user_id`` (document owner context) and ``document_id``.
339
+ Returns ``(body_bytes, suggested_filename_or_none)`` from the final response.
340
+ """
341
+ url = f"{api_base_url()}/admin/document/download"
342
+ token = ensure_fresh_id_token()
343
+ headers = {
344
+ "Authorization": f"Bearer {token}",
345
+ "Accept": "*/*",
346
+ }
347
+ params = {"user_id": user_id, "document_id": document_id}
348
+ with httpx.Client(timeout=timeout, follow_redirects=True) as client:
349
+ r = client.get(url, headers=headers, params=params)
350
+ if r.status_code >= 400:
351
+ try:
352
+ detail = r.json()
353
+ except json.JSONDecodeError:
354
+ detail = r.text
355
+ raise RuntimeError(f"HTTP {r.status_code}: {detail}")
356
+ name = _filename_from_content_disposition(r.headers.get("content-disposition"))
357
+ if name:
358
+ name = PurePosixPath(name).name or None
359
+ return r.content, name
360
+
361
+
362
+ def update_product_technical_specifications(
363
+ product_id: str,
364
+ technical_specifications: list[dict[str, Any]],
365
+ *,
366
+ timeout: float = 120.0,
367
+ ) -> Any:
368
+ """
369
+ POST /admin/products/{id}/technical-specifications — multipart field
370
+ ``technical_specifications`` (JSON array) persists specs on the product.
371
+ """
372
+ return post_multipart_simple(
373
+ f"/admin/products/{product_id}/technical-specifications",
374
+ {"technical_specifications": json.dumps(technical_specifications)},
375
+ timeout=timeout,
376
+ )
makea_cli/auth_pkce.py ADDED
@@ -0,0 +1,251 @@
1
+ """
2
+ Cognito Hosted UI login via OAuth 2.0 Authorization Code + PKCE (public client).
3
+
4
+ The Makea API validates Bearer tokens as Cognito JWTs with audience = app client id,
5
+ which matches Cognito ID tokens — store and send id_token to the API.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import json
13
+ import secrets
14
+ import threading
15
+ import time
16
+ import urllib.parse
17
+ import webbrowser
18
+ from http.server import BaseHTTPRequestHandler, HTTPServer
19
+
20
+ import httpx
21
+
22
+ from makea_cli.config import (
23
+ cognito_client_id,
24
+ cognito_domain_host,
25
+ load_credentials,
26
+ redirect_uri,
27
+ save_credentials,
28
+ )
29
+
30
+
31
+ def _b64url(data: bytes) -> str:
32
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
33
+
34
+
35
+ def _pkce_pair() -> tuple[str, str]:
36
+ verifier = secrets.token_urlsafe(48)
37
+ challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
38
+ return verifier, challenge
39
+
40
+
41
+ def _parse_query(path: str) -> dict[str, str]:
42
+ q = urllib.parse.urlparse(path).query
43
+ return dict(urllib.parse.parse_qsl(q))
44
+
45
+
46
+ def _token_url(domain_host: str) -> str:
47
+ return f"https://{domain_host}/oauth2/token"
48
+
49
+
50
+ def _post_token(domain_host: str, body: dict) -> dict:
51
+ with httpx.Client(timeout=30.0) as client:
52
+ r = client.post(
53
+ _token_url(domain_host),
54
+ data=body,
55
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
56
+ )
57
+ if r.status_code >= 400:
58
+ try:
59
+ err = r.json()
60
+ except json.JSONDecodeError:
61
+ err = r.text
62
+ raise RuntimeError(f"Cognito token endpoint error ({r.status_code}): {err}")
63
+ return r.json()
64
+
65
+
66
+ def exchange_code_for_tokens(
67
+ *,
68
+ domain_host: str,
69
+ client_id: str,
70
+ code: str,
71
+ redirect_uri_value: str,
72
+ code_verifier: str,
73
+ ) -> dict:
74
+ body = {
75
+ "grant_type": "authorization_code",
76
+ "client_id": client_id,
77
+ "code": code,
78
+ "redirect_uri": redirect_uri_value,
79
+ "code_verifier": code_verifier,
80
+ }
81
+ return _post_token(domain_host, body)
82
+
83
+
84
+ def refresh_tokens(
85
+ *,
86
+ domain_host: str,
87
+ client_id: str,
88
+ refresh_token: str,
89
+ ) -> dict:
90
+ body = {
91
+ "grant_type": "refresh_token",
92
+ "client_id": client_id,
93
+ "refresh_token": refresh_token,
94
+ }
95
+ return _post_token(domain_host, body)
96
+
97
+
98
+ def run_browser_login() -> dict:
99
+ """
100
+ Open system browser to Cognito Hosted UI; receive authorization code on localhost.
101
+
102
+ Returns token JSON from Cognito (includes id_token, refresh_token, expires_in, ...).
103
+ """
104
+ domain_host = cognito_domain_host()
105
+ client_id = cognito_client_id()
106
+ redirect_uri_value = redirect_uri()
107
+ verifier, challenge = _pkce_pair()
108
+
109
+ auth_params = {
110
+ "response_type": "code",
111
+ "client_id": client_id,
112
+ "redirect_uri": redirect_uri_value,
113
+ "scope": "openid email profile",
114
+ "code_challenge_method": "S256",
115
+ "code_challenge": challenge,
116
+ }
117
+ auth_url = (
118
+ f"https://{domain_host}/oauth2/authorize?{urllib.parse.urlencode(auth_params)}"
119
+ )
120
+
121
+ code_holder: dict[str, str | None] = {"code": None, "error": None}
122
+ done = threading.Event()
123
+
124
+ class Handler(BaseHTTPRequestHandler):
125
+ def log_message(self, _format: str, *_args: object) -> None:
126
+ return
127
+
128
+ def do_GET(self) -> None: # noqa: N802
129
+ params = _parse_query(self.path)
130
+ if "code" in params:
131
+ code_holder["code"] = params["code"]
132
+ self.send_response(200)
133
+ self.send_header("Content-Type", "text/html; charset=utf-8")
134
+ self.end_headers()
135
+ self.wfile.write(
136
+ b"<html><body><p>Login successful. You can close this tab.</p></body></html>"
137
+ )
138
+ else:
139
+ err = (
140
+ params.get("error_description")
141
+ or params.get("error")
142
+ or "unknown_error"
143
+ )
144
+ code_holder["error"] = err
145
+ self.send_response(400)
146
+ self.send_header("Content-Type", "text/html; charset=utf-8")
147
+ self.end_headers()
148
+ msg = urllib.parse.quote(str(err), safe="")
149
+ self.wfile.write(
150
+ f"<html><body><p>Login failed: {msg}</p></body></html>".encode(
151
+ "utf-8"
152
+ )
153
+ )
154
+ done.set()
155
+
156
+ parsed = urllib.parse.urlparse(redirect_uri_value)
157
+ if parsed.hostname not in ("127.0.0.1", "localhost"):
158
+ raise RuntimeError(
159
+ "Login callback must use 127.0.0.1 or localhost. Use the built-in default; no extra setup."
160
+ )
161
+ port = parsed.port
162
+ if not port:
163
+ raise RuntimeError(
164
+ "Login callback URL must include a port (the default already does)."
165
+ )
166
+
167
+ bind_host = parsed.hostname or "127.0.0.1"
168
+ if bind_host == "localhost":
169
+ bind_host = "127.0.0.1"
170
+
171
+ server = HTTPServer((bind_host, port), Handler)
172
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
173
+ thread.start()
174
+
175
+ webbrowser.open(auth_url)
176
+
177
+ if not done.wait(timeout=300):
178
+ server.shutdown()
179
+ raise TimeoutError("No OAuth callback received within 5 minutes.")
180
+
181
+ server.shutdown()
182
+
183
+ if code_holder["error"]:
184
+ raise RuntimeError(f"Cognito error: {code_holder['error']}")
185
+ code = code_holder["code"]
186
+ if not code:
187
+ raise RuntimeError("No authorization code in callback.")
188
+
189
+ tokens = exchange_code_for_tokens(
190
+ domain_host=domain_host,
191
+ client_id=client_id,
192
+ code=code,
193
+ redirect_uri_value=redirect_uri_value,
194
+ code_verifier=verifier,
195
+ )
196
+ if "id_token" not in tokens:
197
+ raise RuntimeError("Cognito token response missing id_token.")
198
+ return tokens
199
+
200
+
201
+ def persist_tokens(tokens: dict) -> None:
202
+ expires_in = int(tokens.get("expires_in") or 0)
203
+ saved_at = time.time()
204
+ save_credentials(
205
+ {
206
+ "id_token": tokens["id_token"],
207
+ "refresh_token": tokens.get("refresh_token"),
208
+ "expires_in": expires_in,
209
+ "saved_at": saved_at,
210
+ }
211
+ )
212
+
213
+
214
+ def ensure_fresh_id_token() -> str:
215
+ """
216
+ Return a usable id_token, refreshing with refresh_token when close to expiry if possible.
217
+ """
218
+ creds = load_credentials()
219
+ if not creds or not creds.get("id_token"):
220
+ raise RuntimeError("尚未登录。请先执行:makea-cli auth")
221
+
222
+ expires_in = int(creds.get("expires_in") or 0)
223
+ saved_at = float(creds.get("saved_at") or 0)
224
+ if expires_in <= 0:
225
+ return creds["id_token"]
226
+
227
+ remaining = saved_at + expires_in - time.time()
228
+ refresh_token = creds.get("refresh_token")
229
+ if remaining > 120 or not refresh_token:
230
+ return creds["id_token"]
231
+
232
+ domain_host = cognito_domain_host()
233
+ client_id = cognito_client_id()
234
+ new_tokens = refresh_tokens(
235
+ domain_host=domain_host,
236
+ client_id=client_id,
237
+ refresh_token=refresh_token,
238
+ )
239
+ merged = {
240
+ "id_token": new_tokens.get("id_token") or creds["id_token"],
241
+ "refresh_token": new_tokens.get("refresh_token") or refresh_token,
242
+ "expires_in": int(new_tokens.get("expires_in") or expires_in),
243
+ "saved_at": time.time(),
244
+ }
245
+ save_credentials(merged)
246
+ return merged["id_token"]
247
+
248
+
249
+ def login_and_save() -> None:
250
+ tokens = run_browser_login()
251
+ persist_tokens(tokens)
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from makea_cli.commands.auth.cmd import register as register_auth
6
+ from makea_cli.commands.directory.cmd import register as register_directory
7
+ from makea_cli.commands.document.cmd import register as register_document
8
+ from makea_cli.commands.email.cmd import register as register_email
9
+ from makea_cli.commands.metrics.cmd import register as register_metrics
10
+ from makea_cli.commands.orders.cmd import register as register_orders
11
+ from makea_cli.commands.product.cmd import register as register_product
12
+ from makea_cli.commands.supplier.cmd import register as register_supplier
13
+
14
+
15
+ def register_all(app: typer.Typer) -> None:
16
+ register_auth(app)
17
+ register_directory(app)
18
+ register_document(app)
19
+ register_supplier(app)
20
+ register_orders(app)
21
+ register_product(app)
22
+ register_metrics(app)
23
+ register_email(app)
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ import typer
7
+ from rich.console import Console
8
+
9
+
10
+ def print_json_or_exit(console: Console, fetch: Callable[[], Any]) -> None:
11
+ try:
12
+ data = fetch()
13
+ except Exception as e:
14
+ console.print(f"[red]{e}[/red]")
15
+ raise typer.Exit(1) from e
16
+ console.print_json(data=data)
@@ -0,0 +1,3 @@
1
+ from makea_cli.commands.auth.cmd import register
2
+
3
+ __all__ = ["register"]