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.
- upscaler_cli-0.1.0/PKG-INFO +15 -0
- upscaler_cli-0.1.0/README.md +110 -0
- upscaler_cli-0.1.0/pyproject.toml +43 -0
- upscaler_cli-0.1.0/setup.cfg +4 -0
- upscaler_cli-0.1.0/src/SKILL.md +85 -0
- upscaler_cli-0.1.0/src/__init__.py +3 -0
- upscaler_cli-0.1.0/src/auth/__init__.py +0 -0
- upscaler_cli-0.1.0/src/auth/encryption.py +83 -0
- upscaler_cli-0.1.0/src/auth/oauth.py +324 -0
- upscaler_cli-0.1.0/src/auth/token_store.py +117 -0
- upscaler_cli-0.1.0/src/cli/__init__.py +0 -0
- upscaler_cli-0.1.0/src/cli/asset.py +165 -0
- upscaler_cli-0.1.0/src/cli/auth.py +199 -0
- upscaler_cli-0.1.0/src/cli/completions.py +33 -0
- upscaler_cli-0.1.0/src/cli/config_cmd.py +39 -0
- upscaler_cli-0.1.0/src/cli/context.py +17 -0
- upscaler_cli-0.1.0/src/cli/entry.py +135 -0
- upscaler_cli-0.1.0/src/cli/get.py +148 -0
- upscaler_cli-0.1.0/src/cli/helpers.py +108 -0
- upscaler_cli-0.1.0/src/cli/hierarchy.py +44 -0
- upscaler_cli-0.1.0/src/cli/list_cmd.py +84 -0
- upscaler_cli-0.1.0/src/cli/main.py +117 -0
- upscaler_cli-0.1.0/src/cli/search.py +49 -0
- upscaler_cli-0.1.0/src/cli/todo.py +122 -0
- upscaler_cli-0.1.0/src/client.py +144 -0
- upscaler_cli-0.1.0/src/config.py +101 -0
- upscaler_cli-0.1.0/src/errors.py +30 -0
- upscaler_cli-0.1.0/src/formatters/__init__.py +0 -0
- upscaler_cli-0.1.0/src/formatters/json_fmt.py +38 -0
- upscaler_cli-0.1.0/src/formatters/table.py +49 -0
- upscaler_cli-0.1.0/src/formatters/tree.py +47 -0
- upscaler_cli-0.1.0/tests/test_client.py +180 -0
- upscaler_cli-0.1.0/upscaler_cli.egg-info/PKG-INFO +15 -0
- upscaler_cli-0.1.0/upscaler_cli.egg-info/SOURCES.txt +36 -0
- upscaler_cli-0.1.0/upscaler_cli.egg-info/dependency_links.txt +1 -0
- upscaler_cli-0.1.0/upscaler_cli.egg-info/entry_points.txt +2 -0
- upscaler_cli-0.1.0/upscaler_cli.egg-info/requires.txt +11 -0
- 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,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`
|
|
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
|