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/commands/__init__.py +3 -3
- xenfra/commands/auth.py +144 -144
- xenfra/commands/auth_device.py +164 -164
- xenfra/commands/deployments.py +1133 -973
- xenfra/commands/intelligence.py +503 -412
- xenfra/commands/projects.py +204 -204
- xenfra/commands/security_cmd.py +233 -233
- xenfra/main.py +76 -75
- xenfra/utils/__init__.py +3 -3
- xenfra/utils/auth.py +374 -374
- xenfra/utils/codebase.py +169 -169
- xenfra/utils/config.py +459 -436
- xenfra/utils/errors.py +116 -116
- xenfra/utils/file_sync.py +286 -286
- xenfra/utils/security.py +336 -336
- xenfra/utils/validation.py +234 -234
- xenfra-0.4.4.dist-info/METADATA +113 -0
- xenfra-0.4.4.dist-info/RECORD +21 -0
- xenfra-0.4.3.dist-info/METADATA +0 -118
- xenfra-0.4.3.dist-info/RECORD +0 -21
- {xenfra-0.4.3.dist-info → xenfra-0.4.4.dist-info}/WHEEL +0 -0
- {xenfra-0.4.3.dist-info → xenfra-0.4.4.dist-info}/entry_points.txt +0 -0
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
elif operation == "replace":
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
if
|
|
328
|
-
config_data
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
current = current[part]
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|