tlnt-cli 1.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. tlnt/__init__.py +3 -0
  2. tlnt/__main__.py +4 -0
  3. tlnt/browser/__init__.py +0 -0
  4. tlnt/browser/login.py +414 -0
  5. tlnt/browser/oauth.py +50 -0
  6. tlnt/browser/scripted.py +183 -0
  7. tlnt/cli/__init__.py +0 -0
  8. tlnt/cli/avatars.py +51 -0
  9. tlnt/cli/commands/__init__.py +0 -0
  10. tlnt/cli/commands/approvals.py +98 -0
  11. tlnt/cli/commands/attendance.py +283 -0
  12. tlnt/cli/commands/calendar.py +108 -0
  13. tlnt/cli/commands/login.py +225 -0
  14. tlnt/cli/commands/logout.py +12 -0
  15. tlnt/cli/commands/mcp.py +8 -0
  16. tlnt/cli/commands/notifications.py +102 -0
  17. tlnt/cli/commands/profile.py +13 -0
  18. tlnt/cli/commands/setup.py +12 -0
  19. tlnt/cli/commands/status.py +12 -0
  20. tlnt/cli/commands/timeoff.py +491 -0
  21. tlnt/cli/commands/update.py +86 -0
  22. tlnt/cli/commands/version.py +15 -0
  23. tlnt/cli/main.py +163 -0
  24. tlnt/cli/options.py +56 -0
  25. tlnt/cli/output.py +321 -0
  26. tlnt/cli/prompts.py +76 -0
  27. tlnt/core/__init__.py +0 -0
  28. tlnt/core/clients/__init__.py +0 -0
  29. tlnt/core/clients/mekari/__init__.py +0 -0
  30. tlnt/core/clients/mekari/client.py +826 -0
  31. tlnt/core/clients/mekari/constants.py +174 -0
  32. tlnt/core/config.py +53 -0
  33. tlnt/core/http_client.py +110 -0
  34. tlnt/core/logs.py +34 -0
  35. tlnt/core/models.py +84 -0
  36. tlnt/core/play_store.py +85 -0
  37. tlnt/core/services/__init__.py +46 -0
  38. tlnt/core/services/attendance.py +213 -0
  39. tlnt/core/services/calendar.py +54 -0
  40. tlnt/core/services/login.py +100 -0
  41. tlnt/core/services/logout.py +38 -0
  42. tlnt/core/services/notifications.py +97 -0
  43. tlnt/core/services/profile.py +11 -0
  44. tlnt/core/services/setup.py +85 -0
  45. tlnt/core/services/status.py +35 -0
  46. tlnt/core/services/timeoff.py +146 -0
  47. tlnt/core/session.py +189 -0
  48. tlnt/core/user_agent.py +71 -0
  49. tlnt/mcp/__init__.py +0 -0
  50. tlnt/mcp/main.py +176 -0
  51. tlnt_cli-1.5.1.dist-info/METADATA +315 -0
  52. tlnt_cli-1.5.1.dist-info/RECORD +55 -0
  53. tlnt_cli-1.5.1.dist-info/WHEEL +4 -0
  54. tlnt_cli-1.5.1.dist-info/entry_points.txt +3 -0
  55. tlnt_cli-1.5.1.dist-info/licenses/LICENSE +21 -0
