xmemcli 0.0.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.
xmemcli/_login.py ADDED
@@ -0,0 +1,593 @@
1
+ """The browser handoff behind ``xmem auth login`` (PKCE + loopback callback).
2
+
3
+ The CLI mints a PKCE ``verifier`` / ``challenge`` and opens ``/xmemcli/<challenge>``.
4
+ It listens on ``127.0.0.1:9636`` while the human creates a key and clicks **Use this key
5
+ in the CLI**. The Console POSTs the key to ``/cli/handoff``, receives a signed short-lived
6
+ ``code``, then redirects the browser to the local callback with ``code`` + ``state``.
7
+ The CLI redeems with ``verifier`` + ``code`` over TLS; only it holds the verifier.
8
+
9
+ This loopback callback is a conscious multi-node design choice: the API remains stateless
10
+ for sign-in, because any node can mint/verify the signed code and no shared relay table,
11
+ cache, sticky session, or cross-node pub/sub is required.
12
+ Standard-library only, like the rest of the CLI.
13
+ """
14
+
15
+ import base64
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import secrets
20
+ import select
21
+ import sys
22
+ import threading
23
+ import time
24
+ import urllib.error
25
+ import urllib.request
26
+ from http.server import BaseHTTPRequestHandler, HTTPServer
27
+ from typing import IO, Any, Callable, NamedTuple, cast, override
28
+ from urllib.parse import parse_qs, quote, urlparse
29
+
30
+ try:
31
+ import termios
32
+ import tty
33
+
34
+ _TERMIOS_AVAILABLE = True
35
+ except ImportError:
36
+ termios = None # type: ignore[assignment]
37
+ tty = None # type: ignore[assignment]
38
+ _TERMIOS_AVAILABLE = False
39
+
40
+ from ._envelope import batch_envelope_error
41
+ from ._version import CLI_NAME
42
+
43
+ # Must match ``xmem.ENV_CONSOLE_URL`` / ``xmem.ENV_API_URL``.
44
+ # Duplicated here so ``_login`` stays importable without loading ``xmem``.
45
+ _ENV_CONSOLE_URL = "XMEM_CONSOLE_URL"
46
+ _ENV_API_URL = "XMEM_API_URL"
47
+ DEFAULT_CONSOLE_URL = "https://console.xmemory.ai"
48
+
49
+ CALLBACK_PORT = 9636
50
+ CALLBACK_PATH = "/callback"
51
+ CLI_LOGIN_PATH = "/xmemcli"
52
+ CLI_TOKEN_PROXY_PATH = "/api/cli/token"
53
+ CLI_WHOAMI_PROXY_PATH = "/api/cli/whoami"
54
+
55
+ API_KEY_PREFIX = "xmem_"
56
+
57
+ _DONE_PAGE = (
58
+ f"<!doctype html><html><head><meta charset='utf-8'><title>{CLI_NAME}</title></head>"
59
+ "<body style='font-family:system-ui;margin:3rem'>"
60
+ "<h2>You're all set.</h2><p>You can close this tab and return to your terminal.</p>"
61
+ "</body></html>"
62
+ ).encode("utf-8")
63
+
64
+
65
+ class LoginError(Exception):
66
+ """A login flow failed (bind error, missing code, bad ``state``)."""
67
+
68
+
69
+ class LoginTimeout(LoginError):
70
+ """The human did not complete the browser step before the deadline."""
71
+
72
+
73
+ class LoginAborted(Exception):
74
+ """The user cancelled login (e.g. Ctrl+C)."""
75
+
76
+
77
+ class HandoffResult(NamedTuple):
78
+ """Outcome of a successful PKCE login — carries the secret and validated account email."""
79
+
80
+ api_key: str
81
+ key_prefix: str
82
+ name: str
83
+ email: str
84
+
85
+
86
+ def redirect_uri_for_port(port: int) -> str:
87
+ return f"http://127.0.0.1:{port}{CALLBACK_PATH}"
88
+
89
+
90
+ def redirect_uri() -> str:
91
+ return redirect_uri_for_port(CALLBACK_PORT)
92
+
93
+
94
+ def new_verifier() -> str:
95
+ return secrets.token_urlsafe(32)
96
+
97
+
98
+ def challenge_for(verifier: str) -> str:
99
+ digest = hashlib.sha256(verifier.encode("utf-8")).digest()
100
+ return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
101
+
102
+
103
+ def _console_url_from_api_url(api_url: str) -> str | None:
104
+ """Local dev convention: API on :8000, Console on :8002 (same host)."""
105
+ parsed = urlparse(api_url.rstrip("/"))
106
+ if parsed.hostname not in ("localhost", "127.0.0.1"):
107
+ return None
108
+ if parsed.port == 8000:
109
+ return f"{parsed.scheme}://{parsed.hostname}:8002"
110
+ return None
111
+
112
+
113
+ def console_url_for_api(*, console_url: str | None = None) -> str:
114
+ """Resolve the Console origin for PKCE login and token/whoami proxies."""
115
+ if console_url:
116
+ return console_url.rstrip("/")
117
+ from_env = os.environ.get(_ENV_CONSOLE_URL, "").strip()
118
+ if from_env:
119
+ return from_env.rstrip("/")
120
+ api_from_env = os.environ.get(_ENV_API_URL, "").strip()
121
+ if api_from_env:
122
+ inferred = _console_url_from_api_url(api_from_env)
123
+ if inferred:
124
+ return inferred
125
+ return DEFAULT_CONSOLE_URL
126
+
127
+
128
+ def build_login_page_url(console_url: str, *, challenge: str, callback_url: str | None = None) -> str:
129
+ """Console CLI login URL: ``/xmemcli/<challenge>`` (PKCE S256, also the callback state)."""
130
+ segment = quote(challenge, safe="-_")
131
+ url = f"{console_url.rstrip('/')}{CLI_LOGIN_PATH}/{segment}"
132
+ if callback_url and callback_url != redirect_uri_for_port(CALLBACK_PORT):
133
+ url += "?callback_url=" + quote(callback_url, safe=":/?&=-_.")
134
+ return url
135
+
136
+
137
+ def _login_prompt_line(label: str, body: str) -> str:
138
+ return f" {label:<10}{body}"
139
+
140
+
141
+ def write_login_handoff_prompt(
142
+ login_url: str,
143
+ *,
144
+ offer_paste: bool,
145
+ stream: IO[str] | None = None,
146
+ ) -> None:
147
+ """Human-facing instructions while ``auth login`` waits for a credential."""
148
+ out = stream or sys.stderr
149
+ body_indent = " "
150
+ lines = [
151
+ "",
152
+ f"Authorize {CLI_NAME} on this machine.",
153
+ "",
154
+ _login_prompt_line("Browser", "Open this URL and click \"Use this key in the CLI\":"),
155
+ f"{body_indent}{login_url}",
156
+ ]
157
+ if offer_paste:
158
+ lines.extend(
159
+ [
160
+ "",
161
+ _login_prompt_line(
162
+ "Paste",
163
+ "Paste your API key below (input hidden), then press Enter.",
164
+ ),
165
+ ]
166
+ )
167
+ lines.extend(
168
+ [
169
+ "",
170
+ _login_prompt_line("Manual", "Set $XMEM_API_KEY or `.xmemrc.json`"),
171
+ f"{body_indent}See AUTH-FLOW.md.",
172
+ "",
173
+ ]
174
+ )
175
+ out.write("\n".join(lines))
176
+ out.flush()
177
+
178
+
179
+ def token_redeem_urls(*, api_url: str, console_url: str | None) -> list[str]:
180
+ """Candidate ``POST /cli/token`` URLs, most likely first."""
181
+ console = console_url_for_api(console_url=console_url)
182
+ if console_url or os.environ.get(_ENV_CONSOLE_URL, "").strip():
183
+ return [f"{console.rstrip('/')}{CLI_TOKEN_PROXY_PATH}"]
184
+ base = api_url.rstrip("/")
185
+ return [f"{base}/cli/token", f"{base}/api/cli/token"]
186
+
187
+
188
+ def whoami_urls(*, api_url: str, console_url: str | None) -> list[str]:
189
+ """Candidate ``GET /whoami`` URLs for bearer API-key lookup, most likely first."""
190
+ console = console_url_for_api(console_url=console_url)
191
+ if console_url or os.environ.get(_ENV_CONSOLE_URL, "").strip():
192
+ return [f"{console.rstrip('/')}{CLI_WHOAMI_PROXY_PATH}"]
193
+ base = api_url.rstrip("/")
194
+ return [f"{base}/whoami", f"{base}/api/whoami"]
195
+
196
+
197
+ class _LoopbackServer(HTTPServer):
198
+ captured: dict[str, str] | None = None
199
+
200
+
201
+ class _CallbackHandler(BaseHTTPRequestHandler):
202
+ def do_GET(self) -> None: # noqa: N802
203
+ parsed = urlparse(self.path)
204
+ if parsed.path != CALLBACK_PATH:
205
+ self.send_response(404)
206
+ self.end_headers()
207
+ return
208
+ params = {k: v[0] for k, v in parse_qs(parsed.query).items() if v}
209
+ cast(_LoopbackServer, self.server).captured = params
210
+ self.send_response(200)
211
+ self.send_header("Content-Type", "text/html; charset=utf-8")
212
+ self.end_headers()
213
+ self.wfile.write(_DONE_PAGE)
214
+
215
+ @override
216
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002
217
+ return
218
+
219
+
220
+ def wait_for_callback(
221
+ state: str,
222
+ build_url: Callable[[str], str],
223
+ on_url: Callable[[str], None],
224
+ timeout_s: float,
225
+ *,
226
+ callback_port: int = CALLBACK_PORT,
227
+ stop: threading.Event | None = None,
228
+ ) -> dict[str, str]:
229
+ """Listen for the browser's local callback carrying the signed code.
230
+
231
+ This is intentionally the production transport. It keeps CLI sign-in friendly to multi-node API deployments:
232
+ the server only mints/verifies stateless JWT codes, while the browser delivers the code directly to the waiting CLI.
233
+ """
234
+ try:
235
+ server = _LoopbackServer(("127.0.0.1", callback_port), _CallbackHandler)
236
+ except OSError as e:
237
+ label = callback_port if callback_port != 0 else CALLBACK_PORT
238
+ raise LoginError(
239
+ f"could not bind 127.0.0.1:{label} — is another `{CLI_NAME} auth login` in progress? ({e})"
240
+ ) from None
241
+ bound_port = server.server_address[1]
242
+ server.timeout = 0.5
243
+ try:
244
+ on_url(build_url(redirect_uri_for_port(bound_port)))
245
+ deadline = time.monotonic() + timeout_s
246
+ while server.captured is None and not (stop and stop.is_set()):
247
+ if time.monotonic() >= deadline:
248
+ raise LoginTimeout(
249
+ f"timed out after {timeout_s:g}s waiting for the browser callback on port {bound_port}"
250
+ )
251
+ server.handle_request()
252
+ finally:
253
+ server.server_close()
254
+ params = server.captured
255
+ if params is None: # pragma: no cover
256
+ raise LoginError("callback listener stopped")
257
+ if params.get("state") != state:
258
+ raise LoginError("callback `state` did not match — rejecting (possible CSRF / stale tab)")
259
+ return params
260
+
261
+
262
+ def _response_looks_like_html(raw: str) -> bool:
263
+ head = raw.lstrip()[:256].lower()
264
+ return head.startswith("<!doctype") or head.startswith("<html")
265
+
266
+
267
+ def _parse_token_envelope(raw: str, *, url: str) -> HandoffResult:
268
+ if _response_looks_like_html(raw):
269
+ raise LoginError(f"token redeem got a web page instead of JSON from {url}")
270
+ try:
271
+ envelope = json.loads(raw)
272
+ except json.JSONDecodeError as e:
273
+ raise LoginError(f"token redeem returned non-JSON from {url}: {raw[:200]}") from e
274
+ err = batch_envelope_error(envelope)
275
+ if err:
276
+ raise LoginError(f"token redeem failed: {err}")
277
+ items = envelope.get("items") if isinstance(envelope, dict) else None
278
+ if not isinstance(items, list) or not items or not isinstance(items[0], dict):
279
+ raise LoginError("token redeem response missing items[0]")
280
+ item = items[0]
281
+ key = item.get("key")
282
+ if not isinstance(key, str) or not key:
283
+ raise LoginError("token redeem response missing key")
284
+ key_prefix_raw = item.get("key_prefix")
285
+ key_prefix = key_prefix_raw if isinstance(key_prefix_raw, str) and key_prefix_raw else key[:8]
286
+ name_raw = item.get("name")
287
+ name = name_raw if isinstance(name_raw, str) and name_raw else "cli"
288
+ email_raw = item.get("email")
289
+ if not isinstance(email_raw, str) or not email_raw.strip() or "@" not in email_raw:
290
+ raise LoginError("token redeem response missing validated email")
291
+ email = email_raw.strip().lower()
292
+ return HandoffResult(api_key=key, key_prefix=key_prefix, name=name, email=email)
293
+
294
+
295
+ def redeem_token(
296
+ api_url: str,
297
+ *,
298
+ code: str,
299
+ verifier: str,
300
+ console_url: str | None = None,
301
+ timeout_s: float = 30.0,
302
+ ) -> HandoffResult:
303
+ """Exchange ``verifier`` + signed ``code`` for the issued key."""
304
+ payload: dict[str, str] = {"verifier": verifier}
305
+ if not code:
306
+ raise LoginError("internal error: redeem requires code")
307
+ payload["code"] = code
308
+ body = json.dumps(payload).encode("utf-8")
309
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
310
+ errors: list[str] = []
311
+ for url in token_redeem_urls(api_url=api_url, console_url=console_url):
312
+ req = urllib.request.Request(url, data=body, method="POST", headers=headers)
313
+ try:
314
+ with urllib.request.urlopen(req, timeout=timeout_s) as resp:
315
+ raw = resp.read().decode("utf-8")
316
+ except urllib.error.HTTPError as e:
317
+ if e.code == 401:
318
+ raise LoginError(
319
+ f"The login code expired or is invalid — run `{CLI_NAME} auth login` again."
320
+ ) from None
321
+ detail = e.read().decode("utf-8", errors="replace")[:200]
322
+ errors.append(f"{url}: HTTP {e.code} ({detail})")
323
+ continue
324
+ except urllib.error.URLError as e:
325
+ errors.append(f"{url}: network error ({e.reason})")
326
+ continue
327
+ try:
328
+ return _parse_token_envelope(raw, url=url)
329
+ except LoginError as e:
330
+ errors.append(str(e))
331
+ continue
332
+ hint = (
333
+ "Could not reach the accounts API token endpoint. "
334
+ "Set $XMEM_CONSOLE_URL to your Console (e.g. dk-web…) and ensure it can reach the API "
335
+ "(ACCOUNTS_API_URL on the Console server)."
336
+ )
337
+ detail = "; ".join(errors[-3:]) if errors else "no endpoints tried"
338
+ raise LoginError(f"token redeem failed: {detail}. {hint}")
339
+
340
+
341
+ def stdin_is_interactive() -> bool:
342
+ """True when stdin is a TTY (interactive paste is meaningful)."""
343
+ try:
344
+ return sys.stdin.isatty()
345
+ except (AttributeError, ValueError, OSError):
346
+ return False
347
+
348
+
349
+ def paste_from_stdin_supported() -> bool:
350
+ """True when hidden stdin paste can be offered (Unix TTY with termios)."""
351
+ return stdin_is_interactive() and _TERMIOS_AVAILABLE
352
+
353
+
354
+ def _read_pasted_api_key_no_echo(*, stop: threading.Event) -> str | None:
355
+ """Read one line from stdin without echo. Returns None if *stop* is set first."""
356
+ if not _TERMIOS_AVAILABLE or termios is None or tty is None:
357
+ return None
358
+
359
+ fd = sys.stdin.fileno()
360
+ old_settings = termios.tcgetattr(fd)
361
+ chars: list[str] = []
362
+ try:
363
+ tty.setraw(fd)
364
+ while not stop.is_set():
365
+ ready, _, _ = select.select([sys.stdin], [], [], 0.15)
366
+ if not ready:
367
+ continue
368
+ ch = sys.stdin.read(1)
369
+ if ch in ("\n", "\r"):
370
+ return "".join(chars).strip()
371
+ if ch == "\x03":
372
+ raise LoginAborted()
373
+ if ch in ("\x7f", "\b"):
374
+ if chars:
375
+ chars.pop()
376
+ continue
377
+ if ch == "\x04":
378
+ return "".join(chars).strip()
379
+ chars.append(ch)
380
+ return None
381
+ finally:
382
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
383
+ # DELIBERATELY print: newline after raw-mode paste must not touch stdout (machine JSON).
384
+ sys.stderr.write("\n")
385
+ sys.stderr.flush()
386
+
387
+
388
+ def fetch_whoami_email(
389
+ api_url: str,
390
+ api_key: str,
391
+ *,
392
+ console_url: str | None = None,
393
+ timeout_s: float = 10.0,
394
+ ) -> str:
395
+ """Resolve an ``xmem_`` API key to its owner email via ``GET /whoami``."""
396
+ headers = {"Accept": "application/json", "Authorization": f"Bearer {api_key}"}
397
+ errors: list[str] = []
398
+ for url in whoami_urls(api_url=api_url, console_url=console_url):
399
+ req = urllib.request.Request(url, method="GET", headers=headers)
400
+ try:
401
+ with urllib.request.urlopen(req, timeout=timeout_s) as resp:
402
+ raw = resp.read().decode("utf-8")
403
+ except urllib.error.HTTPError as e:
404
+ detail = e.read().decode("utf-8", errors="replace")[:200]
405
+ errors.append(f"{url}: HTTP {e.code} ({detail})")
406
+ continue
407
+ except urllib.error.URLError as e:
408
+ raise LoginError(f"whoami network error at {url}: {e.reason}") from None
409
+ try:
410
+ body = json.loads(raw)
411
+ except json.JSONDecodeError as e:
412
+ raise LoginError(f"whoami returned non-JSON from {url}") from e
413
+ auth = body.get("AuthApiKey") if isinstance(body, dict) else None
414
+ if isinstance(auth, dict):
415
+ email_raw = auth.get("email")
416
+ if isinstance(email_raw, str) and email_raw.strip() and "@" in email_raw:
417
+ return email_raw.strip().lower()
418
+ errors.append(f"{url}: key did not resolve to an active account")
419
+ detail = "; ".join(errors[-2:]) if errors else "no whoami endpoints tried"
420
+ raise LoginError(f"pasted key is not valid or not active ({detail})")
421
+
422
+
423
+ def handoff_from_pasted_key(
424
+ api_url: str,
425
+ api_key: str,
426
+ *,
427
+ console_url: str | None = None,
428
+ timeout_s: float = 10.0,
429
+ ) -> HandoffResult:
430
+ """Build a handoff result from a human-pasted plaintext key."""
431
+ key = api_key.strip()
432
+ if not key.startswith(API_KEY_PREFIX):
433
+ raise LoginError(f"pasted key must start with {API_KEY_PREFIX}")
434
+ if len(key) < len(API_KEY_PREFIX) + 8:
435
+ raise LoginError("pasted key looks too short")
436
+ email = fetch_whoami_email(api_url, key, console_url=console_url, timeout_s=timeout_s)
437
+ return HandoffResult(api_key=key, key_prefix=key[:8], name="pasted", email=email)
438
+
439
+
440
+ def wait_for_handoff(
441
+ api_url: str,
442
+ *,
443
+ console_url: str | None,
444
+ challenge: str,
445
+ verifier: str,
446
+ build_url: Callable[[str], str],
447
+ on_url: Callable[[str], None],
448
+ timeout_s: float,
449
+ callback_port: int = CALLBACK_PORT,
450
+ interactive: bool = False,
451
+ notify: Callable[[str], None] | None = None,
452
+ ) -> HandoffResult:
453
+ """Wait for a key via loopback callback and, when interactive, hidden stdin paste in parallel."""
454
+ notify = notify or (lambda _msg: None)
455
+ stop = threading.Event()
456
+ abort_requested = threading.Event()
457
+ result_box: list[HandoffResult] = []
458
+ errors: list[Exception] = []
459
+ offer_paste = interactive and paste_from_stdin_supported()
460
+ prompt_ready = threading.Event()
461
+
462
+ def request_abort() -> None:
463
+ abort_requested.set()
464
+ stop.set()
465
+
466
+ def callback_worker() -> None:
467
+ def opened(url: str) -> None:
468
+ on_url(url)
469
+ prompt_ready.set()
470
+
471
+ try:
472
+ params = wait_for_callback(
473
+ challenge,
474
+ build_url,
475
+ opened,
476
+ timeout_s,
477
+ callback_port=callback_port,
478
+ stop=stop,
479
+ )
480
+ code = params.get("code")
481
+ if not code:
482
+ raise LoginError("callback missing code")
483
+ result = redeem_token(
484
+ api_url,
485
+ code=code,
486
+ console_url=console_url,
487
+ verifier=verifier,
488
+ timeout_s=min(10.0, timeout_s),
489
+ )
490
+ if not result_box:
491
+ result_box.append(result)
492
+ notify("Key received from the browser callback; paste prompt closed.")
493
+ stop.set()
494
+ except LoginTimeout as e:
495
+ if not stop.is_set():
496
+ errors.append(e)
497
+ except LoginError as e:
498
+ if not stop.is_set():
499
+ errors.append(e)
500
+ stop.set()
501
+
502
+ def paste_worker() -> None:
503
+ while not stop.is_set() and not prompt_ready.wait(timeout=0.1):
504
+ pass
505
+ if stop.is_set():
506
+ return
507
+ try:
508
+ while not stop.is_set():
509
+ try:
510
+ pasted = _read_pasted_api_key_no_echo(stop=stop)
511
+ except LoginAborted:
512
+ errors.append(LoginAborted())
513
+ request_abort()
514
+ return
515
+ if stop.is_set():
516
+ return
517
+ if not pasted:
518
+ continue
519
+ try:
520
+ result = handoff_from_pasted_key(
521
+ api_url, pasted, console_url=console_url, timeout_s=min(10.0, timeout_s)
522
+ )
523
+ except LoginError as e:
524
+ notify(f"Could not use pasted key: {e}")
525
+ continue
526
+ if not result_box:
527
+ result_box.append(result)
528
+ notify("Using pasted key; stopped waiting for browser callback.")
529
+ stop.set()
530
+ return
531
+ except LoginAborted:
532
+ errors.append(LoginAborted())
533
+ request_abort()
534
+
535
+ threads = [threading.Thread(target=callback_worker, daemon=True)]
536
+ if offer_paste:
537
+ threads.append(threading.Thread(target=paste_worker, daemon=True))
538
+ for thread in threads:
539
+ thread.start()
540
+
541
+ deadline = time.monotonic() + timeout_s
542
+ try:
543
+ while time.monotonic() < deadline and not result_box:
544
+ if abort_requested.is_set():
545
+ raise LoginAborted()
546
+ if stop.is_set() and errors:
547
+ break
548
+ time.sleep(0.05)
549
+ except KeyboardInterrupt:
550
+ request_abort()
551
+ raise LoginAborted() from None
552
+ finally:
553
+ stop.set()
554
+ for thread in threads:
555
+ thread.join(timeout=1.0)
556
+
557
+ if result_box:
558
+ return result_box[0]
559
+ if any(isinstance(e, LoginAborted) for e in errors):
560
+ raise LoginAborted()
561
+ if errors:
562
+ raise errors[0]
563
+ raise LoginTimeout(
564
+ f"timed out after {timeout_s:g}s waiting for a key from the browser callback or stdin"
565
+ )
566
+
567
+
568
+ def run_pkce_login(
569
+ api_url: str,
570
+ *,
571
+ console_url: str | None,
572
+ verifier: str,
573
+ on_url: Callable[[str], None],
574
+ timeout_s: float,
575
+ callback_port: int = CALLBACK_PORT,
576
+ interactive: bool = False,
577
+ notify: Callable[[str], None] | None = None,
578
+ ) -> HandoffResult:
579
+ """Open the Console login page and wait for loopback callback and/or hidden stdin paste."""
580
+ console = console_url_for_api(console_url=console_url)
581
+ challenge = challenge_for(verifier)
582
+ return wait_for_handoff(
583
+ api_url,
584
+ console_url=console_url,
585
+ challenge=challenge,
586
+ verifier=verifier,
587
+ build_url=lambda redirect_uri: build_login_page_url(console, challenge=challenge, callback_url=redirect_uri),
588
+ on_url=on_url,
589
+ timeout_s=timeout_s,
590
+ callback_port=callback_port,
591
+ interactive=interactive,
592
+ notify=notify,
593
+ )
@@ -0,0 +1,70 @@
1
+ """Org-scoped inventory commands for ``xmemcli``."""
2
+
3
+ import argparse
4
+ from typing import Any
5
+
6
+ from ._envelope import batch_envelope_error
7
+ from ._output import CliOutput, OutputKind, make_error
8
+ from ._xmem_bridge import curate_instance, http_to_exit, xmem_module
9
+
10
+ _CLUSTER_FIELDS = ("id", "name", "description")
11
+
12
+
13
+ def _curate_cluster(item: dict[str, Any], verbose: bool) -> dict[str, Any]:
14
+ m = xmem_module()
15
+ curated = {k: item[k] for k in _CLUSTER_FIELDS if k in item}
16
+ return m._add_verbose_extras(curated, item, verbose)
17
+
18
+
19
+ def cmd_org_list_clusters(args: argparse.Namespace) -> tuple[CliOutput, int]:
20
+ """``org list clusters`` — clusters visible to this credential."""
21
+ m = xmem_module()
22
+ api_key, _ = m._require(args, need_instance=False)
23
+ base = m._api_url(args)
24
+ try:
25
+ envelope = m._get(f"{base}/clusters?verbose=true", api_key)
26
+ except m.ApiError as e:
27
+ return (make_error(f"/clusters failed: {e}", status=e.status), http_to_exit(e.status))
28
+ err = batch_envelope_error(envelope)
29
+ if err:
30
+ return (make_error(f"/clusters failed: {err}", stage="request"), m.EXIT_HTTP_ERROR)
31
+ items = [i for i in (envelope.get("items") or []) if isinstance(i, dict)]
32
+ curated = [_curate_cluster(i, args.verbose) for i in items]
33
+ return (CliOutput(OutputKind.CLUSTER_LIST, curated), m.EXIT_OK)
34
+
35
+
36
+ def cmd_org_list_instances(args: argparse.Namespace) -> tuple[CliOutput, int]:
37
+ """``org list instances`` — instances visible to this credential (``GET /instances``)."""
38
+ m = xmem_module()
39
+ api_key, _ = m._require(args, need_instance=False)
40
+ base = m._api_url(args)
41
+ cluster_filter = getattr(args, "cluster", None)
42
+ try:
43
+ envelope = m._get(f"{base}/instances?verbose=true", api_key)
44
+ except m.ApiError as e:
45
+ return (make_error(f"/instances failed: {e}", status=e.status), http_to_exit(e.status))
46
+ err = batch_envelope_error(envelope)
47
+ if err:
48
+ return (make_error(f"/instances failed: {err}", stage="request"), m.EXIT_HTTP_ERROR)
49
+ items = [i for i in (envelope.get("items") or []) if isinstance(i, dict)]
50
+ if cluster_filter:
51
+ items = [i for i in items if i.get("cluster_id") == cluster_filter]
52
+ curated = [curate_instance(i, args.verbose) for i in items]
53
+ return (CliOutput(OutputKind.INSTANCE_LIST, curated), m.EXIT_OK)
54
+
55
+
56
+ def cmd_org_list_keys(args: argparse.Namespace) -> tuple[CliOutput, int]:
57
+ """``org list keys`` — API keys visible to this credential."""
58
+ m = xmem_module()
59
+ api_key, _ = m._require(args, need_instance=False)
60
+ base = m._api_url(args)
61
+ try:
62
+ envelope = m._get(f"{base}/api-keys?verbose=true", api_key)
63
+ except m.ApiError as e:
64
+ return (make_error(f"/api-keys failed: {e}", status=e.status), http_to_exit(e.status))
65
+ err = batch_envelope_error(envelope)
66
+ if err:
67
+ return (make_error(f"/api-keys failed: {err}", stage="request"), m.EXIT_HTTP_ERROR)
68
+ keys = envelope.get("items") or []
69
+ curated = [m._curate_key(k, args.verbose) for k in keys if isinstance(k, dict)]
70
+ return (CliOutput(OutputKind.KEY_LIST, curated), m.EXIT_OK)