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.
- {xenfra-0.4.1 → xenfra-0.4.3}/PKG-INFO +1 -1
- {xenfra-0.4.1 → xenfra-0.4.3}/pyproject.toml +56 -56
- {xenfra-0.4.1 → xenfra-0.4.3}/src/xenfra/commands/deployments.py +83 -10
- {xenfra-0.4.1 → xenfra-0.4.3}/src/xenfra/commands/intelligence.py +1 -1
- {xenfra-0.4.1 → xenfra-0.4.3}/src/xenfra/utils/config.py +15 -6
- xenfra-0.4.3/src/xenfra/utils/file_sync.py +286 -0
- {xenfra-0.4.1 → xenfra-0.4.3}/README.md +0 -0
- {xenfra-0.4.1 → xenfra-0.4.3}/src/xenfra/__init__.py +0 -0
- {xenfra-0.4.1 → xenfra-0.4.3}/src/xenfra/commands/__init__.py +0 -0
- {xenfra-0.4.1 → xenfra-0.4.3}/src/xenfra/commands/auth.py +0 -0
- {xenfra-0.4.1 → xenfra-0.4.3}/src/xenfra/commands/auth_device.py +0 -0
- {xenfra-0.4.1 → xenfra-0.4.3}/src/xenfra/commands/projects.py +0 -0
- {xenfra-0.4.1 → xenfra-0.4.3}/src/xenfra/commands/security_cmd.py +0 -0
- {xenfra-0.4.1 → xenfra-0.4.3}/src/xenfra/main.py +0 -0
- {xenfra-0.4.1 → xenfra-0.4.3}/src/xenfra/utils/__init__.py +0 -0
- {xenfra-0.4.1 → xenfra-0.4.3}/src/xenfra/utils/auth.py +0 -0
- {xenfra-0.4.1 → xenfra-0.4.3}/src/xenfra/utils/codebase.py +0 -0
- {xenfra-0.4.1 → xenfra-0.4.3}/src/xenfra/utils/errors.py +0 -0
- {xenfra-0.4.1 → xenfra-0.4.3}/src/xenfra/utils/security.py +0 -0
- {xenfra-0.4.1 → xenfra-0.4.3}/src/xenfra/utils/validation.py +0 -0
|
@@ -1,56 +1,56 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "xenfra"
|
|
3
|
-
version = "0.4.
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
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: {
|
|
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 (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|