xenfra 0.3.3__tar.gz → 0.3.5__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.3 → xenfra-0.3.5}/PKG-INFO +2 -1
- {xenfra-0.3.3 → xenfra-0.3.5}/pyproject.toml +2 -1
- {xenfra-0.3.3 → xenfra-0.3.5}/src/xenfra/commands/deployments.py +36 -13
- {xenfra-0.3.3 → xenfra-0.3.5}/src/xenfra/commands/intelligence.py +50 -34
- {xenfra-0.3.3 → xenfra-0.3.5}/src/xenfra/utils/auth.py +66 -12
- {xenfra-0.3.3 → xenfra-0.3.5}/src/xenfra/utils/codebase.py +43 -0
- xenfra-0.3.5/src/xenfra/utils/errors.py +116 -0
- {xenfra-0.3.3 → xenfra-0.3.5}/README.md +0 -0
- {xenfra-0.3.3 → xenfra-0.3.5}/src/xenfra/__init__.py +0 -0
- {xenfra-0.3.3 → xenfra-0.3.5}/src/xenfra/commands/__init__.py +0 -0
- {xenfra-0.3.3 → xenfra-0.3.5}/src/xenfra/commands/auth.py +0 -0
- {xenfra-0.3.3 → xenfra-0.3.5}/src/xenfra/commands/auth_device.py +0 -0
- {xenfra-0.3.3 → xenfra-0.3.5}/src/xenfra/commands/projects.py +0 -0
- {xenfra-0.3.3 → xenfra-0.3.5}/src/xenfra/commands/security_cmd.py +0 -0
- {xenfra-0.3.3 → xenfra-0.3.5}/src/xenfra/main.py +0 -0
- {xenfra-0.3.3 → xenfra-0.3.5}/src/xenfra/utils/__init__.py +0 -0
- {xenfra-0.3.3 → xenfra-0.3.5}/src/xenfra/utils/config.py +0 -0
- {xenfra-0.3.3 → xenfra-0.3.5}/src/xenfra/utils/security.py +0 -0
- {xenfra-0.3.3 → xenfra-0.3.5}/src/xenfra/utils/validation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: xenfra
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
4
4
|
Summary: A 'Zen Mode' infrastructure engine for Python developers.
|
|
5
5
|
Author: xenfra-cloud
|
|
6
6
|
Author-email: xenfra-cloud <xenfracloud@gmail.com>
|
|
@@ -23,6 +23,7 @@ Requires-Dist: httpx>=0.27.0
|
|
|
23
23
|
Requires-Dist: keyring>=25.7.0
|
|
24
24
|
Requires-Dist: keyrings-alt>=5.0.2
|
|
25
25
|
Requires-Dist: tenacity>=8.2.3
|
|
26
|
+
Requires-Dist: cryptography>=43.0.0
|
|
26
27
|
Requires-Dist: pytest>=8.0.0 ; extra == 'test'
|
|
27
28
|
Requires-Dist: pytest-mock>=3.12.0 ; extra == 'test'
|
|
28
29
|
Requires-Python: >=3.13
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xenfra"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.5"
|
|
4
4
|
description = "A 'Zen Mode' infrastructure engine for Python developers."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -30,6 +30,7 @@ dependencies = [
|
|
|
30
30
|
"keyring>=25.7.0",
|
|
31
31
|
"keyrings.alt>=5.0.2",
|
|
32
32
|
"tenacity>=8.2.3", # For retry logic
|
|
33
|
+
"cryptography>=43.0.0", # For encrypted file-based token storage
|
|
33
34
|
]
|
|
34
35
|
requires-python = ">=3.13"
|
|
35
36
|
|
|
@@ -167,13 +167,11 @@ def deploy(project_name, git_repo, branch, framework, no_heal):
|
|
|
167
167
|
console.print(f"[bold red]Invalid git repository URL: {error_msg}[/bold red]")
|
|
168
168
|
raise click.Abort()
|
|
169
169
|
console.print(f"[cyan]Deploying {project_name} from git repository...[/cyan]")
|
|
170
|
+
console.print(f"[dim]Repository: {git_repo} (branch: {branch})[/dim]")
|
|
170
171
|
else:
|
|
171
172
|
console.print(f"[cyan]Deploying {project_name} from local directory...[/cyan]")
|
|
172
|
-
console.print(
|
|
173
|
-
|
|
174
|
-
)
|
|
175
|
-
console.print("[dim]Please use --git-repo for now.[/dim]")
|
|
176
|
-
return
|
|
173
|
+
console.print("[dim]Code will be uploaded to the droplet[/dim]")
|
|
174
|
+
# Note: SDK's InfraEngine handles local upload via fabric.transfer.Upload()
|
|
177
175
|
|
|
178
176
|
# Validate branch name
|
|
179
177
|
is_valid, error_msg = validate_branch_name(branch)
|
|
@@ -215,12 +213,26 @@ def deploy(project_name, git_repo, branch, framework, no_heal):
|
|
|
215
213
|
|
|
216
214
|
# Create deployment
|
|
217
215
|
try:
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
216
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
|
217
|
+
|
|
218
|
+
with Progress(
|
|
219
|
+
SpinnerColumn(),
|
|
220
|
+
TextColumn("[bold blue]{task.description}"),
|
|
221
|
+
BarColumn(),
|
|
222
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
223
|
+
console=console,
|
|
224
|
+
) as progress:
|
|
225
|
+
# Track deployment phases
|
|
226
|
+
task = progress.add_task("Creating deployment...", total=100)
|
|
227
|
+
|
|
228
|
+
deployment = client.deployments.create(
|
|
229
|
+
project_name=project_name,
|
|
230
|
+
git_repo=git_repo,
|
|
231
|
+
branch=branch,
|
|
232
|
+
framework=framework,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
progress.update(task, advance=100, description="Deployment created!")
|
|
224
236
|
|
|
225
237
|
deployment_id = deployment["deployment_id"]
|
|
226
238
|
console.print(
|
|
@@ -237,6 +249,8 @@ def deploy(project_name, git_repo, branch, framework, no_heal):
|
|
|
237
249
|
if git_repo:
|
|
238
250
|
details_table.add_row("Repository", git_repo)
|
|
239
251
|
details_table.add_row("Branch", branch)
|
|
252
|
+
else:
|
|
253
|
+
details_table.add_row("Source", "Local directory")
|
|
240
254
|
details_table.add_row("Framework", framework)
|
|
241
255
|
details_table.add_row("Status", deployment.get("status", "PENDING"))
|
|
242
256
|
|
|
@@ -260,8 +274,17 @@ def deploy(project_name, git_repo, branch, framework, no_heal):
|
|
|
260
274
|
break
|
|
261
275
|
|
|
262
276
|
except XenfraAPIError as e:
|
|
263
|
-
# Deployment failed
|
|
264
|
-
|
|
277
|
+
# Deployment failed - try to provide helpful error
|
|
278
|
+
from ..utils.errors import detect_error_type, show_error_with_solution
|
|
279
|
+
|
|
280
|
+
console.print(f"[bold red]✗ Deployment failed[/bold red]")
|
|
281
|
+
|
|
282
|
+
# Try to detect error type and show solution
|
|
283
|
+
error_type, error_kwargs = detect_error_type(str(e.detail))
|
|
284
|
+
if error_type:
|
|
285
|
+
show_error_with_solution(error_type, **error_kwargs)
|
|
286
|
+
else:
|
|
287
|
+
console.print(f"[red]{e.detail}[/red]")
|
|
265
288
|
|
|
266
289
|
# Check if we should auto-heal
|
|
267
290
|
if no_heal or attempt >= MAX_RETRY_ATTEMPTS:
|
|
@@ -34,40 +34,42 @@ def get_client() -> XenfraClient:
|
|
|
34
34
|
console.print("[bold red]Not logged in. Run 'xenfra login' first.[/bold red]")
|
|
35
35
|
raise click.Abort()
|
|
36
36
|
|
|
37
|
-
# DEBUG:
|
|
38
|
-
import
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
37
|
+
# DEBUG: Only show token info if XENFRA_DEBUG environment variable is set
|
|
38
|
+
import os
|
|
39
|
+
if os.getenv("XENFRA_DEBUG") == "1":
|
|
40
|
+
import base64
|
|
41
|
+
import json
|
|
42
|
+
try:
|
|
43
|
+
parts = token.split(".")
|
|
44
|
+
if len(parts) == 3:
|
|
45
|
+
# Decode payload
|
|
46
|
+
payload_b64 = parts[1]
|
|
47
|
+
padding = 4 - len(payload_b64) % 4
|
|
48
|
+
if padding != 4:
|
|
49
|
+
payload_b64 += "=" * padding
|
|
50
|
+
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
|
51
|
+
claims = json.loads(payload_bytes)
|
|
52
|
+
|
|
53
|
+
console.print("[dim]━━━ DEBUG: Token Info ━━━[/dim]")
|
|
54
|
+
console.print(f"[dim] API URL: {API_BASE_URL}[/dim]")
|
|
55
|
+
console.print(f"[dim] Token prefix: {token[:20]}...[/dim]")
|
|
56
|
+
console.print(f"[dim] sub (email): {claims.get('sub', 'MISSING')}[/dim]")
|
|
57
|
+
console.print(f"[dim] user_id: {claims.get('user_id', 'MISSING')}[/dim]")
|
|
58
|
+
console.print(f"[dim] iss (issuer): {claims.get('iss', 'MISSING')}[/dim]")
|
|
59
|
+
console.print(f"[dim] aud (audience): {claims.get('aud', 'MISSING')}[/dim]")
|
|
60
|
+
|
|
61
|
+
# Check if token is expired
|
|
62
|
+
exp = claims.get('exp')
|
|
63
|
+
if exp:
|
|
64
|
+
import time
|
|
65
|
+
is_expired = time.time() >= exp
|
|
66
|
+
from datetime import datetime, timezone
|
|
67
|
+
exp_time = datetime.fromtimestamp(exp, tz=timezone.utc)
|
|
68
|
+
console.print(f"[dim] expires_at: {exp_time}[/dim]")
|
|
69
|
+
console.print(f"[dim] expired: {is_expired}[/dim]")
|
|
70
|
+
console.print("[dim]━━━━━━━━━━━━━━━━━━━━━[/dim]\n")
|
|
71
|
+
except Exception as e:
|
|
72
|
+
console.print(f"[dim]DEBUG: Could not decode token: {e}[/dim]\n")
|
|
71
73
|
|
|
72
74
|
return XenfraClient(token=token, api_url=API_BASE_URL)
|
|
73
75
|
|
|
@@ -129,6 +131,20 @@ def init(manual, accept_all):
|
|
|
129
131
|
# Call Intelligence Service
|
|
130
132
|
analysis = client.intelligence.analyze_codebase(code_snippets)
|
|
131
133
|
|
|
134
|
+
# Client-side conflict detection (ensures Zen Nod always triggers)
|
|
135
|
+
from ..utils.codebase import detect_package_manager_conflicts
|
|
136
|
+
has_conflict_local, detected_managers_local = detect_package_manager_conflicts(code_snippets)
|
|
137
|
+
|
|
138
|
+
if has_conflict_local and not analysis.has_conflict:
|
|
139
|
+
# AI missed the conflict - fix it client-side
|
|
140
|
+
console.print("[dim]Note: Enhanced conflict detection activated[/dim]\n")
|
|
141
|
+
analysis.has_conflict = True
|
|
142
|
+
# Convert dict to object for compatibility
|
|
143
|
+
from types import SimpleNamespace
|
|
144
|
+
analysis.detected_package_managers = [
|
|
145
|
+
SimpleNamespace(**pm) for pm in detected_managers_local
|
|
146
|
+
]
|
|
147
|
+
|
|
132
148
|
# Display results
|
|
133
149
|
console.print("\n[bold green]Analysis Complete![/bold green]\n")
|
|
134
150
|
|
|
@@ -124,37 +124,89 @@ def _refresh_token_with_retry(refresh_token: str) -> dict:
|
|
|
124
124
|
return token_data
|
|
125
125
|
|
|
126
126
|
|
|
127
|
+
def _get_encryption_key() -> bytes:
|
|
128
|
+
"""
|
|
129
|
+
Get or create encryption key for file-based token storage.
|
|
130
|
+
|
|
131
|
+
Uses machine-specific identifier to generate key (not perfect but better than plaintext).
|
|
132
|
+
"""
|
|
133
|
+
import platform
|
|
134
|
+
import hashlib
|
|
135
|
+
|
|
136
|
+
# Use machine ID + username as seed (available on Windows/Linux/Mac)
|
|
137
|
+
machine_id = platform.node() + os.getlogin()
|
|
138
|
+
key = hashlib.sha256(machine_id.encode()).digest()[:32] # 32 bytes for Fernet
|
|
139
|
+
return key
|
|
140
|
+
|
|
141
|
+
|
|
127
142
|
def _get_token_from_file(key: str) -> str | None:
|
|
128
143
|
"""Fallback: Get token from file storage (for Windows if keyring fails)."""
|
|
129
144
|
try:
|
|
130
145
|
if TOKEN_FILE.exists():
|
|
146
|
+
with open(TOKEN_FILE, "rb") as f:
|
|
147
|
+
encrypted_data = f.read()
|
|
148
|
+
|
|
149
|
+
# Decrypt the file contents
|
|
150
|
+
from cryptography.fernet import Fernet
|
|
151
|
+
import base64
|
|
152
|
+
|
|
153
|
+
fernet_key = base64.urlsafe_b64encode(_get_encryption_key())
|
|
154
|
+
cipher = Fernet(fernet_key)
|
|
155
|
+
decrypted_data = cipher.decrypt(encrypted_data)
|
|
156
|
+
tokens = json.loads(decrypted_data.decode())
|
|
157
|
+
return tokens.get(key)
|
|
158
|
+
except Exception:
|
|
159
|
+
# If decryption fails, try reading as plaintext (for backward compatibility)
|
|
160
|
+
try:
|
|
131
161
|
with open(TOKEN_FILE, "r") as f:
|
|
132
162
|
tokens = json.load(f)
|
|
133
163
|
return tokens.get(key)
|
|
134
|
-
|
|
135
|
-
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
136
166
|
return None
|
|
137
167
|
|
|
138
168
|
|
|
139
169
|
def _set_token_to_file(key: str, value: str):
|
|
140
|
-
"""Fallback: Save token to file storage (for Windows if keyring fails)."""
|
|
170
|
+
"""Fallback: Save token to file storage (encrypted for Windows if keyring fails)."""
|
|
141
171
|
try:
|
|
142
172
|
TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
143
173
|
|
|
144
|
-
# Load existing tokens
|
|
174
|
+
# Load existing tokens (try encrypted first, then plaintext)
|
|
145
175
|
tokens = {}
|
|
146
176
|
if TOKEN_FILE.exists():
|
|
147
|
-
|
|
148
|
-
|
|
177
|
+
try:
|
|
178
|
+
with open(TOKEN_FILE, "rb") as f:
|
|
179
|
+
encrypted_data = f.read()
|
|
180
|
+
from cryptography.fernet import Fernet
|
|
181
|
+
import base64
|
|
182
|
+
|
|
183
|
+
fernet_key = base64.urlsafe_b64encode(_get_encryption_key())
|
|
184
|
+
cipher = Fernet(fernet_key)
|
|
185
|
+
decrypted_data = cipher.decrypt(encrypted_data)
|
|
186
|
+
tokens = json.loads(decrypted_data.decode())
|
|
187
|
+
except Exception:
|
|
188
|
+
# Fallback to plaintext for backward compatibility
|
|
189
|
+
try:
|
|
190
|
+
with open(TOKEN_FILE, "r") as f:
|
|
191
|
+
tokens = json.load(f)
|
|
192
|
+
except Exception:
|
|
193
|
+
tokens = {}
|
|
149
194
|
|
|
150
195
|
# Update token
|
|
151
196
|
tokens[key] = value
|
|
152
197
|
|
|
153
|
-
#
|
|
154
|
-
|
|
155
|
-
|
|
198
|
+
# Encrypt and save
|
|
199
|
+
from cryptography.fernet import Fernet
|
|
200
|
+
import base64
|
|
201
|
+
|
|
202
|
+
fernet_key = base64.urlsafe_b64encode(_get_encryption_key())
|
|
203
|
+
cipher = Fernet(fernet_key)
|
|
204
|
+
encrypted_data = cipher.encrypt(json.dumps(tokens).encode())
|
|
205
|
+
|
|
206
|
+
with open(TOKEN_FILE, "wb") as f:
|
|
207
|
+
f.write(encrypted_data)
|
|
156
208
|
|
|
157
|
-
# Set file permissions (owner read/write only)
|
|
209
|
+
# Set file permissions (owner read/write only) - works on Unix-like systems
|
|
158
210
|
if os.name != "nt": # Not Windows
|
|
159
211
|
TOKEN_FILE.chmod(0o600)
|
|
160
212
|
except Exception as e:
|
|
@@ -195,9 +247,11 @@ def get_auth_token() -> str | None:
|
|
|
195
247
|
try:
|
|
196
248
|
access_token = keyring.get_password(SERVICE_ID, "access_token")
|
|
197
249
|
refresh_token = keyring.get_password(SERVICE_ID, "refresh_token")
|
|
198
|
-
|
|
250
|
+
if os.getenv("XENFRA_DEBUG") == "1":
|
|
251
|
+
console.print("[dim]DEBUG: Token retrieved from keyring[/dim]")
|
|
199
252
|
except keyring.errors.KeyringError as e:
|
|
200
|
-
|
|
253
|
+
if os.getenv("XENFRA_DEBUG") == "1":
|
|
254
|
+
console.print(f"[dim]DEBUG: Keyring unavailable, using file storage: {e}[/dim]")
|
|
201
255
|
use_file_fallback = True
|
|
202
256
|
access_token = _get_token_from_file("access_token")
|
|
203
257
|
refresh_token = _get_token_from_file("refresh_token")
|
|
@@ -120,6 +120,49 @@ def scan_codebase(max_files: int = 10, max_size: int = 50000) -> dict[str, str]:
|
|
|
120
120
|
|
|
121
121
|
return code_snippets
|
|
122
122
|
|
|
123
|
+
def detect_package_manager_conflicts(code_snippets: dict[str, str]) -> tuple[bool, list[dict]]:
|
|
124
|
+
"""
|
|
125
|
+
Deterministically detect package manager conflicts from scanned files.
|
|
126
|
+
|
|
127
|
+
This ensures Zen Nod (conflict resolution) always triggers when multiple
|
|
128
|
+
package managers are present, regardless of AI detection.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
code_snippets: Dictionary of filename -> content from scan_codebase()
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
(has_conflict, detected_managers) where detected_managers is a list of
|
|
135
|
+
{"manager": str, "file": str} dictionaries
|
|
136
|
+
"""
|
|
137
|
+
detected = []
|
|
138
|
+
|
|
139
|
+
# Check for Python package managers
|
|
140
|
+
if "pyproject.toml" in code_snippets:
|
|
141
|
+
content = code_snippets["pyproject.toml"]
|
|
142
|
+
if "[tool.poetry]" in content or "poetry.lock" in code_snippets:
|
|
143
|
+
detected.append({"manager": "poetry", "file": "pyproject.toml"})
|
|
144
|
+
elif "[tool.uv]" in content or "uv.lock" in code_snippets or "[project]" in content:
|
|
145
|
+
detected.append({"manager": "uv", "file": "pyproject.toml"})
|
|
146
|
+
|
|
147
|
+
if "Pipfile" in code_snippets:
|
|
148
|
+
detected.append({"manager": "pipenv", "file": "Pipfile"})
|
|
149
|
+
|
|
150
|
+
if "requirements.txt" in code_snippets:
|
|
151
|
+
detected.append({"manager": "pip", "file": "requirements.txt"})
|
|
152
|
+
|
|
153
|
+
# Check for Node.js package managers (all independent checks)
|
|
154
|
+
if "pnpm-lock.yaml" in code_snippets:
|
|
155
|
+
detected.append({"manager": "pnpm", "file": "pnpm-lock.yaml"})
|
|
156
|
+
|
|
157
|
+
if "yarn.lock" in code_snippets:
|
|
158
|
+
detected.append({"manager": "yarn", "file": "yarn.lock"})
|
|
159
|
+
|
|
160
|
+
if "package-lock.json" in code_snippets:
|
|
161
|
+
detected.append({"manager": "npm", "file": "package-lock.json"})
|
|
162
|
+
|
|
163
|
+
has_conflict = len(detected) > 1
|
|
164
|
+
return has_conflict, detected
|
|
165
|
+
|
|
123
166
|
|
|
124
167
|
def has_xenfra_config() -> bool:
|
|
125
168
|
"""Check if xenfra.yaml already exists."""
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Human-friendly error messages with actionable solutions."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
|
|
5
|
+
console = Console()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
ERROR_SOLUTIONS = {
|
|
9
|
+
"port_in_use": {
|
|
10
|
+
"message": "Port {port} is already in use on the droplet",
|
|
11
|
+
"solution": "Change the port in xenfra.yaml or stop the conflicting service",
|
|
12
|
+
"command": "ssh root@{{ip}} 'lsof -i :{port}' # Find process using port",
|
|
13
|
+
},
|
|
14
|
+
"missing_dependency": {
|
|
15
|
+
"message": "Missing dependency: {package}",
|
|
16
|
+
"solution": "Add {package} to dependencies in {file}",
|
|
17
|
+
"command": "uv add {package} # OR: echo '{package}' >> requirements.txt",
|
|
18
|
+
},
|
|
19
|
+
"ssh_failure": {
|
|
20
|
+
"message": "Cannot connect to droplet via SSH",
|
|
21
|
+
"solution": "Check firewall rules, wait for droplet boot, or verify SSH keys",
|
|
22
|
+
"command": "ssh -v root@{ip} # Verbose SSH for debugging",
|
|
23
|
+
},
|
|
24
|
+
"docker_build_failed": {
|
|
25
|
+
"message": "Docker build failed",
|
|
26
|
+
"solution": "Check Dockerfile syntax and base image availability",
|
|
27
|
+
"command": "docker build . --no-cache # Test locally",
|
|
28
|
+
},
|
|
29
|
+
"health_check_failed": {
|
|
30
|
+
"message": "Application failed health check",
|
|
31
|
+
"solution": "Ensure your app responds on port {port} at /health or /",
|
|
32
|
+
"command": "curl http://{{ip}}:{port}/health",
|
|
33
|
+
},
|
|
34
|
+
"out_of_memory": {
|
|
35
|
+
"message": "Container out of memory",
|
|
36
|
+
"solution": "Upgrade to a larger instance size in xenfra.yaml",
|
|
37
|
+
"command": "docker stats # Check memory usage",
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def show_error_with_solution(error_type: str, **kwargs) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Display error with actionable solution.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
error_type: Key from ERROR_SOLUTIONS
|
|
48
|
+
**kwargs: Template variables (port, ip, package, file, etc.)
|
|
49
|
+
"""
|
|
50
|
+
error = ERROR_SOLUTIONS.get(error_type)
|
|
51
|
+
|
|
52
|
+
if not error:
|
|
53
|
+
# Fallback for unknown errors
|
|
54
|
+
console.print(f"[red]❌ Error: {error_type}[/red]")
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# Format message with provided kwargs
|
|
58
|
+
try:
|
|
59
|
+
message = error["message"].format(**kwargs)
|
|
60
|
+
solution = error["solution"].format(**kwargs)
|
|
61
|
+
command = error.get("command", "").format(**kwargs)
|
|
62
|
+
except KeyError as e:
|
|
63
|
+
console.print(f"[red]❌ Error formatting message: missing {e}[/red]")
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
console.print()
|
|
67
|
+
console.print(f"[red]❌ {message}[/red]")
|
|
68
|
+
console.print(f"[yellow]💡 Solution: {solution}[/yellow]")
|
|
69
|
+
|
|
70
|
+
if command:
|
|
71
|
+
console.print(f"[dim]Try: {command}[/dim]")
|
|
72
|
+
console.print()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def detect_error_type(error_message: str) -> tuple[str, dict]:
|
|
76
|
+
"""
|
|
77
|
+
Attempt to detect error type from message.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
(error_type, kwargs) for show_error_with_solution()
|
|
81
|
+
"""
|
|
82
|
+
error_lower = error_message.lower()
|
|
83
|
+
|
|
84
|
+
# Port detection
|
|
85
|
+
if "port" in error_lower and ("in use" in error_lower or "already" in error_lower):
|
|
86
|
+
# Try to extract port number
|
|
87
|
+
import re
|
|
88
|
+
port_match = re.search(r"port\s+(\d+)", error_lower)
|
|
89
|
+
port = port_match.group(1) if port_match else "8000"
|
|
90
|
+
return "port_in_use", {"port": port}
|
|
91
|
+
|
|
92
|
+
# SSH detection
|
|
93
|
+
if "ssh" in error_lower or "connection refused" in error_lower:
|
|
94
|
+
return "ssh_failure", {"ip": "DROPLET_IP"}
|
|
95
|
+
|
|
96
|
+
# Docker detection
|
|
97
|
+
if "docker" in error_lower and "build" in error_lower:
|
|
98
|
+
return "docker_build_failed", {}
|
|
99
|
+
|
|
100
|
+
# Health check detection
|
|
101
|
+
if "health" in error_lower and ("fail" in error_lower or "timeout" in error_lower):
|
|
102
|
+
return "health_check_failed", {"port": "8000"}
|
|
103
|
+
|
|
104
|
+
# Memory detection
|
|
105
|
+
if "memory" in error_lower or "oom" in error_lower:
|
|
106
|
+
return "out_of_memory", {}
|
|
107
|
+
|
|
108
|
+
# Module not found
|
|
109
|
+
if "modulenotfounderror" in error_lower or "no module named" in error_lower:
|
|
110
|
+
import re
|
|
111
|
+
module_match = re.search(r"no module named ['\"]([^'\"]+)['\"]", error_lower)
|
|
112
|
+
package = module_match.group(1) if module_match else "PACKAGE_NAME"
|
|
113
|
+
return "missing_dependency", {"package": package, "file": "pyproject.toml"}
|
|
114
|
+
|
|
115
|
+
# Unknown
|
|
116
|
+
return None, {}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|