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/auth.py
CHANGED
|
@@ -13,6 +13,12 @@ import click
|
|
|
13
13
|
import httpx
|
|
14
14
|
import keyring
|
|
15
15
|
from rich.console import Console
|
|
16
|
+
from tenacity import (
|
|
17
|
+
retry,
|
|
18
|
+
stop_after_attempt,
|
|
19
|
+
wait_exponential,
|
|
20
|
+
retry_if_exception_type,
|
|
21
|
+
)
|
|
16
22
|
|
|
17
23
|
from ..utils.auth import (
|
|
18
24
|
API_BASE_URL,
|
|
@@ -28,11 +34,49 @@ from ..utils.auth import (
|
|
|
28
34
|
|
|
29
35
|
console = Console()
|
|
30
36
|
|
|
37
|
+
# HTTP request timeout (30 seconds)
|
|
38
|
+
HTTP_TIMEOUT = 30.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@retry(
|
|
42
|
+
stop=stop_after_attempt(3),
|
|
43
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
44
|
+
retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
|
|
45
|
+
reraise=True,
|
|
46
|
+
)
|
|
47
|
+
def _exchange_code_for_tokens_with_retry(
|
|
48
|
+
code: str, code_verifier: str, redirect_uri: str
|
|
49
|
+
) -> dict:
|
|
50
|
+
"""
|
|
51
|
+
Exchange authorization code for tokens with retry logic.
|
|
52
|
+
|
|
53
|
+
Returns token data dictionary.
|
|
54
|
+
"""
|
|
55
|
+
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
|
|
56
|
+
response = client.post(
|
|
57
|
+
f"{API_BASE_URL}/auth/token",
|
|
58
|
+
data={
|
|
59
|
+
"grant_type": "authorization_code",
|
|
60
|
+
"client_id": CLI_CLIENT_ID,
|
|
61
|
+
"code": code,
|
|
62
|
+
"code_verifier": code_verifier,
|
|
63
|
+
"redirect_uri": redirect_uri,
|
|
64
|
+
},
|
|
65
|
+
headers={"Accept": "application/json"},
|
|
66
|
+
)
|
|
67
|
+
response.raise_for_status()
|
|
68
|
+
|
|
69
|
+
# Safe JSON parsing with content-type check
|
|
70
|
+
content_type = response.headers.get("content-type", "")
|
|
71
|
+
if "application/json" not in content_type:
|
|
72
|
+
raise ValueError(f"Expected JSON response, got {content_type}")
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
token_data = response.json()
|
|
76
|
+
except (ValueError, TypeError) as e:
|
|
77
|
+
raise ValueError(f"Failed to parse JSON response: {e}")
|
|
31
78
|
|
|
32
|
-
|
|
33
|
-
def auth():
|
|
34
|
-
"""Authentication commands."""
|
|
35
|
-
pass
|
|
79
|
+
return token_data
|
|
36
80
|
|
|
37
81
|
|
|
38
82
|
@auth.command()
|
|
@@ -55,101 +99,160 @@ def login():
|
|
|
55
99
|
# 3. Start local HTTP server
|
|
56
100
|
server_port = None
|
|
57
101
|
httpd_instance = None
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
102
|
+
try:
|
|
103
|
+
for port in range(CLI_LOCAL_SERVER_START_PORT, CLI_LOCAL_SERVER_END_PORT + 1):
|
|
104
|
+
try:
|
|
105
|
+
server_address = ("127.0.0.1", port)
|
|
106
|
+
httpd_instance = HTTPServer(server_address, AuthCallbackHandler)
|
|
107
|
+
server_port = port
|
|
108
|
+
break
|
|
109
|
+
except OSError:
|
|
110
|
+
continue
|
|
111
|
+
except Exception as e:
|
|
112
|
+
console.print(f"[yellow]Warning: Failed to bind to port {port}: {e}[/yellow]")
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
if not server_port:
|
|
116
|
+
console.print(
|
|
117
|
+
f"[bold red]Error: No available ports in range {CLI_LOCAL_SERVER_START_PORT}-{CLI_LOCAL_SERVER_END_PORT}[/bold red]"
|
|
118
|
+
)
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
redirect_uri = f"http://localhost:{server_port}{CLI_REDIRECT_PATH}"
|
|
122
|
+
|
|
123
|
+
# 4. Construct Authorization URL
|
|
124
|
+
auth_url = (
|
|
125
|
+
f"{API_BASE_URL}/auth/authorize?"
|
|
126
|
+
f"client_id={CLI_CLIENT_ID}&"
|
|
127
|
+
f"redirect_uri={urllib.parse.quote(redirect_uri)}&"
|
|
128
|
+
f"response_type=code&"
|
|
129
|
+
f"scope={urllib.parse.quote('openid profile')}&"
|
|
130
|
+
f"state={state}&"
|
|
131
|
+
f"code_challenge={code_challenge}&"
|
|
132
|
+
f"code_challenge_method=S256"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
console.print("[bold blue]Opening browser for login...[/bold blue]")
|
|
68
136
|
console.print(
|
|
69
|
-
f"[
|
|
137
|
+
f"[dim]If browser doesn't open, navigate to:[/dim]\n[link={auth_url}]{auth_url}[/link]"
|
|
70
138
|
)
|
|
71
|
-
return
|
|
72
139
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
f"scope={urllib.parse.quote('openid profile')}&"
|
|
82
|
-
f"state={state}&"
|
|
83
|
-
f"code_challenge={code_challenge}&"
|
|
84
|
-
f"code_challenge_method=S256"
|
|
85
|
-
)
|
|
140
|
+
# Try to open browser, handle errors gracefully
|
|
141
|
+
try:
|
|
142
|
+
webbrowser.open(auth_url)
|
|
143
|
+
except Exception as e:
|
|
144
|
+
console.print(
|
|
145
|
+
f"[yellow]Warning: Could not open browser automatically: {e}[/yellow]"
|
|
146
|
+
)
|
|
147
|
+
console.print(f"[dim]Please open the URL manually: {auth_url}[/dim]")
|
|
86
148
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
149
|
+
# 5. Run local server to capture redirect
|
|
150
|
+
try:
|
|
151
|
+
AuthCallbackHandler.server = httpd_instance # type: ignore
|
|
152
|
+
httpd_instance.handle_request() # type: ignore
|
|
153
|
+
console.print("[dim]Local OAuth server shut down.[/dim]")
|
|
154
|
+
except Exception as e:
|
|
155
|
+
console.print(f"[bold red]Error running OAuth server: {e}[/bold red]")
|
|
156
|
+
return
|
|
157
|
+
finally:
|
|
158
|
+
# Ensure server is closed
|
|
159
|
+
if httpd_instance:
|
|
160
|
+
try:
|
|
161
|
+
httpd_instance.server_close()
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
92
164
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
httpd_instance.handle_request() # type: ignore
|
|
97
|
-
console.print("[dim]Local OAuth server shut down.[/dim]")
|
|
98
|
-
except Exception as e:
|
|
99
|
-
console.print(f"[bold red]Error running OAuth server: {e}[/bold red]")
|
|
100
|
-
if httpd_instance:
|
|
101
|
-
httpd_instance.server_close()
|
|
102
|
-
return
|
|
165
|
+
if oauth_data["error"]:
|
|
166
|
+
console.print(f"[bold red]Login failed: {oauth_data['error']}[/bold red]")
|
|
167
|
+
return
|
|
103
168
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
169
|
+
if not oauth_data["code"]:
|
|
170
|
+
console.print("[bold red]Login failed: No authorization code received.[/bold red]")
|
|
171
|
+
return
|
|
107
172
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
173
|
+
# 6. Verify state (CSRF protection)
|
|
174
|
+
if not oauth_data.get("state"):
|
|
175
|
+
console.print(
|
|
176
|
+
"[bold red]Login failed: State parameter missing in callback (possible CSRF attack)[/bold red]"
|
|
177
|
+
)
|
|
178
|
+
return
|
|
111
179
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
180
|
+
if oauth_data["state"] != state:
|
|
181
|
+
console.print(
|
|
182
|
+
"[bold red]Login failed: State mismatch (possible CSRF attack)[/bold red]"
|
|
183
|
+
)
|
|
184
|
+
return
|
|
116
185
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
f"{API_BASE_URL}/auth/token",
|
|
123
|
-
data={
|
|
124
|
-
"grant_type": "authorization_code",
|
|
125
|
-
"client_id": CLI_CLIENT_ID,
|
|
126
|
-
"code": oauth_data["code"],
|
|
127
|
-
"code_verifier": code_verifier,
|
|
128
|
-
"redirect_uri": redirect_uri,
|
|
129
|
-
},
|
|
186
|
+
# 7. Exchange code for tokens with retry logic
|
|
187
|
+
console.print("[bold cyan]Exchanging authorization code for tokens...[/bold cyan]")
|
|
188
|
+
try:
|
|
189
|
+
token_data = _exchange_code_for_tokens_with_retry(
|
|
190
|
+
oauth_data["code"], code_verifier, redirect_uri
|
|
130
191
|
)
|
|
131
|
-
|
|
132
|
-
token_data = response.json()
|
|
192
|
+
|
|
133
193
|
access_token = token_data.get("access_token")
|
|
134
194
|
refresh_token = token_data.get("refresh_token")
|
|
135
195
|
|
|
136
196
|
if access_token and refresh_token:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
197
|
+
try:
|
|
198
|
+
keyring.set_password(SERVICE_ID, "access_token", access_token)
|
|
199
|
+
keyring.set_password(SERVICE_ID, "refresh_token", refresh_token)
|
|
200
|
+
console.print("[bold green]Login successful! Tokens saved securely.[/bold green]")
|
|
201
|
+
except keyring.errors.KeyringError as e:
|
|
202
|
+
console.print(
|
|
203
|
+
f"[bold red]Failed to save tokens to keyring: {e}[/bold red]"
|
|
204
|
+
)
|
|
205
|
+
console.print("[yellow]Tokens were received but not saved.[/yellow]")
|
|
140
206
|
else:
|
|
141
207
|
console.print("[bold red]Login failed: No tokens received.[/bold red]")
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
208
|
+
|
|
209
|
+
except httpx.TimeoutException:
|
|
210
|
+
console.print(
|
|
211
|
+
"[bold red]Token exchange failed: Request timed out. Please try again.[/bold red]"
|
|
212
|
+
)
|
|
213
|
+
except httpx.NetworkError as e:
|
|
214
|
+
console.print(
|
|
215
|
+
f"[bold red]Token exchange failed: Network error - {type(e).__name__}[/bold red]"
|
|
216
|
+
)
|
|
217
|
+
except httpx.HTTPStatusError as exc:
|
|
218
|
+
error_detail = "Unknown error"
|
|
219
|
+
try:
|
|
220
|
+
if exc.response.content:
|
|
221
|
+
content_type = exc.response.headers.get("content-type", "")
|
|
222
|
+
if "application/json" in content_type:
|
|
223
|
+
error_data = exc.response.json()
|
|
224
|
+
error_detail = error_data.get("detail", str(error_data))
|
|
225
|
+
except Exception:
|
|
226
|
+
error_detail = exc.response.text[:200] if exc.response.text else "Unknown error"
|
|
227
|
+
|
|
228
|
+
console.print(
|
|
229
|
+
f"[bold red]Token exchange failed: {exc.response.status_code} - {error_detail}[/bold red]"
|
|
230
|
+
)
|
|
231
|
+
except ValueError as e:
|
|
232
|
+
console.print(f"[bold red]Token exchange failed: {e}[/bold red]")
|
|
233
|
+
except Exception as e:
|
|
234
|
+
console.print(
|
|
235
|
+
f"[bold red]Token exchange failed: Unexpected error - {type(e).__name__}[/bold red]"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
except Exception as e:
|
|
239
|
+
console.print(f"[bold red]Login failed: {type(e).__name__} - {e}[/bold red]")
|
|
240
|
+
if httpd_instance:
|
|
241
|
+
try:
|
|
242
|
+
httpd_instance.server_close()
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
146
245
|
|
|
147
246
|
|
|
148
247
|
@auth.command()
|
|
149
248
|
def logout():
|
|
150
249
|
"""Logout and clear stored tokens."""
|
|
151
|
-
|
|
152
|
-
|
|
250
|
+
try:
|
|
251
|
+
clear_tokens()
|
|
252
|
+
console.print("[bold green]Logged out successfully.[/bold green]")
|
|
253
|
+
except Exception as e:
|
|
254
|
+
console.print(f"[yellow]Warning: Error during logout: {e}[/yellow]")
|
|
255
|
+
console.print("[dim]Tokens may still be stored in keyring.[/dim]")
|
|
153
256
|
|
|
154
257
|
|
|
155
258
|
@auth.command()
|
xenfra/commands/deployments.py
CHANGED
|
@@ -15,6 +15,13 @@ from xenfra_sdk.privacy import scrub_logs
|
|
|
15
15
|
from ..utils.auth import API_BASE_URL, get_auth_token
|
|
16
16
|
from ..utils.codebase import has_xenfra_config
|
|
17
17
|
from ..utils.config import apply_patch
|
|
18
|
+
from ..utils.validation import (
|
|
19
|
+
validate_branch_name,
|
|
20
|
+
validate_deployment_id,
|
|
21
|
+
validate_framework,
|
|
22
|
+
validate_git_repo_url,
|
|
23
|
+
validate_project_name,
|
|
24
|
+
)
|
|
18
25
|
|
|
19
26
|
console = Console()
|
|
20
27
|
|
|
@@ -147,8 +154,18 @@ def deploy(project_name, git_repo, branch, framework, no_heal):
|
|
|
147
154
|
if not project_name:
|
|
148
155
|
project_name = os.path.basename(os.getcwd())
|
|
149
156
|
|
|
150
|
-
#
|
|
157
|
+
# Validate project name
|
|
158
|
+
is_valid, error_msg = validate_project_name(project_name)
|
|
159
|
+
if not is_valid:
|
|
160
|
+
console.print(f"[bold red]Invalid project name: {error_msg}[/bold red]")
|
|
161
|
+
raise click.Abort()
|
|
162
|
+
|
|
163
|
+
# Validate git repo if provided
|
|
151
164
|
if git_repo:
|
|
165
|
+
is_valid, error_msg = validate_git_repo_url(git_repo)
|
|
166
|
+
if not is_valid:
|
|
167
|
+
console.print(f"[bold red]Invalid git repository URL: {error_msg}[/bold red]")
|
|
168
|
+
raise click.Abort()
|
|
152
169
|
console.print(f"[cyan]Deploying {project_name} from git repository...[/cyan]")
|
|
153
170
|
else:
|
|
154
171
|
console.print(f"[cyan]Deploying {project_name} from local directory...[/cyan]")
|
|
@@ -158,6 +175,19 @@ def deploy(project_name, git_repo, branch, framework, no_heal):
|
|
|
158
175
|
console.print("[dim]Please use --git-repo for now.[/dim]")
|
|
159
176
|
return
|
|
160
177
|
|
|
178
|
+
# Validate branch name
|
|
179
|
+
is_valid, error_msg = validate_branch_name(branch)
|
|
180
|
+
if not is_valid:
|
|
181
|
+
console.print(f"[bold red]Invalid branch name: {error_msg}[/bold red]")
|
|
182
|
+
raise click.Abort()
|
|
183
|
+
|
|
184
|
+
# Validate framework if provided
|
|
185
|
+
if framework:
|
|
186
|
+
is_valid, error_msg = validate_framework(framework)
|
|
187
|
+
if not is_valid:
|
|
188
|
+
console.print(f"[bold red]Invalid framework: {error_msg}[/bold red]")
|
|
189
|
+
raise click.Abort()
|
|
190
|
+
|
|
161
191
|
# Retry loop for auto-healing
|
|
162
192
|
attempt = 0
|
|
163
193
|
deployment_id = None
|
|
@@ -288,6 +318,11 @@ def deploy(project_name, git_repo, branch, framework, no_heal):
|
|
|
288
318
|
@click.option("--follow", "-f", is_flag=True, help="Follow log output (stream)")
|
|
289
319
|
@click.option("--tail", type=int, help="Show last N lines")
|
|
290
320
|
def logs(deployment_id, follow, tail):
|
|
321
|
+
# Validate deployment ID
|
|
322
|
+
is_valid, error_msg = validate_deployment_id(deployment_id)
|
|
323
|
+
if not is_valid:
|
|
324
|
+
console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
|
|
325
|
+
raise click.Abort()
|
|
291
326
|
"""
|
|
292
327
|
Stream deployment logs.
|
|
293
328
|
|
|
@@ -357,6 +392,12 @@ def status(deployment_id, watch):
|
|
|
357
392
|
console.print("[dim]Usage: xenfra status <deployment-id>[/dim]")
|
|
358
393
|
return
|
|
359
394
|
|
|
395
|
+
# Validate deployment ID
|
|
396
|
+
is_valid, error_msg = validate_deployment_id(deployment_id)
|
|
397
|
+
if not is_valid:
|
|
398
|
+
console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
|
|
399
|
+
raise click.Abort()
|
|
400
|
+
|
|
360
401
|
with get_client() as client:
|
|
361
402
|
console.print(f"[cyan]Fetching status for deployment {deployment_id}...[/cyan]")
|
|
362
403
|
|
xenfra/commands/intelligence.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
AI-powered intelligence commands for Xenfra CLI.
|
|
3
3
|
Includes smart initialization, deployment diagnosis, and codebase analysis.
|
|
4
4
|
"""
|
|
5
|
+
|
|
5
6
|
import os
|
|
6
7
|
|
|
7
8
|
import click
|
|
@@ -15,7 +16,13 @@ from xenfra_sdk.privacy import scrub_logs
|
|
|
15
16
|
|
|
16
17
|
from ..utils.auth import API_BASE_URL, get_auth_token
|
|
17
18
|
from ..utils.codebase import has_xenfra_config, scan_codebase
|
|
18
|
-
from ..utils.config import
|
|
19
|
+
from ..utils.config import (
|
|
20
|
+
apply_patch,
|
|
21
|
+
generate_xenfra_yaml,
|
|
22
|
+
manual_prompt_for_config,
|
|
23
|
+
read_xenfra_yaml,
|
|
24
|
+
)
|
|
25
|
+
from ..utils.validation import validate_deployment_id
|
|
19
26
|
|
|
20
27
|
console = Console()
|
|
21
28
|
|
|
@@ -31,8 +38,8 @@ def get_client() -> XenfraClient:
|
|
|
31
38
|
|
|
32
39
|
|
|
33
40
|
@click.command()
|
|
34
|
-
@click.option(
|
|
35
|
-
@click.option(
|
|
41
|
+
@click.option("--manual", is_flag=True, help="Skip AI detection, use interactive mode")
|
|
42
|
+
@click.option("--accept-all", is_flag=True, help="Accept AI suggestions without confirmation")
|
|
36
43
|
def init(manual, accept_all):
|
|
37
44
|
"""
|
|
38
45
|
Initialize Xenfra configuration (AI-powered by default).
|
|
@@ -51,7 +58,7 @@ def init(manual, accept_all):
|
|
|
51
58
|
return
|
|
52
59
|
|
|
53
60
|
# Check for XENFRA_NO_AI environment variable
|
|
54
|
-
no_ai = os.environ.get(
|
|
61
|
+
no_ai = os.environ.get("XENFRA_NO_AI", "0") == "1"
|
|
55
62
|
if no_ai and not manual:
|
|
56
63
|
console.print("[yellow]XENFRA_NO_AI is set. Using manual mode.[/yellow]")
|
|
57
64
|
manual = True
|
|
@@ -60,9 +67,9 @@ def init(manual, accept_all):
|
|
|
60
67
|
if manual:
|
|
61
68
|
console.print("[cyan]Manual configuration mode[/cyan]\n")
|
|
62
69
|
try:
|
|
63
|
-
|
|
64
|
-
console.print(
|
|
65
|
-
console.print(
|
|
70
|
+
manual_prompt_for_config()
|
|
71
|
+
console.print("\n[bold green]✓ xenfra.yaml created successfully![/bold green]")
|
|
72
|
+
console.print("[dim]Run 'xenfra deploy' to deploy your project.[/dim]")
|
|
66
73
|
except KeyboardInterrupt:
|
|
67
74
|
console.print("\n[dim]Cancelled.[/dim]")
|
|
68
75
|
except Exception as e:
|
|
@@ -107,7 +114,7 @@ def init(manual, accept_all):
|
|
|
107
114
|
choice = Prompt.ask(
|
|
108
115
|
"\nWhich package manager do you want to use?",
|
|
109
116
|
choices=[str(i) for i in range(1, len(analysis.detected_package_managers) + 1)],
|
|
110
|
-
default="1"
|
|
117
|
+
default="1",
|
|
111
118
|
)
|
|
112
119
|
|
|
113
120
|
# Update selection based on user choice
|
|
@@ -115,7 +122,9 @@ def init(manual, accept_all):
|
|
|
115
122
|
selected_package_manager = selected_option.manager
|
|
116
123
|
selected_dependency_file = selected_option.file
|
|
117
124
|
|
|
118
|
-
console.print(
|
|
125
|
+
console.print(
|
|
126
|
+
f"\n[green]Using {selected_package_manager} ({selected_dependency_file})[/green]\n"
|
|
127
|
+
)
|
|
119
128
|
|
|
120
129
|
table = Table(show_header=False, box=None)
|
|
121
130
|
table.add_column("Property", style="cyan")
|
|
@@ -146,9 +155,9 @@ def init(manual, accept_all):
|
|
|
146
155
|
confirmed = Confirm.ask("\nCreate xenfra.yaml with this configuration?", default=True)
|
|
147
156
|
|
|
148
157
|
if confirmed:
|
|
149
|
-
|
|
150
|
-
console.print(
|
|
151
|
-
console.print(
|
|
158
|
+
generate_xenfra_yaml(analysis)
|
|
159
|
+
console.print("[bold green]xenfra.yaml created successfully![/bold green]")
|
|
160
|
+
console.print("[dim]Run 'xenfra deploy' to deploy your project.[/dim]")
|
|
152
161
|
else:
|
|
153
162
|
console.print("[yellow]Configuration cancelled.[/yellow]")
|
|
154
163
|
|
|
@@ -163,9 +172,9 @@ def init(manual, accept_all):
|
|
|
163
172
|
|
|
164
173
|
|
|
165
174
|
@click.command()
|
|
166
|
-
@click.argument(
|
|
167
|
-
@click.option(
|
|
168
|
-
@click.option(
|
|
175
|
+
@click.argument("deployment-id", required=False)
|
|
176
|
+
@click.option("--apply", is_flag=True, help="Auto-apply suggested patch (with confirmation)")
|
|
177
|
+
@click.option("--logs", type=click.File("r"), help="Diagnose from log file instead of deployment")
|
|
169
178
|
def diagnose(deployment_id, apply, logs):
|
|
170
179
|
"""
|
|
171
180
|
Diagnose deployment failures using AI.
|
|
@@ -179,8 +188,14 @@ def diagnose(deployment_id, apply, logs):
|
|
|
179
188
|
# Get logs
|
|
180
189
|
if logs:
|
|
181
190
|
log_content = logs.read()
|
|
182
|
-
console.print(
|
|
191
|
+
console.print("[cyan]Analyzing logs from file...[/cyan]")
|
|
183
192
|
elif deployment_id:
|
|
193
|
+
# Validate deployment ID
|
|
194
|
+
is_valid, error_msg = validate_deployment_id(deployment_id)
|
|
195
|
+
if not is_valid:
|
|
196
|
+
console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
|
|
197
|
+
return
|
|
198
|
+
|
|
184
199
|
console.print(f"[cyan]Fetching logs for deployment {deployment_id}...[/cyan]")
|
|
185
200
|
log_content = client.deployments.get_logs(deployment_id)
|
|
186
201
|
|
|
@@ -188,8 +203,12 @@ def diagnose(deployment_id, apply, logs):
|
|
|
188
203
|
console.print("[yellow]No logs found for this deployment.[/yellow]")
|
|
189
204
|
return
|
|
190
205
|
else:
|
|
191
|
-
console.print(
|
|
192
|
-
|
|
206
|
+
console.print(
|
|
207
|
+
"[bold red]Please specify a deployment ID or use --logs <file>[/bold red]"
|
|
208
|
+
)
|
|
209
|
+
console.print(
|
|
210
|
+
"[dim]Usage: xenfra diagnose <deployment-id> or xenfra diagnose --logs error.log[/dim]"
|
|
211
|
+
)
|
|
193
212
|
return
|
|
194
213
|
|
|
195
214
|
# Scrub sensitive data
|
|
@@ -200,27 +219,37 @@ def diagnose(deployment_id, apply, logs):
|
|
|
200
219
|
dependency_file = None
|
|
201
220
|
try:
|
|
202
221
|
config = read_xenfra_yaml()
|
|
203
|
-
package_manager = config.get(
|
|
204
|
-
dependency_file = config.get(
|
|
222
|
+
package_manager = config.get("package_manager")
|
|
223
|
+
dependency_file = config.get("dependency_file")
|
|
205
224
|
|
|
206
225
|
if package_manager and dependency_file:
|
|
207
|
-
console.print(
|
|
226
|
+
console.print(
|
|
227
|
+
f"[dim]Using context: {package_manager} ({dependency_file})[/dim]"
|
|
228
|
+
)
|
|
208
229
|
except FileNotFoundError:
|
|
209
230
|
# No config file - diagnosis will infer from logs
|
|
210
|
-
console.print(
|
|
231
|
+
console.print(
|
|
232
|
+
"[dim]No xenfra.yaml found - inferring package manager from logs[/dim]"
|
|
233
|
+
)
|
|
211
234
|
|
|
212
235
|
# Diagnose with context
|
|
213
236
|
console.print("[cyan]Analyzing failure...[/cyan]")
|
|
214
237
|
result = client.intelligence.diagnose(
|
|
215
|
-
logs=scrubbed_logs,
|
|
216
|
-
package_manager=package_manager,
|
|
217
|
-
dependency_file=dependency_file
|
|
238
|
+
logs=scrubbed_logs, package_manager=package_manager, dependency_file=dependency_file
|
|
218
239
|
)
|
|
219
240
|
|
|
220
241
|
# Display diagnosis
|
|
221
242
|
console.print("\n")
|
|
222
|
-
console.print(
|
|
223
|
-
|
|
243
|
+
console.print(
|
|
244
|
+
Panel(result.diagnosis, title="[bold red]Diagnosis[/bold red]", border_style="red")
|
|
245
|
+
)
|
|
246
|
+
console.print(
|
|
247
|
+
Panel(
|
|
248
|
+
result.suggestion,
|
|
249
|
+
title="[bold yellow]Suggestion[/bold yellow]",
|
|
250
|
+
border_style="yellow",
|
|
251
|
+
)
|
|
252
|
+
)
|
|
224
253
|
|
|
225
254
|
# Handle patch
|
|
226
255
|
if result.patch and result.patch.file:
|
|
@@ -300,7 +329,9 @@ def analyze():
|
|
|
300
329
|
if analysis.notes:
|
|
301
330
|
console.print(f"\n[dim]Notes: {analysis.notes}[/dim]")
|
|
302
331
|
|
|
303
|
-
console.print(
|
|
332
|
+
console.print(
|
|
333
|
+
"\n[dim]Run 'xenfra init' to create xenfra.yaml with this configuration.[/dim]"
|
|
334
|
+
)
|
|
304
335
|
|
|
305
336
|
except XenfraAPIError as e:
|
|
306
337
|
console.print(f"[bold red]API Error: {e.detail}[/bold red]")
|