xenfra 0.3.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.
- xenfra/__init__.py +0 -0
- xenfra/commands/__init__.py +3 -0
- xenfra/commands/auth.py +137 -0
- xenfra/commands/auth_device.py +159 -0
- xenfra/commands/deployments.py +666 -0
- xenfra/commands/intelligence.py +343 -0
- xenfra/commands/projects.py +204 -0
- xenfra/commands/security_cmd.py +233 -0
- xenfra/main.py +75 -0
- xenfra/utils/__init__.py +3 -0
- xenfra/utils/auth.py +243 -0
- xenfra/utils/codebase.py +126 -0
- xenfra/utils/config.py +363 -0
- xenfra/utils/security.py +336 -0
- xenfra/utils/validation.py +234 -0
- xenfra-0.3.1.dist-info/METADATA +116 -0
- xenfra-0.3.1.dist-info/RECORD +19 -0
- xenfra-0.3.1.dist-info/WHEEL +4 -0
- xenfra-0.3.1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Security configuration commands for Xenfra CLI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from ..utils.security import ALLOWED_DOMAINS, security_config, validate_and_get_api_url
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.group(hidden=True)
|
|
18
|
+
def security():
|
|
19
|
+
"""Security and configuration management (for debugging/advanced users)."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@security.command()
|
|
24
|
+
def check():
|
|
25
|
+
"""Display current security configuration."""
|
|
26
|
+
# Get current API URL
|
|
27
|
+
try:
|
|
28
|
+
api_url = validate_and_get_api_url()
|
|
29
|
+
except click.Abort:
|
|
30
|
+
api_url = os.getenv("XENFRA_API_URL", "Not set")
|
|
31
|
+
|
|
32
|
+
# Display configuration
|
|
33
|
+
console.print("\n[bold cyan]🔒 Xenfra CLI Security Configuration[/bold cyan]\n")
|
|
34
|
+
|
|
35
|
+
# API URL
|
|
36
|
+
console.print(f"[bold]API URL:[/bold] {api_url}")
|
|
37
|
+
|
|
38
|
+
# Environment
|
|
39
|
+
env_color = "green" if security_config.is_production() else "yellow"
|
|
40
|
+
console.print(
|
|
41
|
+
f"[bold]Environment:[/bold] [{env_color}]{security_config.environment}[/{env_color}]"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
console.print()
|
|
45
|
+
|
|
46
|
+
# Security features table
|
|
47
|
+
table = Table(title="Security Features", show_header=True)
|
|
48
|
+
table.add_column("Feature", style="cyan")
|
|
49
|
+
table.add_column("Status", style="white")
|
|
50
|
+
table.add_column("Description", style="dim")
|
|
51
|
+
|
|
52
|
+
features = [
|
|
53
|
+
(
|
|
54
|
+
"HTTPS Enforcement",
|
|
55
|
+
"✅ Enabled" if security_config.enforce_https else "⚠️ Disabled",
|
|
56
|
+
"Blocks HTTP connections (except localhost)",
|
|
57
|
+
),
|
|
58
|
+
(
|
|
59
|
+
"Domain Whitelist",
|
|
60
|
+
"✅ Enforced" if security_config.enforce_whitelist else "⚠️ Warning Only",
|
|
61
|
+
"Restricts connections to approved domains",
|
|
62
|
+
),
|
|
63
|
+
(
|
|
64
|
+
"HTTP Warning",
|
|
65
|
+
"✅ Enabled" if security_config.warn_on_http else "❌ Disabled",
|
|
66
|
+
"Warns when using insecure HTTP",
|
|
67
|
+
),
|
|
68
|
+
(
|
|
69
|
+
"Certificate Pinning",
|
|
70
|
+
"✅ Enabled" if security_config.enable_cert_pinning else "❌ Disabled",
|
|
71
|
+
"Validates SSL certificate fingerprints",
|
|
72
|
+
),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
for feature, status, description in features:
|
|
76
|
+
table.add_row(feature, status, description)
|
|
77
|
+
|
|
78
|
+
console.print(table)
|
|
79
|
+
console.print()
|
|
80
|
+
|
|
81
|
+
# Whitelisted domains
|
|
82
|
+
console.print("[bold]Whitelisted Domains:[/bold]")
|
|
83
|
+
for domain in ALLOWED_DOMAINS:
|
|
84
|
+
console.print(f" • {domain}")
|
|
85
|
+
|
|
86
|
+
console.print()
|
|
87
|
+
|
|
88
|
+
# Environment variables
|
|
89
|
+
console.print("[bold]Configuration via Environment Variables:[/bold]")
|
|
90
|
+
env_vars = [
|
|
91
|
+
("XENFRA_ENV", os.getenv("XENFRA_ENV", "development")),
|
|
92
|
+
("XENFRA_API_URL", os.getenv("XENFRA_API_URL", "http://localhost:8000")),
|
|
93
|
+
("XENFRA_ENFORCE_HTTPS", os.getenv("XENFRA_ENFORCE_HTTPS", "auto")),
|
|
94
|
+
("XENFRA_ENFORCE_WHITELIST", os.getenv("XENFRA_ENFORCE_WHITELIST", "auto")),
|
|
95
|
+
("XENFRA_ENABLE_CERT_PINNING", os.getenv("XENFRA_ENABLE_CERT_PINNING", "auto")),
|
|
96
|
+
("XENFRA_WARN_ON_HTTP", os.getenv("XENFRA_WARN_ON_HTTP", "true")),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
for var, value in env_vars:
|
|
100
|
+
console.print(f" {var}=[cyan]{value}[/cyan]")
|
|
101
|
+
|
|
102
|
+
console.print()
|
|
103
|
+
|
|
104
|
+
# Security recommendations
|
|
105
|
+
if not security_config.is_production():
|
|
106
|
+
console.print(
|
|
107
|
+
Panel(
|
|
108
|
+
"[yellow]⚠️ Development Mode Active[/yellow]\n\n"
|
|
109
|
+
"For production use:\n"
|
|
110
|
+
"1. Set XENFRA_ENV=production\n"
|
|
111
|
+
"2. Use HTTPS API URL\n"
|
|
112
|
+
"3. All security features will auto-enable",
|
|
113
|
+
title="Recommendation",
|
|
114
|
+
border_style="yellow",
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
console.print(
|
|
119
|
+
Panel(
|
|
120
|
+
"[green]✅ Production Security Enabled[/green]\n\n"
|
|
121
|
+
"All security features are active.\n"
|
|
122
|
+
"Your credentials and data are protected.",
|
|
123
|
+
title="Status",
|
|
124
|
+
border_style="green",
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@security.command()
|
|
130
|
+
@click.argument("url")
|
|
131
|
+
def validate(url):
|
|
132
|
+
"""Validate an API URL against security policies."""
|
|
133
|
+
console.print(f"\n[cyan]Validating URL:[/cyan] {url}\n")
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
validated_url = validate_and_get_api_url(url)
|
|
137
|
+
console.print("[bold green]✅ URL is valid and passed all security checks![/bold green]")
|
|
138
|
+
console.print(f"[dim]Validated URL: {validated_url}[/dim]")
|
|
139
|
+
except click.Abort:
|
|
140
|
+
console.print("[bold red]❌ URL failed security validation[/bold red]")
|
|
141
|
+
except Exception as e:
|
|
142
|
+
console.print(f"[bold red]❌ Validation error: {e}[/bold red]")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@security.command()
|
|
146
|
+
def docs():
|
|
147
|
+
"""Show security documentation."""
|
|
148
|
+
docs_text = """
|
|
149
|
+
[bold cyan]Xenfra CLI Security Guide[/bold cyan]
|
|
150
|
+
|
|
151
|
+
[bold]Environment Detection:[/bold]
|
|
152
|
+
The CLI automatically adjusts security based on the environment:
|
|
153
|
+
|
|
154
|
+
• [green]production[/green]: All security features enforced
|
|
155
|
+
• [yellow]staging[/yellow]: HTTPS required, whitelist warnings
|
|
156
|
+
• [blue]development[/blue]: Permissive (localhost allowed)
|
|
157
|
+
|
|
158
|
+
[bold]Security Features:[/bold]
|
|
159
|
+
|
|
160
|
+
1. [cyan]URL Validation[/cyan]
|
|
161
|
+
- Prevents malicious URL patterns
|
|
162
|
+
- Blocks URLs with embedded credentials
|
|
163
|
+
- Validates scheme (http/https only)
|
|
164
|
+
|
|
165
|
+
2. [cyan]Domain Whitelist[/cyan]
|
|
166
|
+
- Restricts connections to approved domains
|
|
167
|
+
- Prevents credential theft via fake APIs
|
|
168
|
+
- Can be disabled for self-hosted instances
|
|
169
|
+
|
|
170
|
+
3. [cyan]HTTPS Enforcement[/cyan]
|
|
171
|
+
- Requires encrypted connections in production
|
|
172
|
+
- Warns on insecure HTTP (non-localhost)
|
|
173
|
+
- Protects credentials and data in transit
|
|
174
|
+
|
|
175
|
+
4. [cyan]Certificate Pinning[/cyan]
|
|
176
|
+
- Validates SSL certificate fingerprints
|
|
177
|
+
- Prevents man-in-the-middle attacks
|
|
178
|
+
- Optional (enabled in production by default)
|
|
179
|
+
|
|
180
|
+
[bold]Configuration Examples:[/bold]
|
|
181
|
+
|
|
182
|
+
[yellow]Development (default):[/yellow]
|
|
183
|
+
$ xenfra login
|
|
184
|
+
# Uses http://localhost:8000
|
|
185
|
+
|
|
186
|
+
[yellow]Self-hosted instance:[/yellow]
|
|
187
|
+
$ export XENFRA_API_URL=https://xenfra.mycompany.com
|
|
188
|
+
$ export XENFRA_ENFORCE_WHITELIST=false
|
|
189
|
+
$ xenfra login
|
|
190
|
+
|
|
191
|
+
[yellow]Production (strict):[/yellow]
|
|
192
|
+
$ export XENFRA_ENV=production
|
|
193
|
+
$ xenfra login
|
|
194
|
+
# All security features enabled
|
|
195
|
+
|
|
196
|
+
[bold]Environment Variables:[/bold]
|
|
197
|
+
|
|
198
|
+
XENFRA_ENV
|
|
199
|
+
Values: production | staging | development
|
|
200
|
+
Default: development
|
|
201
|
+
|
|
202
|
+
XENFRA_API_URL
|
|
203
|
+
Default: http://localhost:8000 (dev), https://api.xenfra.tech (prod)
|
|
204
|
+
|
|
205
|
+
XENFRA_ENFORCE_HTTPS
|
|
206
|
+
Values: true | false
|
|
207
|
+
Default: false (dev), true (prod)
|
|
208
|
+
|
|
209
|
+
XENFRA_ENFORCE_WHITELIST
|
|
210
|
+
Values: true | false
|
|
211
|
+
Default: false (dev), true (prod)
|
|
212
|
+
|
|
213
|
+
XENFRA_ENABLE_CERT_PINNING
|
|
214
|
+
Values: true | false
|
|
215
|
+
Default: false (dev), true (prod)
|
|
216
|
+
|
|
217
|
+
XENFRA_WARN_ON_HTTP
|
|
218
|
+
Values: true | false
|
|
219
|
+
Default: true
|
|
220
|
+
|
|
221
|
+
[bold]Security Best Practices:[/bold]
|
|
222
|
+
|
|
223
|
+
1. Always use HTTPS in production
|
|
224
|
+
2. Never disable security features without understanding risks
|
|
225
|
+
3. Keep whitelisted domains list updated
|
|
226
|
+
4. Rotate credentials if you suspect compromise
|
|
227
|
+
5. Use environment-specific configurations
|
|
228
|
+
6. Enable all features for production deployments
|
|
229
|
+
|
|
230
|
+
[dim]For more information: https://docs.xenfra.tech/security[/dim]
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
console.print(Panel(docs_text, border_style="cyan", padding=(1, 2)))
|
xenfra/main.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Xenfra CLI - Main entry point.
|
|
3
|
+
|
|
4
|
+
A modern, AI-powered CLI for deploying Python apps to DigitalOcean.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from .commands.auth import auth
|
|
13
|
+
from .commands.deployments import deploy, logs, report, status
|
|
14
|
+
from .commands.intelligence import analyze, diagnose, init
|
|
15
|
+
from .commands.projects import projects
|
|
16
|
+
from .commands.security_cmd import security
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
# Production-ready: API URL is hardcoded as https://api.xenfra.tech
|
|
21
|
+
# No configuration needed - works out of the box after pip install
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.group()
|
|
25
|
+
@click.version_option(version="0.2.9")
|
|
26
|
+
def cli():
|
|
27
|
+
"""
|
|
28
|
+
Xenfra CLI: Deploy Python apps to DigitalOcean with zero configuration.
|
|
29
|
+
|
|
30
|
+
Quick Start:
|
|
31
|
+
xenfra auth login # Authenticate with Xenfra
|
|
32
|
+
xenfra init # Initialize your project (AI-powered)
|
|
33
|
+
xenfra deploy # Deploy to DigitalOcean
|
|
34
|
+
|
|
35
|
+
Commands:
|
|
36
|
+
auth Authentication (login, logout, whoami)
|
|
37
|
+
projects Manage projects (list, show, delete)
|
|
38
|
+
init Smart project initialization (AI-powered)
|
|
39
|
+
diagnose Diagnose deployment failures (AI-powered)
|
|
40
|
+
analyze Analyze codebase without creating config
|
|
41
|
+
|
|
42
|
+
For help on a specific command:
|
|
43
|
+
xenfra <command> --help
|
|
44
|
+
"""
|
|
45
|
+
# Configure keyring backend
|
|
46
|
+
os.environ["KEYRING_BACKEND"] = "keyrings.alt.file.PlaintextKeyring"
|
|
47
|
+
|
|
48
|
+
# Security works silently in the background
|
|
49
|
+
# Only shows warnings if there's an actual security issue
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Register command groups
|
|
53
|
+
cli.add_command(auth)
|
|
54
|
+
cli.add_command(projects)
|
|
55
|
+
cli.add_command(security)
|
|
56
|
+
|
|
57
|
+
# Register intelligence commands at root level
|
|
58
|
+
cli.add_command(init)
|
|
59
|
+
cli.add_command(diagnose)
|
|
60
|
+
cli.add_command(analyze)
|
|
61
|
+
|
|
62
|
+
# Register deployment commands at root level
|
|
63
|
+
cli.add_command(deploy)
|
|
64
|
+
cli.add_command(status)
|
|
65
|
+
cli.add_command(logs)
|
|
66
|
+
cli.add_command(report)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main():
|
|
70
|
+
"""Main entry point."""
|
|
71
|
+
cli()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
xenfra/utils/__init__.py
ADDED
xenfra/utils/auth.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication utilities for Xenfra CLI.
|
|
3
|
+
Handles OAuth2 PKCE flow and token management.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
7
|
+
from urllib.parse import parse_qs, urlparse
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import keyring
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from tenacity import (
|
|
13
|
+
retry,
|
|
14
|
+
retry_if_exception_type,
|
|
15
|
+
stop_after_attempt,
|
|
16
|
+
wait_exponential,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .security import validate_and_get_api_url
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
# Get validated API URL (includes all security checks)
|
|
24
|
+
API_BASE_URL = validate_and_get_api_url()
|
|
25
|
+
SERVICE_ID = "xenfra"
|
|
26
|
+
|
|
27
|
+
# CLI OAuth2 Configuration
|
|
28
|
+
CLI_CLIENT_ID = "xenfra-cli"
|
|
29
|
+
CLI_REDIRECT_PATH = "/auth/callback"
|
|
30
|
+
CLI_LOCAL_SERVER_START_PORT = 8001
|
|
31
|
+
CLI_LOCAL_SERVER_END_PORT = 8005
|
|
32
|
+
|
|
33
|
+
# HTTP request timeout (30 seconds)
|
|
34
|
+
HTTP_TIMEOUT = 30.0
|
|
35
|
+
|
|
36
|
+
# Global storage for OAuth callback data
|
|
37
|
+
oauth_data = {"code": None, "state": None, "error": None}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthCallbackHandler(BaseHTTPRequestHandler):
|
|
41
|
+
"""HTTP handler for OAuth redirect callback."""
|
|
42
|
+
|
|
43
|
+
def do_GET(self):
|
|
44
|
+
global oauth_data
|
|
45
|
+
self.send_response(200)
|
|
46
|
+
self.send_header("Content-type", "text/html")
|
|
47
|
+
self.end_headers()
|
|
48
|
+
|
|
49
|
+
query_params = parse_qs(urlparse(self.path).query)
|
|
50
|
+
|
|
51
|
+
if "code" in query_params:
|
|
52
|
+
oauth_data["code"] = query_params["code"][0]
|
|
53
|
+
oauth_data["state"] = query_params["state"][0] if "state" in query_params else None
|
|
54
|
+
self.wfile.write(
|
|
55
|
+
b"<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>"
|
|
56
|
+
)
|
|
57
|
+
elif "error" in query_params:
|
|
58
|
+
oauth_data["error"] = query_params["error"][0]
|
|
59
|
+
self.wfile.write(
|
|
60
|
+
f"<html><body><h1>Authentication failed!</h1><p>Error: {oauth_data['error']}</p></body></html>".encode()
|
|
61
|
+
)
|
|
62
|
+
else:
|
|
63
|
+
self.wfile.write(
|
|
64
|
+
b"<html><body><h1>Authentication callback received.</h1><p>Waiting for code...</p></body></html>"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Shut down the server after processing
|
|
68
|
+
self.server.shutdown() # type: ignore
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run_local_oauth_server(port: int, redirect_path: str):
|
|
72
|
+
"""Start a local HTTP server to capture the OAuth redirect."""
|
|
73
|
+
server_address = ("127.0.0.1", port)
|
|
74
|
+
httpd = HTTPServer(server_address, AuthCallbackHandler)
|
|
75
|
+
httpd.timeout = 30 # seconds
|
|
76
|
+
console.print(
|
|
77
|
+
f"[dim]Listening for OAuth redirect on http://localhost:{port}{redirect_path}...[/dim]"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Store the server instance in the handler for shutdown
|
|
81
|
+
AuthCallbackHandler.server = httpd # type: ignore
|
|
82
|
+
|
|
83
|
+
# Handle a single request (blocking call)
|
|
84
|
+
httpd.handle_request()
|
|
85
|
+
console.print("[dim]Local OAuth server shut down.[/dim]")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@retry(
|
|
89
|
+
stop=stop_after_attempt(3),
|
|
90
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
91
|
+
retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
|
|
92
|
+
reraise=True,
|
|
93
|
+
)
|
|
94
|
+
def _refresh_token_with_retry(refresh_token: str) -> dict:
|
|
95
|
+
"""
|
|
96
|
+
Refresh access token with retry logic.
|
|
97
|
+
|
|
98
|
+
Returns token data dictionary.
|
|
99
|
+
"""
|
|
100
|
+
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
|
|
101
|
+
response = client.post(
|
|
102
|
+
f"{API_BASE_URL}/auth/refresh",
|
|
103
|
+
data={"refresh_token": refresh_token, "client_id": CLI_CLIENT_ID},
|
|
104
|
+
headers={"Accept": "application/json"},
|
|
105
|
+
)
|
|
106
|
+
response.raise_for_status()
|
|
107
|
+
|
|
108
|
+
# Safe JSON parsing with content-type check
|
|
109
|
+
content_type = response.headers.get("content-type", "")
|
|
110
|
+
if "application/json" not in content_type:
|
|
111
|
+
raise ValueError(f"Expected JSON response, got {content_type}")
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
token_data = response.json()
|
|
115
|
+
except (ValueError, TypeError) as e:
|
|
116
|
+
raise ValueError(f"Failed to parse JSON response: {e}")
|
|
117
|
+
|
|
118
|
+
return token_data
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_auth_token() -> str | None:
|
|
122
|
+
"""
|
|
123
|
+
Retrieve a valid access token, refreshing it if necessary.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Valid access token or None if not authenticated
|
|
127
|
+
"""
|
|
128
|
+
try:
|
|
129
|
+
access_token = keyring.get_password(SERVICE_ID, "access_token")
|
|
130
|
+
refresh_token = keyring.get_password(SERVICE_ID, "refresh_token")
|
|
131
|
+
except keyring.errors.KeyringError as e:
|
|
132
|
+
console.print(f"[yellow]Warning: Could not access keyring: {e}[/yellow]")
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
if not access_token:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
# Check if access token is expired
|
|
139
|
+
# Manually decode JWT payload to check expiration without verifying signature
|
|
140
|
+
try:
|
|
141
|
+
import base64
|
|
142
|
+
import json
|
|
143
|
+
|
|
144
|
+
# JWT format: header.payload.signature
|
|
145
|
+
parts = access_token.split(".")
|
|
146
|
+
if len(parts) != 3:
|
|
147
|
+
claims = None
|
|
148
|
+
else:
|
|
149
|
+
# Decode payload (second part)
|
|
150
|
+
payload_b64 = parts[1]
|
|
151
|
+
# Add padding if needed
|
|
152
|
+
padding = 4 - len(payload_b64) % 4
|
|
153
|
+
if padding != 4:
|
|
154
|
+
payload_b64 += "=" * padding
|
|
155
|
+
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
|
156
|
+
claims = json.loads(payload_bytes)
|
|
157
|
+
|
|
158
|
+
# Check expiration manually
|
|
159
|
+
exp = claims.get("exp")
|
|
160
|
+
if exp:
|
|
161
|
+
import time
|
|
162
|
+
if time.time() >= exp:
|
|
163
|
+
claims = None # Token expired
|
|
164
|
+
except Exception:
|
|
165
|
+
claims = None
|
|
166
|
+
|
|
167
|
+
# Refresh token if expired
|
|
168
|
+
if not claims and refresh_token:
|
|
169
|
+
console.print("[dim]Access token expired. Attempting to refresh...[/dim]")
|
|
170
|
+
try:
|
|
171
|
+
token_data = _refresh_token_with_retry(refresh_token)
|
|
172
|
+
new_access_token = token_data.get("access_token")
|
|
173
|
+
new_refresh_token = token_data.get("refresh_token")
|
|
174
|
+
|
|
175
|
+
if new_access_token:
|
|
176
|
+
try:
|
|
177
|
+
keyring.set_password(SERVICE_ID, "access_token", new_access_token)
|
|
178
|
+
if new_refresh_token:
|
|
179
|
+
keyring.set_password(SERVICE_ID, "refresh_token", new_refresh_token)
|
|
180
|
+
console.print("[bold green]Token refreshed successfully.[/bold green]")
|
|
181
|
+
return new_access_token
|
|
182
|
+
except keyring.errors.KeyringError as e:
|
|
183
|
+
console.print(
|
|
184
|
+
f"[yellow]Warning: Could not save refreshed token to keyring: {e}[/yellow]"
|
|
185
|
+
)
|
|
186
|
+
# Return the token anyway, but warn user
|
|
187
|
+
return new_access_token
|
|
188
|
+
else:
|
|
189
|
+
console.print("[bold red]Failed to get new access token.[/bold red]")
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
except httpx.TimeoutException:
|
|
193
|
+
console.print("[bold red]Token refresh failed: Request timed out.[/bold red]")
|
|
194
|
+
return None
|
|
195
|
+
except httpx.NetworkError:
|
|
196
|
+
console.print("[bold red]Token refresh failed: Network error.[/bold red]")
|
|
197
|
+
return None
|
|
198
|
+
except httpx.HTTPStatusError as exc:
|
|
199
|
+
if exc.response.status_code == 400:
|
|
200
|
+
console.print("[bold red]Refresh token expired. Please log in again.[/bold red]")
|
|
201
|
+
else:
|
|
202
|
+
error_detail = "Unknown error"
|
|
203
|
+
try:
|
|
204
|
+
if exc.response.content:
|
|
205
|
+
content_type = exc.response.headers.get("content-type", "")
|
|
206
|
+
if "application/json" in content_type:
|
|
207
|
+
error_data = exc.response.json()
|
|
208
|
+
error_detail = error_data.get("detail", str(error_data))
|
|
209
|
+
except Exception:
|
|
210
|
+
error_detail = exc.response.text[:200] if exc.response.text else "Unknown error"
|
|
211
|
+
|
|
212
|
+
console.print(
|
|
213
|
+
f"[bold red]Token refresh failed: {exc.response.status_code} - {error_detail}[/bold red]"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Clear tokens on refresh failure
|
|
217
|
+
try:
|
|
218
|
+
keyring.delete_password(SERVICE_ID, "access_token")
|
|
219
|
+
keyring.delete_password(SERVICE_ID, "refresh_token")
|
|
220
|
+
except keyring.errors.KeyringError:
|
|
221
|
+
pass # Ignore errors when clearing
|
|
222
|
+
return None
|
|
223
|
+
except ValueError as e:
|
|
224
|
+
console.print(f"[bold red]Token refresh failed: {e}[/bold red]")
|
|
225
|
+
return None
|
|
226
|
+
except Exception as e:
|
|
227
|
+
console.print(
|
|
228
|
+
f"[bold red]Token refresh failed: Unexpected error - {type(e).__name__}[/bold red]"
|
|
229
|
+
)
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
return access_token
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def clear_tokens():
|
|
236
|
+
"""Clear stored access and refresh tokens."""
|
|
237
|
+
try:
|
|
238
|
+
keyring.delete_password(SERVICE_ID, "access_token")
|
|
239
|
+
keyring.delete_password(SERVICE_ID, "refresh_token")
|
|
240
|
+
except keyring.errors.PasswordDeleteError:
|
|
241
|
+
pass # Tokens already cleared
|
|
242
|
+
except keyring.errors.KeyringError as e:
|
|
243
|
+
console.print(f"[yellow]Warning: Could not clear tokens from keyring: {e}[/yellow]")
|
xenfra/utils/codebase.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Codebase scanning utilities for AI-powered project initialization.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def scan_codebase(max_files: int = 10, max_size: int = 50000) -> dict[str, str]:
|
|
10
|
+
"""
|
|
11
|
+
Scan current directory for important code files.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
max_files: Maximum number of files to include (validated: 1-100)
|
|
15
|
+
max_size: Maximum file size in bytes (validated: 1KB-10MB)
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Dictionary of filename -> content for AI analysis
|
|
19
|
+
"""
|
|
20
|
+
# Validate limits
|
|
21
|
+
from .validation import validate_codebase_scan_limits
|
|
22
|
+
|
|
23
|
+
is_valid, error_msg = validate_codebase_scan_limits(max_files, max_size)
|
|
24
|
+
if not is_valid:
|
|
25
|
+
raise ValueError(f"Invalid scan limits: {error_msg}")
|
|
26
|
+
|
|
27
|
+
# Ensure limits are within bounds
|
|
28
|
+
max_files = max(1, min(100, max_files))
|
|
29
|
+
max_size = max(1024, min(10 * 1024 * 1024, max_size))
|
|
30
|
+
"""
|
|
31
|
+
Scan current directory for important code files.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
max_files: Maximum number of files to include
|
|
35
|
+
max_size: Maximum file size in bytes (default 50KB)
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Dictionary of filename -> content for AI analysis
|
|
39
|
+
"""
|
|
40
|
+
code_snippets = {}
|
|
41
|
+
|
|
42
|
+
# Priority files to scan (in order)
|
|
43
|
+
important_files = [
|
|
44
|
+
# Python entry points
|
|
45
|
+
"main.py",
|
|
46
|
+
"app.py",
|
|
47
|
+
"wsgi.py",
|
|
48
|
+
"asgi.py",
|
|
49
|
+
"manage.py",
|
|
50
|
+
# Configuration files
|
|
51
|
+
"requirements.txt",
|
|
52
|
+
"pyproject.toml",
|
|
53
|
+
"Pipfile",
|
|
54
|
+
"setup.py",
|
|
55
|
+
# Django/Flask specific
|
|
56
|
+
"settings.py",
|
|
57
|
+
"config.py",
|
|
58
|
+
# Docker
|
|
59
|
+
"Dockerfile",
|
|
60
|
+
"docker-compose.yml",
|
|
61
|
+
# Xenfra config
|
|
62
|
+
"xenfra.yaml",
|
|
63
|
+
"xenfra.yml",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
# Scan for important files in current directory
|
|
67
|
+
for filename in important_files:
|
|
68
|
+
if len(code_snippets) >= max_files:
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
if os.path.exists(filename) and os.path.isfile(filename):
|
|
72
|
+
try:
|
|
73
|
+
file_size = os.path.getsize(filename)
|
|
74
|
+
if file_size > max_size:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
with open(filename, "r", encoding="utf-8") as f:
|
|
78
|
+
content = f.read(max_size)
|
|
79
|
+
code_snippets[filename] = content
|
|
80
|
+
except (IOError, OSError, PermissionError, UnicodeDecodeError) as e:
|
|
81
|
+
# Skip files that can't be read (log but don't crash)
|
|
82
|
+
import logging
|
|
83
|
+
|
|
84
|
+
logger = logging.getLogger(__name__)
|
|
85
|
+
logger.debug(f"Skipping file {filename}: {type(e).__name__}")
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
# If we haven't found enough files, look for Python files in common locations
|
|
89
|
+
if len(code_snippets) < 3:
|
|
90
|
+
search_patterns = [
|
|
91
|
+
"src/**/*.py",
|
|
92
|
+
"app/**/*.py",
|
|
93
|
+
"*.py",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
for pattern in search_patterns:
|
|
97
|
+
if len(code_snippets) >= max_files:
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
for filepath in Path(".").glob(pattern):
|
|
101
|
+
if len(code_snippets) >= max_files:
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
if filepath.is_file() and filepath.name not in code_snippets:
|
|
105
|
+
try:
|
|
106
|
+
file_size = filepath.stat().st_size
|
|
107
|
+
if file_size > max_size:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
111
|
+
content = f.read(max_size)
|
|
112
|
+
code_snippets[str(filepath)] = content
|
|
113
|
+
except (IOError, OSError, PermissionError, UnicodeDecodeError) as e:
|
|
114
|
+
# Skip files that can't be read
|
|
115
|
+
import logging
|
|
116
|
+
|
|
117
|
+
logger = logging.getLogger(__name__)
|
|
118
|
+
logger.debug(f"Skipping file {filepath}: {type(e).__name__}")
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
return code_snippets
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def has_xenfra_config() -> bool:
|
|
125
|
+
"""Check if xenfra.yaml already exists."""
|
|
126
|
+
return os.path.exists("xenfra.yaml") or os.path.exists("xenfra.yml")
|