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
deployforge/cli.py
ADDED
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
"""DeployForge command-line interface.
|
|
2
|
+
|
|
3
|
+
Primary experience::
|
|
4
|
+
|
|
5
|
+
deployforge
|
|
6
|
+
|
|
7
|
+
inside any existing GitHub-backed project. Additional commands cover
|
|
8
|
+
analyze, deploy, status, verify, security, doctor, config, and logs.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
|
|
21
|
+
from deployforge.__version__ import __version__
|
|
22
|
+
from deployforge.analyzer import analyze_project
|
|
23
|
+
from deployforge.config import (
|
|
24
|
+
ConfigError,
|
|
25
|
+
KeyringSecrets,
|
|
26
|
+
ProjectConfig,
|
|
27
|
+
ProjectConfigManager,
|
|
28
|
+
default_config_path,
|
|
29
|
+
default_data_dir,
|
|
30
|
+
resolve_render_api_key,
|
|
31
|
+
resolve_vercel_token,
|
|
32
|
+
)
|
|
33
|
+
from deployforge.deployment import (
|
|
34
|
+
DeployOutcome,
|
|
35
|
+
build_plan,
|
|
36
|
+
verify_endpoints,
|
|
37
|
+
)
|
|
38
|
+
from deployforge.deployment.orchestrator import run_deploy
|
|
39
|
+
from deployforge.errors import (
|
|
40
|
+
AuthenticationError,
|
|
41
|
+
DeployForgeError,
|
|
42
|
+
DeploymentError,
|
|
43
|
+
PartialDeploymentError,
|
|
44
|
+
ProjectError,
|
|
45
|
+
ProviderError,
|
|
46
|
+
SecurityError,
|
|
47
|
+
)
|
|
48
|
+
from deployforge.github.integration import github_token, repo_exists
|
|
49
|
+
from deployforge.providers import build_providers
|
|
50
|
+
from deployforge.providers.base import DeploymentResult
|
|
51
|
+
from deployforge.ui import (
|
|
52
|
+
banner,
|
|
53
|
+
error,
|
|
54
|
+
fail,
|
|
55
|
+
info,
|
|
56
|
+
link,
|
|
57
|
+
live_panel,
|
|
58
|
+
note,
|
|
59
|
+
ok,
|
|
60
|
+
prompt_yes_no,
|
|
61
|
+
section,
|
|
62
|
+
spinner,
|
|
63
|
+
step,
|
|
64
|
+
warn,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
app = typer.Typer(
|
|
68
|
+
name="deployforge",
|
|
69
|
+
help="From GitHub to a Live Application. Automatically.",
|
|
70
|
+
add_completion=False,
|
|
71
|
+
no_args_is_help=False,
|
|
72
|
+
rich_markup_mode="rich",
|
|
73
|
+
pretty_exceptions_show_locals=False,
|
|
74
|
+
pretty_exceptions_short=False,
|
|
75
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Shared plumbing
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _project_config(project: Path) -> ProjectConfig:
|
|
85
|
+
try:
|
|
86
|
+
return ProjectConfigManager(project).load()
|
|
87
|
+
except ConfigError as exc:
|
|
88
|
+
error(str(exc))
|
|
89
|
+
raise typer.Exit(code=1) from exc
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _providers(config: ProjectConfig) -> dict:
|
|
93
|
+
secrets = KeyringSecrets()
|
|
94
|
+
vercel = resolve_vercel_token(secrets)
|
|
95
|
+
render = resolve_render_api_key(secrets)
|
|
96
|
+
return build_providers(vercel, render)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _confirm(message: str) -> bool:
|
|
100
|
+
return prompt_yes_no(message)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _step(message: str) -> None:
|
|
104
|
+
step(message)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _auto_deploy(
|
|
108
|
+
project: Path,
|
|
109
|
+
*,
|
|
110
|
+
dry_run: bool,
|
|
111
|
+
non_interactive: bool,
|
|
112
|
+
debug: bool,
|
|
113
|
+
skip_security: bool,
|
|
114
|
+
timeout: int,
|
|
115
|
+
plan_name: str,
|
|
116
|
+
) -> DeployOutcome:
|
|
117
|
+
config = _project_config(project)
|
|
118
|
+
section("Analyzing project")
|
|
119
|
+
analysis = analyze_project(project, config)
|
|
120
|
+
|
|
121
|
+
with spinner("Analyzing project…"):
|
|
122
|
+
if analysis.repo.exists:
|
|
123
|
+
ok("GitHub repository detected")
|
|
124
|
+
if analysis.repo.url:
|
|
125
|
+
note(f" Repository: {analysis.repo.url}")
|
|
126
|
+
elif analysis.repo.branch:
|
|
127
|
+
ok("Git repository detected (no GitHub remote)")
|
|
128
|
+
else:
|
|
129
|
+
warn("No git repository detected")
|
|
130
|
+
ok("Project structure analyzed")
|
|
131
|
+
|
|
132
|
+
if not analysis.has_frontend and not analysis.has_backend:
|
|
133
|
+
fail(
|
|
134
|
+
"No deployable application detected.",
|
|
135
|
+
hint="DeployForge looks for frontend frameworks (Next.js, Vite, …), "
|
|
136
|
+
"Python or Node.js backends, and monorepo workspaces.",
|
|
137
|
+
)
|
|
138
|
+
raise typer.Exit(code=1)
|
|
139
|
+
|
|
140
|
+
plan = build_plan(analysis)
|
|
141
|
+
_render_architecture(analysis, plan)
|
|
142
|
+
|
|
143
|
+
if not analysis.repo.exists and not analysis.repo.slug() and not dry_run:
|
|
144
|
+
fail(
|
|
145
|
+
"No GitHub repository detected.",
|
|
146
|
+
hint="DeployForge works best with PushForge.\n\n"
|
|
147
|
+
" pushforge\n\n"
|
|
148
|
+
"Then return here and run:\n\n"
|
|
149
|
+
" deployforge",
|
|
150
|
+
)
|
|
151
|
+
raise typer.Exit(code=1)
|
|
152
|
+
|
|
153
|
+
_render_plan(plan, analysis)
|
|
154
|
+
|
|
155
|
+
if not non_interactive and not dry_run and not _confirm("Continue?"):
|
|
156
|
+
info("Deployment cancelled by user.")
|
|
157
|
+
raise typer.Exit(code=0)
|
|
158
|
+
|
|
159
|
+
providers = _providers(config)
|
|
160
|
+
outcome = run_deploy(
|
|
161
|
+
analysis,
|
|
162
|
+
plan,
|
|
163
|
+
providers,
|
|
164
|
+
security_enabled=config.security_enabled and not skip_security,
|
|
165
|
+
block_on_secrets=config.block_on_secrets,
|
|
166
|
+
security_level="normal",
|
|
167
|
+
dry_run=dry_run,
|
|
168
|
+
non_interactive=non_interactive,
|
|
169
|
+
confirm=_confirm,
|
|
170
|
+
on_step=_step,
|
|
171
|
+
timeout=timeout,
|
|
172
|
+
plan_name=plan_name,
|
|
173
|
+
)
|
|
174
|
+
return outcome
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _render_architecture(analysis, plan) -> None:
|
|
178
|
+
table = Table(title="Application Architecture", header_style="bold blue")
|
|
179
|
+
table.add_column("Component")
|
|
180
|
+
table.add_column("Technology")
|
|
181
|
+
table.add_column("Route")
|
|
182
|
+
for service in plan.services:
|
|
183
|
+
table.add_row(service.kind.title(), service.framework, service.provider)
|
|
184
|
+
if (db := getattr(analysis, "database", None)) is not None and getattr(db, "engine", None):
|
|
185
|
+
table.add_row("Database", db.engine, "existing" if db.configured else "—")
|
|
186
|
+
if analysis.backend:
|
|
187
|
+
backend = analysis.backend
|
|
188
|
+
if backend.start_command:
|
|
189
|
+
note(f" Backend entry point: {backend.start_command}")
|
|
190
|
+
from deployforge.ui import console
|
|
191
|
+
|
|
192
|
+
console.print(table)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _render_plan(plan, analysis) -> None:
|
|
196
|
+
section("Deployment plan")
|
|
197
|
+
for service in plan.services:
|
|
198
|
+
note(f"{service.kind.title()}: {service.framework} → {service.provider}")
|
|
199
|
+
for env in plan.env_vars:
|
|
200
|
+
note(f" {env.key} → {env.provider}")
|
|
201
|
+
if plan.database_note:
|
|
202
|
+
warn(plan.database_note)
|
|
203
|
+
if not plan.services:
|
|
204
|
+
warn("Nothing to deploy.")
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _render_outcome(outcome: DeployOutcome) -> None:
|
|
209
|
+
if outcome.dry_run:
|
|
210
|
+
warn("Dry run — no changes were made.")
|
|
211
|
+
return
|
|
212
|
+
section("Done")
|
|
213
|
+
lines: list[tuple[str, str]] = []
|
|
214
|
+
if outcome.frontend_url:
|
|
215
|
+
ok("Frontend deployed")
|
|
216
|
+
link(outcome.frontend_url)
|
|
217
|
+
lines.append(("Frontend", outcome.frontend_url))
|
|
218
|
+
if outcome.backend_url:
|
|
219
|
+
ok("Backend deployed")
|
|
220
|
+
link(outcome.backend_url)
|
|
221
|
+
lines.append(("Backend", outcome.backend_url))
|
|
222
|
+
for key, value in outcome.env_vars_set:
|
|
223
|
+
ok(f"{key} configured")
|
|
224
|
+
note(f" {key}={value}")
|
|
225
|
+
if outcome.verification:
|
|
226
|
+
for result in outcome.verification.results:
|
|
227
|
+
if result.reachable:
|
|
228
|
+
ok(f"{result.name} reachable ({result.status_code})")
|
|
229
|
+
else:
|
|
230
|
+
fail(f"{result.name} not reachable")
|
|
231
|
+
if lines:
|
|
232
|
+
state = "✓ CONFIGURED" if outcome.env_vars_set else "— NOT REQUIRED"
|
|
233
|
+
lines.append(("API Connection", state))
|
|
234
|
+
live_panel("🚀 APPLICATION LIVE", lines)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# Default: deployforge
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@app.callback(invoke_without_command=True)
|
|
243
|
+
def main(
|
|
244
|
+
ctx: typer.Context,
|
|
245
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without changing anything."),
|
|
246
|
+
non_interactive: bool = typer.Option(
|
|
247
|
+
False, "--non-interactive", help="Run without prompts (for CI/CD)."
|
|
248
|
+
),
|
|
249
|
+
debug: bool = typer.Option(False, "--debug", help="Show debug detail."),
|
|
250
|
+
skip_security: bool = typer.Option(
|
|
251
|
+
False, "--skip-security", help="Disable the security preflight."
|
|
252
|
+
),
|
|
253
|
+
timeout: int = typer.Option(
|
|
254
|
+
900, "--timeout", help="Deployment wait timeout in seconds.", show_default=True
|
|
255
|
+
),
|
|
256
|
+
plan: str = typer.Option(
|
|
257
|
+
"starter", "--plan", help="Render plan: free | starter | pro.", show_default=True
|
|
258
|
+
),
|
|
259
|
+
) -> None:
|
|
260
|
+
"""Deploy the current GitHub-backed project (analyze → plan → deploy → verify)."""
|
|
261
|
+
if ctx.invoked_subcommand is not None:
|
|
262
|
+
return
|
|
263
|
+
banner()
|
|
264
|
+
project = Path.cwd()
|
|
265
|
+
if dry_run:
|
|
266
|
+
section("DRY RUN")
|
|
267
|
+
try:
|
|
268
|
+
outcome = _auto_deploy(
|
|
269
|
+
project,
|
|
270
|
+
dry_run=dry_run,
|
|
271
|
+
non_interactive=non_interactive,
|
|
272
|
+
debug=debug,
|
|
273
|
+
skip_security=skip_security,
|
|
274
|
+
timeout=timeout,
|
|
275
|
+
plan_name=plan,
|
|
276
|
+
)
|
|
277
|
+
except SecurityError as exc:
|
|
278
|
+
error(str(exc))
|
|
279
|
+
raise typer.Exit(code=3) from exc
|
|
280
|
+
except PartialDeploymentError as exc:
|
|
281
|
+
error(str(exc))
|
|
282
|
+
raise typer.Exit(code=4) from exc
|
|
283
|
+
except (DeploymentError, ProviderError, AuthenticationError, ProjectError) as exc:
|
|
284
|
+
error(str(exc))
|
|
285
|
+
raise typer.Exit(code=1) from exc
|
|
286
|
+
_render_outcome(outcome)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# deployforge analyze
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@app.command("analyze")
|
|
295
|
+
def analyze_cmd(
|
|
296
|
+
path: Path = typer.Argument(Path("."), help="Project directory."),
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Analyze the project architecture without deploying anything."""
|
|
299
|
+
banner()
|
|
300
|
+
if not path.exists() or not path.is_dir():
|
|
301
|
+
error(f"Path does not exist or is not a directory: {path}")
|
|
302
|
+
raise typer.Exit(code=2)
|
|
303
|
+
config = _project_config(path)
|
|
304
|
+
analysis = analyze_project(path, config)
|
|
305
|
+
plan = build_plan(analysis)
|
|
306
|
+
|
|
307
|
+
section("APPLICATION ANALYSIS")
|
|
308
|
+
info(f"Project: {analysis.name}")
|
|
309
|
+
note(f"Repository: {path.resolve()}")
|
|
310
|
+
|
|
311
|
+
if analysis.repo.exists:
|
|
312
|
+
ok("GitHub repository detected")
|
|
313
|
+
if analysis.repo.url:
|
|
314
|
+
note(f" {analysis.repo.url}")
|
|
315
|
+
else:
|
|
316
|
+
warn("No GitHub repository detected")
|
|
317
|
+
|
|
318
|
+
if not analysis.has_frontend and not analysis.has_backend:
|
|
319
|
+
fail("No deployable application detected.")
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
_render_architecture(analysis, plan)
|
|
323
|
+
if plan.database_note:
|
|
324
|
+
warn(plan.database_note)
|
|
325
|
+
step("Recommended deployment")
|
|
326
|
+
for service in plan.services:
|
|
327
|
+
note(f" {service.kind.title()}: {service.framework} → {service.provider}")
|
|
328
|
+
for env in plan.env_vars:
|
|
329
|
+
note(f" {env.key} → {env.provider}")
|
|
330
|
+
step("Run:")
|
|
331
|
+
note(" deployforge deploy")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
# deployforge deploy
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@app.command("deploy")
|
|
340
|
+
def deploy_cmd(
|
|
341
|
+
path: Path = typer.Argument(Path("."), help="Project directory."),
|
|
342
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without changing anything."),
|
|
343
|
+
non_interactive: bool = typer.Option(
|
|
344
|
+
False, "--non-interactive", help="Run without prompts (for CI/CD)."
|
|
345
|
+
),
|
|
346
|
+
debug: bool = typer.Option(False, "--debug", help="Show debug detail."),
|
|
347
|
+
skip_security: bool = typer.Option(
|
|
348
|
+
False, "--skip-security", help="Disable the security preflight."
|
|
349
|
+
),
|
|
350
|
+
timeout: int = typer.Option(
|
|
351
|
+
900, "--timeout", help="Deployment wait timeout in seconds.", show_default=True
|
|
352
|
+
),
|
|
353
|
+
plan: str = typer.Option(
|
|
354
|
+
"starter", "--plan", help="Render plan: free | starter | pro.", show_default=True
|
|
355
|
+
),
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Deploy the project to production (analyze → plan → deploy → verify)."""
|
|
358
|
+
banner()
|
|
359
|
+
if not path.exists() or not path.is_dir():
|
|
360
|
+
error(f"Path does not exist or is not a directory: {path}")
|
|
361
|
+
raise typer.Exit(code=2)
|
|
362
|
+
if dry_run:
|
|
363
|
+
section("DRY RUN")
|
|
364
|
+
try:
|
|
365
|
+
outcome = _auto_deploy(
|
|
366
|
+
path,
|
|
367
|
+
dry_run=dry_run,
|
|
368
|
+
non_interactive=non_interactive,
|
|
369
|
+
debug=debug,
|
|
370
|
+
skip_security=skip_security,
|
|
371
|
+
timeout=timeout,
|
|
372
|
+
plan_name=plan,
|
|
373
|
+
)
|
|
374
|
+
except SecurityError as exc:
|
|
375
|
+
error(str(exc))
|
|
376
|
+
raise typer.Exit(code=3) from exc
|
|
377
|
+
except PartialDeploymentError as exc:
|
|
378
|
+
error(str(exc))
|
|
379
|
+
raise typer.Exit(code=4) from exc
|
|
380
|
+
except (DeploymentError, ProviderError, AuthenticationError, ProjectError) as exc:
|
|
381
|
+
error(str(exc))
|
|
382
|
+
raise typer.Exit(code=1) from exc
|
|
383
|
+
_render_outcome(outcome)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
# deployforge init
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@app.command("init")
|
|
392
|
+
def init_cmd(
|
|
393
|
+
path: Path = typer.Argument(Path("."), help="Project directory."),
|
|
394
|
+
force: bool = typer.Option(False, "--force", help="Overwrite an existing config."),
|
|
395
|
+
) -> None:
|
|
396
|
+
"""Create the .deployforge/config.yml for a project."""
|
|
397
|
+
banner()
|
|
398
|
+
if not path.exists() or not path.is_dir():
|
|
399
|
+
error(f"Path does not exist or is not a directory: {path}")
|
|
400
|
+
raise typer.Exit(code=2)
|
|
401
|
+
manager = ProjectConfigManager(path)
|
|
402
|
+
if manager.path.exists() and not force:
|
|
403
|
+
warn(f"Config already exists at {manager.path}")
|
|
404
|
+
info("Use --force to overwrite it.")
|
|
405
|
+
return
|
|
406
|
+
analysis = analyze_project(path, manager.load())
|
|
407
|
+
config = ProjectConfig(
|
|
408
|
+
name=analysis.name,
|
|
409
|
+
branch=analysis.repo.branch or "main",
|
|
410
|
+
github_repository=analysis.repo.slug(),
|
|
411
|
+
frontend_directory=analysis.frontend.directory.relative_to(path).as_posix()
|
|
412
|
+
if analysis.has_frontend and analysis.frontend
|
|
413
|
+
else None,
|
|
414
|
+
backend_directory=analysis.backend.directory.relative_to(path).as_posix()
|
|
415
|
+
if analysis.has_backend and analysis.backend
|
|
416
|
+
else None,
|
|
417
|
+
)
|
|
418
|
+
manager.save(config)
|
|
419
|
+
ok(f"Configuration written to {manager.path}")
|
|
420
|
+
note("Never store secrets in .deployforge/config.yml.")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# ---------------------------------------------------------------------------
|
|
424
|
+
# deployforge status
|
|
425
|
+
# ---------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@app.command("status")
|
|
429
|
+
def status_cmd(
|
|
430
|
+
path: Path = typer.Argument(Path("."), help="Project directory."),
|
|
431
|
+
) -> None:
|
|
432
|
+
"""Show project, repository, and deployment status."""
|
|
433
|
+
banner()
|
|
434
|
+
config = _project_config(path)
|
|
435
|
+
analysis = analyze_project(path, config)
|
|
436
|
+
|
|
437
|
+
from deployforge.ui import console
|
|
438
|
+
|
|
439
|
+
table = Table(title="DEPLOYFORGE STATUS", header_style="bold blue")
|
|
440
|
+
table.add_column("Item")
|
|
441
|
+
table.add_column("Value")
|
|
442
|
+
table.add_row("Application", analysis.name)
|
|
443
|
+
table.add_row("Directory", str(path.resolve()))
|
|
444
|
+
table.add_row("GitHub", f"✓ {analysis.repo.slug()}" if analysis.repo.slug() else "—")
|
|
445
|
+
table.add_row("Branch", analysis.repo.branch or "—")
|
|
446
|
+
|
|
447
|
+
secrets = KeyringSecrets()
|
|
448
|
+
vercel_token = resolve_vercel_token(secrets)
|
|
449
|
+
render_key = resolve_render_api_key(secrets)
|
|
450
|
+
table.add_row("Vercel auth", "✓ available" if vercel_token else "— not configured")
|
|
451
|
+
table.add_row("Render auth", "✓ available" if render_key else "— not configured")
|
|
452
|
+
|
|
453
|
+
if analysis.frontend:
|
|
454
|
+
fe = analysis.frontend
|
|
455
|
+
table.add_row(
|
|
456
|
+
"Frontend",
|
|
457
|
+
f"{fe.framework} ({fe.directory.name}) → {fe.provider}",
|
|
458
|
+
)
|
|
459
|
+
else:
|
|
460
|
+
table.add_row("Frontend", "—")
|
|
461
|
+
if analysis.backend:
|
|
462
|
+
be = analysis.backend
|
|
463
|
+
table.add_row(
|
|
464
|
+
"Backend",
|
|
465
|
+
f"{be.framework} ({be.directory.name}) → {be.provider}",
|
|
466
|
+
)
|
|
467
|
+
else:
|
|
468
|
+
table.add_row("Backend", "—")
|
|
469
|
+
|
|
470
|
+
last = _last_log_entry()
|
|
471
|
+
if last:
|
|
472
|
+
if last.get("frontend_url"):
|
|
473
|
+
table.add_row("Frontend URL", last["frontend_url"])
|
|
474
|
+
if last.get("backend_url"):
|
|
475
|
+
table.add_row("Backend URL", last["backend_url"])
|
|
476
|
+
from deployforge.integration import connection_summary
|
|
477
|
+
|
|
478
|
+
for line in connection_summary(analysis):
|
|
479
|
+
table.add_row("Integration", line)
|
|
480
|
+
console.print(table)
|
|
481
|
+
note("Status reflects the last recorded deployment. Run 'deployforge verify' for live checks.")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# ---------------------------------------------------------------------------
|
|
485
|
+
# deployforge verify
|
|
486
|
+
# ---------------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@app.command("verify")
|
|
490
|
+
def verify_cmd(
|
|
491
|
+
frontend_url: str | None = typer.Option(None, "--frontend", help="Frontend URL to check."),
|
|
492
|
+
backend_url: str | None = typer.Option(None, "--backend", help="Backend URL to check."),
|
|
493
|
+
) -> None:
|
|
494
|
+
"""Verify deployed services are reachable."""
|
|
495
|
+
banner()
|
|
496
|
+
last = _last_log_entry()
|
|
497
|
+
fe_url = frontend_url or (last.get("frontend_url") if last else None)
|
|
498
|
+
be_url = backend_url or (last.get("backend_url") if last else None)
|
|
499
|
+
if not fe_url and not be_url:
|
|
500
|
+
warn("No deployed URLs found for this machine.")
|
|
501
|
+
info("Pass --frontend and/or --backend, or deploy first with 'deployforge'.")
|
|
502
|
+
return
|
|
503
|
+
section("Verification")
|
|
504
|
+
pairs: list[tuple[str, str, str | None]] = []
|
|
505
|
+
if fe_url:
|
|
506
|
+
pairs.append(("Frontend", fe_url, None))
|
|
507
|
+
if be_url:
|
|
508
|
+
pairs.append(("Backend", be_url, "/health"))
|
|
509
|
+
report = verify_endpoints(pairs)
|
|
510
|
+
for result in report.results:
|
|
511
|
+
if result.reachable:
|
|
512
|
+
ok(f"{result.name} HTTP {result.status_code} ({result.latency_ms}ms)")
|
|
513
|
+
if result.health_ok is True:
|
|
514
|
+
ok(f"{result.name} health endpoint OK")
|
|
515
|
+
else:
|
|
516
|
+
fail(f"{result.name} not reachable {result.url}")
|
|
517
|
+
if report.passed:
|
|
518
|
+
step("Deployment healthy.")
|
|
519
|
+
else:
|
|
520
|
+
fail("Deployment unhealthy.")
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# ---------------------------------------------------------------------------
|
|
524
|
+
# deployforge security
|
|
525
|
+
# ---------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
@app.command("security")
|
|
529
|
+
def security_cmd(
|
|
530
|
+
path: Path = typer.Argument(Path("."), help="Project directory to scan."),
|
|
531
|
+
level: str = typer.Option("normal", "--level", help="off | normal | strict."),
|
|
532
|
+
) -> None:
|
|
533
|
+
"""Run the security preflight independently."""
|
|
534
|
+
from deployforge.security import scan_project
|
|
535
|
+
from deployforge.ui import console
|
|
536
|
+
|
|
537
|
+
banner()
|
|
538
|
+
if not path.exists() or not path.is_dir():
|
|
539
|
+
error(f"Path does not exist or is not a directory: {path}")
|
|
540
|
+
raise typer.Exit(code=2)
|
|
541
|
+
section("DeployForge Security Scan")
|
|
542
|
+
with spinner("Scanning…"):
|
|
543
|
+
report = scan_project(path, level=level)
|
|
544
|
+
ok(f"Sensitive file detection ({len(report.sensitive_files)} blocked file kinds)")
|
|
545
|
+
ok(f"Secret pattern scan ({len(report.findings)} findings)")
|
|
546
|
+
if report.gitignore_ok:
|
|
547
|
+
ok(".gitignore validation")
|
|
548
|
+
else:
|
|
549
|
+
warn(f".gitignore missing rules: {', '.join(report.gitignore_missing)}")
|
|
550
|
+
|
|
551
|
+
if report.findings:
|
|
552
|
+
table = Table(title="Findings", header_style="bold red")
|
|
553
|
+
table.add_column("Confidence")
|
|
554
|
+
table.add_column("Name")
|
|
555
|
+
table.add_column("Location")
|
|
556
|
+
for finding in report.findings:
|
|
557
|
+
location = finding.file + (f":{finding.line}" if finding.line else "")
|
|
558
|
+
confidence = "[bold red]high[/bold red]" if finding.confidence == "high" else "low"
|
|
559
|
+
table.add_row(confidence, finding.name, location)
|
|
560
|
+
console.print(table)
|
|
561
|
+
else:
|
|
562
|
+
ok("No secret patterns detected.")
|
|
563
|
+
|
|
564
|
+
if report.high_confidence:
|
|
565
|
+
fail("SECURITY PREFLIGHT: not ready — potential secrets found.")
|
|
566
|
+
raise typer.Exit(code=3)
|
|
567
|
+
step("RESULT: SECURE ENOUGH TO PROCEED")
|
|
568
|
+
note("This is a preflight check, not a guarantee that the application is secure.")
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
# ---------------------------------------------------------------------------
|
|
572
|
+
# deployforge doctor
|
|
573
|
+
# ---------------------------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
@app.command("doctor")
|
|
577
|
+
def doctor_cmd(
|
|
578
|
+
path: Path = typer.Argument(Path("."), help="Project directory to inspect."),
|
|
579
|
+
) -> None:
|
|
580
|
+
"""Check the local environment and provider readiness."""
|
|
581
|
+
banner()
|
|
582
|
+
section("DeployForge Doctor")
|
|
583
|
+
checks: list[tuple[str, bool, str]] = []
|
|
584
|
+
|
|
585
|
+
def _bin(name: str) -> bool:
|
|
586
|
+
try:
|
|
587
|
+
subprocess.run([name, "--version"], capture_output=True, timeout=10, check=False)
|
|
588
|
+
return True
|
|
589
|
+
except (OSError, subprocess.SubprocessError):
|
|
590
|
+
return False
|
|
591
|
+
|
|
592
|
+
checks.append(("Git installed", _bin("git"), ""))
|
|
593
|
+
checks.append(("Node.js installed", _bin("node"), ""))
|
|
594
|
+
checks.append(("Python installed", _bin("python3"), ""))
|
|
595
|
+
|
|
596
|
+
analysis = analyze_project(path, _project_config(path))
|
|
597
|
+
checks.append(("GitHub repository detected", analysis.repo.exists, analysis.repo.url or ""))
|
|
598
|
+
if analysis.repo.slug():
|
|
599
|
+
try:
|
|
600
|
+
exists = repo_exists(analysis.repo, github_token())
|
|
601
|
+
checks.append(("GitHub repository verified", exists, ""))
|
|
602
|
+
except DeployForgeError:
|
|
603
|
+
checks.append(("GitHub repository verified", False, "API unreachable"))
|
|
604
|
+
|
|
605
|
+
secrets = KeyringSecrets()
|
|
606
|
+
vercel_token = resolve_vercel_token(secrets)
|
|
607
|
+
render_key = resolve_render_api_key(secrets)
|
|
608
|
+
checks.append(("Vercel authentication available", bool(vercel_token), ""))
|
|
609
|
+
checks.append(("Render authentication available", bool(render_key), ""))
|
|
610
|
+
|
|
611
|
+
try:
|
|
612
|
+
import requests
|
|
613
|
+
|
|
614
|
+
requests.get("https://api.github.com", timeout=10)
|
|
615
|
+
checks.append(("Network", True, ""))
|
|
616
|
+
except Exception:
|
|
617
|
+
checks.append(("Network", False, ""))
|
|
618
|
+
|
|
619
|
+
if analysis.frontend:
|
|
620
|
+
checks.append(("Frontend detected", True, f"{analysis.frontend.framework}"))
|
|
621
|
+
else:
|
|
622
|
+
checks.append(("Frontend detected", False, ""))
|
|
623
|
+
if analysis.backend:
|
|
624
|
+
checks.append(("Backend detected", True, f"{analysis.backend.framework}"))
|
|
625
|
+
else:
|
|
626
|
+
checks.append(("Backend detected", False, ""))
|
|
627
|
+
|
|
628
|
+
checks.append(("DeployForge configuration present", _project_config(path).name is not None, ""))
|
|
629
|
+
|
|
630
|
+
all_ok = all(ok_ for _, ok_, _ in checks)
|
|
631
|
+
for label, result, detail in checks:
|
|
632
|
+
if result:
|
|
633
|
+
ok(f"{label}{(' — ' + detail) if detail else ''}")
|
|
634
|
+
else:
|
|
635
|
+
fail(f"{label}{(' — ' + detail) if detail else ''}")
|
|
636
|
+
section("Result")
|
|
637
|
+
if all_ok:
|
|
638
|
+
step("Environment ready.")
|
|
639
|
+
else:
|
|
640
|
+
warn("Environment needs attention before deploying.")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
# ---------------------------------------------------------------------------
|
|
644
|
+
# deployforge config
|
|
645
|
+
# ---------------------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
@app.command("config")
|
|
649
|
+
def config_cmd(
|
|
650
|
+
path: Path = typer.Argument(Path("."), help="Project directory."),
|
|
651
|
+
show: bool = typer.Option(True, "--show", help="Show the current project configuration."),
|
|
652
|
+
name: str | None = typer.Option(None, "--name", help="Project name."),
|
|
653
|
+
github_repository: str | None = typer.Option(
|
|
654
|
+
None, "--github-repository", help="owner/repo on GitHub."
|
|
655
|
+
),
|
|
656
|
+
branch: str | None = typer.Option(None, "--branch", help="Default branch."),
|
|
657
|
+
frontend_directory: str | None = typer.Option(
|
|
658
|
+
None, "--frontend-directory", help="Frontend directory (relative)."
|
|
659
|
+
),
|
|
660
|
+
backend_directory: str | None = typer.Option(
|
|
661
|
+
None, "--backend-directory", help="Backend directory (relative)."
|
|
662
|
+
),
|
|
663
|
+
auto_connect: bool | None = typer.Option(
|
|
664
|
+
None, "--auto-connect/--no-auto-connect", help="Automatically connect frontend ↔ backend."
|
|
665
|
+
),
|
|
666
|
+
security_enabled: bool | None = typer.Option(
|
|
667
|
+
None, "--security-enabled/--no-security", help="Enable or disable the security preflight."
|
|
668
|
+
),
|
|
669
|
+
block_on_secrets: bool | None = typer.Option(
|
|
670
|
+
None, "--block-on-secrets/--no-block", help="Block deployment on secret detection."
|
|
671
|
+
),
|
|
672
|
+
vercel_token: str | None = typer.Option(
|
|
673
|
+
None, "--vercel-token", help="Store the Vercel API token (OS keyring)."
|
|
674
|
+
),
|
|
675
|
+
render_api_key: str | None = typer.Option(
|
|
676
|
+
None, "--render-api-key", help="Store the Render API key (OS keyring)."
|
|
677
|
+
),
|
|
678
|
+
) -> None:
|
|
679
|
+
"""View or update DeployForge configuration."""
|
|
680
|
+
manager = ProjectConfigManager(path)
|
|
681
|
+
updates: dict = {}
|
|
682
|
+
if name:
|
|
683
|
+
updates["name"] = name
|
|
684
|
+
if github_repository:
|
|
685
|
+
updates["github_repository"] = github_repository
|
|
686
|
+
if branch:
|
|
687
|
+
updates["branch"] = branch
|
|
688
|
+
if frontend_directory:
|
|
689
|
+
updates["frontend_directory"] = frontend_directory
|
|
690
|
+
if backend_directory:
|
|
691
|
+
updates["backend_directory"] = backend_directory
|
|
692
|
+
if auto_connect is not None:
|
|
693
|
+
updates["auto_connect"] = auto_connect
|
|
694
|
+
if security_enabled is not None:
|
|
695
|
+
updates["security_enabled"] = security_enabled
|
|
696
|
+
if block_on_secrets is not None:
|
|
697
|
+
updates["block_on_secrets"] = block_on_secrets
|
|
698
|
+
if updates:
|
|
699
|
+
manager.update(**updates)
|
|
700
|
+
ok("Project configuration updated.")
|
|
701
|
+
|
|
702
|
+
secrets = KeyringSecrets()
|
|
703
|
+
if vercel_token:
|
|
704
|
+
if secrets.set_vercel_token(vercel_token):
|
|
705
|
+
ok("Vercel token stored securely in the OS keyring.")
|
|
706
|
+
else:
|
|
707
|
+
warn("No keyring available; export VERCEL_TOKEN instead.")
|
|
708
|
+
if render_api_key:
|
|
709
|
+
if secrets.set_render_api_key(render_api_key):
|
|
710
|
+
ok("Render API key stored securely in the OS keyring.")
|
|
711
|
+
else:
|
|
712
|
+
warn("No keyring available; export RENDER_API_KEY instead.")
|
|
713
|
+
|
|
714
|
+
if show or not updates:
|
|
715
|
+
from deployforge.ui import console
|
|
716
|
+
|
|
717
|
+
table = Table(title="DeployForge Configuration", header_style="bold blue")
|
|
718
|
+
table.add_column("Key")
|
|
719
|
+
table.add_column("Value")
|
|
720
|
+
current = manager.load()
|
|
721
|
+
for key, value in sorted(current.public().items()):
|
|
722
|
+
table.add_row(key, "—" if value is None else str(value))
|
|
723
|
+
console.print(table)
|
|
724
|
+
note(f"Project config: {manager.path}")
|
|
725
|
+
note(f"User config dir: {default_config_path()}")
|
|
726
|
+
note("Credentials live in the OS keyring, never in the config file.")
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
# ---------------------------------------------------------------------------
|
|
730
|
+
# deployforge logs
|
|
731
|
+
# ---------------------------------------------------------------------------
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _last_log_entry() -> dict | None:
|
|
735
|
+
log_path = default_data_dir() / "deployments.jsonl"
|
|
736
|
+
if not log_path.exists():
|
|
737
|
+
return None
|
|
738
|
+
try:
|
|
739
|
+
lines = log_path.read_text(encoding="utf-8").splitlines()
|
|
740
|
+
return json.loads(lines[-1]) if lines else None
|
|
741
|
+
except (OSError, json.JSONDecodeError):
|
|
742
|
+
return None
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
@app.command("logs")
|
|
746
|
+
def logs_cmd(
|
|
747
|
+
limit: int = typer.Option(10, "--limit", help="Number of local log entries to show."),
|
|
748
|
+
) -> None:
|
|
749
|
+
"""Show recent deployment records and provider status where available."""
|
|
750
|
+
banner()
|
|
751
|
+
log_path = default_data_dir() / "deployments.jsonl"
|
|
752
|
+
section("Deployment log")
|
|
753
|
+
if not log_path.exists():
|
|
754
|
+
info("No deployments recorded yet.")
|
|
755
|
+
return
|
|
756
|
+
try:
|
|
757
|
+
lines = log_path.read_text(encoding="utf-8").splitlines()
|
|
758
|
+
except OSError:
|
|
759
|
+
fail("Could not read the deployment log.")
|
|
760
|
+
return
|
|
761
|
+
selected = lines[-limit:]
|
|
762
|
+
last = json.loads(selected[-1]) if selected else None
|
|
763
|
+
for line in selected:
|
|
764
|
+
try:
|
|
765
|
+
entry = json.loads(line)
|
|
766
|
+
except json.JSONDecodeError:
|
|
767
|
+
continue
|
|
768
|
+
when = entry.get("time", 0)
|
|
769
|
+
note(
|
|
770
|
+
f"[{when}] frontend={entry.get('frontend_url') or '—'} "
|
|
771
|
+
f"backend={entry.get('backend_url') or '—'}"
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
if last and (last.get("frontend_id") or last.get("backend_id")):
|
|
775
|
+
section("Provider status")
|
|
776
|
+
secrets = KeyringSecrets()
|
|
777
|
+
providers = build_providers(resolve_vercel_token(secrets), resolve_render_api_key(secrets))
|
|
778
|
+
for kind in ("frontend", "backend"):
|
|
779
|
+
sid = last.get(f"{kind}_id")
|
|
780
|
+
provider_name = last.get(f"{kind}_provider")
|
|
781
|
+
if not sid or provider_name not in providers:
|
|
782
|
+
continue
|
|
783
|
+
provider = providers[provider_name]
|
|
784
|
+
try:
|
|
785
|
+
result = DeploymentResult(
|
|
786
|
+
provider=provider_name,
|
|
787
|
+
project_name=str(last.get(f"{kind}_name") or ""),
|
|
788
|
+
service_id=sid,
|
|
789
|
+
)
|
|
790
|
+
status = provider.get_status(result)
|
|
791
|
+
ok(f"{kind.title()} ({provider_name}): {status.state}")
|
|
792
|
+
if status.url:
|
|
793
|
+
link(status.url)
|
|
794
|
+
for log_line in provider.get_logs(result)[:5]:
|
|
795
|
+
note(f" {log_line}")
|
|
796
|
+
except (ProviderError, AuthenticationError) as exc:
|
|
797
|
+
warn(f"{kind.title()} status unavailable: {exc}")
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
# ---------------------------------------------------------------------------
|
|
801
|
+
# deployforge version
|
|
802
|
+
# ---------------------------------------------------------------------------
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
@app.command("version")
|
|
806
|
+
def version_cmd() -> None:
|
|
807
|
+
"""Show the DeployForge version."""
|
|
808
|
+
banner()
|
|
809
|
+
print(f"deployforge {__version__}")
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
# ---------------------------------------------------------------------------
|
|
813
|
+
# Entry point
|
|
814
|
+
# ---------------------------------------------------------------------------
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def entry() -> None:
|
|
818
|
+
try:
|
|
819
|
+
app()
|
|
820
|
+
except KeyboardInterrupt:
|
|
821
|
+
sys.exit(130)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
if __name__ == "__main__":
|
|
825
|
+
entry()
|