tlnt/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # The name on PyPI, which refuses "tlnt" as too similar to the existing
2
+ # project "tint". The command and the import package are still "tlnt".
3
+ DISTRIBUTION = "tlnt-cli"
tlnt/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from tlnt.cli.main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
File without changes
tlnt/browser/login.py ADDED
@@ -0,0 +1,414 @@
1
+ """Visible browser login and mobile OAuth capture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from collections.abc import Callable, Iterator
7
+ from contextlib import AbstractContextManager, contextmanager
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime
10
+ from time import monotonic
11
+ from typing import Any
12
+ from urllib.parse import urlsplit
13
+
14
+ from playwright.sync_api import Error as PlaywrightError
15
+ from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
16
+ from playwright.sync_api import sync_playwright
17
+
18
+ from tlnt.browser.oauth import authorize_url, code_from_url, pkce_pair
19
+ from tlnt.core.clients.mekari.client import MekariClient
20
+ from tlnt.core.clients.mekari.constants import (
21
+ DASHBOARD_TALENTA_URL,
22
+ MEKARI_DOMAIN,
23
+ STATIC_ASSET_SUFFIXES,
24
+ TALENTA_DOMAIN,
25
+ )
26
+ from tlnt.core.models import MobileToken, Profile, SessionState
27
+ from tlnt.core.logs import logger
28
+
29
+
30
+ _log = logger.bind(component="browser")
31
+
32
+ LOGIN_TIMEOUT_SECONDS = 300
33
+ FORM_TIMEOUT_MS = 10_000
34
+ SIGN_IN_TIMEOUT_MS = 60_000
35
+ _SIGN_IN_PATH = "/users/sign_in"
36
+ # 127.0.0.1, not localhost: Chrome binds IPv4 loopback while localhost may
37
+ # resolve to ::1 first.
38
+ DEFAULT_CDP_ENDPOINT = "http://127.0.0.1:9222"
39
+ _CDP_SCHEMES = ("http", "https", "ws", "wss")
40
+
41
+
42
+ class LoginFailed(RuntimeError):
43
+ pass
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Credentials:
48
+ email: str
49
+ password: str = field(repr=False)
50
+
51
+
52
+ @contextmanager
53
+ def chrome_context(headless: bool = False) -> Iterator[Any]:
54
+ """Yield a context on the installed Chrome, falling back to Edge."""
55
+ with sync_playwright() as playwright:
56
+ for channel in ("chrome", "msedge"):
57
+ try:
58
+ browser = playwright.chromium.launch(
59
+ channel=channel, headless=headless
60
+ )
61
+ except PlaywrightError:
62
+ continue
63
+ try:
64
+ yield browser.new_context()
65
+ finally:
66
+ browser.close()
67
+ return
68
+ raise LoginFailed(
69
+ "Install Google Chrome or Microsoft Edge to sign in, attach to a running "
70
+ "browser with --cdp, or use --headless with --email on a host without a display"
71
+ )
72
+
73
+
74
+ def cdp_endpoint(value: str) -> str:
75
+ """Normalize a port, host:port, or http(s)/ws(s) URL into a CDP endpoint."""
76
+ text = value.strip()
77
+ if text.isdigit():
78
+ text = f"127.0.0.1:{text}"
79
+ if "://" not in text:
80
+ text = f"http://{text}"
81
+ try:
82
+ parts = urlsplit(text)
83
+ valid = (
84
+ parts.scheme in _CDP_SCHEMES
85
+ and bool(parts.hostname)
86
+ and parts.port != 0
87
+ )
88
+ except ValueError:
89
+ valid = False
90
+ if not valid:
91
+ raise ValueError(
92
+ f"Invalid CDP endpoint '{value}': "
93
+ "use a port, host:port, or an http(s)/ws(s) URL"
94
+ )
95
+ return text
96
+
97
+
98
+ @contextmanager
99
+ def cdp_context(endpoint: str) -> Iterator[Any]:
100
+ """Yield the default context of a browser that is already running (CDP).
101
+
102
+ The browser, its context and its tabs belong to the user, so nothing here
103
+ closes them; leaving ``sync_playwright`` only drops the connection.
104
+ """
105
+ with sync_playwright() as playwright:
106
+ try:
107
+ browser = playwright.chromium.connect_over_cdp(endpoint)
108
+ except PlaywrightError as error:
109
+ raise LoginFailed(
110
+ f"Could not connect to a browser at {endpoint}; start it with "
111
+ "--remote-debugging-port=9222 "
112
+ "(Chrome 136+ also needs --user-data-dir)"
113
+ ) from error
114
+ yield browser.contexts[0]
115
+
116
+
117
+ def _wait_for_dashboard(
118
+ context: Any,
119
+ opened: Any,
120
+ existing: list[Any],
121
+ deadline: float,
122
+ ) -> Any:
123
+ """Wait for a tab the login created, not for tabs the browser already had."""
124
+ while True:
125
+ pages = context.pages
126
+ if opened not in pages:
127
+ raise LoginFailed("Browser was closed before login completed")
128
+ for page in pages:
129
+ if page in existing:
130
+ continue
131
+ normalized = page.url.split("?", 1)[0].split("#", 1)[0].rstrip("/")
132
+ if normalized == DASHBOARD_TALENTA_URL:
133
+ return page
134
+ remaining = deadline - monotonic()
135
+ if remaining <= 0:
136
+ raise PlaywrightTimeoutError("Login did not reach the Talenta dashboard")
137
+ try:
138
+ opened.wait_for_timeout(min(100, remaining * 1000))
139
+ except (PlaywrightError, PlaywrightTimeoutError):
140
+ pass
141
+
142
+
143
+ def _sign_in(page: Any, credentials: Credentials) -> None:
144
+ """Type the credentials into Mekari's sign-in form and submit it once."""
145
+ try:
146
+ try:
147
+ page.fill("#user_email", credentials.email, timeout=FORM_TIMEOUT_MS)
148
+ except PlaywrightTimeoutError:
149
+ if _SIGN_IN_PATH in page.url:
150
+ raise LoginFailed(
151
+ "Mekari's sign-in form did not appear. Run tlnt login "
152
+ "without --email to sign in yourself"
153
+ ) from None
154
+ return # not on the sign-in page: the browser is already signed in
155
+ page.fill("#user_password", credentials.password, timeout=FORM_TIMEOUT_MS)
156
+ page.click("#new-signin-button", timeout=FORM_TIMEOUT_MS)
157
+ # ponytail: fixed 60 s wait; read Mekari's error text if wrong-password
158
+ # feedback must be faster.
159
+ page.wait_for_url(
160
+ lambda url: _SIGN_IN_PATH not in url,
161
+ wait_until="commit",
162
+ timeout=SIGN_IN_TIMEOUT_MS,
163
+ )
164
+ except PlaywrightTimeoutError:
165
+ # `from None` on every raise here: Playwright's call log, appended to
166
+ # every error class, echoes the typed value.
167
+ raise LoginFailed(
168
+ "Mekari did not accept the sign-in. Check the email and password, "
169
+ "or run tlnt login without --email if it asks for a code or captcha"
170
+ ) from None
171
+ except PlaywrightError:
172
+ raise LoginFailed(
173
+ "The browser stopped while tlnt was signing in. Run tlnt login again"
174
+ ) from None
175
+
176
+
177
+ def _extract_profile(settings: Any) -> Profile:
178
+ if not isinstance(settings, dict):
179
+ raise LoginFailed("Talenta did not expose user profile data")
180
+
181
+ company = settings.get("company")
182
+ company = company if isinstance(company, dict) else {}
183
+ fields = {
184
+ "name": settings.get("name"),
185
+ "email": settings.get("email") or settings.get("Email"),
186
+ "Talenta User ID": settings.get("Talenta User ID"),
187
+ "Company ID": settings.get("Company ID") or company.get("id"),
188
+ "Company Name": settings.get("Company Name") or company.get("name"),
189
+ }
190
+ missing = next(
191
+ (
192
+ name
193
+ for name, value in fields.items()
194
+ if name != "Company Name"
195
+ if isinstance(value, bool)
196
+ or not isinstance(value, (str, int, float))
197
+ or not str(value).strip()
198
+ ),
199
+ None,
200
+ )
201
+ if missing:
202
+ raise LoginFailed(f"Talenta profile missing {missing}")
203
+
204
+ company_name = fields["Company Name"]
205
+ if (
206
+ isinstance(company_name, bool)
207
+ or not isinstance(company_name, (str, int, float))
208
+ or not str(company_name).strip()
209
+ ):
210
+ company_name = None
211
+
212
+ return Profile(
213
+ name=str(fields["name"]),
214
+ email=str(fields["email"]),
215
+ talenta_user_id=str(fields["Talenta User ID"]),
216
+ company_id=str(fields["Company ID"]),
217
+ company_name=str(company_name) if company_name is not None else None,
218
+ )
219
+
220
+
221
+ def _is_relevant_host(url: str) -> bool:
222
+ host = (urlsplit(url).hostname or "").lower()
223
+ return any(
224
+ host == domain or host.endswith(f".{domain}")
225
+ for domain in (MEKARI_DOMAIN, TALENTA_DOMAIN)
226
+ )
227
+
228
+
229
+ def _is_static_asset(path: str) -> bool:
230
+ return (
231
+ path.endswith(STATIC_ASSET_SUFFIXES)
232
+ or "/assets/" in path
233
+ or "/images/" in path
234
+ )
235
+
236
+ class _OAuthSink:
237
+ def __init__(self) -> None:
238
+ self.code: str | None = None
239
+ self.urls: list[str] = []
240
+
241
+ def note(self, url: str) -> None:
242
+ if not _is_relevant_host(url):
243
+ return
244
+ self.urls.append(url)
245
+ if self.code is None:
246
+ self.code = code_from_url(url)
247
+
248
+ def on_response(self, response: Any) -> None:
249
+ self.note(response.url)
250
+
251
+
252
+ def _redirect_chain(sink: _OAuthSink) -> str:
253
+ seen: list[str] = []
254
+ for url in sink.urls:
255
+ parts = urlsplit(url)
256
+ if _is_static_asset(parts.path):
257
+ continue
258
+ step = f"{parts.netloc}{parts.path}"
259
+ if not seen or seen[-1] != step:
260
+ seen.append(step)
261
+ return " -> ".join(seen) if seen else f"(no {MEKARI_DOMAIN}/{TALENTA_DOMAIN} traffic observed)"
262
+
263
+
264
+ def _report_capture_miss(sink: _OAuthSink) -> None:
265
+ _log.bind(
266
+ event="failure",
267
+ phase="token_capture",
268
+ error_type="token_not_captured",
269
+ ).warning("mobile token not captured")
270
+ print(
271
+ "Mobile token not captured.\n"
272
+ " checked: authorization code and PKCE token exchange\n"
273
+ f" redirect chain: {_redirect_chain(sink)}\n"
274
+ " Set TALENTA_BEARER to clock in now, or share the chain above to "
275
+ "close the gap.",
276
+ file=sys.stderr,
277
+ )
278
+
279
+
280
+ class BrowserLogin:
281
+ def __init__(
282
+ self,
283
+ *,
284
+ client: MekariClient | None = None,
285
+ context_factory: Callable[[], AbstractContextManager[Any]] = chrome_context,
286
+ credentials: Credentials | None = None,
287
+ ) -> None:
288
+ self.client = client or MekariClient()
289
+ self._owns_client = client is None
290
+ self.context_factory = context_factory
291
+ self.credentials = credentials
292
+
293
+ def _capture_mobile_token(
294
+ self,
295
+ page: Any,
296
+ sink: _OAuthSink,
297
+ ) -> MobileToken | None:
298
+ _log.bind(
299
+ event="phase_started",
300
+ phase="token_capture",
301
+ ).debug("mobile token capture started")
302
+ try:
303
+ verifier, challenge = pkce_pair()
304
+ page.set_extra_http_headers({"X-Requested-With": "co.talenta"})
305
+ page.goto(
306
+ authorize_url(challenge),
307
+ wait_until="domcontentloaded",
308
+ timeout=30_000,
309
+ )
310
+ if sink.code is None:
311
+ sink.code = code_from_url(page.url)
312
+ except (PlaywrightError, PlaywrightTimeoutError) as error:
313
+ _log.bind(
314
+ event="failure",
315
+ phase="token_capture",
316
+ error_type=type(error).__name__,
317
+ ).warning("mobile token capture failed")
318
+ return None
319
+ return self._exchange(sink, verifier)
320
+
321
+ def _exchange(self, sink: _OAuthSink, verifier: str) -> MobileToken | None:
322
+ token = (
323
+ self.client.exchange_code(sink.code, verifier)
324
+ if sink.code is not None
325
+ else None
326
+ )
327
+ if token is None:
328
+ _report_capture_miss(sink)
329
+ return None
330
+ _log.bind(
331
+ event="phase_finished",
332
+ phase="token_capture",
333
+ ).debug("mobile token capture finished")
334
+ return token
335
+
336
+ def login(self) -> SessionState:
337
+ try:
338
+ return self._login()
339
+ finally:
340
+ if self._owns_client:
341
+ self.client.close()
342
+
343
+ def _capture_browser_session(
344
+ self,
345
+ sink: _OAuthSink,
346
+ ) -> tuple[Profile | None, MobileToken | None]:
347
+ with self.context_factory() as context:
348
+ _log.bind(
349
+ event="phase_started",
350
+ phase="browser_context",
351
+ ).debug("browser context started")
352
+ existing = list(context.pages)
353
+ opened = context.new_page()
354
+ context.on("response", sink.on_response)
355
+ try:
356
+ opened.goto(
357
+ DASHBOARD_TALENTA_URL,
358
+ wait_until="domcontentloaded",
359
+ timeout=30_000,
360
+ )
361
+ opened.bring_to_front()
362
+ deadline = monotonic() + LOGIN_TIMEOUT_SECONDS
363
+ if self.credentials is not None:
364
+ _sign_in(opened, self.credentials)
365
+ page = _wait_for_dashboard(context, opened, existing, deadline)
366
+ _log.bind(
367
+ event="phase_finished",
368
+ phase="dashboard",
369
+ ).debug("dashboard reached")
370
+ try:
371
+ profile = _extract_profile(
372
+ page.evaluate("() => window.intercomSettings ?? null")
373
+ )
374
+ except LoginFailed:
375
+ profile = None
376
+ mobile = self._capture_mobile_token(opened, sink)
377
+ return profile, mobile
378
+ finally:
379
+ context.remove_listener("response", sink.on_response)
380
+ opened.close()
381
+
382
+ def _login(self) -> SessionState:
383
+ _log.bind(event="phase_started", phase="login").debug(
384
+ "browser login started"
385
+ )
386
+ sink = _OAuthSink()
387
+ try:
388
+ profile, mobile = self._capture_browser_session(sink)
389
+ except OSError as error:
390
+ _log.bind(
391
+ event="failure",
392
+ phase="login",
393
+ error_type=type(error).__name__,
394
+ ).warning("browser login failed")
395
+ raise LoginFailed("Browser could not be started") from error
396
+ except (PlaywrightError, PlaywrightTimeoutError) as error:
397
+ _log.bind(
398
+ event="failure",
399
+ phase="login",
400
+ error_type=type(error).__name__,
401
+ ).warning("browser login failed")
402
+ raise LoginFailed("Login did not complete within five minutes") from error
403
+
404
+ session = SessionState(
405
+ version=1,
406
+ saved_at=datetime.now().astimezone().isoformat(),
407
+ web_storage={},
408
+ profile=profile,
409
+ mobile=mobile,
410
+ )
411
+ _log.bind(event="phase_finished", phase="login").debug(
412
+ "browser login finished"
413
+ )
414
+ return session
tlnt/browser/oauth.py ADDED
@@ -0,0 +1,50 @@
1
+ """PKCE helpers for the Mekari mobile authorization flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import secrets
8
+ from urllib.parse import parse_qsl, urlencode, urlsplit
9
+
10
+ from tlnt.core.clients.mekari import constants
11
+ from tlnt.core.clients.mekari.constants import (
12
+ OAUTH_AUTHORIZE_URL,
13
+ OAUTH_SCOPE,
14
+ TOKEN_REDIRECT_URL,
15
+ )
16
+
17
+
18
+ def pkce_pair() -> tuple[str, str]:
19
+ verifier = secrets.token_urlsafe(64)
20
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
21
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
22
+ return verifier, challenge
23
+
24
+
25
+ def authorize_url(challenge: str) -> str:
26
+ params = {
27
+ "client_id": constants.client_id(),
28
+ "scope": OAUTH_SCOPE,
29
+ "response_type": "code",
30
+ "redirect_uri": TOKEN_REDIRECT_URL,
31
+ "code_challenge": challenge,
32
+ "code_challenge_method": "s256",
33
+ }
34
+ return f"{OAUTH_AUTHORIZE_URL}?{urlencode(params)}"
35
+
36
+
37
+ def code_from_url(url: str) -> str | None:
38
+ parts = urlsplit(url)
39
+ redirect = urlsplit(TOKEN_REDIRECT_URL)
40
+ if (parts.scheme, parts.netloc, parts.path) != (
41
+ redirect.scheme,
42
+ redirect.netloc,
43
+ redirect.path,
44
+ ):
45
+ return None
46
+ for raw in (parts.query, parts.fragment):
47
+ for key, value in parse_qsl(raw):
48
+ if key == "code" and value:
49
+ return value
50
+ return None
@@ -0,0 +1,183 @@
1
+ """Login through browsers that expose no CDP port (ego lite, Yab).
2
+
3
+ One short script runs through the vendor CLI per check. tlnt repeats it until
4
+ the user is signed in to Talenta in that browser, and that same run does the
5
+ OAuth hop. The PKCE verifier never leaves this process.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import subprocess
12
+ from time import monotonic, sleep
13
+ from typing import Any, Literal
14
+
15
+ from tlnt.browser.login import (
16
+ LOGIN_TIMEOUT_SECONDS,
17
+ BrowserLogin,
18
+ LoginFailed,
19
+ _extract_profile,
20
+ _OAuthSink,
21
+ )
22
+ from tlnt.browser.oauth import authorize_url, pkce_pair
23
+ from tlnt.core.clients.mekari.client import MekariClient
24
+ from tlnt.core.clients.mekari.constants import DASHBOARD_TALENTA_URL
25
+ from tlnt.core.logs import logger
26
+ from tlnt.core.models import MobileToken, Profile
27
+
28
+
29
+ _log = logger.bind(component="browser")
30
+
31
+ POLL_SECONDS = 5
32
+ RUN_TIMEOUT_SECONDS = 90
33
+
34
+ _COMMANDS = {
35
+ "ego": ["ego-browser", "nodejs"],
36
+ "yab": ["yab", "exec", "-", "--timeout", "80"],
37
+ }
38
+ LABELS = {"ego": "ego lite", "yab": "Yab"}
39
+
40
+ # Each script opens the dashboard in a throwaway agent tab. Signed out, it
41
+ # reports that and closes the tab. Signed in, it also reads the profile, does
42
+ # the OAuth hop and reports the URLs it saw. __CONFIG__ becomes a JSON object
43
+ # with `dashboard` and `authorize`.
44
+ _EGO_SCRIPT = r"""
45
+ const cfg = __CONFIG__;
46
+ const bare = (u) => u.split(/[?#]/)[0].replace(/\/$/, "");
47
+ const task = await taskSpace("tlnt login");
48
+ const page = task.page("p1");
49
+ const result = { signedIn: false, settings: null, urls: [] };
50
+ try {
51
+ await page.goto(cfg.dashboard, { timeout: 30000 });
52
+ try {
53
+ await page.waitForURL((u) => bare(u.href) === cfg.dashboard, { timeout: 3000 });
54
+ result.signedIn = true;
55
+ } catch (e) {}
56
+ if (result.signedIn) {
57
+ result.settings = await page.evaluate(() => window.intercomSettings ?? null);
58
+ await page.cdp("Network.enable");
59
+ await page.cdp("Network.setExtraHTTPHeaders", { headers: { "X-Requested-With": "co.talenta" } });
60
+ await page.events();
61
+ // The last redirect may abort the navigation; requests seen so far still count.
62
+ try { await page.goto(cfg.authorize, { timeout: 30000 }); } catch (e) {}
63
+ for (const e of await page.events()) {
64
+ if (e.method === "Network.requestWillBeSent") result.urls.push(e.params.request.url);
65
+ }
66
+ result.urls.push(await page.url());
67
+ }
68
+ } finally {
69
+ await task.finish({ keep: [] });
70
+ }
71
+ console.log(JSON.stringify(result));
72
+ """
73
+
74
+ # Yab's eval runs in an isolated world, so it cannot read intercomSettings
75
+ # (no profile is saved) and no request header can be set on the OAuth hop.
76
+ _YAB_SCRIPT = r"""
77
+ const cfg = __CONFIG__;
78
+ const bare = (u) => u.split(/[?#]/)[0].replace(/\/$/, "");
79
+ const t = open(cfg.dashboard);
80
+ const here = () => {
81
+ const row = tabs().find((x) => x.id === t.id);
82
+ return row ? row.line.slice(row.line.lastIndexOf(", ") + 2) : "";
83
+ };
84
+ const result = { signedIn: false, settings: null, urls: [] };
85
+ try {
86
+ result.signedIn = bare(here()) === cfg.dashboard;
87
+ if (result.signedIn) {
88
+ try { t.navigate(cfg.authorize); } catch (e) {}
89
+ result.urls.push(here());
90
+ }
91
+ } finally {
92
+ t.close();
93
+ }
94
+ return JSON.stringify(result);
95
+ """
96
+
97
+ _SCRIPTS = {"ego": _EGO_SCRIPT, "yab": _YAB_SCRIPT}
98
+
99
+
100
+ def _script_result(stdout: str) -> dict[str, Any]:
101
+ """Return the last JSON object line, skipping notices and `(N steps)`."""
102
+ for line in reversed(stdout.splitlines()):
103
+ try:
104
+ data = json.loads(line)
105
+ except ValueError:
106
+ continue
107
+ if isinstance(data, dict):
108
+ return data
109
+ raise LoginFailed("Browser script returned no result")
110
+
111
+
112
+ def _run_script(backend: str, authorize: str) -> dict[str, Any]:
113
+ argv = _COMMANDS[backend]
114
+ label = LABELS[backend]
115
+ config = json.dumps({"dashboard": DASHBOARD_TALENTA_URL, "authorize": authorize})
116
+ try:
117
+ done = subprocess.run(
118
+ argv,
119
+ input=_SCRIPTS[backend].replace("__CONFIG__", config),
120
+ capture_output=True,
121
+ text=True,
122
+ timeout=RUN_TIMEOUT_SECONDS,
123
+ check=False,
124
+ )
125
+ except FileNotFoundError as error:
126
+ raise LoginFailed(f"{argv[0]} was not found on PATH; install it first") from error
127
+ except subprocess.TimeoutExpired as error:
128
+ raise LoginFailed(
129
+ f"{label} did not respond within {RUN_TIMEOUT_SECONDS} seconds"
130
+ ) from error
131
+ if done.returncode != 0:
132
+ _log.bind(
133
+ event="failure",
134
+ phase="scripted_check",
135
+ backend=backend,
136
+ exit_code=done.returncode,
137
+ ).warning("browser script failed")
138
+ raise LoginFailed(
139
+ f"{label} stopped (exit {done.returncode}); "
140
+ "check its window for a prompt, then retry"
141
+ )
142
+ # ego-browser prints console.log to stderr, yab prints its result to stdout.
143
+ return _script_result(f"{done.stdout}\n{done.stderr}")
144
+
145
+
146
+ class ScriptedLogin(BrowserLogin):
147
+ def __init__(
148
+ self,
149
+ backend: Literal["ego", "yab"],
150
+ *,
151
+ client: MekariClient | None = None,
152
+ ) -> None:
153
+ super().__init__(client=client)
154
+ self.backend = backend
155
+
156
+ def _capture_browser_session(
157
+ self,
158
+ sink: _OAuthSink,
159
+ ) -> tuple[Profile | None, MobileToken | None]:
160
+ verifier, challenge = pkce_pair()
161
+ authorize = authorize_url(challenge)
162
+ deadline = monotonic() + LOGIN_TIMEOUT_SECONDS
163
+ while True:
164
+ data = _run_script(self.backend, authorize)
165
+ if data.get("signedIn") is True:
166
+ break
167
+ if monotonic() >= deadline:
168
+ raise LoginFailed("Login did not complete within five minutes")
169
+ sleep(POLL_SECONDS)
170
+ _log.bind(
171
+ event="phase_finished",
172
+ phase="scripted_check",
173
+ backend=self.backend,
174
+ ).debug("signed in to Talenta")
175
+ try:
176
+ profile = _extract_profile(data.get("settings"))
177
+ except LoginFailed:
178
+ profile = None
179
+ urls = data.get("urls")
180
+ for url in urls if isinstance(urls, list) else []:
181
+ if isinstance(url, str):
182
+ sink.note(url)
183
+ return profile, self._exchange(sink, verifier)
tlnt/cli/__init__.py ADDED
File without changes