redeploy 0.2.19__tar.gz → 0.2.20__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 (114) hide show
  1. {redeploy-0.2.19 → redeploy-0.2.20}/PKG-INFO +6 -6
  2. {redeploy-0.2.19 → redeploy-0.2.20}/README.md +5 -5
  3. {redeploy-0.2.19 → redeploy-0.2.20}/pyproject.toml +1 -1
  4. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/__init__.py +1 -1
  5. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/apply/executor.py +61 -33
  6. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/audit.py +76 -23
  7. redeploy-0.2.20/redeploy/cli/__init__.py +66 -0
  8. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/cli.py +3 -32
  9. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/discovery.py +92 -58
  10. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/parse.py +43 -22
  11. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy.egg-info/PKG-INFO +6 -6
  12. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy.egg-info/SOURCES.txt +1 -0
  13. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/apply/__init__.py +0 -0
  14. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/apply/state.py +0 -0
  15. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/data_sync.py +0 -0
  16. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/detect/__init__.py +0 -0
  17. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/detect/detector.py +0 -0
  18. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/detect/probes.py +0 -0
  19. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/detect/remote.py +0 -0
  20. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/detect/templates.py +0 -0
  21. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/detect/workflow.py +0 -0
  22. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl/__init__.py +0 -0
  23. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl/loader.py +0 -0
  24. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl/parser.py +0 -0
  25. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl_python/__init__.py +0 -0
  26. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl_python/context.py +0 -0
  27. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl_python/decorators.py +0 -0
  28. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl_python/docker_steps.py +0 -0
  29. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl_python/exceptions.py +0 -0
  30. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl_python/runner.py +0 -0
  31. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/dsl_python/steps.py +0 -0
  32. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/fleet.py +0 -0
  33. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/iac/__init__.py +0 -0
  34. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/iac/base.py +0 -0
  35. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/iac/docker_compose.py +0 -0
  36. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/iac/parsers/__init__.py +0 -0
  37. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/iac/parsers/compose.py +0 -0
  38. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/iac/registry.py +0 -0
  39. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/markpact/__init__.py +0 -0
  40. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/markpact/compiler.py +0 -0
  41. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/markpact/models.py +0 -0
  42. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/markpact/parser.py +0 -0
  43. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/models.py +0 -0
  44. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/observe.py +0 -0
  45. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/patterns.py +0 -0
  46. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/plan/__init__.py +0 -0
  47. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/plan/planner.py +0 -0
  48. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/plugins/__init__.py +0 -0
  49. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/plugins/builtin/__init__.py +0 -0
  50. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/plugins/builtin/browser_reload.py +0 -0
  51. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/plugins/builtin/notify.py +0 -0
  52. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  53. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/spec_loader.py +0 -0
  54. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/ssh.py +0 -0
  55. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/steps.py +0 -0
  56. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/__init__.py +0 -0
  57. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_audit.py +0 -0
  58. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_cli.py +0 -0
  59. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_data_sync.py +0 -0
  60. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_discovery.py +0 -0
  61. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_dsl.py +0 -0
  62. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_examples.py +0 -0
  63. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_executor.py +0 -0
  64. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_fleet.py +0 -0
  65. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_git_integration.py +0 -0
  66. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_iac.py +0 -0
  67. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_manifest.py +0 -0
  68. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_markpact_compiler.py +0 -0
  69. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_markpact_parser.py +0 -0
  70. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_models.py +0 -0
  71. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_observe.py +0 -0
  72. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_parse.py +0 -0
  73. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_patterns.py +0 -0
  74. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_planner_edge.py +0 -0
  75. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_plugins.py +0 -0
  76. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_probes.py +0 -0
  77. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_public_api.py +0 -0
  78. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_resume.py +0 -0
  79. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_spec_loader.py +0 -0
  80. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_ssh.py +0 -0
  81. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_templates.py +0 -0
  82. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_verify.py +0 -0
  83. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_version.py +0 -0
  84. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_version_changelog.py +0 -0
  85. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_version_cli.py +0 -0
  86. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_version_manifest.py +0 -0
  87. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_version_sources.py +0 -0
  88. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/tests/test_workflow.py +0 -0
  89. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/verify.py +0 -0
  90. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/__init__.py +0 -0
  91. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/bump.py +0 -0
  92. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/changelog.py +0 -0
  93. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/commits.py +0 -0
  94. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/diff.py +0 -0
  95. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/git_integration.py +0 -0
  96. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/git_transaction.py +0 -0
  97. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/manifest.py +0 -0
  98. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/sources/__init__.py +0 -0
  99. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/sources/base.py +0 -0
  100. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/sources/json_.py +0 -0
  101. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/sources/plain.py +0 -0
  102. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/sources/regex.py +0 -0
  103. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/sources/toml_.py +0 -0
  104. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/sources/yaml_.py +0 -0
  105. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version/transaction.py +0 -0
  106. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy/version.py +0 -0
  107. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy.egg-info/dependency_links.txt +0 -0
  108. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy.egg-info/entry_points.txt +0 -0
  109. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy.egg-info/requires.txt +0 -0
  110. {redeploy-0.2.19 → redeploy-0.2.20}/redeploy.egg-info/top_level.txt +0 -0
  111. {redeploy-0.2.19 → redeploy-0.2.20}/setup.cfg +0 -0
  112. {redeploy-0.2.19 → redeploy-0.2.20}/tests/test_iac.py +0 -0
  113. {redeploy-0.2.19 → redeploy-0.2.20}/tests/test_parse.py +0 -0
  114. {redeploy-0.2.19 → redeploy-0.2.20}/tests/test_redeploy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.19
3
+ Version: 0.2.20
4
4
  Summary: Infrastructure migration toolkit: detect → plan → apply
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -27,17 +27,17 @@ Requires-Dist: pfix>=0.1.60; extra == "dev"
27
27
 
28
28
  ## AI Cost Tracking
29
29
 
30
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.19-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-$5.70-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-13.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
30
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.20-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-$5.85-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-13.4h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
32
32
 
33
- - 🤖 **LLM usage:** $5.7000 (38 commits)
34
- - 👤 **Human dev:** ~$1297 (13.0h @ $100/h, 30min dedup)
33
+ - 🤖 **LLM usage:** $5.8500 (39 commits)
34
+ - 👤 **Human dev:** ~$1337 (13.4h @ $100/h, 30min dedup)
35
35
 
36
36
  Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
37
37
 
38
38
  ---
39
39
 
40
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.19-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
40
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.20-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
41
41
 
42
42
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
43
43
 
@@ -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.19-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-$5.70-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-13.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.20-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-$5.85-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-13.4h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $5.7000 (38 commits)
10
- - 👤 **Human dev:** ~$1297 (13.0h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $5.8500 (39 commits)
10
+ - 👤 **Human dev:** ~$1337 (13.4h @ $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.19-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.20-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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "redeploy"
7
- version = "0.2.19"
7
+ version = "0.2.20"
8
8
  description = "Infrastructure migration toolkit: detect → plan → apply"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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.19"
10
+ __version__ = "0.2.20"
11
11
 
12
12
  # ── Core models ───────────────────────────────────────────────────────────────
13
13
  from .models import ( # noqa: F401
@@ -194,14 +194,21 @@ class Executor:
194
194
  logger.info(f"resume: skipping {len(skip_ids)} already-completed step(s): "
195
195
  f"{', '.join(sorted(skip_ids))}")
196
196
 
197
+ ok = self._execute_steps_loop(skip_ids)
198
+ elapsed = time.monotonic() - self._t0
199
+
200
+ self._handle_completion(ok, elapsed)
201
+ self._write_audit(ok=ok, elapsed_s=elapsed)
202
+ return ok
203
+
204
+ def _execute_steps_loop(self, skip_ids: set[str]) -> bool:
205
+ """Execute steps, handling skips and errors. Returns True if all passed."""
197
206
  ok = True
198
207
  for i, step in enumerate(self.plan.steps, 1):
199
208
  if step.id in skip_ids:
200
- step.status = StepStatus.SKIPPED
201
- step.result = "resumed: previously completed"
202
- if self._emitter:
203
- self._emitter.step_done(i, step)
209
+ self._skip_step(i, step)
204
210
  continue
211
+
205
212
  try:
206
213
  if self._emitter:
207
214
  self._emitter.step_start(i, step)
@@ -212,20 +219,33 @@ class Executor:
212
219
  if self._emitter:
213
220
  self._emitter.step_done(i, step)
214
221
  except StepError as e:
215
- logger.error(f"Step failed: {e}")
216
- step.status = StepStatus.FAILED
217
- step.error = str(e)
218
- if self._state is not None:
219
- self._state.mark_failed(step.id, str(e))
220
- if self._emitter:
221
- self._emitter.step_fail(i, step, str(e))
222
- self._emitter.failed(len(self._completed), len(self.plan.steps), str(e))
223
- if not self.dry_run:
224
- self._rollback()
222
+ self._handle_step_failure(i, step, e)
225
223
  ok = False
226
224
  break
225
+ return ok
227
226
 
228
- elapsed = time.monotonic() - self._t0
227
+ def _skip_step(self, i: int, step: MigrationStep) -> None:
228
+ """Handle a skipped step due to resume."""
229
+ step.status = StepStatus.SKIPPED
230
+ step.result = "resumed: previously completed"
231
+ if self._emitter:
232
+ self._emitter.step_done(i, step)
233
+
234
+ def _handle_step_failure(self, i: int, step: MigrationStep, error: StepError) -> None:
235
+ """Handle a step failure."""
236
+ logger.error(f"Step failed: {error}")
237
+ step.status = StepStatus.FAILED
238
+ step.error = str(error)
239
+ if self._state is not None:
240
+ self._state.mark_failed(step.id, str(error))
241
+ if self._emitter:
242
+ self._emitter.step_fail(i, step, str(error))
243
+ self._emitter.failed(len(self._completed), len(self.plan.steps), str(error))
244
+ if not self.dry_run:
245
+ self._rollback()
246
+
247
+ def _handle_completion(self, ok: bool, elapsed: float) -> None:
248
+ """Handle plan completion or failure."""
229
249
  if ok:
230
250
  logger.info(f"{'[DRY RUN] ' if self.dry_run else ''}All {len(self.plan.steps)} steps completed")
231
251
  if self._emitter:
@@ -237,8 +257,6 @@ class Executor:
237
257
  if self._state is not None and self._state_path is not None:
238
258
  logger.info(f"resume: checkpoint saved → {self._state_path} "
239
259
  f"({self._state.completed_count}/{self._state.total_steps} done)")
240
- self._write_audit(ok=ok, elapsed_s=elapsed)
241
- return ok
242
260
 
243
261
  def _compute_skip_set(self) -> set[str]:
244
262
  """Determine which step ids should be skipped this run.
@@ -470,27 +488,14 @@ class Executor:
470
488
  while elapsed < timeout:
471
489
  r = self.probe.run(cmd, timeout=20)
472
490
  if r.ok and r.out.strip():
473
- lines = r.out.strip().splitlines()
474
- # Parse simple table: Name | Status
475
- statuses = []
476
- for line in lines:
477
- if line.startswith("NAME") or not line.strip():
478
- continue
479
- parts = line.split(None, 1)
480
- name = parts[0] if parts else "?"
481
- status = parts[1].strip() if len(parts) > 1 else "?"
482
- statuses.append((name, status))
483
-
491
+ statuses = self._parse_container_statuses(r.out)
484
492
  status_str = ", ".join(f"{n}:{s}" for n, s in statuses)
493
+
485
494
  if status_str != last_status:
486
495
  logger.debug(f" [{elapsed}s] containers: {status_str}")
487
496
  last_status = status_str
488
497
 
489
- unhealthy = [
490
- n for n, s in statuses
491
- if not any(kw in s.lower() for kw in ("up", "running", "healthy"))
492
- ]
493
- if statuses and not unhealthy:
498
+ if self._all_containers_healthy(statuses):
494
499
  step.status = StepStatus.DONE
495
500
  step.result = f"all containers healthy after {elapsed}s: {status_str}"
496
501
  logger.debug(f" ✓ all containers healthy ({elapsed}s)")
@@ -506,6 +511,29 @@ class Executor:
506
511
  step.result = f"timeout {timeout}s reached, last: {last_status or 'unknown'}"
507
512
  logger.warning(f" docker_health_wait timed out after {timeout}s — proceeding to health check")
508
513
 
514
+ def _parse_container_statuses(self, output: str) -> list[tuple[str, str]]:
515
+ """Parse docker compose ps output into (name, status) tuples."""
516
+ lines = output.strip().splitlines()
517
+ statuses = []
518
+ for line in lines:
519
+ if line.startswith("NAME") or not line.strip():
520
+ continue
521
+ parts = line.split(None, 1)
522
+ name = parts[0] if parts else "?"
523
+ status = parts[1].strip() if len(parts) > 1 else "?"
524
+ statuses.append((name, status))
525
+ return statuses
526
+
527
+ def _all_containers_healthy(self, statuses: list[tuple[str, str]]) -> bool:
528
+ """Check if all containers are in a healthy/running state."""
529
+ if not statuses:
530
+ return False
531
+ unhealthy = [
532
+ n for n, s in statuses
533
+ if not any(kw in s.lower() for kw in ("up", "running", "healthy"))
534
+ ]
535
+ return not unhealthy
536
+
509
537
  def _run_container_log_tail(self, step: MigrationStep) -> None:
510
538
  """Fetch and log the last N lines from each container after start."""
511
539
  cmd = step.command
@@ -500,36 +500,23 @@ class Auditor:
500
500
  def _probe_one(self, exp: _Expect) -> AuditCheck:
501
501
  cat = exp.category
502
502
  if cat == "binary":
503
- ok, detail = self.probe.has_binary(exp.name)
503
+ return self._probe_binary(exp)
504
504
  elif cat == "directory":
505
- ok, detail = self.probe.has_path(exp.name, kind="dir")
505
+ return self._probe_directory(exp)
506
506
  elif cat == "file":
507
- ok, detail = self.probe.has_path(exp.name, kind="file")
507
+ return self._probe_file(exp)
508
508
  elif cat == "local_file":
509
- local_ok = Path(exp.name).expanduser().exists()
510
- return AuditCheck(
511
- category=cat, name=exp.name,
512
- status="pass" if local_ok else "fail",
513
- detail="present" if local_ok else "missing on controller",
514
- fix_hint=exp.fix_hint, source_step=exp.source_step,
515
- )
509
+ return self._probe_local_file(exp)
516
510
  elif cat == "port_listening":
517
- ok, detail = self.probe.port_listening(int(exp.name))
511
+ return self._probe_port_listening(exp)
518
512
  elif cat == "container_image":
519
- ok, detail = self.probe.has_image(exp.name)
520
- # built-by-spec images: missing = "warn" not "fail"
521
- return AuditCheck(
522
- category=cat, name=exp.name,
523
- status="pass" if ok else "warn",
524
- detail=detail or ("present" if ok else "will be built by step"),
525
- fix_hint=exp.fix_hint, source_step=exp.source_step,
526
- )
513
+ return self._probe_container_image(exp)
527
514
  elif cat == "systemd_unit":
528
- ok, detail = self.probe.has_systemd_unit(exp.name, user=False)
515
+ return self._probe_systemd_unit(exp, user=False)
529
516
  elif cat == "systemd_user_unit":
530
- ok, detail = self.probe.has_systemd_unit(exp.name, user=True)
517
+ return self._probe_systemd_unit(exp, user=True)
531
518
  elif cat == "apt_package":
532
- ok, detail = self.probe.apt_package(exp.name)
519
+ return self._probe_apt_package(exp)
533
520
  else:
534
521
  return AuditCheck(
535
522
  category=cat, name=exp.name,
@@ -537,8 +524,74 @@ class Auditor:
537
524
  source_step=exp.source_step,
538
525
  )
539
526
 
527
+ def _probe_binary(self, exp: _Expect) -> AuditCheck:
528
+ ok, detail = self.probe.has_binary(exp.name)
529
+ return AuditCheck(
530
+ category=exp.category, name=exp.name,
531
+ status="pass" if ok else "fail",
532
+ detail=detail or ("present" if ok else "missing"),
533
+ fix_hint=exp.fix_hint, source_step=exp.source_step,
534
+ )
535
+
536
+ def _probe_directory(self, exp: _Expect) -> AuditCheck:
537
+ ok, detail = self.probe.has_path(exp.name, kind="dir")
538
+ return AuditCheck(
539
+ category=exp.category, name=exp.name,
540
+ status="pass" if ok else "fail",
541
+ detail=detail or ("present" if ok else "missing"),
542
+ fix_hint=exp.fix_hint, source_step=exp.source_step,
543
+ )
544
+
545
+ def _probe_file(self, exp: _Expect) -> AuditCheck:
546
+ ok, detail = self.probe.has_path(exp.name, kind="file")
547
+ return AuditCheck(
548
+ category=exp.category, name=exp.name,
549
+ status="pass" if ok else "fail",
550
+ detail=detail or ("present" if ok else "missing"),
551
+ fix_hint=exp.fix_hint, source_step=exp.source_step,
552
+ )
553
+
554
+ def _probe_local_file(self, exp: _Expect) -> AuditCheck:
555
+ local_ok = Path(exp.name).expanduser().exists()
556
+ return AuditCheck(
557
+ category=exp.category, name=exp.name,
558
+ status="pass" if local_ok else "fail",
559
+ detail="present" if local_ok else "missing on controller",
560
+ fix_hint=exp.fix_hint, source_step=exp.source_step,
561
+ )
562
+
563
+ def _probe_port_listening(self, exp: _Expect) -> AuditCheck:
564
+ ok, detail = self.probe.port_listening(int(exp.name))
565
+ return AuditCheck(
566
+ category=exp.category, name=exp.name,
567
+ status="pass" if ok else "fail",
568
+ detail=detail or ("present" if ok else "missing"),
569
+ fix_hint=exp.fix_hint, source_step=exp.source_step,
570
+ )
571
+
572
+ def _probe_container_image(self, exp: _Expect) -> AuditCheck:
573
+ ok, detail = self.probe.has_image(exp.name)
574
+ # built-by-spec images: missing = "warn" not "fail"
575
+ return AuditCheck(
576
+ category=exp.category, name=exp.name,
577
+ status="pass" if ok else "warn",
578
+ detail=detail or ("present" if ok else "will be built by step"),
579
+ fix_hint=exp.fix_hint, source_step=exp.source_step,
580
+ )
581
+
582
+ def _probe_systemd_unit(self, exp: _Expect, user: bool) -> AuditCheck:
583
+ ok, detail = self.probe.has_systemd_unit(exp.name, user=user)
584
+ return AuditCheck(
585
+ category=exp.category, name=exp.name,
586
+ status="pass" if ok else "fail",
587
+ detail=detail or ("present" if ok else "missing"),
588
+ fix_hint=exp.fix_hint, source_step=exp.source_step,
589
+ )
590
+
591
+ def _probe_apt_package(self, exp: _Expect) -> AuditCheck:
592
+ ok, detail = self.probe.apt_package(exp.name)
540
593
  return AuditCheck(
541
- category=cat, name=exp.name,
594
+ category=exp.category, name=exp.name,
542
595
  status="pass" if ok else "fail",
543
596
  detail=detail or ("present" if ok else "missing"),
544
597
  fix_hint=exp.fix_hint, source_step=exp.source_step,
@@ -0,0 +1,66 @@
1
+ """redeploy CLI — detect | plan | apply | migrate."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+
6
+ import click
7
+ from loguru import logger
8
+
9
+ from .. import __version__
10
+
11
+
12
+ def _setup_logging(verbose: bool) -> None:
13
+ logger.remove()
14
+ level = "DEBUG" if verbose else "INFO"
15
+ logger.add(
16
+ sys.stderr,
17
+ level=level,
18
+ format="<green>{time:HH:mm:ss}</green> | <level>{level:<7}</level> | {message}",
19
+ )
20
+
21
+
22
+ @click.group()
23
+ @click.version_option(__version__)
24
+ @click.option("-v", "--verbose", is_flag=True)
25
+ @click.pass_context
26
+ def cli(ctx, verbose):
27
+ """redeploy — Infrastructure migration toolkit: detect → plan → apply"""
28
+ _setup_logging(verbose)
29
+ ctx.ensure_object(dict)
30
+ ctx.obj["verbose"] = verbose
31
+
32
+
33
+ # Import and register all commands
34
+ from .commands import detect, diagnose, inspect, workflow, export, plugin
35
+ from .commands import exec_, plan_apply, state, init, status, devices, target, probe
36
+ from .commands import import_, diff, audit, patterns, version as version_cmd
37
+
38
+ # Register commands
39
+ cli.add_command(detect.detect)
40
+ cli.add_command(diagnose.diagnose)
41
+ cli.add_command(inspect.inspect)
42
+ cli.add_command(workflow.workflow_cmd)
43
+ cli.add_command(export.export_cmd)
44
+ cli.add_command(plugin.plugin_cmd)
45
+ cli.add_command(exec_.exec_cmd)
46
+ cli.add_command(exec_.exec_multi_cmd)
47
+ cli.add_command(plan_apply.plan)
48
+ cli.add_command(plan_apply.apply)
49
+ cli.add_command(plan_apply.migrate)
50
+ cli.add_command(plan_apply.run)
51
+ cli.add_command(state.state_cmd)
52
+ cli.add_command(init.init)
53
+ cli.add_command(status.status)
54
+ cli.add_command(devices.devices)
55
+ cli.add_command(devices.scan)
56
+ cli.add_command(devices.device_add)
57
+ cli.add_command(devices.device_rm)
58
+ cli.add_command(target.target)
59
+ cli.add_command(probe.probe)
60
+ cli.add_command(import_.import_cmd)
61
+ cli.add_command(diff.diff)
62
+ cli.add_command(audit.audit)
63
+ cli.add_command(patterns.patterns)
64
+ cli.add_command(version_cmd.version_cmd)
65
+
66
+ __all__ = ["cli"]
@@ -1419,41 +1419,12 @@ def run(ctx, spec_file, dry_run, plan_only, do_detect, plan_out, output,
1419
1419
  spec = _load_spec_or_exit(console, resolved_spec)
1420
1420
 
1421
1421
  # overlay manifest values — env-specific or global
1422
- if manifest:
1423
- if env_name and env_name not in manifest.environments:
1424
- console.print(f"[yellow]⚠ env '{env_name}' not in redeploy.yaml — known: "
1425
- f"{', '.join(manifest.environments) or 'none'}[/yellow]")
1426
- manifest.apply_to_spec(spec, env_name=env_name)
1427
- env_label = f" [cyan][env: {env_name}][/cyan]" if env_name else ""
1428
- console.print(f"[dim]manifest: {_find_manifest_path()}{env_label}[/dim]")
1429
- elif not env_name:
1430
- # Fallback: read DEPLOY_* from .env if no redeploy.yaml
1431
- dotenv_manifest = ProjectManifest.from_dotenv(Path.cwd())
1432
- if dotenv_manifest:
1433
- dotenv_manifest.apply_to_spec(spec)
1434
- console.print("[dim]manifest: .env (DEPLOY_* vars)[/dim]")
1435
-
1436
- console.print(f"\n[bold]{spec.name}[/bold]"
1437
- + (f" [dim]{spec.description}[/dim]" if spec.description else ""))
1438
- console.print(f" [dim]{spec.source.strategy.value}[/dim] → "
1439
- f"[bold]{spec.target.strategy.value}[/bold]"
1440
- f" ({spec.source.host})")
1422
+ _apply_manifest_to_spec(console, manifest, spec, env_name)
1423
+ _print_spec_summary(console, spec)
1441
1424
 
1442
1425
  # ── optional live detect (overrides source in spec) ──────────────────────
1443
1426
  if do_detect:
1444
- from .detect import Detector
1445
- console.print(f"\n[bold]detect[/bold] (live probe of {spec.source.host})")
1446
- d = Detector(
1447
- host=spec.source.host,
1448
- app=spec.source.app,
1449
- domain=spec.source.domain,
1450
- )
1451
- state = d.run()
1452
- console.print(f" detected: {state.detected_strategy.value} "
1453
- f"version={state.current_version or '?'} "
1454
- f"conflicts={len(state.conflicts)}")
1455
- planner = Planner(state, spec.to_target_config())
1456
- planner._spec = spec
1427
+ planner = _perform_live_detect(console, spec)
1457
1428
  else:
1458
1429
  planner = Planner.from_spec(spec)
1459
1430
 
@@ -527,7 +527,22 @@ def _detect_strategy_remote(
527
527
  ) -> dict:
528
528
  """Run a single SSH session to detect strategy, app, version, etc."""
529
529
  key_opts = ["-i", key] if key else []
530
- probe_cmd = (
530
+ probe_cmd = _build_probe_command()
531
+ cmd = _build_ssh_command(host, port, timeout, key_opts, probe_cmd)
532
+
533
+ out = _run_ssh_probe(cmd, timeout)
534
+ if out is None:
535
+ return {}
536
+
537
+ info, services = _parse_probe_output(out)
538
+ info["running_services"] = services
539
+ info["strategy"] = _infer_strategy(info, services)
540
+
541
+ return info
542
+
543
+
544
+ def _build_probe_command() -> str:
545
+ return (
531
546
  "echo __arch__=$(uname -m); "
532
547
  "echo __os__=$(. /etc/os-release 2>/dev/null && echo $PRETTY_NAME || uname -s); "
533
548
  "echo __hostname__=$(hostname); "
@@ -538,20 +553,28 @@ def _detect_strategy_remote(
538
553
  "systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null | awk '{print $1}' | head -30; "
539
554
  "echo __end_services__"
540
555
  )
541
- cmd = ["ssh"] + [
556
+
557
+
558
+ def _build_ssh_command(host: str, port: int, timeout: int, key_opts: list[str], probe_cmd: str) -> list[str]:
559
+ return ["ssh"] + [
542
560
  "-o", f"ConnectTimeout={timeout}",
543
561
  "-o", "StrictHostKeyChecking=no",
544
562
  "-o", "BatchMode=yes",
545
563
  "-p", str(port),
546
564
  ] + key_opts + [host, probe_cmd]
565
+
566
+
567
+ def _run_ssh_probe(cmd: list[str], timeout: int) -> str | None:
547
568
  try:
548
569
  r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 5)
549
570
  if r.returncode != 0:
550
- return {}
551
- out = r.stdout
571
+ return None
572
+ return r.stdout
552
573
  except Exception:
553
- return {}
574
+ return None
554
575
 
576
+
577
+ def _parse_probe_output(out: str) -> tuple[dict, list[str]]:
555
578
  info: dict = {}
556
579
  services: list[str] = []
557
580
  in_services = False
@@ -582,19 +605,72 @@ def _detect_strategy_remote(
582
605
  if line.endswith(".service"):
583
606
  services.append(line)
584
607
 
585
- info["running_services"] = services
608
+ return info, services
586
609
 
587
- # Infer strategy
610
+
611
+ def _infer_strategy(info: dict, services: list[str]) -> str:
588
612
  if info.get("docker_active") or info.get("has_docker"):
589
- info["strategy"] = "docker_full"
613
+ return "docker_full"
590
614
  elif info.get("has_podman"):
591
- info["strategy"] = "podman_quadlet"
615
+ return "podman_quadlet"
592
616
  elif info.get("has_chromium") and any("kiosk" in s or "chromium" in s for s in services):
593
- info["strategy"] = "native_kiosk"
617
+ return "native_kiosk"
594
618
  else:
595
- info["strategy"] = "systemd"
619
+ return "systemd"
596
620
 
597
- return info
621
+
622
+ def _parse_probe_input(ip_or_host: str, users: Optional[list[str]]) -> tuple[str, list[str]]:
623
+ if "@" in ip_or_host:
624
+ forced_user, ip = ip_or_host.split("@", 1)
625
+ return ip, [forced_user] + (users or _DEFAULT_USERS)
626
+ else:
627
+ return ip_or_host, users or _DEFAULT_USERS
628
+
629
+
630
+ def _detect_app_from_services(services: list[str], app_hint: str) -> str:
631
+ if app_hint:
632
+ return app_hint
633
+ for svc in services:
634
+ for candidate in ("c2004", "app", "backend", "kiosk", "api"):
635
+ if candidate in svc.lower():
636
+ return candidate
637
+ return ""
638
+
639
+
640
+ def _update_existing_device(existing, result, ssh_user, ssh_key, ip, host_str, port, now) -> None:
641
+ existing.host = host_str
642
+ existing.ip = ip
643
+ existing.ssh_user = ssh_user
644
+ if ssh_key:
645
+ existing.ssh_key = ssh_key
646
+ existing.ssh_port = port
647
+ existing.strategy = result.strategy
648
+ existing.hostname = result.hostname or existing.hostname
649
+ existing.last_seen = now
650
+ existing.last_ssh_ok = now
651
+ if result.app and not existing.app:
652
+ existing.app = result.app
653
+
654
+
655
+ def _create_new_device(result, ssh_user, ssh_key, ip, host_str, port, now) -> "KnownDevice":
656
+ from .models import KnownDevice
657
+
658
+ return KnownDevice(
659
+ id=host_str,
660
+ host=host_str,
661
+ ip=ip,
662
+ mac="",
663
+ hostname=result.hostname,
664
+ ssh_user=ssh_user,
665
+ ssh_key=ssh_key if ssh_key else None,
666
+ ssh_port=port,
667
+ strategy=result.strategy,
668
+ app=result.app,
669
+ last_seen=now,
670
+ last_ssh_ok=now,
671
+ source="probe",
672
+ tags=["discovered"],
673
+ )
598
674
 
599
675
 
600
676
  def auto_probe(
@@ -622,12 +698,7 @@ def auto_probe(
622
698
  ProbeResult with all discovered fields.
623
699
  """
624
700
  # Normalise input
625
- if "@" in ip_or_host:
626
- forced_user, ip = ip_or_host.split("@", 1)
627
- users = [forced_user] + (users or _DEFAULT_USERS)
628
- else:
629
- ip = ip_or_host
630
- users = users or _DEFAULT_USERS
701
+ ip, users = _parse_probe_input(ip_or_host, users)
631
702
 
632
703
  result = ProbeResult(ip=ip, ssh_port=port)
633
704
 
@@ -659,18 +730,7 @@ def auto_probe(
659
730
  result.has_podman = bool(info.get("has_podman"))
660
731
  result.has_chromium = bool(info.get("has_chromium"))
661
732
  result.running_services = info.get("running_services", [])
662
-
663
- # Try to detect app + version
664
- app = app_hint
665
- if not app:
666
- for svc in result.running_services:
667
- for candidate in ("c2004", "app", "backend", "kiosk", "api"):
668
- if candidate in svc.lower():
669
- app = candidate
670
- break
671
- if app:
672
- break
673
- result.app = app
733
+ result.app = _detect_app_from_services(result.running_services, app_hint)
674
734
 
675
735
  # Save to registry
676
736
  if save:
@@ -678,36 +738,10 @@ def auto_probe(
678
738
  now = datetime.utcnow()
679
739
  existing = reg.get(host_str) or reg.get(ip)
680
740
  if existing:
681
- existing.host = host_str
682
- existing.ip = ip
683
- existing.ssh_user = ssh_user
684
- if ssh_key:
685
- existing.ssh_key = ssh_key
686
- existing.ssh_port = port
687
- existing.strategy = result.strategy
688
- existing.hostname = result.hostname or existing.hostname
689
- existing.last_seen = now
690
- existing.last_ssh_ok = now
691
- if result.app and not existing.app:
692
- existing.app = result.app
741
+ _update_existing_device(existing, result, ssh_user, ssh_key, ip, host_str, port, now)
693
742
  reg.upsert(existing)
694
743
  else:
695
- reg.upsert(KnownDevice(
696
- id=host_str,
697
- host=host_str,
698
- ip=ip,
699
- mac="",
700
- hostname=result.hostname,
701
- ssh_user=ssh_user,
702
- ssh_key=ssh_key if ssh_key else None,
703
- ssh_port=port,
704
- strategy=result.strategy,
705
- app=result.app,
706
- last_seen=now,
707
- last_ssh_ok=now,
708
- source="probe",
709
- tags=["discovered"],
710
- ))
744
+ reg.upsert(_create_new_device(result, ssh_user, ssh_key, ip, host_str, port, now))
711
745
  reg.save()
712
746
  logger.info(f"[probe {ip}] saved to registry as {host_str}")
713
747
 
@@ -98,28 +98,8 @@ def parse_diagnostics(output: str) -> dict:
98
98
  section = _SECTION_HEADERS[line]
99
99
  elif not line or line.startswith("__NO_"):
100
100
  continue
101
- elif section == "system":
102
- _apply_system_line(result["system"], line)
103
- elif section == "containers":
104
- c = parse_container_line(line)
105
- if c:
106
- result["containers"].append(c)
107
- elif section == "git":
108
- for prefix in ("BRANCH:", "COMMIT:", "DIRTY:"):
109
- if line.startswith(prefix):
110
- result["git"][prefix[:-1].lower()] = line[len(prefix):]
111
- break
112
- elif section == "health":
113
- try:
114
- result["health_code"] = int(line)
115
- except ValueError:
116
- pass
117
- elif section == "network":
118
- if line.startswith("PORTS:"):
119
- try:
120
- result["listening_ports"] = int(line[6:])
121
- except ValueError:
122
- pass
101
+ else:
102
+ _parse_section_line(section, result, line)
123
103
  return result
124
104
 
125
105
 
@@ -141,6 +121,47 @@ def _apply_system_line(sys: dict, line: str) -> None:
141
121
  }
142
122
 
143
123
 
124
+ def _apply_git_line(git: dict, line: str) -> bool:
125
+ for prefix in ("BRANCH:", "COMMIT:", "DIRTY:"):
126
+ if line.startswith(prefix):
127
+ git[prefix[:-1].lower()] = line[len(prefix):]
128
+ return True
129
+ return False
130
+
131
+
132
+ def _apply_health_line(result: dict, line: str) -> bool:
133
+ try:
134
+ result["health_code"] = int(line)
135
+ return True
136
+ except ValueError:
137
+ return False
138
+
139
+
140
+ def _apply_network_line(result: dict, line: str) -> bool:
141
+ if line.startswith("PORTS:"):
142
+ try:
143
+ result["listening_ports"] = int(line[6:])
144
+ return True
145
+ except ValueError:
146
+ pass
147
+ return False
148
+
149
+
150
+ def _parse_section_line(section: str, result: dict, line: str) -> None:
151
+ if section == "system":
152
+ _apply_system_line(result["system"], line)
153
+ elif section == "containers":
154
+ c = parse_container_line(line)
155
+ if c:
156
+ result["containers"].append(c)
157
+ elif section == "git":
158
+ _apply_git_line(result["git"], line)
159
+ elif section == "health":
160
+ _apply_health_line(result, line)
161
+ elif section == "network":
162
+ _apply_network_line(result, line)
163
+
164
+
144
165
  # ── health check ─────────────────────────────────────────────────────────────
145
166
 
146
167
  _HEALTH_PREFIXES: dict[str, str] = {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.19
3
+ Version: 0.2.20
4
4
  Summary: Infrastructure migration toolkit: detect → plan → apply
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -27,17 +27,17 @@ Requires-Dist: pfix>=0.1.60; extra == "dev"
27
27
 
28
28
  ## AI Cost Tracking
29
29
 
30
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.19-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-$5.70-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-13.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
30
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.20-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-$5.85-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-13.4h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
32
32
 
33
- - 🤖 **LLM usage:** $5.7000 (38 commits)
34
- - 👤 **Human dev:** ~$1297 (13.0h @ $100/h, 30min dedup)
33
+ - 🤖 **LLM usage:** $5.8500 (39 commits)
34
+ - 👤 **Human dev:** ~$1337 (13.4h @ $100/h, 30min dedup)
35
35
 
36
36
  Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
37
37
 
38
38
  ---
39
39
 
40
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.19-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
40
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.20-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
41
41
 
42
42
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
43
43
 
@@ -24,6 +24,7 @@ redeploy.egg-info/top_level.txt
24
24
  redeploy/apply/__init__.py
25
25
  redeploy/apply/executor.py
26
26
  redeploy/apply/state.py
27
+ redeploy/cli/__init__.py
27
28
  redeploy/detect/__init__.py
28
29
  redeploy/detect/detector.py
29
30
  redeploy/detect/probes.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes