upscaler-cli 0.1.0__tar.gz

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 (38) hide show
  1. upscaler_cli-0.1.0/PKG-INFO +15 -0
  2. upscaler_cli-0.1.0/README.md +110 -0
  3. upscaler_cli-0.1.0/pyproject.toml +43 -0
  4. upscaler_cli-0.1.0/setup.cfg +4 -0
  5. upscaler_cli-0.1.0/src/SKILL.md +85 -0
  6. upscaler_cli-0.1.0/src/__init__.py +3 -0
  7. upscaler_cli-0.1.0/src/auth/__init__.py +0 -0
  8. upscaler_cli-0.1.0/src/auth/encryption.py +83 -0
  9. upscaler_cli-0.1.0/src/auth/oauth.py +324 -0
  10. upscaler_cli-0.1.0/src/auth/token_store.py +117 -0
  11. upscaler_cli-0.1.0/src/cli/__init__.py +0 -0
  12. upscaler_cli-0.1.0/src/cli/asset.py +165 -0
  13. upscaler_cli-0.1.0/src/cli/auth.py +199 -0
  14. upscaler_cli-0.1.0/src/cli/completions.py +33 -0
  15. upscaler_cli-0.1.0/src/cli/config_cmd.py +39 -0
  16. upscaler_cli-0.1.0/src/cli/context.py +17 -0
  17. upscaler_cli-0.1.0/src/cli/entry.py +135 -0
  18. upscaler_cli-0.1.0/src/cli/get.py +148 -0
  19. upscaler_cli-0.1.0/src/cli/helpers.py +108 -0
  20. upscaler_cli-0.1.0/src/cli/hierarchy.py +44 -0
  21. upscaler_cli-0.1.0/src/cli/list_cmd.py +84 -0
  22. upscaler_cli-0.1.0/src/cli/main.py +117 -0
  23. upscaler_cli-0.1.0/src/cli/search.py +49 -0
  24. upscaler_cli-0.1.0/src/cli/todo.py +122 -0
  25. upscaler_cli-0.1.0/src/client.py +144 -0
  26. upscaler_cli-0.1.0/src/config.py +101 -0
  27. upscaler_cli-0.1.0/src/errors.py +30 -0
  28. upscaler_cli-0.1.0/src/formatters/__init__.py +0 -0
  29. upscaler_cli-0.1.0/src/formatters/json_fmt.py +38 -0
  30. upscaler_cli-0.1.0/src/formatters/table.py +49 -0
  31. upscaler_cli-0.1.0/src/formatters/tree.py +47 -0
  32. upscaler_cli-0.1.0/tests/test_client.py +180 -0
  33. upscaler_cli-0.1.0/upscaler_cli.egg-info/PKG-INFO +15 -0
  34. upscaler_cli-0.1.0/upscaler_cli.egg-info/SOURCES.txt +36 -0
  35. upscaler_cli-0.1.0/upscaler_cli.egg-info/dependency_links.txt +1 -0
  36. upscaler_cli-0.1.0/upscaler_cli.egg-info/entry_points.txt +2 -0
  37. upscaler_cli-0.1.0/upscaler_cli.egg-info/requires.txt +11 -0
  38. upscaler_cli-0.1.0/upscaler_cli.egg-info/top_level.txt +1 -0
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: upscaler-cli
3
+ Version: 0.1.0
4
+ Summary: Upscaler CLI - search, retrieve, and manage documents, records, and workflows
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: click>=8.0
7
+ Requires-Dist: httpx>=0.25
8
+ Requires-Dist: cryptography>=41.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8.0; extra == "dev"
11
+ Requires-Dist: pytest-mock>=3.14; extra == "dev"
12
+ Requires-Dist: respx>=0.21; extra == "dev"
13
+ Requires-Dist: black>=24.0; extra == "dev"
14
+ Requires-Dist: flake8>=7.0; extra == "dev"
15
+ Requires-Dist: isort>=5.13; extra == "dev"
@@ -0,0 +1,110 @@
1
+ # Upscaler CLI
2
+
3
+ Command-line tool for searching, retrieving, and managing Upscaler documents, records, and workflows.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install up-sdk
9
+ ```
10
+
11
+ ## Setup
12
+
13
+ ```bash
14
+ upscaler config set server_url https://your-upscaler-api.com
15
+ upscaler login
16
+ ```
17
+
18
+ For dev/staging with self-signed certificates:
19
+
20
+ ```bash
21
+ upscaler config set verify_ssl false
22
+ ```
23
+
24
+ ## Quick Start
25
+
26
+ ```bash
27
+ # Check connection
28
+ upscaler health
29
+
30
+ # Search documents
31
+ upscaler search "safety procedures"
32
+
33
+ # Get an asset
34
+ upscaler get rg_abc123
35
+ upscaler get rg_abc123 --format schema
36
+ upscaler get rg_abc123 --format markdown
37
+
38
+ # List data
39
+ upscaler list definitions
40
+ upscaler list entries --definition-id rg_abc123
41
+ upscaler list todos
42
+
43
+ # View hierarchy
44
+ upscaler hierarchy d_abc123
45
+
46
+ # Manage todos
47
+ upscaler todo create --title "Review document"
48
+ upscaler todo close to_abc123
49
+
50
+ # Manage entries
51
+ upscaler entry create --definition-id rg_abc123 --data '{"title": "New item"}'
52
+ upscaler entry create --definition-id rg_abc123 --data @payload.json
53
+
54
+ # Get members and groups
55
+ upscaler get <firebase_uid> --type member
56
+ upscaler get g_abc123
57
+ ```
58
+
59
+ ## Global Flags
60
+
61
+ Global flags go **before** the command:
62
+
63
+ ```bash
64
+ upscaler --json list todos # JSON output
65
+ upscaler --verbose search "audit" # show HTTP details
66
+ upscaler --server https://... health # override server URL
67
+ upscaler --version # print version
68
+ ```
69
+
70
+ ## Write Safety
71
+
72
+ Preview changes before committing:
73
+
74
+ ```bash
75
+ upscaler entry create --definition-id rg_123 --data @payload.json --dry-run
76
+ upscaler asset delete --asset-id rg_123 --dry-run
77
+ ```
78
+
79
+ ## Data Input
80
+
81
+ Write commands accept `--data` in three forms:
82
+
83
+ ```bash
84
+ --data '{"key": "value"}' # inline JSON
85
+ --data @payload.json # from file (recommended)
86
+ --data - # from stdin
87
+ ```
88
+
89
+ ## Configuration
90
+
91
+ ```bash
92
+ upscaler config set server_url https://api.example.com
93
+ upscaler config set verify_ssl false
94
+ upscaler config get server_url
95
+ ```
96
+
97
+ Settings stored at `~/.upscaler/config.json`. Overridden by environment variables (`UPSCALER_SERVER`, `UPSCALER_VERIFY_SSL`) and flags (`--server`).
98
+
99
+ ## Auth Commands
100
+
101
+ ```bash
102
+ upscaler login # browser-based OAuth2 login
103
+ upscaler status # check auth state + token expiry
104
+ upscaler refresh # refresh expired token
105
+ upscaler logout # revoke and clear tokens
106
+ ```
107
+
108
+ ## All Commands
109
+
110
+ Run `upscaler --help` for the full command list, or `upscaler <command> --help` for details on any command.
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "upscaler-cli"
7
+ version = "0.1.0"
8
+ description = "Upscaler CLI - search, retrieve, and manage documents, records, and workflows"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "click>=8.0",
12
+ "httpx>=0.25",
13
+ "cryptography>=41.0",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ dev = [
18
+ "pytest>=8.0",
19
+ "pytest-mock>=3.14",
20
+ "respx>=0.21",
21
+ "black>=24.0",
22
+ "flake8>=7.0",
23
+ "isort>=5.13",
24
+ ]
25
+
26
+ [project.scripts]
27
+ upscaler = "src.cli.main:cli"
28
+
29
+ [tool.setuptools.packages.find]
30
+ include = ["src*"]
31
+
32
+ [tool.setuptools.package-data]
33
+ src = ["SKILL.md"]
34
+
35
+ [tool.black]
36
+ line-length = 100
37
+
38
+ [tool.isort]
39
+ profile = "black"
40
+ line_length = 100
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,85 @@
1
+ # Upscaler CLI
2
+
3
+ Search, retrieve, and manage Upscaler documents, records, and workflows.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ pip install up-sdk
9
+ upscaler config set server_url https://your-api-url.com
10
+ upscaler config set verify_ssl false # for dev/staging with self-signed certs
11
+ upscaler login
12
+ ```
13
+
14
+ ## Important: Global flags go BEFORE the command
15
+
16
+ ```bash
17
+ upscaler --json list todos # correct
18
+ upscaler list todos --json # WRONG — --json is a global flag
19
+ upscaler --verbose --json search "query" # correct
20
+ ```
21
+
22
+ ## Commands
23
+
24
+ ### Read
25
+
26
+ ```bash
27
+ upscaler search "safety procedures" # table output
28
+ upscaler --json search "compliance" --limit 5 --type policy # JSON output
29
+ upscaler get <asset_id> # asset overview
30
+ upscaler get <asset_id> --format markdown # document content
31
+ upscaler get <asset_id> --format schema # field schema
32
+ upscaler get <uid> --type member # member by ID
33
+ upscaler get <group_id> --type group # group by ID (also auto-detected for g_ prefix)
34
+ upscaler hierarchy <asset_id> # asset tree
35
+ upscaler hierarchy <asset_id> --depth 5 # deep tree
36
+ upscaler list definitions # all definitions
37
+ upscaler list entries --definition-id <id> # entries for a definition
38
+ upscaler list todos # your todos
39
+ upscaler list field-options --definition-id <id> --field-key <key>
40
+ ```
41
+
42
+ ### Write (use --dry-run to preview changes)
43
+
44
+ ```bash
45
+ upscaler todo create --title "Review doc"
46
+ upscaler todo close <id>
47
+ upscaler entry create --definition-id <id> --data @payload.json
48
+ upscaler entry update --entry-id <id> --data '{"values": {...}}'
49
+ upscaler asset create --type register_definition --data '{"title": "..."}'
50
+ upscaler asset delete --asset-id <id> --dry-run # preview only
51
+ ```
52
+
53
+ ### Data Input
54
+
55
+ Write commands accept `--data` in three forms:
56
+
57
+ - Flag value: `--data '{"key": "value"}'`
58
+ - File reference: `--data @payload.json` (safest — avoids shell escaping)
59
+ - Stdin pipe: `echo '{}' | upscaler entry create --data -`
60
+
61
+ ### Utility
62
+
63
+ ```bash
64
+ upscaler health # check server connectivity
65
+ upscaler status # check auth state
66
+ upscaler config set <key> <value> # persist settings
67
+ upscaler config get <key> # read settings
68
+ ```
69
+
70
+ ## Agent Usage
71
+
72
+ Always use `--json` (global flag, before the command) for structured output:
73
+
74
+ ```bash
75
+ upscaler --json search "audit"
76
+ upscaler --json get <asset_id>
77
+ upscaler --json list todos
78
+ upscaler --json todo create --title "x"
79
+ ```
80
+
81
+ ## Error Handling
82
+
83
+ - Exit code 0 = success
84
+ - Exit code 1 = error → check stderr for details
85
+ - Exit code 2 = auth required → tell user to run `upscaler login`
@@ -0,0 +1,3 @@
1
+ """Upscaler CLI — search, retrieve, and manage Upscaler content."""
2
+
3
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,83 @@
1
+ """Token encryption using Fernet with machine-bound PBKDF2 key derivation.
2
+
3
+ Security model:
4
+ - Key derived from machine identity (hostname:username) + random salt
5
+ - PBKDF2-HMAC-SHA256 with 480,000 iterations
6
+ - Fernet symmetric encryption (AES-128-CBC + HMAC-SHA256)
7
+ - Tokens encrypted at rest, decrypted in-memory only
8
+ - Different machine = different key = decryption fails
9
+ """
10
+
11
+ import base64
12
+ import getpass
13
+ import json
14
+ import platform
15
+
16
+ from cryptography.fernet import Fernet, InvalidToken
17
+ from cryptography.hazmat.primitives import hashes
18
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
19
+
20
+
21
+ def _get_machine_identity() -> str:
22
+ """Return machine-bound identity string: '{hostname}:{username}'."""
23
+ hostname = platform.node()
24
+ username = getpass.getuser()
25
+ return f"{hostname}:{username}"
26
+
27
+
28
+ def derive_key(salt: bytes) -> bytes:
29
+ """Derive a Fernet-compatible key from machine identity and salt.
30
+
31
+ Args:
32
+ salt: Random bytes (16 bytes recommended) from ~/.upscaler/.salt
33
+
34
+ Returns:
35
+ 32-byte key suitable for Fernet encryption.
36
+ """
37
+ identity = _get_machine_identity().encode("utf-8")
38
+ kdf = PBKDF2HMAC(
39
+ algorithm=hashes.SHA256(),
40
+ length=32,
41
+ salt=salt,
42
+ iterations=480_000,
43
+ )
44
+ raw_key = kdf.derive(identity)
45
+ return base64.urlsafe_b64encode(raw_key)
46
+
47
+
48
+ def encrypt(plaintext: str, key: bytes) -> bytes:
49
+ """Encrypt a plaintext string using Fernet.
50
+
51
+ Args:
52
+ plaintext: String to encrypt (typically JSON-serialized token data).
53
+ key: Fernet-compatible key from derive_key().
54
+
55
+ Returns:
56
+ Encrypted bytes (Fernet token).
57
+ """
58
+ fernet = Fernet(key)
59
+ return fernet.encrypt(plaintext.encode("utf-8"))
60
+
61
+
62
+ def decrypt(ciphertext: bytes, key: bytes) -> str:
63
+ """Decrypt Fernet-encrypted bytes back to plaintext string.
64
+
65
+ Args:
66
+ ciphertext: Encrypted bytes from encrypt().
67
+ key: Same Fernet key used for encryption.
68
+
69
+ Returns:
70
+ Decrypted plaintext string.
71
+
72
+ Raises:
73
+ RuntimeError: If decryption fails (wrong key, corrupted data, or
74
+ token copied from another machine).
75
+ """
76
+ fernet = Fernet(key)
77
+ try:
78
+ return fernet.decrypt(ciphertext).decode("utf-8")
79
+ except InvalidToken:
80
+ raise RuntimeError(
81
+ "Token store corrupted or copied from another machine. "
82
+ "Run: upscaler login"
83
+ )
@@ -0,0 +1,324 @@
1
+ """OAuth2 Authorization Code + PKCE flow for CLI login.
2
+
3
+ Flow:
4
+ 1. Register client via Dynamic Client Registration (DCR)
5
+ 2. Generate PKCE code verifier + challenge
6
+ 3. Start localhost HTTP server for callback
7
+ 4. Open browser to authorize URL
8
+ 5. Wait for callback with auth code
9
+ 6. Exchange code for tokens
10
+ 7. Return TokenData
11
+
12
+ Also supports:
13
+ - Token refresh (refresh_token grant)
14
+ - Token revocation (best-effort)
15
+ """
16
+
17
+ import asyncio
18
+ import base64
19
+ import hashlib
20
+ import http.server
21
+ import logging
22
+ import os
23
+ import secrets
24
+ import socket
25
+ import sys
26
+ import threading
27
+ import time
28
+ import webbrowser
29
+ from dataclasses import dataclass
30
+ from typing import Optional, Tuple
31
+ from urllib.parse import parse_qs, urlencode, urlparse
32
+
33
+ import httpx
34
+
35
+ from src.auth.token_store import TokenData
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # Default callback port for localhost server
40
+ DEFAULT_PORT = 19876
41
+ LOGIN_TIMEOUT = 120 # seconds
42
+
43
+
44
+ def generate_pkce() -> Tuple[str, str]:
45
+ """Generate PKCE code verifier and challenge.
46
+
47
+ Returns:
48
+ Tuple of (verifier, challenge) where:
49
+ - verifier: 43-128 character random string
50
+ - challenge: base64url(sha256(verifier))
51
+ """
52
+ # Generate 32 random bytes → 43 character base64url string
53
+ verifier = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode("ascii")
54
+
55
+ # S256 challenge
56
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
57
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
58
+
59
+ return verifier, challenge
60
+
61
+
62
+ def generate_state() -> str:
63
+ """Generate random state parameter for CSRF prevention."""
64
+ return secrets.token_urlsafe(32)
65
+
66
+
67
+ class OAuthFlow:
68
+ """Manages OAuth2 flows: login, refresh, revoke."""
69
+
70
+ def __init__(self, server_url: str, verify_ssl: bool = True):
71
+ """Initialize OAuth flow.
72
+
73
+ Args:
74
+ server_url: Base URL of the Upscaler API (e.g., https://api.upscaler.com)
75
+ verify_ssl: If False, skip SSL certificate verification.
76
+ """
77
+ self.server_url = server_url.rstrip("/")
78
+ self.oauth_base = f"{self.server_url}/mcp"
79
+ self.verify_ssl = verify_ssl
80
+
81
+ async def register_client(self) -> Tuple[str, str]:
82
+ """Register a new OAuth client via Dynamic Client Registration.
83
+
84
+ Returns:
85
+ Tuple of (client_id, client_secret).
86
+ """
87
+ url = f"{self.oauth_base}/register"
88
+ payload = {
89
+ "client_name": f"upscaler-cli-{secrets.token_hex(4)}",
90
+ "grant_types": ["authorization_code", "refresh_token"],
91
+ "response_types": ["code"],
92
+ "redirect_uris": [f"http://127.0.0.1:{DEFAULT_PORT}/callback"],
93
+ "token_endpoint_auth_method": "client_secret_post",
94
+ }
95
+
96
+ async with httpx.AsyncClient(verify=self.verify_ssl) as client:
97
+ response = await client.post(url, json=payload)
98
+ response.raise_for_status()
99
+ data = response.json()
100
+
101
+ return data["client_id"], data["client_secret"]
102
+
103
+ async def login(self, port: int = DEFAULT_PORT) -> TokenData:
104
+ """Run full OAuth2 PKCE login flow.
105
+
106
+ Args:
107
+ port: Localhost port for callback server.
108
+
109
+ Returns:
110
+ TokenData with access + refresh tokens.
111
+
112
+ Raises:
113
+ RuntimeError: On timeout, port conflict, or auth failure.
114
+ """
115
+ # Check port availability
116
+ if not self._is_port_available(port):
117
+ raise RuntimeError(
118
+ f"Login callback port {port} in use. Use --port to specify another."
119
+ )
120
+
121
+ # Register client
122
+ client_id, client_secret = await self.register_client()
123
+
124
+ # Generate PKCE
125
+ verifier, challenge = generate_pkce()
126
+ state = generate_state()
127
+
128
+ # Start callback server
129
+ auth_code_holder = {"code": None, "error": None}
130
+ server = self._start_callback_server(port, state, auth_code_holder)
131
+
132
+ try:
133
+ # Build authorize URL
134
+ redirect_uri = f"http://127.0.0.1:{port}/callback"
135
+ params = {
136
+ "response_type": "code",
137
+ "client_id": client_id,
138
+ "redirect_uri": redirect_uri,
139
+ "state": state,
140
+ "code_challenge": challenge,
141
+ "code_challenge_method": "S256",
142
+ "scope": "mcp:read mcp:write",
143
+ }
144
+ authorize_url = f"{self.oauth_base}/authorize?{urlencode(params)}"
145
+
146
+ # Open browser
147
+ if not webbrowser.open(authorize_url):
148
+ print(
149
+ f"Open this URL in your browser: {authorize_url}", file=sys.stderr
150
+ )
151
+
152
+ # Wait for callback
153
+ deadline = time.time() + LOGIN_TIMEOUT
154
+ while time.time() < deadline:
155
+ if auth_code_holder["code"] or auth_code_holder["error"]:
156
+ break
157
+ await asyncio.sleep(0.5)
158
+
159
+ if auth_code_holder["error"]:
160
+ raise RuntimeError(f"Login failed: {auth_code_holder['error']}")
161
+
162
+ if not auth_code_holder["code"]:
163
+ raise RuntimeError("Login timed out. Run upscaler login to try again.")
164
+
165
+ # Exchange code for tokens
166
+ token_data = await self._exchange_code(
167
+ code=auth_code_holder["code"],
168
+ verifier=verifier,
169
+ redirect_uri=redirect_uri,
170
+ client_id=client_id,
171
+ client_secret=client_secret,
172
+ )
173
+
174
+ return token_data
175
+
176
+ finally:
177
+ server.shutdown()
178
+
179
+ async def refresh(self, token_data: TokenData) -> TokenData:
180
+ """Exchange refresh token for new access + refresh tokens.
181
+
182
+ Args:
183
+ token_data: Current token data with refresh_token.
184
+
185
+ Returns:
186
+ Updated TokenData with new tokens.
187
+
188
+ Raises:
189
+ RuntimeError: If refresh fails (session expired).
190
+ """
191
+ payload = {
192
+ "grant_type": "refresh_token",
193
+ "refresh_token": token_data.refresh_token,
194
+ "client_id": token_data.client_id,
195
+ "client_secret": token_data.client_secret,
196
+ }
197
+
198
+ async with httpx.AsyncClient(verify=self.verify_ssl) as client:
199
+ response = await client.post(token_data.token_endpoint, data=payload)
200
+
201
+ if response.status_code != 200:
202
+ raise RuntimeError("Session expired. Run: upscaler login")
203
+
204
+ data = response.json()
205
+ return TokenData(
206
+ access_token=data["access_token"],
207
+ refresh_token=data.get("refresh_token", token_data.refresh_token),
208
+ expires_at=time.time() + data.get("expires_in", 86400),
209
+ client_id=token_data.client_id,
210
+ client_secret=token_data.client_secret,
211
+ organization_id=token_data.organization_id,
212
+ token_endpoint=token_data.token_endpoint,
213
+ )
214
+
215
+ async def revoke(self, token_data: TokenData) -> None:
216
+ """Revoke tokens (best-effort — does not raise on failure)."""
217
+ url = f"{self.oauth_base}/revoke"
218
+ payload = {
219
+ "token": token_data.access_token,
220
+ "client_id": token_data.client_id,
221
+ "client_secret": token_data.client_secret,
222
+ }
223
+
224
+ try:
225
+ async with httpx.AsyncClient(timeout=5.0, verify=self.verify_ssl) as client:
226
+ await client.post(url, data=payload)
227
+ except Exception as e:
228
+ logger.debug(f"Token revocation failed (best-effort): {e}")
229
+
230
+ async def _exchange_code(
231
+ self,
232
+ code: str,
233
+ verifier: str,
234
+ redirect_uri: str,
235
+ client_id: str,
236
+ client_secret: str,
237
+ ) -> TokenData:
238
+ """Exchange authorization code for tokens."""
239
+ token_url = f"{self.oauth_base}/token"
240
+ payload = {
241
+ "grant_type": "authorization_code",
242
+ "code": code,
243
+ "redirect_uri": redirect_uri,
244
+ "code_verifier": verifier,
245
+ "client_id": client_id,
246
+ "client_secret": client_secret,
247
+ }
248
+
249
+ async with httpx.AsyncClient(verify=self.verify_ssl) as client:
250
+ response = await client.post(token_url, data=payload)
251
+
252
+ if response.status_code != 200:
253
+ raise RuntimeError(
254
+ f"Token exchange failed ({response.status_code}). Run: upscaler login"
255
+ )
256
+
257
+ data = response.json()
258
+ return TokenData(
259
+ access_token=data["access_token"],
260
+ refresh_token=data["refresh_token"],
261
+ expires_at=time.time() + data.get("expires_in", 86400),
262
+ client_id=client_id,
263
+ client_secret=client_secret,
264
+ organization_id=data.get("organization_id"),
265
+ token_endpoint=token_url,
266
+ )
267
+
268
+ def _is_port_available(self, port: int) -> bool:
269
+ """Check if a port is available for binding."""
270
+ try:
271
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
272
+ s.bind(("127.0.0.1", port))
273
+ return True
274
+ except OSError:
275
+ return False
276
+
277
+ def _start_callback_server(
278
+ self, port: int, expected_state: str, result_holder: dict
279
+ ) -> http.server.HTTPServer:
280
+ """Start a localhost HTTP server to receive the OAuth callback."""
281
+
282
+ class CallbackHandler(http.server.BaseHTTPRequestHandler):
283
+ def do_GET(self):
284
+ parsed = urlparse(self.path)
285
+
286
+ # Ignore non-callback requests (favicon, prefetch, etc.)
287
+ if not parsed.path.rstrip("/").endswith("/callback"):
288
+ self.send_response(204)
289
+ self.end_headers()
290
+ return
291
+
292
+ params = parse_qs(parsed.query)
293
+
294
+ state = params.get("state", [None])[0]
295
+ code = params.get("code", [None])[0]
296
+ error = params.get("error", [None])[0]
297
+
298
+ if error:
299
+ result_holder["error"] = error
300
+ self.send_response(200)
301
+ self.end_headers()
302
+ self.wfile.write(b"Login failed. You can close this tab.")
303
+ return
304
+
305
+ if state != expected_state:
306
+ result_holder["error"] = "State mismatch — possible CSRF attack"
307
+ self.send_response(400)
308
+ self.end_headers()
309
+ self.wfile.write(b"State mismatch. Login cancelled.")
310
+ return
311
+
312
+ if code:
313
+ result_holder["code"] = code
314
+ self.send_response(200)
315
+ self.end_headers()
316
+ self.wfile.write(b"Login successful! You can close this tab.")
317
+
318
+ def log_message(self, format, *args):
319
+ pass # Suppress server logs
320
+
321
+ server = http.server.HTTPServer(("127.0.0.1", port), CallbackHandler)
322
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
323
+ thread.start()
324
+ return server