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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: xenfra
3
- Version: 0.3.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"
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
- "[yellow]Local deployment requires code upload (not yet fully implemented).[/yellow]"
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
- deployment = client.deployments.create(
219
- project_name=project_name,
220
- git_repo=git_repo,
221
- branch=branch,
222
- framework=framework,
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
- console.print(f"[bold red]✗ Deployment failed: {e.detail}[/bold red]")
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: Show token details
38
- import base64
39
- import json
40
- try:
41
- parts = token.split(".")
42
- if len(parts) == 3:
43
- # Decode payload
44
- payload_b64 = parts[1]
45
- padding = 4 - len(payload_b64) % 4
46
- if padding != 4:
47
- payload_b64 += "=" * padding
48
- payload_bytes = base64.urlsafe_b64decode(payload_b64)
49
- claims = json.loads(payload_bytes)
50
-
51
- console.print("[dim]━━━ DEBUG: Token Info ━━━[/dim]")
52
- console.print(f"[dim] API URL: {API_BASE_URL}[/dim]")
53
- console.print(f"[dim] Token prefix: {token[:20]}...[/dim]")
54
- console.print(f"[dim] sub (email): {claims.get('sub', 'MISSING')}[/dim]")
55
- console.print(f"[dim] user_id: {claims.get('user_id', 'MISSING')}[/dim]")
56
- console.print(f"[dim] iss (issuer): {claims.get('iss', 'MISSING')}[/dim]")
57
- console.print(f"[dim] aud (audience): {claims.get('aud', 'MISSING')}[/dim]")
58
-
59
- # Check if token is expired
60
- exp = claims.get('exp')
61
- if exp:
62
- import time
63
- is_expired = time.time() >= exp
64
- from datetime import datetime, timezone
65
- exp_time = datetime.fromtimestamp(exp, tz=timezone.utc)
66
- console.print(f"[dim] expires_at: {exp_time}[/dim]")
67
- console.print(f"[dim] expired: {is_expired}[/dim]")
68
- console.print("[dim]━━━━━━━━━━━━━━━━━━━━━[/dim]\n")
69
- except Exception as e:
70
- console.print(f"[dim]DEBUG: Could not decode token: {e}[/dim]\n")
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
- except Exception:
135
- pass
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
- with open(TOKEN_FILE, "r") as f:
148
- tokens = json.load(f)
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
- # Save (with restrictive permissions on Unix-like systems)
154
- with open(TOKEN_FILE, "w") as f:
155
- json.dump(tokens, f)
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
- console.print("[dim]DEBUG: Token retrieved from keyring[/dim]")
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
- console.print(f"[dim]DEBUG: Keyring unavailable, using file storage: {e}[/dim]")
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