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
scriptit_cli/auth.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""``scriptit auth`` — connect the workstation CLI to the Script.it platform.
|
|
2
|
+
|
|
3
|
+
Login on Script.it Cloud is a browser approval. The CLI keeps a random
|
|
4
|
+
verifier and opens — or, where it cannot, prints —
|
|
5
|
+
``/app/cli-auth?challenge=<sha256(verifier)>``. The signed-in page approves
|
|
6
|
+
that digest, and the CLI polls the backend with the verifier until the
|
|
7
|
+
approval lands, receiving a backend-minted Firebase custom token that it
|
|
8
|
+
exchanges for an ID + refresh token pair via the public identitytoolkit
|
|
9
|
+
endpoint. The stored record is that pair plus what a refresh needs (the API
|
|
10
|
+
key, the deployment's URLs, the expiry) — no password ever touches the CLI, and
|
|
11
|
+
nothing in it is reusable as one.
|
|
12
|
+
|
|
13
|
+
Only the digest leaves this process, so nothing printed, and nothing a
|
|
14
|
+
transcript carrying the URL holds, can be redeemed by anyone else; and the
|
|
15
|
+
browser never has to reach this machine, so one flow serves a laptop, an SSH
|
|
16
|
+
host and a hosted agent's container alike. Self-hosted deployments sign in
|
|
17
|
+
through their Keycloak realm's device grant instead.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import contextlib
|
|
23
|
+
import hashlib
|
|
24
|
+
import os
|
|
25
|
+
import secrets
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
import webbrowser
|
|
29
|
+
from typing import Any, Dict, Optional
|
|
30
|
+
|
|
31
|
+
import requests
|
|
32
|
+
|
|
33
|
+
from scriptit_cli.analytics import analytics
|
|
34
|
+
from scriptit_cli.client import detect_client
|
|
35
|
+
from scriptit_cli.config import _config_dir
|
|
36
|
+
from scriptit_cli.output import emit, fail, note, warn
|
|
37
|
+
from scriptit_cli.remote_auth import (
|
|
38
|
+
RemoteAuthError,
|
|
39
|
+
credentials_path,
|
|
40
|
+
current_profile_name,
|
|
41
|
+
delete_credentials,
|
|
42
|
+
exchange_custom_token,
|
|
43
|
+
get_fresh_id_token,
|
|
44
|
+
keycloak_device_login,
|
|
45
|
+
list_profiles,
|
|
46
|
+
load_credentials,
|
|
47
|
+
save_credentials,
|
|
48
|
+
set_current_profile,
|
|
49
|
+
)
|
|
50
|
+
from scriptit_cli.util import update_json_file
|
|
51
|
+
|
|
52
|
+
DEFAULT_API_URL = "https://api.script.it"
|
|
53
|
+
LOGIN_TIMEOUT_SECONDS = 300
|
|
54
|
+
|
|
55
|
+
PENDING_LOGIN_FILE = "pending-login.json"
|
|
56
|
+
|
|
57
|
+
# How often a waiting CLI asks whether its login was approved. Each poll is one
|
|
58
|
+
# indexed DELETE on the backend, and nobody reading a code off a screen can
|
|
59
|
+
# tell this apart from anything faster.
|
|
60
|
+
_APPROVAL_POLL_SECONDS = 2
|
|
61
|
+
|
|
62
|
+
# The longest one poll may take, and the least it is given: a poll gets only
|
|
63
|
+
# what is left of the wait, so one that stalls cannot hold the command past
|
|
64
|
+
# its timeout, and the floor still lets a zero timeout check once.
|
|
65
|
+
_REDEEM_TIMEOUT_SECONDS = 30
|
|
66
|
+
_REDEEM_MIN_TIMEOUT_SECONDS = 1
|
|
67
|
+
|
|
68
|
+
# How long a printed approval link stays resumable. A hosted agent's harness
|
|
69
|
+
# may time out the waiting command; running it again inside this window shows
|
|
70
|
+
# the same link and collects an approval already given, rather than asking the
|
|
71
|
+
# user to approve a second one.
|
|
72
|
+
_PENDING_LOGIN_MAX_AGE_SECONDS = 1800
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _fetch_auth_config(api_url: str) -> Dict[str, Any]:
|
|
76
|
+
resp = requests.get(f"{api_url}/api/v1/auth/config", timeout=30)
|
|
77
|
+
if resp.status_code != 200:
|
|
78
|
+
raise RemoteAuthError(f"could not reach {api_url}/api/v1/auth/config ({resp.status_code})")
|
|
79
|
+
return resp.json()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _should_open_browser(no_browser: bool) -> bool:
|
|
83
|
+
"""Whether to pop a browser window for the login URL.
|
|
84
|
+
|
|
85
|
+
An agent harness has its own browser and cannot see the one
|
|
86
|
+
``webbrowser.open`` launches, so opening it puts a login page in front of
|
|
87
|
+
the human instead of the agent — while the agent, which is the caller,
|
|
88
|
+
learns nothing. :func:`detect_client` already knows when a harness is
|
|
89
|
+
driving (it reads the same markers used for session attribution), so the
|
|
90
|
+
URL is simply printed there and whoever is driving decides what to do
|
|
91
|
+
with it.
|
|
92
|
+
"""
|
|
93
|
+
return not no_browser and detect_client() is None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _pending_login_path() -> str:
|
|
97
|
+
return os.path.join(_config_dir(), PENDING_LOGIN_FILE)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_resumable(entry: Any, now: float) -> bool:
|
|
101
|
+
if not isinstance(entry, dict) or not entry.get("verifier"):
|
|
102
|
+
return False
|
|
103
|
+
try:
|
|
104
|
+
return now - float(entry.get("created_at")) < _PENDING_LOGIN_MAX_AGE_SECONDS
|
|
105
|
+
except (TypeError, ValueError):
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _take_verifier(key: str) -> str:
|
|
110
|
+
"""This login's pending verifier if it is still resumable, else a new one.
|
|
111
|
+
|
|
112
|
+
Kept beside the credential store, 0600, until the approval is collected or
|
|
113
|
+
refused. It is a secret for as long as it lives: whoever holds it collects
|
|
114
|
+
any approval of its challenge.
|
|
115
|
+
"""
|
|
116
|
+
now = time.time()
|
|
117
|
+
taken: Dict[str, str] = {}
|
|
118
|
+
|
|
119
|
+
def _mutate(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
120
|
+
live = {k: v for k, v in data.items() if _is_resumable(v, now)}
|
|
121
|
+
if key in live:
|
|
122
|
+
taken["verifier"] = str(live[key]["verifier"])
|
|
123
|
+
else:
|
|
124
|
+
taken["verifier"] = secrets.token_urlsafe(32)
|
|
125
|
+
live[key] = {"verifier": taken["verifier"], "created_at": now}
|
|
126
|
+
return live
|
|
127
|
+
|
|
128
|
+
update_json_file(_pending_login_path(), _mutate)
|
|
129
|
+
return taken["verifier"]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _forget_verifier(key: str) -> None:
|
|
133
|
+
update_json_file(
|
|
134
|
+
_pending_login_path(), lambda data: {k: v for k, v in data.items() if k != key}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _response_detail(resp: requests.Response) -> str:
|
|
139
|
+
with contextlib.suppress(ValueError, KeyError, TypeError):
|
|
140
|
+
return str(resp.json()["detail"])
|
|
141
|
+
return resp.text[:300]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _approval_refused(status: int, detail: str) -> RemoteAuthError:
|
|
145
|
+
if status in (401, 404, 405):
|
|
146
|
+
# A backend without the redeem route answers the unauthenticated poll
|
|
147
|
+
# from its auth middleware, before routing.
|
|
148
|
+
return RemoteAuthError(
|
|
149
|
+
"this Script.it deployment does not support browser-approved login — "
|
|
150
|
+
"sign in to it with an earlier scriptit-cli release"
|
|
151
|
+
)
|
|
152
|
+
if status == 403:
|
|
153
|
+
return RemoteAuthError("the approving account is suspended or deleted")
|
|
154
|
+
return RemoteAuthError(f"login approval failed ({status}): {detail}")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _receive_via_approval(
|
|
158
|
+
api_url: str,
|
|
159
|
+
login_url_base: str,
|
|
160
|
+
pending_key: str,
|
|
161
|
+
timeout: int,
|
|
162
|
+
open_browser: bool = False,
|
|
163
|
+
) -> Dict[str, Any]:
|
|
164
|
+
"""Show an approval link, then poll until a signed-in browser approves it.
|
|
165
|
+
|
|
166
|
+
Only ``sha256(verifier)`` leaves this process. The verifier is forgotten
|
|
167
|
+
once the backend answers for it either way — collected, or refused — and
|
|
168
|
+
kept on a timeout, so running the command again resumes the same link.
|
|
169
|
+
"""
|
|
170
|
+
verifier = _take_verifier(pending_key)
|
|
171
|
+
challenge = hashlib.sha256(verifier.encode("utf-8")).hexdigest()
|
|
172
|
+
url = f"{login_url_base}?challenge={challenge}"
|
|
173
|
+
# `note` flushes: agents and scripts read this through pipes, and the URL
|
|
174
|
+
# must be visible before the wait begins.
|
|
175
|
+
if open_browser:
|
|
176
|
+
note("Opening your browser to approve this login…")
|
|
177
|
+
note(f" {url}")
|
|
178
|
+
note("(If the browser doesn't open, open the URL yourself.)")
|
|
179
|
+
# A headless or misconfigured machine simply doesn't open one; the URL
|
|
180
|
+
# is already printed above, which is the fallback.
|
|
181
|
+
with contextlib.suppress(Exception):
|
|
182
|
+
webbrowser.open(url)
|
|
183
|
+
else:
|
|
184
|
+
note("Open this URL in a browser where you're signed in to Script.it:")
|
|
185
|
+
note(f" {url}")
|
|
186
|
+
note(f"Approve it if the page shows the code {challenge[:4].upper()}-{challenge[4:8].upper()}.")
|
|
187
|
+
note("If this command is interrupted, run it again: it resumes this same login.")
|
|
188
|
+
|
|
189
|
+
deadline = time.monotonic() + timeout
|
|
190
|
+
# Failures that leave a given approval where it is: the connection
|
|
191
|
+
# dropping, and the server answering 5xx or 429 (a deploy rolling, a cold
|
|
192
|
+
# start, a failed mint, which the backend runs before spending the
|
|
193
|
+
# approval). The wait rides them out, saying once per new failure why it
|
|
194
|
+
# is still waiting.
|
|
195
|
+
last_error: Optional[str] = None
|
|
196
|
+
while True:
|
|
197
|
+
error: Optional[str] = None
|
|
198
|
+
remaining = deadline - time.monotonic()
|
|
199
|
+
try:
|
|
200
|
+
resp = requests.post(
|
|
201
|
+
f"{api_url}/api/v1/auth/cli/redeem",
|
|
202
|
+
json={"verifier": verifier},
|
|
203
|
+
timeout=min(_REDEEM_TIMEOUT_SECONDS, max(remaining, _REDEEM_MIN_TIMEOUT_SECONDS)),
|
|
204
|
+
)
|
|
205
|
+
except requests.RequestException as exc:
|
|
206
|
+
error = str(exc)
|
|
207
|
+
else:
|
|
208
|
+
detail = _response_detail(resp)
|
|
209
|
+
if resp.status_code == 200:
|
|
210
|
+
_forget_verifier(pending_key)
|
|
211
|
+
with contextlib.suppress(ValueError):
|
|
212
|
+
body = resp.json()
|
|
213
|
+
if (
|
|
214
|
+
isinstance(body, dict)
|
|
215
|
+
and body.get("custom_token")
|
|
216
|
+
and body.get("firebase_api_key")
|
|
217
|
+
):
|
|
218
|
+
return body
|
|
219
|
+
raise RemoteAuthError("login approval returned an unexpected response")
|
|
220
|
+
if resp.status_code == 429 or resp.status_code >= 500:
|
|
221
|
+
error = f"{resp.status_code} {detail}"
|
|
222
|
+
elif not (resp.status_code == 400 and detail == "authorization_pending"):
|
|
223
|
+
_forget_verifier(pending_key)
|
|
224
|
+
raise _approval_refused(resp.status_code, detail)
|
|
225
|
+
if error and error != last_error:
|
|
226
|
+
note(f"Still waiting — Script.it could not answer ({error}); retrying.")
|
|
227
|
+
last_error = error
|
|
228
|
+
remaining = deadline - time.monotonic()
|
|
229
|
+
if remaining <= 0:
|
|
230
|
+
break
|
|
231
|
+
time.sleep(min(_APPROVAL_POLL_SECONDS, remaining))
|
|
232
|
+
|
|
233
|
+
reason = f" (last error: {last_error})" if last_error else ""
|
|
234
|
+
raise RemoteAuthError(
|
|
235
|
+
f"not approved within {timeout}s{reason} — run the same command again to keep "
|
|
236
|
+
"waiting on this link"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _verify_stored_login(api_url: str, creds: Dict[str, Any], profile: Optional[str]) -> None:
|
|
241
|
+
"""Confirm the credential we just stored is one the backend accepts.
|
|
242
|
+
|
|
243
|
+
Both login modes end here: a token that mints cleanly can still be
|
|
244
|
+
rejected — a Keycloak realm that withholds the ID token is the case this
|
|
245
|
+
catches — and finding out now beats finding out on the user's next command.
|
|
246
|
+
|
|
247
|
+
Rollback names the profile explicitly. ``delete_credentials()`` with no
|
|
248
|
+
argument resolves the *ambient* profile, so under ``SCRIPTIT_PROFILE`` a
|
|
249
|
+
failed login into one profile would delete a different, working one.
|
|
250
|
+
"""
|
|
251
|
+
try:
|
|
252
|
+
resp = requests.get(
|
|
253
|
+
f"{api_url}/api/v1/auth/me",
|
|
254
|
+
headers={"Authorization": f"Bearer {get_fresh_id_token(creds)}"},
|
|
255
|
+
timeout=30,
|
|
256
|
+
)
|
|
257
|
+
except requests.RequestException as exc:
|
|
258
|
+
warn(f"Warning: login stored but /auth/me failed: {exc}")
|
|
259
|
+
return
|
|
260
|
+
if resp.status_code != 200:
|
|
261
|
+
delete_credentials(profile=creds.get("profile") or profile)
|
|
262
|
+
fail(f"backend rejected the new credential ({resp.status_code}): {resp.text[:200]}")
|
|
263
|
+
me = resp.json()
|
|
264
|
+
# Attribution is the bearer token the backend just accepted; nothing about
|
|
265
|
+
# the user is read out of this response and sent anywhere.
|
|
266
|
+
analytics.bind(api_url, lambda: get_fresh_id_token(creds))
|
|
267
|
+
analytics.track("cli_login_succeeded", mode=creds.get("mode"))
|
|
268
|
+
emit(
|
|
269
|
+
{
|
|
270
|
+
"logged_in": True,
|
|
271
|
+
"api_url": api_url,
|
|
272
|
+
"user": {"id": me.get("id"), "email": me.get("email")},
|
|
273
|
+
"profile": creds.get("profile") or profile or current_profile_name(),
|
|
274
|
+
"credentials_file": credentials_path(),
|
|
275
|
+
},
|
|
276
|
+
lambda: print(f"Logged in to {api_url} as {me.get('email') or me.get('id')}"),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class AuthCommands:
|
|
281
|
+
"""Authentication for remote (workstation) use of the scriptit CLI."""
|
|
282
|
+
|
|
283
|
+
def login(
|
|
284
|
+
self,
|
|
285
|
+
api_url: Optional[str] = None,
|
|
286
|
+
app_url: Optional[str] = None,
|
|
287
|
+
manual: bool = False,
|
|
288
|
+
timeout: int = LOGIN_TIMEOUT_SECONDS,
|
|
289
|
+
profile: Optional[str] = None,
|
|
290
|
+
no_browser: bool = False,
|
|
291
|
+
) -> None:
|
|
292
|
+
"""Connect this machine to your Script.it account.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
api_url: Platform API origin (default: existing login's, else
|
|
296
|
+
https://api.script.it). Pass a self-hosted deployment's
|
|
297
|
+
origin to sign in to that instead.
|
|
298
|
+
app_url: Frontend origin override (normally discovered from the API).
|
|
299
|
+
manual: The same as --no-browser; kept so instructions that
|
|
300
|
+
pass it keep working.
|
|
301
|
+
timeout: Seconds to wait for the approval. Run again after a
|
|
302
|
+
timeout or an interruption, the command resumes the same link.
|
|
303
|
+
profile: Named profile to store this login under (multiple
|
|
304
|
+
accounts side by side; switch with `scriptit auth use`,
|
|
305
|
+
override per-invocation with SCRIPTIT_PROFILE).
|
|
306
|
+
no_browser: Print the login URL instead of opening a window.
|
|
307
|
+
Implied when an agent harness is driving.
|
|
308
|
+
"""
|
|
309
|
+
existing = load_credentials(profile) or {}
|
|
310
|
+
api_url = str(api_url or existing.get("api_url") or DEFAULT_API_URL).rstrip("/")
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
config = _fetch_auth_config(api_url)
|
|
314
|
+
except (RemoteAuthError, ValueError, requests.RequestException) as exc:
|
|
315
|
+
fail(f"cannot reach {api_url}: {exc}")
|
|
316
|
+
|
|
317
|
+
if config.get("mode") == "keycloak":
|
|
318
|
+
self._login_keycloak(api_url, config, timeout, profile)
|
|
319
|
+
return
|
|
320
|
+
if config.get("mode") != "firebase":
|
|
321
|
+
fail(f"unknown auth mode {config.get('mode')!r}")
|
|
322
|
+
|
|
323
|
+
app_url = (app_url or config.get("app_url") or "").rstrip("/")
|
|
324
|
+
if not app_url:
|
|
325
|
+
fail("the API did not advertise an app URL; pass --app-url")
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
payload = _receive_via_approval(
|
|
329
|
+
api_url,
|
|
330
|
+
f"{app_url}/app/cli-auth",
|
|
331
|
+
pending_key=f"{current_profile_name(profile)} {api_url}",
|
|
332
|
+
timeout=timeout,
|
|
333
|
+
open_browser=_should_open_browser(no_browser or manual),
|
|
334
|
+
)
|
|
335
|
+
tokens = exchange_custom_token(
|
|
336
|
+
str(payload["firebase_api_key"]),
|
|
337
|
+
str(payload["custom_token"]),
|
|
338
|
+
app_url=app_url,
|
|
339
|
+
)
|
|
340
|
+
except RemoteAuthError as exc:
|
|
341
|
+
fail(str(exc))
|
|
342
|
+
|
|
343
|
+
creds: Dict[str, Any] = {
|
|
344
|
+
"mode": "firebase",
|
|
345
|
+
"api_url": api_url,
|
|
346
|
+
"app_url": app_url,
|
|
347
|
+
"firebase_api_key": payload["firebase_api_key"],
|
|
348
|
+
"user_email": payload.get("user_email"),
|
|
349
|
+
**tokens,
|
|
350
|
+
}
|
|
351
|
+
save_credentials(creds, profile=profile)
|
|
352
|
+
|
|
353
|
+
# Verify end-to-end: the stored credential must satisfy the backend.
|
|
354
|
+
_verify_stored_login(api_url, creds, profile)
|
|
355
|
+
|
|
356
|
+
def _login_keycloak(
|
|
357
|
+
self,
|
|
358
|
+
api_url: str,
|
|
359
|
+
config: Dict[str, Any],
|
|
360
|
+
timeout: int,
|
|
361
|
+
profile: Optional[str] = None,
|
|
362
|
+
) -> None:
|
|
363
|
+
"""RFC 8628 device grant against the realm advertised by /auth/config."""
|
|
364
|
+
keycloak = config.get("keycloak") or {}
|
|
365
|
+
if not (keycloak.get("url") and keycloak.get("realm") and keycloak.get("client_id")):
|
|
366
|
+
fail("the API did not advertise Keycloak realm details")
|
|
367
|
+
# The realm's own CLI client when the backend names one: a session
|
|
368
|
+
# issued to it is what the backend treats as an outside agent, so the
|
|
369
|
+
# account can govern it. A backend that predates the field advertises
|
|
370
|
+
# only the web client, which is then the only way in.
|
|
371
|
+
client_id = str(keycloak.get("cli_client_id") or keycloak["client_id"])
|
|
372
|
+
try:
|
|
373
|
+
creds = keycloak_device_login(
|
|
374
|
+
str(keycloak["url"]),
|
|
375
|
+
str(keycloak["realm"]),
|
|
376
|
+
client_id,
|
|
377
|
+
timeout=timeout,
|
|
378
|
+
)
|
|
379
|
+
except RemoteAuthError as exc:
|
|
380
|
+
fail(str(exc))
|
|
381
|
+
creds["api_url"] = api_url
|
|
382
|
+
save_credentials(creds, profile=profile)
|
|
383
|
+
_verify_stored_login(api_url, creds, profile)
|
|
384
|
+
|
|
385
|
+
def browser_url(
|
|
386
|
+
self,
|
|
387
|
+
next: str = "/app",
|
|
388
|
+
profile: Optional[str] = None,
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Print a one-time URL that signs a browser in as this machine's user.
|
|
391
|
+
|
|
392
|
+
The inverse of `login`. There a signed-in browser lends a session to
|
|
393
|
+
the CLI; here a signed-in CLI lends one to a browser — for an agent
|
|
394
|
+
that can already drive the platform through this CLI but has no way to
|
|
395
|
+
*see* it.
|
|
396
|
+
|
|
397
|
+
An agent cannot log in on the user's behalf: the login page mints its
|
|
398
|
+
token because it is already signed in, and an agent's browser is not,
|
|
399
|
+
so the only way through would be typing the user's password. Borrowing
|
|
400
|
+
this machine's existing session is what an agent can do instead.
|
|
401
|
+
|
|
402
|
+
The URL is a credential. It is good for 60 seconds, once, and the code
|
|
403
|
+
rides in the fragment so it reaches no server log. Treat it as you
|
|
404
|
+
would a password for that minute: the backend records every redeem and
|
|
405
|
+
emails the account when one arrives from an unfamiliar address.
|
|
406
|
+
|
|
407
|
+
Script.it Cloud only. An on-prem deployment authenticates through
|
|
408
|
+
Keycloak, which has no equivalent, and the backend says so rather than
|
|
409
|
+
failing obscurely.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
next: Same-origin path to land on, e.g. `/app/s/<session_id>`.
|
|
413
|
+
The page refuses anything that would leave the origin.
|
|
414
|
+
profile: Named profile to use (default: the active one).
|
|
415
|
+
"""
|
|
416
|
+
creds = load_credentials(profile)
|
|
417
|
+
if not creds:
|
|
418
|
+
fail("not logged in — run `scriptit auth login`")
|
|
419
|
+
|
|
420
|
+
app_url = str(creds.get("app_url") or "").rstrip("/")
|
|
421
|
+
if not app_url:
|
|
422
|
+
fail(
|
|
423
|
+
"this login recorded no browser origin — run `scriptit auth login` "
|
|
424
|
+
"again to refresh it"
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
api_url = str(creds["api_url"]).rstrip("/")
|
|
428
|
+
try:
|
|
429
|
+
token = get_fresh_id_token(creds)
|
|
430
|
+
except RemoteAuthError as exc:
|
|
431
|
+
fail(str(exc))
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
resp = requests.post(
|
|
435
|
+
f"{api_url}/api/v1/auth/browser-handoff",
|
|
436
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
437
|
+
json={"client": detect_client()},
|
|
438
|
+
timeout=30,
|
|
439
|
+
)
|
|
440
|
+
except requests.RequestException as exc:
|
|
441
|
+
fail(f"could not reach {api_url}: {exc}")
|
|
442
|
+
|
|
443
|
+
if resp.status_code != 200:
|
|
444
|
+
# 409 is the deployment saying it cannot do this at all (Keycloak).
|
|
445
|
+
# Its wording is the useful part, so it is passed through rather
|
|
446
|
+
# than replaced with a guess about why.
|
|
447
|
+
detail = resp.text[:300]
|
|
448
|
+
with contextlib.suppress(ValueError, KeyError, TypeError):
|
|
449
|
+
detail = resp.json()["detail"]
|
|
450
|
+
fail(f"could not mint a browser link ({resp.status_code}): {detail}")
|
|
451
|
+
|
|
452
|
+
body = resp.json()
|
|
453
|
+
# The code goes in the fragment, never the query string: a fragment is
|
|
454
|
+
# not sent to any server and never lands in an access log or a Referer.
|
|
455
|
+
url = f"{app_url}/app/cli-handoff#code={body['code']}&next={next}"
|
|
456
|
+
expires_in = int(body.get("expires_in") or 60)
|
|
457
|
+
|
|
458
|
+
def _human() -> None:
|
|
459
|
+
print(url)
|
|
460
|
+
print(
|
|
461
|
+
f"\nOpen it within {expires_in}s — it works once. Anyone who reads it "
|
|
462
|
+
"in that window can sign in as you.",
|
|
463
|
+
file=sys.stderr,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
emit({"url": url, "expires_in": expires_in}, _human)
|
|
467
|
+
|
|
468
|
+
def list(self) -> None:
|
|
469
|
+
"""List stored login profiles (the active one is starred)."""
|
|
470
|
+
profiles = list_profiles()
|
|
471
|
+
current = current_profile_name() if profiles else None
|
|
472
|
+
rows = [
|
|
473
|
+
{
|
|
474
|
+
"profile": name,
|
|
475
|
+
"email": creds.get("user_email"),
|
|
476
|
+
"api_url": creds.get("api_url"),
|
|
477
|
+
"active": name == current,
|
|
478
|
+
}
|
|
479
|
+
for name, creds in sorted(profiles.items())
|
|
480
|
+
]
|
|
481
|
+
|
|
482
|
+
def _human() -> None:
|
|
483
|
+
if not rows:
|
|
484
|
+
print("No profiles. Run `scriptit auth login`.")
|
|
485
|
+
return
|
|
486
|
+
for row in rows:
|
|
487
|
+
marker = " *" if row["active"] else ""
|
|
488
|
+
print(f"{row['profile']}{marker} {row['email'] or '?'} {row['api_url']}")
|
|
489
|
+
|
|
490
|
+
emit({"profiles": rows, "current": current}, _human)
|
|
491
|
+
|
|
492
|
+
def use(self, profile: str) -> None:
|
|
493
|
+
"""Make a stored profile the default for subsequent commands."""
|
|
494
|
+
if not set_current_profile(profile):
|
|
495
|
+
fail(f"no profile named '{profile}' — see `scriptit auth list`")
|
|
496
|
+
emit({"profile": profile}, lambda: print(f"Active profile: {profile}"))
|
|
497
|
+
|
|
498
|
+
def status(self, profile: Optional[str] = None) -> None:
|
|
499
|
+
"""Show who this machine is connected as."""
|
|
500
|
+
creds = load_credentials(profile)
|
|
501
|
+
if not creds:
|
|
502
|
+
emit(
|
|
503
|
+
{"logged_in": False},
|
|
504
|
+
lambda: print("Not logged in. Run `scriptit auth login`."),
|
|
505
|
+
)
|
|
506
|
+
return
|
|
507
|
+
api_url = creds["api_url"]
|
|
508
|
+
try:
|
|
509
|
+
token = get_fresh_id_token(creds)
|
|
510
|
+
resp = requests.get(
|
|
511
|
+
f"{api_url}/api/v1/auth/me",
|
|
512
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
513
|
+
timeout=30,
|
|
514
|
+
)
|
|
515
|
+
except (RemoteAuthError, requests.RequestException) as exc:
|
|
516
|
+
fail(
|
|
517
|
+
f"logged in to {api_url} but the credential is unusable: {exc} "
|
|
518
|
+
f"(credentials file: {credentials_path()})"
|
|
519
|
+
)
|
|
520
|
+
return
|
|
521
|
+
if resp.status_code != 200:
|
|
522
|
+
fail(f"logged in to {api_url}, but /auth/me returned {resp.status_code}")
|
|
523
|
+
return
|
|
524
|
+
me = resp.json()
|
|
525
|
+
profile_name = current_profile_name(profile)
|
|
526
|
+
payload = {
|
|
527
|
+
"logged_in": True,
|
|
528
|
+
"api_url": api_url,
|
|
529
|
+
"user": {"id": me.get("id"), "email": me.get("email")},
|
|
530
|
+
"profile": profile_name,
|
|
531
|
+
"credentials_file": credentials_path(),
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
def _human() -> None:
|
|
535
|
+
print(
|
|
536
|
+
f"Logged in to {api_url} as {me.get('email') or me.get('id')} "
|
|
537
|
+
f"(profile: {profile_name})"
|
|
538
|
+
)
|
|
539
|
+
print(f"Credentials file: {credentials_path()}")
|
|
540
|
+
|
|
541
|
+
emit(payload, _human)
|
|
542
|
+
|
|
543
|
+
def logout(self, profile: Optional[str] = None, all_profiles: bool = False) -> None:
|
|
544
|
+
"""Forget stored credentials (current profile; --all-profiles for every one)."""
|
|
545
|
+
removed = delete_credentials(profile=profile, all_profiles=all_profiles)
|
|
546
|
+
emit(
|
|
547
|
+
{"removed": bool(removed)},
|
|
548
|
+
lambda: print(
|
|
549
|
+
"Logged out (local credentials removed)."
|
|
550
|
+
if removed
|
|
551
|
+
else "Nothing to remove — not logged in."
|
|
552
|
+
),
|
|
553
|
+
)
|
scriptit_cli/client.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Which harness, if any, is driving this CLI.
|
|
2
|
+
|
|
3
|
+
Its own module because everything wants it and it depends on nothing: the
|
|
4
|
+
session labels itself with it, analytics reports it, and `auth login` uses it
|
|
5
|
+
to decide whether opening a browser window would help anyone. Left in
|
|
6
|
+
``remote``, the analytics module could only reach it through an import inside a
|
|
7
|
+
function, since ``remote`` imports analytics itself.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
# Harness markers, checked in order. Self-declaration via SCRIPTIT_CLIENT
|
|
16
|
+
# (documented in the entry-point skill) always wins; these cover harnesses
|
|
17
|
+
# that export an identifying variable without being asked.
|
|
18
|
+
_CLIENT_ENV_MARKERS: List[Tuple[str, str]] = [
|
|
19
|
+
("CLAUDECODE", "claude-code"),
|
|
20
|
+
("CLAUDE_CODE_ENTRYPOINT", "claude-code"),
|
|
21
|
+
("CODEX_THREAD_ID", "codex"),
|
|
22
|
+
("CODEX_SANDBOX", "codex"),
|
|
23
|
+
("CURSOR_TRACE_ID", "cursor"),
|
|
24
|
+
("GEMINI_CLI", "gemini-cli"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def detect_client() -> Optional[str]:
|
|
29
|
+
"""The driving harness's slug (``claude-code``, ``codex``, ...), or None.
|
|
30
|
+
|
|
31
|
+
``SCRIPTIT_CLIENT`` is the explicit contract — agents are told to set it
|
|
32
|
+
before ``scriptit session new``. Known harness env markers are a
|
|
33
|
+
best-effort fallback; both are self-reported, so this is attribution
|
|
34
|
+
for the session UI and analytics, not a security boundary.
|
|
35
|
+
"""
|
|
36
|
+
explicit = os.environ.get("SCRIPTIT_CLIENT", "").strip().lower()
|
|
37
|
+
if explicit:
|
|
38
|
+
return "".join(ch for ch in explicit if ch.isalnum() or ch in "-_")[:32] or None
|
|
39
|
+
for var, slug in _CLIENT_ENV_MARKERS:
|
|
40
|
+
if os.environ.get(var):
|
|
41
|
+
return slug
|
|
42
|
+
return None
|