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
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Project topology analysis.
|
|
2
|
+
|
|
3
|
+
DeployForge analyzes a repository and identifies:
|
|
4
|
+
|
|
5
|
+
* the frontend application (if any),
|
|
6
|
+
* the backend application (if any),
|
|
7
|
+
* the database configuration (if any),
|
|
8
|
+
* monorepo workspaces,
|
|
9
|
+
* the backing GitHub repository.
|
|
10
|
+
|
|
11
|
+
Detection uses multiple signals and never relies on a directory name alone.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from deployforge.analyzer.backend import BackendDetection, detect_backend
|
|
22
|
+
from deployforge.analyzer.database import detect_database
|
|
23
|
+
from deployforge.analyzer.frontend import FrontendDetection, detect_frontend
|
|
24
|
+
from deployforge.analyzer.shared import (
|
|
25
|
+
is_backend_dir_name,
|
|
26
|
+
strong_frontend_marker,
|
|
27
|
+
)
|
|
28
|
+
from deployforge.config import ProjectConfig, ProjectConfigManager
|
|
29
|
+
from deployforge.github.integration import (
|
|
30
|
+
RepoInfo,
|
|
31
|
+
current_branch,
|
|
32
|
+
detect_repository,
|
|
33
|
+
has_local_git,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
IGNORED_DIRS = {
|
|
37
|
+
".git",
|
|
38
|
+
".github",
|
|
39
|
+
"node_modules",
|
|
40
|
+
"__pycache__",
|
|
41
|
+
".venv",
|
|
42
|
+
"venv",
|
|
43
|
+
"dist",
|
|
44
|
+
"build",
|
|
45
|
+
".next",
|
|
46
|
+
".deployforge",
|
|
47
|
+
".pytest_cache",
|
|
48
|
+
".ruff_cache",
|
|
49
|
+
".mypy_cache",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class FrontendApp:
|
|
55
|
+
framework: str
|
|
56
|
+
directory: Path
|
|
57
|
+
package_manager: str | None
|
|
58
|
+
build_command: str | None
|
|
59
|
+
output_directory: str | None
|
|
60
|
+
output_file: str | None = None
|
|
61
|
+
api_url_names: list[str] = field(default_factory=list)
|
|
62
|
+
provider: str = "vercel"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class BackendApp:
|
|
67
|
+
framework: str
|
|
68
|
+
language: str | None
|
|
69
|
+
directory: Path
|
|
70
|
+
entrypoint: Path | None
|
|
71
|
+
start_command: str | None
|
|
72
|
+
build_command: str | None
|
|
73
|
+
health_path: str | None
|
|
74
|
+
cors_env_names: list[str] = field(default_factory=list)
|
|
75
|
+
provider: str = "render"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ProjectAnalysis:
|
|
80
|
+
root: Path
|
|
81
|
+
name: str
|
|
82
|
+
frontends: list[FrontendApp]
|
|
83
|
+
backends: list[BackendApp]
|
|
84
|
+
database: object
|
|
85
|
+
workspaces: list[Path]
|
|
86
|
+
repo: RepoInfo
|
|
87
|
+
is_monorepo: bool = False
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def has_frontend(self) -> bool:
|
|
91
|
+
return bool(self.frontends)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def has_backend(self) -> bool:
|
|
95
|
+
return bool(self.backends)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def frontend(self) -> FrontendApp | None:
|
|
99
|
+
return self.frontends[0] if self.frontends else None
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def backend(self) -> BackendApp | None:
|
|
103
|
+
return self.backends[0] if self.backends else None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def suggest_name(root: Path) -> str:
|
|
107
|
+
raw = root.name or "project"
|
|
108
|
+
slug = re.sub(r"[^a-zA-Z0-9\-_.]+", "-", raw).strip("-._")
|
|
109
|
+
return slug.lower() or "project"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def read_package_json(root: Path) -> dict | None:
|
|
113
|
+
path = root / "package.json"
|
|
114
|
+
if not path.exists():
|
|
115
|
+
return None
|
|
116
|
+
try:
|
|
117
|
+
data = json.loads(path.read_text(encoding="utf-8", errors="ignore"))
|
|
118
|
+
except (OSError, json.JSONDecodeError):
|
|
119
|
+
return None
|
|
120
|
+
return data if isinstance(data, dict) else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _workspace_globs(root: Path) -> list[str]:
|
|
124
|
+
globs: list[str] = []
|
|
125
|
+
if (root / "pnpm-workspace.yaml").exists():
|
|
126
|
+
try:
|
|
127
|
+
text = (root / "pnpm-workspace.yaml").read_text(encoding="utf-8", errors="ignore")
|
|
128
|
+
match = re.search(r"packages:\s*\n((?:\s*-\s*.+\n?)+)", text)
|
|
129
|
+
if match:
|
|
130
|
+
for line in match.group(1).splitlines():
|
|
131
|
+
line = line.strip()
|
|
132
|
+
if line.startswith("-"):
|
|
133
|
+
globs.append(line[1:].strip())
|
|
134
|
+
except OSError:
|
|
135
|
+
pass
|
|
136
|
+
package = read_package_json(root)
|
|
137
|
+
if package:
|
|
138
|
+
workspaces = package.get("workspaces")
|
|
139
|
+
if isinstance(workspaces, list):
|
|
140
|
+
globs.extend(str(entry) for entry in workspaces if isinstance(entry, str))
|
|
141
|
+
elif isinstance(workspaces, dict):
|
|
142
|
+
entries = workspaces.get("packages")
|
|
143
|
+
if isinstance(entries, list):
|
|
144
|
+
globs.extend(str(entry) for entry in entries if isinstance(entry, str))
|
|
145
|
+
return globs
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _resolve_workspaces(root: Path) -> list[Path]:
|
|
149
|
+
resolved: list[Path] = []
|
|
150
|
+
globs = _workspace_globs(root)
|
|
151
|
+
if not globs:
|
|
152
|
+
for parent in ("apps", "app", "services"):
|
|
153
|
+
folder = root / parent
|
|
154
|
+
if folder.is_dir():
|
|
155
|
+
resolved.extend(p for p in folder.iterdir() if p.is_dir())
|
|
156
|
+
return resolved
|
|
157
|
+
for glob in globs:
|
|
158
|
+
try:
|
|
159
|
+
matches = list(root.glob(glob))
|
|
160
|
+
except OSError:
|
|
161
|
+
continue
|
|
162
|
+
for match in matches:
|
|
163
|
+
if match.is_dir() and match not in resolved:
|
|
164
|
+
resolved.append(match)
|
|
165
|
+
if not resolved:
|
|
166
|
+
for parent in ("apps", "app", "services"):
|
|
167
|
+
folder = root / parent
|
|
168
|
+
if folder.is_dir():
|
|
169
|
+
resolved.extend(p for p in folder.iterdir() if p.is_dir())
|
|
170
|
+
return resolved
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _has_python_backend_markers(directory: Path) -> bool:
|
|
174
|
+
for name in ("requirements.txt", "manage.py", "Pipfile", "poetry.lock"):
|
|
175
|
+
if (directory / name).exists():
|
|
176
|
+
return True
|
|
177
|
+
if (directory / "pyproject.toml").exists():
|
|
178
|
+
return True
|
|
179
|
+
return any(p.suffix == ".py" for p in directory.iterdir() if p.is_file())
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _has_node_backend_evidence(directory: Path) -> bool:
|
|
183
|
+
package = read_package_json(directory)
|
|
184
|
+
if not package:
|
|
185
|
+
return False
|
|
186
|
+
from deployforge.analyzer.shared import backend_framework_from_deps
|
|
187
|
+
|
|
188
|
+
return backend_framework_from_deps(package) is not None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _workspace_candidate(directory: Path) -> bool:
|
|
192
|
+
if strong_frontend_marker(directory):
|
|
193
|
+
return True
|
|
194
|
+
if _has_python_backend_markers(directory):
|
|
195
|
+
return True
|
|
196
|
+
if _has_node_backend_evidence(directory):
|
|
197
|
+
return True
|
|
198
|
+
parent = directory.parent.name
|
|
199
|
+
return parent in {"apps", "app", "services", "applications"}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _has_markers(directory: Path) -> bool:
|
|
203
|
+
return bool(
|
|
204
|
+
strong_frontend_marker(directory)
|
|
205
|
+
or (directory / "package.json").exists()
|
|
206
|
+
or (directory / "requirements.txt").exists()
|
|
207
|
+
or (directory / "pyproject.toml").exists()
|
|
208
|
+
or (directory / "Pipfile").exists()
|
|
209
|
+
or (directory / "index.html").exists()
|
|
210
|
+
or (directory / "main.py").exists()
|
|
211
|
+
or (directory / "manage.py").exists()
|
|
212
|
+
or any(p.suffix == ".py" for p in directory.iterdir() if p.is_file())
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def discover_candidates(root: Path, is_monorepo: bool) -> list[Path]:
|
|
217
|
+
if is_monorepo:
|
|
218
|
+
return [w for w in _resolve_workspaces(root) if _workspace_candidate(w)]
|
|
219
|
+
candidates: list[Path] = []
|
|
220
|
+
if _has_markers(root):
|
|
221
|
+
candidates.append(root)
|
|
222
|
+
for entry in sorted(root.iterdir()):
|
|
223
|
+
if not entry.is_dir() or entry.name in IGNORED_DIRS:
|
|
224
|
+
continue
|
|
225
|
+
if _has_markers(entry):
|
|
226
|
+
candidates.append(entry)
|
|
227
|
+
return candidates
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _classify(directory: Path) -> tuple[str, FrontendDetection | BackendDetection] | None:
|
|
231
|
+
"""Return ("frontend", det) or ("backend", det) based on evidence."""
|
|
232
|
+
if strong_frontend_marker(directory):
|
|
233
|
+
det = detect_frontend(directory)
|
|
234
|
+
return ("frontend", det)
|
|
235
|
+
|
|
236
|
+
backend_det = detect_backend(directory)
|
|
237
|
+
if backend_det.detected:
|
|
238
|
+
return ("backend", backend_det)
|
|
239
|
+
|
|
240
|
+
frontend_det = detect_frontend(directory)
|
|
241
|
+
if frontend_det.detected:
|
|
242
|
+
return ("frontend", frontend_det)
|
|
243
|
+
|
|
244
|
+
if (directory / "package.json").exists():
|
|
245
|
+
package = read_package_json(directory)
|
|
246
|
+
if package:
|
|
247
|
+
frontend_fw = frontend_framework_from_package(package)
|
|
248
|
+
backend_fw = backend_framework_from_package(package)
|
|
249
|
+
if frontend_fw and not backend_fw:
|
|
250
|
+
frontend_det = detect_frontend(directory)
|
|
251
|
+
if frontend_det.detected:
|
|
252
|
+
return ("frontend", frontend_det)
|
|
253
|
+
if backend_fw and not frontend_fw:
|
|
254
|
+
backend_det = detect_backend(directory)
|
|
255
|
+
if backend_det.detected:
|
|
256
|
+
return ("backend", backend_det)
|
|
257
|
+
|
|
258
|
+
if (directory / "index.html").exists():
|
|
259
|
+
html_det = detect_frontend(directory)
|
|
260
|
+
if html_det.detected:
|
|
261
|
+
return ("frontend", html_det)
|
|
262
|
+
|
|
263
|
+
# Weak last signal: directory names are only accepted with additional
|
|
264
|
+
# Python marker evidence, never for deployment on the name alone.
|
|
265
|
+
if is_backend_dir_name(directory.name) and _has_python_backend_markers(directory):
|
|
266
|
+
marker_det = detect_backend(directory)
|
|
267
|
+
if marker_det.detected:
|
|
268
|
+
return ("backend", marker_det)
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def frontend_framework_from_package(package: dict) -> str | None:
|
|
273
|
+
deps: dict = {}
|
|
274
|
+
for section in ("dependencies", "devDependencies", "peerDependencies"):
|
|
275
|
+
raw = package.get(section)
|
|
276
|
+
if isinstance(raw, dict):
|
|
277
|
+
deps.update(raw)
|
|
278
|
+
names = set(_dep_names(package))
|
|
279
|
+
if "next" in names:
|
|
280
|
+
return "Next.js"
|
|
281
|
+
if "vue" in names:
|
|
282
|
+
return "Vue"
|
|
283
|
+
if "react" in names or "react-dom" in names:
|
|
284
|
+
return "React"
|
|
285
|
+
if "svelte" in names:
|
|
286
|
+
return "Svelte"
|
|
287
|
+
if "astro" in names:
|
|
288
|
+
return "Astro"
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def backend_framework_from_package(package: dict) -> str | None:
|
|
293
|
+
from deployforge.analyzer.shared import backend_framework_from_deps
|
|
294
|
+
|
|
295
|
+
return backend_framework_from_deps(package)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _dep_names(package: dict) -> list[str]:
|
|
299
|
+
deps: list[str] = []
|
|
300
|
+
for section in ("dependencies", "devDependencies"):
|
|
301
|
+
raw = package.get(section)
|
|
302
|
+
if isinstance(raw, dict):
|
|
303
|
+
deps.extend(raw.keys())
|
|
304
|
+
return deps
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _to_frontend_app(det: FrontendDetection) -> FrontendApp:
|
|
308
|
+
return FrontendApp(
|
|
309
|
+
framework=det.framework or "Unknown",
|
|
310
|
+
directory=det.directory,
|
|
311
|
+
package_manager=det.package_manager,
|
|
312
|
+
build_command=det.build_command,
|
|
313
|
+
output_directory=det.output_directory,
|
|
314
|
+
api_url_names=det.api_url_names,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _to_backend_app(det: BackendDetection) -> BackendApp:
|
|
319
|
+
return BackendApp(
|
|
320
|
+
framework=det.framework or "Unknown",
|
|
321
|
+
language=det.language,
|
|
322
|
+
directory=det.directory,
|
|
323
|
+
entrypoint=det.entrypoint,
|
|
324
|
+
start_command=det.start_command,
|
|
325
|
+
build_command=det.build_command,
|
|
326
|
+
health_path=det.health_path,
|
|
327
|
+
cors_env_names=det.cors_env_names,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _apply_config_overrides(analysis: ProjectAnalysis, config: ProjectConfig) -> ProjectAnalysis:
|
|
332
|
+
if config.frontend_directory:
|
|
333
|
+
directory = analysis.root / config.frontend_directory
|
|
334
|
+
if directory.is_dir():
|
|
335
|
+
frontend_det = detect_frontend(directory)
|
|
336
|
+
if frontend_det.detected:
|
|
337
|
+
analysis.frontends = [_to_frontend_app(frontend_det)]
|
|
338
|
+
analysis.frontends[0].provider = config.frontend_provider
|
|
339
|
+
if config.backend_directory:
|
|
340
|
+
directory = analysis.root / config.backend_directory
|
|
341
|
+
if directory.is_dir():
|
|
342
|
+
backend_det = detect_backend(directory)
|
|
343
|
+
if backend_det.detected:
|
|
344
|
+
analysis.backends = [_to_backend_app(backend_det)]
|
|
345
|
+
analysis.backends[0].provider = config.backend_provider
|
|
346
|
+
return analysis
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def analyze_project(root: Path, config: ProjectConfig | None = None) -> ProjectAnalysis:
|
|
350
|
+
"""Analyze a project directory and return its topology."""
|
|
351
|
+
root = root.resolve()
|
|
352
|
+
config = config or ProjectConfigManager(root).load()
|
|
353
|
+
is_monorepo = bool(_workspace_globs(root))
|
|
354
|
+
candidates = discover_candidates(root, is_monorepo)
|
|
355
|
+
|
|
356
|
+
frontends: list[FrontendApp] = []
|
|
357
|
+
backends: list[BackendApp] = []
|
|
358
|
+
workspaces: list[Path] = _resolve_workspaces(root) if is_monorepo else []
|
|
359
|
+
|
|
360
|
+
for candidate in candidates:
|
|
361
|
+
classified = _classify(candidate)
|
|
362
|
+
if classified is None:
|
|
363
|
+
continue
|
|
364
|
+
kind, det = classified
|
|
365
|
+
if kind == "frontend" and isinstance(det, FrontendDetection):
|
|
366
|
+
frontend_app = _to_frontend_app(det)
|
|
367
|
+
if all(frontend_app.directory != existing.directory for existing in frontends):
|
|
368
|
+
frontends.append(frontend_app)
|
|
369
|
+
elif kind == "backend" and isinstance(det, BackendDetection):
|
|
370
|
+
backend_app = _to_backend_app(det)
|
|
371
|
+
if all(backend_app.directory != existing.directory for existing in backends):
|
|
372
|
+
backends.append(backend_app)
|
|
373
|
+
|
|
374
|
+
repo = detect_repository(root)
|
|
375
|
+
has_git = has_local_git(root)
|
|
376
|
+
if not has_git:
|
|
377
|
+
repo = RepoInfo(exists=False, branch=current_branch(root) or "main")
|
|
378
|
+
|
|
379
|
+
analysis = ProjectAnalysis(
|
|
380
|
+
root=root,
|
|
381
|
+
name=suggest_name(root),
|
|
382
|
+
frontends=frontends,
|
|
383
|
+
backends=backends,
|
|
384
|
+
database=detect_database(root),
|
|
385
|
+
workspaces=workspaces,
|
|
386
|
+
repo=repo,
|
|
387
|
+
is_monorepo=is_monorepo,
|
|
388
|
+
)
|
|
389
|
+
return _apply_config_overrides(analysis, config)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Shared detection helpers used by the analyzer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
# Directory names that commonly hold the frontend and backend of an
|
|
8
|
+
# application. These are *one* signal among several, never proof on their own.
|
|
9
|
+
FRONTEND_DIR_NAMES = {"frontend", "client", "web", "app", "website", "ui"}
|
|
10
|
+
BACKEND_DIR_NAMES = {"backend", "server", "api", "service", "api-server"}
|
|
11
|
+
|
|
12
|
+
BACKEND_FRAMEWORK_DEPS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
13
|
+
("Express", ("express",)),
|
|
14
|
+
("NestJS", ("@nestjs/core",)),
|
|
15
|
+
("Fastify", ("fastify",)),
|
|
16
|
+
("Hapi", ("@hapi/hapi",)),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
IGNORED_DIRS = {
|
|
20
|
+
".git",
|
|
21
|
+
"node_modules",
|
|
22
|
+
"__pycache__",
|
|
23
|
+
".venv",
|
|
24
|
+
"venv",
|
|
25
|
+
"dist",
|
|
26
|
+
"build",
|
|
27
|
+
".next",
|
|
28
|
+
".deployforge",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
MAX_FILE_BYTES = 1_000_000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def walk_files(directory: Path) -> list[Path]:
|
|
35
|
+
"""All files under *directory*, skipping heavy/ignored folders."""
|
|
36
|
+
result: list[Path] = []
|
|
37
|
+
if not directory.exists():
|
|
38
|
+
return result
|
|
39
|
+
for path in directory.rglob("*"):
|
|
40
|
+
if not path.is_file():
|
|
41
|
+
continue
|
|
42
|
+
if any(part in IGNORED_DIRS for part in path.parts):
|
|
43
|
+
continue
|
|
44
|
+
result.append(path)
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_text_safe(path: Path) -> str:
|
|
49
|
+
"""Return *path* contents or ``""`` on read errors."""
|
|
50
|
+
try:
|
|
51
|
+
return path.read_text(encoding="utf-8", errors="ignore")
|
|
52
|
+
except OSError:
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def scan_env_references(directory: Path, names: tuple[str, ...]) -> list[str]:
|
|
57
|
+
"""Return env var names that are actually referenced in the directory."""
|
|
58
|
+
found: set[str] = set()
|
|
59
|
+
for path in walk_files(directory):
|
|
60
|
+
try:
|
|
61
|
+
if path.stat().st_size > MAX_FILE_BYTES:
|
|
62
|
+
continue
|
|
63
|
+
except OSError:
|
|
64
|
+
continue
|
|
65
|
+
text = read_text_safe(path)
|
|
66
|
+
for name in names:
|
|
67
|
+
if name in text:
|
|
68
|
+
found.add(name)
|
|
69
|
+
return [name for name in names if name in found]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def strong_frontend_marker(directory: Path) -> bool:
|
|
73
|
+
"""True when framework-specific frontend config files exist."""
|
|
74
|
+
markers = (
|
|
75
|
+
"next.config.js",
|
|
76
|
+
"next.config.mjs",
|
|
77
|
+
"next.config.ts",
|
|
78
|
+
"next-env.d.ts",
|
|
79
|
+
"vite.config.js",
|
|
80
|
+
"vite.config.ts",
|
|
81
|
+
"vite.config.mjs",
|
|
82
|
+
"nuxt.config.js",
|
|
83
|
+
"nuxt.config.ts",
|
|
84
|
+
"astro.config.js",
|
|
85
|
+
"astro.config.ts",
|
|
86
|
+
"svelte.config.js",
|
|
87
|
+
"svelte.config.ts",
|
|
88
|
+
)
|
|
89
|
+
return any((directory / name).exists() for name in markers)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_frontend_dir_name(name: str) -> bool:
|
|
93
|
+
return name.lower() in FRONTEND_DIR_NAMES
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def is_backend_dir_name(name: str) -> bool:
|
|
97
|
+
return name.lower() in BACKEND_DIR_NAMES
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def backend_framework_from_deps(package: dict) -> str | None:
|
|
101
|
+
deps: dict = {}
|
|
102
|
+
for section in ("dependencies", "devDependencies", "peerDependencies"):
|
|
103
|
+
raw = package.get(section)
|
|
104
|
+
if isinstance(raw, dict):
|
|
105
|
+
deps.update(raw)
|
|
106
|
+
for framework, needles in BACKEND_FRAMEWORK_DEPS:
|
|
107
|
+
if any(needle in deps for needle in needles):
|
|
108
|
+
return framework
|
|
109
|
+
return None
|