xenfra-sdk 0.2.5__py3-none-any.whl → 0.2.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xenfra_sdk/__init__.py +46 -2
- xenfra_sdk/blueprints/base.py +150 -0
- xenfra_sdk/blueprints/factory.py +99 -0
- xenfra_sdk/blueprints/node.py +219 -0
- xenfra_sdk/blueprints/python.py +57 -0
- xenfra_sdk/blueprints/railpack.py +99 -0
- xenfra_sdk/blueprints/schema.py +70 -0
- xenfra_sdk/cli/main.py +175 -49
- xenfra_sdk/client.py +6 -2
- xenfra_sdk/constants.py +26 -0
- xenfra_sdk/db/session.py +8 -3
- xenfra_sdk/detection.py +262 -191
- xenfra_sdk/dockerizer.py +76 -120
- xenfra_sdk/engine.py +758 -172
- xenfra_sdk/events.py +254 -0
- xenfra_sdk/exceptions.py +9 -0
- xenfra_sdk/governance.py +150 -0
- xenfra_sdk/manifest.py +93 -138
- xenfra_sdk/mcp_client.py +7 -5
- xenfra_sdk/{models.py → models/__init__.py} +17 -1
- xenfra_sdk/models/context.py +61 -0
- xenfra_sdk/orchestrator.py +223 -99
- xenfra_sdk/privacy.py +11 -0
- xenfra_sdk/protocol.py +38 -0
- xenfra_sdk/railpack_adapter.py +357 -0
- xenfra_sdk/railpack_detector.py +587 -0
- xenfra_sdk/railpack_manager.py +312 -0
- xenfra_sdk/recipes.py +152 -19
- xenfra_sdk/resources/activity.py +45 -0
- xenfra_sdk/resources/build.py +157 -0
- xenfra_sdk/resources/deployments.py +22 -2
- xenfra_sdk/resources/intelligence.py +25 -0
- xenfra_sdk-0.2.6.dist-info/METADATA +118 -0
- xenfra_sdk-0.2.6.dist-info/RECORD +49 -0
- {xenfra_sdk-0.2.5.dist-info → xenfra_sdk-0.2.6.dist-info}/WHEEL +1 -1
- xenfra_sdk/templates/Caddyfile.j2 +0 -14
- xenfra_sdk/templates/Dockerfile.j2 +0 -41
- xenfra_sdk/templates/cloud-init.sh.j2 +0 -90
- xenfra_sdk/templates/docker-compose-multi.yml.j2 +0 -29
- xenfra_sdk/templates/docker-compose.yml.j2 +0 -30
- xenfra_sdk-0.2.5.dist-info/METADATA +0 -116
- xenfra_sdk-0.2.5.dist-info/RECORD +0 -38
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Railpack Detector Module
|
|
3
|
+
|
|
4
|
+
Provides intelligent framework detection using Railway's Railpack buildpack.
|
|
5
|
+
Railpack analyzes project structure and dependencies to detect language,
|
|
6
|
+
framework, and build configuration.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional, Dict, Any, List
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class RailpackDetectionResult:
|
|
23
|
+
"""Result from Railpack framework detection."""
|
|
24
|
+
framework: str
|
|
25
|
+
confidence: str # "high", "medium", "low"
|
|
26
|
+
detected_from: str
|
|
27
|
+
package_manager: Optional[str] = None
|
|
28
|
+
runtime_version: Optional[str] = None
|
|
29
|
+
start_command: Optional[str] = None
|
|
30
|
+
detected_port: Optional[int] = None
|
|
31
|
+
build_commands: List[str] = None
|
|
32
|
+
runtime_name: Optional[str] = None
|
|
33
|
+
|
|
34
|
+
def __post_init__(self):
|
|
35
|
+
if self.build_commands is None:
|
|
36
|
+
self.build_commands = []
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class EnvVariable:
|
|
40
|
+
"""Environment variable representation."""
|
|
41
|
+
id: str
|
|
42
|
+
key: str
|
|
43
|
+
value: str
|
|
44
|
+
is_secret: bool = False
|
|
45
|
+
|
|
46
|
+
class RailpackDetector:
|
|
47
|
+
"""
|
|
48
|
+
Detects project framework and configuration using Railpack.
|
|
49
|
+
|
|
50
|
+
Railpack is Railway's open-source buildpack that auto-detects
|
|
51
|
+
languages and generates optimized container configurations.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def __init__(self, version: Optional[str] = None):
|
|
56
|
+
"""
|
|
57
|
+
Initialize Railpack detector.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
version: Specific Railpack version.
|
|
61
|
+
"""
|
|
62
|
+
from xenfra_sdk.railpack_manager import get_railpack_manager
|
|
63
|
+
self.manager = get_railpack_manager(version)
|
|
64
|
+
|
|
65
|
+
def detect_from_path(self, project_path: str) -> RailpackDetectionResult:
|
|
66
|
+
"""
|
|
67
|
+
Detect framework from a local project path.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
project_path: Path to project directory.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
RailpackDetectionResult with detected framework info.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
RuntimeError: If Railpack fails to run.
|
|
77
|
+
"""
|
|
78
|
+
# Ensure installed via manager
|
|
79
|
+
binary = self.manager.ensure_installed()
|
|
80
|
+
|
|
81
|
+
# Create temp file for plan output
|
|
82
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
|
83
|
+
plan_path = f.name
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
# Run railpack prepare with absolute path to be safe
|
|
87
|
+
proc_result = subprocess.run(
|
|
88
|
+
[binary, "prepare", project_path, "--plan-out", plan_path],
|
|
89
|
+
cwd=project_path,
|
|
90
|
+
capture_output=True,
|
|
91
|
+
text=True,
|
|
92
|
+
timeout=60
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if proc_result.returncode != 0:
|
|
96
|
+
logger.error(f"Railpack prepare failed: {proc_result.stderr}")
|
|
97
|
+
return self._fallback_detection(project_path)
|
|
98
|
+
|
|
99
|
+
# Parse the plan
|
|
100
|
+
with open(plan_path) as f:
|
|
101
|
+
plan_content = f.read()
|
|
102
|
+
plan = json.loads(plan_content)
|
|
103
|
+
|
|
104
|
+
result = self._parse_plan(plan)
|
|
105
|
+
|
|
106
|
+
# If Railpack returns unknown, try our static fallback
|
|
107
|
+
if result.framework == "unknown":
|
|
108
|
+
logger.warning(
|
|
109
|
+
f"Railpack returned 'unknown' for {project_path}.\n"
|
|
110
|
+
f"Plan content: {plan_content[:1000]}...\n"
|
|
111
|
+
f"Stdout: {proc_result.stdout}\n"
|
|
112
|
+
f"Stderr: {proc_result.stderr}"
|
|
113
|
+
)
|
|
114
|
+
logger.info("Triggering static fallback...")
|
|
115
|
+
return self._fallback_detection(project_path)
|
|
116
|
+
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
except subprocess.TimeoutExpired:
|
|
120
|
+
logger.warning("Railpack prepare timed out")
|
|
121
|
+
return self._fallback_detection(project_path)
|
|
122
|
+
except json.JSONDecodeError as e:
|
|
123
|
+
logger.warning(f"Failed to parse Railpack plan: {e}")
|
|
124
|
+
return self._fallback_detection(project_path)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
logger.warning(f"Railpack detection error: {e}")
|
|
127
|
+
return self._fallback_detection(project_path)
|
|
128
|
+
finally:
|
|
129
|
+
if os.path.exists(plan_path):
|
|
130
|
+
os.unlink(plan_path)
|
|
131
|
+
|
|
132
|
+
def detect_from_manifest(self, file_manifest: List[Dict[str, Any]]) -> RailpackDetectionResult:
|
|
133
|
+
"""
|
|
134
|
+
Quick detection from file manifest (without full Railpack run).
|
|
135
|
+
|
|
136
|
+
Uses static file detection but returns Railpack-compatible result format.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
file_manifest: List of file info dicts with 'path' key.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
RailpackDetectionResult with detected framework info.
|
|
143
|
+
"""
|
|
144
|
+
file_names = {f.get("path", "").lstrip("./") for f in file_manifest}
|
|
145
|
+
|
|
146
|
+
# === Node.js Detection ===
|
|
147
|
+
if "package.json" in file_names:
|
|
148
|
+
framework = "nodejs"
|
|
149
|
+
package_manager = "npm"
|
|
150
|
+
start_command = "npm start"
|
|
151
|
+
build_commands = []
|
|
152
|
+
|
|
153
|
+
if "pnpm-lock.yaml" in file_names:
|
|
154
|
+
package_manager = "pnpm"
|
|
155
|
+
elif "yarn.lock" in file_names:
|
|
156
|
+
package_manager = "yarn"
|
|
157
|
+
elif "bun.lockb" in file_names:
|
|
158
|
+
package_manager = "bun"
|
|
159
|
+
|
|
160
|
+
# Check for specific frameworks in content if available
|
|
161
|
+
for f in file_manifest:
|
|
162
|
+
if f.get("path") == "package.json" and f.get("content"):
|
|
163
|
+
content = f["content"].lower()
|
|
164
|
+
if "next" in content:
|
|
165
|
+
framework = "nextjs"
|
|
166
|
+
start_command = "npm run start" if package_manager == "npm" else f"{package_manager} start"
|
|
167
|
+
build_commands = ["npm run build" if package_manager == "npm" else f"{package_manager} build"]
|
|
168
|
+
elif "express" in content:
|
|
169
|
+
framework = "express"
|
|
170
|
+
start_command = "node server.js" # Common default
|
|
171
|
+
elif "nestjs" in content or "@nestjs/core" in content:
|
|
172
|
+
framework = "nestjs"
|
|
173
|
+
start_command = "npm run start:prod"
|
|
174
|
+
build_commands = ["npm run build"]
|
|
175
|
+
break
|
|
176
|
+
|
|
177
|
+
return RailpackDetectionResult(
|
|
178
|
+
framework=framework,
|
|
179
|
+
confidence="high",
|
|
180
|
+
detected_from="package.json",
|
|
181
|
+
package_manager=package_manager,
|
|
182
|
+
runtime_name="node",
|
|
183
|
+
start_command=start_command,
|
|
184
|
+
build_commands=build_commands
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# === Python Detection ===
|
|
188
|
+
if "requirements.txt" in file_names or "pyproject.toml" in file_names:
|
|
189
|
+
framework = "python"
|
|
190
|
+
package_manager = "pip"
|
|
191
|
+
start_command = "python main.py"
|
|
192
|
+
detected_from = "requirements.txt" if "requirements.txt" in file_names else "pyproject.toml"
|
|
193
|
+
|
|
194
|
+
if "pyproject.toml" in file_names and "uv.lock" in file_names:
|
|
195
|
+
package_manager = "uv"
|
|
196
|
+
elif "Pipfile" in file_names:
|
|
197
|
+
package_manager = "pipenv"
|
|
198
|
+
elif "poetry.lock" in file_names:
|
|
199
|
+
package_manager = "poetry"
|
|
200
|
+
|
|
201
|
+
# Check for specific frameworks
|
|
202
|
+
for f in file_manifest:
|
|
203
|
+
path = f.get("path", "")
|
|
204
|
+
if path in ("requirements.txt", "pyproject.toml") and f.get("content"):
|
|
205
|
+
content = f["content"].lower()
|
|
206
|
+
if "fastapi" in content:
|
|
207
|
+
framework = "fastapi"
|
|
208
|
+
start_command = "uvicorn main:app --host 0.0.0.0 --port 8000"
|
|
209
|
+
elif "django" in content:
|
|
210
|
+
framework = "django"
|
|
211
|
+
start_command = "python manage.py runserver 0.0.0.0:8000"
|
|
212
|
+
elif "flask" in content:
|
|
213
|
+
framework = "flask"
|
|
214
|
+
start_command = "python app.py"
|
|
215
|
+
break
|
|
216
|
+
|
|
217
|
+
return RailpackDetectionResult(
|
|
218
|
+
framework=framework,
|
|
219
|
+
confidence="high",
|
|
220
|
+
detected_from=detected_from,
|
|
221
|
+
package_manager=package_manager,
|
|
222
|
+
runtime_name="python",
|
|
223
|
+
start_command=start_command
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# === Go Detection ===
|
|
227
|
+
if "go.mod" in file_names:
|
|
228
|
+
framework = "go"
|
|
229
|
+
|
|
230
|
+
# Check for specific frameworks
|
|
231
|
+
for f in file_manifest:
|
|
232
|
+
if f.get("path") == "go.mod" and f.get("content"):
|
|
233
|
+
content = f["content"].lower()
|
|
234
|
+
if "gin-gonic" in content:
|
|
235
|
+
framework = "gin"
|
|
236
|
+
elif "labstack/echo" in content:
|
|
237
|
+
framework = "echo"
|
|
238
|
+
break
|
|
239
|
+
|
|
240
|
+
return RailpackDetectionResult(
|
|
241
|
+
framework=framework,
|
|
242
|
+
confidence="high",
|
|
243
|
+
detected_from="go.mod",
|
|
244
|
+
package_manager="go",
|
|
245
|
+
runtime_name="go"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# === Rust Detection ===
|
|
249
|
+
if "Cargo.toml" in file_names:
|
|
250
|
+
return RailpackDetectionResult(
|
|
251
|
+
framework="rust",
|
|
252
|
+
confidence="high",
|
|
253
|
+
detected_from="Cargo.toml",
|
|
254
|
+
package_manager="cargo",
|
|
255
|
+
runtime_name="rust"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# === Ruby Detection ===
|
|
259
|
+
if "Gemfile" in file_names:
|
|
260
|
+
framework = "ruby"
|
|
261
|
+
if "config.ru" in file_names:
|
|
262
|
+
framework = "rails"
|
|
263
|
+
|
|
264
|
+
return RailpackDetectionResult(
|
|
265
|
+
framework=framework,
|
|
266
|
+
confidence="high",
|
|
267
|
+
detected_from="Gemfile",
|
|
268
|
+
package_manager="bundler",
|
|
269
|
+
runtime_name="ruby"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# === PHP Detection ===
|
|
273
|
+
if "composer.json" in file_names:
|
|
274
|
+
return RailpackDetectionResult(
|
|
275
|
+
framework="php",
|
|
276
|
+
confidence="high",
|
|
277
|
+
detected_from="composer.json",
|
|
278
|
+
package_manager="composer",
|
|
279
|
+
runtime_name="php"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# === Java Detection ===
|
|
283
|
+
if "pom.xml" in file_names or "build.gradle" in file_names:
|
|
284
|
+
return RailpackDetectionResult(
|
|
285
|
+
framework="java",
|
|
286
|
+
confidence="high",
|
|
287
|
+
detected_from="pom.xml" if "pom.xml" in file_names else "build.gradle",
|
|
288
|
+
package_manager="maven" if "pom.xml" in file_names else "gradle",
|
|
289
|
+
runtime_name="java"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# === Docker Detection ===
|
|
293
|
+
if "Dockerfile" in file_names:
|
|
294
|
+
return RailpackDetectionResult(
|
|
295
|
+
framework="docker",
|
|
296
|
+
confidence="medium",
|
|
297
|
+
detected_from="Dockerfile",
|
|
298
|
+
runtime_name="docker"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# === Static Site ===
|
|
302
|
+
if "index.html" in file_names:
|
|
303
|
+
return RailpackDetectionResult(
|
|
304
|
+
framework="static",
|
|
305
|
+
confidence="medium",
|
|
306
|
+
detected_from="index.html",
|
|
307
|
+
runtime_name="static"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# Default fallback
|
|
311
|
+
return RailpackDetectionResult(
|
|
312
|
+
framework="unknown",
|
|
313
|
+
confidence="low",
|
|
314
|
+
detected_from="default",
|
|
315
|
+
runtime_name="unknown"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def _parse_plan(self, plan: Dict[str, Any]) -> RailpackDetectionResult:
|
|
319
|
+
"""Parse Railpack plan JSON into detection result."""
|
|
320
|
+
runtime = plan.get("runtime", {})
|
|
321
|
+
phases = plan.get("phases", {})
|
|
322
|
+
caches = plan.get("caches", {})
|
|
323
|
+
|
|
324
|
+
runtime_name = runtime.get("name")
|
|
325
|
+
|
|
326
|
+
# Fuzzy detection for newer Railpack (0.17.x) where runtime field might be different
|
|
327
|
+
if not runtime_name or runtime_name == "unknown":
|
|
328
|
+
if any(k in caches for k in ("next", "node-modules", "npm-install", "yarn", "pnpm")):
|
|
329
|
+
runtime_name = "node"
|
|
330
|
+
elif any(k in caches for k in ("python", "pip", "uv", "poetry", "pipenv")):
|
|
331
|
+
runtime_name = "python"
|
|
332
|
+
elif "go-build" in caches:
|
|
333
|
+
runtime_name = "go"
|
|
334
|
+
elif any(k in caches for k in ("cargo", "cargo-cache", "rust")):
|
|
335
|
+
runtime_name = "rust"
|
|
336
|
+
elif "bundle" in caches or "ruby" in caches:
|
|
337
|
+
runtime_name = "ruby"
|
|
338
|
+
elif "composer" in caches:
|
|
339
|
+
runtime_name = "php"
|
|
340
|
+
|
|
341
|
+
# Fallback: Check deploy variables if cache scan failed
|
|
342
|
+
if runtime_name == "unknown":
|
|
343
|
+
variables = str(plan.get("deploy", {}).get("variables", {})).lower()
|
|
344
|
+
if "python" in variables:
|
|
345
|
+
runtime_name = "python"
|
|
346
|
+
elif "node" in variables or "npm" in variables:
|
|
347
|
+
runtime_name = "node"
|
|
348
|
+
elif "cargo" in variables or "rust" in variables:
|
|
349
|
+
runtime_name = "rust"
|
|
350
|
+
elif "go" in variables:
|
|
351
|
+
runtime_name = "go"
|
|
352
|
+
|
|
353
|
+
runtime_version = runtime.get("version")
|
|
354
|
+
|
|
355
|
+
# Get start command
|
|
356
|
+
start_phase = phases.get("start", {})
|
|
357
|
+
start_cmd = start_phase.get("cmd", "")
|
|
358
|
+
|
|
359
|
+
# Extract port from start command
|
|
360
|
+
detected_port = self._extract_port(start_cmd)
|
|
361
|
+
|
|
362
|
+
# Get build commands
|
|
363
|
+
build_commands = []
|
|
364
|
+
for phase_name in ["setup", "install", "build"]:
|
|
365
|
+
phase = phases.get(phase_name, {})
|
|
366
|
+
for cmd in phase.get("cmds", []):
|
|
367
|
+
build_commands.append(cmd)
|
|
368
|
+
|
|
369
|
+
# Map runtime to framework
|
|
370
|
+
framework = self._map_runtime_to_framework(runtime_name, plan)
|
|
371
|
+
|
|
372
|
+
# Detect package manager from packages
|
|
373
|
+
packages = plan.get("packages", {})
|
|
374
|
+
package_manager = self._detect_package_manager(runtime_name, packages)
|
|
375
|
+
|
|
376
|
+
return RailpackDetectionResult(
|
|
377
|
+
framework=framework,
|
|
378
|
+
confidence="high",
|
|
379
|
+
detected_from="railpack",
|
|
380
|
+
package_manager=package_manager,
|
|
381
|
+
runtime_version=runtime_version,
|
|
382
|
+
start_command=start_cmd,
|
|
383
|
+
detected_port=detected_port,
|
|
384
|
+
build_commands=build_commands,
|
|
385
|
+
runtime_name=runtime_name
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
def _extract_port(self, cmd: str) -> Optional[int]:
|
|
389
|
+
"""Extract port from start command."""
|
|
390
|
+
import re
|
|
391
|
+
|
|
392
|
+
patterns = [
|
|
393
|
+
r'--port[=\s]*(\d+)',
|
|
394
|
+
r'-p[=\s]*(\d+)',
|
|
395
|
+
r'PORT[=\s]*(\d+)',
|
|
396
|
+
r':(\d+)', # URL-style :port
|
|
397
|
+
r'\s(\d{2,4})\s', # Standalone port number
|
|
398
|
+
]
|
|
399
|
+
|
|
400
|
+
for pattern in patterns:
|
|
401
|
+
match = re.search(pattern, cmd)
|
|
402
|
+
if match:
|
|
403
|
+
port = int(match.group(1))
|
|
404
|
+
if 1000 <= port <= 65535:
|
|
405
|
+
return port
|
|
406
|
+
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
def _map_runtime_to_framework(self, runtime_name: str, plan: Dict) -> str:
|
|
410
|
+
"""Map Railpack runtime name to Xenfra framework name."""
|
|
411
|
+
runtime_mapping = {
|
|
412
|
+
"python": "python",
|
|
413
|
+
"node": "nodejs",
|
|
414
|
+
"go": "go",
|
|
415
|
+
"rust": "rust",
|
|
416
|
+
"ruby": "ruby",
|
|
417
|
+
"php": "php",
|
|
418
|
+
"java": "java",
|
|
419
|
+
"deno": "deno",
|
|
420
|
+
"bun": "bun",
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
base_framework = runtime_mapping.get(runtime_name, runtime_name)
|
|
424
|
+
|
|
425
|
+
# Try to detect specific framework from packages, commands, or caches
|
|
426
|
+
packages = str(plan.get("packages", {})).lower()
|
|
427
|
+
cmds = str(plan.get("phases", {})).lower()
|
|
428
|
+
caches = str(plan.get("caches", {})).lower()
|
|
429
|
+
|
|
430
|
+
if runtime_name == "python":
|
|
431
|
+
if "fastapi" in packages or "fastapi" in cmds or "fastapi" in caches:
|
|
432
|
+
return "fastapi"
|
|
433
|
+
elif "django" in packages or "django" in cmds or "django" in caches:
|
|
434
|
+
return "django"
|
|
435
|
+
elif "flask" in packages or "flask" in cmds or "flask" in caches:
|
|
436
|
+
return "flask"
|
|
437
|
+
|
|
438
|
+
elif runtime_name == "node":
|
|
439
|
+
if "next" in packages or "next" in cmds or "next" in caches:
|
|
440
|
+
return "nextjs"
|
|
441
|
+
elif "express" in packages or "express" in cmds or "express" in caches:
|
|
442
|
+
return "express"
|
|
443
|
+
elif "nestjs" in packages or "nest" in cmds or "nest" in caches:
|
|
444
|
+
return "nestjs"
|
|
445
|
+
|
|
446
|
+
elif runtime_name == "go":
|
|
447
|
+
if "gin" in packages or "gin-gonic" in cmds or "gin" in caches:
|
|
448
|
+
return "gin"
|
|
449
|
+
elif "echo" in packages or "labstack" in cmds or "echo" in caches:
|
|
450
|
+
return "echo"
|
|
451
|
+
|
|
452
|
+
elif runtime_name == "ruby":
|
|
453
|
+
if "rails" in packages or "rails" in caches:
|
|
454
|
+
return "rails"
|
|
455
|
+
|
|
456
|
+
return base_framework
|
|
457
|
+
|
|
458
|
+
def _detect_package_manager(self, runtime_name: str, packages: Dict) -> Optional[str]:
|
|
459
|
+
"""Detect package manager from Railpack packages."""
|
|
460
|
+
pkg_str = str(packages).lower()
|
|
461
|
+
|
|
462
|
+
if runtime_name == "node":
|
|
463
|
+
if "pnpm" in pkg_str:
|
|
464
|
+
return "pnpm"
|
|
465
|
+
elif "yarn" in pkg_str:
|
|
466
|
+
return "yarn"
|
|
467
|
+
elif "bun" in pkg_str:
|
|
468
|
+
return "bun"
|
|
469
|
+
return "npm"
|
|
470
|
+
|
|
471
|
+
elif runtime_name == "python":
|
|
472
|
+
if "uv" in pkg_str:
|
|
473
|
+
return "uv"
|
|
474
|
+
elif "poetry" in pkg_str:
|
|
475
|
+
return "poetry"
|
|
476
|
+
elif "pipenv" in pkg_str:
|
|
477
|
+
return "pipenv"
|
|
478
|
+
return "pip"
|
|
479
|
+
|
|
480
|
+
elif runtime_name == "go":
|
|
481
|
+
return "go"
|
|
482
|
+
|
|
483
|
+
elif runtime_name == "rust":
|
|
484
|
+
return "cargo"
|
|
485
|
+
|
|
486
|
+
elif runtime_name == "ruby":
|
|
487
|
+
return "bundler"
|
|
488
|
+
|
|
489
|
+
elif runtime_name == "php":
|
|
490
|
+
return "composer"
|
|
491
|
+
|
|
492
|
+
elif runtime_name == "java":
|
|
493
|
+
if "gradle" in pkg_str:
|
|
494
|
+
return "gradle"
|
|
495
|
+
return "maven"
|
|
496
|
+
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
def parse_env_content(self, content: str) -> List[EnvVariable]:
|
|
500
|
+
"""
|
|
501
|
+
Parse .env file content into EnvVariable objects.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
content: The content of a .env file
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
List of EnvVariable objects
|
|
508
|
+
"""
|
|
509
|
+
lines = content.split('\n')
|
|
510
|
+
variables = []
|
|
511
|
+
|
|
512
|
+
for line in lines:
|
|
513
|
+
trimmed = line.strip()
|
|
514
|
+
|
|
515
|
+
# Skip empty lines and comments
|
|
516
|
+
if not trimmed or trimmed.startswith("#"):
|
|
517
|
+
continue
|
|
518
|
+
|
|
519
|
+
# Use partition for robust handling of '=' in values
|
|
520
|
+
key, sep, value = trimmed.partition('=')
|
|
521
|
+
|
|
522
|
+
if sep:
|
|
523
|
+
key = key.strip()
|
|
524
|
+
value = value.strip()
|
|
525
|
+
|
|
526
|
+
# Validate key
|
|
527
|
+
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', key):
|
|
528
|
+
continue
|
|
529
|
+
|
|
530
|
+
# Remove surrounding quotes if present
|
|
531
|
+
if ((value.startswith('"') and value.endswith('"')) or
|
|
532
|
+
(value.startswith("'") and value.endswith("'"))):
|
|
533
|
+
value = value[1:-1]
|
|
534
|
+
|
|
535
|
+
# Unescape escaped quotes
|
|
536
|
+
value = value.replace('\\"', '"').replace("\\'", "'")
|
|
537
|
+
|
|
538
|
+
# Detect if it looks like a secret
|
|
539
|
+
is_secret = bool(re.search(r'secret|key|token|password|private|api.?key|auth', key, re.IGNORECASE))
|
|
540
|
+
|
|
541
|
+
variables.append(EnvVariable(
|
|
542
|
+
id=f"env_{len(variables)}",
|
|
543
|
+
key=key,
|
|
544
|
+
value=value,
|
|
545
|
+
is_secret=is_secret
|
|
546
|
+
))
|
|
547
|
+
|
|
548
|
+
return variables
|
|
549
|
+
|
|
550
|
+
def _fallback_detection(self, project_path: str) -> RailpackDetectionResult:
|
|
551
|
+
"""Fallback to static file detection if Railpack fails."""
|
|
552
|
+
# Build file manifest from directory
|
|
553
|
+
file_manifest = []
|
|
554
|
+
for root, dirs, files in os.walk(project_path):
|
|
555
|
+
# Skip common non-project directories
|
|
556
|
+
dirs[:] = [d for d in dirs if d not in ('node_modules', '.git', '__pycache__', 'target', 'vendor')]
|
|
557
|
+
for file in files:
|
|
558
|
+
file_path = os.path.join(root, file)
|
|
559
|
+
rel_path = os.path.relpath(file_path, project_path)
|
|
560
|
+
file_manifest.append({"path": rel_path})
|
|
561
|
+
|
|
562
|
+
return self.detect_from_manifest(file_manifest)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
# Global detector instance
|
|
566
|
+
_railpack_detector: Optional[RailpackDetector] = None
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def get_railpack_detector(version: Optional[str] = None) -> RailpackDetector:
|
|
570
|
+
"""
|
|
571
|
+
Get or create global RailpackDetector instance.
|
|
572
|
+
|
|
573
|
+
Args:
|
|
574
|
+
version: Specific version to use. If None, uses default.
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
RailpackDetector singleton
|
|
578
|
+
"""
|
|
579
|
+
global _railpack_detector
|
|
580
|
+
|
|
581
|
+
if _railpack_detector is None:
|
|
582
|
+
_railpack_detector = RailpackDetector(version=version)
|
|
583
|
+
elif version is not None and _railpack_detector.version != version:
|
|
584
|
+
# Requested different version, create new instance
|
|
585
|
+
_railpack_detector = RailpackDetector(version=version)
|
|
586
|
+
|
|
587
|
+
return _railpack_detector
|