xenfra 0.3.4__py3-none-any.whl → 0.3.6__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/deployments.py +36 -13
- xenfra/commands/intelligence.py +14 -0
- xenfra/utils/codebase.py +43 -0
- xenfra/utils/config.py +62 -0
- xenfra/utils/errors.py +116 -0
- {xenfra-0.3.4.dist-info → xenfra-0.3.6.dist-info}/METADATA +2 -1
- {xenfra-0.3.4.dist-info → xenfra-0.3.6.dist-info}/RECORD +9 -8
- {xenfra-0.3.4.dist-info → xenfra-0.3.6.dist-info}/WHEEL +1 -1
- {xenfra-0.3.4.dist-info → xenfra-0.3.6.dist-info}/entry_points.txt +0 -0
xenfra/commands/deployments.py
CHANGED
|
@@ -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:
|
xenfra/commands/intelligence.py
CHANGED
|
@@ -131,6 +131,20 @@ def init(manual, accept_all):
|
|
|
131
131
|
# Call Intelligence Service
|
|
132
132
|
analysis = client.intelligence.analyze_codebase(code_snippets)
|
|
133
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
|
+
|
|
134
148
|
# Display results
|
|
135
149
|
console.print("\n[bold green]Analysis Complete![/bold green]\n")
|
|
136
150
|
|
xenfra/utils/codebase.py
CHANGED
|
@@ -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."""
|
xenfra/utils/config.py
CHANGED
|
@@ -280,6 +280,68 @@ def apply_patch(patch: dict, target_file: str = None, create_backup_file: bool =
|
|
|
280
280
|
# Replace entire file
|
|
281
281
|
with open(file_to_patch, "w") as f:
|
|
282
282
|
f.write(str(value))
|
|
283
|
+
|
|
284
|
+
# For TOML files (pyproject.toml)
|
|
285
|
+
elif file_to_patch.endswith(".toml"):
|
|
286
|
+
import toml
|
|
287
|
+
|
|
288
|
+
with open(file_to_patch, "r") as f:
|
|
289
|
+
config_data = toml.load(f)
|
|
290
|
+
|
|
291
|
+
operation = patch.get("operation")
|
|
292
|
+
path = patch.get("path", "").strip("/")
|
|
293
|
+
value = patch.get("value")
|
|
294
|
+
|
|
295
|
+
if operation == "add":
|
|
296
|
+
# Special case for pyproject.toml dependencies
|
|
297
|
+
is_pyproject = os.path.basename(file_to_patch) == "pyproject.toml"
|
|
298
|
+
if is_pyproject and (not path or path == "project/dependencies"):
|
|
299
|
+
# Ensure project and dependencies exist
|
|
300
|
+
if "project" not in config_data:
|
|
301
|
+
config_data["project"] = {}
|
|
302
|
+
if "dependencies" not in config_data["project"]:
|
|
303
|
+
config_data["project"]["dependencies"] = []
|
|
304
|
+
|
|
305
|
+
# Add value if not already present
|
|
306
|
+
if value not in config_data["project"]["dependencies"]:
|
|
307
|
+
config_data["project"]["dependencies"].append(value)
|
|
308
|
+
elif path:
|
|
309
|
+
path_parts = path.split("/")
|
|
310
|
+
current = config_data
|
|
311
|
+
for part in path_parts[:-1]:
|
|
312
|
+
if part not in current:
|
|
313
|
+
current[part] = {}
|
|
314
|
+
current = current[part]
|
|
315
|
+
|
|
316
|
+
# If target is a list (like dependencies), append
|
|
317
|
+
target_key = path_parts[-1]
|
|
318
|
+
if target_key in current and isinstance(current[target_key], list):
|
|
319
|
+
if value not in current[target_key]:
|
|
320
|
+
current[target_key].append(value)
|
|
321
|
+
else:
|
|
322
|
+
current[target_key] = value
|
|
323
|
+
else:
|
|
324
|
+
# Root level add
|
|
325
|
+
if isinstance(value, dict):
|
|
326
|
+
config_data.update(value)
|
|
327
|
+
else:
|
|
328
|
+
# Ignore root-level non-dict adds for structured files
|
|
329
|
+
# to prevent overwriting the entire config with a string
|
|
330
|
+
pass
|
|
331
|
+
|
|
332
|
+
elif operation == "replace":
|
|
333
|
+
if path:
|
|
334
|
+
path_parts = path.split("/")
|
|
335
|
+
current = config_data
|
|
336
|
+
for part in path_parts[:-1]:
|
|
337
|
+
current = current[part]
|
|
338
|
+
current[path_parts[-1]] = value
|
|
339
|
+
else:
|
|
340
|
+
config_data = value
|
|
341
|
+
|
|
342
|
+
# Write back
|
|
343
|
+
with open(file_to_patch, "w") as f:
|
|
344
|
+
toml.dump(config_data, f)
|
|
283
345
|
else:
|
|
284
346
|
# Design decision: Only support auto-patching for common dependency files
|
|
285
347
|
# Other file types should be manually edited to avoid data loss
|
xenfra/utils/errors.py
ADDED
|
@@ -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, {}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: xenfra
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.6
|
|
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>
|
|
@@ -24,6 +24,7 @@ Requires-Dist: keyring>=25.7.0
|
|
|
24
24
|
Requires-Dist: keyrings-alt>=5.0.2
|
|
25
25
|
Requires-Dist: tenacity>=8.2.3
|
|
26
26
|
Requires-Dist: cryptography>=43.0.0
|
|
27
|
+
Requires-Dist: toml>=0.10.2
|
|
27
28
|
Requires-Dist: pytest>=8.0.0 ; extra == 'test'
|
|
28
29
|
Requires-Dist: pytest-mock>=3.12.0 ; extra == 'test'
|
|
29
30
|
Requires-Python: >=3.13
|
|
@@ -2,18 +2,19 @@ xenfra/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
2
2
|
xenfra/commands/__init__.py,sha256=kTTwVnTvoxikyPUhQiyTAbnw4PYafktuE1----TqQoA,43
|
|
3
3
|
xenfra/commands/auth.py,sha256=ecReVCGl7Ys2d77mv_e4mCbs4ug6FLIb3S9dl2FUhr4,4178
|
|
4
4
|
xenfra/commands/auth_device.py,sha256=caD2UdveEZtAFjgjmnA-l5bjbbPONFjXJXgeJN7mhbk,6710
|
|
5
|
-
xenfra/commands/deployments.py,sha256=
|
|
6
|
-
xenfra/commands/intelligence.py,sha256=
|
|
5
|
+
xenfra/commands/deployments.py,sha256=bVpQ9kuFDT5a9M7ADwXZzXIXYyPS92saZEuYmiI_lU8,30016
|
|
6
|
+
xenfra/commands/intelligence.py,sha256=jDQa2Bx4blTFoqtyutf1xsf6fHjIjRYEFW5G3mrz-Ks,16594
|
|
7
7
|
xenfra/commands/projects.py,sha256=SAxF_pOr95K6uz35U-zENptKndKxJNZn6bcD45PHcpI,6696
|
|
8
8
|
xenfra/commands/security_cmd.py,sha256=EI5sjX2lcMxgMH-LCFmPVkc9YqadOrcoSgTiKknkVRY,7327
|
|
9
9
|
xenfra/main.py,sha256=2EPPuIdxjhW-I-e-Mc0i2ayeLaSJdmzddNThkXq7B7c,2033
|
|
10
10
|
xenfra/utils/__init__.py,sha256=4ZRYkrb--vzoXjBHG8zRxz2jCXNGtAoKNtkyu2WRI2A,45
|
|
11
11
|
xenfra/utils/auth.py,sha256=9JbFnv0-rdlJF-4hKD2uWd9h9ehqC1iIHee1O5e-3RM,13769
|
|
12
|
-
xenfra/utils/codebase.py,sha256=
|
|
13
|
-
xenfra/utils/config.py,sha256=
|
|
12
|
+
xenfra/utils/codebase.py,sha256=GMrqhOJWX8q5ZXSLI9P3hJZBpufXMQA3Z4fKh2XSTNo,5949
|
|
13
|
+
xenfra/utils/config.py,sha256=UjTRIINSg6vPdtC3akJGxnisX621uoh8_JkhhfHl2RE,14519
|
|
14
|
+
xenfra/utils/errors.py,sha256=6G91YzzDDNkKHANTgfAMiOiMElEyi57wo6-FzRa4VuQ,4211
|
|
14
15
|
xenfra/utils/security.py,sha256=EA8CIPLt8Y-QP5uZ7c5NuC6ZLRV1aZS8NapS9ix_vok,11479
|
|
15
16
|
xenfra/utils/validation.py,sha256=cvuL_AEFJ2oCoP0abCqoOIABOwz79Gkf-jh_dcFIQlM,6912
|
|
16
|
-
xenfra-0.3.
|
|
17
|
-
xenfra-0.3.
|
|
18
|
-
xenfra-0.3.
|
|
19
|
-
xenfra-0.3.
|
|
17
|
+
xenfra-0.3.6.dist-info/WHEEL,sha256=KSLUh82mDPEPk0Bx0ScXlWL64bc8KmzIPNcpQZFV-6E,79
|
|
18
|
+
xenfra-0.3.6.dist-info/entry_points.txt,sha256=a_2cGhYK__X6eW05Ba8uB6RIM_61c2sHtXsPY8N0mic,45
|
|
19
|
+
xenfra-0.3.6.dist-info/METADATA,sha256=t9JZDfEXmmUIj8_BYUlhNVTd_bymZSDDdqzVaxgbMl8,3898
|
|
20
|
+
xenfra-0.3.6.dist-info/RECORD,,
|
|
File without changes
|