paradigx-cli-core 0.1.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.
- paradigx_cli_core/__init__.py +63 -0
- paradigx_cli_core/auth.py +113 -0
- paradigx_cli_core/client.py +86 -0
- paradigx_cli_core/config.py +160 -0
- paradigx_cli_core/device_flow.py +129 -0
- paradigx_cli_core/output.py +76 -0
- paradigx_cli_core-0.1.0.dist-info/METADATA +66 -0
- paradigx_cli_core-0.1.0.dist-info/RECORD +9 -0
- paradigx_cli_core-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""paradigx-cli-core — shared core for Paradigx product CLIs.
|
|
2
|
+
|
|
3
|
+
Provides OAuth device-flow login, a shared `~/.paradigx/auth.json` token
|
|
4
|
+
cache, automatic JWT refresh, an HTTP client, and `--json` output helpers.
|
|
5
|
+
Product CLIs (botu, tokenroute, ...) depend on this and keep only their
|
|
6
|
+
command layer. See docs/specs/cli-phase-b.md.
|
|
7
|
+
"""
|
|
8
|
+
from .client import ApiError, exit_code_for, request, with_trailing_slash
|
|
9
|
+
from .config import (
|
|
10
|
+
Credentials,
|
|
11
|
+
clear_credentials,
|
|
12
|
+
default_config_dir,
|
|
13
|
+
import_legacy,
|
|
14
|
+
load_credentials,
|
|
15
|
+
save_credentials,
|
|
16
|
+
)
|
|
17
|
+
from .auth import NeedLogin, do_login, refresh, valid_access_token
|
|
18
|
+
from .device_flow import (
|
|
19
|
+
DeviceCodeResponse,
|
|
20
|
+
DiscoveryInfo,
|
|
21
|
+
fetch_discovery,
|
|
22
|
+
open_browser,
|
|
23
|
+
poll_for_token,
|
|
24
|
+
request_device_code,
|
|
25
|
+
)
|
|
26
|
+
from .output import emit, error, info, is_json_mode, set_json_mode, success
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"__version__",
|
|
32
|
+
# client
|
|
33
|
+
"ApiError",
|
|
34
|
+
"exit_code_for",
|
|
35
|
+
"request",
|
|
36
|
+
"with_trailing_slash",
|
|
37
|
+
# config
|
|
38
|
+
"Credentials",
|
|
39
|
+
"clear_credentials",
|
|
40
|
+
"default_config_dir",
|
|
41
|
+
"import_legacy",
|
|
42
|
+
"load_credentials",
|
|
43
|
+
"save_credentials",
|
|
44
|
+
# auth
|
|
45
|
+
"NeedLogin",
|
|
46
|
+
"do_login",
|
|
47
|
+
"refresh",
|
|
48
|
+
"valid_access_token",
|
|
49
|
+
# device_flow
|
|
50
|
+
"DeviceCodeResponse",
|
|
51
|
+
"DiscoveryInfo",
|
|
52
|
+
"fetch_discovery",
|
|
53
|
+
"open_browser",
|
|
54
|
+
"poll_for_token",
|
|
55
|
+
"request_device_code",
|
|
56
|
+
# output
|
|
57
|
+
"emit",
|
|
58
|
+
"error",
|
|
59
|
+
"info",
|
|
60
|
+
"is_json_mode",
|
|
61
|
+
"set_json_mode",
|
|
62
|
+
"success",
|
|
63
|
+
]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Login orchestration + automatic token refresh.
|
|
2
|
+
|
|
3
|
+
`do_login` runs the full device-flow and persists credentials.
|
|
4
|
+
`valid_access_token` is what command code calls before every API request:
|
|
5
|
+
it returns a non-expired access token, transparently refreshing via the
|
|
6
|
+
stored refresh_token when needed.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .config import Credentials, load_credentials, save_credentials
|
|
16
|
+
from .device_flow import fetch_discovery, poll_for_token, request_device_code
|
|
17
|
+
|
|
18
|
+
# Refresh when fewer than this many seconds of validity remain.
|
|
19
|
+
_REFRESH_SKEW_SECONDS = 60
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NeedLogin(RuntimeError):
|
|
23
|
+
"""Raised when there's no usable credential and the user must log in."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str = "not logged in — run `login` first"):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _token_endpoint(creds: Credentials) -> str | None:
|
|
30
|
+
"""Resolve the OIDC token endpoint for a credential.
|
|
31
|
+
|
|
32
|
+
New logins store `token_endpoint` directly. Pre-Phase-B / legacy-imported
|
|
33
|
+
entries only have `issuer`; derive from it (botu issuer ends with `/oidc`,
|
|
34
|
+
tokenroute issuer doesn't).
|
|
35
|
+
"""
|
|
36
|
+
if creds.token_endpoint:
|
|
37
|
+
return creds.token_endpoint
|
|
38
|
+
iss = (creds.issuer or "").rstrip("/")
|
|
39
|
+
if not iss:
|
|
40
|
+
return None
|
|
41
|
+
return f"{iss}/token" if iss.endswith("/oidc") else f"{iss}/oidc/token"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def refresh(creds: Credentials) -> Credentials:
|
|
45
|
+
"""Exchange the stored refresh_token for a fresh access token.
|
|
46
|
+
|
|
47
|
+
Raises NeedLogin if the credential can't be refreshed (no refresh_token,
|
|
48
|
+
endpoint unknown, or the IdP rejects it).
|
|
49
|
+
"""
|
|
50
|
+
endpoint = _token_endpoint(creds)
|
|
51
|
+
if not creds.refresh_token or not creds.client_id or not endpoint:
|
|
52
|
+
raise NeedLogin("session expired — run `login` again")
|
|
53
|
+
data = {
|
|
54
|
+
"grant_type": "refresh_token",
|
|
55
|
+
"refresh_token": creds.refresh_token,
|
|
56
|
+
"client_id": creds.client_id,
|
|
57
|
+
}
|
|
58
|
+
if creds.resource:
|
|
59
|
+
data["resource"] = creds.resource
|
|
60
|
+
try:
|
|
61
|
+
with httpx.Client(timeout=15.0) as c:
|
|
62
|
+
r = c.post(endpoint, data=data)
|
|
63
|
+
except httpx.RequestError as e:
|
|
64
|
+
raise NeedLogin(f"could not reach auth server to refresh: {e}") from e
|
|
65
|
+
if not r.is_success:
|
|
66
|
+
raise NeedLogin("session expired — run `login` again")
|
|
67
|
+
tok = r.json()
|
|
68
|
+
return replace(
|
|
69
|
+
creds,
|
|
70
|
+
access_token=tok["access_token"],
|
|
71
|
+
refresh_token=tok.get("refresh_token") or creds.refresh_token,
|
|
72
|
+
expires_at=int(time.time()) + int(tok.get("expires_in", 3600)),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def valid_access_token(api_url: str) -> str:
|
|
77
|
+
"""Return a non-expired access token for `api_url`.
|
|
78
|
+
|
|
79
|
+
Auto-refreshes (and persists) when the cached token is within the skew
|
|
80
|
+
window of expiry. Raises NeedLogin if there's nothing usable.
|
|
81
|
+
"""
|
|
82
|
+
creds = load_credentials(api_url)
|
|
83
|
+
if creds is None or not creds.access_token:
|
|
84
|
+
raise NeedLogin()
|
|
85
|
+
if creds.expires_at and creds.expires_at - time.time() < _REFRESH_SKEW_SECONDS:
|
|
86
|
+
creds = refresh(creds)
|
|
87
|
+
save_credentials(creds)
|
|
88
|
+
return creds.access_token
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def do_login(discovery_url: str, api_url: str, *, on_code=None) -> Credentials:
|
|
92
|
+
"""Run the full OAuth device-flow and persist the result.
|
|
93
|
+
|
|
94
|
+
`on_code(DeviceCodeResponse)` is invoked once the user code is known —
|
|
95
|
+
the CLI uses it to print the verification URL and open a browser.
|
|
96
|
+
"""
|
|
97
|
+
disc = fetch_discovery(discovery_url)
|
|
98
|
+
code = request_device_code(disc)
|
|
99
|
+
if on_code is not None:
|
|
100
|
+
on_code(code)
|
|
101
|
+
tok = poll_for_token(disc, code)
|
|
102
|
+
creds = Credentials(
|
|
103
|
+
access_token=tok["access_token"],
|
|
104
|
+
refresh_token=tok.get("refresh_token"),
|
|
105
|
+
expires_at=int(time.time()) + int(tok.get("expires_in", 3600)),
|
|
106
|
+
issuer=disc.issuer,
|
|
107
|
+
client_id=disc.client_id,
|
|
108
|
+
resource=disc.resource,
|
|
109
|
+
api_url=api_url,
|
|
110
|
+
token_endpoint=disc.token_endpoint,
|
|
111
|
+
)
|
|
112
|
+
save_credentials(creds)
|
|
113
|
+
return creds
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Thin HTTP client for Paradigx product APIs.
|
|
2
|
+
|
|
3
|
+
`request()` is product-agnostic: the caller passes the base URL, the bearer
|
|
4
|
+
token, and whether the API needs trailing slashes (botu-web is a Next.js
|
|
5
|
+
app with `trailingSlash: true`; the tokenroute FastAPI gateway is not).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApiError(RuntimeError):
|
|
15
|
+
def __init__(self, status: int, message: str, body: Any = None):
|
|
16
|
+
super().__init__(f"HTTP {status}: {message}")
|
|
17
|
+
self.status = status
|
|
18
|
+
self.message = message
|
|
19
|
+
self.body = body
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def exit_code_for(err: ApiError) -> int:
|
|
23
|
+
"""Map an ApiError onto a CLI exit code: 1 user / 2 network / 3 server."""
|
|
24
|
+
if err.status == 0:
|
|
25
|
+
return 2
|
|
26
|
+
return 1 if err.status < 500 else 3
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def with_trailing_slash(path: str) -> str:
|
|
30
|
+
"""Ensure a `/` before any query string — for `trailingSlash: true` APIs."""
|
|
31
|
+
base, sep, query = path.partition("?")
|
|
32
|
+
if not base.endswith("/"):
|
|
33
|
+
base += "/"
|
|
34
|
+
return f"{base}{sep}{query}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _raise(resp: httpx.Response) -> None:
|
|
38
|
+
if resp.is_success:
|
|
39
|
+
return
|
|
40
|
+
try:
|
|
41
|
+
body = resp.json()
|
|
42
|
+
except (ValueError, httpx.DecodingError):
|
|
43
|
+
body = resp.text
|
|
44
|
+
if isinstance(body, dict):
|
|
45
|
+
err = body.get("error")
|
|
46
|
+
if isinstance(err, dict): # {"error": {"message": ...}}
|
|
47
|
+
msg = err.get("message") or resp.reason_phrase
|
|
48
|
+
else: # {"error": "...", "detail"?: "..."}
|
|
49
|
+
msg = err or body.get("detail") or body.get("message") or resp.reason_phrase
|
|
50
|
+
if err and body.get("detail"):
|
|
51
|
+
msg = f"{err} ({body['detail']})"
|
|
52
|
+
else:
|
|
53
|
+
msg = resp.reason_phrase
|
|
54
|
+
raise ApiError(resp.status_code, str(msg), body)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def request(
|
|
58
|
+
method: str,
|
|
59
|
+
base_url: str,
|
|
60
|
+
path: str,
|
|
61
|
+
*,
|
|
62
|
+
token: str,
|
|
63
|
+
json_body: Any | None = None,
|
|
64
|
+
timeout: float = 30.0,
|
|
65
|
+
trailing_slash: bool = False,
|
|
66
|
+
follow_redirects: bool = True,
|
|
67
|
+
user_agent: str = "paradigx-cli-core",
|
|
68
|
+
) -> Any:
|
|
69
|
+
"""Call a product API endpoint with a bearer token. Returns parsed JSON."""
|
|
70
|
+
if trailing_slash:
|
|
71
|
+
path = with_trailing_slash(path)
|
|
72
|
+
headers = {
|
|
73
|
+
"User-Agent": user_agent,
|
|
74
|
+
"Accept": "application/json",
|
|
75
|
+
"Authorization": f"Bearer {token}",
|
|
76
|
+
}
|
|
77
|
+
url = f"{base_url.rstrip('/')}{path}"
|
|
78
|
+
try:
|
|
79
|
+
with httpx.Client(timeout=timeout, follow_redirects=follow_redirects) as client:
|
|
80
|
+
resp = client.request(method, url, json=json_body, headers=headers)
|
|
81
|
+
except httpx.RequestError as e:
|
|
82
|
+
raise ApiError(0, f"network error: {e}") from e
|
|
83
|
+
_raise(resp)
|
|
84
|
+
if resp.status_code == 204 or not resp.content:
|
|
85
|
+
return None
|
|
86
|
+
return resp.json()
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Shared credential storage for Paradigx product CLIs.
|
|
2
|
+
|
|
3
|
+
Tokens live in ``~/.paradigx/auth.json`` — shared across every Paradigx
|
|
4
|
+
CLI (botu, tokenroute, ...). They all authenticate against the same Logto,
|
|
5
|
+
so a single login is reused. Entries are keyed by Logto resource indicator
|
|
6
|
+
(so multiple products / envs coexist); retrieval picks the entry whose
|
|
7
|
+
``api_url`` matches the current target.
|
|
8
|
+
|
|
9
|
+
auth.json shape::
|
|
10
|
+
|
|
11
|
+
{ "version": 1,
|
|
12
|
+
"tokens": {
|
|
13
|
+
"<resource>": { access_token, refresh_token, expires_at, issuer,
|
|
14
|
+
client_id, resource, api_url, token_endpoint } } }
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import stat
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
_CRED_FIELDS = (
|
|
25
|
+
"access_token",
|
|
26
|
+
"refresh_token",
|
|
27
|
+
"expires_at",
|
|
28
|
+
"issuer",
|
|
29
|
+
"client_id",
|
|
30
|
+
"resource",
|
|
31
|
+
"api_url",
|
|
32
|
+
"token_endpoint",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def default_config_dir() -> Path:
|
|
37
|
+
return Path.home() / ".paradigx"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _auth_path() -> Path:
|
|
41
|
+
return default_config_dir() / "auth.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Credentials:
|
|
46
|
+
access_token: str
|
|
47
|
+
refresh_token: str | None = None
|
|
48
|
+
expires_at: int | None = None # unix seconds
|
|
49
|
+
issuer: str | None = None
|
|
50
|
+
client_id: str | None = None
|
|
51
|
+
resource: str | None = None
|
|
52
|
+
api_url: str | None = None
|
|
53
|
+
token_endpoint: str | None = None # OIDC token endpoint, for refresh
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_store() -> dict:
|
|
57
|
+
p = _auth_path()
|
|
58
|
+
if not p.exists():
|
|
59
|
+
return {"version": 1, "tokens": {}}
|
|
60
|
+
try:
|
|
61
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
62
|
+
except (json.JSONDecodeError, OSError):
|
|
63
|
+
return {"version": 1, "tokens": {}}
|
|
64
|
+
if not isinstance(data, dict):
|
|
65
|
+
return {"version": 1, "tokens": {}}
|
|
66
|
+
if not isinstance(data.get("tokens"), dict):
|
|
67
|
+
data["tokens"] = {}
|
|
68
|
+
data.setdefault("version", 1)
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _write_store(store: dict) -> None:
|
|
73
|
+
d = default_config_dir()
|
|
74
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
p = _auth_path()
|
|
76
|
+
p.write_text(json.dumps(store, indent=2), encoding="utf-8")
|
|
77
|
+
# Owner-only on POSIX; near no-op on Windows but doesn't error.
|
|
78
|
+
try:
|
|
79
|
+
os.chmod(p, stat.S_IRUSR | stat.S_IWUSR)
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save_credentials(creds: Credentials) -> Path:
|
|
85
|
+
store = _read_store()
|
|
86
|
+
key = creds.resource or creds.api_url or "default"
|
|
87
|
+
store["tokens"][key] = {f: getattr(creds, f) for f in _CRED_FIELDS}
|
|
88
|
+
_write_store(store)
|
|
89
|
+
return _auth_path()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_credentials(api_url: str) -> Credentials | None:
|
|
93
|
+
"""Return the stored credential whose ``api_url`` matches."""
|
|
94
|
+
store = _read_store()
|
|
95
|
+
matches = [
|
|
96
|
+
v for v in store["tokens"].values() if isinstance(v, dict) and v.get("api_url") == api_url
|
|
97
|
+
]
|
|
98
|
+
if not matches:
|
|
99
|
+
return None
|
|
100
|
+
best = max(matches, key=lambda v: v.get("expires_at") or 0)
|
|
101
|
+
return Credentials(**{f: best.get(f) for f in _CRED_FIELDS})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def clear_credentials(api_url: str) -> bool:
|
|
105
|
+
"""Drop credential(s) for ``api_url``. True if anything was removed."""
|
|
106
|
+
store = _read_store()
|
|
107
|
+
kept = {
|
|
108
|
+
k: v
|
|
109
|
+
for k, v in store["tokens"].items()
|
|
110
|
+
if not (isinstance(v, dict) and v.get("api_url") == api_url)
|
|
111
|
+
}
|
|
112
|
+
if len(kept) == len(store["tokens"]):
|
|
113
|
+
return False
|
|
114
|
+
store["tokens"] = kept
|
|
115
|
+
_write_store(store)
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def import_legacy(
|
|
120
|
+
legacy_path: Path,
|
|
121
|
+
api_url: str,
|
|
122
|
+
*,
|
|
123
|
+
token_endpoint: str | None = None,
|
|
124
|
+
resource: str | None = None,
|
|
125
|
+
) -> bool:
|
|
126
|
+
"""Migrate a pre-Phase-B single-credential file into the shared store.
|
|
127
|
+
|
|
128
|
+
Used by tokenroute 0.2.0 to pull ``~/.tokenroute/credentials.json`` into
|
|
129
|
+
``~/.paradigx/auth.json``. No-op if the legacy file is absent or this
|
|
130
|
+
api_url already has an entry. The legacy file is left in place (so a
|
|
131
|
+
downgrade to 0.1.0 still works).
|
|
132
|
+
|
|
133
|
+
`resource` is a fallback for legacy files that predate the field — it's
|
|
134
|
+
required for the OIDC token endpoint to mint JWT (not opaque) access
|
|
135
|
+
tokens on refresh, so the caller must supply its product's resource
|
|
136
|
+
indicator when the legacy file may lack one.
|
|
137
|
+
"""
|
|
138
|
+
if not legacy_path.exists():
|
|
139
|
+
return False
|
|
140
|
+
if load_credentials(api_url) is not None:
|
|
141
|
+
return False
|
|
142
|
+
try:
|
|
143
|
+
data = json.loads(legacy_path.read_text(encoding="utf-8"))
|
|
144
|
+
except (json.JSONDecodeError, OSError):
|
|
145
|
+
return False
|
|
146
|
+
if not isinstance(data, dict) or not data.get("access_token"):
|
|
147
|
+
return False
|
|
148
|
+
save_credentials(
|
|
149
|
+
Credentials(
|
|
150
|
+
access_token=data.get("access_token", ""),
|
|
151
|
+
refresh_token=data.get("refresh_token"),
|
|
152
|
+
expires_at=data.get("expires_at"),
|
|
153
|
+
issuer=data.get("issuer"),
|
|
154
|
+
client_id=data.get("client_id"),
|
|
155
|
+
resource=data.get("resource") or resource,
|
|
156
|
+
api_url=api_url,
|
|
157
|
+
token_endpoint=token_endpoint,
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
return True
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""OIDC device-flow client — talks directly to Logto.
|
|
2
|
+
|
|
3
|
+
The product CLI passes its discovery URL (a product endpoint that returns
|
|
4
|
+
``{issuer, client_id, resource, scopes, device_authorization_endpoint,
|
|
5
|
+
token_endpoint}``). The CLI never hardcodes a Logto URL. This is the
|
|
6
|
+
OIDC-standard pattern: the client connects to the IdP, the resource server
|
|
7
|
+
only validates tokens.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
import webbrowser
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
# Per RFC 8628 §3.5 we honour the server's `interval`; this is a sane floor.
|
|
18
|
+
_MIN_POLL_INTERVAL_SECONDS = 5
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class DiscoveryInfo:
|
|
23
|
+
issuer: str
|
|
24
|
+
client_id: str
|
|
25
|
+
resource: str
|
|
26
|
+
scopes: list[str]
|
|
27
|
+
device_authorization_endpoint: str
|
|
28
|
+
token_endpoint: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class DeviceCodeResponse:
|
|
33
|
+
device_code: str
|
|
34
|
+
user_code: str
|
|
35
|
+
verification_uri: str
|
|
36
|
+
verification_uri_complete: str
|
|
37
|
+
expires_in: int
|
|
38
|
+
interval: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fetch_discovery(discovery_url: str) -> DiscoveryInfo:
|
|
42
|
+
"""GET the product's auth-discovery endpoint."""
|
|
43
|
+
with httpx.Client(timeout=10.0, follow_redirects=True) as c:
|
|
44
|
+
r = c.get(discovery_url)
|
|
45
|
+
r.raise_for_status()
|
|
46
|
+
data = r.json()
|
|
47
|
+
return DiscoveryInfo(
|
|
48
|
+
issuer=data["issuer"],
|
|
49
|
+
client_id=data["client_id"],
|
|
50
|
+
resource=data["resource"],
|
|
51
|
+
scopes=data["scopes"],
|
|
52
|
+
device_authorization_endpoint=data["device_authorization_endpoint"],
|
|
53
|
+
token_endpoint=data["token_endpoint"],
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def request_device_code(disc: DiscoveryInfo) -> DeviceCodeResponse:
|
|
58
|
+
"""POST {device_authorization_endpoint} → device_code + user_code."""
|
|
59
|
+
with httpx.Client(timeout=10.0) as c:
|
|
60
|
+
r = c.post(
|
|
61
|
+
disc.device_authorization_endpoint,
|
|
62
|
+
data={
|
|
63
|
+
"client_id": disc.client_id,
|
|
64
|
+
"scope": " ".join(disc.scopes),
|
|
65
|
+
"resource": disc.resource,
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
r.raise_for_status()
|
|
69
|
+
body = r.json()
|
|
70
|
+
return DeviceCodeResponse(
|
|
71
|
+
device_code=body["device_code"],
|
|
72
|
+
user_code=body["user_code"],
|
|
73
|
+
verification_uri=body["verification_uri"],
|
|
74
|
+
verification_uri_complete=body.get(
|
|
75
|
+
"verification_uri_complete", body["verification_uri"]
|
|
76
|
+
),
|
|
77
|
+
expires_in=body["expires_in"],
|
|
78
|
+
interval=max(body.get("interval", 5), _MIN_POLL_INTERVAL_SECONDS),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def open_browser(url: str) -> bool:
|
|
83
|
+
"""Best-effort. False on headless environments with no display."""
|
|
84
|
+
try:
|
|
85
|
+
return webbrowser.open(url)
|
|
86
|
+
except webbrowser.Error:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def poll_for_token(disc: DiscoveryInfo, code: DeviceCodeResponse, *, on_pending=None) -> dict:
|
|
91
|
+
"""Poll {token_endpoint} until success / expired / denied.
|
|
92
|
+
|
|
93
|
+
Returns the raw token response dict. Raises RuntimeError with a
|
|
94
|
+
human-readable message on failure.
|
|
95
|
+
"""
|
|
96
|
+
deadline = time.time() + code.expires_in
|
|
97
|
+
interval = code.interval
|
|
98
|
+
with httpx.Client(timeout=10.0) as client:
|
|
99
|
+
while time.time() < deadline:
|
|
100
|
+
time.sleep(interval)
|
|
101
|
+
r = client.post(
|
|
102
|
+
disc.token_endpoint,
|
|
103
|
+
data={
|
|
104
|
+
"client_id": disc.client_id,
|
|
105
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
106
|
+
"device_code": code.device_code,
|
|
107
|
+
"resource": disc.resource,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
if r.is_success:
|
|
111
|
+
return r.json()
|
|
112
|
+
err = (
|
|
113
|
+
r.json().get("error")
|
|
114
|
+
if r.headers.get("content-type", "").startswith("application/json")
|
|
115
|
+
else None
|
|
116
|
+
)
|
|
117
|
+
if err == "authorization_pending":
|
|
118
|
+
if on_pending is not None:
|
|
119
|
+
on_pending()
|
|
120
|
+
continue
|
|
121
|
+
if err == "slow_down":
|
|
122
|
+
interval += 5
|
|
123
|
+
continue
|
|
124
|
+
if err == "expired_token":
|
|
125
|
+
raise RuntimeError("device code expired — log in again")
|
|
126
|
+
if err == "access_denied":
|
|
127
|
+
raise RuntimeError("login denied by user")
|
|
128
|
+
raise RuntimeError(f"token exchange failed ({r.status_code}): {r.text}")
|
|
129
|
+
raise RuntimeError("device code expired before user completed login")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Output helpers — toggle between human-friendly rich tables and --json.
|
|
2
|
+
|
|
3
|
+
JSON mode is process-global, set by the product CLI's Typer callback via
|
|
4
|
+
`set_json_mode()` and read everywhere via `is_json_mode()`.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
_JSON_ENV = "_PARADIGX_CLI_JSON"
|
|
17
|
+
_console = Console()
|
|
18
|
+
_err_console = Console(stderr=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def set_json_mode(enabled: bool) -> None:
|
|
22
|
+
if enabled:
|
|
23
|
+
os.environ[_JSON_ENV] = "1"
|
|
24
|
+
else:
|
|
25
|
+
os.environ.pop(_JSON_ENV, None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_json_mode() -> bool:
|
|
29
|
+
return os.environ.get(_JSON_ENV) == "1"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def emit(payload: Any, *, table_columns: list[tuple[str, str]] | None = None) -> None:
|
|
33
|
+
"""Print `payload` as JSON (agent mode) or a rich table / kv list (human).
|
|
34
|
+
|
|
35
|
+
`table_columns` is a list of `(header, dict_key)` pairs used when payload
|
|
36
|
+
is a list. Single dicts render as a key/value list.
|
|
37
|
+
"""
|
|
38
|
+
if is_json_mode():
|
|
39
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
if isinstance(payload, list) and table_columns:
|
|
43
|
+
table = Table(show_header=True, header_style="bold")
|
|
44
|
+
for header, _ in table_columns:
|
|
45
|
+
table.add_column(header)
|
|
46
|
+
for row in payload:
|
|
47
|
+
table.add_row(*[str(row.get(k, "")) for _, k in table_columns])
|
|
48
|
+
_console.print(table)
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
if isinstance(payload, dict):
|
|
52
|
+
for k, v in payload.items():
|
|
53
|
+
_console.print(f"[bold]{k}[/bold]: {v}")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
_console.print(payload)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def info(msg: str) -> None:
|
|
60
|
+
if not is_json_mode():
|
|
61
|
+
_console.print(msg)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def success(msg: str) -> None:
|
|
65
|
+
if not is_json_mode():
|
|
66
|
+
# ASCII-safe marker — Windows legacy GBK consoles can't encode U+2713.
|
|
67
|
+
_console.print(f"[green]OK[/green] {msg}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def error(msg: str, *, code: int = 1) -> None:
|
|
71
|
+
"""Print an error and exit. code: 1 user / 2 network / 3 server."""
|
|
72
|
+
if is_json_mode():
|
|
73
|
+
print(json.dumps({"error": msg}, indent=2))
|
|
74
|
+
else:
|
|
75
|
+
_err_console.print(f"[red]error[/red]: {msg}")
|
|
76
|
+
sys.exit(code)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paradigx-cli-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared core for Paradigx product CLIs — OAuth device-flow, token cache, HTTP client
|
|
5
|
+
Project-URL: Homepage, https://github.com/jiangjin11/paradigx-workspace
|
|
6
|
+
Author: Paradigx Pte Ltd
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: agent,cli,device-flow,oauth,paradigx
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: httpx>=0.28
|
|
21
|
+
Requires-Dist: rich>=13.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest-mock>=3.14; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.3; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# paradigx-cli-core
|
|
28
|
+
|
|
29
|
+
Shared core for [Paradigx](https://github.com/jiangjin11/paradigx-workspace)
|
|
30
|
+
product CLIs (`botu`, `tokenroute`, ...).
|
|
31
|
+
|
|
32
|
+
This is a **library, not a CLI** — it has no command entry point. Product
|
|
33
|
+
CLIs depend on it and keep only their command layer.
|
|
34
|
+
|
|
35
|
+
## What it provides
|
|
36
|
+
|
|
37
|
+
- **OAuth device-flow** login against Logto (`auth`, `device_flow`)
|
|
38
|
+
- **Shared token cache** at `~/.paradigx/auth.json`, keyed by API URL, so
|
|
39
|
+
one login is reused across every Paradigx CLI (`config`)
|
|
40
|
+
- **Automatic JWT refresh** — cached tokens are transparently renewed via
|
|
41
|
+
the stored refresh token (`auth.valid_access_token`)
|
|
42
|
+
- **HTTP client** with per-product trailing-slash / redirect handling
|
|
43
|
+
(`client`)
|
|
44
|
+
- **`--json` output helpers** — rich tables for humans, JSON for agents
|
|
45
|
+
(`output`)
|
|
46
|
+
|
|
47
|
+
## Usage (by a product CLI)
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from paradigx_cli_core import do_login, valid_access_token, request
|
|
51
|
+
|
|
52
|
+
# login
|
|
53
|
+
do_login("https://botu.io/api/auth/discovery/", "https://botu.io",
|
|
54
|
+
on_code=lambda c: print("visit", c.verification_uri_complete))
|
|
55
|
+
|
|
56
|
+
# authenticated call (auto-refreshes the token)
|
|
57
|
+
token = valid_access_token("https://botu.io")
|
|
58
|
+
sites = request("GET", "https://botu.io", "/api/sites",
|
|
59
|
+
token=token, trailing_slash=True)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
See `docs/specs/cli-phase-b.md` in the workspace repo for the design.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
© 2026 Paradigx. All Rights Reserved.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
paradigx_cli_core/__init__.py,sha256=hICpsmMPWyeVyuksNVRAyeGOBIyj2rjJJOgGbBjvKuM,1508
|
|
2
|
+
paradigx_cli_core/auth.py,sha256=IyuPeinzfva_ktVQcB2gJHphV3gimnzojVUfJ6tPPYM,3974
|
|
3
|
+
paradigx_cli_core/client.py,sha256=Kpmr5dg2Fd9hTntkXy8kTVeEYhIWohaUtz20Q0Wr2Mw,2729
|
|
4
|
+
paradigx_cli_core/config.py,sha256=3qKf9V1N6YDNAOvgtE9PYJeEf1LgKSC91TUdYxCVimk,4894
|
|
5
|
+
paradigx_cli_core/device_flow.py,sha256=2t5lqKS7OuJnoXNoQFL5s94MP6Q1n2EoyAF8p3PT7XQ,4252
|
|
6
|
+
paradigx_cli_core/output.py,sha256=KraSThVAe-fxv7VZhLkdMdLswbditYuSc7pV9OOR-GU,2153
|
|
7
|
+
paradigx_cli_core-0.1.0.dist-info/METADATA,sha256=4CYZtJsNGs4FiigU5RC-Km21DC79HEqqWjapysID-K8,2412
|
|
8
|
+
paradigx_cli_core-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
paradigx_cli_core-0.1.0.dist-info/RECORD,,
|