taskfile 0.3.78__tar.gz → 0.3.80__tar.gz

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 (149) hide show
  1. {taskfile-0.3.78/src/taskfile.egg-info → taskfile-0.3.80}/PKG-INFO +2 -1
  2. {taskfile-0.3.78 → taskfile-0.3.80}/pyproject.toml +2 -1
  3. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/__init__.py +1 -1
  4. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/api/app.py +55 -28
  5. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/interactive/wizards.py +62 -9
  6. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/main.py +66 -59
  7. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/compose.py +1 -1
  8. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/deploy_recipes.py +73 -50
  9. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/diagnostics/checks.py +34 -567
  10. taskfile-0.3.80/src/taskfile/diagnostics/checks_deploy.py +171 -0
  11. taskfile-0.3.80/src/taskfile/diagnostics/checks_infra.py +179 -0
  12. taskfile-0.3.80/src/taskfile/diagnostics/checks_placeholders.py +161 -0
  13. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/diagnostics/checks_ports.py +71 -37
  14. taskfile-0.3.80/src/taskfile/diagnostics/checks_registry.py +166 -0
  15. taskfile-0.3.80/src/taskfile/diagnostics/checks_ssh.py +100 -0
  16. taskfile-0.3.80/src/taskfile/diagnostics/fixop_adapter.py +113 -0
  17. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/diagnostics/llm_repair.py +27 -0
  18. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/health.py +51 -1
  19. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/models/config.py +49 -48
  20. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/runner/classifier.py +29 -30
  21. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/runner/commands.py +16 -212
  22. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/runner/core.py +23 -13
  23. taskfile-0.3.80/src/taskfile/runner/failure.py +293 -0
  24. {taskfile-0.3.78 → taskfile-0.3.80/src/taskfile.egg-info}/PKG-INFO +2 -1
  25. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile.egg-info/SOURCES.txt +6 -0
  26. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile.egg-info/requires.txt +1 -0
  27. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_doctor_decomposition.py +18 -12
  28. taskfile-0.3.78/src/taskfile/diagnostics/checks_ssh.py +0 -266
  29. {taskfile-0.3.78 → taskfile-0.3.80}/LICENSE +0 -0
  30. {taskfile-0.3.78 → taskfile-0.3.80}/README.md +0 -0
  31. {taskfile-0.3.78 → taskfile-0.3.80}/setup.cfg +0 -0
  32. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/__main__.py +0 -0
  33. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/addons/__init__.py +0 -0
  34. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/addons/monitoring.py +0 -0
  35. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/addons/postgres.py +0 -0
  36. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/addons/redis_addon.py +0 -0
  37. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/api/__init__.py +0 -0
  38. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/api/models.py +0 -0
  39. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cache.py +0 -0
  40. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cigen/__init__.py +0 -0
  41. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cigen/base.py +0 -0
  42. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cigen/drone.py +0 -0
  43. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cigen/gitea.py +0 -0
  44. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cigen/github.py +0 -0
  45. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cigen/gitlab.py +0 -0
  46. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cigen/jenkins.py +0 -0
  47. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cigen/makefile.py +0 -0
  48. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cirunner.py +0 -0
  49. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/__init__.py +0 -0
  50. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/api_cmd.py +0 -0
  51. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/auth.py +0 -0
  52. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/cache_cmds.py +0 -0
  53. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/ci.py +0 -0
  54. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/click_compat.py +0 -0
  55. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/completion.py +0 -0
  56. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/deploy.py +0 -0
  57. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/diagnostics.py +0 -0
  58. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/docker_cmds.py +0 -0
  59. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/e2e_cmd.py +0 -0
  60. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/explain_cmd.py +0 -0
  61. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/fleet.py +0 -0
  62. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/health.py +0 -0
  63. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/import_export.py +0 -0
  64. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/info_cmd.py +0 -0
  65. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/interactive/__init__.py +0 -0
  66. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/interactive/menu.py +0 -0
  67. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/quadlet.py +0 -0
  68. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/registry_cmds.py +0 -0
  69. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/release.py +0 -0
  70. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/setup.py +0 -0
  71. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/cli/version.py +0 -0
  72. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/converters.py +0 -0
  73. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/deploy_utils.py +0 -0
  74. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/diagnostics/__init__.py +0 -0
  75. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/diagnostics/fixes.py +0 -0
  76. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/diagnostics/models.py +0 -0
  77. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/diagnostics/report.py +0 -0
  78. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/fleet.py +0 -0
  79. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/graph.py +0 -0
  80. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/importer.py +0 -0
  81. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/landing.py +0 -0
  82. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/models/__init__.py +0 -0
  83. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/models/environment.py +0 -0
  84. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/models/pipeline.py +0 -0
  85. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/models/task.py +0 -0
  86. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/notifications.py +0 -0
  87. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/parser.py +0 -0
  88. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/provisioner.py +0 -0
  89. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/quadlet.py +0 -0
  90. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/registry.py +0 -0
  91. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/runner/__init__.py +0 -0
  92. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/runner/error_presenter.py +0 -0
  93. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/runner/explainer.py +0 -0
  94. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/runner/functions.py +0 -0
  95. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/runner/resolver.py +0 -0
  96. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/runner/ssh.py +0 -0
  97. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/__init__.py +0 -0
  98. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/codereview.py +0 -0
  99. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/full.py +0 -0
  100. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/minimal.py +0 -0
  101. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/multiplatform.py +0 -0
  102. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/podman.py +0 -0
  103. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/publish.py +0 -0
  104. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/templates/codereview.yml +0 -0
  105. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/templates/full.yml +0 -0
  106. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/templates/iot.yml +0 -0
  107. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/templates/kubernetes.yml +0 -0
  108. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/templates/minimal.yml +0 -0
  109. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/templates/multiplatform.yml +0 -0
  110. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/templates/podman.yml +0 -0
  111. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/templates/publish.yml +0 -0
  112. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/templates/saas.yml +0 -0
  113. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/templates/terraform.yml +0 -0
  114. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/templates/web.yml +0 -0
  115. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/scaffold/web.py +0 -0
  116. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/ssh.py +0 -0
  117. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/watch.py +0 -0
  118. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/webui/__init__.py +0 -0
  119. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/webui/dashboard.py +0 -0
  120. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/webui/handlers.py +0 -0
  121. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile/webui/server.py +0 -0
  122. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile.egg-info/dependency_links.txt +0 -0
  123. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile.egg-info/entry_points.txt +0 -0
  124. {taskfile-0.3.78 → taskfile-0.3.80}/src/taskfile.egg-info/top_level.txt +0 -0
  125. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_api.py +0 -0
  126. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_auth.py +0 -0
  127. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_cigen.py +0 -0
  128. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_classifier.py +0 -0
  129. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_cli.py +0 -0
  130. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_compose.py +0 -0
  131. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_deploy_validation.py +0 -0
  132. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_diagnostics.py +0 -0
  133. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_docker_e2e.py +0 -0
  134. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_doctor_e2e.py +0 -0
  135. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_dsl_commands.py +0 -0
  136. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_e2e_examples.py +0 -0
  137. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_fleet.py +0 -0
  138. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_graceful_restart.py +0 -0
  139. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_health.py +0 -0
  140. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_landing.py +0 -0
  141. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_models.py +0 -0
  142. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_parser.py +0 -0
  143. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_provisioner.py +0 -0
  144. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_quadlet.py +0 -0
  145. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_release.py +0 -0
  146. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_resolver.py +0 -0
  147. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_runner.py +0 -0
  148. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_scaffold.py +0 -0
  149. {taskfile-0.3.78 → taskfile-0.3.80}/tests/test_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskfile
3
- Version: 0.3.78
3
+ Version: 0.3.80
4
4
  Summary: Universal Taskfile runner with multi-environment deploy support. CI/CD agnostic — run locally or from any pipeline.
5
5
  Author-email: Tom Sapletta <tom@sapletta.com>
6
6
  License-Expression: Apache-2.0
@@ -20,6 +20,7 @@ Requires-Dist: pyyaml>=6.0
20
20
  Requires-Dist: click>=8.0
21
21
  Requires-Dist: rich>=13.0
22
22
  Requires-Dist: clickmd>=1.1.1
23
+ Requires-Dist: fixop>=0.1.0
23
24
  Provides-Extra: ssh
24
25
  Requires-Dist: paramiko>=3.0; extra == "ssh"
25
26
  Provides-Extra: api
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "taskfile"
7
- version = "0.3.78"
7
+ version = "0.3.80"
8
8
  description = "Universal Taskfile runner with multi-environment deploy support. CI/CD agnostic — run locally or from any pipeline."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -25,6 +25,7 @@ dependencies = [
25
25
  "click>=8.0",
26
26
  "rich>=13.0",
27
27
  "clickmd>=1.1.1",
28
+ "fixop>=0.1.0",
28
29
  ]
29
30
 
30
31
  [project.optional-dependencies]
