xenfra 0.4.1__tar.gz → 0.4.3__tar.gz

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,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: xenfra
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: A 'Zen Mode' infrastructure engine for Python developers.
5
5
  Author: xenfra-cloud
6
6
  Author-email: xenfra-cloud <xenfracloud@gmail.com>
@@ -1,56 +1,56 @@
1
- [project]
2
- name = "xenfra"
3
- version = "0.4.1"
4
- description = "A 'Zen Mode' infrastructure engine for Python developers."
5
- readme = "README.md"
6
- authors = [
7
- { name = "xenfra-cloud", email = "xenfracloud@gmail.com" }
8
- ]
9
-
10
- classifiers = [
11
- "Programming Language :: Python :: 3",
12
- "License :: OSI Approved :: MIT License",
13
- "Operating System :: OS Independent",
14
- "Development Status :: 3 - Alpha",
15
- "Intended Audience :: Developers",
16
- "Topic :: Software Development :: Build Tools",
17
- "Topic :: System :: Systems Administration",
18
- ]
19
-
20
- dependencies = [
21
- "click>=8.1.7",
22
- "rich>=14.2.0",
23
- "sqlmodel>=0.0.16",
24
- "python-digitalocean>=1.17.0",
25
- "python-dotenv>=1.2.1",
26
- "pyyaml>=6.0.1",
27
- "fabric>=3.2.2",
28
- "xenfra-sdk",
29
- "httpx>=0.27.0",
30
- "keyring>=25.7.0",
31
- "keyrings.alt>=5.0.2",
32
- "tenacity>=8.2.3", # For retry logic
33
- "cryptography>=43.0.0", # For encrypted file-based token storage
34
- "toml>=0.10.2",
35
- ]
36
- requires-python = ">=3.13"
37
-
38
- [tool.uv.sources]
39
- xenfra-sdk = { workspace = true }
40
-
41
- [project.urls]
42
- Homepage = "https://github.com/xenfra-cloud/xenfra"
43
- Issues = "https://github.com/xenfra-cloud/xenfra/issues"
44
-
45
- [project.optional-dependencies]
46
- test = [
47
- "pytest>=8.0.0",
48
- "pytest-mock>=3.12.0",
49
- ]
50
-
51
- [project.scripts]
52
- xenfra = "xenfra.main:main"
53
-
54
- [build-system]
55
- requires = ["uv_build>=0.9.18,<0.10.0"]
56
- build-backend = "uv_build"
1
+ [project]
2
+ name = "xenfra"
3
+ version = "0.4.3"
4
+ description = "A 'Zen Mode' infrastructure engine for Python developers."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "xenfra-cloud", email = "xenfracloud@gmail.com" }
8
+ ]
9
+
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Operating System :: OS Independent",
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Topic :: Software Development :: Build Tools",
17
+ "Topic :: System :: Systems Administration",
18
+ ]
19
+
20
+ dependencies = [
21
+ "click>=8.1.7",
22
+ "rich>=14.2.0",
23
+ "sqlmodel>=0.0.16",
24
+ "python-digitalocean>=1.17.0",
25
+ "python-dotenv>=1.2.1",
26
+ "pyyaml>=6.0.1",
27
+ "fabric>=3.2.2",
28
+ "xenfra-sdk",
29
+ "httpx>=0.27.0",
30
+ "keyring>=25.7.0",
31
+ "keyrings.alt>=5.0.2",
32
+ "tenacity>=8.2.3", # For retry logic
33
+ "cryptography>=43.0.0", # For encrypted file-based token storage
34
+ "toml>=0.10.2",
35
+ ]
36
+ requires-python = ">=3.13"
37
+
38
+ [tool.uv.sources]
39
+ xenfra-sdk = { workspace = true }
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/xenfra-cloud/xenfra"
43
+ Issues = "https://github.com/xenfra-cloud/xenfra/issues"
44
+
45
+ [project.optional-dependencies]
46
+ test = [
47
+ "pytest>=8.0.0",
48
+ "pytest-mock>=3.12.0",
49
+ ]
50
+
51
+ [project.scripts]
52
+ xenfra = "xenfra.main:main"
53
+
54
+ [build-system]
55
+ requires = ["uv_build>=0.9.18,<0.10.0"]
56
+ build-backend = "uv_build"
@@ -65,7 +65,7 @@ def show_patch_preview(patch_data: dict):
65
65
  console.print()
