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