@@ -12,7 +12,7 @@ Features:
12
12
  - @remote SSH command execution
13
13
  """
14
14
 
15
- __version__ = "0.3.78"
15
+ __version__ = "0.3.80"
16
16
  __author__ = "Softreck"
17
17
 
18
18
  from taskfile.runner import TaskfileRunner
@@ -555,6 +555,16 @@ def _register_doctor_routes(app: FastAPI) -> None:
555
555
  )
556
556
 
557
557
 
558
+ # ── Category filter for doctor endpoint ──────────────────────────
559
+
560
+ _CATEGORY_FILTER = {
561
+ "config": {"config_error", "taskfile_bug"},
562
+ "env": {"dependency_missing"},
563
+ "infra": {"external_error"},
564
+ "runtime": {"runtime_error"},
565
+ }
566
+
567
+
558
568
  def _run_doctor(
559
569
  app: FastAPI,
560
570
  *,
@@ -567,37 +577,45 @@ def _run_doctor(
567
577
  from taskfile.diagnostics import ProjectDiagnostics
568
578
 
569
579
  diagnostics = ProjectDiagnostics()
570
-
571
580
  diagnostics.run_all_checks(verbose=verbose)
572
581
 
573
- # Layer 4: Auto-fix
574
- fixed_count = 0
582
+ fixed_count = _apply_layer4_fixes(diagnostics, fix)
583
+ llm_suggestions = _apply_layer5_llm(diagnostics, llm)
584
+ issues = _filter_issues_by_category(diagnostics._issues, category)
585
+ issue_infos, by_category = _build_issue_infos(issues)
586
+
587
+ return _build_doctor_response(issue_infos, by_category, fixed_count, llm_suggestions)
588
+
589
+
590
+ def _apply_layer4_fixes(diagnostics, fix: bool) -> int:
591
+ """Layer 4: Apply auto-fixes if requested. Returns fixed count."""
575
592
  if fix and diagnostics.issues:
576
- fixed_count = diagnostics.auto_fix()
593
+ return diagnostics.auto_fix()
594
+ return 0
577
595
 
578
- # Layer 5: LLM assist
579
- llm_suggestions: list[str] = []
596
+
597
+ def _apply_layer5_llm(diagnostics, llm: bool) -> list[str]:
598
+ """Layer 5: Ask LLM for suggestions on unresolved issues."""
580
599
  if llm and diagnostics._issues:
581
600
  try:
582
- llm_suggestions = diagnostics.llm_repair()
601
+ return diagnostics.llm_repair()
583
602
  except Exception:
584
603
  pass
604
+ return []
605
+
606
+
607
+ def _filter_issues_by_category(issues, category: str):
608
+ """Filter issues by doctor category (config/env/infra/runtime/all)."""
609
+ if category == "all" or category not in _CATEGORY_FILTER:
610
+ return issues
611
+ allowed = _CATEGORY_FILTER[category]
612
+ return [i for i in issues if i.category.value in allowed]
585
613
 
586
- # Filter by category if requested
587
- CATEGORY_FILTER = {
588
- "config": {"config_error", "taskfile_bug"},
589
- "env": {"dependency_missing"},
590
- "infra": {"external_error"},
591
- "runtime": {"runtime_error"},
592
- }
593
- issues = diagnostics._issues
594
- if category != "all" and category in CATEGORY_FILTER:
595
- allowed = CATEGORY_FILTER[category]
596
- issues = [i for i in issues if i.category.value in allowed]
597
-
598
- # Build response
599
- issue_infos = []
600
- categories: dict[str, list[DoctorIssueInfo]] = {}
614
+
615
+ def _build_issue_infos(issues) -> tuple[list[DoctorIssueInfo], dict[str, list[DoctorIssueInfo]]]:
616
+ """Convert Issue objects to API response models, grouped by category."""
617
+ infos: list[DoctorIssueInfo] = []
618
+ by_category: dict[str, list[DoctorIssueInfo]] = {}
601
619
  for iss in issues:
602
620
  info = DoctorIssueInfo(
603
621
  category=iss.category.value,
@@ -611,13 +629,22 @@ def _run_doctor(
611
629
  teach=iss.teach,
612
630
  context={k: v for k, v in iss.context.items() if not k.startswith("_")} if iss.context else None,
613
631
  )
614
- issue_infos.append(info)
615
- categories.setdefault(iss.category.value, []).append(info)
632
+ infos.append(info)
633
+ by_category.setdefault(iss.category.value, []).append(info)
634
+ return infos, by_category
635
+
616
636
 
617
- error_count = sum(1 for i in issues if i.severity == "error")
618
- warn_count = sum(1 for i in issues if i.severity == "warning")
619
- info_count = sum(1 for i in issues if i.severity == "info")
620
- fixable = sum(1 for i in issues if i.auto_fixable)
637
+ def _build_doctor_response(
638
+ issue_infos: list[DoctorIssueInfo],
639
+ categories: dict[str, list[DoctorIssueInfo]],
640
+ fixed_count: int,
641
+ llm_suggestions: list[str],
642
+ ) -> DoctorResponse:
643
+ """Assemble the final DoctorResponse from computed parts."""
644
+ error_count = sum(1 for i in issue_infos if i.severity == "error")
645
+ warn_count = sum(1 for i in issue_infos if i.severity == "warning")
646
+ info_count = sum(1 for i in issue_infos if i.severity == "info")
647
+ fixable = sum(1 for i in issue_infos if i.auto_fixable)
621
648
 
622
649
  parts = []
623
650
  if error_count:
@@ -263,13 +263,8 @@ curl -X POST http://localhost:8000/doctor -H "Content-Type: application/json" \
263
263
  border_style="blue"
264
264
  ))
265
265
 
266
- with console.status("[bold green]Checking project...[/]" if not remote else "[bold green]Checking remote server...[/]") if not report else _nullcontext():
267
- diagnostics.run_all_checks(verbose=verbose, remote=remote)
268
- # Remote-only: extended remote diagnostics
269
- if remote:
270
- _run_remote_diagnostics(diagnostics)
266
+ _doctor_run_checks(diagnostics, verbose=verbose, remote=remote, report=report)
271
267
 
272
- # Validate examples/ directory if requested
273
268
  if check_examples_flag:
274
269
  _run_examples_check(diagnostics, report)
275
270
 
@@ -278,19 +273,32 @@ curl -X POST http://localhost:8000/doctor -H "Content-Type: application/json" \
278
273
  sys.exit(1 if any(s == "error" for _, s, _ in diagnostics.issues) else 0)
279
274
 
280
275
  diagnostics.print_report(show_teach=teach)
276
+ _doctor_apply_fixes(diagnostics, fix=fix, llm=llm)
277
+ _doctor_print_summary(diagnostics)
278
+
279
+
280
+ def _doctor_run_checks(diagnostics: ProjectDiagnostics, *, verbose: bool, remote: bool, report: bool) -> None:
281
+ """Layers 1-3: Run all diagnostic checks (with optional spinner)."""
282
+ with console.status("[bold green]Checking project...[/]" if not remote else "[bold green]Checking remote server...[/]") if not report else _nullcontext():
283
+ diagnostics.run_all_checks(verbose=verbose, remote=remote)
284
+ if remote:
285
+ _run_remote_diagnostics(diagnostics)
286
+
281
287
 
282
- # Layer 4: Algorithmic fix
288
+ def _doctor_apply_fixes(diagnostics: ProjectDiagnostics, *, fix: bool, llm: bool) -> None:
289
+ """Layers 4-5: Apply auto-fixes and optionally ask LLM for suggestions."""
283
290
  if fix and diagnostics.issues:
284
291
  console.print("\n[bold]Layer 4: Attempting auto-fix...[/]")
285
292
  fixed = diagnostics.auto_fix()
286
293
  if fixed > 0:
287
294
  console.print(f"[green]✓ Fixed {fixed} issue(s)[/]")
288
295
 
289
- # Layer 5: LLM assist
290
296
  if llm and diagnostics._issues:
291
297
  _run_llm_assist(diagnostics)
292
298
 
293
- # Summary
299
+
300
+ def _doctor_print_summary(diagnostics: ProjectDiagnostics) -> None:
301
+ """Print final doctor summary with next-step guidance."""
294
302
  if not diagnostics.issues:
295
303
  console.print("\n[bold green]Your project is ready! 🚀[/]")
296
304
  console.print("\n[dim]Next steps:[/]")
@@ -406,6 +414,48 @@ def _collect_remote_envs(config, selected_env: str | None) -> list[tuple] | None
406
414
  return envs
407
415
 
408
416
 
417
+ def _run_fixop_checks(env) -> None:
418
+ """Run extended fixop infrastructure checks on a remote environment.
419
+
420
+ Checks: DNS, container DNS, UFW forward policy, systemd-resolved, NAT masquerade.
421
+ Degrades gracefully when fixop is not installed.
422
+ """
423
+ try:
424
+ import fixop
425
+ except ImportError:
426
+ return # fixop not installed — skip silently
427
+
428
+ ctx = fixop.HostContext(
429
+ host=env.ssh_host,
430
+ user=env.ssh_user or "root",
431
+ port=env.ssh_port or 22,
432
+ key=getattr(env, "ssh_key", None) or "~/.ssh/id_ed25519",
433
+ )
434
+
435
+ _CHECKS = [
436
+ ("DNS", fixop.check_host_dns),
437
+ ("systemd-resolved", fixop.check_systemd_resolved),
438
+ ("UFW forward policy", fixop.check_ufw_forward_policy),
439
+ ]
440
+
441
+ found = 0
442
+ for label, check_fn in _CHECKS:
443
+ try:
444
+ issues = check_fn(ctx)
445
+ for issue in issues:
446
+ sev = issue.severity.value
447
+ color = "red" if sev in ("error", "critical") else "yellow"
448
+ console.print(f"[{color}] ⚠ [{label}] {issue.message}[/{color}]")
449
+ if issue.fix_command:
450
+ console.print(f"[dim] Fix: {issue.fix_command}[/]")
451
+ found += 1
452
+ except Exception:
453
+ pass # individual check failure — skip
454
+
455
+ if found == 0:
456
+ console.print("[green] ✓ Infrastructure checks passed (DNS, UFW, systemd-resolved)[/]")
457
+
458
+
409
459
  def _diagnose_single_remote_env(env_name, env, taskfile_dir) -> None:
410
460
  """Run all remote diagnostics for a single environment."""
411
461
  from taskfile.deploy_utils import test_ssh_connection
@@ -432,6 +482,9 @@ def _diagnose_single_remote_env(env_name, env, taskfile_dir) -> None:
432
482
  except Exception as e:
433
483
  console.print(f"[yellow] Remote diagnostics error: {e}[/]")
434
484
 
485
+ # Extended infrastructure checks via fixop (DNS, UFW, TLS, systemd-resolved, NAT)
486
+ _run_fixop_checks(env)
487
+
435
488
 
436
489
  def _run_remote_diagnostics(diagnostics: ProjectDiagnostics) -> None:
437
490
  """Extended remote diagnostics — check containers, images, system status on remote server."""
@@ -328,70 +328,14 @@ taskfile run deploy --teach
328
328
  ```
329
329
  """
