xenfra 0.4.3__py3-none-any.whl → 0.4.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.
@@ -1,973 +1,1358 @@
1
- """
2
- Deployment commands for Xenfra CLI.
3
- """
4
-
5
- import os
6
-
7
- import click
8
- from rich.console import Console
9
- from rich.panel import Panel
10
- from rich.table import Table
11
- from xenfra_sdk import XenfraClient
12
- from xenfra_sdk.exceptions import XenfraAPIError, XenfraError
13
- from xenfra_sdk.privacy import scrub_logs
14
-
15
- from ..utils.auth import API_BASE_URL, get_auth_token
16
- from ..utils.codebase import has_xenfra_config
17
- from ..utils.config import apply_patch, read_xenfra_yaml
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
- )
25
-
26
- import time
27
- from datetime import datetime
28
-
29
- from rich.live import Live
30
- from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
31
-
32
- console = Console()
33
-
34
- # Maximum number of retry attempts for auto-healing
35
- MAX_RETRY_ATTEMPTS = 3
36
-
37
-
38
- def get_client() -> XenfraClient:
39
- """Get authenticated SDK client."""
40
- token = get_auth_token()
41
- if not token:
42
- console.print("[bold red]Not logged in. Run 'xenfra auth login' first.[/bold red]")
43
- raise click.Abort()
44
-
45
- return XenfraClient(token=token, api_url=API_BASE_URL)
46
-
47
-
48
- def show_diagnosis_panel(diagnosis: str, suggestion: str):
49
- """Display diagnosis and suggestion in formatted panels."""
50
- console.print()
51
- console.print(Panel(diagnosis, title="[bold red]🔍 Diagnosis[/bold red]", border_style="red"))
52
- console.print()
53
- console.print(
54
- Panel(suggestion, title="[bold yellow]💡 Suggestion[/bold yellow]", border_style="yellow")
55
- )
56
-
57
-
58
- def show_patch_preview(patch_data: dict):
59
- """Show a preview of the patch that will be applied."""
60
- console.print()
61
- console.print("[bold green]🔧 Automatic Fix Available[/bold green]")
62
- console.print(f" [cyan]File:[/cyan] {patch_data.get('file')}")
63
- console.print(f" [cyan]Operation:[/cyan] {patch_data.get('operation')}")
64
- console.print(f" [cyan]Value:[/cyan] {patch_data.get('value')}")
65
- console.print()
66
-
67
-
68
- def _stream_deployment(client: XenfraClient, project_name: str, git_repo: str, branch: str, framework: str, region: str, size_slug: str, is_dockerized: bool = True, port: int = None, command: str = None, entrypoint: str = None, database: str = None, package_manager: str = None, dependency_file: str = None, file_manifest: list = None):
69
- """
70
- Creates deployment with real-time SSE streaming (no polling needed).
71
-
72
- Returns tuple of (status, deployment_id, logs_buffer)
73
- """
74
- console.print(Panel(
75
- f"[bold cyan]Project:[/bold cyan] {project_name}\n"
76
- f"[bold cyan]Mode:[/bold cyan] Real-time Streaming Deployment",
77
- title="[bold green]🚀 Deployment Starting[/bold green]",
78
- border_style="green"
79
- ))
80
-
81
- deployment_id = None
82
- logs_buffer = []
83
- status_val = "PENDING"
84
-
85
- try:
86
- for event in client.deployments.create_stream(
87
- project_name=project_name,
88
- git_repo=git_repo,
89
- branch=branch,
90
- framework=framework,
91
- region=region,
92
- size_slug=size_slug,
93
- is_dockerized=is_dockerized,
94
- port=port,
95
- command=command,
96
- entrypoint=entrypoint, # Pass entrypoint to deployment API
97
- database=database,
98
- package_manager=package_manager,
99
- dependency_file=dependency_file,
100
- file_manifest=file_manifest,
101
- ):
102
-
103
- event_type = event.get("event", "message")
104
- data = event.get("data", "")
105
-
106
- if event_type == "deployment_created":
107
- # Extract deployment ID
108
- if isinstance(data, dict):
109
- deployment_id = data.get("deployment_id")
110
- console.print(f"[bold green]✓[/bold green] Deployment created: [cyan]{deployment_id}[/cyan]\n")
111
-
112
- elif event_type == "log":
113
- # Real-time log output
114
- log_line = str(data)
115
- logs_buffer.append(log_line)
116
-
117
- # Colorize output
118
- if any(x in log_line for x in ["ERROR", "FAILED", "✗"]):
119
- console.print(f"[bold red]{log_line}[/bold red]")
120
- elif any(x in log_line for x in ["WARN", "WARNING", "⚠"]):
121
- console.print(f"[yellow]{log_line}[/yellow]")
122
- elif any(x in log_line for x in ["SUCCESS", "COMPLETED", "✓", "passed!"]):
123
- console.print(f"[bold green]{log_line}[/bold green]")
124
- elif "PHASE" in log_line:
125
- console.print(f"\n[bold blue]{log_line}[/bold blue]")
126
- elif "[InfraEngine]" in log_line or "[INFO]" in log_line:
127
- console.print(f"[cyan][/cyan] {log_line}")
128
- else:
129
- console.print(f"[dim]{log_line}[/dim]")
130
-
131
- elif event_type == "error":
132
- error_msg = str(data)
133
- logs_buffer.append(f"ERROR: {error_msg}")
134
- console.print(f"\n[bold red]❌ Error: {error_msg}[/bold red]")
135
- status_val = "FAILED"
136
-
137
- elif event_type == "deployment_complete":
138
- # Final status
139
- if isinstance(data, dict):
140
- status_val = data.get("status", "UNKNOWN")
141
- ip_address = data.get("ip_address")
142
-
143
- console.print()
144
- if status_val == "SUCCESS":
145
- console.print("[bold green]✨ SUCCESS: Your application is live![/bold green]")
146
- if ip_address and ip_address != "unknown":
147
- console.print(f"[bold]Accessible at:[/bold] [link=http://{ip_address}]http://{ip_address}[/link]")
148
- elif status_val == "FAILED":
149
- console.print("[bold red]❌ DEPLOYMENT FAILED[/bold red]")
150
- error = data.get("error")
151
- if error:
152
- console.print(f"[red]Error: {error}[/red]")
153
- break
154
-
155
- except Exception as e:
156
- console.print(f"\n[bold red]❌ Streaming error: {e}[/bold red]")
157
- status_val = "FAILED"
158
- logs_buffer.append(f"Streaming error: {e}")
159
-
160
- return (status_val, deployment_id, "\n".join(logs_buffer))
161
-
162
-
163
- def _follow_deployment(client: XenfraClient, deployment_id: str):
164
- """
165
- Polls logs and status in real-time until completion with CI/CD style output.
166
- (LEGACY - Used for backward compatibility)
167
- """
168
- console.print(Panel(
169
- f"[bold cyan]Deployment ID:[/bold cyan] {deployment_id}\n"
170
- f"[bold cyan]Mode:[/bold cyan] Streaming Real-time Infrastructure Logs",
171
- title="[bold green]🚀 Deployment Monitor[/bold green]",
172
- border_style="green"
173
- ))
174
-
175
- last_log_len = 0
176
- status_val = "PENDING"
177
-
178
- # Use a live display for the progress bar at the bottom
179
- with Progress(
180
- SpinnerColumn(),
181
- TextColumn("[bold blue]{task.description}"),
182
- BarColumn(bar_width=40),
183
- TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
184
- console=console,
185
- transient=False,
186
- ) as progress:
187
- task = progress.add_task("Waiting for server response...", total=100)
188
-
189
- while status_val not in ["SUCCESS", "FAILED", "CANCELLED"]:
190
- try:
191
- # 1. Update Status
192
- dep_status = client.deployments.get_status(deployment_id)
193
- status_val = dep_status.get("status", "PENDING")
194
- progress_val = dep_status.get("progress", 0)
195
- state = dep_status.get("state", "preparing")
196
-
197
- # Use a more descriptive description for the progress task
198
- desc = f"Phase: {state}"
199
- if status_val == "FAILED":
200
- desc = "[bold red]FAILED[/bold red]"
201
- elif status_val == "SUCCESS":
202
- desc = "[bold green]SUCCESS[/bold green]"
203
-
204
- progress.update(task, completed=progress_val, description=desc)
205
-
206
- # 2. Update Logs
207
- log_content = client.deployments.get_logs(deployment_id)
208
- if log_content and len(log_content) > last_log_len:
209
- new_logs = log_content[last_log_len:].strip()
210
- for line in new_logs.split("\n"):
211
- # Process and colorize lines
212
- clean_line = line.strip()
213
- if not clean_line:
214
- continue
215
-
216
- if any(x in clean_line for x in ["ERROR", "FAILED", "✗"]):
217
- progress.console.print(f"[bold red]{clean_line}[/bold red]")
218
- elif any(x in clean_line for x in ["WARN", "WARNING", "⚠"]):
219
- progress.console.print(f"[yellow]{clean_line}[/yellow]")
220
- elif any(x in clean_line for x in ["SUCCESS", "COMPLETED", "✓", "passed!"]):
221
- progress.console.print(f"[bold green]{clean_line}[/bold green]")
222
- elif "PHASE" in clean_line:
223
- progress.console.print(f"\n[bold blue]{clean_line}[/bold blue]")
224
- elif "[InfraEngine]" in clean_line:
225
- progress.console.print(f"[dim]{clean_line}[/dim]")
226
- else:
227
- progress.console.print(f"[cyan][/cyan] {clean_line}")
228
-
229
- last_log_len = len(log_content)
230
-
231
- if status_val in ["SUCCESS", "FAILED", "CANCELLED"]:
232
- break
233
-
234
- time.sleep(1.5) # Slightly faster polling for better feel
235
- except Exception as e:
236
- # progress.console.print(f"[dim]Transient connection issue: {e}[/dim]")
237
- time.sleep(3)
238
- continue
239
-
240
- console.print()
241
- if status_val == "SUCCESS":
242
- console.print("[bold green] SUCCESS: Your application is live![/bold green]")
243
- # Try to get the IP address
244
- try:
245
- final_status = client.deployments.get_status(deployment_id)
246
- ip = final_status.get("ip_address")
247
- if ip:
248
- console.print(f"[bold]Accessible at:[/bold] [link=http://{ip}]http://{ip}[/link]")
249
- except:
250
- pass
251
- elif status_val == "FAILED":
252
- console.print("\n[bold red]❌ FAILURE DETECTED: Entering AI Diagnosis Mode...[/bold red]")
253
-
254
- return status_val
255
-
256
-
257
- def zen_nod_workflow(logs: str, client: XenfraClient, attempt: int) -> bool:
258
- """
259
- Execute the Zen Nod auto-healing workflow.
260
-
261
- Args:
262
- logs: Deployment error logs
263
- client: Authenticated SDK client
264
- attempt: Current attempt number
265
-
266
- Returns:
267
- True if patch was applied and user wants to retry, False otherwise
268
- """
269
- console.print()
270
- console.print(f"[cyan]🤖 Analyzing failure (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]")
271
-
272
- # Slice logs to last 300 lines for focused diagnosis (Fix #26)
273
- log_lines = logs.split("\n")
274
- if len(log_lines) > 300:
275
- logs = "\n".join(log_lines[-300:])
276
- console.print("[dim]Note: Analyzing only the last 300 lines of logs for efficiency.[/dim]")
277
-
278
- # Scrub sensitive data from logs
279
- scrubbed_logs = scrub_logs(logs)
280
-
281
- # Diagnose with AI
282
- try:
283
- diagnosis_result = client.intelligence.diagnose(scrubbed_logs)
284
- except Exception as e:
285
- console.print(f"[yellow]Could not diagnose failure: {e}[/yellow]")
286
- return False
287
-
288
- # Show diagnosis
289
- show_diagnosis_panel(diagnosis_result.diagnosis, diagnosis_result.suggestion)
290
-
291
- # Check if there's an automatic patch
292
- if diagnosis_result.patch and diagnosis_result.patch.file:
293
- show_patch_preview(diagnosis_result.patch.model_dump())
294
-
295
- # Zen Nod confirmation
296
- if click.confirm("Apply this fix and retry deployment?", default=True):
297
- try:
298
- # Apply patch (with automatic backup)
299
- backup_path = apply_patch(diagnosis_result.patch.model_dump())
300
- console.print("[bold green]✓ Patch applied[/bold green]")
301
- if backup_path:
302
- console.print(f"[dim]Backup saved: {backup_path}[/dim]")
303
- return True # Signal to retry
304
- except Exception as e:
305
- console.print(f"[bold red]Failed to apply patch: {e}[/bold red]")
306
- return False
307
- else:
308
- console.print()
309
- console.print("[yellow]❌ Patch declined. Follow the manual steps above.[/yellow]")
310
- return False
311
- else:
312
- console.print()
313
- console.print(
314
- "[yellow]No automatic fix available. Please follow the manual steps above.[/yellow]"
315
- )
316
- return False
317
-
318
-
319
- @click.command()
320
- @click.option("--project-name", help="Project name (defaults to current directory name)")
321
- @click.option("--git-repo", help="Git repository URL (if deploying from git)")
322
- @click.option("--branch", default="main", help="Git branch (default: main)")
323
- @click.option("--framework", help="Framework override (fastapi, flask, django)")
324
- @click.option("--region", help="DigitalOcean region override")
325
- @click.option("--size", help="DigitalOcean size slug override")
326
- @click.option("--no-heal", is_flag=True, help="Disable auto-healing on failure")
327
- def deploy(project_name, git_repo, branch, framework, region, size, no_heal):
328
- """
329
- Deploy current project to DigitalOcean with auto-healing.
330
-
331
- Deploys your application with zero configuration. The CLI will:
332
- 1. Check for xenfra.yaml (or run init if missing)
333
- 2. Create a deployment
334
- 3. Auto-diagnose and fix failures (unless --no-heal is set or XENFRA_NO_AI=1)
335
-
336
- Set XENFRA_NO_AI=1 environment variable to disable all AI features.
337
- """
338
- # Check XENFRA_NO_AI environment variable
339
- no_ai = os.environ.get("XENFRA_NO_AI", "0") == "1"
340
- if no_ai:
341
- console.print("[yellow]XENFRA_NO_AI is set. Auto-healing disabled.[/yellow]")
342
- no_heal = True
343
-
344
- # Check for xenfra.yaml
345
- if not has_xenfra_config():
346
- console.print("[yellow]No xenfra.yaml found.[/yellow]")
347
- if click.confirm("Run 'xenfra init' to create configuration?", default=True):
348
- from .intelligence import init
349
-
350
- ctx = click.get_current_context()
351
- ctx.invoke(init, manual=no_ai, accept_all=False)
352
- else:
353
- console.print("[dim]Deployment cancelled.[/dim]")
354
- return
355
-
356
- # Load configuration from xenfra.yaml if it exists
357
- config = {}
358
- if has_xenfra_config():
359
- try:
360
- config = read_xenfra_yaml()
361
- except Exception as e:
362
- console.print(f"[yellow]Warning: Could not read xenfra.yaml: {e}[/dim]")
363
-
364
- # Resolve values with precedence: 1. CLI Flag, 2. xenfra.yaml, 3. Default
365
- project_name = project_name or config.get("name") or os.path.basename(os.getcwd())
366
- framework = framework or config.get("framework")
367
- # Track if is_dockerized was explicitly set in config (to avoid AI override)
368
- is_dockerized_from_config = "is_dockerized" in config
369
- is_dockerized = config.get("is_dockerized", True)
370
- region = region or config.get("region") or "nyc3"
371
-
372
- # Resolve size slug (complex mapping)
373
- if not size:
374
- if config.get("size"):
375
- size = config.get("size")
376
- else:
377
- instance_size = config.get("instance_size", "basic")
378
- resources = config.get("resources", {})
379
- cpu = resources.get("cpu", 1)
380
-
381
- if instance_size == "standard" or cpu >= 2:
382
- size = "s-2vcpu-4gb"
383
- elif instance_size == "premium" or cpu >= 4:
384
- size = "s-4vcpu-8gb"
385
- else:
386
- size = "s-1vcpu-1gb"
387
-
388
- # Extract port, command, database from config
389
- # Track if port was explicitly set to avoid AI override
390
- port_from_config = config.get("port")
391
- port = port_from_config or 8000
392
- command = config.get("command") # Auto-detected if not provided
393
- entrypoint = config.get("entrypoint") # e.g., "todo.main:app"
394
- database_config = config.get("database", {})
395
- database = database_config.get("type") if isinstance(database_config, dict) else None
396
- package_manager = config.get("package_manager", "pip")
397
- dependency_file = config.get("dependency_file", "requirements.txt")
398
-
399
- # Default project name to current directory
400
- if not project_name:
401
- project_name = os.path.basename(os.getcwd())
402
-
403
- # Validate project name
404
- is_valid, error_msg = validate_project_name(project_name)
405
- if not is_valid:
406
- console.print(f"[bold red]Invalid project name: {error_msg}[/bold red]")
407
- raise click.Abort()
408
-
409
- # Validate git repo if provided
410
- if git_repo:
411
- is_valid, error_msg = validate_git_repo_url(git_repo)
412
- if not is_valid:
413
- console.print(f"[bold red]Invalid git repository URL: {error_msg}[/bold red]")
414
- raise click.Abort()
415
- console.print(f"[cyan]Deploying {project_name} from git repository...[/cyan]")
416
- console.print(f"[dim]Repository: {git_repo} (branch: {branch})[/dim]")
417
- else:
418
- # Note: Local folder deployment only works when engine runs locally
419
- # In cloud API mode, this will fail with a clear error from the server
420
- console.print(f"[cyan]Deploying {project_name}...[/cyan]")
421
- console.print("[dim]Note: Git repository recommended for cloud deployments[/dim]")
422
-
423
- # Validate branch name
424
- is_valid, error_msg = validate_branch_name(branch)
425
- if not is_valid:
426
- console.print(f"[bold red]Invalid branch name: {error_msg}[/bold red]")
427
- raise click.Abort()
428
-
429
- # Validate framework if provided
430
- if framework:
431
- is_valid, error_msg = validate_framework(framework)
432
- if not is_valid:
433
- console.print(f"[bold red]Invalid framework: {error_msg}[/bold red]")
434
- raise click.Abort()
435
-
436
- # Retry loop for auto-healing
437
- attempt = 0
438
- deployment_id = None
439
-
440
- try:
441
- with get_client() as client:
442
- while attempt < MAX_RETRY_ATTEMPTS:
443
- attempt += 1
444
-
445
- if attempt > 1:
446
- console.print(
447
- f"\n[cyan]🔄 Retrying deployment (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]"
448
- )
449
- else:
450
- console.print("[cyan]Creating deployment...[/cyan]")
451
-
452
- # Detect framework if not provided (AI-powered Zen Mode)
453
- if not framework:
454
- console.print("[cyan]🔍 AI Auto-detecting project type...[/cyan]")
455
- try:
456
- from ..utils.codebase import scan_codebase
457
- code_snippets = scan_codebase()
458
- if code_snippets:
459
- analysis = client.intelligence.analyze_codebase(code_snippets)
460
- framework = analysis.framework
461
- # Only use AI's is_dockerized if config didn't explicitly set it
462
- if not is_dockerized_from_config:
463
- is_dockerized = analysis.is_dockerized
464
- # Override port if AI detected it and config didn't set one
465
- if not port_from_config and analysis.port:
466
- port = analysis.port
467
- # Override port and size if AI has strong recommendations
468
- if not size and analysis.instance_size:
469
- size = "s-1vcpu-1gb" if analysis.instance_size == "basic" else "s-2vcpu-4gb"
470
-
471
- mode_str = "Docker" if is_dockerized else "Bare Metal"
472
- console.print(f"[green]✓ Detected {framework.upper()} project ({mode_str} Mode)[/green] (Port: {port})")
473
- else:
474
- console.print("[yellow] No code files found for AI analysis. Defaulting to 'fastapi'[/yellow]")
475
- framework = "fastapi"
476
- is_dockerized = True
477
- except Exception as e:
478
- console.print(f"[yellow]⚠ AI detection failed: {e}. Defaulting to 'fastapi'[/yellow]")
479
- framework = "fastapi"
480
- is_dockerized = True
481
-
482
- # Delta upload: if no git_repo, scan and upload local files
483
- file_manifest = None
484
- if not git_repo:
485
- from ..utils.file_sync import scan_project_files_cached, ensure_gitignore_ignored
486
-
487
- # Protect privacy: ensure .xenfra is in .gitignore
488
- if ensure_gitignore_ignored():
489
- console.print("[dim] - Added .xenfra to .gitignore for privacy[/dim]")
490
-
491
- console.print("[cyan]📁 Scanning project files...[/cyan]")
492
-
493
- file_manifest = scan_project_files_cached()
494
- console.print(f"[dim]Found {len(file_manifest)} files[/dim]")
495
-
496
- if not file_manifest:
497
- console.print("[bold red]Error: No files found to deploy.[/bold red]")
498
- raise click.Abort()
499
-
500
- # Check which files need uploading
501
- console.print("[cyan]🔍 Checking file cache...[/cyan]")
502
- check_result = client.files.check(file_manifest)
503
- missing = check_result.get('missing', [])
504
- cached = check_result.get('cached', 0)
505
-
506
- if cached > 0:
507
- console.print(f"[green]✓ {cached} files already cached[/green]")
508
-
509
- # Upload missing files
510
- if missing:
511
- console.print(f"[cyan]☁️ Uploading {len(missing)} files...[/cyan]")
512
- uploaded = client.files.upload_files(
513
- file_manifest,
514
- missing,
515
- progress_callback=lambda done, total: console.print(f"[dim] Progress: {done}/{total}[/dim]") if done % 10 == 0 or done == total else None
516
- )
517
- console.print(f"[green]✓ Uploaded {uploaded} files[/green]")
518
- else:
519
- console.print("[green]✓ All files already cached[/green]")
520
-
521
- # Remove abs_path from manifest before sending to API
522
- file_manifest = [{"path": f["path"], "sha": f["sha"], "size": f["size"]} for f in file_manifest]
523
-
524
- # Create deployment with real-time streaming
525
- try:
526
- status_result, deployment_id, logs_data = _stream_deployment(
527
- client=client,
528
- project_name=project_name,
529
- git_repo=git_repo,
530
- branch=branch,
531
- framework=framework,
532
- region=region,
533
- size_slug=size,
534
- is_dockerized=is_dockerized,
535
- port=port,
536
- command=command,
537
- entrypoint=entrypoint, # Pass entrypoint to deployment API
538
- database=database,
539
- package_manager=package_manager,
540
- dependency_file=dependency_file,
541
- file_manifest=file_manifest,
542
- )
543
-
544
-
545
- if status_result == "FAILED" and not no_heal:
546
- # Hand off to the Zen Nod AI Agent
547
- should_retry = zen_nod_workflow(logs_data, client, attempt)
548
-
549
- if should_retry:
550
- # The agent applied a fix, loop back for attempt + 1
551
- continue
552
- else:
553
- # Agent couldn't fix it or user declined
554
- raise click.Abort()
555
-
556
- # If we got here with success, break the retry loop
557
- if status_result == "SUCCESS":
558
- break
559
- else:
560
- raise click.Abort()
561
-
562
- except XenfraAPIError as e:
563
- # Deployment failed - try to provide helpful error
564
- from ..utils.errors import detect_error_type, show_error_with_solution
565
-
566
- console.print(f"[bold red] Deployment failed[/bold red]")
567
-
568
- # Try to detect error type and show solution
569
- error_type, error_kwargs = detect_error_type(str(e.detail))
570
- if error_type:
571
- show_error_with_solution(error_type, **error_kwargs)
572
- else:
573
- console.print(f"[red]{e.detail}[/red]")
574
-
575
- # Check if we should auto-heal
576
- if no_heal or attempt >= MAX_RETRY_ATTEMPTS:
577
- # No auto-healing or max retries reached
578
- if attempt >= MAX_RETRY_ATTEMPTS:
579
- console.print(
580
- f"\n[bold red]❌ Maximum retry attempts ({MAX_RETRY_ATTEMPTS}) reached.[/bold red]"
581
- )
582
- console.print(
583
- "[yellow]Unable to auto-fix the issue. Please review the errors above.[/yellow]"
584
- )
585
- raise
586
- else:
587
- # Try to get logs for diagnosis
588
- error_logs = str(e.detail)
589
- try:
590
- if deployment_id:
591
- # This should be a method in the SDK that returns a string
592
- logs_response = client.deployments.get_logs(deployment_id)
593
- if isinstance(logs_response, dict):
594
- error_logs = logs_response.get("logs", str(e.detail))
595
- else:
596
- error_logs = str(logs_response) # Assuming it can be a string
597
- except Exception as log_err:
598
- console.print(
599
- f"[yellow]Warning: Could not fetch detailed logs for diagnosis: {log_err}[/yellow]"
600
- )
601
- # Fallback to the initial error detail
602
- pass
603
-
604
- # Run Zen Nod workflow
605
- should_retry = zen_nod_workflow(error_logs, client, attempt)
606
-
607
- if not should_retry:
608
- # User declined patch or no patch available
609
- console.print("\n[dim]Deployment cancelled.[/dim]")
610
- raise click.Abort()
611
-
612
- # Continue to next iteration (retry)
613
- continue
614
-
615
- except XenfraAPIError as e:
616
- console.print(f"[bold red]API Error: {e.detail}[/bold red]")
617
- except XenfraError as e:
618
- console.print(f"[bold red]Error: {e}[/bold red]")
619
- except click.Abort:
620
- pass
621
- except Exception as e:
622
- console.print(f"[bold red]Unexpected error: {e}[/bold red]")
623
-
624
-
625
- @click.command()
626
- @click.argument("deployment-id")
627
- @click.option("--follow", "-f", is_flag=True, help="Follow log output (stream)")
628
- @click.option("--tail", type=int, help="Show last N lines")
629
- def logs(deployment_id, follow, tail):
630
- # Validate deployment ID
631
- is_valid, error_msg = validate_deployment_id(deployment_id)
632
- if not is_valid:
633
- console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
634
- raise click.Abort()
635
- """
636
- Stream deployment logs.
637
-
638
- Shows logs for a specific deployment. Use --follow to stream logs in real-time.
639
- """
640
- try:
641
- with get_client() as client:
642
- console.print(f"[cyan]Fetching logs for deployment {deployment_id}...[/cyan]")
643
-
644
- log_content = client.deployments.get_logs(deployment_id)
645
-
646
- if not log_content:
647
- console.print("[yellow]No logs available yet.[/yellow]")
648
- console.print("[dim]The deployment may still be starting up.[/dim]")
649
- return
650
-
651
- # Process logs
652
- log_lines = log_content.strip().split("\n")
653
-
654
- # Apply tail if specified
655
- if tail:
656
- log_lines = log_lines[-tail:]
657
-
658
- # Display logs with syntax highlighting
659
- console.print(f"\n[bold]Logs for deployment {deployment_id}:[/bold]\n")
660
-
661
- if follow:
662
- _follow_deployment(client, deployment_id)
663
- return
664
-
665
- # Display logs
666
- for line in log_lines:
667
- # Color-code based on log level
668
- if "ERROR" in line or "FAILED" in line:
669
- console.print(f"[red]{line}[/red]")
670
- elif "WARN" in line or "WARNING" in line:
671
- console.print(f"[yellow]{line}[/yellow]")
672
- elif "SUCCESS" in line or "COMPLETED" in line:
673
- console.print(f"[green]{line}[/green]")
674
- elif "INFO" in line:
675
- console.print(f"[cyan]{line}[/cyan]")
676
- else:
677
- console.print(line)
678
-
679
- except XenfraAPIError as e:
680
- console.print(f"[bold red]API Error: {e.detail}[/bold red]")
681
- except XenfraError as e:
682
- console.print(f"[bold red]Error: {e}[/bold red]")
683
- except click.Abort:
684
- pass
685
-
686
-
687
- @click.command()
688
- @click.argument("deployment-id", required=False)
689
- @click.option("--watch", "-w", is_flag=True, help="Watch status updates")
690
- def status(deployment_id, watch):
691
- """
692
- Show deployment status.
693
-
694
- Displays current status, progress, and details for a deployment.
695
- Use --watch to monitor status in real-time.
696
- """
697
- try:
698
- if not deployment_id:
699
- console.print("[yellow]No deployment ID provided.[/yellow]")
700
- console.print("[dim]Usage: xenfra status <deployment-id>[/dim]")
701
- return
702
-
703
- # Validate deployment ID
704
- is_valid, error_msg = validate_deployment_id(deployment_id)
705
- if not is_valid:
706
- console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
707
- raise click.Abort()
708
-
709
- with get_client() as client:
710
- console.print(f"[cyan]Fetching status for deployment {deployment_id}...[/cyan]")
711
-
712
- deployment_status = client.deployments.get_status(deployment_id)
713
-
714
- if watch:
715
- _follow_deployment(client, deployment_id)
716
- return
717
-
718
- # Display status
719
- status_value = deployment_status.get("status", "UNKNOWN")
720
- state = deployment_status.get("state", "unknown")
721
- progress = deployment_status.get("progress", 0)
722
-
723
- # Status panel
724
- status_color = {
725
- "PENDING": "yellow",
726
- "IN_PROGRESS": "cyan",
727
- "SUCCESS": "green",
728
- "FAILED": "red",
729
- "CANCELLED": "dim",
730
- }.get(status_value, "white")
731
-
732
- # Create status table
733
- table = Table(show_header=False, box=None)
734
- table.add_column("Property", style="cyan")
735
- table.add_column("Value")
736
-
737
- table.add_row("Deployment ID", str(deployment_id))
738
- table.add_row("Status", f"[{status_color}]{status_value}[/{status_color}]")
739
- table.add_row("State", state)
740
-
741
- if progress > 0:
742
- table.add_row("Progress", f"{progress}%")
743
-
744
- if "project_name" in deployment_status:
745
- table.add_row("Project", deployment_status["project_name"])
746
-
747
- if "created_at" in deployment_status:
748
- table.add_row("Created", deployment_status["created_at"])
749
-
750
- if "finished_at" in deployment_status:
751
- table.add_row("Finished", deployment_status["finished_at"])
752
-
753
- if "url" in deployment_status:
754
- table.add_row("URL", f"[link]{deployment_status['url']}[/link]")
755
-
756
- if "ip_address" in deployment_status:
757
- table.add_row("IP Address", deployment_status["ip_address"])
758
-
759
- panel = Panel(table, title="[bold]Deployment Status[/bold]", border_style=status_color)
760
- console.print(panel)
761
-
762
- # Show error if failed
763
- if status_value == "FAILED" and "error" in deployment_status:
764
- error_panel = Panel(
765
- deployment_status["error"],
766
- title="[bold red]Error[/bold red]",
767
- border_style="red",
768
- )
769
- console.print("\n", error_panel)
770
-
771
- console.print("\n[bold]Troubleshooting:[/bold]")
772
- console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
773
- console.print(f" • Diagnose: [cyan]xenfra diagnose {deployment_id}[/cyan]")
774
-
775
- # Show next steps based on status
776
- elif status_value == "SUCCESS":
777
- console.print("\n[bold green]Deployment successful! 🎉[/bold green]")
778
- if "url" in deployment_status:
779
- console.print(f" • Visit: [link]{deployment_status['url']}[/link]")
780
-
781
- elif status_value in ["PENDING", "IN_PROGRESS"]:
782
- console.print("\n[bold]Deployment in progress...[/bold]")
783
- console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
784
- console.print(f" • Check again: [cyan]xenfra status {deployment_id}[/cyan]")
785
-
786
- except XenfraAPIError as e:
787
- console.print(f"[bold red]API Error: {e.detail}[/bold red]")
788
- except XenfraError as e:
789
- console.print(f"[bold red]Error: {e}[/bold red]")
790
- except click.Abort:
791
- pass
792
-
793
-
794
- @click.command()
795
- @click.argument("deployment-id")
796
- @click.option("--format", "output_format", type=click.Choice(["detailed", "summary"], case_sensitive=False), default="detailed", help="Report format (detailed or summary)")
797
- def report(deployment_id, output_format):
798
- """
799
- Generate deployment report with self-healing events.
800
-
801
- Shows comprehensive deployment information including:
802
- - Deployment status and timeline
803
- - Self-healing attempts and outcomes
804
- - Patches applied during healing
805
- - Statistics and metrics
806
- """
807
- try:
808
- # Validate deployment ID
809
- is_valid, error_msg = validate_deployment_id(deployment_id)
810
- if not is_valid:
811
- console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
812
- raise click.Abort()
813
-
814
- with get_client() as client:
815
- console.print(f"[cyan]Generating report for deployment {deployment_id}...[/cyan]\n")
816
-
817
- # Get deployment status
818
- try:
819
- deployment_status = client.deployments.get_status(deployment_id)
820
- except XenfraAPIError as e:
821
- console.print(f"[bold red]Error fetching deployment status: {e.detail}[/bold red]")
822
- raise click.Abort()
823
-
824
- # Get deployment logs
825
- try:
826
- logs = client.deployments.get_logs(deployment_id)
827
- except XenfraAPIError:
828
- logs = None
829
-
830
- # Parse status
831
- status_value = deployment_status.get("status", "UNKNOWN")
832
- state = deployment_status.get("state", "unknown")
833
- progress = deployment_status.get("progress", 0)
834
-
835
- # Status color mapping
836
- status_color = {
837
- "PENDING": "yellow",
838
- "IN_PROGRESS": "cyan",
839
- "SUCCESS": "green",
840
- "FAILED": "red",
841
- "CANCELLED": "dim",
842
- }.get(status_value, "white")
843
-
844
- # Calculate statistics from logs
845
- heal_attempts = logs.count("🤖 Analyzing failure") if logs else 0
846
- patches_applied = logs.count("✓ Patch applied") if logs else 0
847
- diagnoses = logs.count("🔍 Diagnosis") if logs else 0
848
-
849
- # Create main report table
850
- report_table = Table(show_header=True, box=None)
851
- report_table.add_column("Property", style="cyan", width=25)
852
- report_table.add_column("Value", style="white")
853
-
854
- report_table.add_row("Deployment ID", str(deployment_id))
855
- report_table.add_row("Status", f"[{status_color}]{status_value}[/{status_color}]")
856
- report_table.add_row("State", state)
857
-
858
- if progress > 0:
859
- report_table.add_row("Progress", f"{progress}%")
860
-
861
- if "project_name" in deployment_status:
862
- report_table.add_row("Project", deployment_status["project_name"])
863
-
864
- if "created_at" in deployment_status:
865
- report_table.add_row("Created", deployment_status["created_at"])
866
-
867
- if "finished_at" in deployment_status:
868
- report_table.add_row("Finished", deployment_status["finished_at"])
869
-
870
- if "url" in deployment_status:
871
- report_table.add_row("URL", f"[link]{deployment_status['url']}[/link]")
872
-
873
- if "ip_address" in deployment_status:
874
- report_table.add_row("IP Address", deployment_status["ip_address"])
875
-
876
- # Self-healing statistics
877
- report_table.add_row("", "") # Separator
878
- report_table.add_row("[bold]Self-Healing Stats[/bold]", "")
879
- report_table.add_row("Healing Attempts", str(heal_attempts))
880
- report_table.add_row("Patches Applied", str(patches_applied))
881
- report_table.add_row("Diagnoses Performed", str(diagnoses))
882
-
883
- if heal_attempts > 0:
884
- success_rate = (patches_applied / heal_attempts * 100) if heal_attempts > 0 else 0
885
- report_table.add_row("Healing Success Rate", f"{success_rate:.1f}%")
886
-
887
- # Display main report
888
- console.print(Panel(report_table, title="[bold]Deployment Report[/bold]", border_style=status_color))
889
-
890
- # Detailed format includes timeline and healing events
891
- if output_format == "detailed" and logs:
892
- console.print("\n[bold]Self-Healing Timeline[/bold]\n")
893
-
894
- # Extract healing events from logs
895
- log_lines = logs.split("\n")
896
- timeline_entries = []
897
-
898
- for i, line in enumerate(log_lines):
899
- if "🤖 Analyzing failure" in line:
900
- attempt_match = None
901
- # Try to find attempt number in surrounding lines
902
- for j in range(max(0, i-5), min(len(log_lines), i+10)):
903
- if "attempt" in log_lines[j].lower():
904
- timeline_entries.append(("Healing Attempt", log_lines[j].strip()))
905
- break
906
- elif "🔍 Diagnosis" in line or "Diagnosis" in line:
907
- # Extract diagnosis text from next few lines
908
- diagnosis_text = line.strip()
909
- if i+1 < len(log_lines) and log_lines[i+1].strip():
910
- diagnosis_text += "\n " + log_lines[i+1].strip()[:100]
911
- timeline_entries.append(("Diagnosis", diagnosis_text))
912
- elif " Patch applied" in line or "Patch applied" in line:
913
- timeline_entries.append(("Patch Applied", line.strip()))
914
- elif "🔄 Retrying deployment" in line:
915
- timeline_entries.append(("Retry", line.strip()))
916
-
917
- if timeline_entries:
918
- timeline_table = Table(show_header=True, box=None)
919
- timeline_table.add_column("Event", style="cyan", width=20)
920
- timeline_table.add_column("Details", style="white")
921
-
922
- for event_type, details in timeline_entries[:20]: # Limit to 20 entries
923
- timeline_table.add_row(event_type, details)
924
-
925
- console.print(timeline_table)
926
- else:
927
- console.print("[dim]No self-healing events detected in logs.[/dim]")
928
-
929
- # Show error if failed
930
- if status_value == "FAILED":
931
- console.print("\n[bold red]⚠ Deployment Failed[/bold red]")
932
- if "error" in deployment_status:
933
- error_panel = Panel(
934
- deployment_status["error"],
935
- title="[bold red]Error Details[/bold red]",
936
- border_style="red",
937
- )
938
- console.print("\n", error_panel)
939
-
940
- console.print("\n[bold]Troubleshooting:[/bold]")
941
- console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
942
- console.print(f" • Diagnose: [cyan]xenfra diagnose {deployment_id}[/cyan]")
943
- console.print(f" • View status: [cyan]xenfra status {deployment_id}[/cyan]")
944
-
945
- # Success summary
946
- elif status_value == "SUCCESS":
947
- console.print("\n[bold green]✓ Deployment Successful[/bold green]")
948
- if heal_attempts > 0:
949
- console.print(f"[dim]Deployment succeeded after {heal_attempts} self-healing attempt(s).[/dim]")
950
- if "url" in deployment_status:
951
- console.print(f" • Visit: [link]{deployment_status['url']}[/link]")
952
-
953
- # Export summary format (JSON-like structure for programmatic use)
954
- if output_format == "summary":
955
- console.print("\n[bold]Summary Format:[/bold]")
956
- import json
957
- summary = {
958
- "deployment_id": deployment_id,
959
- "status": status_value,
960
- "healing_attempts": heal_attempts,
961
- "patches_applied": patches_applied,
962
- "success": status_value == "SUCCESS",
963
- }
964
- console.print(f"[dim]{json.dumps(summary, indent=2)}[/dim]")
965
-
966
- except XenfraAPIError as e:
967
- console.print(f"[bold red]API Error: {e.detail}[/bold red]")
968
- except XenfraError as e:
969
- console.print(f"[bold red]Error: {e}[/bold red]")
970
- except click.Abort:
971
- pass
972
- except Exception as e:
973
- console.print(f"[bold red]Unexpected error: {e}[/bold red]")
1
+ """
2
+ Deployment commands for Xenfra CLI.
3
+ """
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+ from xenfra_sdk import XenfraClient
13
+ from xenfra_sdk.exceptions import XenfraAPIError, XenfraError
14
+ from xenfra_sdk.privacy import scrub_logs
15
+
16
+ from ..utils.auth import API_BASE_URL, get_auth_token
17
+ from ..utils.codebase import has_xenfra_config
18
+ from ..utils.config import apply_patch, read_xenfra_yaml
19
+ from ..blueprints import run_dry_run
20
+ from ..utils.validation import (
21
+ validate_branch_name,
22
+ validate_deployment_id,
23
+ validate_framework,
24
+ validate_git_repo_url,
25
+ validate_project_id,
26
+ validate_project_name,
27
+ )
28
+
29
+ import time
30
+ from datetime import datetime
31
+
32
+ from rich.live import Live
33
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
34
+
35
+ console = Console()
36
+
37
+ # Maximum number of retry attempts for auto-healing
38
+ MAX_RETRY_ATTEMPTS = 3
39
+
40
+
41
+ def get_client() -> XenfraClient:
42
+ """Get authenticated SDK client."""
43
+ token = get_auth_token()
44
+ if not token:
45
+ console.print("[bold red]Not logged in. Run 'xenfra auth login' first.[/bold red]")
46
+ raise click.Abort()
47
+
48
+ return XenfraClient(token=token, api_url=API_BASE_URL)
49
+
50
+
51
+ def show_diagnosis_panel(diagnosis: str, suggestion: str):
52
+ """Display diagnosis and suggestion in formatted panels."""
53
+ console.print()
54
+ console.print(Panel(diagnosis, title="[bold red]🔍 Diagnosis[/bold red]", border_style="red"))
55
+ console.print()
56
+ console.print(
57
+ Panel(suggestion, title="[bold yellow]💡 Suggestion[/bold yellow]", border_style="yellow")
58
+ )
59
+
60
+
61
+ def show_patch_preview(patch_data: dict):
62
+ """Show a preview of the patch that will be applied."""
63
+ console.print()
64
+ console.print("[bold green]🔧 Automatic Fix Available[/bold green]")
65
+ console.print(f" [cyan]File:[/cyan] {patch_data.get('file')}")
66
+ console.print(f" [cyan]Operation:[/cyan] {patch_data.get('operation')}")
67
+ console.print(f" [cyan]Value:[/cyan] {patch_data.get('value')}")
68
+ console.print()
69
+
70
+
71
+ def _stream_deployment(client: XenfraClient, project_name: str, git_repo: str, branch: str, framework: str, region: str, size_slug: str, is_dockerized: bool = True, port: int = None, command: str = None, entrypoint: str = None, database: str = None, package_manager: str = None, dependency_file: str = None, file_manifest: list = None, cleanup_on_failure: bool = False, services: list = None, mode: str = None):
72
+ """
73
+ Creates deployment with real-time SSE streaming (no polling needed).
74
+
75
+ Returns tuple of (status, deployment_id, logs_buffer)
76
+ """
77
+ console.print(Panel(
78
+ f"[bold cyan]Project:[/bold cyan] {project_name}\n"
79
+ f"[bold cyan]Mode:[/bold cyan] Real-time Streaming Deployment",
80
+ title="[bold green]🚀 Deployment Starting[/bold green]",
81
+ border_style="green"
82
+ ))
83
+
84
+ deployment_id = None
85
+ logs_buffer = []
86
+ status_val = "PENDING"
87
+
88
+ try:
89
+ for event in client.deployments.create_stream(
90
+ project_name=project_name,
91
+ git_repo=git_repo,
92
+ branch=branch,
93
+ framework=framework,
94
+ region=region,
95
+ size_slug=size_slug,
96
+ is_dockerized=is_dockerized,
97
+ port=port,
98
+ command=command,
99
+ entrypoint=entrypoint, # Pass entrypoint to deployment API
100
+ database=database,
101
+ package_manager=package_manager,
102
+ dependency_file=dependency_file,
103
+ file_manifest=file_manifest,
104
+ cleanup_on_failure=cleanup_on_failure,
105
+ services=services, # Microservices support
106
+ mode=mode, # Deployment mode
107
+ ):
108
+
109
+ event_type = event.get("event", "message")
110
+ data = event.get("data", "")
111
+
112
+ if event_type == "deployment_created":
113
+ # Extract deployment ID
114
+ if isinstance(data, dict):
115
+ deployment_id = data.get("deployment_id")
116
+ console.print(f"[bold green]✓[/bold green] Deployment created: [cyan]{deployment_id}[/cyan]\n")
117
+
118
+ elif event_type == "log":
119
+ # Real-time log output
120
+ log_line = str(data)
121
+ logs_buffer.append(log_line)
122
+
123
+ # Colorize output
124
+ if any(x in log_line for x in ["ERROR", "FAILED", "✗"]):
125
+ console.print(f"[bold red]{log_line}[/bold red]")
126
+ elif any(x in log_line for x in ["WARN", "WARNING", "⚠"]):
127
+ console.print(f"[yellow]{log_line}[/yellow]")
128
+ elif any(x in log_line for x in ["SUCCESS", "COMPLETED", "✓", "passed!"]):
129
+ console.print(f"[bold green]{log_line}[/bold green]")
130
+ elif "PHASE" in log_line:
131
+ console.print(f"\n[bold blue]{log_line}[/bold blue]")
132
+ elif "[InfraEngine]" in log_line or "[INFO]" in log_line:
133
+ console.print(f"[cyan]›[/cyan] {log_line}")
134
+ else:
135
+ console.print(f"[dim]{log_line}[/dim]")
136
+
137
+ elif event_type == "error":
138
+ error_msg = str(data)
139
+ logs_buffer.append(f"ERROR: {error_msg}")
140
+ console.print(f"\n[bold red]❌ Error: {error_msg}[/bold red]")
141
+ status_val = "FAILED"
142
+
143
+ elif event_type == "deployment_complete":
144
+ # Final status
145
+ if isinstance(data, dict):
146
+ status_val = data.get("status", "UNKNOWN")
147
+ ip_address = data.get("ip_address")
148
+
149
+ console.print()
150
+ if status_val == "SUCCESS":
151
+ console.print("[bold green]✨ SUCCESS: Your application is live![/bold green]")
152
+ if ip_address and ip_address != "unknown":
153
+ console.print(f"[bold]Accessible at:[/bold] [link=http://{ip_address}]http://{ip_address}[/link]")
154
+ elif status_val == "FAILED":
155
+ console.print("[bold red]❌ DEPLOYMENT FAILED[/bold red]")
156
+ error = data.get("error")
157
+ if error:
158
+ console.print(f"[red]Error: {error}[/red]")
159
+ break
160
+
161
+ except Exception as e:
162
+ console.print(f"\n[bold red]❌ Streaming error: {e}[/bold red]")
163
+ status_val = "FAILED"
164
+ logs_buffer.append(f"Streaming error: {e}")
165
+
166
+ return (status_val, deployment_id, "\n".join(logs_buffer))
167
+
168
+
169
+ def _follow_deployment(client: XenfraClient, deployment_id: str):
170
+ """
171
+ Polls logs and status in real-time until completion with CI/CD style output.
172
+ (LEGACY - Used for backward compatibility)
173
+ """
174
+ console.print(Panel(
175
+ f"[bold cyan]Deployment ID:[/bold cyan] {deployment_id}\n"
176
+ f"[bold cyan]Mode:[/bold cyan] Streaming Real-time Infrastructure Logs",
177
+ title="[bold green]🚀 Deployment Monitor[/bold green]",
178
+ border_style="green"
179
+ ))
180
+
181
+ last_log_len = 0
182
+ status_val = "PENDING"
183
+
184
+ # Use a live display for the progress bar at the bottom
185
+ with Progress(
186
+ SpinnerColumn(),
187
+ TextColumn("[bold blue]{task.description}"),
188
+ BarColumn(bar_width=40),
189
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
190
+ console=console,
191
+ transient=False,
192
+ ) as progress:
193
+ task = progress.add_task("Waiting for server response...", total=100)
194
+
195
+ while status_val not in ["SUCCESS", "FAILED", "CANCELLED"]:
196
+ try:
197
+ # 1. Update Status
198
+ dep_status = client.deployments.get_status(deployment_id)
199
+ status_val = dep_status.get("status", "PENDING")
200
+ progress_val = dep_status.get("progress", 0)
201
+ state = dep_status.get("state", "preparing")
202
+
203
+ # Use a more descriptive description for the progress task
204
+ desc = f"Phase: {state}"
205
+ if status_val == "FAILED":
206
+ desc = "[bold red]FAILED[/bold red]"
207
+ elif status_val == "SUCCESS":
208
+ desc = "[bold green]SUCCESS[/bold green]"
209
+
210
+ progress.update(task, completed=progress_val, description=desc)
211
+
212
+ # 2. Update Logs
213
+ log_content = client.deployments.get_logs(deployment_id)
214
+ if log_content and len(log_content) > last_log_len:
215
+ new_logs = log_content[last_log_len:].strip()
216
+ for line in new_logs.split("\n"):
217
+ # Process and colorize lines
218
+ clean_line = line.strip()
219
+ if not clean_line:
220
+ continue
221
+
222
+ if any(x in clean_line for x in ["ERROR", "FAILED", "✗"]):
223
+ progress.console.print(f"[bold red]{clean_line}[/bold red]")
224
+ elif any(x in clean_line for x in ["WARN", "WARNING", "⚠"]):
225
+ progress.console.print(f"[yellow]{clean_line}[/yellow]")
226
+ elif any(x in clean_line for x in ["SUCCESS", "COMPLETED", "✓", "passed!"]):
227
+ progress.console.print(f"[bold green]{clean_line}[/bold green]")
228
+ elif "PHASE" in clean_line:
229
+ progress.console.print(f"\n[bold blue]{clean_line}[/bold blue]")
230
+ elif "[InfraEngine]" in clean_line:
231
+ progress.console.print(f"[dim]{clean_line}[/dim]")
232
+ else:
233
+ progress.console.print(f"[cyan]›[/cyan] {clean_line}")
234
+
235
+ last_log_len = len(log_content)
236
+
237
+ if status_val in ["SUCCESS", "FAILED", "CANCELLED"]:
238
+ break
239
+
240
+ time.sleep(1.5) # Slightly faster polling for better feel
241
+ except Exception as e:
242
+ # progress.console.print(f"[dim]Transient connection issue: {e}[/dim]")
243
+ time.sleep(3)
244
+ continue
245
+
246
+ console.print()
247
+ if status_val == "SUCCESS":
248
+ console.print("[bold green] SUCCESS: Your application is live![/bold green]")
249
+ # Try to get the IP address
250
+ try:
251
+ final_status = client.deployments.get_status(deployment_id)
252
+ ip = final_status.get("ip_address")
253
+ if ip:
254
+ console.print(f"[bold]Accessible at:[/bold] [link=http://{ip}]http://{ip}[/link]")
255
+ except:
256
+ pass
257
+ elif status_val == "FAILED":
258
+ console.print("\n[bold red]❌ FAILURE DETECTED: Entering AI Diagnosis Mode...[/bold red]")
259
+
260
+ return status_val
261
+
262
+
263
+ def zen_nod_workflow(
264
+ logs: str,
265
+ client: XenfraClient,
266
+ attempt: int,
267
+ package_manager: str = None,
268
+ dependency_file: str = None,
269
+ services: list = None
270
+ ) -> bool:
271
+ """
272
+ Execute the Zen Nod auto-healing workflow.
273
+
274
+ Args:
275
+ logs: Deployment error logs
276
+ client: Authenticated SDK client
277
+ attempt: Current attempt number
278
+ package_manager: Project package manager
279
+ dependency_file: Project dependency file
280
+ services: List of services in the project (for multi-service context)
281
+
282
+ Returns:
283
+ True if patch was applied and user wants to retry, False otherwise
284
+ """
285
+ console.print()
286
+ console.print(f"[cyan]🤖 Analyzing failure (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]")
287
+
288
+ # Slice logs to last 300 lines for focused diagnosis (Fix #26)
289
+ log_lines = logs.split("\n")
290
+ if len(log_lines) > 300:
291
+ logs = "\n".join(log_lines[-300:])
292
+ console.print("[dim]Note: Analyzing only the last 300 lines of logs for efficiency.[/dim]")
293
+
294
+ # Scrub sensitive data from logs
295
+ scrubbed_logs = scrub_logs(logs)
296
+
297
+ # Diagnose with AI
298
+ try:
299
+ diagnosis_result = client.intelligence.diagnose(
300
+ logs=scrubbed_logs,
301
+ package_manager=package_manager,
302
+ dependency_file=dependency_file,
303
+ services=services
304
+ )
305
+ except Exception as e:
306
+ console.print(f"[yellow]Could not diagnose failure: {e}[/yellow]")
307
+ return False
308
+
309
+ # Show diagnosis
310
+ show_diagnosis_panel(diagnosis_result.diagnosis, diagnosis_result.suggestion)
311
+
312
+ # Check if there's an automatic patch
313
+ if diagnosis_result.patch and diagnosis_result.patch.file:
314
+ show_patch_preview(diagnosis_result.patch.model_dump())
315
+
316
+ # Zen Nod confirmation
317
+ if click.confirm("Apply this fix and retry deployment?", default=True):
318
+ try:
319
+ # Apply patch (with automatic backup)
320
+ backup_path = apply_patch(diagnosis_result.patch.model_dump())
321
+ console.print("[bold green]✓ Patch applied[/bold green]")
322
+ if backup_path:
323
+ console.print(f"[dim]Backup saved: {backup_path}[/dim]")
324
+ return True # Signal to retry
325
+ except Exception as e:
326
+ console.print(f"[bold red]Failed to apply patch: {e}[/bold red]")
327
+ return False
328
+ else:
329
+ console.print()
330
+ console.print("[yellow]❌ Patch declined. Follow the manual steps above.[/yellow]")
331
+ return False
332
+ else:
333
+ console.print()
334
+ console.print(
335
+ "[yellow]No automatic fix available. Please follow the manual steps above.[/yellow]"
336
+ )
337
+ return False
338
+
339
+
340
+ @click.command()
341
+ @click.argument("project_id", type=int)
342
+ @click.option("--show-details", is_flag=True, help="Show project details before confirmation")
343
+ @click.confirmation_option(prompt="Are you sure you want to delete this project and its infrastructure?")
344
+ def delete(project_id, show_details):
345
+ """
346
+ Delete a project and its infrastructure.
347
+
348
+ This command will:
349
+ - Destroy the DigitalOcean droplet
350
+ - Remove database records
351
+
352
+ WARNING: This action cannot be undone.
353
+ """
354
+ # Validate
355
+ is_valid, error_msg = validate_project_id(project_id)
356
+ if not is_valid:
357
+ console.print(f"[bold red]Invalid project ID: {error_msg}[/bold red]")
358
+ raise click.Abort()
359
+
360
+ try:
361
+ with get_client() as client:
362
+ # Optional: Show details
363
+ if show_details:
364
+ try:
365
+ project = client.projects.show(project_id)
366
+
367
+ # Display panel with project info
368
+ details_table = Table(show_header=False, box=None)
369
+ details_table.add_column("Property", style="cyan")
370
+ details_table.add_column("Value")
371
+
372
+ details_table.add_row("Project ID", str(project_id))
373
+ if hasattr(project, 'name'):
374
+ details_table.add_row("Name", project.name)
375
+ if hasattr(project, 'droplet_id'):
376
+ details_table.add_row("Droplet ID", str(project.droplet_id))
377
+ if hasattr(project, 'ip_address'):
378
+ details_table.add_row("IP Address", project.ip_address)
379
+ if hasattr(project, 'created_at'):
380
+ details_table.add_row("Created", str(project.created_at))
381
+
382
+ panel = Panel(details_table, title="[bold]Project Details[/bold]", border_style="yellow")
383
+ console.print(panel)
384
+ console.print()
385
+ except XenfraAPIError as e:
386
+ if e.status_code == 404:
387
+ console.print(f"[yellow]Note: Project {project_id} not found in records.[/yellow]")
388
+ else:
389
+ console.print(f"[yellow]Warning: Could not fetch project details: {e.detail}[/yellow]")
390
+ console.print()
391
+
392
+ # Delete
393
+ console.print(f"[cyan]Deleting project {project_id}...[/cyan]")
394
+ client.projects.delete(str(project_id))
395
+ console.print(f"[bold green]✓ Project {project_id} deleted successfully.[/bold green]")
396
+ console.print("[dim]The droplet has been destroyed and all records removed.[/dim]")
397
+
398
+ except XenfraAPIError as e:
399
+ if e.status_code == 404:
400
+ console.print(f"[yellow]Project {project_id} not found. It may have already been deleted.[/yellow]")
401
+ else:
402
+ console.print(f"[bold red]API Error: {e.detail}[/bold red]")
403
+ raise click.Abort()
404
+ except XenfraError as e:
405
+ console.print(f"[bold red]Error: {e}[/bold red]")
406
+ raise click.Abort()
407
+ except click.Abort:
408
+ console.print("[dim]Deletion cancelled.[/dim]")
409
+ raise
410
+ except Exception as e:
411
+ console.print(f"[bold red]Unexpected error: {e}[/bold red]")
412
+ raise click.Abort()
413
+
414
+
415
+ @click.command()
416
+ @click.option("--project-name", help="Project name (defaults to current directory name)")
417
+ @click.option("--git-repo", help="Git repository URL (if deploying from git)")
418
+ @click.option("--branch", default="main", help="Git branch (default: main)")
419
+ @click.option("--framework", help="Framework override (fastapi, flask, django)")
420
+ @click.option("--region", help="DigitalOcean region override")
421
+ @click.option("--size", help="DigitalOcean size slug override")
422
+ @click.option("--no-heal", is_flag=True, help="Disable auto-healing on failure")
423
+ @click.option("--cleanup-on-failure", is_flag=True, help="Automatically cleanup resources if deployment fails")
424
+ @click.option("--force-sandbox", is_flag=True, help="Force full sandbox validation (Tier 3)")
425
+ @click.option("--skip-sandbox", is_flag=True, help="Skip sandbox validation for faster iteration")
426
+ def deploy(project_name, git_repo, branch, framework, region, size, no_heal, cleanup_on_failure, force_sandbox, skip_sandbox):
427
+ """
428
+ Deploy current project to DigitalOcean with 3-tier dry-run validation.
429
+
430
+ Every deployment is validated before touching real infrastructure:
431
+
432
+ \b
433
+ [bold]Tier 1[/bold]: Local validation (~100ms) - Syntax, imports, config
434
+ [bold]Tier 2[/bold]: Railpack plan (~1-2s) - Build plan generation
435
+ [bold]Tier 3[/bold]: E2B Sandbox (~30-60s) - Full build validation (smart detection)
436
+
437
+ This "Dry Run First" architecture ensures deployments never fail due to
438
+ avoidable errors. Set E2B_API_KEY for full sandbox validation.
439
+
440
+ \b
441
+ Deploys your application with zero configuration. The CLI will:
442
+ 1. Run 3-tier dry-run validation
443
+ 2. Create a deployment
444
+ 3. Auto-diagnose and fix failures (unless --no-heal is set)
445
+
446
+ Environment Variables:
447
+ - E2B_API_KEY: API key for sandbox validation
448
+ - XENFRA_AI=off: Disable AI features
449
+ - XENFRA_SANDBOX=force: Always run Tier 3
450
+ - XENFRA_SANDBOX=skip: Skip Tier 3 validation
451
+ - XENFRA_SANDBOX=auto: Smart detection (default)
452
+ """
453
+ # Check XENFRA_AI environment variable
454
+ ai_mode = os.environ.get("XENFRA_AI", "on").lower()
455
+ no_ai = ai_mode == "off"
456
+ if no_ai:
457
+ console.print("[yellow]XENFRA_AI=off is set. Auto-healing disabled.[/yellow]")
458
+ no_heal = True
459
+
460
+ # ==========================================
461
+ # 3-TIER DRY-RUN VALIDATION ("Dry Run First")
462
+ # ==========================================
463
+ project_path = Path(os.getcwd())
464
+
465
+ # Check for xenfra.yaml
466
+ if not has_xenfra_config():
467
+ console.print("[yellow]No xenfra.yaml found.[/yellow]")
468
+ if click.confirm("Run 'xenfra init' to create configuration?", default=True):
469
+ from .intelligence import init
470
+
471
+ ctx = click.get_current_context()
472
+ ctx.invoke(init, manual=no_ai, accept_all=False)
473
+ else:
474
+ console.print("[dim]Deployment cancelled.[/dim]")
475
+ return
476
+
477
+ # Load configuration from xenfra.yaml if it exists
478
+ config = {}
479
+ if has_xenfra_config():
480
+ try:
481
+ config = read_xenfra_yaml()
482
+ except Exception as e:
483
+ console.print(f"[yellow]Warning: Could not read xenfra.yaml: {e}[/dim]")
484
+
485
+ # Resolve values with precedence: 1. CLI Flag, 2. xenfra.yaml, 3. Default
486
+ project_name = project_name or config.get("name") or os.path.basename(os.getcwd())
487
+ framework = framework or config.get("framework")
488
+ # Track if is_dockerized was explicitly set in config (to avoid AI override)
489
+ is_dockerized_from_config = "is_dockerized" in config
490
+ is_dockerized = config.get("is_dockerized", True)
491
+ region = region or config.get("region") or "nyc3"
492
+
493
+ # Resolve size slug (complex mapping)
494
+ if not size:
495
+ if config.get("size"):
496
+ size = config.get("size")
497
+ else:
498
+ instance_size = config.get("instance_size", "basic")
499
+ resources = config.get("resources", {})
500
+ cpu = resources.get("cpu", 1)
501
+
502
+ if instance_size == "standard" or cpu >= 2:
503
+ size = "s-2vcpu-4gb"
504
+ elif instance_size == "premium" or cpu >= 4:
505
+ size = "s-4vcpu-8gb"
506
+ else:
507
+ size = "s-1vcpu-1gb"
508
+
509
+ # Extract port, command, database from config
510
+ # Track if port was explicitly set to avoid AI override
511
+ port_from_config = config.get("port")
512
+ port = port_from_config or 8000
513
+ command = config.get("command") # Auto-detected if not provided
514
+ entrypoint = config.get("entrypoint") # e.g., "todo.main:app"
515
+ database_config = config.get("database", {})
516
+ database = database_config.get("type") if isinstance(database_config, dict) else None
517
+ package_manager = config.get("package_manager", "pip")
518
+ dependency_file = config.get("dependency_file", "requirements.txt")
519
+
520
+ # Microservices support: extract services and mode from xenfra.yaml
521
+ services = config.get("services") # List of service definitions
522
+ mode = config.get("mode", "monolithic") # monolithic, single-droplet, multi-droplet
523
+
524
+ # If services are defined and > 1, this is a microservices deployment
525
+ if services and len(services) > 1:
526
+ console.print(f"\n[bold cyan]🔍 Detected microservices project ({len(services)} services)[/bold cyan]")
527
+
528
+ # Display services table
529
+ from rich.table import Table
530
+ svc_table = Table(show_header=True, header_style="bold cyan", box=None)
531
+ svc_table.add_column("Service", style="white")
532
+ svc_table.add_column("Port", style="green")
533
+ svc_table.add_column("Framework", style="yellow")
534
+
535
+ for svc in services:
536
+ svc_table.add_row(
537
+ svc.get("name", "?"),
538
+ str(svc.get("port", "?")),
539
+ svc.get("framework", "?")
540
+ )
541
+
542
+ console.print(svc_table)
543
+ console.print()
544
+
545
+ # If mode not explicitly set in config, ask user
546
+ if mode == "monolithic" or mode not in ["single-droplet", "multi-droplet"]:
547
+ console.print("[bold]Choose deployment mode:[/bold]")
548
+ console.print(" [cyan]1.[/cyan] Single Droplet - All services on one machine [dim](cost-effective)[/dim]")
549
+ console.print(" [cyan]2.[/cyan] Multi Droplet - Each service on its own machine [dim](scalable)[/dim]")
550
+ console.print()
551
+
552
+ mode_choice = Prompt.ask(
553
+ "Deployment mode",
554
+ choices=["1", "2"],
555
+ default="1"
556
+ )
557
+
558
+ if mode_choice == "1":
559
+ mode = "single-droplet"
560
+ console.print("[green]✓ Using single-droplet mode[/green]\n")
561
+ else:
562
+ mode = "multi-droplet"
563
+ console.print("[green]✓ Using multi-droplet mode[/green]")
564
+ console.print(f"[dim]This will create {len(services)} separate droplets[/dim]\n")
565
+ else:
566
+ console.print(f"[dim]Using configured mode: {mode}[/dim]\n")
567
+
568
+ # Default project name to current directory
569
+ if not project_name:
570
+ project_name = os.path.basename(os.getcwd())
571
+
572
+ # Validate project name
573
+ is_valid, error_msg = validate_project_name(project_name)
574
+ if not is_valid:
575
+ console.print(f"[bold red]Invalid project name: {error_msg}[/bold red]")
576
+ raise click.Abort()
577
+
578
+ # Validate git repo if provided
579
+ if git_repo:
580
+ is_valid, error_msg = validate_git_repo_url(git_repo)
581
+ if not is_valid:
582
+ console.print(f"[bold red]Invalid git repository URL: {error_msg}[/bold red]")
583
+ raise click.Abort()
584
+ console.print(f"[cyan]Deploying {project_name} from git repository...[/cyan]")
585
+ console.print(f"[dim]Repository: {git_repo} (branch: {branch})[/dim]")
586
+ else:
587
+ # Note: Local folder deployment only works when engine runs locally
588
+ # In cloud API mode, this will fail with a clear error from the server
589
+ console.print(f"[cyan]Deploying {project_name}...[/cyan]")
590
+ console.print("[dim]Note: Git repository recommended for cloud deployments[/dim]")
591
+
592
+ # Validate branch name
593
+ is_valid, error_msg = validate_branch_name(branch)
594
+ if not is_valid:
595
+ console.print(f"[bold red]Invalid branch name: {error_msg}[/bold red]")
596
+ raise click.Abort()
597
+
598
+ # Validate framework if provided
599
+ if framework:
600
+ is_valid, error_msg = validate_framework(framework)
601
+ if not is_valid:
602
+ console.print(f"[bold red]Invalid framework: {error_msg}[/bold red]")
603
+ raise click.Abort()
604
+
605
+ # Retry loop for auto-healing
606
+ attempt = 0
607
+ deployment_id = None
608
+
609
+ # Context manager for temp dir (if cloning)
610
+ import tempfile
611
+ import shutil
612
+ import subprocess
613
+
614
+ temp_dir_obj = None
615
+ original_project_path = project_path
616
+
617
+ try:
618
+ with get_client() as client:
619
+ from ..utils.file_sync import scan_project_files_cached, ensure_gitignore_ignored
620
+
621
+ # --- 1. PREPARE SOURCE CODE ---
622
+ if git_repo:
623
+ console.print(f"[cyan]📥 Cloning {git_repo}...[/cyan]")
624
+ temp_dir_obj = tempfile.TemporaryDirectory()
625
+ temp_dir_path = Path(temp_dir_obj.name)
626
+
627
+ try:
628
+ # Shallow clone for speed
629
+ cmd = ["git", "clone", "--depth", "1"]
630
+ if branch:
631
+ cmd.extend(["-b", branch])
632
+ cmd.extend([git_repo, str(temp_dir_path)])
633
+
634
+ subprocess.run(cmd, check=True, capture_output=True)
635
+ project_path = temp_dir_path
636
+ console.print("[green]✓ Repository cloned[/green]")
637
+ except subprocess.CalledProcessError as e:
638
+ console.print(f"[bold red]Failed to clone repository: {e}[/bold red]")
639
+ raise click.Abort()
640
+ else:
641
+ # Local directory
642
+ # Protect privacy: ensure .xenfra is in .gitignore
643
+ if ensure_gitignore_ignored():
644
+ console.print("[dim] - Added .xenfra to .gitignore for privacy[/dim]")
645
+
646
+ # --- 2. SCAN FILES ---
647
+ console.print("[cyan]📁 Scanning project files...[/cyan]")
648
+
649
+ # We need to temporarily change CWD for scan_project_files_cached to work if we cloned
650
+ # OR pass path to it. Assuming it scans CWD, we might need chdir
651
+ import os
652
+ cwd = os.getcwd()
653
+ try:
654
+ if project_path != Path(cwd):
655
+ os.chdir(project_path)
656
+ file_manifest = scan_project_files_cached()
657
+ finally:
658
+ os.chdir(cwd)
659
+
660
+ console.print(f"[dim]Found {len(file_manifest)} files[/dim]")
661
+
662
+ if not file_manifest:
663
+ console.print("[bold red]Error: No files found.[/bold red]")
664
+ raise click.Abort()
665
+
666
+ # --- 3. UPLOAD FILES (Required for Tier 3 & Deployment) ---
667
+ # Check which files need uploading
668
+ console.print("[cyan]🔍 Checking file cache...[/cyan]")
669
+ check_result = client.files.check(file_manifest)
670
+ missing = check_result.get('missing', [])
671
+ cached = check_result.get('cached', 0)
672
+
673
+ if cached > 0:
674
+ console.print(f"[green]✓ {cached} files already cached on server[/green]")
675
+
676
+ # Upload missing files
677
+ if missing:
678
+ console.print(f"[cyan]☁️ Uploading {len(missing)} files...[/cyan]")
679
+ # We need absolute paths for upload reading
680
+ # If we cloned, file_manifest paths are relative to temp_dir
681
+ # But upload_files needs to know where to read from.
682
+ # Let's patch file_manifest with abs_paths for the uploader
683
+
684
+ upload_manifest = []
685
+ for f in file_manifest:
686
+ f_copy = f.copy()
687
+ f_copy["abs_path"] = str(project_path / f["path"])
688
+ upload_manifest.append(f_copy)
689
+
690
+ uploaded = client.files.upload_files(
691
+ upload_manifest,
692
+ missing,
693
+ progress_callback=lambda done, total: console.print(f"[dim] Progress: {done}/{total}[/dim]") if done % 10 == 0 or done == total else None
694
+ )
695
+ console.print(f"[green]✓ Uploaded {uploaded} files[/green]")
696
+ else:
697
+ console.print("[green]✓ All files synced[/green]")
698
+
699
+ # --- 4. RUN DRY RUN ---
700
+ console.print(Panel(
701
+ "[bold]🔍 Running 3-Tier Dry-Run Validation[/bold]\n"
702
+ "Tier 1: Local syntax check (~100ms)\n"
703
+ "Tier 2: Railpack build plan (~1-2s)\n"
704
+ "Tier 3: E2B sandbox via Xenfra API (smart detection)",
705
+ title="[bold cyan]Dry Run First Architecture[/bold cyan]",
706
+ border_style="cyan"
707
+ ))
708
+
709
+ with console.status("[cyan]Running validation...[/cyan]"):
710
+ dry_run_result = run_dry_run(
711
+ project_path,
712
+ force_sandbox=force_sandbox, # or (force_sandbox or check_result['missing'])?
713
+ skip_sandbox=skip_sandbox,
714
+ api_client=client,
715
+ file_manifest=file_manifest
716
+ )
717
+
718
+ # Display results
719
+ console.print(dry_run_result.get_summary())
720
+ console.print()
721
+
722
+ # Check if dry-run failed
723
+ if not dry_run_result.success:
724
+ console.print("[bold red]❌ Dry-run validation failed[/bold red]")
725
+ console.print("[yellow]Fix the errors above before deploying.[/yellow]")
726
+ console.print()
727
+ console.print("[dim]Tip: Run with --skip-sandbox to skip Tier 3[/dim]")
728
+ raise click.Abort()
729
+
730
+ console.print("[bold green]✅ Dry-run validation passed[/bold green]")
731
+ console.print()
732
+
733
+ # Prepare manifest for API (remove local-only fields if any, strictly path/sha/size)
734
+ api_file_manifest = [{"path": f["path"], "sha": f["sha"], "size": f["size"]} for f in file_manifest]
735
+
736
+ while attempt < MAX_RETRY_ATTEMPTS:
737
+ attempt += 1
738
+
739
+ if attempt > 1:
740
+ console.print(
741
+ f"\n[cyan]🔄 Retrying deployment (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]"
742
+ )
743
+ else:
744
+ console.print("[cyan]Creating deployment...[/cyan]")
745
+
746
+ # Detect framework if not provided (AI-powered Zen Mode)
747
+ if not framework:
748
+ if no_ai:
749
+ # ... static logic existing ...
750
+ console.print("[yellow]⚠ AI disabled. Using static detection...[/yellow]")
751
+ # Simple static checks based on Tier 2 result potentially?
752
+ if dry_run_result.tier2:
753
+ framework = dry_run_result.tier2.framework
754
+ port = dry_run_result.tier2.port
755
+ is_dockerized = True
756
+ console.print(f"[green]✓ Detected {framework.upper()} (from Dry Run)[/green]")
757
+ else:
758
+ framework = "fastapi"
759
+ port = 8000
760
+ else:
761
+ # AI-powered detection
762
+ # We can use Tier 2 result for speed instead of full AI scan if available
763
+ if dry_run_result.tier2 and dry_run_result.tier2.framework != "unknown":
764
+ framework = dry_run_result.tier2.framework
765
+ # config overrides...
766
+ if not is_dockerized_from_config:
767
+ is_dockerized = True # Railpack is docker based
768
+ if not port_from_config:
769
+ port = dry_run_result.tier2.port
770
+ console.print(f"[green]✓ Detected {framework.upper()} (from Railpack)[/green]")
771
+ else:
772
+ # Fallback to AI
773
+ console.print("[cyan]🔍 AI Auto-detecting project type...[/cyan]")
774
+ try:
775
+ # We need to scan code snippet from PROJECT_PATH (temp or local)
776
+ from ..utils.codebase import scan_codebase
777
+ # scan_codebase needs CWD to be correct
778
+ curr = os.getcwd()
779
+ os.chdir(project_path)
780
+ try:
781
+ code_snippets = scan_codebase()
782
+ finally:
783
+ os.chdir(curr)
784
+
785
+ if code_snippets:
786
+ analysis = client.intelligence.analyze_codebase(code_snippets)
787
+ framework = analysis.framework
788
+ if not is_dockerized_from_config: is_dockerized = analysis.is_dockerized
789
+ if not port_from_config and analysis.port: port = analysis.port
790
+ if not size and analysis.instance_size: size = "s-1vcpu-1gb" if analysis.instance_size == "basic" else "s-2vcpu-4gb"
791
+ mode_str = "Docker" if is_dockerized else "Bare Metal"
792
+ console.print(f"[green]✓ Detected {framework.upper()} project ({mode_str} Mode)[/green] (Port: {port})")
793
+ else:
794
+ framework = "fastapi"
795
+ is_dockerized = True
796
+ except Exception as e:
797
+ framework = "fastapi"
798
+ is_dockerized = True
799
+
800
+ # Delta upload was already done above!
801
+ # file_manifest is ready (api_file_manifest)
802
+
803
+ # Create deployment with real-time streaming
804
+ try:
805
+ status_result, deployment_id, logs_data = _stream_deployment(
806
+ client=client,
807
+ project_name=project_name,
808
+ git_repo=git_repo,
809
+ branch=branch,
810
+ framework=framework,
811
+ region=region,
812
+ size_slug=size,
813
+ is_dockerized=is_dockerized,
814
+ port=port,
815
+ command=command,
816
+ entrypoint=entrypoint, # Pass entrypoint to deployment API
817
+ database=database,
818
+ package_manager=package_manager,
819
+ dependency_file=dependency_file,
820
+ file_manifest=api_file_manifest,
821
+ cleanup_on_failure=cleanup_on_failure,
822
+ services=services, # Microservices support
823
+ mode=mode, # Deployment mode
824
+ )
825
+
826
+
827
+ if status_result == "FAILED" and not no_heal:
828
+ # Hand off to the Zen Nod AI Agent
829
+ should_retry = zen_nod_workflow(
830
+ logs_data,
831
+ client,
832
+ attempt,
833
+ package_manager=package_manager,
834
+ dependency_file=dependency_file,
835
+ services=services
836
+ )
837
+
838
+ if should_retry:
839
+ # The agent applied a fix, loop back for attempt + 1
840
+ continue
841
+ else:
842
+ # Agent couldn't fix it or user declined
843
+ raise click.Abort()
844
+
845
+ # If we got here with success, break the retry loop
846
+ if status_result == "SUCCESS":
847
+ break
848
+ else:
849
+ raise click.Abort()
850
+
851
+ except XenfraAPIError as e:
852
+ # Deployment failed - try to provide helpful error
853
+ from ..utils.errors import detect_error_type, show_error_with_solution
854
+
855
+ console.print(f"[bold red]✗ Deployment failed[/bold red]")
856
+
857
+ # Try to detect error type and show solution
858
+ error_type, error_kwargs = detect_error_type(str(e.detail))
859
+ if error_type:
860
+ show_error_with_solution(error_type, **error_kwargs)
861
+ else:
862
+ console.print(f"[red]{e.detail}[/red]")
863
+
864
+ # Check if we should auto-heal
865
+ if no_heal or attempt >= MAX_RETRY_ATTEMPTS:
866
+ # No auto-healing or max retries reached
867
+ if attempt >= MAX_RETRY_ATTEMPTS:
868
+ console.print(
869
+ f"\n[bold red]❌ Maximum retry attempts ({MAX_RETRY_ATTEMPTS}) reached.[/bold red]"
870
+ )
871
+ console.print(
872
+ "[yellow]Unable to auto-fix the issue. Please review the errors above.[/yellow]"
873
+ )
874
+ raise
875
+ else:
876
+ # Try to get logs for diagnosis
877
+ error_logs = str(e.detail)
878
+ try:
879
+ if deployment_id:
880
+ # This should be a method in the SDK that returns a string
881
+ logs_response = client.deployments.get_logs(deployment_id)
882
+ if isinstance(logs_response, dict):
883
+ error_logs = logs_response.get("logs", str(e.detail))
884
+ else:
885
+ error_logs = str(logs_response) # Assuming it can be a string
886
+ except Exception as log_err:
887
+ console.print(
888
+ f"[yellow]Warning: Could not fetch detailed logs for diagnosis: {log_err}[/yellow]"
889
+ )
890
+ # Fallback to the initial error detail
891
+ pass
892
+
893
+ # Run Zen Nod workflow
894
+ should_retry = zen_nod_workflow(
895
+ error_logs,
896
+ client,
897
+ attempt,
898
+ package_manager=package_manager,
899
+ dependency_file=dependency_file,
900
+ services=services
901
+ )
902
+
903
+ if not should_retry:
904
+ # User declined patch or no patch available
905
+ console.print("\n[dim]Deployment cancelled.[/dim]")
906
+ raise click.Abort()
907
+
908
+ # Continue to next iteration (retry)
909
+ continue
910
+
911
+ except XenfraAPIError as e:
912
+ console.print(f"[bold red]API Error: {e.detail}[/bold red]")
913
+ except XenfraError as e:
914
+ console.print(f"[bold red]Error: {e}[/bold red]")
915
+ except click.Abort:
916
+ pass
917
+ except Exception as e:
918
+ console.print(f"[bold red]Unexpected error: {e}[/bold red]")
919
+
920
+
921
+ @click.command()
922
+ @click.argument("deployment-id")
923
+ @click.option("--follow", "-f", is_flag=True, help="Follow log output (stream)")
924
+ @click.option("--tail", type=int, help="Show last N lines")
925
+ def logs(deployment_id, follow, tail):
926
+ # Validate deployment ID
927
+ is_valid, error_msg = validate_deployment_id(deployment_id)
928
+ if not is_valid:
929
+ console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
930
+ raise click.Abort()
931
+ """
932
+ Stream deployment logs.
933
+
934
+ Shows logs for a specific deployment. Use --follow to stream logs in real-time.
935
+ """
936
+ try:
937
+ with get_client() as client:
938
+ console.print(f"[cyan]Fetching logs for deployment {deployment_id}...[/cyan]")
939
+
940
+ log_content = client.deployments.get_logs(deployment_id)
941
+
942
+ if not log_content:
943
+ console.print("[yellow]No logs available yet.[/yellow]")
944
+ console.print("[dim]The deployment may still be starting up.[/dim]")
945
+ return
946
+
947
+ # Process logs
948
+ log_lines = log_content.strip().split("\n")
949
+
950
+ # Apply tail if specified
951
+ if tail:
952
+ log_lines = log_lines[-tail:]
953
+
954
+ # Display logs with syntax highlighting
955
+ console.print(f"\n[bold]Logs for deployment {deployment_id}:[/bold]\n")
956
+
957
+ if follow:
958
+ _follow_deployment(client, deployment_id)
959
+ return
960
+
961
+ # Display logs
962
+ for line in log_lines:
963
+ # Color-code based on log level
964
+ if "ERROR" in line or "FAILED" in line:
965
+ console.print(f"[red]{line}[/red]")
966
+ elif "WARN" in line or "WARNING" in line:
967
+ console.print(f"[yellow]{line}[/yellow]")
968
+ elif "SUCCESS" in line or "COMPLETED" in line:
969
+ console.print(f"[green]{line}[/green]")
970
+ elif "INFO" in line:
971
+ console.print(f"[cyan]{line}[/cyan]")
972
+ else:
973
+ console.print(line)
974
+
975
+ except XenfraAPIError as e:
976
+ console.print(f"[bold red]API Error: {e.detail}[/bold red]")
977
+ except XenfraError as e:
978
+ console.print(f"[bold red]Error: {e}[/bold red]")
979
+ except click.Abort:
980
+ pass
981
+
982
+
983
+ @click.command()
984
+ @click.argument("deployment-id", required=False)
985
+ @click.option("--watch", "-w", is_flag=True, help="Watch status updates")
986
+ def status(deployment_id, watch):
987
+ """
988
+ Show deployment status.
989
+
990
+ Displays current status, progress, and details for a deployment.
991
+ Use --watch to monitor status in real-time.
992
+ """
993
+ try:
994
+ if not deployment_id:
995
+ console.print("[yellow]No deployment ID provided.[/yellow]")
996
+ console.print("[dim]Usage: xenfra status <deployment-id>[/dim]")
997
+ return
998
+
999
+ # Validate deployment ID
1000
+ is_valid, error_msg = validate_deployment_id(deployment_id)
1001
+ if not is_valid:
1002
+ console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
1003
+ raise click.Abort()
1004
+
1005
+ with get_client() as client:
1006
+ console.print(f"[cyan]Fetching status for deployment {deployment_id}...[/cyan]")
1007
+
1008
+ deployment_status = client.deployments.get_status(deployment_id)
1009
+
1010
+ if watch:
1011
+ _follow_deployment(client, deployment_id)
1012
+ return
1013
+
1014
+ # Display status
1015
+ status_value = deployment_status.get("status", "UNKNOWN")
1016
+ state = deployment_status.get("state", "unknown")
1017
+ progress = deployment_status.get("progress", 0)
1018
+
1019
+ # Status panel
1020
+ status_color = {
1021
+ "PENDING": "yellow",
1022
+ "IN_PROGRESS": "cyan",
1023
+ "SUCCESS": "green",
1024
+ "FAILED": "red",
1025
+ "CANCELLED": "dim",
1026
+ }.get(status_value, "white")
1027
+
1028
+ # Create status table
1029
+ table = Table(show_header=False, box=None)
1030
+ table.add_column("Property", style="cyan")
1031
+ table.add_column("Value")
1032
+
1033
+ table.add_row("Deployment ID", str(deployment_id))
1034
+ table.add_row("Status", f"[{status_color}]{status_value}[/{status_color}]")
1035
+ table.add_row("State", state)
1036
+
1037
+ if progress > 0:
1038
+ table.add_row("Progress", f"{progress}%")
1039
+
1040
+ if "project_name" in deployment_status:
1041
+ table.add_row("Project", deployment_status["project_name"])
1042
+
1043
+ if "created_at" in deployment_status:
1044
+ table.add_row("Created", deployment_status["created_at"])
1045
+
1046
+ if "finished_at" in deployment_status:
1047
+ table.add_row("Finished", deployment_status["finished_at"])
1048
+
1049
+ if "url" in deployment_status:
1050
+ table.add_row("URL", f"[link]{deployment_status['url']}[/link]")
1051
+
1052
+ if "ip_address" in deployment_status:
1053
+ table.add_row("IP Address", deployment_status["ip_address"])
1054
+
1055
+ panel = Panel(table, title="[bold]Deployment Status[/bold]", border_style=status_color)
1056
+ console.print(panel)
1057
+
1058
+ # Show error if failed
1059
+ if status_value == "FAILED" and "error" in deployment_status:
1060
+ error_panel = Panel(
1061
+ deployment_status["error"],
1062
+ title="[bold red]Error[/bold red]",
1063
+ border_style="red",
1064
+ )
1065
+ console.print("\n", error_panel)
1066
+
1067
+ console.print("\n[bold]Troubleshooting:[/bold]")
1068
+ console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
1069
+ console.print(f" • Diagnose: [cyan]xenfra diagnose {deployment_id}[/cyan]")
1070
+
1071
+ # Show next steps based on status
1072
+ elif status_value == "SUCCESS":
1073
+ console.print("\n[bold green]Deployment successful! 🎉[/bold green]")
1074
+ if "url" in deployment_status:
1075
+ console.print(f" • Visit: [link]{deployment_status['url']}[/link]")
1076
+
1077
+ elif status_value in ["PENDING", "IN_PROGRESS"]:
1078
+ console.print("\n[bold]Deployment in progress...[/bold]")
1079
+ console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
1080
+ console.print(f" • Check again: [cyan]xenfra status {deployment_id}[/cyan]")
1081
+
1082
+ except XenfraAPIError as e:
1083
+ console.print(f"[bold red]API Error: {e.detail}[/bold red]")
1084
+ except XenfraError as e:
1085
+ console.print(f"[bold red]Error: {e}[/bold red]")
1086
+ except click.Abort:
1087
+ pass
1088
+
1089
+
1090
+ @click.command()
1091
+ @click.argument("deployment-id")
1092
+ @click.option("--format", "output_format", type=click.Choice(["detailed", "summary"], case_sensitive=False), default="detailed", help="Report format (detailed or summary)")
1093
+ def report(deployment_id, output_format):
1094
+ """
1095
+ Generate deployment report with self-healing events.
1096
+
1097
+ Shows comprehensive deployment information including:
1098
+ - Deployment status and timeline
1099
+ - Self-healing attempts and outcomes
1100
+ - Patches applied during healing
1101
+ - Statistics and metrics
1102
+ """
1103
+ try:
1104
+ # Validate deployment ID
1105
+ is_valid, error_msg = validate_deployment_id(deployment_id)
1106
+ if not is_valid:
1107
+ console.print(f"[bold red]Invalid deployment ID: {error_msg}[/bold red]")
1108
+ raise click.Abort()
1109
+
1110
+ with get_client() as client:
1111
+ console.print(f"[cyan]Generating report for deployment {deployment_id}...[/cyan]\n")
1112
+
1113
+ # Get deployment status
1114
+ try:
1115
+ deployment_status = client.deployments.get_status(deployment_id)
1116
+ except XenfraAPIError as e:
1117
+ console.print(f"[bold red]Error fetching deployment status: {e.detail}[/bold red]")
1118
+ raise click.Abort()
1119
+
1120
+ # Get deployment logs
1121
+ try:
1122
+ logs = client.deployments.get_logs(deployment_id)
1123
+ except XenfraAPIError:
1124
+ logs = None
1125
+
1126
+ # Parse status
1127
+ status_value = deployment_status.get("status", "UNKNOWN")
1128
+ state = deployment_status.get("state", "unknown")
1129
+ progress = deployment_status.get("progress", 0)
1130
+
1131
+ # Status color mapping
1132
+ status_color = {
1133
+ "PENDING": "yellow",
1134
+ "IN_PROGRESS": "cyan",
1135
+ "SUCCESS": "green",
1136
+ "FAILED": "red",
1137
+ "CANCELLED": "dim",
1138
+ }.get(status_value, "white")
1139
+
1140
+ # Calculate statistics from logs
1141
+ heal_attempts = logs.count("🤖 Analyzing failure") if logs else 0
1142
+ patches_applied = logs.count("✓ Patch applied") if logs else 0
1143
+ diagnoses = logs.count("🔍 Diagnosis") if logs else 0
1144
+
1145
+ # Create main report table
1146
+ report_table = Table(show_header=True, box=None)
1147
+ report_table.add_column("Property", style="cyan", width=25)
1148
+ report_table.add_column("Value", style="white")
1149
+
1150
+ report_table.add_row("Deployment ID", str(deployment_id))
1151
+ report_table.add_row("Status", f"[{status_color}]{status_value}[/{status_color}]")
1152
+ report_table.add_row("State", state)
1153
+
1154
+ if progress > 0:
1155
+ report_table.add_row("Progress", f"{progress}%")
1156
+
1157
+ if "project_name" in deployment_status:
1158
+ report_table.add_row("Project", deployment_status["project_name"])
1159
+
1160
+ if "created_at" in deployment_status:
1161
+ report_table.add_row("Created", deployment_status["created_at"])
1162
+
1163
+ if "finished_at" in deployment_status:
1164
+ report_table.add_row("Finished", deployment_status["finished_at"])
1165
+
1166
+ if "url" in deployment_status:
1167
+ report_table.add_row("URL", f"[link]{deployment_status['url']}[/link]")
1168
+
1169
+ if "ip_address" in deployment_status:
1170
+ report_table.add_row("IP Address", deployment_status["ip_address"])
1171
+
1172
+ # Self-healing statistics
1173
+ report_table.add_row("", "") # Separator
1174
+ report_table.add_row("[bold]Self-Healing Stats[/bold]", "")
1175
+ report_table.add_row("Healing Attempts", str(heal_attempts))
1176
+ report_table.add_row("Patches Applied", str(patches_applied))
1177
+ report_table.add_row("Diagnoses Performed", str(diagnoses))
1178
+
1179
+ if heal_attempts > 0:
1180
+ success_rate = (patches_applied / heal_attempts * 100) if heal_attempts > 0 else 0
1181
+ report_table.add_row("Healing Success Rate", f"{success_rate:.1f}%")
1182
+
1183
+ # Display main report
1184
+ console.print(Panel(report_table, title="[bold]Deployment Report[/bold]", border_style=status_color))
1185
+
1186
+ # Detailed format includes timeline and healing events
1187
+ if output_format == "detailed" and logs:
1188
+ console.print("\n[bold]Self-Healing Timeline[/bold]\n")
1189
+
1190
+ # Extract healing events from logs
1191
+ log_lines = logs.split("\n")
1192
+ timeline_entries = []
1193
+
1194
+ for i, line in enumerate(log_lines):
1195
+ if "🤖 Analyzing failure" in line:
1196
+ attempt_match = None
1197
+ # Try to find attempt number in surrounding lines
1198
+ for j in range(max(0, i-5), min(len(log_lines), i+10)):
1199
+ if "attempt" in log_lines[j].lower():
1200
+ timeline_entries.append(("Healing Attempt", log_lines[j].strip()))
1201
+ break
1202
+ elif "🔍 Diagnosis" in line or "Diagnosis" in line:
1203
+ # Extract diagnosis text from next few lines
1204
+ diagnosis_text = line.strip()
1205
+ if i+1 < len(log_lines) and log_lines[i+1].strip():
1206
+ diagnosis_text += "\n " + log_lines[i+1].strip()[:100]
1207
+ timeline_entries.append(("Diagnosis", diagnosis_text))
1208
+ elif "✓ Patch applied" in line or "Patch applied" in line:
1209
+ timeline_entries.append(("Patch Applied", line.strip()))
1210
+ elif "🔄 Retrying deployment" in line:
1211
+ timeline_entries.append(("Retry", line.strip()))
1212
+
1213
+ if timeline_entries:
1214
+ timeline_table = Table(show_header=True, box=None)
1215
+ timeline_table.add_column("Event", style="cyan", width=20)
1216
+ timeline_table.add_column("Details", style="white")
1217
+
1218
+ for event_type, details in timeline_entries[:20]: # Limit to 20 entries
1219
+ timeline_table.add_row(event_type, details)
1220
+
1221
+ console.print(timeline_table)
1222
+ else:
1223
+ console.print("[dim]No self-healing events detected in logs.[/dim]")
1224
+
1225
+ # Show error if failed
1226
+ if status_value == "FAILED":
1227
+ console.print("\n[bold red]⚠ Deployment Failed[/bold red]")
1228
+ if "error" in deployment_status:
1229
+ error_panel = Panel(
1230
+ deployment_status["error"],
1231
+ title="[bold red]Error Details[/bold red]",
1232
+ border_style="red",
1233
+ )
1234
+ console.print("\n", error_panel)
1235
+
1236
+ console.print("\n[bold]Troubleshooting:[/bold]")
1237
+ console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
1238
+ console.print(f" • Diagnose: [cyan]xenfra diagnose {deployment_id}[/cyan]")
1239
+ console.print(f" • View status: [cyan]xenfra status {deployment_id}[/cyan]")
1240
+
1241
+ # Success summary
1242
+ elif status_value == "SUCCESS":
1243
+ console.print("\n[bold green]✓ Deployment Successful[/bold green]")
1244
+ if heal_attempts > 0:
1245
+ console.print(f"[dim]Deployment succeeded after {heal_attempts} self-healing attempt(s).[/dim]")
1246
+ if "url" in deployment_status:
1247
+ console.print(f" • Visit: [link]{deployment_status['url']}[/link]")
1248
+
1249
+ # Export summary format (JSON-like structure for programmatic use)
1250
+ if output_format == "summary":
1251
+ console.print("\n[bold]Summary Format:[/bold]")
1252
+ import json
1253
+ summary = {
1254
+ "deployment_id": deployment_id,
1255
+ "status": status_value,
1256
+ "healing_attempts": heal_attempts,
1257
+ "patches_applied": patches_applied,
1258
+ "success": status_value == "SUCCESS",
1259
+ }
1260
+ console.print(f"[dim]{json.dumps(summary, indent=2)}[/dim]")
1261
+
1262
+ except XenfraAPIError as e:
1263
+ console.print(f"[bold red]API Error: {e.detail}[/bold red]")
1264
+ except XenfraError as e:
1265
+ console.print(f"[bold red]Error: {e}[/bold red]")
1266
+ except click.Abort:
1267
+ pass
1268
+ except Exception as e:
1269
+ console.print(f"[bold red]Unexpected error: {e}[/bold red]")
1270
+
1271
+
1272
+
1273
+ @click.command(name="dry-run")
1274
+ @click.option("--force-sandbox", is_flag=True, help="Force full sandbox validation (Tier 3)")
1275
+ @click.option("--skip-sandbox", is_flag=True, help="Skip sandbox validation for faster iteration")
1276
+ @click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
1277
+ def dry_run_command(force_sandbox, skip_sandbox, verbose):
1278
+ """
1279
+ Run 3-tier dry-run validation without deploying.
1280
+
1281
+ Validates your project through all three tiers without
1282
+ actually deploying to infrastructure:
1283
+
1284
+ \b
1285
+ [bold]Tier 1[/bold]: Local validation - Syntax, imports, config files
1286
+ [bold]Tier 2[/bold]: Railpack plan - Generate build plan
1287
+ [bold]Tier 3[/bold]: E2B Sandbox - Full build validation via Xenfra API
1288
+
1289
+ This is useful for:
1290
+ - Validating changes before deploying
1291
+ - CI/CD pipelines
1292
+ - Checking for syntax errors quickly
1293
+
1294
+ Tier 3 runs on Xenfra's servers - no local setup needed.
1295
+ """
1296
+ from pathlib import Path
1297
+ from ..blueprints import run_dry_run
1298
+ from ..utils.file_sync import scan_project_files_cached, ensure_gitignore_ignored
1299
+
1300
+ project_path = Path(os.getcwd())
1301
+
1302
+ # Get authenticated client for Tier 3 sandbox
1303
+ try:
1304
+ client = get_client()
1305
+ except click.Abort:
1306
+ return
1307
+
1308
+ # Scan project files
1309
+ if ensure_gitignore_ignored():
1310
+ console.print("[dim] - Added .xenfra to .gitignore for privacy[/dim]")
1311
+
1312
+ console.print("[cyan]📁 Scanning project files...[/cyan]")
1313
+ file_manifest = scan_project_files_cached()
1314
+ console.print(f"[dim]Found {len(file_manifest)} files[/dim]")
1315
+
1316
+ if not file_manifest:
1317
+ console.print("[bold red]Error: No files found to validate.[/bold red]")
1318
+ raise click.Abort()
1319
+
1320
+ console.print(Panel(
1321
+ "[bold]🔍 Running 3-Tier Dry-Run Validation[/bold]\n"
1322
+ "Validating your project without deploying...",
1323
+ title="[bold cyan]Dry Run[/bold cyan]",
1324
+ border_style="cyan"
1325
+ ))
1326
+
1327
+ # Run dry-run
1328
+ with console.status("[cyan]Running validation...[/cyan]"):
1329
+ result = run_dry_run(
1330
+ project_path,
1331
+ force_sandbox=force_sandbox,
1332
+ skip_sandbox=skip_sandbox,
1333
+ api_client=client,
1334
+ file_manifest=file_manifest
1335
+ )
1336
+
1337
+ # Display results
1338
+ console.print(result.get_summary())
1339
+ console.print()
1340
+
1341
+ # Detailed output if verbose
1342
+ if verbose and result.tier2:
1343
+ console.print("[bold]Build Plan Details:[/bold]")
1344
+ console.print(f" Framework: {result.tier2.framework}")
1345
+ console.print(f" Language: {result.tier2.language}")
1346
+ console.print(f" Package Manager: {result.tier2.package_manager}")
1347
+ console.print(f" Port: {result.tier2.port}")
1348
+ if result.tier2.start_command:
1349
+ console.print(f" Start Command: {result.tier2.start_command}")
1350
+ console.print()
1351
+
1352
+ # Final status
1353
+ if result.success:
1354
+ console.print("[bold green]✅ All validations passed - Ready to deploy![/bold green]")
1355
+ console.print("[dim]Run 'xenfra deploy' to deploy to infrastructure.[/dim]")
1356
+ else:
1357
+ console.print("[bold red]❌ Validation failed - Fix errors before deploying[/bold red]")
1358
+ raise click.Abort()