xenfra 0.4.3__py3-none-any.whl → 0.4.4__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,436 +1,459 @@
1
- """
2
- Configuration file generation utilities.
3
- """
4
-
5
- import os
6
- import shutil
7
- from datetime import datetime
8
- from pathlib import Path
9
-
10
- import click
11
- import yaml
12
- from rich.console import Console
13
- from rich.prompt import Confirm, IntPrompt, Prompt
14
- from xenfra_sdk import CodebaseAnalysisResponse
15
-
16
- console = Console()
17
-
18
-
19
- def read_xenfra_yaml(filename: str = "xenfra.yaml") -> dict:
20
- """
21
- Read and parse xenfra.yaml configuration file.
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
- ValueError: If the YAML is invalid
33
- IOError: If reading fails
34
- """
35
- if not Path(filename).exists():
36
- raise FileNotFoundError(
37
- f"Configuration file '{filename}' not found. Run 'xenfra init' first."
38
- )
39
-
40
- try:
41
- with open(filename, "r") as f:
42
- return yaml.safe_load(f) or {}
43
- except yaml.YAMLError as e:
44
- raise ValueError(f"Invalid YAML in {filename}: {e}")
45
- except Exception as e:
46
- raise IOError(f"Failed to read {filename}: {e}")
47
-
48
-
49
- def generate_xenfra_yaml(analysis: CodebaseAnalysisResponse, filename: str = "xenfra.yaml", package_manager_override: str = None, dependency_file_override: str = None) -> str:
50
- """
51
- Generate xenfra.yaml from AI codebase analysis.
52
-
53
- Args:
54
- analysis: CodebaseAnalysisResponse from Intelligence Service
55
- filename: Output filename (default: xenfra.yaml)
56
- package_manager_override: Optional override for package manager (user selection)
57
- dependency_file_override: Optional override for dependency file (user selection)
58
-
59
- Returns:
60
- Path to the generated file
61
- """
62
- # Build configuration dictionary
63
- config = {
64
- "name": os.path.basename(os.getcwd()),
65
- "framework": analysis.framework,
66
- "region": "nyc3", # Default to NYC3
67
- "port": analysis.port,
68
- }
69
-
70
- # Add entrypoint if detected (e.g., "todo.main:app")
71
- if hasattr(analysis, 'entrypoint') and analysis.entrypoint:
72
- config["entrypoint"] = analysis.entrypoint
73
-
74
- # Add database configuration if detected
75
- if analysis.database and analysis.database != "none":
76
- config["database"] = {"type": analysis.database, "env_var": "DATABASE_URL"}
77
-
78
- # Add cache configuration if detected
79
- if analysis.cache and analysis.cache != "none":
80
- config["cache"] = {"type": analysis.cache, "env_var": f"{analysis.cache.upper()}_URL"}
81
-
82
- # Add worker configuration if detected
83
- if analysis.workers and len(analysis.workers) > 0:
84
- config["workers"] = analysis.workers
85
-
86
- # Add environment variables
87
- if analysis.env_vars and len(analysis.env_vars) > 0:
88
- config["env_vars"] = analysis.env_vars
89
-
90
- # Infrastructure configuration
91
- config["instance_size"] = analysis.instance_size
92
- config["resources"] = {
93
- "cpu": 1,
94
- "ram": "1GB"
95
- }
96
-
97
- # Map resources based on detected size for better defaults
98
- if analysis.instance_size == "standard":
99
- config["resources"]["cpu"] = 2
100
- config["resources"]["ram"] = "4GB"
101
- elif analysis.instance_size == "premium":
102
- config["resources"]["cpu"] = 4
103
- config["resources"]["ram"] = "8GB"
104
-
105
- # Add package manager info (use override if provided, otherwise use analysis)
106
- package_manager = package_manager_override or analysis.package_manager
107
- dependency_file = dependency_file_override or analysis.dependency_file
108
-
109
- if package_manager:
110
- config["package_manager"] = package_manager
111
- if dependency_file:
112
- config["dependency_file"] = dependency_file
113
-
114
- # Write to file
115
- with open(filename, "w") as f:
116
- yaml.dump(config, f, sort_keys=False, default_flow_style=False)
117
-
118
- return filename
119
-
120
-
121
- def create_backup(file_path: str) -> str:
122
- """
123
- Create a timestamped backup of a file in .xenfra/backups/ directory.
124
-
125
- Args:
126
- file_path: Path to the file to backup
127
-
128
- Returns:
129
- Path to the backup file
130
- """
131
- # Create .xenfra/backups directory if it doesn't exist
132
- backup_dir = Path(".xenfra") / "backups"
133
- backup_dir.mkdir(parents=True, exist_ok=True)
134
-
135
- # Generate timestamped backup filename
136
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
137
- file_name = Path(file_path).name
138
- backup_path = backup_dir / f"{file_name}.{timestamp}.backup"
139
-
140
- # Copy file to backup location
141
- shutil.copy2(file_path, backup_path)
142
-
143
- return str(backup_path)
144
-
145
-
146
- def apply_patch(patch: dict, target_file: str = None, create_backup_file: bool = True):
147
- """
148
- Apply a JSON patch to a configuration file with automatic backup.
149
-
150
- Args:
151
- patch: Patch object with file, operation, path, value
152
- target_file: Optional override for the file to patch
153
- create_backup_file: Whether to create a backup before patching (default: True)
154
-
155
- Returns:
156
- Path to the backup file if created, None otherwise
157
-
158
- Raises:
159
- ValueError: If patch structure is invalid
160
- FileNotFoundError: If target file doesn't exist
161
- NotImplementedError: If file type is not supported
162
- """
163
- # Validate patch structure
164
- if not isinstance(patch, dict):
165
- raise ValueError("Patch must be a dictionary")
166
-
167
- required_fields = ["file", "operation"]
168
- for field in required_fields:
169
- if field not in patch:
170
- raise ValueError(f"Patch missing required field: {field}")
171
-
172
- operation = patch.get("operation")
173
- if operation not in ["add", "replace", "remove"]:
174
- raise ValueError(
175
- f"Invalid patch operation: {operation}. Must be 'add', 'replace', or 'remove'"
176
- )
177
-
178
- file_to_patch = target_file or patch.get("file")
179
-
180
- if not file_to_patch:
181
- raise ValueError("No target file specified in patch")
182
-
183
- if not os.path.exists(file_to_patch):
184
- raise FileNotFoundError(f"File '{file_to_patch}' not found")
185
-
186
- # Create backup before modifying
187
- backup_path = None
188
- if create_backup_file:
189
- backup_path = create_backup(file_to_patch)
190
-
191
- # For YAML files
192
- if file_to_patch.endswith((".yaml", ".yml")):
193
- with open(file_to_patch, "r") as f:
194
- config_data = yaml.safe_load(f) or {}
195
-
196
- # Apply patch based on operation
197
- operation = patch.get("operation")
198
- path = (patch.get("path") or "").strip("/")
199
- value = patch.get("value")
200
-
201
- if operation == "add":
202
- # For simple paths, add to root
203
- if path:
204
- path_parts = path.split("/")
205
- current = config_data
206
- for part in path_parts[:-1]:
207
- if part not in current:
208
- current[part] = {}
209
- current = current[part]
210
- current[path_parts[-1]] = value
211
- else:
212
- # Add to root level
213
- if isinstance(value, dict):
214
- config_data.update(value)
215
- else:
216
- config_data = value
217
-
218
- elif operation == "replace":
219
- if path:
220
- path_parts = path.split("/")
221
- current = config_data
222
- for part in path_parts[:-1]:
223
- current = current[part]
224
- current[path_parts[-1]] = value
225
- else:
226
- config_data = value
227
-
228
- # Write back
229
- with open(file_to_patch, "w") as f:
230
- yaml.dump(config_data, f, sort_keys=False, default_flow_style=False)
231
-
232
- # For JSON files
233
- elif file_to_patch.endswith(".json"):
234
- import json
235
-
236
- with open(file_to_patch, "r") as f:
237
- config_data = json.load(f)
238
-
239
- operation = patch.get("operation")
240
- path = (patch.get("path") or "").strip("/")
241
- value = patch.get("value")
242
-
243
- if operation == "add":
244
- if path:
245
- path_parts = path.split("/")
246
- current = config_data
247
- for part in path_parts[:-1]:
248
- if part not in current:
249
- current[part] = {}
250
- current = current[part]
251
- current[path_parts[-1]] = value
252
- else:
253
- if isinstance(value, dict):
254
- config_data.update(value)
255
- else:
256
- config_data = value
257
-
258
- elif operation == "replace":
259
- if path:
260
- path_parts = path.split("/")
261
- current = config_data
262
- for part in path_parts[:-1]:
263
- current = current[part]
264
- current[path_parts[-1]] = value
265
- else:
266
- config_data = value
267
-
268
- # Write back
269
- with open(file_to_patch, "w") as f:
270
- json.dump(config_data, f, indent=2)
271
-
272
- # For text files (like requirements.txt)
273
- elif file_to_patch.endswith(".txt"):
274
- operation = patch.get("operation")
275
- value = patch.get("value")
276
-
277
- if operation == "add":
278
- # Append to file
279
- with open(file_to_patch, "a") as f:
280
- f.write(f"\n{value}\n")
281
- elif operation == "replace":
282
- # Replace entire file
283
- with open(file_to_patch, "w") as f:
284
- f.write(str(value))
285
-
286
- # For TOML files (pyproject.toml)
287
- elif file_to_patch.endswith(".toml"):
288
- import toml
289
-
290
- with open(file_to_patch, "r") as f:
291
- config_data = toml.load(f)
292
-
293
- operation = patch.get("operation")
294
- path = (patch.get("path") or "").strip("/")
295
- value = patch.get("value")
296
-
297
- if operation == "add":
298
- # Special case for pyproject.toml dependencies
299
- is_pyproject = os.path.basename(file_to_patch) == "pyproject.toml"
300
- if is_pyproject and (not path or path == "project/dependencies"):
301
- # Ensure project and dependencies exist
302
- if "project" not in config_data:
303
- config_data["project"] = {}
304
- if "dependencies" not in config_data["project"]:
305
- config_data["project"]["dependencies"] = []
306
-
307
- # Add value if not already present
308
- if value not in config_data["project"]["dependencies"]:
309
- config_data["project"]["dependencies"].append(value)
310
- elif path:
311
- path_parts = path.split("/")
312
- current = config_data
313
- for part in path_parts[:-1]:
314
- if part not in current:
315
- current[part] = {}
316
- current = current[part]
317
-
318
- # If target is a list (like dependencies), append
319
- target_key = path_parts[-1]
320
- if target_key in current and isinstance(current[target_key], list):
321
- if value not in current[target_key]:
322
- current[target_key].append(value)
323
- else:
324
- current[target_key] = value
325
- else:
326
- # Root level add
327
- if isinstance(value, dict):
328
- config_data.update(value)
329
- else:
330
- # Ignore root-level non-dict adds for structured files
331
- # to prevent overwriting the entire config with a string
332
- pass
333
-
334
- elif operation == "replace":
335
- if path:
336
- path_parts = path.split("/")
337
- current = config_data
338
- for part in path_parts[:-1]:
339
- current = current[part]
340
- current[path_parts[-1]] = value
341
- else:
342
- config_data = value
343
-
344
- # Write back
345
- with open(file_to_patch, "w") as f:
346
- toml.dump(config_data, f)
347
- else:
348
- # Design decision: Only support auto-patching for common dependency files
349
- # Other file types should be manually edited to avoid data loss
350
- # See docs/future-enhancements.md #4 for potential extensions
351
- raise NotImplementedError(f"Patching not supported for file type: {file_to_patch}")
352
-
353
- return backup_path
354
-
355
-
356
- def manual_prompt_for_config(filename: str = "xenfra.yaml") -> str:
357
- """
358
- Prompt user interactively for configuration details and generate xenfra.yaml.
359
-
360
- Args:
361
- filename: Output filename (default: xenfra.yaml)
362
-
363
- Returns:
364
- Path to the generated file
365
- """
366
- config = {}
367
-
368
- # Project name (default to directory name)
369
- default_name = os.path.basename(os.getcwd())
370
- config["name"] = Prompt.ask("Project name", default=default_name)
371
-
372
- # Framework
373
- framework = Prompt.ask(
374
- "Framework", choices=["fastapi", "flask", "django", "other"], default="fastapi"
375
- )
376
- config["framework"] = framework
377
-
378
- # Port
379
- port = IntPrompt.ask("Application port", default=8000)
380
- # Validate port
381
- from .validation import validate_port
382
-
383
- is_valid, error_msg = validate_port(port)
384
- if not is_valid:
385
- console.print(f"[bold red]Invalid port: {error_msg}[/bold red]")
386
- raise click.Abort()
387
- config["port"] = port
388
-
389
- # Database
390
- use_database = Confirm.ask("Does your app use a database?", default=False)
391
- if use_database:
392
- db_type = Prompt.ask(
393
- "Database type",
394
- choices=["postgresql", "mysql", "sqlite", "mongodb"],
395
- default="postgresql",
396
- )
397
- config["database"] = {"type": db_type, "env_var": "DATABASE_URL"}
398
-
399
- # Cache
400
- use_cache = Confirm.ask("Does your app use caching?", default=False)
401
- if use_cache:
402
- cache_type = Prompt.ask("Cache type", choices=["redis", "memcached"], default="redis")
403
- config["cache"] = {"type": cache_type, "env_var": f"{cache_type.upper()}_URL"}
404
-
405
- # Region
406
- config["region"] = Prompt.ask("Region", choices=["nyc3", "sfo3", "ams3", "fra1", "lon1"], default="nyc3")
407
-
408
- # Instance size
409
- instance_size = Prompt.ask(
410
- "Instance size", choices=["basic", "standard", "premium"], default="basic"
411
- )
412
- config["instance_size"] = instance_size
413
-
414
- # Resources (CPU/RAM)
415
- config["resources"] = {
416
- "cpu": IntPrompt.ask("CPU (vCPUs)", default=1 if instance_size == "basic" else 2),
417
- "ram": Prompt.ask("RAM (e.g., 1GB, 4GB)", default="1GB" if instance_size == "basic" else "4GB"),
418
- }
419
-
420
- # Environment variables
421
- add_env = Confirm.ask("Add environment variables?", default=False)
422
- if add_env:
423
- env_vars = []
424
- while True:
425
- env_var = Prompt.ask("Environment variable name (blank to finish)", default="")
426
- if not env_var:
427
- break
428
- env_vars.append(env_var)
429
- if env_vars:
430
- config["env_vars"] = env_vars
431
-
432
- # Write to file
433
- with open(filename, "w") as f:
434
- yaml.dump(config, f, sort_keys=False, default_flow_style=False)
435
-
436
- return filename
1
+ """
2
+ Configuration file generation utilities.
3
+ """
4
+
5
+ import os
6
+ import shutil
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ import click
11
+ import yaml
12
+ from rich.console import Console
13
+ from rich.prompt import Confirm, IntPrompt, Prompt
14
+ from xenfra_sdk import CodebaseAnalysisResponse
15
+
16
+ console = Console()
17
+
18
+
19
+ def read_xenfra_yaml(filename: str = "xenfra.yaml") -> dict:
20
+ """
21
+ Read and parse xenfra.yaml configuration file.
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
+ ValueError: If the YAML is invalid
33
+ IOError: If reading fails
34
+ """
35
+ if not Path(filename).exists():
36
+ raise FileNotFoundError(
37
+ f"Configuration file '{filename}' not found. Run 'xenfra init' first."
38
+ )
39
+
40
+ try:
41
+ with open(filename, "r") as f:
42
+ return yaml.safe_load(f) or {}
43
+ except yaml.YAMLError as e:
44
+ raise ValueError(f"Invalid YAML in {filename}: {e}")
45
+ except Exception as e:
46
+ raise IOError(f"Failed to read {filename}: {e}")
47
+
48
+
49
+ def generate_xenfra_yaml(analysis: CodebaseAnalysisResponse, filename: str = "xenfra.yaml", package_manager_override: str = None, dependency_file_override: str = None) -> str:
50
+ """
51
+ Generate xenfra.yaml from AI codebase analysis.
52
+
53
+ Args:
54
+ analysis: CodebaseAnalysisResponse from Intelligence Service
55
+ filename: Output filename (default: xenfra.yaml)
56
+ package_manager_override: Optional override for package manager (user selection)
57
+ dependency_file_override: Optional override for dependency file (user selection)
58
+
59
+ Returns:
60
+ Path to the generated file
61
+ """
62
+ # Build configuration dictionary
63
+ config = {
64
+ "name": os.path.basename(os.getcwd()),
65
+ "framework": analysis.framework,
66
+ "region": "nyc3", # Default to NYC3
67
+ "port": analysis.port,
68
+ }
69
+
70
+ # Add entrypoint if detected (e.g., "todo.main:app")
71
+ if hasattr(analysis, 'entrypoint') and analysis.entrypoint:
72
+ config["entrypoint"] = analysis.entrypoint
73
+
74
+ # Add database configuration if detected
75
+ if analysis.database and analysis.database != "none":
76
+ config["database"] = {"type": analysis.database, "env_var": "DATABASE_URL"}
77
+
78
+ # Add cache configuration if detected
79
+ if analysis.cache and analysis.cache != "none":
80
+ config["cache"] = {"type": analysis.cache, "env_var": f"{analysis.cache.upper()}_URL"}
81
+
82
+ # Add worker configuration if detected
83
+ if analysis.workers and len(analysis.workers) > 0:
84
+ config["workers"] = analysis.workers
85
+
86
+ # Add environment variables
87
+ if analysis.env_vars and len(analysis.env_vars) > 0:
88
+ config["env_vars"] = analysis.env_vars
89
+
90
+ # Infrastructure configuration
91
+ config["instance_size"] = analysis.instance_size
92
+ config["resources"] = {
93
+ "cpu": 1,
94
+ "ram": "1GB"
95
+ }
96
+
97
+ # Map resources based on detected size for better defaults
98
+ if analysis.instance_size == "standard":
99
+ config["resources"]["cpu"] = 2
100
+ config["resources"]["ram"] = "4GB"
101
+ elif analysis.instance_size == "premium":
102
+ config["resources"]["cpu"] = 4
103
+ config["resources"]["ram"] = "8GB"
104
+
105
+ # Add package manager info (use override if provided, otherwise use analysis)
106
+ package_manager = package_manager_override or analysis.package_manager
107
+ dependency_file = dependency_file_override or analysis.dependency_file
108
+
109
+ if package_manager:
110
+ config["package_manager"] = package_manager
111
+ if dependency_file:
112
+ config["dependency_file"] = dependency_file
113
+
114
+ # Write to file
115
+ with open(filename, "w") as f:
116
+ yaml.dump(config, f, sort_keys=False, default_flow_style=False)
117
+
118
+ return filename
119
+
120
+
121
+ def create_backup(file_path: str) -> str:
122
+ """
123
+ Create a timestamped backup of a file in .xenfra/backups/ directory.
124
+
125
+ Args:
126
+ file_path: Path to the file to backup
127
+
128
+ Returns:
129
+ Path to the backup file
130
+ """
131
+ # Create .xenfra/backups directory if it doesn't exist
132
+ backup_dir = Path(".xenfra") / "backups"
133
+ backup_dir.mkdir(parents=True, exist_ok=True)
134
+
135
+ # Generate timestamped backup filename
136
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
137
+ file_name = Path(file_path).name
138
+ backup_path = backup_dir / f"{file_name}.{timestamp}.backup"
139
+
140
+ # Copy file to backup location
141
+ shutil.copy2(file_path, backup_path)
142
+
143
+ return str(backup_path)
144
+
145
+
146
+ def apply_patch(patch: dict, target_file: str = None, create_backup_file: bool = True):
147
+ """
148
+ Apply a JSON patch to a configuration file with automatic backup.
149
+
150
+ Args:
151
+ patch: Patch object with file, operation, path, value
152
+ target_file: Optional override for the file to patch
153
+ create_backup_file: Whether to create a backup before patching (default: True)
154
+
155
+ Returns:
156
+ Path to the backup file if created, None otherwise
157
+
158
+ Raises:
159
+ ValueError: If patch structure is invalid
160
+ FileNotFoundError: If target file doesn't exist
161
+ NotImplementedError: If file type is not supported
162
+ """
163
+ # Validate patch structure
164
+ if not isinstance(patch, dict):
165
+ raise ValueError("Patch must be a dictionary")
166
+
167
+ required_fields = ["file", "operation"]
168
+ for field in required_fields:
169
+ if field not in patch:
170
+ raise ValueError(f"Patch missing required field: {field}")
171
+
172
+ operation = patch.get("operation")
173
+ if operation not in ["add", "replace", "remove"]:
174
+ raise ValueError(
175
+ f"Invalid patch operation: {operation}. Must be 'add', 'replace', or 'remove'"
176
+ )
177
+
178
+ file_to_patch = target_file or patch.get("file")
179
+
180
+ if not file_to_patch:
181
+ raise ValueError("No target file specified in patch")
182
+
183
+ if not os.path.exists(file_to_patch):
184
+ # Path resolution fallback for multi-service projects
185
+ filename = os.path.basename(file_to_patch)
186
+ if os.path.exists(filename):
187
+ console.print(f"[dim]Note: Suggested path '{file_to_patch}' not found. Falling back to '{filename}'[/dim]")
188
+ file_to_patch = filename
189
+ else:
190
+ # Try to resolve via xenfra.yaml if available
191
+ try:
192
+ from .config import read_xenfra_yaml
193
+ config = read_xenfra_yaml()
194
+ if "services" in config:
195
+ for svc in config["services"]:
196
+ svc_path = svc.get("path", ".")
197
+ # If service path is '.' and we're looking for filename in it
198
+ potential_path = os.path.join(svc_path, filename) if svc_path != "." else filename
199
+ if os.path.exists(potential_path):
200
+ console.print(f"[dim]Note: Resolved '{file_to_patch}' to '{potential_path}' via xenfra.yaml[/dim]")
201
+ file_to_patch = potential_path
202
+ break
203
+ except Exception:
204
+ pass
205
+
206
+ if not os.path.exists(file_to_patch):
207
+ raise FileNotFoundError(f"File '{file_to_patch}' not found")
208
+
209
+ # Create backup before modifying
210
+ backup_path = None
211
+ if create_backup_file:
212
+ backup_path = create_backup(file_to_patch)
213
+
214
+ # For YAML files
215
+ if file_to_patch.endswith((".yaml", ".yml")):
216
+ with open(file_to_patch, "r") as f:
217
+ config_data = yaml.safe_load(f) or {}
218
+
219
+ # Apply patch based on operation
220
+ operation = patch.get("operation")
221
+ path = (patch.get("path") or "").strip("/")
222
+ value = patch.get("value")
223
+
224
+ if operation == "add":
225
+ # For simple paths, add to root
226
+ if path:
227
+ path_parts = path.split("/")
228
+ current = config_data
229
+ for part in path_parts[:-1]:
230
+ if part not in current:
231
+ current[part] = {}
232
+ current = current[part]
233
+ current[path_parts[-1]] = value
234
+ else:
235
+ # Add to root level
236
+ if isinstance(value, dict):
237
+ config_data.update(value)
238
+ else:
239
+ config_data = value
240
+
241
+ elif operation == "replace":
242
+ if path:
243
+ path_parts = path.split("/")
244
+ current = config_data
245
+ for part in path_parts[:-1]:
246
+ current = current[part]
247
+ current[path_parts[-1]] = value
248
+ else:
249
+ config_data = value
250
+
251
+ # Write back
252
+ with open(file_to_patch, "w") as f:
253
+ yaml.dump(config_data, f, sort_keys=False, default_flow_style=False)
254
+
255
+ # For JSON files
256
+ elif file_to_patch.endswith(".json"):
257
+ import json
258
+
259
+ with open(file_to_patch, "r") as f:
260
+ config_data = json.load(f)
261
+
262
+ operation = patch.get("operation")
263
+ path = (patch.get("path") or "").strip("/")
264
+ value = patch.get("value")
265
+
266
+ if operation == "add":
267
+ if path:
268
+ path_parts = path.split("/")
269
+ current = config_data
270
+ for part in path_parts[:-1]:
271
+ if part not in current:
272
+ current[part] = {}
273
+ current = current[part]
274
+ current[path_parts[-1]] = value
275
+ else:
276
+ if isinstance(value, dict):
277
+ config_data.update(value)
278
+ else:
279
+ config_data = value
280
+
281
+ elif operation == "replace":
282
+ if path:
283
+ path_parts = path.split("/")
284
+ current = config_data
285
+ for part in path_parts[:-1]:
286
+ current = current[part]
287
+ current[path_parts[-1]] = value
288
+ else:
289
+ config_data = value
290
+
291
+ # Write back
292
+ with open(file_to_patch, "w") as f:
293
+ json.dump(config_data, f, indent=2)
294
+
295
+ # For text files (like requirements.txt)
296
+ elif file_to_patch.endswith(".txt"):
297
+ operation = patch.get("operation")
298
+ value = patch.get("value")
299
+
300
+ if operation == "add":
301
+ # Append to file
302
+ with open(file_to_patch, "a") as f:
303
+ f.write(f"\n{value}\n")
304
+ elif operation == "replace":
305
+ # Replace entire file
306
+ with open(file_to_patch, "w") as f:
307
+ f.write(str(value))
308
+
309
+ # For TOML files (pyproject.toml)
310
+ elif file_to_patch.endswith(".toml"):
311
+ import toml
312
+
313
+ with open(file_to_patch, "r") as f:
314
+ config_data = toml.load(f)
315
+
316
+ operation = patch.get("operation")
317
+ path = (patch.get("path") or "").strip("/")
318
+ value = patch.get("value")
319
+
320
+ if operation == "add":
321
+ # Special case for pyproject.toml dependencies
322
+ is_pyproject = os.path.basename(file_to_patch) == "pyproject.toml"
323
+ if is_pyproject and (not path or path == "project/dependencies"):
324
+ # Ensure project and dependencies exist
325
+ if "project" not in config_data:
326
+ config_data["project"] = {}
327
+ if "dependencies" not in config_data["project"]:
328
+ config_data["project"]["dependencies"] = []
329
+
330
+ # Add value if not already present
331
+ if value not in config_data["project"]["dependencies"]:
332
+ config_data["project"]["dependencies"].append(value)
333
+ elif path:
334
+ path_parts = path.split("/")
335
+ current = config_data
336
+ for part in path_parts[:-1]:
337
+ if part not in current:
338
+ current[part] = {}
339
+ current = current[part]
340
+
341
+ # If target is a list (like dependencies), append
342
+ target_key = path_parts[-1]
343
+ if target_key in current and isinstance(current[target_key], list):
344
+ if value not in current[target_key]:
345
+ current[target_key].append(value)
346
+ else:
347
+ current[target_key] = value
348
+ else:
349
+ # Root level add
350
+ if isinstance(value, dict):
351
+ config_data.update(value)
352
+ else:
353
+ # Ignore root-level non-dict adds for structured files
354
+ # to prevent overwriting the entire config with a string
355
+ pass
356
+
357
+ elif operation == "replace":
358
+ if path:
359
+ path_parts = path.split("/")
360
+ current = config_data
361
+ for part in path_parts[:-1]:
362
+ current = current[part]
363
+ current[path_parts[-1]] = value
364
+ else:
365
+ config_data = value
366
+
367
+ # Write back
368
+ with open(file_to_patch, "w") as f:
369
+ toml.dump(config_data, f)
370
+ else:
371
+ # Design decision: Only support auto-patching for common dependency files
372
+ # Other file types should be manually edited to avoid data loss
373
+ # See docs/future-enhancements.md #4 for potential extensions
374
+ raise NotImplementedError(f"Patching not supported for file type: {file_to_patch}")
375
+
376
+ return backup_path
377
+
378
+
379
+ def manual_prompt_for_config(filename: str = "xenfra.yaml") -> str:
380
+ """
381
+ Prompt user interactively for configuration details and generate xenfra.yaml.
382
+
383
+ Args:
384
+ filename: Output filename (default: xenfra.yaml)
385
+
386
+ Returns:
387
+ Path to the generated file
388
+ """
389
+ config = {}
390
+
391
+ # Project name (default to directory name)
392
+ default_name = os.path.basename(os.getcwd())
393
+ config["name"] = Prompt.ask("Project name", default=default_name)
394
+
395
+ # Framework
396
+ framework = Prompt.ask(
397
+ "Framework", choices=["fastapi", "flask", "django", "other"], default="fastapi"
398
+ )
399
+ config["framework"] = framework
400
+
401
+ # Port
402
+ port = IntPrompt.ask("Application port", default=8000)
403
+ # Validate port
404
+ from .validation import validate_port
405
+
406
+ is_valid, error_msg = validate_port(port)
407
+ if not is_valid:
408
+ console.print(f"[bold red]Invalid port: {error_msg}[/bold red]")
409
+ raise click.Abort()
410
+ config["port"] = port
411
+
412
+ # Database
413
+ use_database = Confirm.ask("Does your app use a database?", default=False)
414
+ if use_database:
415
+ db_type = Prompt.ask(
416
+ "Database type",
417
+ choices=["postgresql", "mysql", "sqlite", "mongodb"],
418
+ default="postgresql",
419
+ )
420
+ config["database"] = {"type": db_type, "env_var": "DATABASE_URL"}
421
+
422
+ # Cache
423
+ use_cache = Confirm.ask("Does your app use caching?", default=False)
424
+ if use_cache:
425
+ cache_type = Prompt.ask("Cache type", choices=["redis", "memcached"], default="redis")
426
+ config["cache"] = {"type": cache_type, "env_var": f"{cache_type.upper()}_URL"}
427
+
428
+ # Region
429
+ config["region"] = Prompt.ask("Region", choices=["nyc3", "sfo3", "ams3", "fra1", "lon1"], default="nyc3")
430
+
431
+ # Instance size
432
+ instance_size = Prompt.ask(
433
+ "Instance size", choices=["basic", "standard", "premium"], default="basic"
434
+ )
435
+ config["instance_size"] = instance_size
436
+
437
+ # Resources (CPU/RAM)
438
+ config["resources"] = {
439
+ "cpu": IntPrompt.ask("CPU (vCPUs)", default=1 if instance_size == "basic" else 2),
440
+ "ram": Prompt.ask("RAM (e.g., 1GB, 4GB)", default="1GB" if instance_size == "basic" else "4GB"),
441
+ }
442
+
443
+ # Environment variables
444
+ add_env = Confirm.ask("Add environment variables?", default=False)
445
+ if add_env:
446
+ env_vars = []
447
+ while True:
448
+ env_var = Prompt.ask("Environment variable name (blank to finish)", default="")
449
+ if not env_var:
450
+ break
451
+ env_vars.append(env_var)
452
+ if env_vars:
453
+ config["env_vars"] = env_vars
454
+
455
+ # Write to file
456
+ with open(filename, "w") as f:
457
+ yaml.dump(config, f, sort_keys=False, default_flow_style=False)
458
+
459
+ return filename