xenfra 0.2.4__py3-none-any.whl → 0.2.5__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/commands/auth.py +181 -78
- xenfra/commands/deployments.py +42 -1
- xenfra/commands/intelligence.py +59 -28
- xenfra/commands/projects.py +54 -13
- xenfra/commands/security_cmd.py +16 -18
- xenfra/main.py +1 -0
- xenfra/utils/auth.py +99 -21
- xenfra/utils/codebase.py +53 -15
- xenfra/utils/config.py +144 -66
- xenfra/utils/security.py +27 -21
- xenfra/utils/validation.py +229 -0
- {xenfra-0.2.4.dist-info → xenfra-0.2.5.dist-info}/METADATA +26 -25
- xenfra-0.2.5.dist-info/RECORD +18 -0
- xenfra-0.2.4.dist-info/RECORD +0 -17
- {xenfra-0.2.4.dist-info → xenfra-0.2.5.dist-info}/WHEEL +0 -0
- {xenfra-0.2.4.dist-info → xenfra-0.2.5.dist-info}/entry_points.txt +0 -0
xenfra/commands/projects.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Project management commands for Xenfra CLI.
|
|
3
3
|
"""
|
|
4
|
+
|
|
4
5
|
import click
|
|
5
6
|
from rich.console import Console
|
|
6
7
|
from rich.table import Table
|
|
@@ -8,6 +9,12 @@ from xenfra_sdk import XenfraClient
|
|
|
8
9
|
from xenfra_sdk.exceptions import XenfraAPIError, XenfraError
|
|
9
10
|
|
|
10
11
|
from ..utils.auth import API_BASE_URL, get_auth_token
|
|
12
|
+
from ..utils.validation import (
|
|
13
|
+
validate_project_id,
|
|
14
|
+
validate_project_name,
|
|
15
|
+
validate_region,
|
|
16
|
+
validate_size_slug,
|
|
17
|
+
)
|
|
11
18
|
|
|
12
19
|
console = Console()
|
|
13
20
|
|
|
@@ -50,14 +57,18 @@ def list():
|
|
|
50
57
|
table.add_column("Cost/Month", style="red")
|
|
51
58
|
|
|
52
59
|
for project in projects_list:
|
|
53
|
-
cost =
|
|
60
|
+
cost = (
|
|
61
|
+
f"${project.estimated_monthly_cost:.2f}"
|
|
62
|
+
if project.estimated_monthly_cost
|
|
63
|
+
else "N/A"
|
|
64
|
+
)
|
|
54
65
|
table.add_row(
|
|
55
66
|
str(project.id),
|
|
56
67
|
project.name,
|
|
57
68
|
project.status,
|
|
58
69
|
project.region,
|
|
59
70
|
project.ip_address or "N/A",
|
|
60
|
-
cost
|
|
71
|
+
cost,
|
|
61
72
|
)
|
|
62
73
|
|
|
63
74
|
console.print(table)
|
|
@@ -71,9 +82,15 @@ def list():
|
|
|
71
82
|
|
|
72
83
|
|
|
73
84
|
@projects.command()
|
|
74
|
-
@click.argument(
|
|
85
|
+
@click.argument("project_id", type=int)
|
|
75
86
|
def show(project_id):
|
|
76
87
|
"""Show details for a specific project."""
|
|
88
|
+
# Validate project ID
|
|
89
|
+
is_valid, error_msg = validate_project_id(project_id)
|
|
90
|
+
if not is_valid:
|
|
91
|
+
console.print(f"[bold red]Invalid project ID: {error_msg}[/bold red]")
|
|
92
|
+
raise click.Abort()
|
|
93
|
+
|
|
77
94
|
try:
|
|
78
95
|
with get_client() as client:
|
|
79
96
|
project = client.projects.show(project_id)
|
|
@@ -92,7 +109,7 @@ def show(project_id):
|
|
|
92
109
|
panel = Panel(
|
|
93
110
|
details,
|
|
94
111
|
title=f"[bold green]Project {project.id}[/bold green]",
|
|
95
|
-
border_style="green"
|
|
112
|
+
border_style="green",
|
|
96
113
|
)
|
|
97
114
|
console.print(panel)
|
|
98
115
|
|
|
@@ -105,10 +122,16 @@ def show(project_id):
|
|
|
105
122
|
|
|
106
123
|
|
|
107
124
|
@projects.command()
|
|
108
|
-
@click.argument(
|
|
109
|
-
@click.confirmation_option(prompt=
|
|
125
|
+
@click.argument("project_id", type=int)
|
|
126
|
+
@click.confirmation_option(prompt="Are you sure you want to delete this project?")
|
|
110
127
|
def delete(project_id):
|
|
111
128
|
"""Delete a project."""
|
|
129
|
+
# Validate project ID
|
|
130
|
+
is_valid, error_msg = validate_project_id(project_id)
|
|
131
|
+
if not is_valid:
|
|
132
|
+
console.print(f"[bold red]Invalid project ID: {error_msg}[/bold red]")
|
|
133
|
+
raise click.Abort()
|
|
134
|
+
|
|
112
135
|
try:
|
|
113
136
|
with get_client() as client:
|
|
114
137
|
client.projects.delete(str(project_id))
|
|
@@ -123,11 +146,31 @@ def delete(project_id):
|
|
|
123
146
|
|
|
124
147
|
|
|
125
148
|
@projects.command()
|
|
126
|
-
@click.argument(
|
|
127
|
-
@click.option(
|
|
128
|
-
@click.option(
|
|
149
|
+
@click.argument("name")
|
|
150
|
+
@click.option("--region", default="nyc3", help="DigitalOcean region (default: nyc3)")
|
|
151
|
+
@click.option(
|
|
152
|
+
"--size", "size_slug", default="s-1vcpu-1gb", help="Droplet size (default: s-1vcpu-1gb)"
|
|
153
|
+
)
|
|
129
154
|
def create(name, region, size_slug):
|
|
130
155
|
"""Create a new project."""
|
|
156
|
+
# Validate project name
|
|
157
|
+
is_valid, error_msg = validate_project_name(name)
|
|
158
|
+
if not is_valid:
|
|
159
|
+
console.print(f"[bold red]Invalid project name: {error_msg}[/bold red]")
|
|
160
|
+
raise click.Abort()
|
|
161
|
+
|
|
162
|
+
# Validate region
|
|
163
|
+
is_valid, error_msg = validate_region(region)
|
|
164
|
+
if not is_valid:
|
|
165
|
+
console.print(f"[bold red]Invalid region: {error_msg}[/bold red]")
|
|
166
|
+
raise click.Abort()
|
|
167
|
+
|
|
168
|
+
# Validate size slug
|
|
169
|
+
is_valid, error_msg = validate_size_slug(size_slug)
|
|
170
|
+
if not is_valid:
|
|
171
|
+
console.print(f"[bold red]Invalid size slug: {error_msg}[/bold red]")
|
|
172
|
+
raise click.Abort()
|
|
173
|
+
|
|
131
174
|
try:
|
|
132
175
|
with get_client() as client:
|
|
133
176
|
console.print(f"[cyan]Creating project '{name}'...[/cyan]")
|
|
@@ -136,7 +179,7 @@ def create(name, region, size_slug):
|
|
|
136
179
|
project = client.projects.create(name=name, region=region, size_slug=size_slug)
|
|
137
180
|
|
|
138
181
|
# Display success message
|
|
139
|
-
console.print(
|
|
182
|
+
console.print("[bold green]✓[/bold green] Project created successfully!")
|
|
140
183
|
|
|
141
184
|
# Show project details
|
|
142
185
|
from rich.panel import Panel
|
|
@@ -149,9 +192,7 @@ def create(name, region, size_slug):
|
|
|
149
192
|
[cyan]Estimated Cost:[/cyan] ${project.estimated_monthly_cost:.2f}/month"""
|
|
150
193
|
|
|
151
194
|
panel = Panel(
|
|
152
|
-
details,
|
|
153
|
-
title="[bold green]New Project[/bold green]",
|
|
154
|
-
border_style="green"
|
|
195
|
+
details, title="[bold green]New Project[/bold green]", border_style="green"
|
|
155
196
|
)
|
|
156
197
|
console.print(panel)
|
|
157
198
|
|
xenfra/commands/security_cmd.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Security configuration commands for Xenfra CLI.
|
|
3
3
|
"""
|
|
4
|
+
|
|
4
5
|
import os
|
|
5
6
|
|
|
6
7
|
import click
|
|
@@ -8,12 +9,7 @@ from rich.console import Console
|
|
|
8
9
|
from rich.panel import Panel
|
|
9
10
|
from rich.table import Table
|
|
10
11
|
|
|
11
|
-
from ..utils.security import
|
|
12
|
-
ALLOWED_DOMAINS,
|
|
13
|
-
display_security_info,
|
|
14
|
-
security_config,
|
|
15
|
-
validate_and_get_api_url,
|
|
16
|
-
)
|
|
12
|
+
from ..utils.security import ALLOWED_DOMAINS, security_config, validate_and_get_api_url
|
|
17
13
|
|
|
18
14
|
console = Console()
|
|
19
15
|
|
|
@@ -41,7 +37,9 @@ def check():
|
|
|
41
37
|
|
|
42
38
|
# Environment
|
|
43
39
|
env_color = "green" if security_config.is_production() else "yellow"
|
|
44
|
-
console.print(
|
|
40
|
+
console.print(
|
|
41
|
+
f"[bold]Environment:[/bold] [{env_color}]{security_config.environment}[/{env_color}]"
|
|
42
|
+
)
|
|
45
43
|
|
|
46
44
|
console.print()
|
|
47
45
|
|
|
@@ -55,22 +53,22 @@ def check():
|
|
|
55
53
|
(
|
|
56
54
|
"HTTPS Enforcement",
|
|
57
55
|
"✅ Enabled" if security_config.enforce_https else "⚠️ Disabled",
|
|
58
|
-
"Blocks HTTP connections (except localhost)"
|
|
56
|
+
"Blocks HTTP connections (except localhost)",
|
|
59
57
|
),
|
|
60
58
|
(
|
|
61
59
|
"Domain Whitelist",
|
|
62
60
|
"✅ Enforced" if security_config.enforce_whitelist else "⚠️ Warning Only",
|
|
63
|
-
"Restricts connections to approved domains"
|
|
61
|
+
"Restricts connections to approved domains",
|
|
64
62
|
),
|
|
65
63
|
(
|
|
66
64
|
"HTTP Warning",
|
|
67
65
|
"✅ Enabled" if security_config.warn_on_http else "❌ Disabled",
|
|
68
|
-
"Warns when using insecure HTTP"
|
|
66
|
+
"Warns when using insecure HTTP",
|
|
69
67
|
),
|
|
70
68
|
(
|
|
71
69
|
"Certificate Pinning",
|
|
72
70
|
"✅ Enabled" if security_config.enable_cert_pinning else "❌ Disabled",
|
|
73
|
-
"Validates SSL certificate fingerprints"
|
|
71
|
+
"Validates SSL certificate fingerprints",
|
|
74
72
|
),
|
|
75
73
|
]
|
|
76
74
|
|
|
@@ -113,7 +111,7 @@ def check():
|
|
|
113
111
|
"2. Use HTTPS API URL\n"
|
|
114
112
|
"3. All security features will auto-enable",
|
|
115
113
|
title="Recommendation",
|
|
116
|
-
border_style="yellow"
|
|
114
|
+
border_style="yellow",
|
|
117
115
|
)
|
|
118
116
|
)
|
|
119
117
|
else:
|
|
@@ -123,23 +121,23 @@ def check():
|
|
|
123
121
|
"All security features are active.\n"
|
|
124
122
|
"Your credentials and data are protected.",
|
|
125
123
|
title="Status",
|
|
126
|
-
border_style="green"
|
|
124
|
+
border_style="green",
|
|
127
125
|
)
|
|
128
126
|
)
|
|
129
127
|
|
|
130
128
|
|
|
131
129
|
@security.command()
|
|
132
|
-
@click.argument(
|
|
130
|
+
@click.argument("url")
|
|
133
131
|
def validate(url):
|
|
134
132
|
"""Validate an API URL against security policies."""
|
|
135
133
|
console.print(f"\n[cyan]Validating URL:[/cyan] {url}\n")
|
|
136
134
|
|
|
137
135
|
try:
|
|
138
136
|
validated_url = validate_and_get_api_url(url)
|
|
139
|
-
console.print(
|
|
137
|
+
console.print("[bold green]✅ URL is valid and passed all security checks![/bold green]")
|
|
140
138
|
console.print(f"[dim]Validated URL: {validated_url}[/dim]")
|
|
141
139
|
except click.Abort:
|
|
142
|
-
console.print(
|
|
140
|
+
console.print("[bold red]❌ URL failed security validation[/bold red]")
|
|
143
141
|
except Exception as e:
|
|
144
142
|
console.print(f"[bold red]❌ Validation error: {e}[/bold red]")
|
|
145
143
|
|
|
@@ -202,7 +200,7 @@ XENFRA_ENV
|
|
|
202
200
|
Default: development
|
|
203
201
|
|
|
204
202
|
XENFRA_API_URL
|
|
205
|
-
Default: http://localhost:8000 (dev), https://api.xenfra.
|
|
203
|
+
Default: http://localhost:8000 (dev), https://api.xenfra.tech (prod)
|
|
206
204
|
|
|
207
205
|
XENFRA_ENFORCE_HTTPS
|
|
208
206
|
Values: true | false
|
|
@@ -229,7 +227,7 @@ XENFRA_WARN_ON_HTTP
|
|
|
229
227
|
5. Use environment-specific configurations
|
|
230
228
|
6. Enable all features for production deployments
|
|
231
229
|
|
|
232
|
-
[dim]For more information: https://docs.xenfra.
|
|
230
|
+
[dim]For more information: https://docs.xenfra.tech/security[/dim]
|
|
233
231
|
"""
|
|
234
232
|
|
|
235
233
|
console.print(Panel(docs_text, border_style="cyan", padding=(1, 2)))
|
xenfra/main.py
CHANGED
xenfra/utils/auth.py
CHANGED
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
Authentication utilities for Xenfra CLI.
|
|
3
3
|
Handles OAuth2 PKCE flow and token management.
|
|
4
4
|
"""
|
|
5
|
-
|
|
5
|
+
|
|
6
6
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
7
7
|
from urllib.parse import parse_qs, urlparse
|
|
8
8
|
|
|
9
9
|
import httpx
|
|
10
10
|
import keyring
|
|
11
11
|
from rich.console import Console
|
|
12
|
+
from tenacity import (
|
|
13
|
+
retry,
|
|
14
|
+
stop_after_attempt,
|
|
15
|
+
wait_exponential,
|
|
16
|
+
retry_if_exception_type,
|
|
17
|
+
)
|
|
12
18
|
|
|
13
19
|
from .security import validate_and_get_api_url
|
|
14
20
|
|
|
@@ -24,6 +30,9 @@ CLI_REDIRECT_PATH = "/auth/callback"
|
|
|
24
30
|
CLI_LOCAL_SERVER_START_PORT = 8001
|
|
25
31
|
CLI_LOCAL_SERVER_END_PORT = 8005
|
|
26
32
|
|
|
33
|
+
# HTTP request timeout (30 seconds)
|
|
34
|
+
HTTP_TIMEOUT = 30.0
|
|
35
|
+
|
|
27
36
|
# Global storage for OAuth callback data
|
|
28
37
|
oauth_data = {"code": None, "state": None, "error": None}
|
|
29
38
|
|
|
@@ -76,6 +85,39 @@ def run_local_oauth_server(port: int, redirect_path: str):
|
|
|
76
85
|
console.print("[dim]Local OAuth server shut down.[/dim]")
|
|
77
86
|
|
|
78
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
|
+
|
|
79
121
|
def get_auth_token() -> str | None:
|
|
80
122
|
"""
|
|
81
123
|
Retrieve a valid access token, refreshing it if necessary.
|
|
@@ -83,8 +125,12 @@ def get_auth_token() -> str | None:
|
|
|
83
125
|
Returns:
|
|
84
126
|
Valid access token or None if not authenticated
|
|
85
127
|
"""
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
88
134
|
|
|
89
135
|
if not access_token:
|
|
90
136
|
return None
|
|
@@ -105,35 +151,65 @@ def get_auth_token() -> str | None:
|
|
|
105
151
|
if not claims and refresh_token:
|
|
106
152
|
console.print("[dim]Access token expired. Attempting to refresh...[/dim]")
|
|
107
153
|
try:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
data={"refresh_token": refresh_token, "client_id": CLI_CLIENT_ID},
|
|
112
|
-
)
|
|
113
|
-
response.raise_for_status()
|
|
114
|
-
token_data = response.json()
|
|
115
|
-
new_access_token = token_data.get("access_token")
|
|
116
|
-
new_refresh_token = token_data.get("refresh_token")
|
|
154
|
+
token_data = _refresh_token_with_retry(refresh_token)
|
|
155
|
+
new_access_token = token_data.get("access_token")
|
|
156
|
+
new_refresh_token = token_data.get("refresh_token")
|
|
117
157
|
|
|
118
|
-
|
|
158
|
+
if new_access_token:
|
|
159
|
+
try:
|
|
119
160
|
keyring.set_password(SERVICE_ID, "access_token", new_access_token)
|
|
120
161
|
if new_refresh_token:
|
|
121
162
|
keyring.set_password(SERVICE_ID, "refresh_token", new_refresh_token)
|
|
122
163
|
console.print("[bold green]Token refreshed successfully.[/bold green]")
|
|
123
164
|
return new_access_token
|
|
124
|
-
|
|
125
|
-
console.print(
|
|
126
|
-
|
|
165
|
+
except keyring.errors.KeyringError as e:
|
|
166
|
+
console.print(
|
|
167
|
+
f"[yellow]Warning: Could not save refreshed token to keyring: {e}[/yellow]"
|
|
168
|
+
)
|
|
169
|
+
# Return the token anyway, but warn user
|
|
170
|
+
return new_access_token
|
|
171
|
+
else:
|
|
172
|
+
console.print("[bold red]Failed to get new access token.[/bold red]")
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
except httpx.TimeoutException:
|
|
176
|
+
console.print("[bold red]Token refresh failed: Request timed out.[/bold red]")
|
|
177
|
+
return None
|
|
178
|
+
except httpx.NetworkError:
|
|
179
|
+
console.print("[bold red]Token refresh failed: Network error.[/bold red]")
|
|
180
|
+
return None
|
|
127
181
|
except httpx.HTTPStatusError as exc:
|
|
128
182
|
if exc.response.status_code == 400:
|
|
129
183
|
console.print("[bold red]Refresh token expired. Please log in again.[/bold red]")
|
|
130
184
|
else:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
185
|
+
error_detail = "Unknown error"
|
|
186
|
+
try:
|
|
187
|
+
if exc.response.content:
|
|
188
|
+
content_type = exc.response.headers.get("content-type", "")
|
|
189
|
+
if "application/json" in content_type:
|
|
190
|
+
error_data = exc.response.json()
|
|
191
|
+
error_detail = error_data.get("detail", str(error_data))
|
|
192
|
+
except Exception:
|
|
193
|
+
error_detail = exc.response.text[:200] if exc.response.text else "Unknown error"
|
|
194
|
+
|
|
195
|
+
console.print(
|
|
196
|
+
f"[bold red]Token refresh failed: {exc.response.status_code} - {error_detail}[/bold red]"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Clear tokens on refresh failure
|
|
200
|
+
try:
|
|
201
|
+
keyring.delete_password(SERVICE_ID, "access_token")
|
|
202
|
+
keyring.delete_password(SERVICE_ID, "refresh_token")
|
|
203
|
+
except keyring.errors.KeyringError:
|
|
204
|
+
pass # Ignore errors when clearing
|
|
134
205
|
return None
|
|
135
|
-
except
|
|
136
|
-
console.print(f"[bold red]Token refresh failed: {
|
|
206
|
+
except ValueError as e:
|
|
207
|
+
console.print(f"[bold red]Token refresh failed: {e}[/bold red]")
|
|
208
|
+
return None
|
|
209
|
+
except Exception as e:
|
|
210
|
+
console.print(
|
|
211
|
+
f"[bold red]Token refresh failed: Unexpected error - {type(e).__name__}[/bold red]"
|
|
212
|
+
)
|
|
137
213
|
return None
|
|
138
214
|
|
|
139
215
|
return access_token
|
|
@@ -146,3 +222,5 @@ def clear_tokens():
|
|
|
146
222
|
keyring.delete_password(SERVICE_ID, "refresh_token")
|
|
147
223
|
except keyring.errors.PasswordDeleteError:
|
|
148
224
|
pass # Tokens already cleared
|
|
225
|
+
except keyring.errors.KeyringError as e:
|
|
226
|
+
console.print(f"[yellow]Warning: Could not clear tokens from keyring: {e}[/yellow]")
|
xenfra/utils/codebase.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Codebase scanning utilities for AI-powered project initialization.
|
|
3
3
|
"""
|
|
4
|
+
|
|
4
5
|
import os
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
@@ -9,6 +10,26 @@ def scan_codebase(max_files: int = 10, max_size: int = 50000) -> dict[str, str]:
|
|
|
9
10
|
"""
|
|
10
11
|
Scan current directory for important code files.
|
|
11
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
|
+
|
|
12
33
|
Args:
|
|
13
34
|
max_files: Maximum number of files to include
|
|
14
35
|
max_size: Maximum file size in bytes (default 50KB)
|
|
@@ -21,15 +42,25 @@ def scan_codebase(max_files: int = 10, max_size: int = 50000) -> dict[str, str]:
|
|
|
21
42
|
# Priority files to scan (in order)
|
|
22
43
|
important_files = [
|
|
23
44
|
# Python entry points
|
|
24
|
-
|
|
45
|
+
"main.py",
|
|
46
|
+
"app.py",
|
|
47
|
+
"wsgi.py",
|
|
48
|
+
"asgi.py",
|
|
49
|
+
"manage.py",
|
|
25
50
|
# Configuration files
|
|
26
|
-
|
|
51
|
+
"requirements.txt",
|
|
52
|
+
"pyproject.toml",
|
|
53
|
+
"Pipfile",
|
|
54
|
+
"setup.py",
|
|
27
55
|
# Django/Flask specific
|
|
28
|
-
|
|
56
|
+
"settings.py",
|
|
57
|
+
"config.py",
|
|
29
58
|
# Docker
|
|
30
|
-
|
|
59
|
+
"Dockerfile",
|
|
60
|
+
"docker-compose.yml",
|
|
31
61
|
# Xenfra config
|
|
32
|
-
|
|
62
|
+
"xenfra.yaml",
|
|
63
|
+
"xenfra.yml",
|
|
33
64
|
]
|
|
34
65
|
|
|
35
66
|
# Scan for important files in current directory
|
|
@@ -43,26 +74,29 @@ def scan_codebase(max_files: int = 10, max_size: int = 50000) -> dict[str, str]:
|
|
|
43
74
|
if file_size > max_size:
|
|
44
75
|
continue
|
|
45
76
|
|
|
46
|
-
with open(filename,
|
|
77
|
+
with open(filename, "r", encoding="utf-8") as f:
|
|
47
78
|
content = f.read(max_size)
|
|
48
79
|
code_snippets[filename] = content
|
|
49
|
-
except (IOError, UnicodeDecodeError):
|
|
50
|
-
# Skip files that can't be read
|
|
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
|
+
logger = logging.getLogger(__name__)
|
|
84
|
+
logger.debug(f"Skipping file {filename}: {type(e).__name__}")
|
|
51
85
|
continue
|
|
52
86
|
|
|
53
87
|
# If we haven't found enough files, look for Python files in common locations
|
|
54
88
|
if len(code_snippets) < 3:
|
|
55
89
|
search_patterns = [
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
90
|
+
"src/**/*.py",
|
|
91
|
+
"app/**/*.py",
|
|
92
|
+
"*.py",
|
|
59
93
|
]
|
|
60
94
|
|
|
61
95
|
for pattern in search_patterns:
|
|
62
96
|
if len(code_snippets) >= max_files:
|
|
63
97
|
break
|
|
64
98
|
|
|
65
|
-
for filepath in Path(
|
|
99
|
+
for filepath in Path(".").glob(pattern):
|
|
66
100
|
if len(code_snippets) >= max_files:
|
|
67
101
|
break
|
|
68
102
|
|
|
@@ -72,10 +106,14 @@ def scan_codebase(max_files: int = 10, max_size: int = 50000) -> dict[str, str]:
|
|
|
72
106
|
if file_size > max_size:
|
|
73
107
|
continue
|
|
74
108
|
|
|
75
|
-
with open(filepath,
|
|
109
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
76
110
|
content = f.read(max_size)
|
|
77
111
|
code_snippets[str(filepath)] = content
|
|
78
|
-
except (IOError, UnicodeDecodeError):
|
|
112
|
+
except (IOError, OSError, PermissionError, UnicodeDecodeError) as e:
|
|
113
|
+
# Skip files that can't be read
|
|
114
|
+
import logging
|
|
115
|
+
logger = logging.getLogger(__name__)
|
|
116
|
+
logger.debug(f"Skipping file {filepath}: {type(e).__name__}")
|
|
79
117
|
continue
|
|
80
118
|
|
|
81
119
|
return code_snippets
|
|
@@ -83,4 +121,4 @@ def scan_codebase(max_files: int = 10, max_size: int = 50000) -> dict[str, str]:
|
|
|
83
121
|
|
|
84
122
|
def has_xenfra_config() -> bool:
|
|
85
123
|
"""Check if xenfra.yaml already exists."""
|
|
86
|
-
return os.path.exists(
|
|
124
|
+
return os.path.exists("xenfra.yaml") or os.path.exists("xenfra.yml")
|