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.
Files changed (37) hide show
  1. deployforge/__init__.py +3 -0
  2. deployforge/__version__.py +3 -0
  3. deployforge/analyzer/__init__.py +23 -0
  4. deployforge/analyzer/backend.py +326 -0
  5. deployforge/analyzer/database.py +120 -0
  6. deployforge/analyzer/frontend.py +179 -0
  7. deployforge/analyzer/project.py +389 -0
  8. deployforge/analyzer/shared.py +109 -0
  9. deployforge/cli.py +825 -0
  10. deployforge/config.py +195 -0
  11. deployforge/deployment/__init__.py +19 -0
  12. deployforge/deployment/orchestrator.py +331 -0
  13. deployforge/deployment/planner.py +172 -0
  14. deployforge/deployment/verifier.py +65 -0
  15. deployforge/errors/__init__.py +53 -0
  16. deployforge/github/__init__.py +21 -0
  17. deployforge/github/integration.py +127 -0
  18. deployforge/integration/__init__.py +20 -0
  19. deployforge/integration/cors.py +30 -0
  20. deployforge/integration/environment.py +62 -0
  21. deployforge/integration/frontend_backend.py +39 -0
  22. deployforge/providers/__init__.py +32 -0
  23. deployforge/providers/base.py +151 -0
  24. deployforge/providers/render.py +218 -0
  25. deployforge/providers/vercel.py +205 -0
  26. deployforge/security/__init__.py +4 -0
  27. deployforge/security/gitignore.py +35 -0
  28. deployforge/security/scanner.py +125 -0
  29. deployforge/security/secrets.py +110 -0
  30. deployforge/ui/__init__.py +1 -0
  31. deployforge/ui/terminal.py +151 -0
  32. deployforge-0.1.0.dist-info/METADATA +218 -0
  33. deployforge-0.1.0.dist-info/RECORD +37 -0
  34. deployforge-0.1.0.dist-info/WHEEL +5 -0
  35. deployforge-0.1.0.dist-info/entry_points.txt +2 -0
  36. deployforge-0.1.0.dist-info/licenses/LICENSE +21 -0
  37. deployforge-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """DeployForge — from GitHub to a Live Application. Automatically."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ """Package version for DeployForge."""
2
+
3
+ __version__ = "0.1.0"
@@ -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