xenfra 0.2.1__py3-none-any.whl → 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xenfra/__init__.py CHANGED
@@ -1 +0,0 @@
1
- # This file makes src/xenfra a Python package.
@@ -0,0 +1,3 @@
1
+ """
2
+ CLI command modules for Xenfra.
3
+ """
@@ -0,0 +1,186 @@
1
+ """
2
+ Authentication commands for Xenfra CLI.
3
+ """
4
+
5
+ import base64
6
+ import hashlib
7
+ import secrets
8
+ import urllib.parse
9
+ import webbrowser
10
+ from http.server import HTTPServer
11
+
12
+ import click
13
+ import httpx
14
+ import keyring
15
+ from rich.console import Console
16
+
17
+ from ..utils.auth import (
18
+ API_BASE_URL,
19
+ CLI_CLIENT_ID,
20
+ CLI_LOCAL_SERVER_END_PORT,
21
+ CLI_LOCAL_SERVER_START_PORT,
22
+ CLI_REDIRECT_PATH,
23
+ SERVICE_ID,
24
+ AuthCallbackHandler,
25
+ clear_tokens,
26
+ get_auth_token,
27
+ )
28
+
29
+ console = Console()
30
+
31
+
32
+ @click.group()
33
+ def auth():
34
+ """Authentication commands."""
35
+ pass
36
+
37
+
38
+ @auth.command()
39
+ def login():
40
+ """Login to Xenfra using OAuth2 PKCE flow."""
41
+ global oauth_data
42
+ oauth_data = {"code": None, "state": None, "error": None}
43
+
44
+ # 1. Generate PKCE parameters
45
+ code_verifier = secrets.token_urlsafe(96)
46
+ code_challenge = (
47
+ base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
48
+ .decode()
49
+ .rstrip("=")
50
+ )
51
+
52
+ # 2. Generate state for CSRF protection
53
+ state = secrets.token_urlsafe(32)
54
+
55
+ # 3. Start local HTTP server
56
+ server_port = None
57
+ httpd_instance = None
58
+ for port in range(CLI_LOCAL_SERVER_START_PORT, CLI_LOCAL_SERVER_END_PORT + 1):
59
+ try:
60
+ server_address = ("127.0.0.1", port)
61
+ httpd_instance = HTTPServer(server_address, AuthCallbackHandler)
62
+ server_port = port
63
+ break
64
+ except OSError:
65
+ continue
66
+
67
+ if not server_port:
68
+ console.print(
69
+ f"[bold red]Error: No available ports in range {CLI_LOCAL_SERVER_START_PORT}-{CLI_LOCAL_SERVER_END_PORT}[/bold red]"
70
+ )
71
+ return
72
+
73
+ redirect_uri = f"http://localhost:{server_port}{CLI_REDIRECT_PATH}"
74
+
75
+ # 4. Construct Authorization URL
76
+ auth_url = (
77
+ f"{API_BASE_URL}/auth/authorize?"
78
+ f"client_id={CLI_CLIENT_ID}&"
79
+ f"redirect_uri={urllib.parse.quote(redirect_uri)}&"
80
+ f"response_type=code&"
81
+ f"scope={urllib.parse.quote('openid profile')}&"
82
+ f"state={state}&"
83
+ f"code_challenge={code_challenge}&"
84
+ f"code_challenge_method=S256"
85
+ )
86
+
87
+ console.print("[bold blue]Opening browser for login...[/bold blue]")
88
+ console.print(
89
+ f"[dim]If browser doesn't open, navigate to:[/dim]\n[link={auth_url}]{auth_url}[/link]"
90
+ )
91
+ webbrowser.open(auth_url)
92
+
93
+ # 5. Run local server to capture redirect
94
+ try:
95
+ AuthCallbackHandler.server = httpd_instance # type: ignore
96
+ httpd_instance.handle_request() # type: ignore
97
+ console.print("[dim]Local OAuth server shut down.[/dim]")
98
+ except Exception as e:
99
+ console.print(f"[bold red]Error running OAuth server: {e}[/bold red]")
100
+ if httpd_instance:
101
+ httpd_instance.server_close()
102
+ return
103
+
104
+ if oauth_data["error"]:
105
+ console.print(f"[bold red]Login failed: {oauth_data['error']}[/bold red]")
106
+ return
107
+
108
+ if not oauth_data["code"]:
109
+ console.print("[bold red]Login failed: No authorization code received.[/bold red]")
110
+ return
111
+
112
+ # 6. Verify state
113
+ if oauth_data["state"] != state:
114
+ console.print("[bold red]Login failed: State mismatch (possible CSRF attack)[/bold red]")
115
+ return
116
+
117
+ # 7. Exchange code for tokens
118
+ console.print("[bold cyan]Exchanging authorization code for tokens...[/bold cyan]")
119
+ try:
120
+ with httpx.Client() as client:
121
+ response = client.post(
122
+ f"{API_BASE_URL}/auth/token",
123
+ data={
124
+ "grant_type": "authorization_code",
125
+ "client_id": CLI_CLIENT_ID,
126
+ "code": oauth_data["code"],
127
+ "code_verifier": code_verifier,
128
+ "redirect_uri": redirect_uri,
129
+ },
130
+ )
131
+ response.raise_for_status()
132
+ token_data = response.json()
133
+ access_token = token_data.get("access_token")
134
+ refresh_token = token_data.get("refresh_token")
135
+
136
+ if access_token and refresh_token:
137
+ keyring.set_password(SERVICE_ID, "access_token", access_token)
138
+ keyring.set_password(SERVICE_ID, "refresh_token", refresh_token)
139
+ console.print("[bold green]Login successful! Tokens saved securely.[/bold green]")
140
+ else:
141
+ console.print("[bold red]Login failed: No tokens received.[/bold red]")
142
+ except httpx.RequestError as exc:
143
+ console.print(f"[bold red]Token exchange failed: {exc}[/bold red]")
144
+ except httpx.HTTPStatusError as exc:
145
+ console.print(f"[bold red]Token exchange failed: {exc.response.status_code}[/bold red]")
146
+
147
+
148
+ @auth.command()
149
+ def logout():
150
+ """Logout and clear stored tokens."""
151
+ clear_tokens()
152
+ console.print("[bold green]Logged out successfully.[/bold green]")
153
+
154
+
155
+ @auth.command()
156
+ @click.option("--token", is_flag=True, help="Show access token")
157
+ def whoami(token):
158
+ """Show current authenticated user."""
159
+ access_token = get_auth_token()
160
+
161
+ if not access_token:
162
+ console.print("[bold red]Not logged in. Run 'xenfra login' first.[/bold red]")
163
+ return
164
+
165
+ try:
166
+ from jose import jwt
167
+
168
+ # For display purposes only, in a CLI context where the token has just
169
+ # been retrieved from a secure source (keyring), we can disable
170
+ # signature verification.
171
+ #
172
+ # SECURITY BEST PRACTICE: In a real application, especially a server,
173
+ # you would fetch the public key from the SSO's JWKS endpoint and
174
+ # fully verify the token's signature to ensure its integrity.
175
+ claims = jwt.decode(
176
+ access_token, options={"verify_signature": False} # OK for local display
177
+ )
178
+
179
+ console.print("[bold green]Logged in as:[/bold green]")
180
+ console.print(f" User ID: {claims.get('sub')}")
181
+ console.print(f" Email: {claims.get('email', 'N/A')}")
182
+
183
+ if token:
184
+ console.print(f"\n[dim]Access Token:[/dim]\n{access_token}")
185
+ except Exception as e:
186
+ console.print(f"[bold red]Failed to decode token: {e}[/bold red]")
@@ -0,0 +1,443 @@
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
18
+
19
+ console = Console()
20
+
21
+ # Maximum number of retry attempts for auto-healing
22
+ MAX_RETRY_ATTEMPTS = 3
23
+
24
+
25
+ def get_client() -> XenfraClient:
26
+ """Get authenticated SDK client."""
27
+ token = get_auth_token()
28
+ if not token:
29
+ console.print("[bold red]Not logged in. Run 'xenfra auth login' first.[/bold red]")
30
+ raise click.Abort()
31
+
32
+ return XenfraClient(token=token, api_url=API_BASE_URL)
33
+
34
+
35
+ def show_diagnosis_panel(diagnosis: str, suggestion: str):
36
+ """Display diagnosis and suggestion in formatted panels."""
37
+ console.print()
38
+ console.print(Panel(diagnosis, title="[bold red]🔍 Diagnosis[/bold red]", border_style="red"))
39
+ console.print()
40
+ console.print(
41
+ Panel(suggestion, title="[bold yellow]💡 Suggestion[/bold yellow]", border_style="yellow")
42
+ )
43
+
44
+
45
+ def show_patch_preview(patch_data: dict):
46
+ """Show a preview of the patch that will be applied."""
47
+ console.print()
48
+ console.print("[bold green]🔧 Automatic Fix Available[/bold green]")
49
+ console.print(f" [cyan]File:[/cyan] {patch_data.get('file')}")
50
+ console.print(f" [cyan]Operation:[/cyan] {patch_data.get('operation')}")
51
+ console.print(f" [cyan]Value:[/cyan] {patch_data.get('value')}")
52
+ console.print()
53
+
54
+
55
+ def zen_nod_workflow(logs: str, client: XenfraClient, attempt: int) -> bool:
56
+ """
57
+ Execute the Zen Nod auto-healing workflow.
58
+
59
+ Args:
60
+ logs: Deployment error logs
61
+ client: Authenticated SDK client
62
+ attempt: Current attempt number
63
+
64
+ Returns:
65
+ True if patch was applied and user wants to retry, False otherwise
66
+ """
67
+ console.print()
68
+ console.print(f"[cyan]🤖 Analyzing failure (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]")
69
+
70
+ # Scrub sensitive data from logs
71
+ scrubbed_logs = scrub_logs(logs)
72
+
73
+ # Diagnose with AI
74
+ try:
75
+ diagnosis_result = client.intelligence.diagnose(scrubbed_logs)
76
+ except Exception as e:
77
+ console.print(f"[yellow]Could not diagnose failure: {e}[/yellow]")
78
+ return False
79
+
80
+ # Show diagnosis
81
+ show_diagnosis_panel(diagnosis_result.diagnosis, diagnosis_result.suggestion)
82
+
83
+ # Check if there's an automatic patch
84
+ if diagnosis_result.patch and diagnosis_result.patch.file:
85
+ show_patch_preview(diagnosis_result.patch.model_dump())
86
+
87
+ # Zen Nod confirmation
88
+ if click.confirm("Apply this fix and retry deployment?", default=True):
89
+ try:
90
+ # Apply patch (with automatic backup)
91
+ backup_path = apply_patch(diagnosis_result.patch.model_dump())
92
+ console.print("[bold green]✓ Patch applied[/bold green]")
93
+ if backup_path:
94
+ console.print(f"[dim]Backup saved: {backup_path}[/dim]")
95
+ return True # Signal to retry
96
+ except Exception as e:
97
+ console.print(f"[bold red]Failed to apply patch: {e}[/bold red]")
98
+ return False
99
+ else:
100
+ console.print()
101
+ console.print("[yellow]❌ Patch declined. Follow the manual steps above.[/yellow]")
102
+ return False
103
+ else:
104
+ console.print()
105
+ console.print(
106
+ "[yellow]No automatic fix available. Please follow the manual steps above.[/yellow]"
107
+ )
108
+ return False
109
+
110
+
111
+ @click.command()
112
+ @click.option("--project-name", help="Project name (defaults to current directory name)")
113
+ @click.option("--git-repo", help="Git repository URL (if deploying from git)")
114
+ @click.option("--branch", default="main", help="Git branch (default: main)")
115
+ @click.option("--framework", help="Framework override (fastapi, flask, django)")
116
+ @click.option("--no-heal", is_flag=True, help="Disable auto-healing on failure")
117
+ def deploy(project_name, git_repo, branch, framework, no_heal):
118
+ """
119
+ Deploy current project to DigitalOcean with auto-healing.
120
+
121
+ Deploys your application with zero configuration. The CLI will:
122
+ 1. Check for xenfra.yaml (or run init if missing)
123
+ 2. Create a deployment
124
+ 3. Auto-diagnose and fix failures (unless --no-heal is set or XENFRA_NO_AI=1)
125
+
126
+ Set XENFRA_NO_AI=1 environment variable to disable all AI features.
127
+ """
128
+ # Check XENFRA_NO_AI environment variable
129
+ no_ai = os.environ.get("XENFRA_NO_AI", "0") == "1"
130
+ if no_ai:
131
+ console.print("[yellow]XENFRA_NO_AI is set. Auto-healing disabled.[/yellow]")
132
+ no_heal = True
133
+
134
+ # Check for xenfra.yaml
135
+ if not has_xenfra_config():
136
+ console.print("[yellow]No xenfra.yaml found.[/yellow]")
137
+ if click.confirm("Run 'xenfra init' to create configuration?", default=True):
138
+ from .intelligence import init
139
+
140
+ ctx = click.get_current_context()
141
+ ctx.invoke(init, manual=no_ai, accept_all=False)
142
+ else:
143
+ console.print("[dim]Deployment cancelled.[/dim]")
144
+ return
145
+
146
+ # Default project name to current directory
147
+ if not project_name:
148
+ project_name = os.path.basename(os.getcwd())
149
+
150
+ # Determine deployment source
151
+ if git_repo:
152
+ console.print(f"[cyan]Deploying {project_name} from git repository...[/cyan]")
153
+ else:
154
+ console.print(f"[cyan]Deploying {project_name} from local directory...[/cyan]")
155
+ console.print(
156
+ "[yellow]Local deployment requires code upload (not yet fully implemented).[/yellow]"
157
+ )
158
+ console.print("[dim]Please use --git-repo for now.[/dim]")
159
+ return
160
+
161
+ # Retry loop for auto-healing
162
+ attempt = 0
163
+ deployment_id = None
164
+
165
+ try:
166
+ with get_client() as client:
167
+ while attempt < MAX_RETRY_ATTEMPTS:
168
+ # Safety check to prevent infinite loops
169
+ if attempt > MAX_RETRY_ATTEMPTS:
170
+ raise RuntimeError("Safety break: Retry loop exceeded MAX_RETRY_ATTEMPTS.")
171
+
172
+ attempt += 1
173
+
174
+ if attempt > 1:
175
+ console.print(
176
+ f"\n[cyan]🔄 Retrying deployment (attempt {attempt}/{MAX_RETRY_ATTEMPTS})...[/cyan]"
177
+ )
178
+ else:
179
+ console.print("[cyan]Creating deployment...[/cyan]")
180
+
181
+ # Detect framework if not provided
182
+ if not framework:
183
+ console.print("[dim]Auto-detecting framework...[/dim]")
184
+ framework = "fastapi" # Default for now
185
+
186
+ # Create deployment
187
+ try:
188
+ deployment = client.deployments.create(
189
+ project_name=project_name,
190
+ git_repo=git_repo,
191
+ branch=branch,
192
+ framework=framework,
193
+ )
194
+
195
+ deployment_id = deployment["deployment_id"]
196
+ console.print(
197
+ f"[bold green]✓[/bold green] Deployment created: [cyan]{deployment_id}[/cyan]"
198
+ )
199
+
200
+ # Show deployment details
201
+ details_table = Table(show_header=False, box=None)
202
+ details_table.add_column("Property", style="cyan")
203
+ details_table.add_column("Value", style="white")
204
+
205
+ details_table.add_row("Deployment ID", str(deployment_id))
206
+ details_table.add_row("Project", project_name)
207
+ if git_repo:
208
+ details_table.add_row("Repository", git_repo)
209
+ details_table.add_row("Branch", branch)
210
+ details_table.add_row("Framework", framework)
211
+ details_table.add_row("Status", deployment.get("status", "PENDING"))
212
+
213
+ panel = Panel(
214
+ details_table,
215
+ title="[bold green]Deployment Started[/bold green]",
216
+ border_style="green",
217
+ )
218
+ console.print(panel)
219
+
220
+ # Show next steps
221
+ console.print("\n[bold]Next steps:[/bold]")
222
+ console.print(f" • Monitor status: [cyan]xenfra status {deployment_id}[/cyan]")
223
+ console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
224
+ if not no_heal:
225
+ console.print(
226
+ f" • Diagnose issues: [cyan]xenfra diagnose {deployment_id}[/cyan]"
227
+ )
228
+
229
+ # Success - break out of retry loop
230
+ break
231
+
232
+ except XenfraAPIError as e:
233
+ # Deployment failed
234
+ console.print(f"[bold red]✗ Deployment failed: {e.detail}[/bold red]")
235
+
236
+ # Check if we should auto-heal
237
+ if no_heal or attempt >= MAX_RETRY_ATTEMPTS:
238
+ # No auto-healing or max retries reached
239
+ if attempt >= MAX_RETRY_ATTEMPTS:
240
+ console.print(
241
+ f"\n[bold red]❌ Maximum retry attempts ({MAX_RETRY_ATTEMPTS}) reached.[/bold red]"
242
+ )
243
+ console.print(
244
+ "[yellow]Unable to auto-fix the issue. Please review the errors above.[/yellow]"
245
+ )
246
+ raise
247
+ else:
248
+ # Try to get logs for diagnosis
249
+ error_logs = str(e.detail)
250
+ try:
251
+ if deployment_id:
252
+ # This should be a method in the SDK that returns a string
253
+ logs_response = client.deployments.get_logs(deployment_id)
254
+ if isinstance(logs_response, dict):
255
+ error_logs = logs_response.get("logs", str(e.detail))
256
+ else:
257
+ error_logs = str(logs_response) # Assuming it can be a string
258
+ except Exception as log_err:
259
+ console.print(
260
+ f"[yellow]Warning: Could not fetch detailed logs for diagnosis: {log_err}[/yellow]"
261
+ )
262
+ # Fallback to the initial error detail
263
+ pass
264
+
265
+ # Run Zen Nod workflow
266
+ should_retry = zen_nod_workflow(error_logs, client, attempt)
267
+
268
+ if not should_retry:
269
+ # User declined patch or no patch available
270
+ console.print("\n[dim]Deployment cancelled.[/dim]")
271
+ raise click.Abort()
272
+
273
+ # Continue to next iteration (retry)
274
+ continue
275
+
276
+ except XenfraAPIError as e:
277
+ console.print(f"[bold red]API Error: {e.detail}[/bold red]")
278
+ except XenfraError as e:
279
+ console.print(f"[bold red]Error: {e}[/bold red]")
280
+ except click.Abort:
281
+ pass
282
+ except Exception as e:
283
+ console.print(f"[bold red]Unexpected error: {e}[/bold red]")
284
+
285
+
286
+ @click.command()
287
+ @click.argument("deployment-id")
288
+ @click.option("--follow", "-f", is_flag=True, help="Follow log output (stream)")
289
+ @click.option("--tail", type=int, help="Show last N lines")
290
+ def logs(deployment_id, follow, tail):
291
+ """
292
+ Stream deployment logs.
293
+
294
+ Shows logs for a specific deployment. Use --follow to stream logs in real-time.
295
+ """
296
+ try:
297
+ with get_client() as client:
298
+ console.print(f"[cyan]Fetching logs for deployment {deployment_id}...[/cyan]")
299
+
300
+ log_content = client.deployments.get_logs(deployment_id)
301
+
302
+ if not log_content:
303
+ console.print("[yellow]No logs available yet.[/yellow]")
304
+ console.print("[dim]The deployment may still be starting up.[/dim]")
305
+ return
306
+
307
+ # Process logs
308
+ log_lines = log_content.strip().split("\n")
309
+
310
+ # Apply tail if specified
311
+ if tail:
312
+ log_lines = log_lines[-tail:]
313
+
314
+ # Display logs with syntax highlighting
315
+ console.print(f"\n[bold]Logs for deployment {deployment_id}:[/bold]\n")
316
+
317
+ if follow:
318
+ console.print(
319
+ "[yellow]Note: --follow flag not yet implemented (showing static logs)[/yellow]\n"
320
+ )
321
+
322
+ # Display logs
323
+ for line in log_lines:
324
+ # Color-code based on log level
325
+ if "ERROR" in line or "FAILED" in line:
326
+ console.print(f"[red]{line}[/red]")
327
+ elif "WARN" in line or "WARNING" in line:
328
+ console.print(f"[yellow]{line}[/yellow]")
329
+ elif "SUCCESS" in line or "COMPLETED" in line:
330
+ console.print(f"[green]{line}[/green]")
331
+ elif "INFO" in line:
332
+ console.print(f"[cyan]{line}[/cyan]")
333
+ else:
334
+ console.print(line)
335
+
336
+ except XenfraAPIError as e:
337
+ console.print(f"[bold red]API Error: {e.detail}[/bold red]")
338
+ except XenfraError as e:
339
+ console.print(f"[bold red]Error: {e}[/bold red]")
340
+ except click.Abort:
341
+ pass
342
+
343
+
344
+ @click.command()
345
+ @click.argument("deployment-id", required=False)
346
+ @click.option("--watch", "-w", is_flag=True, help="Watch status updates")
347
+ def status(deployment_id, watch):
348
+ """
349
+ Show deployment status.
350
+
351
+ Displays current status, progress, and details for a deployment.
352
+ Use --watch to monitor status in real-time.
353
+ """
354
+ try:
355
+ if not deployment_id:
356
+ console.print("[yellow]No deployment ID provided.[/yellow]")
357
+ console.print("[dim]Usage: xenfra status <deployment-id>[/dim]")
358
+ return
359
+
360
+ with get_client() as client:
361
+ console.print(f"[cyan]Fetching status for deployment {deployment_id}...[/cyan]")
362
+
363
+ deployment_status = client.deployments.get_status(deployment_id)
364
+
365
+ if watch:
366
+ console.print(
367
+ "[yellow]Note: --watch flag not yet implemented (showing current status)[/yellow]\n"
368
+ )
369
+
370
+ # Display status
371
+ status_value = deployment_status.get("status", "UNKNOWN")
372
+ state = deployment_status.get("state", "unknown")
373
+ progress = deployment_status.get("progress", 0)
374
+
375
+ # Status panel
376
+ status_color = {
377
+ "PENDING": "yellow",
378
+ "IN_PROGRESS": "cyan",
379
+ "SUCCESS": "green",
380
+ "FAILED": "red",
381
+ "CANCELLED": "dim",
382
+ }.get(status_value, "white")
383
+
384
+ # Create status table
385
+ table = Table(show_header=False, box=None)
386
+ table.add_column("Property", style="cyan")
387
+ table.add_column("Value")
388
+
389
+ table.add_row("Deployment ID", str(deployment_id))
390
+ table.add_row("Status", f"[{status_color}]{status_value}[/{status_color}]")
391
+ table.add_row("State", state)
392
+
393
+ if progress > 0:
394
+ table.add_row("Progress", f"{progress}%")
395
+
396
+ if "project_name" in deployment_status:
397
+ table.add_row("Project", deployment_status["project_name"])
398
+
399
+ if "created_at" in deployment_status:
400
+ table.add_row("Created", deployment_status["created_at"])
401
+
402
+ if "finished_at" in deployment_status:
403
+ table.add_row("Finished", deployment_status["finished_at"])
404
+
405
+ if "url" in deployment_status:
406
+ table.add_row("URL", f"[link]{deployment_status['url']}[/link]")
407
+
408
+ if "ip_address" in deployment_status:
409
+ table.add_row("IP Address", deployment_status["ip_address"])
410
+
411
+ panel = Panel(table, title="[bold]Deployment Status[/bold]", border_style=status_color)
412
+ console.print(panel)
413
+
414
+ # Show error if failed
415
+ if status_value == "FAILED" and "error" in deployment_status:
416
+ error_panel = Panel(
417
+ deployment_status["error"],
418
+ title="[bold red]Error[/bold red]",
419
+ border_style="red",
420
+ )
421
+ console.print("\n", error_panel)
422
+
423
+ console.print("\n[bold]Troubleshooting:[/bold]")
424
+ console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
425
+ console.print(f" • Diagnose: [cyan]xenfra diagnose {deployment_id}[/cyan]")
426
+
427
+ # Show next steps based on status
428
+ elif status_value == "SUCCESS":
429
+ console.print("\n[bold green]Deployment successful! 🎉[/bold green]")
430
+ if "url" in deployment_status:
431
+ console.print(f" • Visit: [link]{deployment_status['url']}[/link]")
432
+
433
+ elif status_value in ["PENDING", "IN_PROGRESS"]:
434
+ console.print("\n[bold]Deployment in progress...[/bold]")
435
+ console.print(f" • View logs: [cyan]xenfra logs {deployment_id}[/cyan]")
436
+ console.print(f" • Check again: [cyan]xenfra status {deployment_id}[/cyan]")
437
+
438
+ except XenfraAPIError as e:
439
+ console.print(f"[bold red]API Error: {e.detail}[/bold red]")
440
+ except XenfraError as e:
441
+ console.print(f"[bold red]Error: {e}[/bold red]")
442
+ except click.Abort:
443
+ pass