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.
@@ -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