330
330
  opts = ctx.obj
331
- env_group = opts.get("env_group")
332
331
  tag_filter = [t.strip() for t in run_tags.split(",")] if run_tags else None
333
332
 
334
333
  try:
335
- # --explain / --teach mode: analyze without running
336
334
  if explain or teach:
337
- from taskfile.runner.resolver import TaskResolver
338
- from taskfile.runner.explainer import (
339
- TaskExplainer, print_explain_report, print_teach_report,
340
- )
341
- config = load_taskfile(opts["taskfile_path"])
342
- resolver = TaskResolver(
343
- config,
344
- env_name=opts["env_name"],
345
- platform_name=opts["platform_name"],
346
- var_overrides=opts["var"],
347
- )
348
- task_list = list(tasks)
349
- _check_unknown_tasks(task_list, config.tasks)
350
- if tag_filter:
351
- task_list = _filter_tasks_by_tags(config, task_list, tag_filter)
352
- if not task_list:
353
- console.print(f"[yellow]No tasks match tags: {', '.join(tag_filter)}[/]")
354
- sys.exit(0)
355
-
356
- explainer = TaskExplainer(resolver)
357
- report = explainer.explain(task_list)
358
-
359
- if teach:
360
- print_teach_report(report, task_list, resolver.env_name, config)
361
- else:
362
- print_explain_report(report, task_list, resolver.env_name)
363
-
364
- sys.exit(1 if report.has_errors else 0)
365
-
366
- if env_group:
367
- success = _run_env_group(
368
- taskfile_path=opts["taskfile_path"],
369
- env_group=env_group,
370
- task_names=list(tasks),
371
- platform_name=opts["platform_name"],
372
- var_overrides=opts["var"],
373
- dry_run=opts["dry_run"],
374
- verbose=opts["verbose"],
375
- )
335
+ _run_explain_mode(opts, list(tasks), tag_filter, teach=teach)
376
336
  else:
