deployforge 0.1.0__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.
- deployforge/__init__.py +3 -0
- deployforge/__version__.py +3 -0
- deployforge/analyzer/__init__.py +23 -0
- deployforge/analyzer/backend.py +326 -0
- deployforge/analyzer/database.py +120 -0
- deployforge/analyzer/frontend.py +179 -0
- deployforge/analyzer/project.py +389 -0
- deployforge/analyzer/shared.py +109 -0
- deployforge/cli.py +825 -0
- deployforge/config.py +195 -0
- deployforge/deployment/__init__.py +19 -0
- deployforge/deployment/orchestrator.py +331 -0
- deployforge/deployment/planner.py +172 -0
- deployforge/deployment/verifier.py +65 -0
- deployforge/errors/__init__.py +53 -0
- deployforge/github/__init__.py +21 -0
- deployforge/github/integration.py +127 -0
- deployforge/integration/__init__.py +20 -0
- deployforge/integration/cors.py +30 -0
- deployforge/integration/environment.py +62 -0
- deployforge/integration/frontend_backend.py +39 -0
- deployforge/providers/__init__.py +32 -0
- deployforge/providers/base.py +151 -0
- deployforge/providers/render.py +218 -0
- deployforge/providers/vercel.py +205 -0
- deployforge/security/__init__.py +4 -0
- deployforge/security/gitignore.py +35 -0
- deployforge/security/scanner.py +125 -0
- deployforge/security/secrets.py +110 -0
- deployforge/ui/__init__.py +1 -0
- deployforge/ui/terminal.py +151 -0
- deployforge-0.1.0.dist-info/METADATA +218 -0
- deployforge-0.1.0.dist-info/RECORD +37 -0
- deployforge-0.1.0.dist-info/WHEEL +5 -0
- deployforge-0.1.0.dist-info/entry_points.txt +2 -0
- deployforge-0.1.0.dist-info/licenses/LICENSE +21 -0
- deployforge-0.1.0.dist-info/top_level.txt +1 -0
deployforge/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from deployforge.analyzer.backend import BACKEND_CORS_ENV_NAMES, detect_backend
|
|
2
|
+
from deployforge.analyzer.database import detect_database
|
|
3
|
+
from deployforge.analyzer.frontend import FRONTEND_API_ENV_NAMES, detect_frontend
|
|
4
|
+
from deployforge.analyzer.project import (
|
|
5
|
+
BackendApp,
|
|
6
|
+
FrontendApp,
|
|
7
|
+
ProjectAnalysis,
|
|
8
|
+
analyze_project,
|
|
9
|
+
suggest_name,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BACKEND_CORS_ENV_NAMES",
|
|
14
|
+
"BackendApp",
|
|
15
|
+
"FRONTEND_API_ENV_NAMES",
|
|
16
|
+
"FrontendApp",
|
|
17
|
+
"ProjectAnalysis",
|
|
18
|
+
"analyze_project",
|
|
19
|
+
"detect_backend",
|
|
20
|
+
"detect_database",
|
|
21
|
+
"detect_frontend",
|
|
22
|
+
"suggest_name",
|
|
23
|
+
]
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Backend application detection.
|
|
2
|
+
|
|
3
|
+
Handles Python (FastAPI, Flask, Django, generic) and Node.js (Express,
|
|
4
|
+
NestJS, Fastify, generic) backends. Detection relies on manifests, entry
|
|
5
|
+
point files, and framework references — never directory names alone.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from deployforge.analyzer.frontend import read_package_json
|
|
15
|
+
from deployforge.analyzer.shared import (
|
|
16
|
+
backend_framework_from_deps,
|
|
17
|
+
read_text_safe,
|
|
18
|
+
scan_env_references,
|
|
19
|
+
walk_files,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
BACKEND_CORS_ENV_NAMES: tuple[str, ...] = (
|
|
23
|
+
"FRONTEND_URL",
|
|
24
|
+
"FRONTEND_ORIGIN",
|
|
25
|
+
"ALLOWED_ORIGINS",
|
|
26
|
+
"CORS_ORIGIN",
|
|
27
|
+
"CORS_ORIGINS",
|
|
28
|
+
"CLIENT_URL",
|
|
29
|
+
"CLIENT_ORIGIN",
|
|
30
|
+
"WEB_ORIGIN",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
PYTHON_MARKERS = (
|
|
34
|
+
"requirements.txt",
|
|
35
|
+
"pyproject.toml",
|
|
36
|
+
"Pipfile",
|
|
37
|
+
"poetry.lock",
|
|
38
|
+
"setup.py",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
PYTHON_ENTRY_CANDIDATES = ("main.py", "app.py", "server.py", "api.py")
|
|
42
|
+
NODE_ENTRY_CANDIDATES = (
|
|
43
|
+
"server.js",
|
|
44
|
+
"server.ts",
|
|
45
|
+
"src/server.js",
|
|
46
|
+
"src/server.ts",
|
|
47
|
+
"index.js",
|
|
48
|
+
"index.ts",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class BackendDetection:
|
|
54
|
+
framework: str | None
|
|
55
|
+
language: str | None
|
|
56
|
+
entrypoint: Path | None
|
|
57
|
+
start_command: str | None
|
|
58
|
+
build_command: str | None
|
|
59
|
+
health_path: str | None
|
|
60
|
+
cors_env_names: list[str]
|
|
61
|
+
directory: Path
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def detected(self) -> bool:
|
|
65
|
+
return self.framework is not None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _fastapi_object(path: Path) -> tuple[str, str] | None:
|
|
69
|
+
"""Return (module_stem, object_name) for a FastAPI/Starlette entry file."""
|
|
70
|
+
try:
|
|
71
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
72
|
+
except OSError:
|
|
73
|
+
return None
|
|
74
|
+
match = re.search(r"(\w+)\s*=\s*(FastAPI|Starlette)\s*\(", text)
|
|
75
|
+
if match:
|
|
76
|
+
return path.stem, match.group(1)
|
|
77
|
+
return (path.stem, "app") if "FastAPI(" in text else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _flask_object(path: Path) -> tuple[str, str] | None:
|
|
81
|
+
try:
|
|
82
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
83
|
+
except OSError:
|
|
84
|
+
return None
|
|
85
|
+
match = re.search(r"(\w+)\s*=\s*Flask\s*\(", text)
|
|
86
|
+
if match:
|
|
87
|
+
return path.stem, match.group(1)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _detect_python(directory: Path) -> BackendDetection:
|
|
92
|
+
markers_present = any((directory / name).exists() for name in PYTHON_MARKERS)
|
|
93
|
+
py_files = [p for p in walk_files(directory) if p.suffix == ".py"]
|
|
94
|
+
detection = BackendDetection(
|
|
95
|
+
framework=None,
|
|
96
|
+
language="python",
|
|
97
|
+
entrypoint=None,
|
|
98
|
+
start_command=None,
|
|
99
|
+
build_command=None,
|
|
100
|
+
health_path=None,
|
|
101
|
+
cors_env_names=[],
|
|
102
|
+
directory=directory,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
framework: str | None = None
|
|
106
|
+
deps_files = ("requirements.txt", "pyproject.toml", "Pipfile", "poetry.lock")
|
|
107
|
+
deps_text = "".join(
|
|
108
|
+
read_text_safe(directory / name).lower()
|
|
109
|
+
for name in deps_files
|
|
110
|
+
if (directory / name).exists()
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if "fastapi" in deps_text:
|
|
114
|
+
framework = "FastAPI"
|
|
115
|
+
elif "flask" in deps_text:
|
|
116
|
+
framework = "Flask"
|
|
117
|
+
elif "django" in deps_text or (directory / "manage.py").exists():
|
|
118
|
+
framework = "Django"
|
|
119
|
+
elif markers_present or py_files:
|
|
120
|
+
framework = "Python"
|
|
121
|
+
|
|
122
|
+
detection.framework = framework
|
|
123
|
+
|
|
124
|
+
entry = _find_python_entrypoint(directory)
|
|
125
|
+
if framework == "FastAPI":
|
|
126
|
+
detection.start_command = _fastapi_start(directory, entry)
|
|
127
|
+
elif framework == "Flask":
|
|
128
|
+
detection.start_command = _flask_start(directory, entry)
|
|
129
|
+
elif framework == "Django":
|
|
130
|
+
detection.start_command = _django_start(directory)
|
|
131
|
+
elif framework == "Python":
|
|
132
|
+
detection.start_command = _fastapi_start(directory, entry)
|
|
133
|
+
|
|
134
|
+
detection.entrypoint = entry
|
|
135
|
+
detection.health_path = _detect_health_path(directory)
|
|
136
|
+
detection.cors_env_names = scan_env_references(directory, BACKEND_CORS_ENV_NAMES)
|
|
137
|
+
detection.build_command = _python_build_command(directory)
|
|
138
|
+
return detection
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _find_python_entrypoint(directory: Path) -> Path | None:
|
|
142
|
+
for name in PYTHON_ENTRY_CANDIDATES:
|
|
143
|
+
candidate = directory / name
|
|
144
|
+
if candidate.exists():
|
|
145
|
+
return candidate
|
|
146
|
+
for candidate in walk_files(directory):
|
|
147
|
+
if candidate.name in ("main.py", "app.py", "server.py", "api.py"):
|
|
148
|
+
text = read_text_safe(candidate)
|
|
149
|
+
if "FastAPI(" in text or "Flask(" in text or "Starlette(" in text:
|
|
150
|
+
return candidate
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _fastapi_start(directory: Path, entry: Path | None) -> str | None:
|
|
155
|
+
if entry is None:
|
|
156
|
+
return None
|
|
157
|
+
module = _to_module_path(directory, entry)
|
|
158
|
+
pair = _fastapi_object(entry)
|
|
159
|
+
obj = pair[1] if pair else "app"
|
|
160
|
+
return f"uvicorn {module}:{obj} --host 0.0.0.0 --port $PORT"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _to_module_path(directory: Path, entry: Path) -> str:
|
|
164
|
+
try:
|
|
165
|
+
rel = entry.relative_to(directory)
|
|
166
|
+
except ValueError:
|
|
167
|
+
return "main"
|
|
168
|
+
parts = list(rel.parts)
|
|
169
|
+
if parts and parts[-1].endswith(".py"):
|
|
170
|
+
parts[-1] = parts[-1][:-3]
|
|
171
|
+
if parts and parts[-1] == "__init__":
|
|
172
|
+
parts = parts[:-1]
|
|
173
|
+
return ".".join(parts) if parts else "main"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _flask_start(directory: Path, entry: Path | None) -> str | None:
|
|
177
|
+
if entry is None:
|
|
178
|
+
return None
|
|
179
|
+
module = _to_module_path(directory, entry)
|
|
180
|
+
pair = _flask_object(entry)
|
|
181
|
+
obj = pair[1] if pair else "app"
|
|
182
|
+
return f"gunicorn {module}:{obj} --bind 0.0.0.0:$PORT"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _django_start(directory: Path) -> str | None:
|
|
186
|
+
manage = directory / "manage.py"
|
|
187
|
+
project = None
|
|
188
|
+
if manage.exists():
|
|
189
|
+
try:
|
|
190
|
+
text = manage.read_text(encoding="utf-8", errors="ignore")
|
|
191
|
+
match = re.search(r"DJANGO_SETTINGS_MODULE\s*[=:]\s*['\"]?([\w.]+)['\"]?", text)
|
|
192
|
+
if match:
|
|
193
|
+
project = match.group(1).split(".")[0]
|
|
194
|
+
except OSError:
|
|
195
|
+
pass
|
|
196
|
+
if project is None and directory.name:
|
|
197
|
+
project = directory.name.replace("-", "_")
|
|
198
|
+
return f"gunicorn {project}.wsgi:application --bind 0.0.0.0:$PORT"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _python_build_command(directory: Path) -> str | None:
|
|
202
|
+
if (directory / "requirements.txt").exists():
|
|
203
|
+
return "pip install -r requirements.txt"
|
|
204
|
+
if (directory / "pyproject.toml").exists() or (directory / "setup.py").exists():
|
|
205
|
+
return "pip install -e ."
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _detect_health_path(directory: Path) -> str | None:
|
|
210
|
+
for path in walk_files(directory):
|
|
211
|
+
if path.suffix not in (".py", ".js", ".ts"):
|
|
212
|
+
continue
|
|
213
|
+
if "health" in path.name.lower():
|
|
214
|
+
continue
|
|
215
|
+
try:
|
|
216
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
217
|
+
except OSError:
|
|
218
|
+
continue
|
|
219
|
+
if '"/health"' in text or "'/health'" in text or "health/" in text:
|
|
220
|
+
return "/health"
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
_NODE_ENTRY_SUFFIXES = ("server.js", "server.ts", "index.js", "index.ts")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _detect_node(directory: Path) -> BackendDetection:
|
|
228
|
+
detection = BackendDetection(
|
|
229
|
+
framework=None,
|
|
230
|
+
language="node",
|
|
231
|
+
entrypoint=None,
|
|
232
|
+
start_command=None,
|
|
233
|
+
build_command=None,
|
|
234
|
+
health_path=None,
|
|
235
|
+
cors_env_names=[],
|
|
236
|
+
directory=directory,
|
|
237
|
+
)
|
|
238
|
+
package = read_package_json(directory)
|
|
239
|
+
if package is None:
|
|
240
|
+
return detection
|
|
241
|
+
|
|
242
|
+
backend_fw = backend_framework_from_deps(package)
|
|
243
|
+
if backend_fw:
|
|
244
|
+
detection.framework = backend_fw
|
|
245
|
+
else:
|
|
246
|
+
frontend_fw = _frontend_deps_present(package)
|
|
247
|
+
entry = _find_node_entrypoint(directory)
|
|
248
|
+
if not frontend_fw and entry is not None:
|
|
249
|
+
detection.framework = "Node.js"
|
|
250
|
+
detection.entrypoint = entry
|
|
251
|
+
|
|
252
|
+
if detection.framework is None:
|
|
253
|
+
return detection
|
|
254
|
+
|
|
255
|
+
entry = detection.entrypoint or _find_node_entrypoint(directory)
|
|
256
|
+
detection.entrypoint = entry
|
|
257
|
+
scripts = package.get("scripts")
|
|
258
|
+
if isinstance(scripts, dict) and isinstance(scripts.get("start"), str):
|
|
259
|
+
detection.start_command = scripts["start"]
|
|
260
|
+
elif entry is not None:
|
|
261
|
+
detection.start_command = f"node {_rel_path(directory, entry)}"
|
|
262
|
+
detection.build_command = _node_build_command(directory)
|
|
263
|
+
detection.health_path = _detect_health_path(directory)
|
|
264
|
+
detection.cors_env_names = scan_env_references(directory, BACKEND_CORS_ENV_NAMES)
|
|
265
|
+
return detection
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _frontend_deps_present(package: dict) -> bool:
|
|
269
|
+
names = set(_iter_dep_names(package))
|
|
270
|
+
return bool(names & {"react", "react-dom", "next", "vue", "svelte", "astro", "vite"})
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _iter_dep_names(package: dict) -> list[str]:
|
|
274
|
+
names: list[str] = []
|
|
275
|
+
for section in ("dependencies", "devDependencies"):
|
|
276
|
+
raw = package.get(section)
|
|
277
|
+
if isinstance(raw, dict):
|
|
278
|
+
names.extend(raw.keys())
|
|
279
|
+
return names
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _find_node_entrypoint(directory: Path) -> Path | None:
|
|
283
|
+
for name in _NODE_ENTRY_SUFFIXES:
|
|
284
|
+
for candidate in (directory / name, directory / "src" / name):
|
|
285
|
+
if candidate.exists():
|
|
286
|
+
return candidate
|
|
287
|
+
package = read_package_json(directory)
|
|
288
|
+
if package:
|
|
289
|
+
main = package.get("main")
|
|
290
|
+
if isinstance(main, str):
|
|
291
|
+
candidate = directory / main
|
|
292
|
+
if candidate.exists():
|
|
293
|
+
return candidate
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _rel_path(directory: Path, entry: Path) -> str:
|
|
298
|
+
try:
|
|
299
|
+
return entry.relative_to(directory).as_posix()
|
|
300
|
+
except ValueError:
|
|
301
|
+
return entry.name
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _node_build_command(directory: Path) -> str | None:
|
|
305
|
+
if (directory / "package-lock.json").exists():
|
|
306
|
+
return "npm ci"
|
|
307
|
+
if (directory / "pnpm-lock.yaml").exists():
|
|
308
|
+
return "pnpm install --frozen-lockfile"
|
|
309
|
+
if (directory / "yarn.lock").exists():
|
|
310
|
+
return "yarn install --frozen-lockfile"
|
|
311
|
+
if (directory / "package.json").exists():
|
|
312
|
+
return "npm install"
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def detect_backend(directory: Path) -> BackendDetection:
|
|
317
|
+
"""Classify *directory* as a backend application if the evidence supports it."""
|
|
318
|
+
if (directory / "package.json").exists():
|
|
319
|
+
node = _detect_node(directory)
|
|
320
|
+
# A node backend wins over python only when its evidence is real.
|
|
321
|
+
if node.detected:
|
|
322
|
+
return node
|
|
323
|
+
python = _detect_python(directory)
|
|
324
|
+
if python.detected:
|
|
325
|
+
return python
|
|
326
|
+
return python
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Database configuration detection.
|
|
2
|
+
|
|
3
|
+
DeployForge never provisions databases in the MVP. It only *reports* which
|
|
4
|
+
database a project appears to use so deployment plans preserve that
|
|
5
|
+
configuration instead of guessing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from deployforge.analyzer.shared import read_text_safe
|
|
14
|
+
|
|
15
|
+
DATABASE_ENV_MAP: tuple[tuple[str, str], ...] = (
|
|
16
|
+
("POSTGRES_URL", "PostgreSQL"),
|
|
17
|
+
("POSTGRESQL_URL", "PostgreSQL"),
|
|
18
|
+
("DATABASE_URL", "PostgreSQL"),
|
|
19
|
+
("MONGODB_URI", "MongoDB"),
|
|
20
|
+
("MONGO_URI", "MongoDB"),
|
|
21
|
+
("REDIS_URL", "Redis"),
|
|
22
|
+
("MYSQL_URL", "MySQL"),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
DATABASE_TEXT_KEYWORDS: tuple[tuple[str, str], ...] = (
|
|
26
|
+
("psycopg", "PostgreSQL"),
|
|
27
|
+
("postgres", "PostgreSQL"),
|
|
28
|
+
("djangodb", "PostgreSQL"),
|
|
29
|
+
("sqlalchemy", "PostgreSQL"),
|
|
30
|
+
("pymongo", "MongoDB"),
|
|
31
|
+
("redis", "Redis"),
|
|
32
|
+
("mysql", "MySQL"),
|
|
33
|
+
("sqlite", "SQLite"),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_MAX_DEPTH = 3
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class DatabaseDetection:
|
|
41
|
+
engine: str | None
|
|
42
|
+
env_name: str | None
|
|
43
|
+
configured: bool
|
|
44
|
+
presets: list[str]
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def detected(self) -> bool:
|
|
48
|
+
return self.engine is not None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _env_files(directory: Path) -> list[Path]:
|
|
52
|
+
result: list[Path] = []
|
|
53
|
+
for path in sorted(directory.rglob("*")):
|
|
54
|
+
if not path.is_file():
|
|
55
|
+
continue
|
|
56
|
+
if any(part in {".git", "node_modules", ".venv", "__pycache__"} for part in path.parts):
|
|
57
|
+
continue
|
|
58
|
+
parts = path.relative_to(directory).parts
|
|
59
|
+
if len(parts) > _MAX_DEPTH:
|
|
60
|
+
continue
|
|
61
|
+
is_env = path.name.startswith(".env") or ".env." in path.name
|
|
62
|
+
is_compose = path.name in {
|
|
63
|
+
"docker-compose.yml",
|
|
64
|
+
"docker-compose.yaml",
|
|
65
|
+
"compose.yml",
|
|
66
|
+
"compose.yaml",
|
|
67
|
+
}
|
|
68
|
+
if is_env or is_compose:
|
|
69
|
+
result.append(path)
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _scan_env_text(directory: Path) -> tuple[str | None, str | None]:
|
|
74
|
+
for path in _env_files(directory):
|
|
75
|
+
try:
|
|
76
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
77
|
+
except OSError:
|
|
78
|
+
continue
|
|
79
|
+
for name, engine in DATABASE_ENV_MAP:
|
|
80
|
+
if name in text:
|
|
81
|
+
return engine, name
|
|
82
|
+
return None, None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _scan_dependency_text(directory: Path) -> str | None:
|
|
86
|
+
text_parts: list[str] = []
|
|
87
|
+
for name in ("requirements.txt", "pyproject.toml", "Pipfile"):
|
|
88
|
+
text_parts.append(read_text_safe(directory / name).lower())
|
|
89
|
+
for path in directory.rglob("package.json"):
|
|
90
|
+
if any(part in {"node_modules", ".git"} for part in path.parts):
|
|
91
|
+
continue
|
|
92
|
+
text_parts.append(read_text_safe(path).lower())
|
|
93
|
+
return "\n".join(text_parts) or None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _keyword_engine(text: str) -> str | None:
|
|
97
|
+
for keyword, engine in DATABASE_TEXT_KEYWORDS:
|
|
98
|
+
if keyword in text:
|
|
99
|
+
return engine
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def detect_database(directory: Path) -> DatabaseDetection:
|
|
104
|
+
"""Detect which database a project appears to use."""
|
|
105
|
+
engine, env_name = _scan_env_text(directory)
|
|
106
|
+
presets: list[str] = []
|
|
107
|
+
|
|
108
|
+
text = _scan_dependency_text(directory)
|
|
109
|
+
if text:
|
|
110
|
+
keyword_engine = _keyword_engine(text)
|
|
111
|
+
if keyword_engine and keyword_engine != engine:
|
|
112
|
+
presets.append(keyword_engine)
|
|
113
|
+
|
|
114
|
+
configured = engine is not None
|
|
115
|
+
return DatabaseDetection(
|
|
116
|
+
engine=engine or (presets[0] if presets else None),
|
|
117
|
+
env_name=env_name,
|
|
118
|
+
configured=configured,
|
|
119
|
+
presets=presets,
|
|
120
|
+
)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Frontend application detection.
|
|
2
|
+
|
|
3
|
+
Detection uses multiple signals — dependency manifests, framework-specific
|
|
4
|
+
config files, package manager lockfiles, and source references to API
|
|
5
|
+
environment variables. A directory name alone never decides anything.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from deployforge.analyzer.shared import scan_env_references, strong_frontend_marker
|
|
15
|
+
|
|
16
|
+
FRONTEND_FRAMEWORKS = (
|
|
17
|
+
"Next.js",
|
|
18
|
+
"React",
|
|
19
|
+
"Vite",
|
|
20
|
+
"Vue",
|
|
21
|
+
"Nuxt",
|
|
22
|
+
"Angular",
|
|
23
|
+
"Astro",
|
|
24
|
+
"Svelte",
|
|
25
|
+
"SvelteKit",
|
|
26
|
+
"Static HTML",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_FRAMEWORK_DEPS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
30
|
+
("Next.js", ("next",)),
|
|
31
|
+
("React", ("react", "react-dom")),
|
|
32
|
+
("Vite", ("vite",)),
|
|
33
|
+
("Vue", ("vue",)),
|
|
34
|
+
("Nuxt", ("nuxt",)),
|
|
35
|
+
("Angular", ("@angular/core",)),
|
|
36
|
+
("Astro", ("astro",)),
|
|
37
|
+
("SvelteKit", ("@sveltejs/kit",)),
|
|
38
|
+
("Svelte", ("svelte",)),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Preferred order matters: the first referenced name wins when we wire up the
|
|
42
|
+
# frontend to the backend URL.
|
|
43
|
+
FRONTEND_API_ENV_NAMES: tuple[str, ...] = (
|
|
44
|
+
"NEXT_PUBLIC_API_URL",
|
|
45
|
+
"NEXT_PUBLIC_API_BASE_URL",
|
|
46
|
+
"NEXT_PUBLIC_BACKEND_URL",
|
|
47
|
+
"VITE_API_URL",
|
|
48
|
+
"VITE_API_BASE_URL",
|
|
49
|
+
"VITE_BACKEND_URL",
|
|
50
|
+
"REACT_APP_API_URL",
|
|
51
|
+
"REACT_APP_API_BASE_URL",
|
|
52
|
+
"REACT_APP_BACKEND_URL",
|
|
53
|
+
"NUXT_PUBLIC_API_URL",
|
|
54
|
+
"NG_API_URL",
|
|
55
|
+
"SVELTEKIT_API_URL",
|
|
56
|
+
"API_URL",
|
|
57
|
+
"API_BASE_URL",
|
|
58
|
+
"BACKEND_URL",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
PACKAGE_MANAGER_LOCKS = (
|
|
62
|
+
("package-lock.json", "npm"),
|
|
63
|
+
("pnpm-lock.yaml", "pnpm"),
|
|
64
|
+
("yarn.lock", "yarn"),
|
|
65
|
+
("bun.lockb", "bun"),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class FrontendDetection:
|
|
71
|
+
framework: str | None
|
|
72
|
+
package_manager: str | None
|
|
73
|
+
build_command: str | None
|
|
74
|
+
output_directory: str | None
|
|
75
|
+
api_url_names: list[str]
|
|
76
|
+
directory: Path
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def detected(self) -> bool:
|
|
80
|
+
return self.framework is not None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def read_package_json(directory: Path) -> dict | None:
|
|
84
|
+
pkg = directory / "package.json"
|
|
85
|
+
if not pkg.exists():
|
|
86
|
+
return None
|
|
87
|
+
try:
|
|
88
|
+
data = json.loads(pkg.read_text(encoding="utf-8", errors="ignore"))
|
|
89
|
+
except (OSError, json.JSONDecodeError):
|
|
90
|
+
return None
|
|
91
|
+
return data if isinstance(data, dict) else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def frontend_framework_from_deps(package: dict) -> str | None:
|
|
95
|
+
deps: dict = {}
|
|
96
|
+
for section in ("dependencies", "devDependencies", "peerDependencies"):
|
|
97
|
+
raw = package.get(section)
|
|
98
|
+
if isinstance(raw, dict):
|
|
99
|
+
deps.update(raw)
|
|
100
|
+
for framework, needles in _FRAMEWORK_DEPS:
|
|
101
|
+
if any(needle in deps for needle in needles):
|
|
102
|
+
return framework
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _detect_package_manager(directory: Path) -> str | None:
|
|
107
|
+
for lock, manager in PACKAGE_MANAGER_LOCKS:
|
|
108
|
+
if (directory / lock).exists():
|
|
109
|
+
return manager
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _detect_build_command(directory: Path) -> str | None:
|
|
114
|
+
package = read_package_json(directory)
|
|
115
|
+
if not package:
|
|
116
|
+
return None
|
|
117
|
+
scripts = package.get("scripts")
|
|
118
|
+
if isinstance(scripts, dict):
|
|
119
|
+
builder = scripts.get("build") or scripts.get("deploy")
|
|
120
|
+
if isinstance(builder, str) and builder:
|
|
121
|
+
return builder
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _detect_framework_from_config(directory: Path) -> str | None:
|
|
126
|
+
for name in (
|
|
127
|
+
"next.config.js",
|
|
128
|
+
"next.config.mjs",
|
|
129
|
+
"next.config.ts",
|
|
130
|
+
"next-env.d.ts",
|
|
131
|
+
):
|
|
132
|
+
if (directory / name).exists():
|
|
133
|
+
return "Next.js"
|
|
134
|
+
for name in ("vite.config.js", "vite.config.ts", "vite.config.mjs"):
|
|
135
|
+
if (directory / name).exists():
|
|
136
|
+
return "Vite"
|
|
137
|
+
if (directory / "nuxt.config.js").exists() or (directory / "nuxt.config.ts").exists():
|
|
138
|
+
return "Nuxt"
|
|
139
|
+
if (directory / "astro.config.js").exists() or (directory / "astro.config.ts").exists():
|
|
140
|
+
return "Astro"
|
|
141
|
+
if (directory / "svelte.config.js").exists() or (directory / "svelte.config.ts").exists():
|
|
142
|
+
return "SvelteKit"
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def detect_frontend(directory: Path) -> FrontendDetection:
|
|
147
|
+
"""Classify *directory* as a frontend application if the evidence supports it."""
|
|
148
|
+
detected = FrontendDetection(
|
|
149
|
+
framework=None,
|
|
150
|
+
package_manager=None,
|
|
151
|
+
build_command=None,
|
|
152
|
+
output_directory=None,
|
|
153
|
+
api_url_names=[],
|
|
154
|
+
directory=directory,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if strong_frontend_marker(directory):
|
|
158
|
+
detected.framework = _detect_framework_from_config(directory)
|
|
159
|
+
detected.package_manager = _detect_package_manager(directory)
|
|
160
|
+
detected.build_command = _detect_build_command(directory)
|
|
161
|
+
detected.api_url_names = scan_env_references(directory, FRONTEND_API_ENV_NAMES)
|
|
162
|
+
return detected
|
|
163
|
+
|
|
164
|
+
package = read_package_json(directory)
|
|
165
|
+
if package:
|
|
166
|
+
framework = frontend_framework_from_deps(package)
|
|
167
|
+
if framework:
|
|
168
|
+
detected.framework = framework
|
|
169
|
+
detected.package_manager = _detect_package_manager(directory)
|
|
170
|
+
detected.build_command = _detect_build_command(directory)
|
|
171
|
+
detected.api_url_names = scan_env_references(directory, FRONTEND_API_ENV_NAMES)
|
|
172
|
+
return detected
|
|
173
|
+
|
|
174
|
+
if (directory / "index.html").exists():
|
|
175
|
+
detected.framework = "Static HTML"
|
|
176
|
+
detected.api_url_names = scan_env_references(directory, FRONTEND_API_ENV_NAMES)
|
|
177
|
+
return detected
|
|
178
|
+
|
|
179
|
+
return detected
|