66
66
 
67
67
 
68
- def _stream_deployment(client: XenfraClient, project_name: str, git_repo: str, branch: str, framework: str, region: str, size_slug: str, is_dockerized: bool = True):
68
+ def _stream_deployment(client: XenfraClient, project_name: str, git_repo: str, branch: str, framework: str, region: str, size_slug: str, is_dockerized: bool = True, port: int = None, command: str = None, entrypoint: str = None, database: str = None, package_manager: str = None, dependency_file: str = None, file_manifest: list = None):
69
69
  """
70
70
  Creates deployment with real-time SSE streaming (no polling needed).
71
71
 
@@ -91,7 +91,15 @@ def _stream_deployment(client: XenfraClient, project_name: str, git_repo: str, b
91
91
  region=region,
92
92
  size_slug=size_slug,
93
93
  is_dockerized=is_dockerized,
94
+ port=port,
95
+ command=command,
96
+ entrypoint=entrypoint, # Pass entrypoint to deployment API
97
+ database=database,
98
+ package_manager=package_manager,
99
+ dependency_file=dependency_file,
100
+ file_manifest=file_manifest,
94
101
  ):
102
+
95
103
  event_type = event.get("event", "message")
96
104
  data = event.get("data", "")
97
105
 
@@ -356,6 +364,8 @@ def deploy(project_name, git_repo, branch, framework, region, size, no_heal):
356
364
  # Resolve values with precedence: 1. CLI Flag, 2. xenfra.yaml, 3. Default
357
365
  project_name = project_name or config.get("name") or os.path.basename(os.getcwd())
358
366
  framework = framework or config.get("framework")
367
+ # Track if is_dockerized was explicitly set in config (to avoid AI override)
368
+ is_dockerized_from_config = "is_dockerized" in config
359
369
  is_dockerized = config.get("is_dockerized", True)
360
370
  region = region or config.get("region") or "nyc3"
361
371
 
@@ -375,6 +385,17 @@ def deploy(project_name, git_repo, branch, framework, region, size, no_heal):
375
385
  else:
376
386
  size = "s-1vcpu-1gb"
377
387
 
388
+ # Extract port, command, database from config
389
+ # Track if port was explicitly set to avoid AI override
390
+ port_from_config = config.get("port")
391
+ port = port_from_config or 8000
392
+ command = config.get("command") # Auto-detected if not provided
393
+ entrypoint = config.get("entrypoint") # e.g., "todo.main:app"
394
+ database_config = config.get("database", {})
395
+ database = database_config.get("type") if isinstance(database_config, dict) else None
396
+ package_manager = config.get("package_manager", "pip")
397
+ dependency_file = config.get("dependency_file", "requirements.txt")
398
+
378
399
  # Default project name to current directory
379
400
  if not project_name:
380
401
  project_name = os.path.basename(os.getcwd())
@@ -394,9 +415,10 @@ def deploy(project_name, git_repo, branch, framework, region, size, no_heal):
394
415
  console.print(f"[cyan]Deploying {project_name} from git repository...[/cyan]")
395
416
  console.print(f"[dim]Repository: {git_repo} (branch: {branch})[/dim]")
396
417
  else:
397
- console.print(f"[cyan]Deploying {project_name} from local directory...[/cyan]")
398
- console.print("[dim]Code will be uploaded to the droplet[/dim]")
399
- # Note: SDK's InfraEngine handles local upload via fabric.transfer.Upload()
418
+ # Note: Local folder deployment only works when engine runs locally
419
+ # In cloud API mode, this will fail with a clear error from the server
420
+ console.print(f"[cyan]Deploying {project_name}...[/cyan]")
421
+ console.print("[dim]Note: Git repository recommended for cloud deployments[/dim]")
400
422
 
401
423
  # Validate branch name
402
424
  is_valid, error_msg = validate_branch_name(branch)
@@ -418,10 +440,6 @@ def deploy(project_name, git_repo, branch, framework, region, size, no_heal):
418
440
  try:
419
441
  with get_client() as client:
420
442
  while attempt < MAX_RETRY_ATTEMPTS:
421
- # Safety check to prevent infinite loops
422
- if attempt > MAX_RETRY_ATTEMPTS:
423
- raise RuntimeError("Safety break: Retry loop exceeded MAX_RETRY_ATTEMPTS.")
424
-
425
443
  attempt += 1
426
444
 
427
445
  if attempt > 1:
@@ -440,13 +458,18 @@ def deploy(project_name, git_repo, branch, framework, region, size, no_heal):
440
458
  if code_snippets:
441
459
  analysis = client.intelligence.analyze_codebase(code_snippets)
442
460
  framework = analysis.framework
443
- is_dockerized = analysis.is_dockerized
461
+ # Only use AI's is_dockerized if config didn't explicitly set it
462
+ if not is_dockerized_from_config:
463
+ is_dockerized = analysis.is_dockerized
464
+ # Override port if AI detected it and config didn't set one
465
+ if not port_from_config and analysis.port:
466
+ port = analysis.port
444
467
  # Override port and size if AI has strong recommendations
445
468
  if not size and analysis.instance_size:
446
469
  size = "s-1vcpu-1gb" if analysis.instance_size == "basic" else "s-2vcpu-4gb"
447
470
 
448
471
  mode_str = "Docker" if is_dockerized else "Bare Metal"
449
- console.print(f"[green]✓ Detected {framework.upper()} project ({mode_str} Mode)[/green] (Port: {analysis.port})")
472
+ console.print(f"[green]✓ Detected {framework.upper()} project ({mode_str} Mode)[/green] (Port: {port})")
450
473
  else:
451
474
  console.print("[yellow]⚠ No code files found for AI analysis. Defaulting to 'fastapi'[/yellow]")
452
475
  framework = "fastapi"
@@ -456,6 +479,48 @@ def deploy(project_name, git_repo, branch, framework, region, size, no_heal):
456
479
  framework = "fastapi"
457
480
  is_dockerized = True
458
481
 
482
+ # Delta upload: if no git_repo, scan and upload local files
483
+ file_manifest = None
484
+ if not git_repo:
485
+ from ..utils.file_sync import scan_project_files_cached, ensure_gitignore_ignored
486
+
487
+ # Protect privacy: ensure .xenfra is in .gitignore
488
+ if ensure_gitignore_ignored():
489
+ console.print("[dim] - Added .xenfra to .gitignore for privacy[/dim]")
490
+
491
+ console.print("[cyan]📁 Scanning project files...[/cyan]")
492
+
493
+ file_manifest = scan_project_files_cached()
494
+ console.print(f"[dim]Found {len(file_manifest)} files[/dim]")
495
+
496
+ if not file_manifest:
497
+ console.print("[bold red]Error: No files found to deploy.[/bold red]")
498
+ raise click.Abort()
499
+
500
+ # Check which files need uploading
501
+ console.print("[cyan]🔍 Checking file cache...[/cyan]")
502
+ check_result = client.files.check(file_manifest)
503
+ missing = check_result.get('missing', [])
504
+ cached = check_result.get('cached', 0)
505
+
506
+ if cached > 0:
507
+ console.print(f"[green]✓ {cached} files already cached[/green]")
508
+
509
+ # Upload missing files
510
+ if missing:
511
+ console.print(f"[cyan]☁️ Uploading {len(missing)} files...[/cyan]")
512
+ uploaded = client.files.upload_files(
513
+ file_manifest,
514
+ missing,
515
+ progress_callback=lambda done, total: console.print(f"[dim] Progress: {done}/{total}[/dim]") if done % 10 == 0 or done == total else None
516
+ )
517
+ console.print(f"[green]✓ Uploaded {uploaded} files[/green]")
518
+ else:
519
+ console.print("[green]✓ All files already cached[/green]")
520
+
521
+ # Remove abs_path from manifest before sending to API
522
+ file_manifest = [{"path": f["path"], "sha": f["sha"], "size": f["size"]} for f in file_manifest]
523
+
459
524
  # Create deployment with real-time streaming
460
525
  try:
461
526
  status_result, deployment_id, logs_data = _stream_deployment(
@@ -467,8 +532,16 @@ def deploy(project_name, git_repo, branch, framework, region, size, no_heal):
467
532
  region=region,
468
533
  size_slug=size,
469
534
  is_dockerized=is_dockerized,
535
+ port=port,
536
+ command=command,
537
+ entrypoint=entrypoint, # Pass entrypoint to deployment API
538
+ database=database,
539
+ package_manager=package_manager,
540
+ dependency_file=dependency_file,
541
+ file_manifest=file_manifest,
470
542
  )
471
543
 
544
+
472
545
  if status_result == "FAILED" and not no_heal:
473
546
  # Hand off to the Zen Nod AI Agent
474
547
  should_retry = zen_nod_workflow(logs_data, client, attempt)
@@ -215,7 +215,7 @@ def init(manual, accept_all):
215
215
  confirmed = Confirm.ask("\nCreate xenfra.yaml with this configuration?", default=True)
216
216
 
217
217
  if confirmed:
218
- generate_xenfra_yaml(analysis)
218
+ generate_xenfra_yaml(analysis, package_manager_override=selected_package_manager, dependency_file_override=selected_dependency_file)
219
219
  console.print("[bold green]xenfra.yaml created successfully![/bold green]")
220
220
  console.print("[dim]Run 'xenfra deploy' to deploy your project.[/dim]")
221
221
  else:
@@ -46,13 +46,15 @@ def read_xenfra_yaml(filename: str = "xenfra.yaml") -> dict:
46
46
  raise IOError(f"Failed to read {filename}: {e}")
47
47
 
48
48
 
49
- def generate_xenfra_yaml(analysis: CodebaseAnalysisResponse, filename: str = "xenfra.yaml") -> str:
49
+ def generate_xenfra_yaml(analysis: CodebaseAnalysisResponse, filename: str = "xenfra.yaml", package_manager_override: str = None, dependency_file_override: str = None) -> str:
50
50
  """
