redeploy 0.2.51__tar.gz → 0.2.52__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 (213) hide show
  1. {redeploy-0.2.51 → redeploy-0.2.52}/PKG-INFO +43 -6
  2. {redeploy-0.2.51 → redeploy-0.2.52}/README.md +42 -5
  3. {redeploy-0.2.51 → redeploy-0.2.52}/pyproject.toml +1 -1
  4. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/__init__.py +5 -1
  5. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/apply/executor.py +88 -2
  6. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/detect.py +20 -1
  7. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/import_.py +103 -2
  8. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/prompt_cmd.py +28 -2
  9. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/heal.py +37 -0
  10. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/integrations/op3_bridge.py +44 -1
  11. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/markpact/compiler.py +6 -1
  12. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/models.py +102 -2
  13. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/plan/planner.py +1 -0
  14. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/schema.py +67 -0
  15. redeploy-0.2.52/redeploy/tests/test_heal.py +26 -0
  16. redeploy-0.2.52/redeploy/tests/test_hooks.py +123 -0
  17. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy.egg-info/PKG-INFO +43 -6
  18. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy.egg-info/SOURCES.txt +7 -0
  19. redeploy-0.2.52/tests/test_example_argocd_flux_plugin.py +82 -0
  20. redeploy-0.2.52/tests/test_example_gitops_ci_plugin.py +74 -0
  21. redeploy-0.2.52/tests/test_example_helm_kustomize_plugin.py +72 -0
  22. redeploy-0.2.52/tests/test_iac_plugin_loading.py +79 -0
  23. redeploy-0.2.52/tests/test_import_plugin_template.py +55 -0
  24. {redeploy-0.2.51 → redeploy-0.2.52}/tests/test_prompt_llm.py +93 -0
  25. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/apply/__init__.py +0 -0
  26. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/apply/exceptions.py +0 -0
  27. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/apply/handlers.py +0 -0
  28. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/apply/progress.py +0 -0
  29. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/apply/rollback.py +0 -0
  30. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/apply/state.py +0 -0
  31. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/apply/state_apply.py +0 -0
  32. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/apply/utils/__init__.py +0 -0
  33. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/apply/utils/run_container_build.py +0 -0
  34. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/audit.py +0 -0
  35. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/blueprint/__init__.py +0 -0
  36. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/blueprint/extractor.py +0 -0
  37. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/blueprint/generators/__init__.py +0 -0
  38. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/blueprint/generators/docker_compose.py +0 -0
  39. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/blueprint/generators/migration.py +0 -0
  40. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/blueprint/sources/__init__.py +0 -0
  41. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/blueprint/sources/compose.py +0 -0
  42. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/blueprint/sources/hardware.py +0 -0
  43. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/blueprint/sources/infra.py +0 -0
  44. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/blueprint/sources/migration.py +0 -0
  45. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/__init__.py +0 -0
  46. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/__init__.py +0 -0
  47. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/audit.py +0 -0
  48. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/blueprint.py +0 -0
  49. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/bump_fix.py +0 -0
  50. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/device_map.py +0 -0
  51. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/device_map_renderers.py +0 -0
  52. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/devices.py +0 -0
  53. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/diagnose.py +0 -0
  54. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/diff.py +0 -0
  55. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/exec_.py +0 -0
  56. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/export.py +0 -0
  57. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/hardware.py +0 -0
  58. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/init.py +0 -0
  59. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/inspect.py +0 -0
  60. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/mcp_cmd.py +0 -0
  61. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/patterns.py +0 -0
  62. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/plan_apply.py +0 -0
  63. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/plugin.py +0 -0
  64. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/probe.py +0 -0
  65. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/push.py +0 -0
  66. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/state.py +0 -0
  67. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/status.py +0 -0
  68. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/target.py +0 -0
  69. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/version/__init__.py +0 -0
  70. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/version/commands.py +0 -0
  71. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/version/helpers.py +0 -0
  72. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/version/monorepo.py +0 -0
  73. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/version/release.py +0 -0
  74. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/version/scanner.py +0 -0
  75. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/commands/workflow.py +0 -0
  76. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/core.py +0 -0
  77. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/display.py +0 -0
  78. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli/query.py +0 -0
  79. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/cli.py +0 -0
  80. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/config_apply/__init__.py +0 -0
  81. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/config_apply/applier.py +0 -0
  82. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/config_apply/loader.py +0 -0
  83. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/data_sync.py +0 -0
  84. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/detect/__init__.py +0 -0
  85. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/detect/builtin/__init__.py +0 -0
  86. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/detect/detector.py +0 -0
  87. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/detect/hardware.py +0 -0
  88. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/detect/hardware_rules.py +0 -0
  89. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/detect/probes.py +0 -0
  90. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/detect/remote.py +0 -0
  91. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/detect/templates.py +0 -0
  92. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/detect/workflow.py +0 -0
  93. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/discovery.py +0 -0
  94. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/dsl/__init__.py +0 -0
  95. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/dsl/loader.py +0 -0
  96. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/dsl/parser.py +0 -0
  97. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/dsl_python/__init__.py +0 -0
  98. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/dsl_python/context.py +0 -0
  99. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/dsl_python/decorators.py +0 -0
  100. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/dsl_python/docker_steps.py +0 -0
  101. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/dsl_python/exceptions.py +0 -0
  102. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/dsl_python/runner.py +0 -0
  103. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/dsl_python/steps.py +0 -0
  104. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/fleet.py +0 -0
  105. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/hardware/__init__.py +0 -0
  106. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/hardware/config_txt.py +0 -0
  107. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/hardware/data/__init__.py +0 -0
  108. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/hardware/data/hyperpixel.py +0 -0
  109. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/hardware/data/official.py +0 -0
  110. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/hardware/data/waveshare.py +0 -0
  111. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/hardware/fixes.py +0 -0
  112. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/hardware/kiosk/__init__.py +0 -0
  113. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/hardware/kiosk/autostart.py +0 -0
  114. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/hardware/kiosk/browsers.py +0 -0
  115. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/hardware/kiosk/compositors.py +0 -0
  116. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/hardware/kiosk/output_profiles.py +0 -0
  117. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/hardware/panels.py +0 -0
  118. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/hardware/raspi_config.py +0 -0
  119. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/iac/__init__.py +0 -0
  120. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/iac/base.py +0 -0
  121. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/iac/config_hints.py +0 -0
  122. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/iac/docker_compose.py +0 -0
  123. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/iac/parsers/__init__.py +0 -0
  124. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/iac/parsers/compose.py +0 -0
  125. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/iac/registry.py +0 -0
  126. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/integrations/__init__.py +0 -0
  127. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/markpact/__init__.py +0 -0
  128. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/markpact/models.py +0 -0
  129. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/markpact/parser.py +0 -0
  130. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/mcp_server.py +0 -0
  131. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/observe.py +0 -0
  132. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/parse.py +0 -0
  133. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/patterns.py +0 -0
  134. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/plan/__init__.py +0 -0
  135. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/plugins/__init__.py +0 -0
  136. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/plugins/builtin/__init__.py +0 -0
  137. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/plugins/builtin/browser_reload.py +0 -0
  138. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/plugins/builtin/hardware_diagnostic.py +0 -0
  139. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/plugins/builtin/notify.py +0 -0
  140. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/plugins/builtin/process_control.py +0 -0
  141. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  142. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/spec_loader.py +0 -0
  143. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/ssh.py +0 -0
  144. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/steps/__init__.py +0 -0
  145. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/steps/builtins.py +0 -0
  146. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/steps/kiosk.py +0 -0
  147. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/__init__.py +0 -0
  148. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_audit.py +0 -0
  149. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_cli.py +0 -0
  150. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_data_sync.py +0 -0
  151. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_discovery.py +0 -0
  152. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_dsl.py +0 -0
  153. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_examples.py +0 -0
  154. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_executor.py +0 -0
  155. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_fleet.py +0 -0
  156. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_git_integration.py +0 -0
  157. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_iac.py +0 -0
  158. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_manifest.py +0 -0
  159. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_markpact_compiler.py +0 -0
  160. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_markpact_parser.py +0 -0
  161. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_models.py +0 -0
  162. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_observe.py +0 -0
  163. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_parse.py +0 -0
  164. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_patterns.py +0 -0
  165. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_planner_edge.py +0 -0
  166. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_plugins.py +0 -0
  167. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_probes.py +0 -0
  168. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_public_api.py +0 -0
  169. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_resume.py +0 -0
  170. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_spec_loader.py +0 -0
  171. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_ssh.py +0 -0
  172. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_templates.py +0 -0
  173. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_verify.py +0 -0
  174. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_version.py +0 -0
  175. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_version_changelog.py +0 -0
  176. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_version_cli.py +0 -0
  177. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_version_manifest.py +0 -0
  178. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_version_sources.py +0 -0
  179. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/tests/test_workflow.py +0 -0
  180. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/verify.py +0 -0
  181. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/version/__init__.py +0 -0
  182. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/version/bump.py +0 -0
  183. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/version/changelog.py +0 -0
  184. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/version/commits.py +0 -0
  185. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/version/diff.py +0 -0
  186. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/version/git_integration.py +0 -0
  187. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/version/git_transaction.py +0 -0
  188. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/version/manifest.py +0 -0
  189. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/version/sources/__init__.py +0 -0
  190. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/version/sources/base.py +0 -0
  191. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/version/sources/json_.py +0 -0
  192. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/version/sources/plain.py +0 -0
  193. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/version/sources/regex.py +0 -0
  194. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/version/sources/toml_.py +0 -0
  195. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/version/sources/yaml_.py +0 -0
  196. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/version/transaction.py +0 -0
  197. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy/version.py +0 -0
  198. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy.egg-info/dependency_links.txt +0 -0
  199. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy.egg-info/entry_points.txt +0 -0
  200. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy.egg-info/requires.txt +0 -0
  201. {redeploy-0.2.51 → redeploy-0.2.52}/redeploy.egg-info/top_level.txt +0 -0
  202. {redeploy-0.2.51 → redeploy-0.2.52}/setup.cfg +0 -0
  203. {redeploy-0.2.51 → redeploy-0.2.52}/tests/test_blueprint_sources.py +0 -0
  204. {redeploy-0.2.51 → redeploy-0.2.52}/tests/test_blueprint_sources_compose.py +0 -0
  205. {redeploy-0.2.51 → redeploy-0.2.52}/tests/test_cli_query.py +0 -0
  206. {redeploy-0.2.51 → redeploy-0.2.52}/tests/test_hardware_config.py +0 -0
  207. {redeploy-0.2.51 → redeploy-0.2.52}/tests/test_hardware_kiosk.py +0 -0
  208. {redeploy-0.2.51 → redeploy-0.2.52}/tests/test_hardware_rules.py +0 -0
  209. {redeploy-0.2.51 → redeploy-0.2.52}/tests/test_iac.py +0 -0
  210. {redeploy-0.2.51 → redeploy-0.2.52}/tests/test_iac_config_hints.py +0 -0
  211. {redeploy-0.2.51 → redeploy-0.2.52}/tests/test_parse.py +0 -0
  212. {redeploy-0.2.51 → redeploy-0.2.52}/tests/test_redeploy.py +0 -0
  213. {redeploy-0.2.51 → redeploy-0.2.52}/tests/test_steps_kiosk.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.51
3
+ Version: 0.2.52
4
4
  Summary: Infrastructure migration toolkit: detect → plan → apply
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -32,17 +32,17 @@ Requires-Dist: mcp>=1.0; extra == "mcp"
32
32
 
33
33
  ## AI Cost Tracking
34
34
 
35
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.51-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
36
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-25.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
35
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.52-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
36
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-27.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
37
37
 
38
- - 🤖 **LLM usage:** $7.5000 (71 commits)
39
- - 👤 **Human dev:** ~$2496 (25.0h @ $100/h, 30min dedup)
38
+ - 🤖 **LLM usage:** $7.5000 (72 commits)
39
+ - 👤 **Human dev:** ~$2695 (27.0h @ $100/h, 30min dedup)
40
40
 
41
41
  Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
42
42
 
43
43
  ---
44
44
 
45
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.51-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
45
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.52-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
46
46
 
47
47
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
48
48
 
@@ -684,6 +684,43 @@ Parser plugin extension:
684
684
  - Project-local parsers: `./redeploy_iac_parsers/*.py`
685
685
  - User-global parsers: `~/.redeploy/iac_parsers/*.py`
686
686
 
687
+ Built-in template generator:
688
+ - `redeploy import --list-plugin-templates`
689
+ - `redeploy import --plugin-template helm-kustomize`
690
+ - `redeploy import --plugin-template argocd-flux --plugin-dir redeploy_iac_parsers`
691
+
692
+ Example external plugin (Helm + Ansible):
693
+ - Source template: `examples/redeploy_iac_parsers/helm_ansible.py`
694
+ - Quick start:
695
+ 1. `mkdir -p redeploy_iac_parsers`
696
+ 2. `cp examples/redeploy_iac_parsers/helm_ansible.py redeploy_iac_parsers/`
697
+ 3. `redeploy import path/to/Chart.yaml`
698
+ 4. `redeploy import path/to/playbook.yml`
699
+
700
+ Example external plugin (Helm templates + Kustomize):
701
+ - Source template: `examples/redeploy_iac_parsers/helm_kustomize.py`
702
+ - Quick start:
703
+ 1. `mkdir -p redeploy_iac_parsers`
704
+ 2. `cp examples/redeploy_iac_parsers/helm_kustomize.py redeploy_iac_parsers/`
705
+ 3. `redeploy import path/to/chart/templates/deployment.yaml`
706
+ 4. `redeploy import path/to/kustomization.yaml`
707
+
708
+ Example external plugin (ArgoCD Application + Flux Kustomization):
709
+ - Source template: `examples/redeploy_iac_parsers/argocd_flux.py`
710
+ - Quick start:
711
+ 1. `mkdir -p redeploy_iac_parsers`
712
+ 2. `cp examples/redeploy_iac_parsers/argocd_flux.py redeploy_iac_parsers/`
713
+ 3. `redeploy import path/to/argocd-application.yaml`
714
+ 4. `redeploy import path/to/flux-kustomization.yaml`
715
+
716
+ Example external plugin (GitOps CI for ArgoCD/Flux):
717
+ - Source template: `examples/redeploy_iac_parsers/gitops_ci.py`
718
+ - Quick start:
719
+ 1. `mkdir -p redeploy_iac_parsers`
720
+ 2. `cp examples/redeploy_iac_parsers/gitops_ci.py redeploy_iac_parsers/`
721
+ 3. `redeploy import .github/workflows/deploy-gitops.yml`
722
+ 4. `redeploy import .gitlab-ci.yml`
723
+
687
724
  ## Examples
688
725
 
689
726
  | Directory | Scenario | Strategy |
@@ -3,17 +3,17 @@
3
3
 
4
4
  ## AI Cost Tracking
5
5
 
6
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.51-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-25.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.52-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-27.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $7.5000 (71 commits)
10
- - 👤 **Human dev:** ~$2496 (25.0h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $7.5000 (72 commits)
10
+ - 👤 **Human dev:** ~$2695 (27.0h @ $100/h, 30min dedup)
11
11
 
12
12
  Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
13
13
 
14
14
  ---
15
15
 
16
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.51-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
16
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.52-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
17
17
 
18
18
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
19
19
 
@@ -655,6 +655,43 @@ Parser plugin extension:
655
655
  - Project-local parsers: `./redeploy_iac_parsers/*.py`
656
656
  - User-global parsers: `~/.redeploy/iac_parsers/*.py`
657
657
 
658
+ Built-in template generator:
659
+ - `redeploy import --list-plugin-templates`
660
+ - `redeploy import --plugin-template helm-kustomize`
661
+ - `redeploy import --plugin-template argocd-flux --plugin-dir redeploy_iac_parsers`
662
+
663
+ Example external plugin (Helm + Ansible):
664
+ - Source template: `examples/redeploy_iac_parsers/helm_ansible.py`
665
+ - Quick start:
666
+ 1. `mkdir -p redeploy_iac_parsers`
667
+ 2. `cp examples/redeploy_iac_parsers/helm_ansible.py redeploy_iac_parsers/`
668
+ 3. `redeploy import path/to/Chart.yaml`
669
+ 4. `redeploy import path/to/playbook.yml`
670
+
671
+ Example external plugin (Helm templates + Kustomize):
672
+ - Source template: `examples/redeploy_iac_parsers/helm_kustomize.py`
673
+ - Quick start:
674
+ 1. `mkdir -p redeploy_iac_parsers`
675
+ 2. `cp examples/redeploy_iac_parsers/helm_kustomize.py redeploy_iac_parsers/`
676
+ 3. `redeploy import path/to/chart/templates/deployment.yaml`
677
+ 4. `redeploy import path/to/kustomization.yaml`
678
+
679
+ Example external plugin (ArgoCD Application + Flux Kustomization):
680
+ - Source template: `examples/redeploy_iac_parsers/argocd_flux.py`
681
+ - Quick start:
682
+ 1. `mkdir -p redeploy_iac_parsers`
683
+ 2. `cp examples/redeploy_iac_parsers/argocd_flux.py redeploy_iac_parsers/`
684
+ 3. `redeploy import path/to/argocd-application.yaml`
685
+ 4. `redeploy import path/to/flux-kustomization.yaml`
686
+
687
+ Example external plugin (GitOps CI for ArgoCD/Flux):
688
+ - Source template: `examples/redeploy_iac_parsers/gitops_ci.py`
689
+ - Quick start:
690
+ 1. `mkdir -p redeploy_iac_parsers`
691
+ 2. `cp examples/redeploy_iac_parsers/gitops_ci.py redeploy_iac_parsers/`
692
+ 3. `redeploy import .github/workflows/deploy-gitops.yml`
693
+ 4. `redeploy import .gitlab-ci.yml`
694
+
658
695
  ## Examples
659
696
 
660
697
  | Directory | Scenario | Strategy |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "redeploy"
7
- version = "0.2.51"
7
+ version = "0.2.52"
8
8
  description = "Infrastructure migration toolkit: detect → plan → apply"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -7,17 +7,19 @@ Public API (stable from 0.2.0, semver guaranteed)::
7
7
 
8
8
  Everything not listed in ``__all__`` is internal and may change without notice.
9
9
  """
10
- __version__ = "0.2.51"
10
+ __version__ = "0.2.52"
11
11
 
12
12
  # ── Core models ───────────────────────────────────────────────────────────────
13
13
  from .models import ( # noqa: F401
14
14
  ConflictSeverity,
15
15
  DeployStrategy,
16
+ Hook,
16
17
  InfraSpec,
17
18
  InfraState,
18
19
  MigrationPlan,
19
20
  MigrationSpec,
20
21
  MigrationStep,
22
+ PipelinePhase,
21
23
  StepAction,
22
24
  StepStatus,
23
25
  TargetConfig,
@@ -91,11 +93,13 @@ __all__ = [
91
93
  # models
92
94
  "ConflictSeverity",
93
95
  "DeployStrategy",
96
+ "Hook",
94
97
  "InfraSpec",
95
98
  "InfraState",
96
99
  "MigrationPlan",
97
100
  "MigrationSpec",
98
101
  "MigrationStep",
102
+ "PipelinePhase",
99
103
  "StepAction",
100
104
  "StepStatus",
101
105
  "TargetConfig",
@@ -3,13 +3,13 @@ from __future__ import annotations
3
3
 
4
4
  import time
5
5
  from pathlib import Path
6
- from typing import IO, Optional
6
+ from typing import IO, Any, Optional
7
7
 
8
8
  import yaml
9
9
  from loguru import logger
10
10
 
11
11
  from ..detect.remote import RemoteProbe
12
- from ..models import MigrationPlan, MigrationStep, StepAction, StepStatus
12
+ from ..models import Hook, MigrationPlan, MigrationStep, StepAction, StepStatus
13
13
  from .exceptions import StepError
14
14
  from .handlers import (
15
15
  run_ssh, run_scp, run_rsync, run_docker_build, run_podman_build,
@@ -100,11 +100,13 @@ class Executor:
100
100
  logger.info(f"resume: skipping {len(skip_ids)} already-completed step(s): "
101
101
  f"{', '.join(sorted(skip_ids))}")
102
102
 
103
+ self._fire_hooks("before_apply")
103
104
  ok = self._execute_steps_loop(skip_ids)
104
105
  elapsed = time.monotonic() - self._t0
105
106
 
106
107
  self._handle_completion(ok, elapsed)
107
108
  self._write_audit(ok=ok, elapsed_s=elapsed)
109
+ self._fire_hooks("always", ok=ok, elapsed_s=elapsed)
108
110
  return ok
109
111
 
110
112
  def _execute_steps_loop(self, skip_ids: set[str]) -> bool:
@@ -118,13 +120,16 @@ class Executor:
118
120
  try:
119
121
  if self._emitter:
120
122
  self._emitter.step_start(i, step)
123
+ self._fire_hooks("before_step", step=step)
121
124
  self._execute_step(step)
122
125
  self._completed.append(step)
123
126
  if self._state is not None:
124
127
  self._state.mark_done(step.id)
125
128
  if self._emitter:
126
129
  self._emitter.step_done(i, step)
130
+ self._fire_hooks("after_step", step=step)
127
131
  except StepError as e:
132
+ self._fire_hooks("on_step_failure", step=step, error=str(e))
128
133
  self._handle_step_failure(i, step, e)
129
134
  ok = False
130
135
  break
@@ -159,10 +164,91 @@ class Executor:
159
164
  # Plan finished cleanly — drop the checkpoint.
160
165
  if self._state is not None:
161
166
  self._state.remove()
167
+ self._fire_hooks("after_apply", elapsed_s=elapsed)
162
168
  else:
163
169
  if self._state is not None and self._state_path is not None:
164
170
  logger.info(f"resume: checkpoint saved → {self._state_path} "
165
171
  f"({self._state.completed_count}/{self._state.total_steps} done)")
172
+ self._fire_hooks("on_failure", elapsed_s=elapsed)
173
+
174
+ # ── Pipeline hooks (generic) ─────────────────────────────────────────────
175
+
176
+ def _fire_hooks(self, phase: str, **context: Any) -> None:
177
+ """Fire all hooks registered for a given phase. Honors ``when`` + ``on_failure``."""
178
+ hooks = [h for h in getattr(self.plan, "hooks", []) if h.phase == phase]
179
+ if not hooks:
180
+ return
181
+ for hook in hooks:
182
+ if hook.when and not self._eval_hook_condition(hook.when, context):
183
+ continue
184
+ try:
185
+ self._execute_hook(hook, context)
186
+ except Exception as exc: # noqa: BLE001
187
+ self._handle_hook_failure(hook, exc)
188
+
189
+ def _eval_hook_condition(self, expr: str, context: dict) -> bool:
190
+ """Evaluate a simple hook condition against the context.
191
+
192
+ Supports only ``step.id == 'foo'`` / ``step.id != 'foo'`` for now to avoid
193
+ executing arbitrary expressions. Extend with jmespath when needed.
194
+ """
195
+ step = context.get("step")
196
+ step_id = getattr(step, "id", None)
197
+ expr = expr.strip()
198
+ for op in ("==", "!="):
199
+ if op in expr:
200
+ left, right = [p.strip() for p in expr.split(op, 1)]
201
+ right = right.strip("'\"")
202
+ if left == "step.id":
203
+ return (step_id == right) if op == "==" else (step_id != right)
204
+ logger.warning(f"hook: unsupported when expression: {expr!r}")
205
+ return True
206
+
207
+ def _execute_hook(self, hook: "Hook", context: dict) -> None:
208
+ """Execute a hook. Built-in actions handled inline; rest delegated to step runners."""
209
+ action = hook.action
210
+ extra = hook.model_dump(exclude={"id", "phase", "action", "description", "when", "on_failure"})
211
+ logger.info(f"hook[{hook.phase}] {hook.id}: {action}")
212
+ if self.dry_run:
213
+ return
214
+
215
+ # Built-in: local_cmd — run a command on the controller machine (opens browser, clears local cache, etc.)
216
+ if action == "local_cmd":
217
+ import subprocess
218
+ cmd = extra.get("command") or ""
219
+ if not cmd:
220
+ raise ValueError(f"hook {hook.id}: local_cmd requires 'command'")
221
+ subprocess.run(cmd, shell=True, check=True)
222
+ return
223
+
224
+ # Built-in: open_url — open a URL in the default browser (controller machine)
225
+ if action == "open_url":
226
+ import webbrowser
227
+ url = extra.get("url") or extra.get("command") or ""
228
+ if not url:
229
+ raise ValueError(f"hook {hook.id}: open_url requires 'url'")
230
+ webbrowser.open(url)
231
+ return
232
+
233
+ # Fallback: treat hook like a MigrationStep and let existing runners handle it.
234
+ step = MigrationStep(
235
+ id=hook.id,
236
+ action=action, # pydantic will validate against StepAction enum
237
+ description=hook.description or f"hook:{hook.phase}:{hook.id}",
238
+ **extra,
239
+ )
240
+ self._execute_step(step)
241
+
242
+ def _handle_hook_failure(self, hook: "Hook", exc: Exception) -> None:
243
+ policy = hook.on_failure
244
+ msg = f"hook {hook.id} ({hook.phase}) failed: {exc}"
245
+ if policy == "abort":
246
+ logger.error(msg)
247
+ raise exc
248
+ if policy == "warn":
249
+ logger.warning(msg)
250
+ else: # continue
251
+ logger.debug(msg)
166
252
 
167
253
  def _compute_skip_set(self) -> set[str]:
168
254
  """Determine which step ids should be skipped this run.
@@ -105,7 +105,26 @@ def detect(
105
105
 
106
106
  try:
107
107
  d = Detector(host=host, app=app_name, domain=domain)
108
- state = d.run()
108
+ from ...integrations.op3_bridge import should_use_op3
109
+ if should_use_op3():
110
+ from ...integrations.op3_bridge import (
111
+ make_scanner,
112
+ make_ssh_context,
113
+ snapshot_to_infra_state,
114
+ )
115
+ scanner = make_scanner(
116
+ [
117
+ "runtime.container",
118
+ "service.containers",
119
+ "endpoint.http",
120
+ "business.health",
121
+ ]
122
+ )
123
+ ctx = make_ssh_context(target=host)
124
+ snapshot = scanner.scan(host, ctx.execute)
125
+ state = snapshot_to_infra_state(snapshot, host=host)
126
+ else:
127
+ state = d.run()
109
128
  d.save(state, out_path)
110
129
  except ConnectionError as e:
111
130
  console.print(f"[red]✗ {e}[/red]")
@@ -1,6 +1,7 @@
1
1
  """import command — Parse IaC/CI-CD file and produce migration.yaml scaffold."""
2
2
  from __future__ import annotations
3
3
 
4
+ import shutil
4
5
  import sys
5
6
  from pathlib import Path
6
7
 
@@ -10,7 +11,7 @@ from rich.table import Table
10
11
 
11
12
 
12
13
  @click.command(name="import")
13
- @click.argument("source", type=click.Path(exists=True))
14
+ @click.argument("source", required=False, type=click.Path())
14
15
  @click.option(
15
16
  "-o", "--output", default=None, type=click.Path(),
16
17
  help="Output migration.yaml path (default: <source-stem>.migration.yaml)"
@@ -36,7 +37,36 @@ from rich.table import Table
36
37
  "--parser", default=None,
37
38
  help="Force specific parser (e.g. docker_compose). Default: auto-detect"
38
39
  )
39
- def import_cmd(source, output, target_host, target_strategy, dry_run, out_format, parser):
40
+ @click.option(
41
+ "--plugin-template",
42
+ type=click.Choice(["helm-ansible", "helm-kustomize", "argocd-flux", "gitops-ci"]),
43
+ default=None,
44
+ help="Copy a ready-made parser plugin template into the project instead of parsing SOURCE.",
45
+ )
46
+ @click.option(
47
+ "--plugin-dir",
48
+ default="redeploy_iac_parsers",
49
+ type=click.Path(),
50
+ show_default=True,
51
+ help="Destination directory for --plugin-template.",
52
+ )
53
+ @click.option(
54
+ "--list-plugin-templates",
55
+ is_flag=True,
56
+ help="List built-in parser plugin templates and exit.",
57
+ )
58
+ def import_cmd(
59
+ source,
60
+ output,
61
+ target_host,
62
+ target_strategy,
63
+ dry_run,
64
+ out_format,
65
+ parser,
66
+ plugin_template,
67
+ plugin_dir,
68
+ list_plugin_templates,
69
+ ):
40
70
  """Parse an IaC/CI-CD file and produce a migration.yaml scaffold.
41
71
 
42
72
  Auto-detects format from filename. Built-in parsers cover:
@@ -55,12 +85,34 @@ def import_cmd(source, output, target_host, target_strategy, dry_run, out_format
55
85
  redeploy import docker-compose.yml --target-host root@vps.example.com
56
86
  redeploy import . --dry-run # parse whole directory
57
87
  redeploy import docker-compose.yml --format summary
88
+ redeploy import --plugin-template helm-kustomize
58
89
  """
59
90
  import json as _json
60
91
  from ...iac import parse_file, parse_dir, parser_registry
61
92
 
62
93
  console = Console()
94
+
95
+ if list_plugin_templates:
96
+ _print_plugin_templates(console)
97
+ return
98
+
99
+ if plugin_template:
100
+ _copy_plugin_template(
101
+ console,
102
+ plugin_template=plugin_template,
103
+ plugin_dir=Path(plugin_dir),
104
+ dry_run=dry_run,
105
+ )
106
+ return
107
+
108
+ if not source:
109
+ console.print("[red]✗ SOURCE is required unless --plugin-template is used[/red]")
110
+ sys.exit(1)
111
+
63
112
  src_path = Path(source)
113
+ if not src_path.exists():
114
+ console.print(f"[red]✗ source does not exist: {source}[/red]")
115
+ sys.exit(1)
64
116
 
65
117
  # Parse
66
118
  if parser:
@@ -190,6 +242,55 @@ def _default_output(src: Path, spec) -> Path:
190
242
  return src.parent / f"{stem}.yaml"
191
243
 
192
244
 
245
+ def _plugin_templates() -> dict[str, str]:
246
+ return {
247
+ "helm-ansible": "helm_ansible.py",
248
+ "helm-kustomize": "helm_kustomize.py",
249
+ "argocd-flux": "argocd_flux.py",
250
+ "gitops-ci": "gitops_ci.py",
251
+ }
252
+
253
+
254
+ def _examples_plugin_dir() -> Path:
255
+ return Path(__file__).resolve().parents[3] / "examples" / "redeploy_iac_parsers"
256
+
257
+
258
+ def _print_plugin_templates(console: Console) -> None:
259
+ console.print("[bold]Available parser plugin templates[/bold]")
260
+ for name, filename in _plugin_templates().items():
261
+ console.print(f" - [cyan]{name}[/cyan] ({filename})")
262
+
263
+
264
+ def _copy_plugin_template(
265
+ console: Console,
266
+ *,
267
+ plugin_template: str,
268
+ plugin_dir: Path,
269
+ dry_run: bool,
270
+ ) -> Path:
271
+ templates = _plugin_templates()
272
+ filename = templates[plugin_template]
273
+ src = _examples_plugin_dir() / filename
274
+ dst = plugin_dir / filename
275
+
276
+ if not src.exists():
277
+ console.print(f"[red]✗ built-in plugin template not found: {src}[/red]")
278
+ sys.exit(1)
279
+
280
+ console.print(f"[bold]plugin template[/bold] {plugin_template}")
281
+ console.print(f" source: {src}")
282
+ console.print(f" destination: {dst}")
283
+
284
+ if dry_run:
285
+ console.print("\n[dim][DRY RUN] No file written.[/dim]")
286
+ return dst
287
+
288
+ dst.parent.mkdir(parents=True, exist_ok=True)
289
+ shutil.copy2(src, dst)
290
+ console.print(f"\n [green]✓[/green] copied → [bold]{dst}[/bold]")
291
+ return dst
292
+
293
+
193
294
  def _spec_to_migration_yaml(
194
295
  spec, *, target_host: str = None, target_strategy: str = None
195
296
  ) -> dict:
@@ -35,6 +35,7 @@ You will receive a JSON schema describing the current workspace with:
35
35
  - git_branch : current git branch
36
36
  - specs : list of discovered migration specs (path, version, target, name)
37
37
  - commands : catalogue of available redeploy commands with descriptions and examples
38
+ - iac : parser/plugin-template info for IaC/CI-CD imports
38
39
 
39
40
  Your task: map the user's instruction to a single redeploy command.
40
41
 
@@ -49,9 +50,23 @@ Output ONLY valid JSON (no markdown fences, no extra text) with this structure:
49
50
  Rules:
50
51
  - argv[0] MUST be "redeploy".
51
52
  - Use relative paths for specs (relative to cwd).
53
+ - If the user asks about Dockerfile, nginx, Kubernetes, Terraform, TOML, Vite, GitHub Actions, GitLab CI, Jenkinsfile, Helm, Kustomize, ArgoCD, Flux, or GitOps configs, prefer `redeploy import ...`.
54
+ - If the user asks to add support for a new IaC/CI/CD format and a matching template exists in schema["iac"]["plugin_templates"], prefer `redeploy import --plugin-template <name>`.
55
+ - If the user asks what plugin templates or parsers are available, prefer `redeploy import --list-plugin-templates`.
52
56
  - If the user's intent is unclear, pick the safest command (e.g. "plan" with --dry-run=true).
53
57
  - Set confirm=false ONLY for read-only/safe commands (plan, inspect, status, audit, diff, diagnose).
54
- - Set confirm=true for commands that change state (fix, run, bump, push, init).
58
+ - Set confirm=false for `import --list-plugin-templates` and `import --dry-run`.
59
+ - Set confirm=true for `import --plugin-template ...` because it writes files.
60
+ - Set confirm=false for "run --dry-run" (previewing a spec is safe, no changes made).
61
+ - Set confirm=true for "fix" commands (even with --dry-run flag - repair operations need confirmation for safety).
62
+ - Set confirm=true for "run" without --dry-run (actual deployment changes state).
63
+ - Set confirm=true for "bump", "push", "init" (these change project state).
64
+ - Examples:
65
+ * "pokaż plan" → argv=["redeploy", "run", "spec.md", "--dry-run"], confirm=false
66
+ * "deploy to pi109" → argv=["redeploy", "run", "spec.md"], confirm=true
67
+ * "napraw kiosk" → argv=["redeploy", "fix", "spec.md", "--dry-run"], confirm=true
68
+ * "dodaj parser helm + kustomize" → argv=["redeploy", "import", "--plugin-template", "helm-kustomize"], confirm=true
69
+ * "jakie plugin templates są dostępne" → argv=["redeploy", "import", "--list-plugin-templates"], confirm=false
55
70
  - If the user explicitly says "dry run" / "pokaż" / "sprawdź" / "plan", prefer --dry-run.
56
71
  - Never invent spec paths — use only paths from the schema["specs"] list.
57
72
  - If no matching spec exists, return argv=["redeploy", "plan", "--help"] and explain in human_summary.
@@ -102,7 +117,7 @@ def _call_llm(schema: dict, user_prompt: str) -> str:
102
117
 
103
118
 
104
119
  def _parse_llm_response(raw: str) -> dict:
105
- """Parse JSON from LLM response, strip accidental fences."""
120
+ """Parse JSON from LLM response, strip accidental fences and escape control characters."""
106
121
  # Strip ```json ... ``` if present
107
122
  clean = raw.strip()
108
123
  if clean.startswith("```"):
@@ -111,6 +126,17 @@ def _parse_llm_response(raw: str) -> dict:
111
126
  line for line in lines
112
127
  if not line.strip().startswith("```")
113
128
  )
129
+ # Escape control characters (except \n, \r, \t) for JSON safety
130
+ def escape_control_char(match):
131
+ char = match.group(0)
132
+ # Keep allowed whitespace, escape others as \uXXXX
133
+ if char in "\n\r\t":
134
+ return char
135
+ return f"\\u{ord(char):04x}"
136
+
137
+ # Match any control character (ord < 32) except \n, \r, \t
138
+ import re
139
+ clean = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", escape_control_char, clean)
114
140
  return json.loads(clean)
115
141
 
116
142
 
@@ -19,6 +19,7 @@ import os
19
19
  import re
20
20
  import textwrap
21
21
  import datetime
22
+ from collections import defaultdict
22
23
  from pathlib import Path
23
24
  from typing import Optional
24
25
 
@@ -248,6 +249,33 @@ def parse_failed_step(executor_summary: str, executor=None) -> tuple[str | None,
248
249
  return None, ""
249
250
 
250
251
 
252
+ class HealLoopDetector:
253
+ """Detect repeated non-converging heal hints for a given step."""
254
+
255
+ def __init__(self, max_identical_hints: int = 3):
256
+ self.max_identical_hints = max_identical_hints
257
+ self._history: dict[str, list[str]] = defaultdict(list)
258
+
259
+ def observe(self, step_id: str, hint: str) -> bool:
260
+ """Return True when the latest hints indicate a heal loop.
261
+
262
+ A loop is defined as ``max_identical_hints`` identical non-empty hints
263
+ in a row for the same failed step.
264
+ """
265
+ normalized = (hint or "").strip()
266
+ if not normalized:
267
+ return False
268
+
269
+ history = self._history[step_id]
270
+ history.append(normalized)
271
+
272
+ if len(history) < self.max_identical_hints:
273
+ return False
274
+
275
+ recent = history[-self.max_identical_hints :]
276
+ return all(item == recent[0] for item in recent)
277
+
278
+
251
279
  class HealRunner:
252
280
  """
253
281
  Wraps Executor with self-healing loop.
@@ -296,6 +324,7 @@ class HealRunner:
296
324
  self._state_file = executor_kwargs.pop("state_file", None)
297
325
  self._no_state = executor_kwargs.pop("no_state", False)
298
326
  self.repairs: list[dict] = []
327
+ self._loop_detector = HealLoopDetector(max_identical_hints=3)
299
328
 
300
329
  from rich.console import Console
301
330
  self.console = console or Console()
@@ -402,6 +431,14 @@ class HealRunner:
402
431
  })
403
432
  write_repair_log(self.spec_path, self.version, self.repairs)
404
433
 
434
+ loop_hint = f"{summary} | {diag_hint}".strip()
435
+ if self._loop_detector.observe(failed_step, loop_hint):
436
+ self.console.print(
437
+ "[yellow]heal: detected repeating non-converging hint pattern; "
438
+ "stopping auto-retry to avoid loop[/yellow]"
439
+ )
440
+ break
441
+
405
442
  # Retry
406
443
  executor = self._make_executor(resume=True)
407
444
  ok = executor.run()