377
- runner = TaskfileRunner(
378
- taskfile_path=opts["taskfile_path"],
379
- env_name=opts["env_name"],
380
- platform_name=opts["platform_name"],
381
- var_overrides=opts["var"],
382
- dry_run=opts["dry_run"],
383
- verbose=opts["verbose"],
384
- )
385
- task_list = list(tasks)
386
- _check_unknown_tasks(task_list, runner.config.tasks)
387
-
388
- if tag_filter:
389
- task_list = _filter_tasks_by_tags(runner.config, task_list, tag_filter)
390
- if not task_list:
391
- console.print(f"[yellow]No tasks match tags: {', '.join(tag_filter)}[/]")
392
- sys.exit(0)
393
- success = runner.run(task_list)
394
- sys.exit(0 if success else 1)
337
+ success = _run_tasks(opts, list(tasks), tag_filter)
338
+ sys.exit(0 if success else 1)
395
339
  except (TaskfileNotFoundError, TaskfileParseError) as e:
396
340
  console.print(f"[red]Error:[/] {e}")
397
341
  if isinstance(e, TaskfileNotFoundError):
@@ -399,6 +343,69 @@ taskfile run deploy --teach
399
343
  sys.exit(1)
400
344
 
401
345
 
346
+ def _run_explain_mode(opts: dict, task_list: list[str], tag_filter: list[str] | None, *, teach: bool) -> None:
347
+ """Handle --explain / --teach mode: analyze execution plan without running."""
348
+ from taskfile.runner.resolver import TaskResolver
349
+ from taskfile.runner.explainer import (
350
+ TaskExplainer, print_explain_report, print_teach_report,
351
+ )
352
+ config = load_taskfile(opts["taskfile_path"])
353
+ resolver = TaskResolver(
354
+ config,
355
+ env_name=opts["env_name"],
356
+ platform_name=opts["platform_name"],
357
+ var_overrides=opts["var"],
358
+ )
359
+ _check_unknown_tasks(task_list, config.tasks)
360
+ if tag_filter:
361
+ task_list = _filter_tasks_by_tags(config, task_list, tag_filter)
362
+ if not task_list:
363
+ console.print(f"[yellow]No tasks match tags: {', '.join(tag_filter)}[/]")
364
+ sys.exit(0)
365
+
366
+ explainer = TaskExplainer(resolver)
367
+ report = explainer.explain(task_list)
368
+
369
+ if teach:
370
+ print_teach_report(report, task_list, resolver.env_name, config)
371
+ else:
372
+ print_explain_report(report, task_list, resolver.env_name)
373
+
374
+ sys.exit(1 if report.has_errors else 0)
375
+
376
+
377
+ def _run_tasks(opts: dict, task_list: list[str], tag_filter: list[str] | None) -> bool:
378
+ """Execute tasks normally, via env group or single runner. Returns success."""
379
+ env_group = opts.get("env_group")
380
+ if env_group:
381
+ return _run_env_group(
382
+ taskfile_path=opts["taskfile_path"],
383
+ env_group=env_group,
384
+ task_names=task_list,
385
+ platform_name=opts["platform_name"],
386
+ var_overrides=opts["var"],
387
+ dry_run=opts["dry_run"],
388
+ verbose=opts["verbose"],
389
+ )
390
+
391
+ runner = TaskfileRunner(
392
+ taskfile_path=opts["taskfile_path"],
393
+ env_name=opts["env_name"],
394
+ platform_name=opts["platform_name"],
395
+ var_overrides=opts["var"],
396
+ dry_run=opts["dry_run"],
397
+ verbose=opts["verbose"],
398
+ )
399
+ _check_unknown_tasks(task_list, runner.config.tasks)
400
+
401
+ if tag_filter:
402
+ task_list = _filter_tasks_by_tags(runner.config, task_list, tag_filter)
403
+ if not task_list:
404
+ console.print(f"[yellow]No tasks match tags: {', '.join(tag_filter)}[/]")
405
+ sys.exit(0)
406
+ return runner.run(task_list)
407
+
408
+
402
409
  def _filter_tasks_by_tags(config, task_names: list[str], tags: list[str]) -> list[str]:
