recce-cloud 1.32.0__py3-none-any.whl → 1.33.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.
- recce_cloud/VERSION +1 -1
- recce_cloud/api/client.py +245 -2
- recce_cloud/auth/__init__.py +21 -0
- recce_cloud/auth/callback_server.py +128 -0
- recce_cloud/auth/login.py +281 -0
- recce_cloud/auth/profile.py +131 -0
- recce_cloud/auth/templates/error.html +58 -0
- recce_cloud/auth/templates/success.html +58 -0
- recce_cloud/cli.py +661 -33
- recce_cloud/commands/__init__.py +1 -0
- recce_cloud/commands/diagnostics.py +174 -0
- recce_cloud/config/__init__.py +19 -0
- recce_cloud/config/project_config.py +187 -0
- recce_cloud/config/resolver.py +137 -0
- recce_cloud/services/__init__.py +1 -0
- recce_cloud/services/diagnostic_service.py +380 -0
- recce_cloud/upload.py +211 -0
- {recce_cloud-1.32.0.dist-info → recce_cloud-1.33.1.dist-info}/METADATA +112 -2
- recce_cloud-1.33.1.dist-info/RECORD +37 -0
- recce_cloud-1.32.0.dist-info/RECORD +0 -24
- {recce_cloud-1.32.0.dist-info → recce_cloud-1.33.1.dist-info}/WHEEL +0 -0
- {recce_cloud-1.32.0.dist-info → recce_cloud-1.33.1.dist-info}/entry_points.txt +0 -0
recce_cloud/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.33.1
|
recce_cloud/api/client.py
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Recce Cloud API clients for lightweight operations.
|
|
3
3
|
|
|
4
|
-
Provides clients for session management and report generation.
|
|
4
|
+
Provides clients for session management, organization/project listing, and report generation.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
8
|
import logging
|
|
9
9
|
import os
|
|
10
|
-
from typing import Optional
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
11
|
|
|
12
12
|
import requests
|
|
13
13
|
|
|
@@ -260,6 +260,249 @@ class RecceCloudClient:
|
|
|
260
260
|
status_code=response.status_code,
|
|
261
261
|
)
|
|
262
262
|
|
|
263
|
+
def list_organizations(self) -> List[Dict[str, Any]]:
|
|
264
|
+
"""
|
|
265
|
+
List all organizations the user has access to.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
List of organization dictionaries with id, name, slug fields.
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
RecceCloudException: If the API call fails.
|
|
272
|
+
"""
|
|
273
|
+
api_url = f"{self.base_url_v2}/organizations"
|
|
274
|
+
response = self._request("GET", api_url)
|
|
275
|
+
|
|
276
|
+
if response.status_code != 200:
|
|
277
|
+
raise RecceCloudException(
|
|
278
|
+
reason=response.text,
|
|
279
|
+
status_code=response.status_code,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
data = response.json()
|
|
283
|
+
return data.get("organizations", [])
|
|
284
|
+
|
|
285
|
+
def list_projects(self, org_id: str) -> List[Dict[str, Any]]:
|
|
286
|
+
"""
|
|
287
|
+
List all projects in an organization.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
org_id: Organization ID or slug.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
List of project dictionaries with id, name, slug fields.
|
|
294
|
+
|
|
295
|
+
Raises:
|
|
296
|
+
RecceCloudException: If the API call fails.
|
|
297
|
+
"""
|
|
298
|
+
api_url = f"{self.base_url_v2}/organizations/{org_id}/projects"
|
|
299
|
+
response = self._request("GET", api_url)
|
|
300
|
+
|
|
301
|
+
if response.status_code != 200:
|
|
302
|
+
raise RecceCloudException(
|
|
303
|
+
reason=response.text,
|
|
304
|
+
status_code=response.status_code,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
data = response.json()
|
|
308
|
+
return data.get("projects", [])
|
|
309
|
+
|
|
310
|
+
def get_organization(self, org_id: str) -> Optional[Dict[str, Any]]:
|
|
311
|
+
"""
|
|
312
|
+
Get a specific organization by ID or slug.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
org_id: Organization ID or slug.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
Organization dictionary, or None if not found.
|
|
319
|
+
|
|
320
|
+
Raises:
|
|
321
|
+
RecceCloudException: If the API call fails.
|
|
322
|
+
"""
|
|
323
|
+
orgs = self.list_organizations()
|
|
324
|
+
for org in orgs:
|
|
325
|
+
# Compare as strings to handle both int and str IDs
|
|
326
|
+
if str(org.get("id")) == str(org_id) or org.get("slug") == org_id or org.get("name") == org_id:
|
|
327
|
+
return org
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
def get_project(self, org_id: str, project_id: str) -> Optional[Dict[str, Any]]:
|
|
331
|
+
"""
|
|
332
|
+
Get a specific project by ID or slug.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
org_id: Organization ID or slug.
|
|
336
|
+
project_id: Project ID or slug.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Project dictionary, or None if not found.
|
|
340
|
+
|
|
341
|
+
Raises:
|
|
342
|
+
RecceCloudException: If the API call fails.
|
|
343
|
+
"""
|
|
344
|
+
projects = self.list_projects(org_id)
|
|
345
|
+
for project in projects:
|
|
346
|
+
# Compare as strings to handle both int and str IDs
|
|
347
|
+
if (
|
|
348
|
+
str(project.get("id")) == str(project_id)
|
|
349
|
+
or project.get("slug") == project_id
|
|
350
|
+
or project.get("name") == project_id
|
|
351
|
+
):
|
|
352
|
+
return project
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
def list_sessions(
|
|
356
|
+
self,
|
|
357
|
+
org_id: str,
|
|
358
|
+
project_id: str,
|
|
359
|
+
session_name: Optional[str] = None,
|
|
360
|
+
session_type: Optional[str] = None,
|
|
361
|
+
branch: Optional[str] = None,
|
|
362
|
+
limit: Optional[int] = None,
|
|
363
|
+
offset: Optional[int] = None,
|
|
364
|
+
) -> List[Dict[str, Any]]:
|
|
365
|
+
"""
|
|
366
|
+
List sessions in a project with optional filtering.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
org_id: Organization ID or slug.
|
|
370
|
+
project_id: Project ID or slug.
|
|
371
|
+
session_name: Filter by session name (exact match).
|
|
372
|
+
session_type: Filter by session type (e.g., "pr", "prod", "manual").
|
|
373
|
+
branch: Filter by branch name (exact match).
|
|
374
|
+
limit: Maximum number of results to return (1-1000).
|
|
375
|
+
offset: Number of results to skip for pagination.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
List of session dictionaries.
|
|
379
|
+
|
|
380
|
+
Raises:
|
|
381
|
+
RecceCloudException: If the API call fails.
|
|
382
|
+
"""
|
|
383
|
+
api_url = f"{self.base_url_v2}/organizations/{org_id}/projects/{project_id}/sessions"
|
|
384
|
+
params = {}
|
|
385
|
+
if session_name:
|
|
386
|
+
params["name"] = session_name
|
|
387
|
+
if session_type:
|
|
388
|
+
params["type"] = session_type
|
|
389
|
+
if branch:
|
|
390
|
+
params["branch"] = branch
|
|
391
|
+
if limit is not None:
|
|
392
|
+
params["limit"] = limit
|
|
393
|
+
if offset is not None:
|
|
394
|
+
params["offset"] = offset
|
|
395
|
+
|
|
396
|
+
response = self._request("GET", api_url, params=params if params else None)
|
|
397
|
+
|
|
398
|
+
if response.status_code == 404:
|
|
399
|
+
raise RecceCloudException(
|
|
400
|
+
reason="Organization or project not found",
|
|
401
|
+
status_code=response.status_code,
|
|
402
|
+
)
|
|
403
|
+
if response.status_code != 200:
|
|
404
|
+
raise RecceCloudException(
|
|
405
|
+
reason=response.text,
|
|
406
|
+
status_code=response.status_code,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
data = response.json()
|
|
410
|
+
return data.get("sessions", [])
|
|
411
|
+
|
|
412
|
+
def get_session_by_name(
|
|
413
|
+
self,
|
|
414
|
+
org_id: str,
|
|
415
|
+
project_id: str,
|
|
416
|
+
session_name: str,
|
|
417
|
+
) -> Optional[Dict[str, Any]]:
|
|
418
|
+
"""
|
|
419
|
+
Get a session by its name.
|
|
420
|
+
|
|
421
|
+
Uses the list sessions endpoint with filtering to find a session
|
|
422
|
+
by name, which is unique within a project.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
org_id: Organization ID or slug.
|
|
426
|
+
project_id: Project ID or slug.
|
|
427
|
+
session_name: The session name to look up.
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
Session dictionary if found, None otherwise.
|
|
431
|
+
|
|
432
|
+
Raises:
|
|
433
|
+
RecceCloudException: If the API call fails.
|
|
434
|
+
"""
|
|
435
|
+
sessions = self.list_sessions(
|
|
436
|
+
org_id=org_id,
|
|
437
|
+
project_id=project_id,
|
|
438
|
+
session_name=session_name,
|
|
439
|
+
limit=1,
|
|
440
|
+
)
|
|
441
|
+
if sessions:
|
|
442
|
+
return sessions[0]
|
|
443
|
+
return None
|
|
444
|
+
|
|
445
|
+
def create_session(
|
|
446
|
+
self,
|
|
447
|
+
org_id: str,
|
|
448
|
+
project_id: str,
|
|
449
|
+
session_name: str,
|
|
450
|
+
adapter_type: Optional[str] = None,
|
|
451
|
+
session_type: str = "manual",
|
|
452
|
+
) -> Dict[str, Any]:
|
|
453
|
+
"""
|
|
454
|
+
Create a new session with the given name.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
org_id: Organization ID or slug.
|
|
458
|
+
project_id: Project ID or slug.
|
|
459
|
+
session_name: The name for the new session.
|
|
460
|
+
adapter_type: dbt adapter type (e.g., "postgres", "snowflake").
|
|
461
|
+
session_type: Session type (default: "manual").
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
Created session dictionary with id, name, and other fields.
|
|
465
|
+
|
|
466
|
+
Raises:
|
|
467
|
+
RecceCloudException: If the API call fails or session creation fails.
|
|
468
|
+
"""
|
|
469
|
+
api_url = f"{self.base_url_v2}/organizations/{org_id}/projects/{project_id}/sessions"
|
|
470
|
+
data = {
|
|
471
|
+
"name": session_name,
|
|
472
|
+
"type": session_type,
|
|
473
|
+
}
|
|
474
|
+
if adapter_type:
|
|
475
|
+
data["adapter_type"] = adapter_type
|
|
476
|
+
|
|
477
|
+
response = self._request("POST", api_url, json=data)
|
|
478
|
+
|
|
479
|
+
if response.status_code == 404:
|
|
480
|
+
raise RecceCloudException(
|
|
481
|
+
reason="Organization or project not found",
|
|
482
|
+
status_code=response.status_code,
|
|
483
|
+
)
|
|
484
|
+
if response.status_code == 409:
|
|
485
|
+
raise RecceCloudException(
|
|
486
|
+
reason=f"Session with name '{session_name}' already exists",
|
|
487
|
+
status_code=response.status_code,
|
|
488
|
+
)
|
|
489
|
+
if response.status_code == 403:
|
|
490
|
+
raise RecceCloudException(
|
|
491
|
+
reason=response.json().get("detail", "Permission denied"),
|
|
492
|
+
status_code=response.status_code,
|
|
493
|
+
)
|
|
494
|
+
if response.status_code not in [200, 201]:
|
|
495
|
+
raise RecceCloudException(
|
|
496
|
+
reason=response.text,
|
|
497
|
+
status_code=response.status_code,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
result = response.json()
|
|
501
|
+
# Handle both direct session response and wrapped response
|
|
502
|
+
if "session" in result:
|
|
503
|
+
return result["session"]
|
|
504
|
+
return result
|
|
505
|
+
|
|
263
506
|
|
|
264
507
|
class ReportClient:
|
|
265
508
|
"""Client for fetching reports from Recce Cloud API."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication module for Recce Cloud CLI.
|
|
3
|
+
|
|
4
|
+
This module provides browser-based OAuth authentication and credential management.
|
|
5
|
+
It is designed to be standalone (duplicated from Recce OSS) to support future
|
|
6
|
+
repository separation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from recce_cloud.auth.profile import (
|
|
10
|
+
get_api_token,
|
|
11
|
+
get_user_id,
|
|
12
|
+
load_profile,
|
|
13
|
+
update_api_token,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"get_api_token",
|
|
18
|
+
"get_user_id",
|
|
19
|
+
"load_profile",
|
|
20
|
+
"update_api_token",
|
|
21
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
One-time HTTP callback server for OAuth authentication.
|
|
3
|
+
|
|
4
|
+
This server receives the encrypted token from Recce Cloud after
|
|
5
|
+
browser-based authentication.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import threading
|
|
9
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
from urllib.parse import parse_qs, urlparse
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CallbackResult:
|
|
16
|
+
"""Container for callback result."""
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self.code: Optional[str] = None
|
|
20
|
+
self.error: Optional[str] = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def make_callback_handler(result: CallbackResult, on_success_html: str, on_error_html: str):
|
|
24
|
+
"""
|
|
25
|
+
Create a one-time HTTP request handler.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
result: CallbackResult object to store the received code.
|
|
29
|
+
on_success_html: HTML content to return on success.
|
|
30
|
+
on_error_html: HTML content to return on error.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
HTTP request handler class.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
class OneTimeHTTPRequestHandler(BaseHTTPRequestHandler):
|
|
37
|
+
def do_GET(self):
|
|
38
|
+
try:
|
|
39
|
+
parsed_url = urlparse(self.path)
|
|
40
|
+
query_params = parse_qs(parsed_url.query)
|
|
41
|
+
|
|
42
|
+
code = query_params.get("code", [None])[0]
|
|
43
|
+
if not code:
|
|
44
|
+
result.error = "Missing 'code' parameter in callback"
|
|
45
|
+
self._send_response(400, on_error_html)
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
result.code = code
|
|
49
|
+
self._send_response(200, on_success_html)
|
|
50
|
+
|
|
51
|
+
except Exception as e:
|
|
52
|
+
result.error = str(e)
|
|
53
|
+
self._send_response(500, on_error_html)
|
|
54
|
+
finally:
|
|
55
|
+
# Shutdown server after handling the request
|
|
56
|
+
self.server.server_close()
|
|
57
|
+
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
58
|
+
|
|
59
|
+
def _send_response(self, status_code: int, html_content: str):
|
|
60
|
+
self.send_response(status_code)
|
|
61
|
+
self.send_header("Content-Type", "text/html")
|
|
62
|
+
self.send_header("Content-Length", str(len(html_content.encode())))
|
|
63
|
+
self.end_headers()
|
|
64
|
+
self.wfile.write(html_content.encode())
|
|
65
|
+
|
|
66
|
+
def log_message(self, format, *args):
|
|
67
|
+
# Suppress default logging
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
return OneTimeHTTPRequestHandler
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run_callback_server(
|
|
74
|
+
port: int,
|
|
75
|
+
result: CallbackResult,
|
|
76
|
+
on_success_html: str,
|
|
77
|
+
on_error_html: str,
|
|
78
|
+
timeout: int = 300,
|
|
79
|
+
) -> bool:
|
|
80
|
+
"""
|
|
81
|
+
Run a one-time HTTP server to receive the OAuth callback.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
port: Port to listen on.
|
|
85
|
+
result: CallbackResult object to store the received code.
|
|
86
|
+
on_success_html: HTML content to return on success.
|
|
87
|
+
on_error_html: HTML content to return on error.
|
|
88
|
+
timeout: Server timeout in seconds (default 5 minutes).
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
True if callback was received, False if timeout or error.
|
|
92
|
+
"""
|
|
93
|
+
handler = make_callback_handler(result, on_success_html, on_error_html)
|
|
94
|
+
server = HTTPServer(("localhost", port), handler)
|
|
95
|
+
server.timeout = timeout
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
# Handle a single request
|
|
99
|
+
server.handle_request()
|
|
100
|
+
return result.code is not None
|
|
101
|
+
except Exception:
|
|
102
|
+
return False
|
|
103
|
+
finally:
|
|
104
|
+
server.server_close()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _load_template(filename: str) -> str:
|
|
108
|
+
"""
|
|
109
|
+
Load HTML template from the templates directory.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
filename: Name of the template file (e.g., 'success.html').
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
HTML content as string.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
FileNotFoundError: If template file is not found.
|
|
119
|
+
"""
|
|
120
|
+
templates_dir = Path(__file__).parent / "templates"
|
|
121
|
+
template_path = templates_dir / filename
|
|
122
|
+
with open(template_path, "r", encoding="utf-8") as f:
|
|
123
|
+
return f.read()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Load HTML templates from separate files
|
|
127
|
+
SUCCESS_HTML = _load_template("success.html")
|
|
128
|
+
ERROR_HTML = _load_template("error.html")
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Browser-based OAuth login flow for Recce Cloud.
|
|
3
|
+
|
|
4
|
+
This module implements the RSA-encrypted OAuth flow:
|
|
5
|
+
1. Generate RSA-2048 key pair
|
|
6
|
+
2. Open browser to Recce Cloud with public key
|
|
7
|
+
3. Start local callback server
|
|
8
|
+
4. Receive encrypted token via callback
|
|
9
|
+
5. Decrypt and verify token
|
|
10
|
+
6. Save to profile
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import os
|
|
15
|
+
import random
|
|
16
|
+
import webbrowser
|
|
17
|
+
from typing import Optional, Tuple
|
|
18
|
+
|
|
19
|
+
import requests
|
|
20
|
+
from cryptography.hazmat.backends import default_backend
|
|
21
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
22
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
23
|
+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
|
|
26
|
+
from recce_cloud.auth.callback_server import (
|
|
27
|
+
ERROR_HTML,
|
|
28
|
+
SUCCESS_HTML,
|
|
29
|
+
CallbackResult,
|
|
30
|
+
run_callback_server,
|
|
31
|
+
)
|
|
32
|
+
from recce_cloud.auth.profile import (
|
|
33
|
+
clear_api_token,
|
|
34
|
+
get_api_token,
|
|
35
|
+
get_profile_path,
|
|
36
|
+
update_api_token,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Cloud API configuration
|
|
40
|
+
RECCE_CLOUD_API_HOST = os.environ.get("RECCE_CLOUD_API_HOST", "https://cloud.datarecce.io")
|
|
41
|
+
RECCE_CLOUD_BASE_URL = os.environ.get("RECCE_CLOUD_BASE_URL", RECCE_CLOUD_API_HOST)
|
|
42
|
+
|
|
43
|
+
console = Console()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def generate_key_pair() -> Tuple[RSAPrivateKey, RSAPublicKey]:
|
|
47
|
+
"""
|
|
48
|
+
Generate RSA-2048 key pair for secure token exchange.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Tuple of (private_key, public_key).
|
|
52
|
+
"""
|
|
53
|
+
private_key = rsa.generate_private_key(
|
|
54
|
+
public_exponent=65537,
|
|
55
|
+
key_size=2048,
|
|
56
|
+
backend=default_backend(),
|
|
57
|
+
)
|
|
58
|
+
public_key = private_key.public_key()
|
|
59
|
+
return private_key, public_key
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def decrypt_code(private_key: RSAPrivateKey, encrypted_code: str) -> str:
|
|
63
|
+
"""
|
|
64
|
+
Decrypt the RSA-encrypted token from Recce Cloud.
|
|
65
|
+
|
|
66
|
+
Uses RSA-OAEP-SHA1 padding to match Node.js defaults.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
private_key: RSA private key.
|
|
70
|
+
encrypted_code: Base64-encoded encrypted token.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Decrypted API token string.
|
|
74
|
+
"""
|
|
75
|
+
ciphertext = base64.b64decode(encrypted_code)
|
|
76
|
+
plaintext = private_key.decrypt(
|
|
77
|
+
ciphertext,
|
|
78
|
+
padding.OAEP(
|
|
79
|
+
mgf=padding.MGF1(algorithm=hashes.SHA1()), # Node.js uses SHA1 by default
|
|
80
|
+
algorithm=hashes.SHA1(),
|
|
81
|
+
label=None,
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
return plaintext.decode("utf-8")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def prepare_connection_url(public_key: RSAPublicKey) -> Tuple[str, int]:
|
|
88
|
+
"""
|
|
89
|
+
Prepare the OAuth connection URL with public key.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
public_key: RSA public key to include in URL.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Tuple of (connection_url, callback_port).
|
|
96
|
+
"""
|
|
97
|
+
public_key_pem_bytes = public_key.public_bytes(
|
|
98
|
+
encoding=serialization.Encoding.PEM,
|
|
99
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
100
|
+
)
|
|
101
|
+
public_key_pem_str = base64.b64encode(public_key_pem_bytes).decode("utf-8")
|
|
102
|
+
callback_port = random.randint(10000, 15000)
|
|
103
|
+
connect_url = f"{RECCE_CLOUD_BASE_URL}/connect?_key={public_key_pem_str}&_port={callback_port}"
|
|
104
|
+
return connect_url, callback_port
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def verify_token(token: str) -> bool:
|
|
108
|
+
"""
|
|
109
|
+
Verify the API token with Recce Cloud.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
token: API token to verify.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
True if token is valid, False otherwise.
|
|
116
|
+
"""
|
|
117
|
+
api_url = f"{RECCE_CLOUD_API_HOST}/api/v1/verify-token"
|
|
118
|
+
try:
|
|
119
|
+
response = requests.get(
|
|
120
|
+
api_url,
|
|
121
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
122
|
+
timeout=30,
|
|
123
|
+
)
|
|
124
|
+
return response.status_code == 200
|
|
125
|
+
except Exception:
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_user_info(token: str) -> Optional[dict]:
|
|
130
|
+
"""
|
|
131
|
+
Get user information from Recce Cloud.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
token: API token for authentication.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
User info dict with email, name, etc., or None if failed.
|
|
138
|
+
"""
|
|
139
|
+
api_url = f"{RECCE_CLOUD_API_HOST}/api/v1/users"
|
|
140
|
+
try:
|
|
141
|
+
response = requests.get(
|
|
142
|
+
api_url,
|
|
143
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
144
|
+
timeout=30,
|
|
145
|
+
)
|
|
146
|
+
if response.status_code == 200:
|
|
147
|
+
return response.json().get("user")
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def login_with_browser() -> bool:
|
|
154
|
+
"""
|
|
155
|
+
Perform browser-based OAuth login.
|
|
156
|
+
|
|
157
|
+
Opens browser to Recce Cloud, waits for callback with encrypted token,
|
|
158
|
+
decrypts and verifies token, then saves to profile.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
True if login successful, False otherwise.
|
|
162
|
+
"""
|
|
163
|
+
console.print("Opening browser to authenticate...")
|
|
164
|
+
console.print()
|
|
165
|
+
|
|
166
|
+
# Generate key pair
|
|
167
|
+
private_key, public_key = generate_key_pair()
|
|
168
|
+
connect_url, callback_port = prepare_connection_url(public_key)
|
|
169
|
+
|
|
170
|
+
# Open browser
|
|
171
|
+
webbrowser.open(connect_url)
|
|
172
|
+
|
|
173
|
+
# Always show the URL for manual access
|
|
174
|
+
console.print("If the browser does not open, please visit:")
|
|
175
|
+
console.print(f" [cyan]{connect_url}[/cyan]")
|
|
176
|
+
console.print()
|
|
177
|
+
|
|
178
|
+
# Wait for callback
|
|
179
|
+
console.print("Waiting for authentication...")
|
|
180
|
+
result = CallbackResult()
|
|
181
|
+
|
|
182
|
+
if not run_callback_server(callback_port, result, SUCCESS_HTML, ERROR_HTML):
|
|
183
|
+
if result.error:
|
|
184
|
+
console.print(f"[red]Error:[/red] {result.error}")
|
|
185
|
+
else:
|
|
186
|
+
console.print("[red]Error:[/red] Authentication timed out")
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
if not result.code:
|
|
190
|
+
console.print("[red]Error:[/red] No authentication code received")
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
# Decrypt token
|
|
194
|
+
try:
|
|
195
|
+
api_token = decrypt_code(private_key, result.code)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
console.print(f"[red]Error:[/red] Failed to decrypt authentication code: {e}")
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
# Verify token
|
|
201
|
+
if not verify_token(api_token):
|
|
202
|
+
console.print("[red]Error:[/red] Invalid token received from Recce Cloud")
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
# Save token
|
|
206
|
+
update_api_token(api_token)
|
|
207
|
+
|
|
208
|
+
# Get user info for display
|
|
209
|
+
user_info = get_user_info(api_token)
|
|
210
|
+
if user_info:
|
|
211
|
+
email = user_info.get("email", "Unknown")
|
|
212
|
+
console.print(f"[green]✓[/green] Logged in as [cyan]{email}[/cyan]")
|
|
213
|
+
else:
|
|
214
|
+
console.print("[green]✓[/green] Logged in successfully")
|
|
215
|
+
|
|
216
|
+
console.print(f" Credentials saved to {get_profile_path()}")
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def login_with_token(token: str) -> bool:
|
|
221
|
+
"""
|
|
222
|
+
Login with a manually provided token.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
token: API token to use.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
True if login successful, False otherwise.
|
|
229
|
+
"""
|
|
230
|
+
# Verify token
|
|
231
|
+
if not verify_token(token):
|
|
232
|
+
console.print("[red]Error:[/red] Invalid API token")
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
# Save token
|
|
236
|
+
update_api_token(token)
|
|
237
|
+
|
|
238
|
+
# Get user info for display
|
|
239
|
+
user_info = get_user_info(token)
|
|
240
|
+
if user_info:
|
|
241
|
+
email = user_info.get("email", "Unknown")
|
|
242
|
+
console.print(f"[green]✓[/green] Logged in as [cyan]{email}[/cyan]")
|
|
243
|
+
else:
|
|
244
|
+
console.print("[green]✓[/green] Logged in successfully")
|
|
245
|
+
|
|
246
|
+
console.print(f" Credentials saved to {get_profile_path()}")
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def check_login_status() -> Tuple[bool, Optional[str]]:
|
|
251
|
+
"""
|
|
252
|
+
Check current login status.
|
|
253
|
+
|
|
254
|
+
Checks for authentication token in this order:
|
|
255
|
+
1. RECCE_API_TOKEN environment variable
|
|
256
|
+
2. Stored token from profile (via get_api_token)
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Tuple of (is_logged_in, user_email_or_none).
|
|
260
|
+
"""
|
|
261
|
+
token = os.getenv("RECCE_API_TOKEN") or get_api_token()
|
|
262
|
+
if not token:
|
|
263
|
+
return False, None
|
|
264
|
+
|
|
265
|
+
if not verify_token(token):
|
|
266
|
+
return False, None
|
|
267
|
+
|
|
268
|
+
user_info = get_user_info(token)
|
|
269
|
+
email = user_info.get("email") if user_info else None
|
|
270
|
+
return True, email
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def logout() -> bool:
|
|
274
|
+
"""
|
|
275
|
+
Clear stored credentials.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
True if logout successful.
|
|
279
|
+
"""
|
|
280
|
+
clear_api_token()
|
|
281
|
+
return True
|