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,172 @@
1
+ """Deployment planning.
2
+
3
+ Turns a :class:`ProjectAnalysis` into an ordered, safe deployment plan before
4
+ any provider API is touched.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ from deployforge.analyzer.project import ProjectAnalysis
13
+
14
+
15
+ @dataclass
16
+ class PlannedService:
17
+ kind: str
18
+ provider: str
19
+ directory: Path
20
+ framework: str
21
+ label: str
22
+
23
+
24
+ @dataclass
25
+ class PlannedEnvVar:
26
+ key: str
27
+ provider: str
28
+ applies_to: str
29
+ value: str = field(default="(obtained after deployment)")
30
+
31
+
32
+ @dataclass
33
+ class DeploymentPlan:
34
+ services: list[PlannedService]
35
+ env_vars: list[PlannedEnvVar]
36
+ actions: list[str]
37
+ database_note: str | None = None
38
+
39
+ @property
40
+ def has_backend(self) -> bool:
41
+ return any(s.kind == "backend" for s in self.services)
42
+
43
+ @property
44
+ def has_frontend(self) -> bool:
45
+ return any(s.kind == "frontend" for s in self.services)
46
+
47
+ def summarize(self) -> list[str]:
48
+ lines = ["DEPLOYMENT PLAN"]
49
+ if self.has_backend:
50
+ for service in self.services:
51
+ if service.kind == "backend":
52
+ lines.append(f"Backend: {service.framework} → {service.provider}")
53
+ if self.has_frontend:
54
+ for service in self.services:
55
+ if service.kind == "frontend":
56
+ lines.append(f"Frontend: {service.framework} → {service.provider}")
57
+ if self.env_vars:
58
+ lines.append("Connection:")
59
+ for env in self.env_vars:
60
+ lines.append(f" {env.key} → {env.provider}")
61
+ lines.append("Actions:")
62
+ for action in self.actions:
63
+ lines.append(f" {action}")
64
+ return lines
65
+
66
+
67
+ def default_api_var(frontend_framework: str) -> str:
68
+ mapping = {
69
+ "Next.js": "NEXT_PUBLIC_API_URL",
70
+ "Nuxt": "NUXT_PUBLIC_API_URL",
71
+ "Vite": "VITE_API_URL",
72
+ "React": "REACT_APP_API_URL",
73
+ "SvelteKit": "SVELTEKIT_API_URL",
74
+ }
75
+ return mapping.get(frontend_framework, "API_URL")
76
+
77
+
78
+ def default_cors_var() -> str:
79
+ return "FRONTEND_URL"
80
+
81
+
82
+ def build_plan(analysis: ProjectAnalysis) -> DeploymentPlan:
83
+ services: list[PlannedService] = []
84
+ env_vars: list[PlannedEnvVar] = []
85
+ actions: list[str] = []
86
+
87
+ backend = analysis.backend
88
+ frontend = analysis.frontend
89
+
90
+ if backend:
91
+ label = f"{analysis.name}-api" if frontend else analysis.name
92
+ services.append(
93
+ PlannedService(
94
+ kind="backend",
95
+ provider=backend.provider,
96
+ directory=backend.directory,
97
+ framework=backend.framework,
98
+ label=label,
99
+ )
100
+ )
101
+ actions.append("Deploy backend")
102
+ actions.append("Obtain backend URL")
103
+
104
+ if frontend:
105
+ services.append(
106
+ PlannedService(
107
+ kind="frontend",
108
+ provider=frontend.provider,
109
+ directory=frontend.directory,
110
+ framework=frontend.framework,
111
+ label=analysis.name,
112
+ )
113
+ )
114
+ actions.append("Deploy frontend")
115
+
116
+ if backend and frontend:
117
+ api_var = (
118
+ frontend.api_url_names[0]
119
+ if frontend.api_url_names
120
+ else default_api_var(frontend.framework)
121
+ )
122
+ env_vars.append(
123
+ PlannedEnvVar(
124
+ key=api_var,
125
+ provider=analysis.frontends[0].provider,
126
+ applies_to="frontend",
127
+ )
128
+ )
129
+ actions.append("Configure frontend environment")
130
+
131
+ cors_var = backend.cors_env_names[0] if backend.cors_env_names else None
132
+ if cors_var:
133
+ env_vars.append(
134
+ PlannedEnvVar(
135
+ key=cors_var,
136
+ provider=backend.provider,
137
+ applies_to="backend",
138
+ )
139
+ )
140
+ actions.append("Configure backend CORS")
141
+
142
+ actions.extend(
143
+ [
144
+ "Verify frontend",
145
+ "Verify backend",
146
+ "Verify frontend → backend communication",
147
+ ]
148
+ )
149
+
150
+ database_note = None
151
+ database = getattr(analysis, "database", None)
152
+ if database is not None:
153
+ engine = getattr(database, "engine", None)
154
+ configured = getattr(database, "configured", False)
155
+ if engine:
156
+ if configured:
157
+ database_note = (
158
+ f"Database ({engine}) configuration detected and preserved. "
159
+ "DeployForge does not migrate or modify databases."
160
+ )
161
+ else:
162
+ database_note = (
163
+ f"Database ({engine}) may be used; no connection configuration was found "
164
+ "in the project. DeployForge will not provision databases."
165
+ )
166
+
167
+ return DeploymentPlan(
168
+ services=services,
169
+ env_vars=env_vars,
170
+ actions=actions,
171
+ database_note=database_note,
172
+ )
@@ -0,0 +1,65 @@
1
+ """Deployment verification.
2
+
3
+ Checks that deployed services are reachable over HTTP and that expected
4
+ endpoints respond. Uses conservative defaults and never guesses endpoint
5
+ paths that were not detected in the source.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from dataclasses import dataclass
12
+ from urllib.parse import urljoin
13
+
14
+ import requests
15
+
16
+
17
+ @dataclass
18
+ class VerificationResult:
19
+ name: str
20
+ url: str
21
+ reachable: bool
22
+ status_code: int | None = None
23
+ health_ok: bool | None = None
24
+ latency_ms: float | None = None
25
+
26
+
27
+ @dataclass
28
+ class VerificationReport:
29
+ results: list[VerificationResult]
30
+
31
+ @property
32
+ def passed(self) -> bool:
33
+ return bool(self.results) and all(r.reachable for r in self.results)
34
+
35
+
36
+ def check_url(name: str, url: str, health_path: str | None = None) -> VerificationResult:
37
+ start = time.monotonic()
38
+ try:
39
+ response = requests.get(url, timeout=15, allow_redirects=True)
40
+ except requests.RequestException:
41
+ return VerificationResult(name=name, url=url, reachable=False, latency_ms=None)
42
+ latency_ms = round((time.monotonic() - start) * 1000, 1)
43
+ reachable = response.status_code < 400
44
+ health_ok: bool | None = None
45
+ if reachable and health_path:
46
+ try:
47
+ base = url.rstrip("/") + "/"
48
+ health = requests.get(urljoin(base, health_path.lstrip("/")), timeout=15)
49
+ health_ok = health.status_code < 500
50
+ except requests.RequestException:
51
+ health_ok = False
52
+ return VerificationResult(
53
+ name=name,
54
+ url=url,
55
+ reachable=reachable,
56
+ status_code=response.status_code,
57
+ health_ok=health_ok,
58
+ latency_ms=latency_ms,
59
+ )
60
+
61
+
62
+ def verify_endpoints(pairs: list[tuple[str, str, str | None]]) -> VerificationReport:
63
+ """Verify a list of ``(name, url, health_path)`` tuples."""
64
+ results = [check_url(name, url, health_path) for name, url, health_path in pairs]
65
+ return VerificationReport(results=results)
@@ -0,0 +1,53 @@
1
+ """Base exceptions for DeployForge."""
2
+
3
+
4
+ class DeployForgeError(Exception):
5
+ """Base exception for all DeployForge errors."""
6
+
7
+
8
+ class ConfigError(DeployForgeError):
9
+ """Raised when configuration is invalid or cannot be read."""
10
+
11
+
12
+ class ProjectError(DeployForgeError):
13
+ """Raised when a project cannot be analyzed or deployed."""
14
+
15
+
16
+ class GitHubError(DeployForgeError):
17
+ """Raised when a GitHub operation fails."""
18
+
19
+
20
+ class SecurityError(DeployForgeError):
21
+ """Raised when the security preflight detects a potential secret."""
22
+
23
+
24
+ class AuthenticationError(DeployForgeError):
25
+ """Raised when provider authentication is missing or invalid."""
26
+
27
+
28
+ class ProviderError(DeployForgeError):
29
+ """Raised when a deployment provider API call fails."""
30
+
31
+ def __init__(self, message: str, status_code: int | None = None) -> None:
32
+ super().__init__(message)
33
+ self.status_code = status_code
34
+
35
+
36
+ class DeploymentError(DeployForgeError):
37
+ """Raised when a deployment fails."""
38
+
39
+
40
+ class VerificationError(DeployForgeError):
41
+ """Raised when a deployment cannot be verified."""
42
+
43
+
44
+ class PartialDeploymentError(DeployForgeError):
45
+ """Raised when part of a deployment fails after earlier steps succeeded.
46
+
47
+ Carries the steps that succeeded so the caller can report "partial
48
+ deployment" accurately instead of claiming success or total failure.
49
+ """
50
+
51
+ def __init__(self, message: str, succeeded: list[str]) -> None:
52
+ super().__init__(message)
53
+ self.succeeded = succeeded
@@ -0,0 +1,21 @@
1
+ from deployforge.github.integration import (
2
+ RepoInfo,
3
+ current_branch,
4
+ detect_repository,
5
+ github_token,
6
+ has_local_git,
7
+ parse_github_url,
8
+ remote_urls,
9
+ repo_exists,
10
+ )
11
+
12
+ __all__ = [
13
+ "RepoInfo",
14
+ "current_branch",
15
+ "detect_repository",
16
+ "github_token",
17
+ "has_local_git",
18
+ "parse_github_url",
19
+ "remote_urls",
20
+ "repo_exists",
21
+ ]
@@ -0,0 +1,127 @@
1
+ """GitHub is the source of truth.
2
+
3
+ DeployForge does not create repositories. It reads the git remote of the
4
+ local project and, when possible, verifies the repository exists on GitHub.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import subprocess
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ import requests
16
+
17
+ from deployforge.errors import GitHubError
18
+
19
+ _GITHUB_URL_RE = re.compile(
20
+ r"^((?:https?://)?(?:www\.)?github\.com[:/]|git@github\.com:)([^/\s]+)/([^/\s]+?)(?:\.git)?$"
21
+ )
22
+
23
+
24
+ @dataclass
25
+ class RepoInfo:
26
+ exists: bool
27
+ host: str | None = None
28
+ owner: str | None = None
29
+ name: str | None = None
30
+ url: str | None = None
31
+ branch: str | None = None
32
+
33
+ def slug(self) -> str | None:
34
+ if self.owner and self.name:
35
+ return f"{self.owner}/{self.name}"
36
+ return None
37
+
38
+
39
+ def _run_git(project: Path, args: list[str]) -> str | None:
40
+ try:
41
+ result = subprocess.run(
42
+ ["git", "-C", str(project), *args],
43
+ capture_output=True,
44
+ text=True,
45
+ timeout=15,
46
+ check=False,
47
+ )
48
+ except (OSError, subprocess.SubprocessError):
49
+ return None
50
+ if result.returncode != 0:
51
+ return None
52
+ return result.stdout.strip()
53
+
54
+
55
+ def has_local_git(project: Path) -> bool:
56
+ inside = _run_git(project, ["rev-parse", "--is-inside-work-tree"]) == "true"
57
+ return (project / ".git").exists() or inside
58
+
59
+
60
+ def current_branch(project: Path) -> str | None:
61
+ return _run_git(project, ["branch", "--show-current"])
62
+
63
+
64
+ def remote_urls(project: Path) -> list[str]:
65
+ output = _run_git(project, ["remote", "-v"])
66
+ if not output:
67
+ return []
68
+ urls: list[str] = []
69
+ for line in output.splitlines():
70
+ parts = line.split()
71
+ if len(parts) >= 2:
72
+ urls.append(parts[1])
73
+ return urls
74
+
75
+
76
+ def parse_github_url(url: str) -> tuple[str, str] | None:
77
+ """Return (owner, name) for a GitHub URL, or None."""
78
+ candidate = url.strip()
79
+ if candidate.startswith("git@"):
80
+ candidate = candidate.replace("git@", "https://", 1).replace(":", "/", 1)
81
+ match = _GITHUB_URL_RE.match(candidate)
82
+ if not match:
83
+ return None
84
+ owner = match.group(2)
85
+ name = match.group(3).rstrip("/")
86
+ return owner, name
87
+
88
+
89
+ def detect_repository(project: Path) -> RepoInfo:
90
+ """Detect the GitHub repository backing *project* from its git remote."""
91
+ branch = current_branch(project) or "main"
92
+ if not has_local_git(project):
93
+ return RepoInfo(exists=False, branch=branch)
94
+
95
+ for url in remote_urls(project):
96
+ parsed = parse_github_url(url)
97
+ if parsed:
98
+ owner, name = parsed
99
+ return RepoInfo(
100
+ exists=True,
101
+ host="github.com",
102
+ owner=owner,
103
+ name=name,
104
+ url=f"https://github.com/{owner}/{name}",
105
+ branch=branch,
106
+ )
107
+ return RepoInfo(exists=False, branch=branch)
108
+
109
+
110
+ def github_token() -> str | None:
111
+ return os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
112
+
113
+
114
+ def repo_exists(repo: RepoInfo, token: str | None = None) -> bool:
115
+ """Verify a repository exists on GitHub without printing the token."""
116
+ slug = repo.slug()
117
+ if not slug:
118
+ return False
119
+ headers = {"Accept": "application/vnd.github+json"}
120
+ token = token or github_token()
121
+ if token:
122
+ headers["Authorization"] = f"Bearer {token}"
123
+ try:
124
+ response = requests.get(f"https://api.github.com/repos/{slug}", headers=headers, timeout=15)
125
+ except requests.RequestException as exc:
126
+ raise GitHubError(f"Unable to reach GitHub API: {exc}") from exc
127
+ return response.status_code == 200
@@ -0,0 +1,20 @@
1
+ from deployforge.integration.cors import (
2
+ cors_configured,
3
+ cors_origin_variable,
4
+ detect_cors_origin,
5
+ )
6
+ from deployforge.integration.environment import is_configured, read_env_value
7
+ from deployforge.integration.frontend_backend import (
8
+ connection_summary,
9
+ determine_api_variable,
10
+ )
11
+
12
+ __all__ = [
13
+ "connection_summary",
14
+ "cors_configured",
15
+ "cors_origin_variable",
16
+ "detect_cors_origin",
17
+ "determine_api_variable",
18
+ "is_configured",
19
+ "read_env_value",
20
+ ]
@@ -0,0 +1,30 @@
1
+ """CORS origin detection for backends.
2
+
3
+ DeployForge only proposes CORS changes through provider environment
4
+ variables when a backend references a configurable origin variable. It never
5
+ rewrites application source code.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from deployforge.analyzer.backend import BackendDetection
11
+ from deployforge.deployment.planner import default_cors_var
12
+
13
+
14
+ def detect_cors_origin(backend: BackendDetection | None) -> str | None:
15
+ """Return the CORS origin env var the backend expects, if detectable."""
16
+ if backend is None:
17
+ return None
18
+ if backend.cors_env_names:
19
+ return backend.cors_env_names[0]
20
+ return None
21
+
22
+
23
+ def cors_origin_variable(backend: BackendDetection | None) -> str:
24
+ """Return the var to use for a backend that expects a configurable origin."""
25
+ return detect_cors_origin(backend) or default_cors_var()
26
+
27
+
28
+ def cors_configured(backend: BackendDetection | None) -> bool:
29
+ """A backend is CORS-configurable only when evidence exists in its source."""
30
+ return bool(backend and backend.cors_env_names)
@@ -0,0 +1,62 @@
1
+ """Environment variable helpers for service wiring.
2
+
3
+ Reads what the project currently expects and reports it without modifying
4
+ source code. All actual wiring happens through provider APIs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from deployforge.analyzer.shared import walk_files
12
+
13
+ ENV_FILE_NAMES = (".env", ".env.local", ".env.production", ".env.development")
14
+
15
+
16
+ def read_env_value(directory: Path, key: str) -> str | None:
17
+ """Return the current value of *key* from any ``.env`` file, if present."""
18
+ for path in walk_files(directory):
19
+ if path.name not in ENV_FILE_NAMES:
20
+ continue
21
+ try:
22
+ text = path.read_text(encoding="utf-8", errors="ignore")
23
+ except OSError:
24
+ continue
25
+ for line in text.splitlines():
26
+ line = line.strip()
27
+ if not line or line.startswith("#") or "=" not in line:
28
+ continue
29
+ name, _, value = line.partition("=")
30
+ if name.strip() == key:
31
+ return value.strip().strip('"').strip("'")
32
+ return None
33
+
34
+
35
+ def is_configured(value: str | None) -> bool:
36
+ """True when a value looks real rather than a placeholder."""
37
+ if not value:
38
+ return False
39
+ lowered = value.lower()
40
+ if lowered in {
41
+ "unset",
42
+ "not-configured",
43
+ "none",
44
+ "null",
45
+ "changeme",
46
+ "set-me",
47
+ "to-be-configured",
48
+ }:
49
+ return False
50
+ return not any(
51
+ token in lowered
52
+ for token in (
53
+ "localhost",
54
+ "127.0.0.1",
55
+ "example",
56
+ "placeholder",
57
+ "your-url",
58
+ "your_url",
59
+ "yoururl",
60
+ "your-api",
61
+ )
62
+ )
@@ -0,0 +1,39 @@
1
+ """Frontend ↔ backend auto-connection.
2
+
3
+ After the backend is deployed, DeployForge wires the backend URL into the
4
+ frontend's environment. It only ever uses environment variables — it never
5
+ hard-codes URLs into source code.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from deployforge.analyzer.project import ProjectAnalysis
11
+ from deployforge.deployment.planner import default_api_var
12
+
13
+
14
+ def determine_api_variable(analysis: ProjectAnalysis) -> tuple[str | None, str | None]:
15
+ """Return the (env var, provider) used to connect a frontend to its backend."""
16
+ frontend = analysis.frontend
17
+ backend = analysis.backend
18
+ if not frontend or not backend:
19
+ return None, None
20
+ if frontend.api_url_names:
21
+ key = frontend.api_url_names[0]
22
+ else:
23
+ key = default_api_var(frontend.framework)
24
+ return key, frontend.provider
25
+
26
+
27
+ def connection_summary(analysis: ProjectAnalysis) -> list[str]:
28
+ lines: list[str] = []
29
+ frontend = analysis.frontend
30
+ backend = analysis.backend
31
+ if not frontend or not backend:
32
+ return lines
33
+ key, provider = determine_api_variable(analysis)
34
+ if key:
35
+ lines.append(f"Frontend → Backend: {key} on {provider}")
36
+ if backend.cors_env_names:
37
+ cors = backend.cors_env_names[0]
38
+ lines.append(f"Backend → Frontend: {cors} on {backend.provider}")
39
+ return lines
@@ -0,0 +1,32 @@
1
+ from deployforge.providers.base import (
2
+ AuthInfo,
3
+ DeploymentProvider,
4
+ DeploymentResult,
5
+ DeployStatus,
6
+ HttpClient,
7
+ )
8
+ from deployforge.providers.render import RenderProvider
9
+ from deployforge.providers.vercel import VercelProvider
10
+
11
+
12
+ def build_providers(
13
+ vercel_token: str | None,
14
+ render_api_key: str | None,
15
+ client: HttpClient | None = None,
16
+ ) -> dict[str, DeploymentProvider]:
17
+ return {
18
+ "vercel": VercelProvider(vercel_token, client),
19
+ "render": RenderProvider(render_api_key, client),
20
+ }
21
+
22
+
23
+ __all__ = [
24
+ "AuthInfo",
25
+ "DeploymentProvider",
26
+ "DeploymentResult",
27
+ "DeployStatus",
28
+ "HttpClient",
29
+ "RenderProvider",
30
+ "VercelProvider",
31
+ "build_providers",
32
+ ]