xenfra 0.2.4__py3-none-any.whl → 0.2.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xenfra/utils/config.py CHANGED
@@ -1,20 +1,38 @@
1
1
  """
2
2
  Configuration file generation utilities.
3
3
  """
4
+
4
5
  import os
5
6
  import shutil
6
7
  from datetime import datetime
7
8
  from pathlib import Path
8
9
 
10
+ import click
9
11
  import yaml
12
+ from rich.console import Console
10
13
  from rich.prompt import Confirm, IntPrompt, Prompt
11
14
  from xenfra_sdk import CodebaseAnalysisResponse
12
15
 
16
+ console = Console()
17
+
13
18
 
14
19
  def read_xenfra_yaml(filename: str = "xenfra.yaml") -> dict:
15
20
  """
16
21
  Read and parse xenfra.yaml configuration file.
17
22
 
23
+ Args:
24
+ filename: Path to the config file (default: xenfra.yaml)
25
+
26
+ Returns:
27
+ Dictionary containing the configuration
28
+
29
+ Raises:
30
+ FileNotFoundError: If the config file doesn't exist
31
+ yaml.YAMLError: If the YAML is malformed
32
+ """
33
+ """
34
+ Read and parse xenfra.yaml configuration file.
35
+
18
36
  Args:
19
37
  filename: Path to the config file (default: xenfra.yaml)
20
38
 
@@ -25,10 +43,17 @@ def read_xenfra_yaml(filename: str = "xenfra.yaml") -> dict:
25
43
  FileNotFoundError: If the config file doesn't exist
26
44
  """
27
45
  if not Path(filename).exists():
28
- raise FileNotFoundError(f"Configuration file '{filename}' not found. Run 'xenfra init' first.")
46
+ raise FileNotFoundError(
47
+ f"Configuration file '{filename}' not found. Run 'xenfra init' first."
48
+ )
29
49
 
30
- with open(filename, 'r') as f:
31
- return yaml.safe_load(f) or {}
50
+ try:
51
+ with open(filename, "r") as f:
52
+ return yaml.safe_load(f) or {}
53
+ except yaml.YAMLError as e:
54
+ raise ValueError(f"Invalid YAML in {filename}: {e}")
55
+ except Exception as e:
56
+ raise IOError(f"Failed to read {filename}: {e}")
32
57
 
33
58
 
34
59
  def generate_xenfra_yaml(analysis: CodebaseAnalysisResponse, filename: str = "xenfra.yaml") -> str:
@@ -44,44 +69,38 @@ def generate_xenfra_yaml(analysis: CodebaseAnalysisResponse, filename: str = "xe
44
69
  """
45
70
  # Build configuration dictionary
46
71
  config = {
47
- 'name': os.path.basename(os.getcwd()),
48
- 'framework': analysis.framework,
49
- 'port': analysis.port,
72
+ "name": os.path.basename(os.getcwd()),
73
+ "framework": analysis.framework,
74
+ "port": analysis.port,
50
75
  }
51
76
 
52
77
  # Add database configuration if detected
53
- if analysis.database and analysis.database != 'none':
54
- config['database'] = {
55
- 'type': analysis.database,
56
- 'env_var': 'DATABASE_URL'
57
- }
78
+ if analysis.database and analysis.database != "none":
79
+ config["database"] = {"type": analysis.database, "env_var": "DATABASE_URL"}
58
80
 
59
81
  # Add cache configuration if detected
60
- if analysis.cache and analysis.cache != 'none':
61
- config['cache'] = {
62
- 'type': analysis.cache,
63
- 'env_var': f"{analysis.cache.upper()}_URL"
64
- }
82
+ if analysis.cache and analysis.cache != "none":
83
+ config["cache"] = {"type": analysis.cache, "env_var": f"{analysis.cache.upper()}_URL"}
65
84
 
66
85
  # Add worker configuration if detected
67
86
  if analysis.workers and len(analysis.workers) > 0:
68
- config['workers'] = analysis.workers
87
+ config["workers"] = analysis.workers
69
88
 
70
89
  # Add environment variables
71
90
  if analysis.env_vars and len(analysis.env_vars) > 0:
72
- config['env_vars'] = analysis.env_vars
91
+ config["env_vars"] = analysis.env_vars
73
92
 
74
93
  # Add instance size
75
- config['instance_size'] = analysis.instance_size
94
+ config["instance_size"] = analysis.instance_size
76
95
 
77
96
  # Add package manager info (for intelligent diagnosis)
78
97
  if analysis.package_manager:
79
- config['package_manager'] = analysis.package_manager
98
+ config["package_manager"] = analysis.package_manager
80
99
  if analysis.dependency_file:
81
- config['dependency_file'] = analysis.dependency_file
100
+ config["dependency_file"] = analysis.dependency_file
82
101
 
83
102
  # Write to file
84
- with open(filename, 'w') as f:
103
+ with open(filename, "w") as f:
85
104
  yaml.dump(config, f, sort_keys=False, default_flow_style=False)
86
105
 
87
106
  return filename
@@ -116,6 +135,36 @@ def apply_patch(patch: dict, target_file: str = None, create_backup_file: bool =
116
135
  """
117
136
  Apply a JSON patch to a configuration file with automatic backup.
118
137
 
138
+ Args:
139
+ patch: Patch object with file, operation, path, value
140
+ target_file: Optional override for the file to patch
141
+ create_backup_file: Whether to create a backup before patching (default: True)
142
+
143
+ Returns:
144
+ Path to the backup file if created, None otherwise
145
+
146
+ Raises:
147
+ ValueError: If patch structure is invalid
148
+ FileNotFoundError: If target file doesn't exist
149
+ NotImplementedError: If file type is not supported
150
+ """
151
+ # Validate patch structure
152
+ if not isinstance(patch, dict):
153
+ raise ValueError("Patch must be a dictionary")
154
+
155
+ required_fields = ["file", "operation"]
156
+ for field in required_fields:
157
+ if field not in patch:
158
+ raise ValueError(f"Patch missing required field: {field}")
159
+
160
+ operation = patch.get("operation")
161
+ if operation not in ["add", "replace", "remove"]:
162
+ raise ValueError(
163
+ f"Invalid patch operation: {operation}. Must be 'add', 'replace', or 'remove'"
164
+ )
165
+ """
166
+ Apply a JSON patch to a configuration file with automatic backup.
167
+
119
168
  Args:
120
169
  patch: Patch object with file, operation, path, value
121
170
  target_file: Optional override for the file to patch
@@ -124,7 +173,7 @@ def apply_patch(patch: dict, target_file: str = None, create_backup_file: bool =
124
173
  Returns:
125
174
  Path to the backup file if created, None otherwise
126
175
  """
127
- file_to_patch = target_file or patch.get('file')
176
+ file_to_patch = target_file or patch.get("file")
128
177
 
129
178
  if not file_to_patch:
130
179
  raise ValueError("No target file specified in patch")
@@ -138,19 +187,19 @@ def apply_patch(patch: dict, target_file: str = None, create_backup_file: bool =
138
187
  backup_path = create_backup(file_to_patch)
139
188
 
140
189
  # For YAML files
141
- if file_to_patch.endswith(('.yaml', '.yml')):
142
- with open(file_to_patch, 'r') as f:
190
+ if file_to_patch.endswith((".yaml", ".yml")):
191
+ with open(file_to_patch, "r") as f:
143
192
  config_data = yaml.safe_load(f) or {}
144
193
 
145
194
  # Apply patch based on operation
146
- operation = patch.get('operation')
147
- path = patch.get('path', '').strip('/')
148
- value = patch.get('value')
195
+ operation = patch.get("operation")
196
+ path = patch.get("path", "").strip("/")
197
+ value = patch.get("value")
149
198
 
150
- if operation == 'add':
199
+ if operation == "add":
151
200
  # For simple paths, add to root
152
201
  if path:
153
- path_parts = path.split('/')
202
+ path_parts = path.split("/")
154
203
  current = config_data
155
204
  for part in path_parts[:-1]:
156
205
  if part not in current:
@@ -164,9 +213,9 @@ def apply_patch(patch: dict, target_file: str = None, create_backup_file: bool =
164
213
  else:
165
214
  config_data = value
166
215
 
167
- elif operation == 'replace':
216
+ elif operation == "replace":
168
217
  if path:
169
- path_parts = path.split('/')
218
+ path_parts = path.split("/")
170
219
  current = config_data
171
220
  for part in path_parts[:-1]:
172
221
  current = current[part]
@@ -175,23 +224,66 @@ def apply_patch(patch: dict, target_file: str = None, create_backup_file: bool =
175
224
  config_data = value
176
225
 
177
226
  # Write back
178
- with open(file_to_patch, 'w') as f:
227
+ with open(file_to_patch, "w") as f:
179
228
  yaml.dump(config_data, f, sort_keys=False, default_flow_style=False)
180
229
 
230
+ # For JSON files
231
+ elif file_to_patch.endswith(".json"):
232
+ import json
233
+
234
+ with open(file_to_patch, "r") as f:
235
+ config_data = json.load(f)
236
+
237
+ operation = patch.get("operation")
238
+ path = patch.get("path", "").strip("/")
239
+ value = patch.get("value")
240
+
241
+ if operation == "add":
242
+ if path:
243
+ path_parts = path.split("/")
244
+ current = config_data
245
+ for part in path_parts[:-1]:
246
+ if part not in current:
247
+ current[part] = {}
248
+ current = current[part]
249
+ current[path_parts[-1]] = value
250
+ else:
251
+ if isinstance(value, dict):
252
+ config_data.update(value)
253
+ else:
254
+ config_data = value
255
+
256
+ elif operation == "replace":
257
+ if path:
258
+ path_parts = path.split("/")
259
+ current = config_data
260
+ for part in path_parts[:-1]:
261
+ current = current[part]
262
+ current[path_parts[-1]] = value
263
+ else:
264
+ config_data = value
265
+
266
+ # Write back
267
+ with open(file_to_patch, "w") as f:
268
+ json.dump(config_data, f, indent=2)
269
+
181
270
  # For text files (like requirements.txt)
182
- elif file_to_patch.endswith('.txt'):
183
- operation = patch.get('operation')
184
- value = patch.get('value')
271
+ elif file_to_patch.endswith(".txt"):
272
+ operation = patch.get("operation")
273
+ value = patch.get("value")
185
274
 
186
- if operation == 'add':
275
+ if operation == "add":
187
276
  # Append to file
188
- with open(file_to_patch, 'a') as f:
277
+ with open(file_to_patch, "a") as f:
189
278
  f.write(f"\n{value}\n")
190
- elif operation == 'replace':
279
+ elif operation == "replace":
191
280
  # Replace entire file
192
- with open(file_to_patch, 'w') as f:
281
+ with open(file_to_patch, "w") as f:
193
282
  f.write(str(value))
194
283
  else:
284
+ # Design decision: Only support auto-patching for common dependency files
285
+ # Other file types should be manually edited to avoid data loss
286
+ # See docs/future-enhancements.md #4 for potential extensions
195
287
  raise NotImplementedError(f"Patching not supported for file type: {file_to_patch}")
196
288
 
197
289
  return backup_path
@@ -211,19 +303,24 @@ def manual_prompt_for_config(filename: str = "xenfra.yaml") -> str:
211
303
 
212
304
  # Project name (default to directory name)
213
305
  default_name = os.path.basename(os.getcwd())
214
- config['name'] = Prompt.ask("Project name", default=default_name)
306
+ config["name"] = Prompt.ask("Project name", default=default_name)
215
307
 
216
308
  # Framework
217
309
  framework = Prompt.ask(
218
- "Framework",
219
- choices=["fastapi", "flask", "django", "other"],
220
- default="fastapi"
310
+ "Framework", choices=["fastapi", "flask", "django", "other"], default="fastapi"
221
311
  )
222
- config['framework'] = framework
312
+ config["framework"] = framework
223
313
 
224
314
  # Port
225
315
  port = IntPrompt.ask("Application port", default=8000)
226
- config['port'] = port
316
+ # Validate port
317
+ from .validation import validate_port
318
+
319
+ is_valid, error_msg = validate_port(port)
320
+ if not is_valid:
321
+ console.print(f"[bold red]Invalid port: {error_msg}[/bold red]")
322
+ raise click.Abort()
323
+ config["port"] = port
227
324
 
228
325
  # Database
229
326
  use_database = Confirm.ask("Does your app use a database?", default=False)
@@ -231,33 +328,21 @@ def manual_prompt_for_config(filename: str = "xenfra.yaml") -> str:
231
328
  db_type = Prompt.ask(
232
329
  "Database type",
233
330
  choices=["postgresql", "mysql", "sqlite", "mongodb"],
234
- default="postgresql"
331
+ default="postgresql",
235
332
  )
236
- config['database'] = {
237
- 'type': db_type,
238
- 'env_var': 'DATABASE_URL'
239
- }
333
+ config["database"] = {"type": db_type, "env_var": "DATABASE_URL"}
240
334
 
241
335
  # Cache
242
336
  use_cache = Confirm.ask("Does your app use caching?", default=False)
243
337
  if use_cache:
244
- cache_type = Prompt.ask(
245
- "Cache type",
246
- choices=["redis", "memcached"],
247
- default="redis"
248
- )
249
- config['cache'] = {
250
- 'type': cache_type,
251
- 'env_var': f"{cache_type.upper()}_URL"
252
- }
338
+ cache_type = Prompt.ask("Cache type", choices=["redis", "memcached"], default="redis")
339
+ config["cache"] = {"type": cache_type, "env_var": f"{cache_type.upper()}_URL"}
253
340
 
254
341
  # Instance size
255
342
  instance_size = Prompt.ask(
256
- "Instance size",
257
- choices=["basic", "standard", "premium"],
258
- default="basic"
343
+ "Instance size", choices=["basic", "standard", "premium"], default="basic"
259
344
  )
260
- config['instance_size'] = instance_size
345
+ config["instance_size"] = instance_size
261
346
 
262
347
  # Environment variables
263
348
  add_env = Confirm.ask("Add environment variables?", default=False)
@@ -269,10 +354,10 @@ def manual_prompt_for_config(filename: str = "xenfra.yaml") -> str:
269
354
  break
270
355
  env_vars.append(env_var)
271
356
  if env_vars:
272
- config['env_vars'] = env_vars
357
+ config["env_vars"] = env_vars
273
358
 
274
359
  # Write to file
275
- with open(filename, 'w') as f:
360
+ with open(filename, "w") as f:
276
361
  yaml.dump(config, f, sort_keys=False, default_flow_style=False)
277
362
 
278
363
  return filename
xenfra/utils/security.py CHANGED
@@ -2,6 +2,7 @@
2
2
  Security utilities for Xenfra CLI.
3
3
  Implements comprehensive URL validation, domain whitelisting, HTTPS enforcement, and certificate pinning.
4
4
  """
5
+
5
6
  import os
6
7
  import ssl
7
8
  from urllib.parse import urlparse
@@ -14,23 +15,25 @@ from rich.console import Console
14
15
  console = Console()
15
16
 
16
17
  # Production API URL
17
- PRODUCTION_API_URL = "https://api.xenfra.com" # TODO: Update with real production URL
18
+ PRODUCTION_API_URL = "https://api.xenfra.tech"
18
19
 
19
20
  # Allowed domains (whitelist) - Solution 2
20
21
  ALLOWED_DOMAINS = [
21
- "api.xenfra.com", # Production
22
- "api-staging.xenfra.com", # Staging
23
- "localhost", # Local development
24
- "127.0.0.1", # Local development (IP)
22
+ "api.xenfra.tech", # Production
23
+ "api-staging.xenfra.tech", # Staging
24
+ "localhost", # Local development
25
+ "127.0.0.1", # Local development (IP)
25
26
  ]
26
27
 
27
28
  # Certificate fingerprints for pinning - Solution 4
28
29
  # These should be updated when certificates are rotated
29
30
  PINNED_CERTIFICATES = {
30
- "api.xenfra.com": {
31
+ "api.xenfra.tech": {
31
32
  # SHA256 fingerprint of the expected certificate
32
33
  # Example: "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
33
- # To get: openssl s_client -connect api.xenfra.com:443 | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64
34
+ # To get fingerprint, run:
35
+ # openssl s_client -connect api.xenfra.tech:443 | openssl x509 -pubkey -noout \
36
+ # | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64
34
37
  "fingerprints": [], # Add actual fingerprints when you have production cert
35
38
  }
36
39
  }
@@ -47,7 +50,9 @@ class SecurityConfig:
47
50
  # Security settings (can be overridden by environment variables)
48
51
  self.enforce_https = os.getenv("XENFRA_ENFORCE_HTTPS", "false").lower() == "true"
49
52
  self.enforce_whitelist = os.getenv("XENFRA_ENFORCE_WHITELIST", "false").lower() == "true"
50
- self.enable_cert_pinning = os.getenv("XENFRA_ENABLE_CERT_PINNING", "false").lower() == "true"
53
+ self.enable_cert_pinning = (
54
+ os.getenv("XENFRA_ENABLE_CERT_PINNING", "false").lower() == "true"
55
+ )
51
56
  self.warn_on_http = os.getenv("XENFRA_WARN_ON_HTTP", "true").lower() == "true"
52
57
 
53
58
  # Auto-enable strict security in production
@@ -88,8 +93,7 @@ def validate_url_format(url: str) -> dict:
88
93
  # Check scheme
89
94
  if parsed.scheme not in ["http", "https"]:
90
95
  raise ValueError(
91
- f"Invalid URL scheme '{parsed.scheme}'. "
92
- f"Only 'http' and 'https' are allowed."
96
+ f"Invalid URL scheme '{parsed.scheme}'. " f"Only 'http' and 'https' are allowed."
93
97
  )
94
98
 
95
99
  # Check hostname exists
@@ -108,7 +112,7 @@ def validate_url_format(url: str) -> dict:
108
112
  "scheme": parsed.scheme,
109
113
  "hostname": parsed.hostname,
110
114
  "port": parsed.port,
111
- "url": url
115
+ "url": url,
112
116
  }
113
117
 
114
118
  except Exception as e:
@@ -145,12 +149,8 @@ def check_domain_whitelist(hostname: str) -> bool:
145
149
  console.print(
146
150
  f"[yellow]⚠️ Warning: Domain '{hostname}' is not in the official whitelist.[/yellow]"
147
151
  )
148
- console.print(
149
- f"[dim]Whitelisted domains: {', '.join(ALLOWED_DOMAINS)}[/dim]"
150
- )
151
- console.print(
152
- f"[yellow]Are you sure you want to connect to this API?[/yellow]"
153
- )
152
+ console.print(f"[dim]Whitelisted domains: {', '.join(ALLOWED_DOMAINS)}[/dim]")
153
+ console.print("[yellow]Are you sure you want to connect to this API?[/yellow]")
154
154
 
155
155
  if not click.confirm("Continue?", default=False):
156
156
  raise click.Abort()
@@ -220,8 +220,8 @@ def create_secure_client(url: str, token: str = None) -> httpx.Client:
220
220
  ssl_context.verify_mode = ssl.CERT_REQUIRED
221
221
 
222
222
  # Note: Full certificate pinning implementation would require custom verification
223
- # For now, we use strict certificate validation
224
- # TODO: Implement actual fingerprint verification if needed
223
+ # For now, we use strict certificate validation with system CA bundle
224
+ # Future enhancement: Certificate fingerprint pinning (see docs/future-enhancements.md #3)
225
225
 
226
226
  return httpx.Client(
227
227
  base_url=url,
@@ -285,7 +285,7 @@ def validate_and_get_api_url(url: str = None) -> str:
285
285
  return url
286
286
 
287
287
  except ValueError as e:
288
- console.print(f"[bold red]🔒 Security Validation Failed:[/bold red]")
288
+ console.print("[bold red]🔒 Security Validation Failed:[/bold red]")
289
289
  console.print(f"[red]{e}[/red]")
290
290
  raise click.Abort()
291
291
 
@@ -297,9 +297,15 @@ def display_security_info():
297
297
  table_data = [
298
298
  ("Environment", security_config.environment),
299
299
  ("HTTPS Enforcement", "✅ Enabled" if security_config.enforce_https else "⚠️ Disabled"),
300
- ("Domain Whitelist", "✅ Enforced" if security_config.enforce_whitelist else "⚠️ Warning Only"),
300
+ (
301
+ "Domain Whitelist",
302
+ "✅ Enforced" if security_config.enforce_whitelist else "⚠️ Warning Only",
303
+ ),
301
304
  ("HTTP Warning", "✅ Enabled" if security_config.warn_on_http else "❌ Disabled"),
302
- ("Certificate Pinning", "✅ Enabled" if security_config.enable_cert_pinning else "❌ Disabled"),
305
+ (
306
+ "Certificate Pinning",
307
+ "✅ Enabled" if security_config.enable_cert_pinning else "❌ Disabled",
308
+ ),
303
309
  ]
304
310
 
305
311
  for key, value in table_data:
@@ -346,5 +352,5 @@ XENFRA_API_URL=http://localhost:8000 xenfra login
346
352
  XENFRA_API_URL=https://xenfra.mycompany.com XENFRA_ENFORCE_WHITELIST=false xenfra login
347
353
 
348
354
  # Production (strict):
349
- XENFRA_ENV=production XENFRA_API_URL=https://api.xenfra.com xenfra login
355
+ XENFRA_ENV=production XENFRA_API_URL=https://api.xenfra.tech xenfra login
350
356
  """