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 +1 -0
- makea_cli/__main__.py +6 -0
- makea_cli/api_client.py +376 -0
- makea_cli/auth_pkce.py +251 -0
- makea_cli/commands/__init__.py +23 -0
- makea_cli/commands/_util.py +16 -0
- makea_cli/commands/auth/__init__.py +3 -0
- makea_cli/commands/auth/cmd.py +41 -0
- makea_cli/commands/directory/__init__.py +3 -0
- makea_cli/commands/directory/cmd.py +39 -0
- makea_cli/commands/document/__init__.py +3 -0
- makea_cli/commands/document/cmd.py +182 -0
- makea_cli/commands/email/__init__.py +3 -0
- makea_cli/commands/email/cmd.py +122 -0
- makea_cli/commands/metrics/__init__.py +0 -0
- makea_cli/commands/metrics/cmd.py +98 -0
- makea_cli/commands/orders/__init__.py +3 -0
- makea_cli/commands/orders/cmd.py +202 -0
- makea_cli/commands/product/__init__.py +3 -0
- makea_cli/commands/product/cmd.py +160 -0
- makea_cli/commands/supplier/__init__.py +3 -0
- makea_cli/commands/supplier/cmd.py +305 -0
- makea_cli/config.py +74 -0
- makea_cli/main.py +29 -0
- makea_cli-0.1.1.dist-info/METADATA +98 -0
- makea_cli-0.1.1.dist-info/RECORD +28 -0
- makea_cli-0.1.1.dist-info/WHEEL +4 -0
- makea_cli-0.1.1.dist-info/entry_points.txt +2 -0
makea_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
makea_cli/__main__.py
ADDED
makea_cli/api_client.py
ADDED
|
@@ -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)
|