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,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