xenfra 0.3.8__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.
- xenfra-0.3.8/PKG-INFO +118 -0
- xenfra-0.3.8/README.md +83 -0
- xenfra-0.3.8/pyproject.toml +56 -0
- xenfra-0.3.8/src/xenfra/__init__.py +0 -0
- xenfra-0.3.8/src/xenfra/commands/__init__.py +3 -0
- xenfra-0.3.8/src/xenfra/commands/auth.py +144 -0
- xenfra-0.3.8/src/xenfra/commands/auth_device.py +164 -0
- xenfra-0.3.8/src/xenfra/commands/deployments.py +877 -0
- xenfra-0.3.8/src/xenfra/commands/intelligence.py +412 -0
- xenfra-0.3.8/src/xenfra/commands/projects.py +204 -0
- xenfra-0.3.8/src/xenfra/commands/security_cmd.py +233 -0
- xenfra-0.3.8/src/xenfra/main.py +75 -0
- xenfra-0.3.8/src/xenfra/utils/__init__.py +3 -0
- xenfra-0.3.8/src/xenfra/utils/auth.py +374 -0
- xenfra-0.3.8/src/xenfra/utils/codebase.py +169 -0
- xenfra-0.3.8/src/xenfra/utils/config.py +427 -0
- xenfra-0.3.8/src/xenfra/utils/errors.py +116 -0
- xenfra-0.3.8/src/xenfra/utils/security.py +336 -0
- xenfra-0.3.8/src/xenfra/utils/validation.py +234 -0
xenfra-0.3.8/PKG-INFO
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: xenfra
|
|
3
|
+
Version: 0.3.8
|
|
4
|
+
Summary: A 'Zen Mode' infrastructure engine for Python developers.
|
|
5
|
+
Author: xenfra-cloud
|
|
6
|
+
Author-email: xenfra-cloud <xenfracloud@gmail.com>
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
13
|
+
Classifier: Topic :: System :: Systems Administration
|
|
14
|
+
Requires-Dist: click>=8.1.7
|
|
15
|
+
Requires-Dist: rich>=14.2.0
|
|
16
|
+
Requires-Dist: sqlmodel>=0.0.16
|
|
17
|
+
Requires-Dist: python-digitalocean>=1.17.0
|
|
18
|
+
Requires-Dist: python-dotenv>=1.2.1
|
|
19
|
+
Requires-Dist: pyyaml>=6.0.1
|
|
20
|
+
Requires-Dist: fabric>=3.2.2
|
|
21
|
+
Requires-Dist: xenfra-sdk
|
|
22
|
+
Requires-Dist: httpx>=0.27.0
|
|
23
|
+
Requires-Dist: keyring>=25.7.0
|
|
24
|
+
Requires-Dist: keyrings-alt>=5.0.2
|
|
25
|
+
Requires-Dist: tenacity>=8.2.3
|
|
26
|
+
Requires-Dist: cryptography>=43.0.0
|
|
27
|
+
Requires-Dist: toml>=0.10.2
|
|
28
|
+
Requires-Dist: pytest>=8.0.0 ; extra == 'test'
|
|
29
|
+
Requires-Dist: pytest-mock>=3.12.0 ; extra == 'test'
|
|
30
|
+
Requires-Python: >=3.13
|
|
31
|
+
Project-URL: Homepage, https://github.com/xenfra-cloud/xenfra
|
|
32
|
+
Project-URL: Issues, https://github.com/xenfra-cloud/xenfra/issues
|
|
33
|
+
Provides-Extra: test
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# Xenfra CLI
|
|
37
|
+
|
|
38
|
+
## Xenfra CLI: Deploy Python Apps with Zen Mode
|
|
39
|
+
|
|
40
|
+
The Xenfra CLI is a powerful and intuitive command-line interface designed to streamline the deployment of Python applications to DigitalOcean. Built with a "Zen Mode" philosophy, it automates complex infrastructure tasks, allowing developers to focus on writing code.
|
|
41
|
+
|
|
42
|
+
### ✨ Key Features
|
|
43
|
+
|
|
44
|
+
- **Zero-Configuration Deployment:** Automatically detects your project's framework and dependencies.
|
|
45
|
+
- **AI-Powered Auto-Healing:** Diagnoses common deployment failures and suggests, or even applies, fixes automatically.
|
|
46
|
+
- **Real-time Monitoring:** View deployment status and stream live application logs directly from your terminal.
|
|
47
|
+
- **Integrated Project Management:** Easily list, view, and destroy your deployed projects.
|
|
48
|
+
- **Secure Authentication:** Uses OAuth2 PKCE flow for secure, token-based authentication.
|
|
49
|
+
|
|
50
|
+
### 🚀 Quickstart
|
|
51
|
+
|
|
52
|
+
#### 1. Installation
|
|
53
|
+
|
|
54
|
+
Install the Xenfra CLI using `uv` (recommended) or `pip`:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
uv pip install xenfra-cli
|
|
58
|
+
# or
|
|
59
|
+
pip install xenfra-cli
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
#### 2. Authentication
|
|
63
|
+
|
|
64
|
+
Log in to your Xenfra account. This will open your web browser to complete the OAuth2 flow.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
xenfra auth login
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
#### 3. Initialize Your Project
|
|
71
|
+
|
|
72
|
+
Navigate to your Python project's root directory and run `init`. The CLI will scan your codebase, detect its characteristics, and generate a `xenfra.yaml` configuration file.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
cd your-python-project/
|
|
76
|
+
xenfra init
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
#### 4. Deploy Your Application
|
|
80
|
+
|
|
81
|
+
Once `xenfra.yaml` is configured, deploy your application. The CLI will handle provisioning a DigitalOcean Droplet, setting up Docker, and deploying your code.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
xenfra deploy
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 📋 Usage Examples
|
|
88
|
+
|
|
89
|
+
- **Monitor Deployment Status:**
|
|
90
|
+
```bash
|
|
91
|
+
xenfra status <deployment-id>
|
|
92
|
+
```
|
|
93
|
+
- **Stream Application Logs:**
|
|
94
|
+
```bash
|
|
95
|
+
xenfra logs <deployment-id>
|
|
96
|
+
```
|
|
97
|
+
- **List Deployed Projects:**
|
|
98
|
+
```bash
|
|
99
|
+
xenfra projects list
|
|
100
|
+
```
|
|
101
|
+
- **Diagnose a Failed Deployment (AI-Powered):**
|
|
102
|
+
```bash
|
|
103
|
+
xenfra diagnose <deployment-id>
|
|
104
|
+
# Or to diagnose from a log file:
|
|
105
|
+
xenfra diagnose --logs error.log
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### 📚 Documentation
|
|
109
|
+
|
|
110
|
+
For more detailed information, advanced configurations, and API references, please refer to the [official Xenfra Documentation](https://docs.xenfra.tech/cli) (Link will be updated upon final deployment).
|
|
111
|
+
|
|
112
|
+
### 🤝 Contributing
|
|
113
|
+
|
|
114
|
+
We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for more details.
|
|
115
|
+
|
|
116
|
+
### 📄 License
|
|
117
|
+
|
|
118
|
+
This project is licensed under the [MIT License](LICENSE).
|
xenfra-0.3.8/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Xenfra CLI
|
|
2
|
+
|
|
3
|
+
## Xenfra CLI: Deploy Python Apps with Zen Mode
|
|
4
|
+
|
|
5
|
+
The Xenfra CLI is a powerful and intuitive command-line interface designed to streamline the deployment of Python applications to DigitalOcean. Built with a "Zen Mode" philosophy, it automates complex infrastructure tasks, allowing developers to focus on writing code.
|
|
6
|
+
|
|
7
|
+
### ✨ Key Features
|
|
8
|
+
|
|
9
|
+
- **Zero-Configuration Deployment:** Automatically detects your project's framework and dependencies.
|
|
10
|
+
- **AI-Powered Auto-Healing:** Diagnoses common deployment failures and suggests, or even applies, fixes automatically.
|
|
11
|
+
- **Real-time Monitoring:** View deployment status and stream live application logs directly from your terminal.
|
|
12
|
+
- **Integrated Project Management:** Easily list, view, and destroy your deployed projects.
|
|
13
|
+
- **Secure Authentication:** Uses OAuth2 PKCE flow for secure, token-based authentication.
|
|
14
|
+
|
|
15
|
+
### 🚀 Quickstart
|
|
16
|
+
|
|
17
|
+
#### 1. Installation
|
|
18
|
+
|
|
19
|
+
Install the Xenfra CLI using `uv` (recommended) or `pip`:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv pip install xenfra-cli
|
|
23
|
+
# or
|
|
24
|
+
pip install xenfra-cli
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
#### 2. Authentication
|
|
28
|
+
|
|
29
|
+
Log in to your Xenfra account. This will open your web browser to complete the OAuth2 flow.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
xenfra auth login
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
#### 3. Initialize Your Project
|
|
36
|
+
|
|
37
|
+
Navigate to your Python project's root directory and run `init`. The CLI will scan your codebase, detect its characteristics, and generate a `xenfra.yaml` configuration file.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
cd your-python-project/
|
|
41
|
+
xenfra init
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
#### 4. Deploy Your Application
|
|
45
|
+
|
|
46
|
+
Once `xenfra.yaml` is configured, deploy your application. The CLI will handle provisioning a DigitalOcean Droplet, setting up Docker, and deploying your code.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
xenfra deploy
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 📋 Usage Examples
|
|
53
|
+
|
|
54
|
+
- **Monitor Deployment Status:**
|
|
55
|
+
```bash
|
|
56
|
+
xenfra status <deployment-id>
|
|
57
|
+
```
|
|
58
|
+
- **Stream Application Logs:**
|
|
59
|
+
```bash
|
|
60
|
+
xenfra logs <deployment-id>
|
|
61
|
+
```
|
|
62
|
+
- **List Deployed Projects:**
|
|
63
|
+
```bash
|
|
64
|
+
xenfra projects list
|
|
65
|
+
```
|
|
66
|
+
- **Diagnose a Failed Deployment (AI-Powered):**
|
|
67
|
+
```bash
|
|
68
|
+
xenfra diagnose <deployment-id>
|
|
69
|
+
# Or to diagnose from a log file:
|
|
70
|
+
xenfra diagnose --logs error.log
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 📚 Documentation
|
|
74
|
+
|
|
75
|
+
For more detailed information, advanced configurations, and API references, please refer to the [official Xenfra Documentation](https://docs.xenfra.tech/cli) (Link will be updated upon final deployment).
|
|
76
|
+
|
|
77
|
+
### 🤝 Contributing
|
|
78
|
+
|
|
79
|
+
We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for more details.
|
|
80
|
+
|
|
81
|
+
### 📄 License
|
|
82
|
+
|
|
83
|
+
This project is licensed under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "xenfra"
|
|
3
|
+
version = "0.3.8"
|
|
4
|
+
description = "A 'Zen Mode' infrastructure engine for Python developers."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "xenfra-cloud", email = "xenfracloud@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Topic :: Software Development :: Build Tools",
|
|
17
|
+
"Topic :: System :: Systems Administration",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
dependencies = [
|
|
21
|
+
"click>=8.1.7",
|
|
22
|
+
"rich>=14.2.0",
|
|
23
|
+
"sqlmodel>=0.0.16",
|
|
24
|
+
"python-digitalocean>=1.17.0",
|
|
25
|
+
"python-dotenv>=1.2.1",
|
|
26
|
+
"pyyaml>=6.0.1",
|
|
27
|
+
"fabric>=3.2.2",
|
|
28
|
+
"xenfra-sdk",
|
|
29
|
+
"httpx>=0.27.0",
|
|
30
|
+
"keyring>=25.7.0",
|
|
31
|
+
"keyrings.alt>=5.0.2",
|
|
32
|
+
"tenacity>=8.2.3", # For retry logic
|
|
33
|
+
"cryptography>=43.0.0", # For encrypted file-based token storage
|
|
34
|
+
"toml>=0.10.2",
|
|
35
|
+
]
|
|
36
|
+
requires-python = ">=3.13"
|
|
37
|
+
|
|
38
|
+
[tool.uv.sources]
|
|
39
|
+
xenfra-sdk = { workspace = true }
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/xenfra-cloud/xenfra"
|
|
43
|
+
Issues = "https://github.com/xenfra-cloud/xenfra/issues"
|
|
44
|
+
|
|
45
|
+
[project.optional-dependencies]
|
|
46
|
+
test = [
|
|
47
|
+
"pytest>=8.0.0",
|
|
48
|
+
"pytest-mock>=3.12.0",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[project.scripts]
|
|
52
|
+
xenfra = "xenfra.main:main"
|
|
53
|
+
|
|
54
|
+
[build-system]
|
|
55
|
+
requires = ["uv_build>=0.9.18,<0.10.0"]
|
|
56
|
+
build-backend = "uv_build"
|
|
File without changes
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication commands for Xenfra CLI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import secrets
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import webbrowser
|
|
10
|
+
from http.server import HTTPServer
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
import httpx
|
|
14
|
+
import keyring
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from tenacity import (
|
|
17
|
+
retry,
|
|
18
|
+
retry_if_exception_type,
|
|
19
|
+
stop_after_attempt,
|
|
20
|
+
wait_exponential,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from ..utils.auth import (
|
|
24
|
+
API_BASE_URL,
|
|
25
|
+
CLI_CLIENT_ID,
|
|
26
|
+
CLI_LOCAL_SERVER_END_PORT,
|
|
27
|
+
CLI_LOCAL_SERVER_START_PORT,
|
|
28
|
+
CLI_REDIRECT_PATH,
|
|
29
|
+
SERVICE_ID,
|
|
30
|
+
AuthCallbackHandler,
|
|
31
|
+
clear_tokens,
|
|
32
|
+
get_auth_token,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
console = Console()
|
|
36
|
+
|
|
37
|
+
# HTTP request timeout (30 seconds)
|
|
38
|
+
HTTP_TIMEOUT = 30.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@click.group()
|
|
42
|
+
def auth():
|
|
43
|
+
"""Authentication commands (login, logout, whoami)."""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@retry(
|
|
48
|
+
stop=stop_after_attempt(3),
|
|
49
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
50
|
+
retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
|
|
51
|
+
reraise=True,
|
|
52
|
+
)
|
|
53
|
+
def _exchange_code_for_tokens_with_retry(code: str, code_verifier: str, redirect_uri: str) -> dict:
|
|
54
|
+
"""
|
|
55
|
+
Exchange authorization code for tokens with retry logic.
|
|
56
|
+
|
|
57
|
+
Returns token data dictionary.
|
|
58
|
+
"""
|
|
59
|
+
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
|
|
60
|
+
response = client.post(
|
|
61
|
+
f"{API_BASE_URL}/auth/token",
|
|
62
|
+
data={
|
|
63
|
+
"grant_type": "authorization_code",
|
|
64
|
+
"client_id": CLI_CLIENT_ID,
|
|
65
|
+
"code": code,
|
|
66
|
+
"code_verifier": code_verifier,
|
|
67
|
+
"redirect_uri": redirect_uri,
|
|
68
|
+
},
|
|
69
|
+
headers={"Accept": "application/json"},
|
|
70
|
+
)
|
|
71
|
+
response.raise_for_status()
|
|
72
|
+
|
|
73
|
+
# Safe JSON parsing with content-type check
|
|
74
|
+
content_type = response.headers.get("content-type", "")
|
|
75
|
+
if "application/json" not in content_type:
|
|
76
|
+
raise ValueError(f"Expected JSON response, got {content_type}")
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
token_data = response.json()
|
|
80
|
+
except (ValueError, TypeError) as e:
|
|
81
|
+
raise ValueError(f"Failed to parse JSON response: {e}")
|
|
82
|
+
|
|
83
|
+
return token_data
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@auth.command()
|
|
87
|
+
def login():
|
|
88
|
+
"""Login to Xenfra using Device Authorization Flow (like GitHub CLI, Claude Code)."""
|
|
89
|
+
from .auth_device import device_login
|
|
90
|
+
device_login()
|
|
91
|
+
|
|
92
|
+
# Removed old PKCE flow - now using Device Authorization Flow
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@auth.command()
|
|
96
|
+
def logout():
|
|
97
|
+
"""Logout and clear stored tokens."""
|
|
98
|
+
try:
|
|
99
|
+
clear_tokens()
|
|
100
|
+
console.print("[bold green]Logged out successfully.[/bold green]")
|
|
101
|
+
except Exception as e:
|
|
102
|
+
console.print(f"[yellow]Warning: Error during logout: {e}[/yellow]")
|
|
103
|
+
console.print("[dim]Tokens may still be stored in keyring.[/dim]")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@auth.command()
|
|
107
|
+
@click.option("--token", is_flag=True, help="Show access token")
|
|
108
|
+
def whoami(token):
|
|
109
|
+
"""Show current authenticated user."""
|
|
110
|
+
access_token = get_auth_token()
|
|
111
|
+
|
|
112
|
+
if not access_token:
|
|
113
|
+
console.print("[bold red]Not logged in. Run 'xenfra login' first.[/bold red]")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
import base64
|
|
118
|
+
import json
|
|
119
|
+
|
|
120
|
+
# Manually decode JWT payload without verification
|
|
121
|
+
# JWT format: header.payload.signature
|
|
122
|
+
parts = access_token.split(".")
|
|
123
|
+
if len(parts) != 3:
|
|
124
|
+
console.print("[bold red]Invalid token format[/bold red]")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# Decode payload (second part)
|
|
128
|
+
payload_b64 = parts[1]
|
|
129
|
+
# Add padding if needed
|
|
130
|
+
padding = 4 - len(payload_b64) % 4
|
|
131
|
+
if padding != 4:
|
|
132
|
+
payload_b64 += "=" * padding
|
|
133
|
+
|
|
134
|
+
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
|
135
|
+
claims = json.loads(payload_bytes)
|
|
136
|
+
|
|
137
|
+
console.print("[bold green]Logged in as:[/bold green]")
|
|
138
|
+
console.print(f" Email: {claims.get('sub', 'N/A')}")
|
|
139
|
+
console.print(f" User ID: {claims.get('user_id', 'N/A')}")
|
|
140
|
+
|
|
141
|
+
if token:
|
|
142
|
+
console.print(f"\n[dim]Access Token:[/dim]\n{access_token}")
|
|
143
|
+
except Exception as e:
|
|
144
|
+
console.print(f"[bold red]Failed to decode token: {e}[/bold red]")
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Device Authorization Flow for Xenfra CLI.
|
|
3
|
+
Modern OAuth flow used by GitHub CLI, AWS CLI, Claude Code, etc.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
import webbrowser
|
|
8
|
+
from urllib.parse import urlencode
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import httpx
|
|
12
|
+
import keyring
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
|
|
16
|
+
from ..utils.auth import API_BASE_URL, CLI_CLIENT_ID, HTTP_TIMEOUT, SERVICE_ID
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def device_login():
|
|
22
|
+
"""
|
|
23
|
+
Device Authorization Flow (OAuth 2.0 Device Grant).
|
|
24
|
+
|
|
25
|
+
Flow:
|
|
26
|
+
1. CLI calls /auth/device/authorize to get device_code and user_code
|
|
27
|
+
2. User visits https://www.xenfra.tech/activate and enters user_code
|
|
28
|
+
3. CLI polls /auth/device/token until user authorizes
|
|
29
|
+
4. CLI receives access_token and stores it
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
# Step 1: Request device code
|
|
33
|
+
console.print("[cyan]Initiating device authorization...[/cyan]")
|
|
34
|
+
|
|
35
|
+
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
|
|
36
|
+
response = client.post(
|
|
37
|
+
f"{API_BASE_URL}/auth/device/authorize",
|
|
38
|
+
data={
|
|
39
|
+
"client_id": CLI_CLIENT_ID,
|
|
40
|
+
"scope": "openid profile",
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
response.raise_for_status()
|
|
44
|
+
device_data = response.json()
|
|
45
|
+
|
|
46
|
+
device_code = device_data["device_code"]
|
|
47
|
+
user_code = device_data["user_code"]
|
|
48
|
+
verification_uri = device_data["verification_uri"]
|
|
49
|
+
verification_uri_complete = device_data.get("verification_uri_complete")
|
|
50
|
+
expires_in = device_data["expires_in"]
|
|
51
|
+
interval = device_data.get("interval", 5)
|
|
52
|
+
|
|
53
|
+
# Step 2: Show user code and open browser
|
|
54
|
+
console.print()
|
|
55
|
+
console.print(
|
|
56
|
+
Panel.fit(
|
|
57
|
+
f"[bold white]{user_code}[/bold white]",
|
|
58
|
+
title="[bold green]Your Activation Code[/bold green]",
|
|
59
|
+
border_style="green",
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
console.print()
|
|
63
|
+
console.print(f"[bold]Visit:[/bold] [link]{verification_uri}[/link]")
|
|
64
|
+
console.print(f"[bold]Enter code:[/bold] [cyan]{user_code}[/cyan]")
|
|
65
|
+
console.print()
|
|
66
|
+
|
|
67
|
+
# Open browser automatically
|
|
68
|
+
try:
|
|
69
|
+
url_to_open = verification_uri_complete or verification_uri
|
|
70
|
+
webbrowser.open(url_to_open)
|
|
71
|
+
console.print("[dim]Opening browser...[/dim]")
|
|
72
|
+
except Exception:
|
|
73
|
+
console.print("[yellow]Could not open browser automatically. Please visit the URL above.[/yellow]")
|
|
74
|
+
|
|
75
|
+
# Step 3: Poll for authorization
|
|
76
|
+
console.print()
|
|
77
|
+
console.print("[cyan]Waiting for authorization...[/cyan]")
|
|
78
|
+
console.print("[dim](Press Ctrl+C to cancel)[/dim]")
|
|
79
|
+
console.print()
|
|
80
|
+
|
|
81
|
+
start_time = time.time()
|
|
82
|
+
poll_count = 0
|
|
83
|
+
|
|
84
|
+
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
|
|
85
|
+
while True:
|
|
86
|
+
# Check timeout
|
|
87
|
+
if time.time() - start_time > expires_in:
|
|
88
|
+
console.print("[bold red]✗ Authorization timed out. Please try again.[/bold red]")
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
# Poll the token endpoint
|
|
92
|
+
try:
|
|
93
|
+
response = client.post(
|
|
94
|
+
f"{API_BASE_URL}/auth/device/token",
|
|
95
|
+
data={
|
|
96
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
97
|
+
"device_code": device_code,
|
|
98
|
+
"client_id": CLI_CLIENT_ID,
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if response.status_code == 200:
|
|
103
|
+
# Success! User authorized
|
|
104
|
+
token_data = response.json()
|
|
105
|
+
access_token = token_data["access_token"]
|
|
106
|
+
refresh_token = token_data.get("refresh_token")
|
|
107
|
+
|
|
108
|
+
# Store tokens (keyring or file fallback)
|
|
109
|
+
try:
|
|
110
|
+
keyring.set_password(SERVICE_ID, "access_token", access_token)
|
|
111
|
+
if refresh_token:
|
|
112
|
+
keyring.set_password(SERVICE_ID, "refresh_token", refresh_token)
|
|
113
|
+
except keyring.errors.KeyringError as e:
|
|
114
|
+
console.print(f"[dim]Keyring unavailable, using file storage: {e}[/dim]")
|
|
115
|
+
# Fallback to file storage
|
|
116
|
+
from ..utils.auth import _set_token_to_file
|
|
117
|
+
_set_token_to_file("access_token", access_token)
|
|
118
|
+
if refresh_token:
|
|
119
|
+
_set_token_to_file("refresh_token", refresh_token)
|
|
120
|
+
|
|
121
|
+
console.print()
|
|
122
|
+
console.print("[bold green]✓ Successfully authenticated![/bold green]")
|
|
123
|
+
console.print()
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
elif response.status_code == 400:
|
|
127
|
+
error_data = response.json()
|
|
128
|
+
error = error_data.get("error", "unknown_error")
|
|
129
|
+
|
|
130
|
+
if error == "authorization_pending":
|
|
131
|
+
# Still waiting for user to authorize
|
|
132
|
+
poll_count += 1
|
|
133
|
+
if poll_count % 6 == 0: # Every 30 seconds
|
|
134
|
+
console.print("[dim]Still waiting...[/dim]")
|
|
135
|
+
time.sleep(interval)
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
elif error == "slow_down":
|
|
139
|
+
# We're polling too fast
|
|
140
|
+
interval += 5
|
|
141
|
+
time.sleep(interval)
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
else:
|
|
145
|
+
# Other error
|
|
146
|
+
error_desc = error_data.get("error_description", error)
|
|
147
|
+
console.print(f"[bold red]✗ Authorization failed: {error_desc}[/bold red]")
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
else:
|
|
151
|
+
console.print(f"[bold red]✗ Unexpected response: {response.status_code}[/bold red]")
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
except httpx.HTTPError as e:
|
|
155
|
+
console.print(f"[bold red]✗ Network error: {e}[/bold red]")
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
except KeyboardInterrupt:
|
|
159
|
+
console.print()
|
|
160
|
+
console.print("[yellow]Authorization cancelled.[/yellow]")
|
|
161
|
+
return False
|
|
162
|
+
except Exception as e:
|
|
163
|
+
console.print(f"[bold red]✗ Error: {e}[/bold red]")
|
|
164
|
+
return False
|