403
410
  """Filter task names to only those whose tags overlap with the requested tags."""
404
411
  filtered = []
@@ -74,7 +74,7 @@ def resolve_variables(text: str, variables: dict[str, str]) -> str:
74
74
  name = groups.get("n") or groups.get("name") or groups.get("simple")
75
75
  default = groups.get("default")
76
76
  if name and name in variables:
77
- return variables[name]
77
+ return str(variables[name])
78
78
  if default is not None:
79
79
  return default
80
80
  # Keep unresolved vars as-is (useful for runtime vars)
@@ -40,13 +40,23 @@ def expand_deploy_recipe(deploy_section: dict[str, Any], variables: dict[str, st
40
40
  health_check = deploy_section.get("health_check", "/health")
41
41
  health_retries = deploy_section.get("health_retries", 5)
42
42
  health_delay = deploy_section.get("health_delay", 5)
43
- rollback_mode = deploy_section.get("rollback", "manual") # auto | manual
44
43
  restart_delay = deploy_section.get("restart_delay", 3) # seconds between stop→start
45
44
  tag_var = "${TAG}"
46
45
 
47
46
  tasks: dict[str, dict] = {}
47
+ tasks.update(_build_tasks(images, registry, tag_var))
48
+ tasks.update(_push_tasks(images, registry, tag_var))
49
+ tasks["validate-deploy"] = _validate_task()
50
+ tasks["deploy"] = _deploy_task(strategy, images, registry, tag_var, restart_delay)
51
+ tasks.update(_health_tasks(images, health_check, health_retries, health_delay, registry, tag_var))
52
+ tasks.update(_rollback_tasks(images, registry, tag_var, restart_delay))
48
53
 
49
- # ── Build tasks (one per image) ──
54
+ return tasks
55
+
56
+
57
+ def _build_tasks(images: dict, registry: str, tag_var: str) -> dict[str, dict]:
58
+ """Generate build-<svc> and build-all tasks."""
59
+ tasks: dict[str, dict] = {}
50
60
  for svc_name, dockerfile in images.items():
51
61
  image_var = f"{registry}/{svc_name}:{tag_var}"
52
62
  tasks[f"build-{svc_name}"] = {
@@ -55,8 +65,6 @@ def expand_deploy_recipe(deploy_section: dict[str, Any], variables: dict[str, st
55
65
  "tags": ["ci", "build"],
56
66
  "cmds": [f"docker build -t {image_var} -f {dockerfile} ."],
57
67
  }
58
-
59
- # ── build-all ──
60
68
  if len(images) > 1:
61
69
  tasks["build-all"] = {
62
70
  "desc": "Build all images",
@@ -65,8 +73,12 @@ def expand_deploy_recipe(deploy_section: dict[str, Any], variables: dict[str, st
65
73
  "tags": ["ci", "build"],
66
74
  "cmds": ["echo 'All images built'"],
67
75
  }
76
+ return tasks
68
77
 
69
- # ── Push tasks ──
78
+
79
+ def _push_tasks(images: dict, registry: str, tag_var: str) -> dict[str, dict]:
80
+ """Generate push-<svc> and push-all tasks."""
81
+ tasks: dict[str, dict] = {}
70
82
  for svc_name in images:
71
83
  image_var = f"{registry}/{svc_name}:{tag_var}"
72
84
  tasks[f"push-{svc_name}"] = {
@@ -76,7 +88,6 @@ def expand_deploy_recipe(deploy_section: dict[str, Any], variables: dict[str, st
76
88
  "tags": ["ci", "push"],
77
89
  "cmds": [f"docker push {image_var}"],
78
90
  }
79
-
80
91
  if len(images) > 1:
81
92
  tasks["push-all"] = {
82
93
  "desc": "Push all images to registry",
@@ -86,9 +97,12 @@ def expand_deploy_recipe(deploy_section: dict[str, Any], variables: dict[str, st
86
97
  "tags": ["ci", "push"],
87
98
  "cmds": [f"echo 'All images pushed to {registry}'"],
88
99
  }
100
+ return tasks
89
101
 
90
- # ── Validate deploy artifacts (pre-deploy gate) ──
91
- tasks["validate-deploy"] = {
102
+
103
+ def _validate_task() -> dict:
104
+ """Generate the validate-deploy gate task."""
105
+ return {
92
106
  "desc": "Validate deploy artifacts — check for unresolved variables and placeholders",
93
107
  "tags": ["ci", "validate"],
94
108
  "silent": True,
@@ -103,60 +117,69 @@ def expand_deploy_recipe(deploy_section: dict[str, Any], variables: dict[str, st
103
117
  ],
104
118
  }
105
119
 
106
- # ── Deploy task (strategy-specific) ──
120
+
121
+ def _deploy_task(
122
+ strategy: str, images: dict, registry: str, tag_var: str, restart_delay: int,
123
+ ) -> dict:
124
+ """Generate the deploy task based on strategy. Dispatches to strategy-specific builders."""
107
125
  push_dep = "push-all" if len(images) > 1 else f"push-{list(images)[0]}" if images else None
108
126
  deploy_deps = ["validate-deploy"]
109
127
  if push_dep:
110
128
  deploy_deps.insert(0, push_dep)
111
129
 
112
- if strategy == "compose":
113
- tasks["deploy"] = _compose_deploy(deploy_deps)
114
- elif strategy == "quadlet":
115
- tasks["deploy"] = _quadlet_deploy(deploy_deps, images, registry, tag_var, restart_delay)
116
- elif strategy == "ssh-push":
117
- tasks["deploy"] = _ssh_push_deploy(deploy_deps, images, registry, tag_var, restart_delay)
118
- else:
119
- tasks["deploy"] = _compose_deploy(deploy_deps)
120
-
121
- # ── Health check ──
122
- tasks["health"] = {
123
- "desc": "Check application health",
124
- "tags": ["ops", "health"],
125
- "silent": True,
126
- "retries": health_retries,
127
- "retry_delay": health_delay,
128
- "cmds": [
129
- f"curl -sf https://${{DOMAIN}}{health_check} && echo 'OK' || exit 1",
130
- ],
130
+ _STRATEGY_DISPATCH = {
131
+ "compose": lambda: _compose_deploy(deploy_deps),
132
+ "quadlet": lambda: _quadlet_deploy(deploy_deps, images, registry, tag_var, restart_delay),
133
+ "ssh-push": lambda: _ssh_push_deploy(deploy_deps, images, registry, tag_var, restart_delay),
131
134
  }
135
+ builder = _STRATEGY_DISPATCH.get(strategy, _STRATEGY_DISPATCH["compose"])
136
+ return builder()
132
137
 
133
- # ── Post-deploy health gate ──
134
- tasks["post-deploy"] = {
135
- "desc": "Post-deploy health gate verify all services are healthy after deploy",
136
- "tags": ["deploy", "health"],
137
- "silent": True,
138
- "retries": health_retries,
139
- "retry_delay": health_delay,
140
- "cmds": _post_deploy_health_cmds(images, health_check, registry, tag_var),
138
+
139
+ def _health_tasks(
140
+ images: dict, health_check: str, health_retries: int, health_delay: int,
141
+ registry: str, tag_var: str,
142
+ ) -> dict[str, dict]:
143
+ """Generate health and post-deploy health gate tasks."""
144
+ return {
145
+ "health": {
146
+ "desc": "Check application health",
147
+ "tags": ["ops", "health"],
148
+ "silent": True,
149
+ "retries": health_retries,
150
+ "retry_delay": health_delay,
151
+ "cmds": [
152
+ f"curl -sf https://${{DOMAIN}}{health_check} && echo 'OK' || exit 1",
153
+ ],
154
+ },
155
+ "post-deploy": {
156
+ "desc": "Post-deploy health gate — verify all services are healthy after deploy",
157
+ "tags": ["deploy", "health"],
158
+ "silent": True,
159
+ "retries": health_retries,
160
+ "retry_delay": health_delay,
161
+ "cmds": _post_deploy_health_cmds(images, health_check, registry, tag_var),
162
+ },
141
163
  }
142
164
 
143
- # ── Rollback ──
144
- if images:
145
- rollback_cmds = []
146
- for svc_name in images:
147
- image_var = f"{registry}/{svc_name}:{tag_var}"
148
- rollback_cmds.append(f"@remote podman pull {image_var}")
149
- for svc_name in images:
150
- rollback_cmds.extend(
151
- _graceful_restart_cmds(svc_name, restart_delay)
152
- )
153
- tasks["rollback"] = {
165
+
166
+ def _rollback_tasks(images: dict, registry: str, tag_var: str, restart_delay: int) -> dict[str, dict]:
167
+ """Generate rollback task if images are defined."""
168
+ if not images:
169
+ return {}
170
+ rollback_cmds = []
171
+ for svc_name in images:
172
+ image_var = f"{registry}/{svc_name}:{tag_var}"
173
+ rollback_cmds.append(f"@remote podman pull {image_var}")
174
+ for svc_name in images:
175
+ rollback_cmds.extend(_graceful_restart_cmds(svc_name, restart_delay))
176
+ return {
177
+ "rollback": {
154
178
  "desc": "Rollback to specified version (--var TAG=<prev>)",
155
179
  "tags": ["deploy", "rollback"],
156
180
  "cmds": rollback_cmds,
157
- }
158
-
159
- return tasks
181
+ },
182
+ }
160
183
 
161
184
 
162
185
  def _graceful_restart_cmds(svc_name: str, restart_delay: int = 3) -> list[str]: