redeploy 0.2.40__tar.gz → 0.2.42__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 (175) hide show
  1. {redeploy-0.2.40 → redeploy-0.2.42}/PKG-INFO +157 -6
  2. {redeploy-0.2.40 → redeploy-0.2.42}/README.md +155 -5
  3. {redeploy-0.2.40 → redeploy-0.2.42}/pyproject.toml +2 -1
  4. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/__init__.py +1 -1
  5. redeploy-0.2.42/redeploy/apply/state_apply.py +222 -0
  6. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/__init__.py +2 -0
  7. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/blueprint.py +119 -6
  8. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/device_map.py +165 -16
  9. redeploy-0.2.42/redeploy/cli/commands/diagnose.py +47 -0
  10. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/diff.py +1 -5
  11. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/export.py +10 -10
  12. redeploy-0.2.42/redeploy/cli/commands/hardware.py +599 -0
  13. redeploy-0.2.42/redeploy/cli/commands/push.py +110 -0
  14. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/detect/hardware.py +71 -1
  15. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/detect/hardware_rules.py +227 -0
  16. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/models.py +20 -0
  17. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy.egg-info/PKG-INFO +157 -6
  18. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy.egg-info/SOURCES.txt +2 -0
  19. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy.egg-info/requires.txt +1 -0
  20. {redeploy-0.2.40 → redeploy-0.2.42}/tests/test_hardware_rules.py +3 -3
  21. redeploy-0.2.40/redeploy/cli/commands/diagnose.py +0 -88
  22. redeploy-0.2.40/redeploy/cli/commands/hardware.py +0 -325
  23. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/apply/__init__.py +0 -0
  24. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/apply/exceptions.py +0 -0
  25. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/apply/executor.py +0 -0
  26. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/apply/handlers.py +0 -0
  27. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/apply/progress.py +0 -0
  28. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/apply/rollback.py +0 -0
  29. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/apply/state.py +0 -0
  30. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/apply/utils/__init__.py +0 -0
  31. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/apply/utils/run_container_build.py +0 -0
  32. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/audit.py +0 -0
  33. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/blueprint/__init__.py +0 -0
  34. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/blueprint/extractor.py +0 -0
  35. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/blueprint/generators/__init__.py +0 -0
  36. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/blueprint/generators/docker_compose.py +0 -0
  37. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/blueprint/generators/migration.py +0 -0
  38. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/__init__.py +0 -0
  39. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/audit.py +0 -0
  40. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/detect.py +0 -0
  41. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/devices.py +0 -0
  42. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/exec_.py +0 -0
  43. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/import_.py +0 -0
  44. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/init.py +0 -0
  45. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/inspect.py +0 -0
  46. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/patterns.py +0 -0
  47. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/plan_apply.py +0 -0
  48. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/plugin.py +0 -0
  49. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/probe.py +0 -0
  50. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/state.py +0 -0
  51. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/status.py +0 -0
  52. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/target.py +0 -0
  53. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/version/__init__.py +0 -0
  54. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/version/commands.py +0 -0
  55. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/version/helpers.py +0 -0
  56. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/version/monorepo.py +0 -0
  57. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/version/release.py +0 -0
  58. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/version/scanner.py +0 -0
  59. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/commands/workflow.py +0 -0
  60. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/core.py +0 -0
  61. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli/display.py +0 -0
  62. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/cli.py +0 -0
  63. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/data_sync.py +0 -0
  64. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/detect/__init__.py +0 -0
  65. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/detect/builtin/__init__.py +0 -0
  66. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/detect/detector.py +0 -0
  67. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/detect/probes.py +0 -0
  68. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/detect/remote.py +0 -0
  69. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/detect/templates.py +0 -0
  70. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/detect/workflow.py +0 -0
  71. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/discovery.py +0 -0
  72. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/dsl/__init__.py +0 -0
  73. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/dsl/loader.py +0 -0
  74. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/dsl/parser.py +0 -0
  75. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/dsl_python/__init__.py +0 -0
  76. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/dsl_python/context.py +0 -0
  77. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/dsl_python/decorators.py +0 -0
  78. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/dsl_python/docker_steps.py +0 -0
  79. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/dsl_python/exceptions.py +0 -0
  80. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/dsl_python/runner.py +0 -0
  81. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/dsl_python/steps.py +0 -0
  82. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/fleet.py +0 -0
  83. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/hardware/__init__.py +0 -0
  84. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/hardware/config_txt.py +0 -0
  85. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/hardware/data/__init__.py +0 -0
  86. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/hardware/data/hyperpixel.py +0 -0
  87. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/hardware/data/official.py +0 -0
  88. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/hardware/data/waveshare.py +0 -0
  89. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/hardware/fixes.py +0 -0
  90. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/hardware/panels.py +0 -0
  91. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/hardware/raspi_config.py +0 -0
  92. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/iac/__init__.py +0 -0
  93. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/iac/base.py +0 -0
  94. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/iac/docker_compose.py +0 -0
  95. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/iac/parsers/__init__.py +0 -0
  96. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/iac/parsers/compose.py +0 -0
  97. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/iac/registry.py +0 -0
  98. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/markpact/__init__.py +0 -0
  99. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/markpact/compiler.py +0 -0
  100. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/markpact/models.py +0 -0
  101. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/markpact/parser.py +0 -0
  102. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/observe.py +0 -0
  103. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/parse.py +0 -0
  104. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/patterns.py +0 -0
  105. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/plan/__init__.py +0 -0
  106. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/plan/planner.py +0 -0
  107. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/plugins/__init__.py +0 -0
  108. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/plugins/builtin/__init__.py +0 -0
  109. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/plugins/builtin/browser_reload.py +0 -0
  110. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/plugins/builtin/hardware_diagnostic.py +0 -0
  111. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/plugins/builtin/notify.py +0 -0
  112. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/plugins/builtin/process_control.py +0 -0
  113. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  114. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/spec_loader.py +0 -0
  115. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/ssh.py +0 -0
  116. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/steps.py +0 -0
  117. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/__init__.py +0 -0
  118. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_audit.py +0 -0
  119. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_cli.py +0 -0
  120. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_data_sync.py +0 -0
  121. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_discovery.py +0 -0
  122. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_dsl.py +0 -0
  123. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_examples.py +0 -0
  124. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_executor.py +0 -0
  125. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_fleet.py +0 -0
  126. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_git_integration.py +0 -0
  127. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_iac.py +0 -0
  128. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_manifest.py +0 -0
  129. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_markpact_compiler.py +0 -0
  130. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_markpact_parser.py +0 -0
  131. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_models.py +0 -0
  132. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_observe.py +0 -0
  133. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_parse.py +0 -0
  134. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_patterns.py +0 -0
  135. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_planner_edge.py +0 -0
  136. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_plugins.py +0 -0
  137. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_probes.py +0 -0
  138. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_public_api.py +0 -0
  139. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_resume.py +0 -0
  140. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_spec_loader.py +0 -0
  141. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_ssh.py +0 -0
  142. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_templates.py +0 -0
  143. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_verify.py +0 -0
  144. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_version.py +0 -0
  145. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_version_changelog.py +0 -0
  146. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_version_cli.py +0 -0
  147. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_version_manifest.py +0 -0
  148. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_version_sources.py +0 -0
  149. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/tests/test_workflow.py +0 -0
  150. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/verify.py +0 -0
  151. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/version/__init__.py +0 -0
  152. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/version/bump.py +0 -0
  153. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/version/changelog.py +0 -0
  154. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/version/commits.py +0 -0
  155. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/version/diff.py +0 -0
  156. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/version/git_integration.py +0 -0
  157. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/version/git_transaction.py +0 -0
  158. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/version/manifest.py +0 -0
  159. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/version/sources/__init__.py +0 -0
  160. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/version/sources/base.py +0 -0
  161. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/version/sources/json_.py +0 -0
  162. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/version/sources/plain.py +0 -0
  163. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/version/sources/regex.py +0 -0
  164. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/version/sources/toml_.py +0 -0
  165. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/version/sources/yaml_.py +0 -0
  166. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/version/transaction.py +0 -0
  167. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy/version.py +0 -0
  168. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy.egg-info/dependency_links.txt +0 -0
  169. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy.egg-info/entry_points.txt +0 -0
  170. {redeploy-0.2.40 → redeploy-0.2.42}/redeploy.egg-info/top_level.txt +0 -0
  171. {redeploy-0.2.40 → redeploy-0.2.42}/setup.cfg +0 -0
  172. {redeploy-0.2.40 → redeploy-0.2.42}/tests/test_hardware_config.py +0 -0
  173. {redeploy-0.2.40 → redeploy-0.2.42}/tests/test_iac.py +0 -0
  174. {redeploy-0.2.40 → redeploy-0.2.42}/tests/test_parse.py +0 -0
  175. {redeploy-0.2.40 → redeploy-0.2.42}/tests/test_redeploy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.40
3
+ Version: 0.2.42
4
4
  Summary: Infrastructure migration toolkit: detect → plan → apply
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -12,6 +12,7 @@ Requires-Dist: loguru>=0.7
12
12
  Requires-Dist: paramiko>=3.0
13
13
  Requires-Dist: httpx>=0.25
14
14
  Requires-Dist: rich>=13.0
15
+ Requires-Dist: jmespath>=1.0
15
16
  Requires-Dist: goal>=2.1.0
16
17
  Requires-Dist: costs>=0.1.20
17
18
  Requires-Dist: pfix>=0.1.60
@@ -27,17 +28,17 @@ Requires-Dist: pfix>=0.1.60; extra == "dev"
27
28
 
28
29
  ## AI Cost Tracking
29
30
 
30
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.40-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
31
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-19.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
31
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.42-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
32
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-20.9h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
32
33
 
33
- - 🤖 **LLM usage:** $7.5000 (61 commits)
34
- - 👤 **Human dev:** ~$1974 (19.7h @ $100/h, 30min dedup)
34
+ - 🤖 **LLM usage:** $7.5000 (63 commits)
35
+ - 👤 **Human dev:** ~$2088 (20.9h @ $100/h, 30min dedup)
35
36
 
36
37
  Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
37
38
 
38
39
  ---
39
40
 
40
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.40-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
41
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.42-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
41
42
 
42
43
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
43
44
 
@@ -152,6 +153,72 @@ redeploy device-rm root@10.0.0.5
152
153
 
153
154
  Registry is stored at `~/.config/redeploy/devices.yaml` (chmod 600 — safe for SSH key paths).
154
155
 
156
+ ## Declarative config workflow
157
+
158
+ redeploy supports a declarative configuration workflow for hardware settings — scan to YAML, edit locally, and apply to device:
159
+
160
+ ```bash
161
+ # 1. Scan hardware state to YAML
162
+ redeploy hardware pi@192.168.188.109 > hardware.yaml
163
+ redeploy device-map pi@192.168.188.109 > device-map.yaml
164
+
165
+ # 2. Edit YAML locally (e.g., change display rotation)
166
+ # hardware.yaml:
167
+ # drm_outputs:
168
+ # - name: card0-DSI-2
169
+ # connector: DSI-2
170
+ # transform: '270' # ← edit this value
171
+
172
+ # 3. Apply config to remote device
173
+ redeploy hardware pi@192.168.188.109 --apply-config hardware.yaml
174
+ redeploy device-map pi@192.168.188.109 --apply-config device-map.yaml
175
+ ```
176
+
177
+ **What `--apply-config` does:**
178
+ - Applies display transforms via `wlr-randr` (Wayland compositor)
179
+ - Updates kanshi config (`~/.config/kanshi/config`) for persistent display rotation
180
+ - Sets backlight brightness and power state
181
+ - Supports both YAML and JSON config files
182
+
183
+ **Supported commands:**
184
+ - `redeploy hardware --apply-config FILE`
185
+ - `redeploy device-map --apply-config FILE`
186
+ - `redeploy blueprint show FILE --apply-config FILE`
187
+
188
+ ## JMESPath query support
189
+
190
+ Extract specific values from YAML/JSON output using JMESPath query language (similar to XPath for XML):
191
+
192
+ ```bash
193
+ # Simple path queries
194
+ redeploy hardware pi@192.168.188.109 --query "drm_outputs[0].transform"
195
+ redeploy hardware pi@192.168.188.109 --query "kernel"
196
+ redeploy device-map pi@192.168.188.109 --query "host"
197
+
198
+ # Filter queries
199
+ redeploy hardware pi@192.168.188.109 --query "backlights[?name==\`11-0045\`].brightness"
200
+ redeploy hardware pi@192.168.188.109 --query "drm_outputs[?connector==\`DSI-2\`].transform"
201
+
202
+ # From saved YAML files
203
+ redeploy blueprint show blueprint.yaml --query "hardware.drm_outputs[0].transform"
204
+ redeploy device-map --show device-map.yaml --query "tags"
205
+
206
+ # JSON output
207
+ redeploy hardware pi@192.168.188.109 --query "tags" --format json
208
+ ```
209
+
210
+ **JMESPath features:**
211
+ - **Simple paths:** `kernel`, `host`, `board`
212
+ - **Array indexing:** `drm_outputs[0].transform`
213
+ - **Filtering:** `[?name==\`11-0045\`]`
214
+ - **Projections:** `backlights[0].[name,brightness]`
215
+ - **Wildcards:** `drm_outputs[*].transform`
216
+
217
+ **Supported commands:**
218
+ - `redeploy hardware --query EXPR`
219
+ - `redeploy device-map --query EXPR`
220
+ - `redeploy blueprint show FILE --query EXPR`
221
+
155
222
  ## CLI reference
156
223
 
157
224
  ### `redeploy run SPEC [options]`
@@ -614,6 +681,90 @@ redeploy run examples/01-vps-version-bump/migration.yaml --plan-only
614
681
  redeploy run examples/04-rpi-kiosk/migration.yaml --plan-only
615
682
  ```
616
683
 
684
+ ## Hardware diagnostics commands
685
+
686
+ ### `redeploy hardware HOST [options]`
687
+
688
+ Probe and diagnose hardware on a remote host (DSI display, DRM connectors, backlight, I2C, config.txt, Wayland compositor).
689
+
690
+ | Option | Description |
691
+ |--------|-------------|
692
+ | `--format [yaml|json]` | Output format (default: yaml) |
693
+ | `--fix` | Print fix commands for all issues found |
694
+ | `--apply-fix COMPONENT` | Run fix for specific component via SSH |
695
+ | `--panel PANEL_ID` | Specify panel ID explicitly |
696
+ | `--list-panels` | List available panel definitions |
697
+ | `--set-transform TRANSFORM` | Set display rotation for DSI output (normal, 90, 180, 270, flipped, etc.) |
698
+ | `--apply-config FILE` | Apply display settings from YAML/JSON config file |
699
+ | `--query EXPR` | Extract specific values using JMESPath query |
700
+ | `--ssh-key PATH` | SSH private key path |
701
+
702
+ ```bash
703
+ redeploy hardware pi@192.168.188.109
704
+ redeploy hardware pi@192.168.188.109 --fix
705
+ redeploy hardware pi@192.168.188.109 --set-transform 270
706
+ redeploy hardware pi@192.168.188.109 --apply-config hardware.yaml
707
+ redeploy hardware pi@192.168.188.109 --query "drm_outputs[0].transform"
708
+ ```
709
+
710
+ ### `redeploy device-map HOST [options]`
711
+
712
+ Generate full device snapshot (hardware + infra + diagnostics).
713
+
714
+ | Option | Description |
715
+ |--------|-------------|
716
+ | `--name TEXT` | Human-friendly device label |
717
+ | `--tag TEXT` | Tag(s) to attach (repeatable) |
718
+ | `--save` | Persist map to `~/.config/redeploy/device-maps/` |
719
+ | `--out PATH` | Save to specific file |
720
+ | `--format [yaml|json]` | Output format (default: yaml) |
721
+ | `--no-infra` | Skip infra probe (hardware only) |
722
+ | `--list` | List saved device maps |
723
+ | `--show PATH` | Load and display saved device-map file |
724
+ | `--diff PATH...` | Diff two saved device-map files |
725
+ | `--apply-config FILE` | Apply hardware/infra settings from YAML config file |
726
+ | `--query EXPR` | Extract specific values using JMESPath query |
727
+ | `--ssh-key PATH` | SSH private key path |
728
+
729
+ ```bash
730
+ redeploy device-map pi@192.168.188.109 --save --name "kiosk-lab"
731
+ redeploy device-map --list
732
+ redeploy device-map --show ~/.config/redeploy/device-maps/pi_at_192.168.188.109.yaml
733
+ redeploy device-map pi@192.168.188.109 --apply-config device-map.yaml
734
+ redeploy device-map pi@192.168.188.109 --query "hardware.drm_outputs[0].transform"
735
+ ```
736
+
737
+ ### `redeploy blueprint [command]`
738
+
739
+ Manage device blueprints (capture, show, list, twin, migrate).
740
+
741
+ #### `redeploy blueprint capture HOST [options]`
742
+
743
+ Capture device state as blueprint.
744
+
745
+ | Option | Description |
746
+ |--------|-------------|
747
+ | `--format [yaml|json]` | Output format (default: yaml) |
748
+ | `--save` | Persist to `~/.config/redeploy/blueprints/` |
749
+ | `--out PATH` | Save to specific file |
750
+ | `--ssh-key PATH` | SSH private key path |
751
+
752
+ #### `redeploy blueprint show FILE [options]`
753
+
754
+ Display saved blueprint.
755
+
756
+ | Option | Description |
757
+ |--------|-------------|
758
+ | `--format [yaml|json]` | Output format (default: yaml) |
759
+ | `--apply-config FILE` | Apply blueprint settings from YAML config file |
760
+ | `--query EXPR` | Extract specific values using JMESPath query |
761
+
762
+ ```bash
763
+ redeploy blueprint capture pi@192.168.188.109 > blueprint.yaml
764
+ redeploy blueprint show blueprint.yaml --apply-config blueprint.yaml
765
+ redeploy blueprint show blueprint.yaml --query "hardware.drm_outputs[0].transform"
766
+ ```
767
+
617
768
  ## License
618
769
 
619
770
  Licensed under Apache-2.0.
@@ -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.40-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-19.7h-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.42-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-20.9h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $7.5000 (61 commits)
10
- - 👤 **Human dev:** ~$1974 (19.7h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $7.5000 (63 commits)
10
+ - 👤 **Human dev:** ~$2088 (20.9h @ $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.40-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.42-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
 
@@ -128,6 +128,72 @@ redeploy device-rm root@10.0.0.5
128
128
 
129
129
  Registry is stored at `~/.config/redeploy/devices.yaml` (chmod 600 — safe for SSH key paths).
130
130
 
131
+ ## Declarative config workflow
132
+
133
+ redeploy supports a declarative configuration workflow for hardware settings — scan to YAML, edit locally, and apply to device:
134
+
135
+ ```bash
136
+ # 1. Scan hardware state to YAML
137
+ redeploy hardware pi@192.168.188.109 > hardware.yaml
138
+ redeploy device-map pi@192.168.188.109 > device-map.yaml
139
+
140
+ # 2. Edit YAML locally (e.g., change display rotation)
141
+ # hardware.yaml:
142
+ # drm_outputs:
143
+ # - name: card0-DSI-2
144
+ # connector: DSI-2
145
+ # transform: '270' # ← edit this value
146
+
147
+ # 3. Apply config to remote device
148
+ redeploy hardware pi@192.168.188.109 --apply-config hardware.yaml
149
+ redeploy device-map pi@192.168.188.109 --apply-config device-map.yaml
150
+ ```
151
+
152
+ **What `--apply-config` does:**
153
+ - Applies display transforms via `wlr-randr` (Wayland compositor)
154
+ - Updates kanshi config (`~/.config/kanshi/config`) for persistent display rotation
155
+ - Sets backlight brightness and power state
156
+ - Supports both YAML and JSON config files
157
+
158
+ **Supported commands:**
159
+ - `redeploy hardware --apply-config FILE`
160
+ - `redeploy device-map --apply-config FILE`
161
+ - `redeploy blueprint show FILE --apply-config FILE`
162
+
163
+ ## JMESPath query support
164
+
165
+ Extract specific values from YAML/JSON output using JMESPath query language (similar to XPath for XML):
166
+
167
+ ```bash
168
+ # Simple path queries
169
+ redeploy hardware pi@192.168.188.109 --query "drm_outputs[0].transform"
170
+ redeploy hardware pi@192.168.188.109 --query "kernel"
171
+ redeploy device-map pi@192.168.188.109 --query "host"
172
+
173
+ # Filter queries
174
+ redeploy hardware pi@192.168.188.109 --query "backlights[?name==\`11-0045\`].brightness"
175
+ redeploy hardware pi@192.168.188.109 --query "drm_outputs[?connector==\`DSI-2\`].transform"
176
+
177
+ # From saved YAML files
178
+ redeploy blueprint show blueprint.yaml --query "hardware.drm_outputs[0].transform"
179
+ redeploy device-map --show device-map.yaml --query "tags"
180
+
181
+ # JSON output
182
+ redeploy hardware pi@192.168.188.109 --query "tags" --format json
183
+ ```
184
+
185
+ **JMESPath features:**
186
+ - **Simple paths:** `kernel`, `host`, `board`
187
+ - **Array indexing:** `drm_outputs[0].transform`
188
+ - **Filtering:** `[?name==\`11-0045\`]`
189
+ - **Projections:** `backlights[0].[name,brightness]`
190
+ - **Wildcards:** `drm_outputs[*].transform`
191
+
192
+ **Supported commands:**
193
+ - `redeploy hardware --query EXPR`
194
+ - `redeploy device-map --query EXPR`
195
+ - `redeploy blueprint show FILE --query EXPR`
196
+
131
197
  ## CLI reference
132
198
 
133
199
  ### `redeploy run SPEC [options]`
@@ -590,6 +656,90 @@ redeploy run examples/01-vps-version-bump/migration.yaml --plan-only
590
656
  redeploy run examples/04-rpi-kiosk/migration.yaml --plan-only
591
657
  ```
592
658
 
659
+ ## Hardware diagnostics commands
660
+
661
+ ### `redeploy hardware HOST [options]`
662
+
663
+ Probe and diagnose hardware on a remote host (DSI display, DRM connectors, backlight, I2C, config.txt, Wayland compositor).
664
+
665
+ | Option | Description |
666
+ |--------|-------------|
667
+ | `--format [yaml|json]` | Output format (default: yaml) |
668
+ | `--fix` | Print fix commands for all issues found |
669
+ | `--apply-fix COMPONENT` | Run fix for specific component via SSH |
670
+ | `--panel PANEL_ID` | Specify panel ID explicitly |
671
+ | `--list-panels` | List available panel definitions |
672
+ | `--set-transform TRANSFORM` | Set display rotation for DSI output (normal, 90, 180, 270, flipped, etc.) |
673
+ | `--apply-config FILE` | Apply display settings from YAML/JSON config file |
674
+ | `--query EXPR` | Extract specific values using JMESPath query |
675
+ | `--ssh-key PATH` | SSH private key path |
676
+
677
+ ```bash
678
+ redeploy hardware pi@192.168.188.109
679
+ redeploy hardware pi@192.168.188.109 --fix
680
+ redeploy hardware pi@192.168.188.109 --set-transform 270
681
+ redeploy hardware pi@192.168.188.109 --apply-config hardware.yaml
682
+ redeploy hardware pi@192.168.188.109 --query "drm_outputs[0].transform"
683
+ ```
684
+
685
+ ### `redeploy device-map HOST [options]`
686
+
687
+ Generate full device snapshot (hardware + infra + diagnostics).
688
+
689
+ | Option | Description |
690
+ |--------|-------------|
691
+ | `--name TEXT` | Human-friendly device label |
692
+ | `--tag TEXT` | Tag(s) to attach (repeatable) |
693
+ | `--save` | Persist map to `~/.config/redeploy/device-maps/` |
694
+ | `--out PATH` | Save to specific file |
695
+ | `--format [yaml|json]` | Output format (default: yaml) |
696
+ | `--no-infra` | Skip infra probe (hardware only) |
697
+ | `--list` | List saved device maps |
698
+ | `--show PATH` | Load and display saved device-map file |
699
+ | `--diff PATH...` | Diff two saved device-map files |
700
+ | `--apply-config FILE` | Apply hardware/infra settings from YAML config file |
701
+ | `--query EXPR` | Extract specific values using JMESPath query |
702
+ | `--ssh-key PATH` | SSH private key path |
703
+
704
+ ```bash
705
+ redeploy device-map pi@192.168.188.109 --save --name "kiosk-lab"
706
+ redeploy device-map --list
707
+ redeploy device-map --show ~/.config/redeploy/device-maps/pi_at_192.168.188.109.yaml
708
+ redeploy device-map pi@192.168.188.109 --apply-config device-map.yaml
709
+ redeploy device-map pi@192.168.188.109 --query "hardware.drm_outputs[0].transform"
710
+ ```
711
+
712
+ ### `redeploy blueprint [command]`
713
+
714
+ Manage device blueprints (capture, show, list, twin, migrate).
715
+
716
+ #### `redeploy blueprint capture HOST [options]`
717
+
718
+ Capture device state as blueprint.
719
+
720
+ | Option | Description |
721
+ |--------|-------------|
722
+ | `--format [yaml|json]` | Output format (default: yaml) |
723
+ | `--save` | Persist to `~/.config/redeploy/blueprints/` |
724
+ | `--out PATH` | Save to specific file |
725
+ | `--ssh-key PATH` | SSH private key path |
726
+
727
+ #### `redeploy blueprint show FILE [options]`
728
+
729
+ Display saved blueprint.
730
+
731
+ | Option | Description |
732
+ |--------|-------------|
733
+ | `--format [yaml|json]` | Output format (default: yaml) |
734
+ | `--apply-config FILE` | Apply blueprint settings from YAML config file |
735
+ | `--query EXPR` | Extract specific values using JMESPath query |
736
+
737
+ ```bash
738
+ redeploy blueprint capture pi@192.168.188.109 > blueprint.yaml
739
+ redeploy blueprint show blueprint.yaml --apply-config blueprint.yaml
740
+ redeploy blueprint show blueprint.yaml --query "hardware.drm_outputs[0].transform"
741
+ ```
742
+
593
743
  ## License
594
744
 
595
745
  Licensed under Apache-2.0.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "redeploy"
7
- version = "0.2.40"
7
+ version = "0.2.42"
8
8
  description = "Infrastructure migration toolkit: detect → plan → apply"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -17,6 +17,7 @@ dependencies = [
17
17
  "paramiko>=3.0",
18
18
  "httpx>=0.25",
19
19
  "rich>=13.0",
20
+ "jmespath>=1.0",
20
21
  "goal>=2.1.0",
21
22
  "costs>=0.1.20",
22
23
  "pfix>=0.1.60",
@@ -7,7 +7,7 @@ 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.40"
10
+ __version__ = "0.2.42"
11
11
 
12
12
  # ── Core models ───────────────────────────────────────────────────────────────
13
13
  from .models import ( # noqa: F401
@@ -0,0 +1,222 @@
1
+ """Declarative state apply — push a desired-state YAML/JSON to a remote host.
2
+
3
+ Pattern:
4
+ redeploy hardware pi@host > device.yaml # scan
5
+ # edit device.yaml # declare desired state
6
+ redeploy push pi@host device.yaml # apply diff
7
+
8
+ The module is extensible: register a new :class:`StateHandler` for every new
9
+ file schema. Each handler says whether it can handle a given dict (``accept``)
10
+ and then applies the desired state (``apply``).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from abc import ABC, abstractmethod
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ from rich.console import Console
20
+
21
+ from ..detect.remote import RemoteProbe
22
+
23
+
24
+ # ── handler base ──────────────────────────────────────────────────────────────
25
+
26
+
27
+ @dataclass
28
+ class ApplyResult:
29
+ handler: str
30
+ applied: list[str]
31
+ skipped: list[str]
32
+ errors: list[str]
33
+
34
+ @property
35
+ def ok(self) -> bool:
36
+ return not self.errors
37
+
38
+
39
+ class StateHandler(ABC):
40
+ """Base class for a declarative state applier."""
41
+
42
+ name: str = "base"
43
+
44
+ @abstractmethod
45
+ def accept(self, data: dict) -> bool:
46
+ """Return True if this handler knows how to apply *data*."""
47
+
48
+ @abstractmethod
49
+ def apply(self, data: dict, p: RemoteProbe, console: Console) -> ApplyResult:
50
+ """Apply *data* to the host behind *p*."""
51
+
52
+
53
+ # ── hardware handler ──────────────────────────────────────────────────────────
54
+
55
+
56
+ class HardwareStateHandler(StateHandler):
57
+ """Applies HardwareInfo-shaped YAML: display transforms, backlight, etc."""
58
+
59
+ name = "hardware"
60
+
61
+ def accept(self, data: dict) -> bool:
62
+ # HardwareInfo always has drm_outputs or backlights
63
+ return "drm_outputs" in data or "backlights" in data
64
+
65
+ def apply(self, data: dict, p: RemoteProbe, console: Console) -> ApplyResult:
66
+ applied: list[str] = []
67
+ skipped: list[str] = []
68
+ errors: list[str] = []
69
+
70
+ # ── display transforms ────────────────────────────────────────────────
71
+ for output in data.get("drm_outputs", []):
72
+ connector = output.get("connector", "")
73
+ transform = output.get("transform", "normal")
74
+ enabled = output.get("enabled", "enabled")
75
+
76
+ if not connector or ("DSI" not in connector and "HDMI" not in connector):
77
+ continue
78
+
79
+ # Apply via wlr-randr
80
+ on_off = "--on" if enabled == "enabled" else "--off"
81
+ transform_arg = f"--transform {transform}" if transform != "normal" else ""
82
+ wlr_cmd = (
83
+ f"WAYLAND_DISPLAY=wayland-0 XDG_RUNTIME_DIR=/run/user/$(id -u) "
84
+ f"wlr-randr --output {connector} {on_off} {transform_arg} 2>&1"
85
+ ).strip()
86
+ r = p.run(wlr_cmd)
87
+ if r.ok:
88
+ applied.append(f"display/{connector}: transform={transform} enabled={enabled}")
89
+ console.print(f"[green] ✓ {connector}: transform={transform}[/green]")
90
+ else:
91
+ msg = r.out.strip()
92
+ # wlr-randr unavailable (no compositor) — not fatal
93
+ if msg:
94
+ console.print(f"[yellow] ⚠ {connector} wlr-randr: {msg}[/yellow]")
95
+ skipped.append(f"display/{connector}: wlr-randr unavailable")
96
+
97
+ # ── persist DSI transform in kanshi ──────────────────────────────────
98
+ dsi_outputs = [o for o in data.get("drm_outputs", []) if "DSI" in o.get("connector", "")]
99
+ if dsi_outputs:
100
+ _update_kanshi(dsi_outputs, p, console, applied, errors)
101
+
102
+ # ── backlight ─────────────────────────────────────────────────────────
103
+ for bl in data.get("backlights", []):
104
+ name = bl.get("name", "")
105
+ if not name:
106
+ continue
107
+ brightness = bl.get("brightness")
108
+ bl_power = bl.get("bl_power")
109
+ if brightness is not None:
110
+ r = p.run(
111
+ f"echo {brightness} | sudo tee /sys/class/backlight/{name}/brightness > /dev/null"
112
+ )
113
+ if r.ok:
114
+ applied.append(f"backlight/{name}: brightness={brightness}")
115
+ console.print(f"[green] ✓ backlight/{name}: brightness={brightness}[/green]")
116
+ else:
117
+ errors.append(f"backlight/{name}: brightness set failed")
118
+ if bl_power is not None:
119
+ r = p.run(
120
+ f"echo {bl_power} | sudo tee /sys/class/backlight/{name}/bl_power > /dev/null"
121
+ )
122
+ if r.ok:
123
+ applied.append(f"backlight/{name}: bl_power={bl_power}")
124
+ console.print(f"[green] ✓ backlight/{name}: bl_power={bl_power}[/green]")
125
+ else:
126
+ errors.append(f"backlight/{name}: bl_power set failed")
127
+
128
+ return ApplyResult(handler=self.name, applied=applied, skipped=skipped, errors=errors)
129
+
130
+
131
+ def _update_kanshi(dsi_outputs: list, p: RemoteProbe, console: Console,
132
+ applied: list, errors: list) -> None:
133
+ """Rewrite ~/.config/kanshi/config from desired dsi_outputs state."""
134
+ kanshi_cfg_path = "~/.config/kanshi/config"
135
+ read_r = p.run(f"cat {kanshi_cfg_path} 2>/dev/null")
136
+ current = read_r.out if read_r.ok else ""
137
+
138
+ for output in dsi_outputs:
139
+ connector = output.get("connector", "")
140
+ transform = output.get("transform", "normal")
141
+
142
+ line_pat = re.compile(
143
+ rf'(\s*output\s+{re.escape(connector)}\s+enable)(\s+transform\s+\S+)?'
144
+ )
145
+ if re.search(line_pat, current):
146
+ if transform == "normal":
147
+ current = re.sub(line_pat, r'\1', current)
148
+ else:
149
+ current = re.sub(line_pat, rf'\1 transform {transform}', current)
150
+ elif current.strip():
151
+ current = re.sub(
152
+ rf'(\s*output\s+{re.escape(connector)}\b)',
153
+ (rf'\1 transform {transform}' if transform != "normal" else r'\1'),
154
+ current,
155
+ )
156
+ else:
157
+ current = (
158
+ f"profile waveshare-only {{\n"
159
+ f" output {connector} enable"
160
+ + (f" transform {transform}" if transform != "normal" else "")
161
+ + "\n output HDMI-A-2 disable\n}\n"
162
+ )
163
+
164
+ escaped = current.replace("'", "'\\''")
165
+ write_r = p.run(
166
+ f"mkdir -p ~/.config/kanshi && printf '%s' '{escaped}' > {kanshi_cfg_path}"
167
+ )
168
+ if write_r.ok:
169
+ applied.append(f"kanshi: config updated")
170
+ console.print(f"[green] ✓ kanshi config saved ({kanshi_cfg_path})[/green]")
171
+ else:
172
+ errors.append("kanshi: config write failed")
173
+
174
+ # Reload kanshi if running — SIGUSR1 = reload profiles
175
+ p.run("pkill -SIGUSR1 kanshi 2>/dev/null || true")
176
+
177
+
178
+ # ── future handlers registered here ──────────────────────────────────────────
179
+
180
+
181
+ class InfraStateHandler(StateHandler):
182
+ """Placeholder — applies InfraState-shaped YAML (services, ports, etc.)."""
183
+
184
+ name = "infra"
185
+
186
+ def accept(self, data: dict) -> bool:
187
+ return "runtime" in data and "services" in data
188
+
189
+ def apply(self, data: dict, p: RemoteProbe, console: Console) -> ApplyResult:
190
+ # TODO: implement service-level desired state reconciliation
191
+ console.print("[yellow] infra state apply not yet implemented[/yellow]")
192
+ return ApplyResult(handler=self.name, applied=[], skipped=["not implemented"], errors=[])
193
+
194
+
195
+ # ── registry ──────────────────────────────────────────────────────────────────
196
+
197
+ _HANDLERS: list[StateHandler] = [
198
+ HardwareStateHandler(),
199
+ InfraStateHandler(),
200
+ ]
201
+
202
+
203
+ def detect_handler(data: dict) -> StateHandler | None:
204
+ """Return the first handler that accepts *data*, or None."""
205
+ for h in _HANDLERS:
206
+ if h.accept(data):
207
+ return h
208
+ return None
209
+
210
+
211
+ def apply_state(data: dict, p: RemoteProbe, console: Console) -> ApplyResult:
212
+ """Auto-detect file type and apply desired state."""
213
+ handler = detect_handler(data)
214
+ if handler is None:
215
+ return ApplyResult(
216
+ handler="unknown",
217
+ applied=[],
218
+ skipped=[],
219
+ errors=["No handler found for this file format"],
220
+ )
221
+ console.print(f"[dim]handler: {handler.name}[/dim]")
222
+ return handler.apply(data, p, console)
@@ -49,6 +49,7 @@ from .commands.exec_ import exec_cmd, exec_multi_cmd
49
49
  from .commands.plan_apply import plan, apply, migrate, run
50
50
  from .commands.state import state_cmd
51
51
  from .commands.hardware import hardware
52
+ from .commands.push import push
52
53
  from .commands.device_map import device_map_cmd
53
54
  from .commands.blueprint import blueprint_cmd
54
55
  from .commands.init import init
@@ -68,6 +69,7 @@ cli.add_command(exec_multi_cmd)
68
69
  cli.add_command(export_cmd)
69
70
  cli.add_command(import_cmd)
70
71
  cli.add_command(hardware)
72
+ cli.add_command(push)
71
73
  cli.add_command(device_map_cmd, name="device-map")
72
74
  cli.add_command(blueprint_cmd, name="blueprint")
73
75
  cli.add_command(init)