xenfra 0.2.3__py3-none-any.whl → 0.2.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,6 +1,7 @@
1
1
  """
2
2
  Project management commands for Xenfra CLI.
3
3
  """
4
+
4
5
  import click
5
6
  from rich.console import Console
6
7
  from rich.table import Table
@@ -8,6 +9,12 @@ from xenfra_sdk import XenfraClient
8
9
  from xenfra_sdk.exceptions import XenfraAPIError, XenfraError
9
10
 
10
11
  from ..utils.auth import API_BASE_URL, get_auth_token
12
+ from ..utils.validation import (
13
+ validate_project_id,
14
+ validate_project_name,
15
+ validate_region,
16
+ validate_size_slug,
17
+ )
11
18
 
12
19
  console = Console()
13
20
 
@@ -50,14 +57,18 @@ def list():
50
57
  table.add_column("Cost/Month", style="red")
51
58
 
52
59
  for project in projects_list:
53
- cost = f"${project.estimated_monthly_cost:.2f}" if project.estimated_monthly_cost else "N/A"
60
+ cost = (
61
+ f"${project.estimated_monthly_cost:.2f}"
62
+ if project.estimated_monthly_cost
63
+ else "N/A"
64
+ )
54
65
  table.add_row(
55
66
  str(project.id),
56
67
  project.name,
57
68
  project.status,
58
69
  project.region,
59
70
  project.ip_address or "N/A",
60
- cost
71
+ cost,
61
72
  )
62
73
 
63
74
  console.print(table)
@@ -71,9 +82,15 @@ def list():
71
82
 
72
83
 
73
84
  @projects.command()
74
- @click.argument('project_id', type=int)
85
+ @click.argument("project_id", type=int)
75
86
  def show(project_id):
76
87
  """Show details for a specific project."""
88
+ # Validate project ID
89
+ is_valid, error_msg = validate_project_id(project_id)
90
+ if not is_valid:
91
+ console.print(f"[bold red]Invalid project ID: {error_msg}[/bold red]")
92
+ raise click.Abort()
93
+
77
94
  try:
78
95
  with get_client() as client:
79
96
  project = client.projects.show(project_id)
@@ -92,7 +109,7 @@ def show(project_id):
92
109
  panel = Panel(
93
110
  details,
94
111
  title=f"[bold green]Project {project.id}[/bold green]",
95
- border_style="green"
112
+ border_style="green",
96
113
  )
97
114
  console.print(panel)
98
115
 
@@ -105,10 +122,16 @@ def show(project_id):
105
122
 
106
123
 
107
124
  @projects.command()
108
- @click.argument('project_id', type=int)
109
- @click.confirmation_option(prompt='Are you sure you want to delete this project?')
125
+ @click.argument("project_id", type=int)
126
+ @click.confirmation_option(prompt="Are you sure you want to delete this project?")
110
127
  def delete(project_id):
111
128
  """Delete a project."""
129
+ # Validate project ID
130
+ is_valid, error_msg = validate_project_id(project_id)
131
+ if not is_valid:
132
+ console.print(f"[bold red]Invalid project ID: {error_msg}[/bold red]")
133
+ raise click.Abort()
134
+
112
135
  try:
113
136
  with get_client() as client:
114
137
  client.projects.delete(str(project_id))
@@ -123,11 +146,31 @@ def delete(project_id):
123
146
 
124
147
 
125
148
  @projects.command()
126
- @click.argument('name')
127
- @click.option('--region', default='nyc3', help='DigitalOcean region (default: nyc3)')
128
- @click.option('--size', 'size_slug', default='s-1vcpu-1gb', help='Droplet size (default: s-1vcpu-1gb)')
149
+ @click.argument("name")
150
+ @click.option("--region", default="nyc3", help="DigitalOcean region (default: nyc3)")
151
+ @click.option(
152
+ "--size", "size_slug", default="s-1vcpu-1gb", help="Droplet size (default: s-1vcpu-1gb)"
153
+ )
129
154
  def create(name, region, size_slug):
130
155
  """Create a new project."""
156
+ # Validate project name
157
+ is_valid, error_msg = validate_project_name(name)
158
+ if not is_valid:
159
+ console.print(f"[bold red]Invalid project name: {error_msg}[/bold red]")
160
+ raise click.Abort()
161
+
162
+ # Validate region
163
+ is_valid, error_msg = validate_region(region)
164
+ if not is_valid:
165
+ console.print(f"[bold red]Invalid region: {error_msg}[/bold red]")
166
+ raise click.Abort()
167
+
168
+ # Validate size slug
169
+ is_valid, error_msg = validate_size_slug(size_slug)
170
+ if not is_valid:
171
+ console.print(f"[bold red]Invalid size slug: {error_msg}[/bold red]")
172
+ raise click.Abort()
173
+
131
174
  try:
132
175
  with get_client() as client:
133
176
  console.print(f"[cyan]Creating project '{name}'...[/cyan]")
@@ -136,7 +179,7 @@ def create(name, region, size_slug):
136
179
  project = client.projects.create(name=name, region=region, size_slug=size_slug)
137
180
 
138
181
  # Display success message
139
- console.print(f"[bold green]✓[/bold green] Project created successfully!")
182
+ console.print("[bold green]✓[/bold green] Project created successfully!")
140
183
 
141
184
  # Show project details
142
185
  from rich.panel import Panel
@@ -149,9 +192,7 @@ def create(name, region, size_slug):
149
192
  [cyan]Estimated Cost:[/cyan] ${project.estimated_monthly_cost:.2f}/month"""
150
193
 
151
194
  panel = Panel(
152
- details,
153
- title="[bold green]New Project[/bold green]",
154
- border_style="green"
195
+ details, title="[bold green]New Project[/bold green]", border_style="green"
155
196
  )
156
197
  console.print(panel)
157
198
 
@@ -1,6 +1,7 @@
1
1
  """
2
2
  Security configuration commands for Xenfra CLI.
3
3
  """
4
+
4
5
  import os
5
6
 
6
7
  import click
@@ -8,12 +9,7 @@ from rich.console import Console
8
9
  from rich.panel import Panel
9
10
  from rich.table import Table
10
11
 
11
- from ..utils.security import (
12
- ALLOWED_DOMAINS,
13
- display_security_info,
14
- security_config,
15
- validate_and_get_api_url,
16
- )
12
+ from ..utils.security import ALLOWED_DOMAINS, security_config, validate_and_get_api_url
17
13
 
18
14
  console = Console()
19
15
 
@@ -41,7 +37,9 @@ def check():
41
37
 
42
38
  # Environment
43
39
  env_color = "green" if security_config.is_production() else "yellow"
44
- console.print(f"[bold]Environment:[/bold] [{env_color}]{security_config.environment}[/{env_color}]")
40
+ console.print(
41
+ f"[bold]Environment:[/bold] [{env_color}]{security_config.environment}[/{env_color}]"
42
+ )
45
43
 
46
44
  console.print()
47
45
 
@@ -55,22 +53,22 @@ def check():
55
53
  (
56
54
  "HTTPS Enforcement",
57
55
  "✅ Enabled" if security_config.enforce_https else "⚠️ Disabled",
58
- "Blocks HTTP connections (except localhost)"
56
+ "Blocks HTTP connections (except localhost)",
59
57
  ),
60
58
  (
61
59
  "Domain Whitelist",
62
60
  "✅ Enforced" if security_config.enforce_whitelist else "⚠️ Warning Only",
63
- "Restricts connections to approved domains"
61
+ "Restricts connections to approved domains",
64
62
  ),
65
63
  (
66
64
  "HTTP Warning",
67
65
  "✅ Enabled" if security_config.warn_on_http else "❌ Disabled",
68
- "Warns when using insecure HTTP"
66
+ "Warns when using insecure HTTP",
69
67
  ),
70
68
  (
71
69
  "Certificate Pinning",
72
70
  "✅ Enabled" if security_config.enable_cert_pinning else "❌ Disabled",
73
- "Validates SSL certificate fingerprints"
71
+ "Validates SSL certificate fingerprints",
74
72
  ),
75
73
  ]
76
74
 
@@ -113,7 +111,7 @@ def check():
113
111
  "2. Use HTTPS API URL\n"
114
112
  "3. All security features will auto-enable",
115
113
  title="Recommendation",
116
- border_style="yellow"
114
+ border_style="yellow",
117
115
  )
118
116
  )
119
117
  else:
@@ -123,23 +121,23 @@ def check():
123
121
  "All security features are active.\n"
124
122
  "Your credentials and data are protected.",
125
123
  title="Status",
126
- border_style="green"
124
+ border_style="green",
127
125
  )
128
126
  )
129
127
 
130
128
 
131
129
  @security.command()
132
- @click.argument('url')
130
+ @click.argument("url")
133
131
  def validate(url):
134
132
  """Validate an API URL against security policies."""
135
133
  console.print(f"\n[cyan]Validating URL:[/cyan] {url}\n")
136
134
 
137
135
  try:
138
136
  validated_url = validate_and_get_api_url(url)
139
- console.print(f"[bold green]✅ URL is valid and passed all security checks![/bold green]")
137
+ console.print("[bold green]✅ URL is valid and passed all security checks![/bold green]")
140
138
  console.print(f"[dim]Validated URL: {validated_url}[/dim]")
141
139
  except click.Abort:
142
- console.print(f"[bold red]❌ URL failed security validation[/bold red]")
140
+ console.print("[bold red]❌ URL failed security validation[/bold red]")
143
141
  except Exception as e:
144
142
  console.print(f"[bold red]❌ Validation error: {e}[/bold red]")
145
143
 
@@ -202,7 +200,7 @@ XENFRA_ENV
202
200
  Default: development
203
201
 
204
202
  XENFRA_API_URL
205
- Default: http://localhost:8000 (dev), https://api.xenfra.com (prod)
203
+ Default: http://localhost:8000 (dev), https://api.xenfra.tech (prod)
206
204
 
207
205
  XENFRA_ENFORCE_HTTPS
208
206
  Values: true | false
@@ -229,7 +227,7 @@ XENFRA_WARN_ON_HTTP
229
227
  5. Use environment-specific configurations
230
228
  6. Enable all features for production deployments
231
229
 
232
- [dim]For more information: https://docs.xenfra.com/security[/dim]
230
+ [dim]For more information: https://docs.xenfra.tech/security[/dim]
233
231
  """
234
232
 
235
233
  console.print(Panel(docs_text, border_style="cyan", padding=(1, 2)))
xenfra/main.py CHANGED
@@ -3,6 +3,7 @@ Xenfra CLI - Main entry point.
3
3
 
4
4
  A modern, AI-powered CLI for deploying Python apps to DigitalOcean.
5
5
  """
6
+
6
7
  import os
7
8
 
8
9
  import click
xenfra/utils/auth.py CHANGED
@@ -2,13 +2,19 @@
2
2
  Authentication utilities for Xenfra CLI.
3
3
  Handles OAuth2 PKCE flow and token management.
4
4
  """
5
- import os
5
+
6
6
  from http.server import BaseHTTPRequestHandler, HTTPServer
7
7
  from urllib.parse import parse_qs, urlparse
8
8
 
9
9
  import httpx
10
10
  import keyring
11
11
  from rich.console import Console
12
+ from tenacity import (
13
+ retry,
14
+ stop_after_attempt,
15
+ wait_exponential,
16
+ retry_if_exception_type,
17
+ )
12
18
 
13
19
  from .security import validate_and_get_api_url
14
20
 
@@ -24,6 +30,9 @@ CLI_REDIRECT_PATH = "/auth/callback"
24
30
  CLI_LOCAL_SERVER_START_PORT = 8001
25
31
  CLI_LOCAL_SERVER_END_PORT = 8005
26
32
 
33
+ # HTTP request timeout (30 seconds)
34
+ HTTP_TIMEOUT = 30.0
35
+
27
36
  # Global storage for OAuth callback data
28
37
  oauth_data = {"code": None, "state": None, "error": None}
29
38
 
@@ -76,6 +85,39 @@ def run_local_oauth_server(port: int, redirect_path: str):
76
85
  console.print("[dim]Local OAuth server shut down.[/dim]")
77
86
 
78
87
 
88
+ @retry(
89
+ stop=stop_after_attempt(3),
90
+ wait=wait_exponential(multiplier=1, min=2, max=10),
91
+ retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
92
+ reraise=True,
93
+ )
94
+ def _refresh_token_with_retry(refresh_token: str) -> dict:
95
+ """
96
+ Refresh access token with retry logic.
97
+
98
+ Returns token data dictionary.
99
+ """
100
+ with httpx.Client(timeout=HTTP_TIMEOUT) as client:
101
+ response = client.post(
102
+ f"{API_BASE_URL}/auth/refresh",
103
+ data={"refresh_token": refresh_token, "client_id": CLI_CLIENT_ID},
104
+ headers={"Accept": "application/json"},
105
+ )
106
+ response.raise_for_status()
107
+
108
+ # Safe JSON parsing with content-type check
109
+ content_type = response.headers.get("content-type", "")
110
+ if "application/json" not in content_type:
111
+ raise ValueError(f"Expected JSON response, got {content_type}")
112
+
113
+ try:
114
+ token_data = response.json()
115
+ except (ValueError, TypeError) as e:
116
+ raise ValueError(f"Failed to parse JSON response: {e}")
117
+
118
+ return token_data
119
+
120
+
79
121
  def get_auth_token() -> str | None:
80
122
  """
81
123
  Retrieve a valid access token, refreshing it if necessary.
@@ -83,8 +125,12 @@ def get_auth_token() -> str | None:
83
125
  Returns:
84
126
  Valid access token or None if not authenticated
85
127
  """
86
- access_token = keyring.get_password(SERVICE_ID, "access_token")
87
- refresh_token = keyring.get_password(SERVICE_ID, "refresh_token")
128
+ try:
129
+ access_token = keyring.get_password(SERVICE_ID, "access_token")
130
+ refresh_token = keyring.get_password(SERVICE_ID, "refresh_token")
131
+ except keyring.errors.KeyringError as e:
132
+ console.print(f"[yellow]Warning: Could not access keyring: {e}[/yellow]")
133
+ return None
88
134
 
89
135
  if not access_token:
90
136
  return None
@@ -105,35 +151,65 @@ def get_auth_token() -> str | None:
105
151
  if not claims and refresh_token:
106
152
  console.print("[dim]Access token expired. Attempting to refresh...[/dim]")
107
153
  try:
108
- with httpx.Client() as client:
109
- response = client.post(
110
- f"{API_BASE_URL}/auth/refresh",
111
- data={"refresh_token": refresh_token, "client_id": CLI_CLIENT_ID},
112
- )
113
- response.raise_for_status()
114
- token_data = response.json()
115
- new_access_token = token_data.get("access_token")
116
- new_refresh_token = token_data.get("refresh_token")
154
+ token_data = _refresh_token_with_retry(refresh_token)
155
+ new_access_token = token_data.get("access_token")
156
+ new_refresh_token = token_data.get("refresh_token")
117
157
 
118
- if new_access_token:
158
+ if new_access_token:
159
+ try:
119
160
  keyring.set_password(SERVICE_ID, "access_token", new_access_token)
120
161
  if new_refresh_token:
121
162
  keyring.set_password(SERVICE_ID, "refresh_token", new_refresh_token)
122
163
  console.print("[bold green]Token refreshed successfully.[/bold green]")
123
164
  return new_access_token
124
- else:
125
- console.print("[bold red]Failed to get new access token.[/bold red]")
126
- return None
165
+ except keyring.errors.KeyringError as e:
166
+ console.print(
167
+ f"[yellow]Warning: Could not save refreshed token to keyring: {e}[/yellow]"
168
+ )
169
+ # Return the token anyway, but warn user
170
+ return new_access_token
171
+ else:
172
+ console.print("[bold red]Failed to get new access token.[/bold red]")
173
+ return None
174
+
175
+ except httpx.TimeoutException:
176
+ console.print("[bold red]Token refresh failed: Request timed out.[/bold red]")
177
+ return None
178
+ except httpx.NetworkError:
179
+ console.print("[bold red]Token refresh failed: Network error.[/bold red]")
180
+ return None
127
181
  except httpx.HTTPStatusError as exc:
128
182
  if exc.response.status_code == 400:
129
183
  console.print("[bold red]Refresh token expired. Please log in again.[/bold red]")
130
184
  else:
131
- console.print(f"[bold red]Token refresh failed: {exc.response.status_code}[/bold red]")
132
- keyring.delete_password(SERVICE_ID, "access_token")
133
- keyring.delete_password(SERVICE_ID, "refresh_token")
185
+ error_detail = "Unknown error"
186
+ try:
187
+ if exc.response.content:
188
+ content_type = exc.response.headers.get("content-type", "")
189
+ if "application/json" in content_type:
190
+ error_data = exc.response.json()
191
+ error_detail = error_data.get("detail", str(error_data))
192
+ except Exception:
193
+ error_detail = exc.response.text[:200] if exc.response.text else "Unknown error"
194
+
195
+ console.print(
196
+ f"[bold red]Token refresh failed: {exc.response.status_code} - {error_detail}[/bold red]"
197
+ )
198
+
199
+ # Clear tokens on refresh failure
200
+ try:
201
+ keyring.delete_password(SERVICE_ID, "access_token")
202
+ keyring.delete_password(SERVICE_ID, "refresh_token")
203
+ except keyring.errors.KeyringError:
204
+ pass # Ignore errors when clearing
134
205
  return None
135
- except httpx.RequestError as exc:
136
- console.print(f"[bold red]Token refresh failed: {exc}[/bold red]")
206
+ except ValueError as e:
207
+ console.print(f"[bold red]Token refresh failed: {e}[/bold red]")
208
+ return None
209
+ except Exception as e:
210
+ console.print(
211
+ f"[bold red]Token refresh failed: Unexpected error - {type(e).__name__}[/bold red]"
212
+ )
137
213
  return None
138
214
 
139
215
  return access_token
@@ -146,3 +222,5 @@ def clear_tokens():
146
222
  keyring.delete_password(SERVICE_ID, "refresh_token")
147
223
  except keyring.errors.PasswordDeleteError:
148
224
  pass # Tokens already cleared
225
+ except keyring.errors.KeyringError as e:
226
+ console.print(f"[yellow]Warning: Could not clear tokens from keyring: {e}[/yellow]")
xenfra/utils/codebase.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """
2
2
  Codebase scanning utilities for AI-powered project initialization.
3
3
  """
4
+
4
5
  import os
5
6
  from pathlib import Path
6
7
 
@@ -9,6 +10,26 @@ def scan_codebase(max_files: int = 10, max_size: int = 50000) -> dict[str, str]:
9
10
  """
10
11
  Scan current directory for important code files.
11
12
 
13
+ Args:
14
+ max_files: Maximum number of files to include (validated: 1-100)
15
+ max_size: Maximum file size in bytes (validated: 1KB-10MB)
16
+
17
+ Returns:
18
+ Dictionary of filename -> content for AI analysis
19
+ """
20
+ # Validate limits
21
+ from .validation import validate_codebase_scan_limits
22
+
23
+ is_valid, error_msg = validate_codebase_scan_limits(max_files, max_size)
24
+ if not is_valid:
25
+ raise ValueError(f"Invalid scan limits: {error_msg}")
26
+
27
+ # Ensure limits are within bounds
28
+ max_files = max(1, min(100, max_files))
29
+ max_size = max(1024, min(10 * 1024 * 1024, max_size))
30
+ """
31
+ Scan current directory for important code files.
32
+
12
33
  Args:
13
34
  max_files: Maximum number of files to include
14
35
  max_size: Maximum file size in bytes (default 50KB)
@@ -21,15 +42,25 @@ def scan_codebase(max_files: int = 10, max_size: int = 50000) -> dict[str, str]:
21
42
  # Priority files to scan (in order)
22
43
  important_files = [
23
44
  # Python entry points
24
- 'main.py', 'app.py', 'wsgi.py', 'asgi.py', 'manage.py',
45
+ "main.py",
46
+ "app.py",
47
+ "wsgi.py",
48
+ "asgi.py",
49
+ "manage.py",
25
50
  # Configuration files
26
- 'requirements.txt', 'pyproject.toml', 'Pipfile', 'setup.py',
51
+ "requirements.txt",
52
+ "pyproject.toml",
53
+ "Pipfile",
54
+ "setup.py",
27
55
  # Django/Flask specific
28
- 'settings.py', 'config.py',
56
+ "settings.py",
57
+ "config.py",
29
58
  # Docker
30
- 'Dockerfile', 'docker-compose.yml',
59
+ "Dockerfile",
60
+ "docker-compose.yml",
31
61
  # Xenfra config
32
- 'xenfra.yaml', 'xenfra.yml',
62
+ "xenfra.yaml",
63
+ "xenfra.yml",
33
64
  ]
34
65
 
35
66
  # Scan for important files in current directory
@@ -43,26 +74,29 @@ def scan_codebase(max_files: int = 10, max_size: int = 50000) -> dict[str, str]:
43
74
  if file_size > max_size:
44
75
  continue
45
76
 
46
- with open(filename, 'r', encoding='utf-8') as f:
77
+ with open(filename, "r", encoding="utf-8") as f:
47
78
  content = f.read(max_size)
48
79
  code_snippets[filename] = content
49
- except (IOError, UnicodeDecodeError):
50
- # Skip files that can't be read
80
+ except (IOError, OSError, PermissionError, UnicodeDecodeError) as e:
81
+ # Skip files that can't be read (log but don't crash)
82
+ import logging
83
+ logger = logging.getLogger(__name__)
84
+ logger.debug(f"Skipping file {filename}: {type(e).__name__}")
51
85
  continue
52
86
 
53
87
  # If we haven't found enough files, look for Python files in common locations
54
88
  if len(code_snippets) < 3:
55
89
  search_patterns = [
56
- 'src/**/*.py',
57
- 'app/**/*.py',
58
- '*.py',
90
+ "src/**/*.py",
91
+ "app/**/*.py",
92
+ "*.py",
59
93
  ]
60
94
 
61
95
  for pattern in search_patterns:
62
96
  if len(code_snippets) >= max_files:
63
97
  break
64
98
 
65
- for filepath in Path('.').glob(pattern):
99
+ for filepath in Path(".").glob(pattern):
66
100
  if len(code_snippets) >= max_files:
67
101
  break
68
102
 
@@ -72,10 +106,14 @@ def scan_codebase(max_files: int = 10, max_size: int = 50000) -> dict[str, str]:
72
106
  if file_size > max_size:
73
107
  continue
74
108
 
75
- with open(filepath, 'r', encoding='utf-8') as f:
109
+ with open(filepath, "r", encoding="utf-8") as f:
76
110
  content = f.read(max_size)
77
111
  code_snippets[str(filepath)] = content
78
- except (IOError, UnicodeDecodeError):
112
+ except (IOError, OSError, PermissionError, UnicodeDecodeError) as e:
113
+ # Skip files that can't be read
114
+ import logging
115
+ logger = logging.getLogger(__name__)
116
+ logger.debug(f"Skipping file {filepath}: {type(e).__name__}")
79
117
  continue
80
118
 
81
119
  return code_snippets
@@ -83,4 +121,4 @@ def scan_codebase(max_files: int = 10, max_size: int = 50000) -> dict[str, str]:
83
121
 
84
122
  def has_xenfra_config() -> bool:
85
123
  """Check if xenfra.yaml already exists."""
86
- return os.path.exists('xenfra.yaml') or os.path.exists('xenfra.yml')
124
+ return os.path.exists("xenfra.yaml") or os.path.exists("xenfra.yml")