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
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()