51
51
  Generate xenfra.yaml from AI codebase analysis.
52
52
 
53
53
  Args:
54
54
  analysis: CodebaseAnalysisResponse from Intelligence Service
55
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)
56
58
 
57
59
  Returns:
58
60
  Path to the generated file
@@ -65,6 +67,10 @@ def generate_xenfra_yaml(analysis: CodebaseAnalysisResponse, filename: str = "xe
65
67
  "port": analysis.port,
66
68
  }
67
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
+
68
74
  # Add database configuration if detected
69
75
  if analysis.database and analysis.database != "none":
70
76
  config["database"] = {"type": analysis.database, "env_var": "DATABASE_URL"}
@@ -96,11 +102,14 @@ def generate_xenfra_yaml(analysis: CodebaseAnalysisResponse, filename: str = "xe
96
102
  config["resources"]["cpu"] = 4
97
103
  config["resources"]["ram"] = "8GB"
98
104
 
99
- # Add package manager info (for intelligent diagnosis)
100
- if analysis.package_manager:
101
- config["package_manager"] = analysis.package_manager
102
- if analysis.dependency_file:
103
- config["dependency_file"] = analysis.dependency_file
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
104
113
 
105
114
  # Write to file
106
115
  with open(filename, "w") as f:
@@ -0,0 +1,286 @@
1
+ """
2
+ File synchronization utilities for delta uploads.
3
+
4
+ Provides functions to scan project files, compute SHA256 hashes,
5
+ and manage local file caches for incremental deployments.
6
+ """
7
+
8
+ import hashlib
9
+ import json
10
+ import os
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional, Set
14
+
15
+ # Patterns to exclude from deployment
16
+ EXCLUDE_PATTERNS: Set[str] = {
17
+ # Version control
18
+ '.git',
19
+ '.svn',
20
+ '.hg',
21
+
22
+ # Python
23
+ '.venv',
24
+ 'venv',
25
+ '__pycache__',
26
+ '*.pyc',
27
+ '*.pyo',
28
+ '.pytest_cache',
29
+ '.mypy_cache',
30
+ '*.egg-info',
31
+ 'dist',
32
+ 'build',
33
+
34
+ # Node.js
35
+ 'node_modules',
36
+
37
+ # IDE/Editor
38
+ '.idea',
39
+ '.vscode',
40
+ '*.swp',
41
+
42
+ # Xenfra
43
+ '.xenfra',
44
+
45
+ # Environment
46
+ '.env',
47
+ '.env.local',
48
+ '.env.*.local',
49
+
50
+ # OS
51
+ '.DS_Store',
52
+ 'Thumbs.db',
53
+ }
54
+
55
+ # File extensions to always exclude
56
+ EXCLUDE_EXTENSIONS: Set[str] = {
57
+ '.pyc', '.pyo', '.so', '.dylib', '.dll',
58
+ '.exe', '.bin', '.obj', '.o',
59
+ }
60
+
61
+
62
+ def should_exclude(path: Path, root: Path) -> bool:
63
+ """Check if a path should be excluded from upload."""
64
+ rel_parts = path.relative_to(root).parts
65
+
66
+ # Check each part of the path against exclusion patterns
67
+ for part in rel_parts:
68
+ if part in EXCLUDE_PATTERNS:
69
+ return True
70
+ # Check wildcard patterns
71
+ for pattern in EXCLUDE_PATTERNS:
72
+ if pattern.startswith('*') and part.endswith(pattern[1:]):
73
+ return True
74
+
75
+ # Check file extension
76
+ if path.suffix.lower() in EXCLUDE_EXTENSIONS:
77
+ return True
78
+
79
+ return False
80
+
81
+
82
+ def compute_file_sha(filepath: str) -> str:
83
+ """Compute SHA256 hash of a file's content."""
84
+ sha256 = hashlib.sha256()
85
+ with open(filepath, 'rb') as f:
86
+ for chunk in iter(lambda: f.read(8192), b''):
87
+ sha256.update(chunk)
88
+ return sha256.hexdigest()
89
+
90
+
91
+ def scan_project_files(root: str = '.') -> List[Dict]:
92
+ """
93
+ Scan project directory and return list of files with their metadata.
94
+
95
+ Returns:
96
+ List of dicts with keys: path, sha, size, abs_path
97
+ """
98
+ files = []
99
+ root_path = Path(root).resolve()
100
+
101
+ for filepath in root_path.rglob('*'):
102
+ # Skip directories
103
+ if not filepath.is_file():
104
+ continue
105
+
106
+ # Check exclusions
107
+ if should_exclude(filepath, root_path):
108
+ continue
109
+
110
+ # Skip very large files (> 50MB)
111
+ file_size = filepath.stat().st_size
112
+ if file_size > 50 * 1024 * 1024:
113
+ continue
114
+
115
+ # Normalize path to use forward slashes
116
+ rel_path = str(filepath.relative_to(root_path)).replace('\\', '/')
117
+
118
+ files.append({
119
+ 'path': rel_path,
120
+ 'sha': compute_file_sha(str(filepath)),
121
+ 'size': file_size,
122
+ 'abs_path': str(filepath),
123
+ })
124
+
125
+ return files
126
+
127
+
128
+ def get_xenfra_dir(project_root: str = '.') -> Path:
129
+ """Get or create the .xenfra directory."""
130
+ xenfra_dir = Path(project_root).resolve() / '.xenfra'
131
+ xenfra_dir.mkdir(exist_ok=True)
132
+
133
+ # Create cache subdirectory
134
+ cache_dir = xenfra_dir / 'cache'
135
+ cache_dir.mkdir(exist_ok=True)
136
+
137
+ return xenfra_dir
138
+
139
+
140
+ def load_file_cache(project_root: str = '.') -> Dict[str, Dict]:
141
+ """
142
+ Load cached file hashes from .xenfra/cache/file_hashes.json.
143
+
144
+ Returns:
145
+ Dict mapping file paths to {sha, mtime, size}
146
+ """
147
+ xenfra_dir = get_xenfra_dir(project_root)
148
+ cache_file = xenfra_dir / 'cache' / 'file_hashes.json'
149
+
150
+ if cache_file.exists():
151
+ try:
152
+ with open(cache_file, 'r') as f:
153
+ return json.load(f)
154
+ except (json.JSONDecodeError, IOError):
155
+ return {}
156
+ return {}
157
+
158
+
159
+ def save_file_cache(cache: Dict[str, Dict], project_root: str = '.'):
160
+ """Save file hashes to .xenfra/cache/file_hashes.json."""
161
+ xenfra_dir = get_xenfra_dir(project_root)
162
+ cache_file = xenfra_dir / 'cache' / 'file_hashes.json'
163
+
164
+ with open(cache_file, 'w') as f:
165
+ json.dump(cache, f, indent=2)
166
+
167
+
168
+ def scan_project_files_cached(root: str = '.') -> List[Dict]:
169
+ """
170
+ Scan project files using local cache for unchanged files.
171
+
172
+ Only recomputes SHA for files whose mtime or size changed.
173
+ This is much faster for large projects with few changes.
174
+ """
175
+ files = []
176
+ root_path = Path(root).resolve()
177
+ cache = load_file_cache(root)
178
+ new_cache = {}
179
+
180
+ for filepath in root_path.rglob('*'):
181
+ if not filepath.is_file():
182
+ continue
183
+
184
+ if should_exclude(filepath, root_path):
185
+ continue
186
+
187
+ file_size = filepath.stat().st_size
188
+ if file_size > 50 * 1024 * 1024:
189
+ continue
190
+
191
+ rel_path = str(filepath.relative_to(root_path)).replace('\\', '/')
192
+ mtime = filepath.stat().st_mtime
193
+
194
+ # Check if we can use cached value
195
+ cached = cache.get(rel_path)
196
+ if cached and cached.get('mtime') == mtime and cached.get('size') == file_size:
197
+ sha = cached['sha']
198
+ else:
199
+ # File changed, recompute SHA
200
+ sha = compute_file_sha(str(filepath))
201
+
202
+ # Update cache
203
+ new_cache[rel_path] = {
204
+ 'sha': sha,
205
+ 'mtime': mtime,
206
+ 'size': file_size,
207
+ }
208
+
209
+ files.append({
210
+ 'path': rel_path,
211
+ 'sha': sha,
212
+ 'size': file_size,
213
+ 'abs_path': str(filepath),
214
+ })
215
+
216
+ # Save updated cache
217
+ save_file_cache(new_cache, root)
218
+
219
+ return files
220
+
221
+
222
+ def load_project_config(project_root: str = '.') -> Optional[Dict]:
223
+ """Load .xenfra/config.json if it exists."""
224
+ xenfra_dir = Path(project_root).resolve() / '.xenfra'
225
+ config_file = xenfra_dir / 'config.json'
226
+
227
+ if config_file.exists():
228
+ try:
229
+ with open(config_file, 'r') as f:
230
+ return json.load(f)
231
+ except (json.JSONDecodeError, IOError):
232
+ return None
233
+ return None
234
+
235
+
236
+ def ensure_gitignore_ignored(project_root: str = '.'):
237
+ """Ensure .xenfra/ is in the .gitignore file."""
238
+ root_path = Path(project_root).resolve()
239
+ gitignore_path = root_path / '.gitignore'
240
+
241
+ entry = '.xenfra/\n'
242
+
243
+ if not gitignore_path.exists():
244
+ try:
245
+ with open(gitignore_path, 'w') as f:
246
+ f.write(entry)
247
+ return True
248
+ except IOError:
249
+ return False
250
+
251
+ try:
252
+ with open(gitignore_path, 'r') as f:
253
+ content = f.read()
254
+
255
+ if '.xenfra/' not in content and '.xenfra' not in content:
256
+ with open(gitignore_path, 'a') as f:
257
+ if not content.endswith('\n'):
258
+ f.write('\n')
259
+ f.write(entry)
260
+ return True
261
+ except IOError:
262
+ return False
263
+
264
+ return False
265
+
266
+
267
+ def save_project_config(config: Dict, project_root: str = '.'):
268
+ """Save project config to .xenfra/config.json."""
269
+ xenfra_dir = get_xenfra_dir(project_root)
270
+ config_file = xenfra_dir / 'config.json'
271
+
272
+ with open(config_file, 'w') as f:
273
+ json.dump(config, f, indent=2)
274
+
275
+
276
+ def update_last_deployment(deployment_id: str, url: str = None, project_root: str = '.'):
277
+ """Update the last deployment info in project config."""
278
+ config = load_project_config(project_root) or {}
279
+
280
+ config['lastDeployment'] = {
281
+ 'id': deployment_id,
282
+ 'url': url,
283
+ 'createdAt': datetime.utcnow().isoformat() + 'Z',
284
+ }
285
+
286
+ save_project_config(config, project_root)
File without changes
File without changes
File without changes
File without changes