scriptit-cli 0.2.2__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.
- scriptit_cli/__init__.py +17 -0
- scriptit_cli/analytics.py +244 -0
- scriptit_cli/auth.py +553 -0
- scriptit_cli/client.py +42 -0
- scriptit_cli/commands.py +302 -0
- scriptit_cli/config.py +19 -0
- scriptit_cli/errors.py +21 -0
- scriptit_cli/main.py +184 -0
- scriptit_cli/output.py +79 -0
- scriptit_cli/remote.py +1306 -0
- scriptit_cli/remote_auth.py +464 -0
- scriptit_cli/update_check.py +104 -0
- scriptit_cli/util.py +84 -0
- scriptit_cli-0.2.2.dist-info/METADATA +599 -0
- scriptit_cli-0.2.2.dist-info/RECORD +18 -0
- scriptit_cli-0.2.2.dist-info/WHEEL +4 -0
- scriptit_cli-0.2.2.dist-info/entry_points.txt +2 -0
- scriptit_cli-0.2.2.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""Workstation credentials for the remote CLI (``scriptit auth login``).
|
|
2
|
+
|
|
3
|
+
Storage lives in the config directory: ``$XDG_CONFIG_HOME/scriptit/
|
|
4
|
+
credentials.json`` (else ``~/.config/scriptit/credentials.json``), chmod 600.
|
|
5
|
+
The file holds the platform ``api_url`` plus a Firebase **refresh token** —
|
|
6
|
+
never a password. ID tokens are short-lived (~1h) and refreshed lazily through
|
|
7
|
+
the public securetoken endpoint; the refreshed token is persisted so parallel
|
|
8
|
+
CLI invocations reuse it instead of each spending a refresh round-trip.
|
|
9
|
+
|
|
10
|
+
Profiles let one machine hold credentials for several deployments at once; the
|
|
11
|
+
active one is picked by ``SCRIPTIT_PROFILE`` or ``scriptit auth use``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import secrets
|
|
21
|
+
import time
|
|
22
|
+
from typing import Any, Dict, Optional
|
|
23
|
+
|
|
24
|
+
import requests
|
|
25
|
+
|
|
26
|
+
from scriptit_cli.config import _config_dir
|
|
27
|
+
from scriptit_cli.errors import RemoteAuthError
|
|
28
|
+
from scriptit_cli.output import note
|
|
29
|
+
from scriptit_cli.util import update_json_file
|
|
30
|
+
|
|
31
|
+
CREDENTIALS_FILE = "credentials.json"
|
|
32
|
+
|
|
33
|
+
_IDENTITY_TOOLKIT_URL = "https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken"
|
|
34
|
+
# Google's public token endpoint, not a credential — hence the suppression.
|
|
35
|
+
_SECURE_TOKEN_URL = "https://securetoken.googleapis.com/v1/token" # noqa: S105
|
|
36
|
+
|
|
37
|
+
# Refresh when the ID token has less than this many seconds left — covers
|
|
38
|
+
# clock skew and the request's own flight time.
|
|
39
|
+
_REFRESH_MARGIN_SECONDS = 120
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
ENV_PROFILE = "SCRIPTIT_PROFILE"
|
|
43
|
+
DEFAULT_PROFILE = "default"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def credentials_path() -> str:
|
|
47
|
+
"""Absolute path of the (multi-profile) credential store."""
|
|
48
|
+
return os.path.join(_config_dir(), CREDENTIALS_FILE)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _normalize_store(data: Any) -> Dict[str, Any]:
|
|
52
|
+
"""Coerce whatever is on disk into ``{"profiles": {...}, "current": name}``.
|
|
53
|
+
|
|
54
|
+
A flat pre-profiles blob (top-level ``api_url``) becomes a single
|
|
55
|
+
``default`` profile; the next save persists the new shape.
|
|
56
|
+
"""
|
|
57
|
+
if not isinstance(data, dict):
|
|
58
|
+
return {"profiles": {}, "current": DEFAULT_PROFILE}
|
|
59
|
+
if data.get("api_url"): # flat single-account layout
|
|
60
|
+
return {"profiles": {DEFAULT_PROFILE: data}, "current": DEFAULT_PROFILE}
|
|
61
|
+
profiles = data.get("profiles")
|
|
62
|
+
if not isinstance(profiles, dict):
|
|
63
|
+
profiles = {}
|
|
64
|
+
return {"profiles": profiles, "current": data.get("current") or DEFAULT_PROFILE}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load_store() -> Dict[str, Any]:
|
|
68
|
+
"""The store as it is on disk, normalized."""
|
|
69
|
+
try:
|
|
70
|
+
with open(credentials_path(), encoding="utf-8") as f:
|
|
71
|
+
return _normalize_store(json.load(f))
|
|
72
|
+
except (OSError, ValueError):
|
|
73
|
+
return {"profiles": {}, "current": DEFAULT_PROFILE}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def current_profile_name(profile: Optional[str] = None) -> str:
|
|
77
|
+
"""The profile in effect: explicit arg → ``SCRIPTIT_PROFILE`` → stored
|
|
78
|
+
``current`` → ``default``."""
|
|
79
|
+
if profile:
|
|
80
|
+
return profile
|
|
81
|
+
env = os.environ.get(ENV_PROFILE, "").strip()
|
|
82
|
+
if env:
|
|
83
|
+
return env
|
|
84
|
+
return str(_load_store().get("current") or DEFAULT_PROFILE)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def list_profiles() -> Dict[str, Dict[str, Any]]:
|
|
88
|
+
"""All stored profiles, keyed by name."""
|
|
89
|
+
return dict(_load_store()["profiles"])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_credentials(profile: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
93
|
+
"""The selected profile's credential blob, or ``None`` when not logged in."""
|
|
94
|
+
store = _load_store()
|
|
95
|
+
name = current_profile_name(profile)
|
|
96
|
+
creds = store["profiles"].get(name)
|
|
97
|
+
if isinstance(creds, dict) and creds.get("api_url"):
|
|
98
|
+
creds.setdefault("profile", name)
|
|
99
|
+
return creds
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def save_credentials(creds: Dict[str, Any], profile: Optional[str] = None) -> None:
|
|
104
|
+
"""Store ``creds`` under the profile and make that profile current."""
|
|
105
|
+
name = current_profile_name(profile)
|
|
106
|
+
payload = {k: v for k, v in creds.items() if k != "profile"}
|
|
107
|
+
|
|
108
|
+
def _mutate(store: Dict[str, Any]) -> Dict[str, Any]:
|
|
109
|
+
store = _normalize_store(store)
|
|
110
|
+
store["profiles"][name] = payload
|
|
111
|
+
store["current"] = name
|
|
112
|
+
return store
|
|
113
|
+
|
|
114
|
+
update_json_file(credentials_path(), _mutate)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def merge_credentials(profile: str, fields: Dict[str, Any]) -> bool:
|
|
118
|
+
"""Merge refreshed fields into an existing profile, under the store lock.
|
|
119
|
+
|
|
120
|
+
Deliberately not :func:`save_credentials`, which writes the record whole
|
|
121
|
+
and makes the profile current. That is right for a login and wrong for a
|
|
122
|
+
refresh, in two ways a refresh actually hits:
|
|
123
|
+
|
|
124
|
+
- a refresh under ``SCRIPTIT_PROFILE=b`` would move the default profile
|
|
125
|
+
from ``a`` to ``b``, silently retargeting every later command;
|
|
126
|
+
- a refresh whose network call was in flight during ``auth logout`` would
|
|
127
|
+
write the profile back, undoing the logout.
|
|
128
|
+
|
|
129
|
+
So it merges into whatever is on disk *now*, leaves ``current`` alone, and
|
|
130
|
+
does nothing when the profile is gone. Returns whether it wrote.
|
|
131
|
+
"""
|
|
132
|
+
written = False
|
|
133
|
+
|
|
134
|
+
def _mutate(store: Dict[str, Any]) -> Dict[str, Any]:
|
|
135
|
+
nonlocal written
|
|
136
|
+
store = _normalize_store(store)
|
|
137
|
+
existing = store["profiles"].get(profile)
|
|
138
|
+
if not isinstance(existing, dict):
|
|
139
|
+
return store
|
|
140
|
+
store["profiles"][profile] = {
|
|
141
|
+
**existing,
|
|
142
|
+
**{k: v for k, v in fields.items() if k != "profile"},
|
|
143
|
+
}
|
|
144
|
+
written = True
|
|
145
|
+
return store
|
|
146
|
+
|
|
147
|
+
update_json_file(credentials_path(), _mutate)
|
|
148
|
+
return written
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def set_current_profile(name: str) -> bool:
|
|
152
|
+
"""Switch the default profile; False when it doesn't exist."""
|
|
153
|
+
found = False
|
|
154
|
+
|
|
155
|
+
def _mutate(store: Dict[str, Any]) -> Dict[str, Any]:
|
|
156
|
+
nonlocal found
|
|
157
|
+
store = _normalize_store(store)
|
|
158
|
+
if name in store["profiles"]:
|
|
159
|
+
found = True
|
|
160
|
+
store["current"] = name
|
|
161
|
+
return store
|
|
162
|
+
|
|
163
|
+
update_json_file(credentials_path(), _mutate)
|
|
164
|
+
return found
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def delete_credentials(profile: Optional[str] = None, all_profiles: bool = False) -> bool:
|
|
168
|
+
"""Forget one profile (the current one by default) or every profile."""
|
|
169
|
+
if all_profiles:
|
|
170
|
+
# Emptying under the lock, not unlinking: a concurrent writer holding
|
|
171
|
+
# the lock would otherwise recreate the file right after the delete,
|
|
172
|
+
# and logout would report success over live credentials.
|
|
173
|
+
emptied = False
|
|
174
|
+
|
|
175
|
+
def _clear(store: Dict[str, Any]) -> Dict[str, Any]:
|
|
176
|
+
nonlocal emptied
|
|
177
|
+
emptied = bool(_normalize_store(store)["profiles"])
|
|
178
|
+
return {"profiles": {}, "current": DEFAULT_PROFILE}
|
|
179
|
+
|
|
180
|
+
update_json_file(credentials_path(), _clear)
|
|
181
|
+
return emptied
|
|
182
|
+
name = current_profile_name(profile)
|
|
183
|
+
removed = False
|
|
184
|
+
|
|
185
|
+
def _mutate(store: Dict[str, Any]) -> Dict[str, Any]:
|
|
186
|
+
nonlocal removed
|
|
187
|
+
store = _normalize_store(store)
|
|
188
|
+
if name in store["profiles"]:
|
|
189
|
+
removed = True
|
|
190
|
+
del store["profiles"][name]
|
|
191
|
+
if store.get("current") == name:
|
|
192
|
+
store["current"] = next(iter(store["profiles"]), DEFAULT_PROFILE)
|
|
193
|
+
return store
|
|
194
|
+
|
|
195
|
+
update_json_file(credentials_path(), _mutate)
|
|
196
|
+
return removed
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _google_auth_headers(app_url: Optional[str]) -> Dict[str, str]:
|
|
200
|
+
"""Headers for identitytoolkit/securetoken calls.
|
|
201
|
+
|
|
202
|
+
A deployment's Firebase browser key is HTTP-referrer-restricted to its
|
|
203
|
+
own app origin, and Google rejects referer-less requests against such
|
|
204
|
+
a key outright. The CLI is a first-party client of that key, so it
|
|
205
|
+
presents that origin — the restriction targets third-party *websites*
|
|
206
|
+
embedding the key, where the browser controls the header.
|
|
207
|
+
"""
|
|
208
|
+
return {"Referer": f"{app_url.rstrip('/')}/"} if app_url else {}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _expires_in(value: Any, default: int) -> int:
|
|
212
|
+
"""A lifetime in seconds from a provider field that may be junk.
|
|
213
|
+
|
|
214
|
+
``int(body["expires_in"])`` throws on ``None``, ``"soon"`` or a float
|
|
215
|
+
string — after the token was already minted, so the login would fail with
|
|
216
|
+
a traceback despite having worked.
|
|
217
|
+
"""
|
|
218
|
+
try:
|
|
219
|
+
seconds = int(float(value))
|
|
220
|
+
except (TypeError, ValueError):
|
|
221
|
+
return default
|
|
222
|
+
return seconds if seconds > 0 else default
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _auth_post(url: str, *, what: str, timeout: float = 30, **kwargs: Any) -> Dict[str, Any]:
|
|
226
|
+
"""POST to an identity provider and return its JSON body.
|
|
227
|
+
|
|
228
|
+
Every failure mode arrives as :class:`RemoteAuthError`: a refused
|
|
229
|
+
connection, a DNS failure and a proxy that answers HTML are all "your
|
|
230
|
+
login did not work", and a traceback for any of them reads as a CLI crash.
|
|
231
|
+
The status check stays with each caller, which knows what its own non-200
|
|
232
|
+
means.
|
|
233
|
+
"""
|
|
234
|
+
try:
|
|
235
|
+
resp = requests.post(url, timeout=timeout, **kwargs)
|
|
236
|
+
except requests.RequestException as exc:
|
|
237
|
+
raise RemoteAuthError(f"{what} failed: {exc}") from None
|
|
238
|
+
if resp.status_code != 200:
|
|
239
|
+
raise RemoteAuthError(f"{what} failed ({resp.status_code}): {resp.text[:300]}")
|
|
240
|
+
try:
|
|
241
|
+
body = resp.json()
|
|
242
|
+
except ValueError:
|
|
243
|
+
raise RemoteAuthError(
|
|
244
|
+
f"{what} returned a non-JSON response ({resp.status_code}): {resp.text[:200]}"
|
|
245
|
+
) from None
|
|
246
|
+
if not isinstance(body, dict):
|
|
247
|
+
raise RemoteAuthError(f"{what} returned an unexpected response shape")
|
|
248
|
+
return body
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _auth_field(body: Dict[str, Any], key: str, what: str) -> Any:
|
|
252
|
+
"""A field the provider promised, or a readable error instead of KeyError."""
|
|
253
|
+
if key not in body:
|
|
254
|
+
raise RemoteAuthError(f"{what} response is missing {key!r}")
|
|
255
|
+
return body[key]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def exchange_custom_token(
|
|
259
|
+
api_key: str, custom_token: str, app_url: Optional[str] = None
|
|
260
|
+
) -> Dict[str, Any]:
|
|
261
|
+
"""Exchange a backend-minted Firebase custom token for ID + refresh tokens."""
|
|
262
|
+
what = "custom-token exchange"
|
|
263
|
+
body = _auth_post(
|
|
264
|
+
_IDENTITY_TOOLKIT_URL,
|
|
265
|
+
what=what,
|
|
266
|
+
params={"key": api_key},
|
|
267
|
+
json={"token": custom_token, "returnSecureToken": True},
|
|
268
|
+
headers=_google_auth_headers(app_url),
|
|
269
|
+
timeout=30,
|
|
270
|
+
)
|
|
271
|
+
return {
|
|
272
|
+
"id_token": _auth_field(body, "idToken", what),
|
|
273
|
+
"refresh_token": _auth_field(body, "refreshToken", what),
|
|
274
|
+
"id_token_expires_at": time.time() + _expires_in(body.get("expiresIn"), 3600),
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _refresh_id_token(
|
|
279
|
+
api_key: str, refresh_token: str, app_url: Optional[str] = None
|
|
280
|
+
) -> Dict[str, Any]:
|
|
281
|
+
what = "session refresh (run `scriptit auth login` again)"
|
|
282
|
+
body = _auth_post(
|
|
283
|
+
_SECURE_TOKEN_URL,
|
|
284
|
+
what=what,
|
|
285
|
+
params={"key": api_key},
|
|
286
|
+
data={"grant_type": "refresh_token", "refresh_token": refresh_token},
|
|
287
|
+
headers=_google_auth_headers(app_url),
|
|
288
|
+
timeout=30,
|
|
289
|
+
)
|
|
290
|
+
return {
|
|
291
|
+
"id_token": _auth_field(body, "id_token", what),
|
|
292
|
+
"refresh_token": body.get("refresh_token") or refresh_token,
|
|
293
|
+
"id_token_expires_at": time.time() + _expires_in(body.get("expires_in"), 3600),
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _refresh_keycloak_token(creds: Dict[str, Any]) -> Dict[str, Any]:
|
|
298
|
+
body = _auth_post(
|
|
299
|
+
str(creds["token_url"]),
|
|
300
|
+
what="session refresh (run `scriptit auth login` again)",
|
|
301
|
+
data={
|
|
302
|
+
"grant_type": "refresh_token",
|
|
303
|
+
"client_id": str(creds["client_id"]),
|
|
304
|
+
"refresh_token": str(creds["refresh_token"]),
|
|
305
|
+
},
|
|
306
|
+
timeout=30,
|
|
307
|
+
)
|
|
308
|
+
# Same choice as login: the backend validates `aud == client_id`, which
|
|
309
|
+
# only Keycloak's ID token carries. Refreshing into the access token
|
|
310
|
+
# would swap a working credential for a rejected one at expiry.
|
|
311
|
+
return {
|
|
312
|
+
"id_token": _bearer_from(body),
|
|
313
|
+
"refresh_token": body.get("refresh_token") or creds["refresh_token"],
|
|
314
|
+
"id_token_expires_at": time.time() + _expires_in(body.get("expires_in"), 300),
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def get_fresh_id_token(creds: Optional[Dict[str, Any]] = None) -> str:
|
|
319
|
+
"""A currently-valid bearer token, refreshing and persisting when needed.
|
|
320
|
+
|
|
321
|
+
Firebase mode refreshes via securetoken; Keycloak mode via the realm's
|
|
322
|
+
token endpoint (stored as ``token_url`` at login).
|
|
323
|
+
"""
|
|
324
|
+
creds = creds or load_credentials()
|
|
325
|
+
if not creds:
|
|
326
|
+
raise RemoteAuthError("not logged in — run `scriptit auth login`")
|
|
327
|
+
expires_at = float(creds.get("id_token_expires_at") or 0)
|
|
328
|
+
if creds.get("id_token") and expires_at - time.time() > _REFRESH_MARGIN_SECONDS:
|
|
329
|
+
return str(creds["id_token"])
|
|
330
|
+
try:
|
|
331
|
+
if creds.get("mode") == "keycloak":
|
|
332
|
+
refreshed = _refresh_keycloak_token(creds)
|
|
333
|
+
else:
|
|
334
|
+
refreshed = _refresh_id_token(
|
|
335
|
+
str(creds["firebase_api_key"]),
|
|
336
|
+
str(creds["refresh_token"]),
|
|
337
|
+
app_url=str(creds["app_url"]) if creds.get("app_url") else None,
|
|
338
|
+
)
|
|
339
|
+
except KeyError as exc:
|
|
340
|
+
# An interrupted or hand-edited store is missing what a refresh needs.
|
|
341
|
+
# Say so; a traceback here reads as a CLI crash, not a stale login.
|
|
342
|
+
raise RemoteAuthError(
|
|
343
|
+
f"stored credentials are incomplete (no {exc.args[0]}) — "
|
|
344
|
+
"run `scriptit auth login` to reconnect this machine"
|
|
345
|
+
) from None
|
|
346
|
+
creds.update(refreshed)
|
|
347
|
+
# Persist into the profile the creds were loaded from, which is not
|
|
348
|
+
# necessarily the store's current profile (SCRIPTIT_PROFILE, --profile).
|
|
349
|
+
# Only the refreshed fields, merged: this command already holds a valid
|
|
350
|
+
# token in memory either way, so a logout that landed mid-refresh must
|
|
351
|
+
# win on disk rather than being written back.
|
|
352
|
+
merge_credentials(str(creds.get("profile") or current_profile_name()), refreshed)
|
|
353
|
+
return str(creds["id_token"])
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
# Keycloak device authorization grant (RFC 8628)
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _bearer_from(body: Dict[str, Any]) -> str:
|
|
362
|
+
"""The token to present to the backend, ID token first.
|
|
363
|
+
|
|
364
|
+
The backend validates ``aud == KEYCLOAK_CLIENT_ID`` (the same check the
|
|
365
|
+
browser app satisfies by sending Keycloak's ID token). A Keycloak access
|
|
366
|
+
token is audienced at ``account`` unless the realm adds an audience
|
|
367
|
+
mapper, so sending one would be rejected after the user had already
|
|
368
|
+
approved. ``scope=openid`` on the device request is what makes the ID
|
|
369
|
+
token present; fall back to the access token for a realm that withholds
|
|
370
|
+
it rather than failing the login outright.
|
|
371
|
+
"""
|
|
372
|
+
return str(body.get("id_token") or body["access_token"])
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _pkce_pair() -> tuple[str, str]:
|
|
376
|
+
"""A (verifier, S256 challenge) pair, base64url without padding per RFC 7636."""
|
|
377
|
+
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
|
|
378
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
379
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
|
380
|
+
return verifier, challenge
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def keycloak_device_login(
|
|
384
|
+
keycloak_url: str, realm: str, client_id: str, timeout: int = 300
|
|
385
|
+
) -> Dict[str, Any]:
|
|
386
|
+
"""Run the OAuth device grant against a Keycloak realm; returns the
|
|
387
|
+
credential fields to store (token_url/client_id/refresh_token/...).
|
|
388
|
+
|
|
389
|
+
Requires the realm client to exist with the device grant enabled;
|
|
390
|
+
Keycloak reports ``invalid_client`` or ``unauthorized_client`` otherwise,
|
|
391
|
+
which is surfaced verbatim.
|
|
392
|
+
|
|
393
|
+
PKCE rides along because both realm clients the platform ships pin
|
|
394
|
+
``pkce.code.challenge.method``: Keycloak enforces that on the device
|
|
395
|
+
endpoint too and rejects a challenge-less request with ``Missing
|
|
396
|
+
parameter: code_challenge_method``. Sending it is correct for a public
|
|
397
|
+
client regardless of whether the realm demands it.
|
|
398
|
+
"""
|
|
399
|
+
base = f"{keycloak_url.rstrip('/')}/realms/{realm}/protocol/openid-connect"
|
|
400
|
+
verifier, challenge = _pkce_pair()
|
|
401
|
+
resp = requests.post(
|
|
402
|
+
f"{base}/auth/device",
|
|
403
|
+
data={
|
|
404
|
+
"client_id": client_id,
|
|
405
|
+
"scope": "openid",
|
|
406
|
+
"code_challenge": challenge,
|
|
407
|
+
"code_challenge_method": "S256",
|
|
408
|
+
},
|
|
409
|
+
timeout=30,
|
|
410
|
+
)
|
|
411
|
+
if resp.status_code != 200:
|
|
412
|
+
raise RemoteAuthError(
|
|
413
|
+
f"device authorization failed ({resp.status_code}): {resp.text[:300]}"
|
|
414
|
+
)
|
|
415
|
+
grant = resp.json()
|
|
416
|
+
verification = grant.get("verification_uri_complete") or grant.get("verification_uri")
|
|
417
|
+
# `note`, not `print`: under --json stdout is reserved for the one result
|
|
418
|
+
# document, and progress that landed there would corrupt it.
|
|
419
|
+
note("To log in, open this URL and approve the request:")
|
|
420
|
+
note(f" {verification}")
|
|
421
|
+
if not grant.get("verification_uri_complete"):
|
|
422
|
+
note(f" code: {grant.get('user_code')}")
|
|
423
|
+
|
|
424
|
+
interval = int(grant.get("interval") or 5)
|
|
425
|
+
deadline = time.time() + min(timeout, int(grant.get("expires_in") or timeout))
|
|
426
|
+
while time.time() < deadline:
|
|
427
|
+
time.sleep(interval)
|
|
428
|
+
token_resp = requests.post(
|
|
429
|
+
f"{base}/token",
|
|
430
|
+
data={
|
|
431
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
432
|
+
"client_id": client_id,
|
|
433
|
+
"device_code": grant["device_code"],
|
|
434
|
+
"code_verifier": verifier,
|
|
435
|
+
},
|
|
436
|
+
timeout=30,
|
|
437
|
+
)
|
|
438
|
+
if token_resp.status_code == 200:
|
|
439
|
+
body = token_resp.json()
|
|
440
|
+
return {
|
|
441
|
+
"mode": "keycloak",
|
|
442
|
+
"token_url": f"{base}/token",
|
|
443
|
+
"client_id": client_id,
|
|
444
|
+
"id_token": _bearer_from(body),
|
|
445
|
+
"refresh_token": body["refresh_token"],
|
|
446
|
+
"id_token_expires_at": time.time() + int(body.get("expires_in", 300)),
|
|
447
|
+
}
|
|
448
|
+
error: Dict[str, Any] = {}
|
|
449
|
+
try:
|
|
450
|
+
body = token_resp.json()
|
|
451
|
+
if isinstance(body, dict):
|
|
452
|
+
error = body
|
|
453
|
+
except ValueError:
|
|
454
|
+
pass
|
|
455
|
+
code = error.get("error")
|
|
456
|
+
if code == "authorization_pending":
|
|
457
|
+
continue
|
|
458
|
+
if code == "slow_down":
|
|
459
|
+
interval += 5
|
|
460
|
+
continue
|
|
461
|
+
raise RemoteAuthError(
|
|
462
|
+
f"device login failed ({token_resp.status_code}): {token_resp.text[:300]}"
|
|
463
|
+
)
|
|
464
|
+
raise RemoteAuthError(f"device login timed out after {timeout}s")
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""A once-a-day "newer version available" notice.
|
|
2
|
+
|
|
3
|
+
Deliberately not self-update: a CLI that rewrites itself surprises people and
|
|
4
|
+
fights whatever package manager installed it. The notice tells you; `pip
|
|
5
|
+
install -U` is yours to run.
|
|
6
|
+
|
|
7
|
+
Nothing here can delay a command. The version that gets compared is whatever
|
|
8
|
+
the last run cached, so the check on the request path is a file read; the
|
|
9
|
+
refresh happens on a daemon thread whose result is only ever used next time.
|
|
10
|
+
Set ``SCRIPTIT_NO_UPDATE_CHECK=1`` to silence it entirely.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from typing import Optional, Tuple
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
from scriptit_cli import __version__
|
|
25
|
+
from scriptit_cli.config import _config_dir
|
|
26
|
+
from scriptit_cli.util import parse_version, update_json_file
|
|
27
|
+
|
|
28
|
+
CACHE_FILE = "update-check.json"
|
|
29
|
+
PYPI_URL = "https://pypi.org/pypi/scriptit-cli/json"
|
|
30
|
+
CHECK_INTERVAL_SECONDS = 24 * 60 * 60
|
|
31
|
+
_FETCH_TIMEOUT_SECONDS = 3.0
|
|
32
|
+
ENV_DISABLE = "SCRIPTIT_NO_UPDATE_CHECK"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _cache_path() -> str:
|
|
36
|
+
return os.path.join(_config_dir(), CACHE_FILE)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_cache() -> Tuple[float, Optional[str]]:
|
|
40
|
+
try:
|
|
41
|
+
with open(_cache_path(), encoding="utf-8") as f:
|
|
42
|
+
data = json.load(f)
|
|
43
|
+
# This runs before every command, so nothing in here may be the thing
|
|
44
|
+
# that fails one. The container and both fields are checked
|
|
45
|
+
# separately: a well-formed object with `"latest": 123` passes an
|
|
46
|
+
# isinstance check on the object and then raises inside the version
|
|
47
|
+
# comparison instead.
|
|
48
|
+
if not isinstance(data, dict):
|
|
49
|
+
return 0.0, None
|
|
50
|
+
latest = data.get("latest")
|
|
51
|
+
if not isinstance(latest, str):
|
|
52
|
+
latest = None
|
|
53
|
+
return float(data.get("checked_at") or 0), latest
|
|
54
|
+
except (OSError, ValueError, TypeError):
|
|
55
|
+
return 0.0, None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _write_cache(latest: Optional[str]) -> None:
|
|
59
|
+
# A read-only or full config dir must not break the command.
|
|
60
|
+
with contextlib.suppress(OSError):
|
|
61
|
+
update_json_file(
|
|
62
|
+
_cache_path(),
|
|
63
|
+
lambda _cache: {"checked_at": time.time(), "latest": latest},
|
|
64
|
+
mode=0o644,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_newer(latest: Optional[str], current: str = __version__) -> bool:
|
|
69
|
+
"""True when ``latest`` is a strictly higher release than ``current``.
|
|
70
|
+
|
|
71
|
+
PEP 440 ordering, so a prerelease does not read as its own final release
|
|
72
|
+
(`0.2.0rc1` must not silence the notice for `0.2.0`) and a post-release or
|
|
73
|
+
epoch is ranked as its author intended.
|
|
74
|
+
"""
|
|
75
|
+
a, b = parse_version(latest or ""), parse_version(current)
|
|
76
|
+
return bool(a and b and a > b)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _fetch_latest() -> None:
|
|
80
|
+
try:
|
|
81
|
+
resp = requests.get(PYPI_URL, timeout=_FETCH_TIMEOUT_SECONDS)
|
|
82
|
+
if resp.status_code != 200:
|
|
83
|
+
# Record the attempt so an unpublished or unreachable index is not
|
|
84
|
+
# re-fetched on every invocation.
|
|
85
|
+
_write_cache(None)
|
|
86
|
+
return
|
|
87
|
+
_write_cache(resp.json().get("info", {}).get("version"))
|
|
88
|
+
except Exception:
|
|
89
|
+
_write_cache(None)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def check(now: Optional[float] = None) -> Optional[str]:
|
|
93
|
+
"""The notice to print, if any, and schedule a refresh when stale."""
|
|
94
|
+
if os.environ.get(ENV_DISABLE, "").strip():
|
|
95
|
+
return None
|
|
96
|
+
checked_at, latest = _read_cache()
|
|
97
|
+
if (now or time.time()) - checked_at > CHECK_INTERVAL_SECONDS:
|
|
98
|
+
threading.Thread(target=_fetch_latest, daemon=True).start()
|
|
99
|
+
if not is_newer(latest):
|
|
100
|
+
return None
|
|
101
|
+
return (
|
|
102
|
+
f"scriptit-cli {latest} is available (you have {__version__}) — "
|
|
103
|
+
"upgrade with `pip install -U scriptit-cli`"
|
|
104
|
+
)
|
scriptit_cli/util.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Shared helpers: version parsing, and safe updates to the JSON stores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
from typing import Any, Callable, Dict, Optional
|
|
10
|
+
|
|
11
|
+
from filelock import FileLock
|
|
12
|
+
from packaging.version import InvalidVersion, Version
|
|
13
|
+
|
|
14
|
+
# How long to wait for another process to finish its read-modify-write. The
|
|
15
|
+
# lock is only ever held across a file read, a dict update and an atomic
|
|
16
|
+
# rename, so anything approaching this means a wedged process rather than
|
|
17
|
+
# contention — better to fail loudly than to write without the lock and
|
|
18
|
+
# silently drop the other writer's changes.
|
|
19
|
+
_LOCK_TIMEOUT_SECONDS = 10
|
|
20
|
+
|
|
21
|
+
# A dotted release, optionally epoch-prefixed, plus whatever suffix follows
|
|
22
|
+
# (`rc1`, `.post2`, `+local`). At least one dot is required so a bare number
|
|
23
|
+
# in surrounding prose ("Python 3 ...") is not read as a version.
|
|
24
|
+
_VERSION_CANDIDATE = re.compile(r"(?:\d+!)?\d+(?:\.\d+)+[^\s]*")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_version(text: str) -> Optional[Version]:
|
|
28
|
+
"""The first PEP 440 version in some text, or None if there isn't one.
|
|
29
|
+
|
|
30
|
+
Version output can be an error string (`command not found`) — a missing
|
|
31
|
+
answer, not an old one, so it must not be read as a version. It can also
|
|
32
|
+
be a whole line (`scriptit 0.2.0`), so a candidate is extracted before
|
|
33
|
+
parsing.
|
|
34
|
+
|
|
35
|
+
:class:`~packaging.version.Version` rather than a hand-rolled
|
|
36
|
+
``(major, minor, patch)`` tuple: that shape silently truncates everything
|
|
37
|
+
PEP 440 puts after the release segment, so `1.2.3rc1` compares *equal* to
|
|
38
|
+
`1.2.3` and an epoch (`1!1.0.0`, which outranks every version without one)
|
|
39
|
+
compares as `1.0.0`.
|
|
40
|
+
"""
|
|
41
|
+
for candidate in _VERSION_CANDIDATE.findall(text or ""):
|
|
42
|
+
try:
|
|
43
|
+
return Version(candidate)
|
|
44
|
+
except InvalidVersion:
|
|
45
|
+
continue
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def update_json_file(
|
|
50
|
+
path: str, mutate: Callable[[Dict[str, Any]], Dict[str, Any]], mode: int = 0o600
|
|
51
|
+
) -> Dict[str, Any]:
|
|
52
|
+
"""Read a JSON object, apply ``mutate``, write it back — atomically, and
|
|
53
|
+
serialized against other processes doing the same.
|
|
54
|
+
|
|
55
|
+
Both stores are read-modify-write, and two CLI invocations overlap
|
|
56
|
+
routinely (an agent firing commands, a shell in another terminal). Without
|
|
57
|
+
the lock the writes do not corrupt the file, but the later one silently
|
|
58
|
+
drops whatever the earlier one added — a rotated refresh token, another
|
|
59
|
+
profile, the session another shell just chose.
|
|
60
|
+
|
|
61
|
+
``filelock`` rather than ``fcntl.flock`` so that holds on Windows too: it
|
|
62
|
+
uses the platform's own primitive (``msvcrt.locking`` there, ``flock``
|
|
63
|
+
here), and both are released by the OS when a process dies, so a crash
|
|
64
|
+
cannot wedge the store.
|
|
65
|
+
"""
|
|
66
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
67
|
+
with FileLock(f"{path}.lock", timeout=_LOCK_TIMEOUT_SECONDS):
|
|
68
|
+
try:
|
|
69
|
+
with open(path, encoding="utf-8") as f:
|
|
70
|
+
data = json.load(f)
|
|
71
|
+
if not isinstance(data, dict):
|
|
72
|
+
data = {}
|
|
73
|
+
except (OSError, ValueError):
|
|
74
|
+
data = {}
|
|
75
|
+
|
|
76
|
+
data = mutate(data)
|
|
77
|
+
|
|
78
|
+
tmp = f"{path}.{os.getpid()}.{secrets.token_hex(4)}.tmp"
|
|
79
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
|
|
80
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
81
|
+
json.dump(data, f, indent=2)
|
|
82
|
+
os.replace(tmp, path)
|
|
83
|
+
os.chmod(path, mode)
|
|
84
|
+
return data
|