redeploy 0.2.30__tar.gz → 0.2.32__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.
- {redeploy-0.2.30 → redeploy-0.2.32}/PKG-INFO +6 -6
- {redeploy-0.2.30 → redeploy-0.2.32}/README.md +5 -5
- {redeploy-0.2.30 → redeploy-0.2.32}/pyproject.toml +1 -1
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/__init__.py +1 -1
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/models.py +86 -0
- redeploy-0.2.32/redeploy/plugins/builtin/hardware_diagnostic.py +282 -0
- redeploy-0.2.32/redeploy/plugins/builtin/process_control.py +123 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/steps.py +34 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy.egg-info/PKG-INFO +6 -6
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy.egg-info/SOURCES.txt +2 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/apply/__init__.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/apply/exceptions.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/apply/executor.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/apply/handlers.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/apply/progress.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/apply/rollback.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/apply/state.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/audit.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/__init__.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/__init__.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/audit.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/detect.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/devices.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/diagnose.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/diff.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/exec_.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/export.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/import_.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/init.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/inspect.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/patterns.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/plan_apply.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/plugin.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/probe.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/state.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/status.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/target.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/version.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/commands/workflow.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/core.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli/display.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/cli.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/data_sync.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/detect/__init__.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/detect/detector.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/detect/probes.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/detect/remote.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/detect/templates.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/detect/workflow.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/discovery.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/dsl/__init__.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/dsl/loader.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/dsl/parser.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/dsl_python/__init__.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/dsl_python/context.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/dsl_python/decorators.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/dsl_python/docker_steps.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/dsl_python/exceptions.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/dsl_python/runner.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/dsl_python/steps.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/fleet.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/iac/__init__.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/iac/base.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/iac/docker_compose.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/iac/parsers/__init__.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/iac/parsers/compose.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/iac/registry.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/markpact/__init__.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/markpact/compiler.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/markpact/models.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/markpact/parser.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/observe.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/parse.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/patterns.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/plan/__init__.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/plan/planner.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/plugins/__init__.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/plugins/builtin/__init__.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/plugins/builtin/browser_reload.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/plugins/builtin/notify.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/plugins/builtin/systemd_reload.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/spec_loader.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/ssh.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/__init__.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_audit.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_cli.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_data_sync.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_discovery.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_dsl.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_examples.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_executor.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_fleet.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_git_integration.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_iac.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_manifest.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_markpact_compiler.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_markpact_parser.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_models.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_observe.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_parse.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_patterns.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_planner_edge.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_plugins.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_probes.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_public_api.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_resume.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_spec_loader.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_ssh.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_templates.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_verify.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_version.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_version_changelog.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_version_cli.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_version_manifest.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_version_sources.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/tests/test_workflow.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/verify.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/version/__init__.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/version/bump.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/version/changelog.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/version/commits.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/version/diff.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/version/git_integration.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/version/git_transaction.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/version/manifest.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/version/sources/__init__.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/version/sources/base.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/version/sources/json_.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/version/sources/plain.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/version/sources/regex.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/version/sources/toml_.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/version/sources/yaml_.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/version/transaction.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy/version.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy.egg-info/dependency_links.txt +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy.egg-info/entry_points.txt +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy.egg-info/requires.txt +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/redeploy.egg-info/top_level.txt +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/setup.cfg +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/tests/test_iac.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/tests/test_parse.py +0 -0
- {redeploy-0.2.30 → redeploy-0.2.32}/tests/test_redeploy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redeploy
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.32
|
|
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
|
-
     
|
|
31
|
+
  
|
|
32
32
|
|
|
33
|
-
- 🤖 **LLM usage:** $7.5000 (
|
|
34
|
-
- 👤 **Human dev:** ~$
|
|
33
|
+
- 🤖 **LLM usage:** $7.5000 (52 commits)
|
|
34
|
+
- 👤 **Human dev:** ~$1755 (17.5h @ $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
|
-
    
|
|
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
|
-
     
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $7.5000 (
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $7.5000 (52 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$1755 (17.5h @ $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
|
-
    
|
|
17
17
|
|
|
18
18
|
Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
|
|
19
19
|
|
|
@@ -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.
|
|
10
|
+
__version__ = "0.2.32"
|
|
11
11
|
|
|
12
12
|
# ── Core models ───────────────────────────────────────────────────────────────
|
|
13
13
|
from .models import ( # noqa: F401
|
|
@@ -120,6 +120,92 @@ class AppHealthInfo(BaseModel):
|
|
|
120
120
|
response_ms: Optional[float] = None
|
|
121
121
|
|
|
122
122
|
|
|
123
|
+
# ── Hardware (output of hardware probe) ───────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
class DrmOutput(BaseModel):
|
|
126
|
+
"""One DRM connector (e.g. card1-DSI-2, card2-HDMI-A-1)."""
|
|
127
|
+
name: str # e.g. "card1-DSI-2"
|
|
128
|
+
connector: str # e.g. "DSI-2", "HDMI-A-1"
|
|
129
|
+
status: str = "unknown" # connected / disconnected
|
|
130
|
+
enabled: str = "unknown" # enabled / disabled
|
|
131
|
+
modes: list[str] = Field(default_factory=list) # e.g. ["1280x800@60"]
|
|
132
|
+
transform: str = "normal"
|
|
133
|
+
position: str = "0,0"
|
|
134
|
+
scale: str = "1.0"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class BacklightInfo(BaseModel):
|
|
138
|
+
"""Sysfs backlight device."""
|
|
139
|
+
name: str # e.g. "11-0045"
|
|
140
|
+
brightness: int = 0
|
|
141
|
+
max_brightness: int = 255
|
|
142
|
+
bl_power: int = 0 # 0=on, 4=off
|
|
143
|
+
display_name: Optional[str] = None # e.g. "DSI-2"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class I2CBusInfo(BaseModel):
|
|
147
|
+
bus: int
|
|
148
|
+
devices: list[str] = Field(default_factory=list) # hex addresses found
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class HardwareDiagnostic(BaseModel):
|
|
152
|
+
"""Problem found during hardware probe."""
|
|
153
|
+
component: str # "dsi", "backlight", "i2c", "gpio", "overlay"
|
|
154
|
+
severity: str = "warning" # info / warning / error / critical
|
|
155
|
+
message: str
|
|
156
|
+
fix: Optional[str] = None # suggested fix command or action
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class HardwareInfo(BaseModel):
|
|
160
|
+
"""Hardware state produced by hardware probe."""
|
|
161
|
+
board: Optional[str] = None # e.g. "Raspberry Pi 5 Model B Rev 1.0"
|
|
162
|
+
kernel: Optional[str] = None # uname -r
|
|
163
|
+
config_txt: str = "" # full /boot/firmware/config.txt
|
|
164
|
+
|
|
165
|
+
# Display
|
|
166
|
+
drm_outputs: list[DrmOutput] = Field(default_factory=list)
|
|
167
|
+
backlights: list[BacklightInfo] = Field(default_factory=list)
|
|
168
|
+
framebuffers: list[str] = Field(default_factory=list) # /dev/fb* names
|
|
169
|
+
wlr_outputs: list[dict] = Field(default_factory=list) # raw wlr-randr output
|
|
170
|
+
|
|
171
|
+
# DSI specific
|
|
172
|
+
dsi_overlays: list[str] = Field(default_factory=list) # active dtoverlay lines
|
|
173
|
+
dsi_dmesg: list[str] = Field(default_factory=list) # relevant dmesg lines
|
|
174
|
+
|
|
175
|
+
# I2C
|
|
176
|
+
i2c_buses: list[I2CBusInfo] = Field(default_factory=list)
|
|
177
|
+
|
|
178
|
+
# GPIO header
|
|
179
|
+
gpio_4pin_detected: Optional[bool] = None # 5V/GND/SDA/SCL header present
|
|
180
|
+
|
|
181
|
+
# Diagnostics
|
|
182
|
+
diagnostics: list[HardwareDiagnostic] = Field(default_factory=list)
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def has_dsi(self) -> bool:
|
|
186
|
+
return any("DSI" in o.name for o in self.drm_outputs)
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def dsi_connected(self) -> bool:
|
|
190
|
+
return any("DSI" in o.name and o.status == "connected" for o in self.drm_outputs)
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def dsi_enabled(self) -> bool:
|
|
194
|
+
return any("DSI" in o.name and o.enabled == "enabled" for o in self.drm_outputs)
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def backlight_on(self) -> bool:
|
|
198
|
+
return any(b.bl_power == 0 and b.brightness > 0 for b in self.backlights)
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def errors(self) -> list[HardwareDiagnostic]:
|
|
202
|
+
return [d for d in self.diagnostics if d.severity in ("error", "critical")]
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def warnings(self) -> list[HardwareDiagnostic]:
|
|
206
|
+
return [d for d in self.diagnostics if d.severity == "warning"]
|
|
207
|
+
|
|
208
|
+
|
|
123
209
|
class InfraState(BaseModel):
|
|
124
210
|
"""Full detected state of infrastructure — output of `detect`."""
|
|
125
211
|
host: str # "root@1.2.3.4" or "local"
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Hardware diagnostic plugin for redeploy.
|
|
2
|
+
|
|
3
|
+
Analyzes system hardware and provides configuration recommendations.
|
|
4
|
+
Supports Raspberry Pi variants and desktop platforms.
|
|
5
|
+
|
|
6
|
+
Usage in deployment.yaml:
|
|
7
|
+
extra_steps:
|
|
8
|
+
- id: analyze_hardware
|
|
9
|
+
action: plugin
|
|
10
|
+
plugin_type: hardware_diagnostic
|
|
11
|
+
plugin_params:
|
|
12
|
+
checks: ["platform", "cpu", "memory", "storage", "gpio", "usb", "audio"]
|
|
13
|
+
platform: "auto" # or "rpi3", "rpi4", "rpi5", "desktop"
|
|
14
|
+
verbose: true
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
from loguru import logger
|
|
24
|
+
|
|
25
|
+
from redeploy.plugins import PluginContext, register_plugin
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class HardwareInfo:
|
|
30
|
+
"""Hardware diagnostic information."""
|
|
31
|
+
platform: str
|
|
32
|
+
cpu_model: str
|
|
33
|
+
cpu_cores: int
|
|
34
|
+
memory_mb: int
|
|
35
|
+
storage_gb: float
|
|
36
|
+
gpio_available: bool
|
|
37
|
+
usb_devices: List[str]
|
|
38
|
+
audio_devices: List[str]
|
|
39
|
+
network_interfaces: List[str]
|
|
40
|
+
issues: List[str]
|
|
41
|
+
recommendations: List[str]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _run_command(cmd: str) -> str:
|
|
45
|
+
"""Run shell command and return output."""
|
|
46
|
+
try:
|
|
47
|
+
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10)
|
|
48
|
+
return result.stdout.strip()
|
|
49
|
+
except Exception as e:
|
|
50
|
+
logger.debug(f"Command failed: {cmd} - {e}")
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _detect_platform() -> str:
|
|
55
|
+
"""Detect the platform type."""
|
|
56
|
+
# Check for Raspberry Pi
|
|
57
|
+
model_file = "/proc/device-tree/model"
|
|
58
|
+
if os.path.exists(model_file):
|
|
59
|
+
try:
|
|
60
|
+
with open(model_file, "r") as f:
|
|
61
|
+
model = f.read().strip("\x00")
|
|
62
|
+
if "Raspberry Pi 5" in model:
|
|
63
|
+
return "rpi5"
|
|
64
|
+
elif "Raspberry Pi 4" in model:
|
|
65
|
+
return "rpi4"
|
|
66
|
+
elif "Raspberry Pi 3" in model:
|
|
67
|
+
return "rpi3"
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
# Check CPU info
|
|
72
|
+
cpuinfo = _run_command("cat /proc/cpuinfo")
|
|
73
|
+
if "Raspberry Pi" in cpuinfo or "BCM" in cpuinfo:
|
|
74
|
+
return "rpi_unknown"
|
|
75
|
+
|
|
76
|
+
# Check architecture
|
|
77
|
+
arch = _run_command("uname -m")
|
|
78
|
+
if arch == "aarch64":
|
|
79
|
+
return "arm64_unknown"
|
|
80
|
+
elif arch == "x86_64":
|
|
81
|
+
return "desktop"
|
|
82
|
+
|
|
83
|
+
return "unknown"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _get_cpu_info() -> tuple[str, int]:
|
|
87
|
+
"""Get CPU model and core count."""
|
|
88
|
+
cpuinfo = _run_command("cat /proc/cpuinfo")
|
|
89
|
+
model = "unknown"
|
|
90
|
+
cores = 0
|
|
91
|
+
|
|
92
|
+
for line in cpuinfo.split("\n"):
|
|
93
|
+
if line.startswith("model name"):
|
|
94
|
+
model = line.split(":", 1)[1].strip()
|
|
95
|
+
elif line.startswith("Hardware"):
|
|
96
|
+
model = line.split(":", 1)[1].strip()
|
|
97
|
+
elif line.startswith("processor"):
|
|
98
|
+
cores += 1
|
|
99
|
+
|
|
100
|
+
return model, max(cores, 1)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _get_memory_mb() -> int:
|
|
104
|
+
"""Get total memory in MB."""
|
|
105
|
+
meminfo = _run_command("cat /proc/meminfo")
|
|
106
|
+
for line in meminfo.split("\n"):
|
|
107
|
+
if line.startswith("MemTotal"):
|
|
108
|
+
kb = int(line.split()[1])
|
|
109
|
+
return kb // 1024
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _get_storage_gb() -> float:
|
|
114
|
+
"""Get available storage in GB."""
|
|
115
|
+
output = _run_command("df -h /")
|
|
116
|
+
if output:
|
|
117
|
+
lines = output.split("\n")
|
|
118
|
+
if len(lines) > 1:
|
|
119
|
+
parts = lines[1].split()
|
|
120
|
+
if len(parts) > 3:
|
|
121
|
+
available = parts[3]
|
|
122
|
+
if available.endswith("G"):
|
|
123
|
+
return float(available.rstrip("G"))
|
|
124
|
+
elif available.endswith("M"):
|
|
125
|
+
return float(available.rstrip("M")) / 1024
|
|
126
|
+
return 0.0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _check_gpio() -> bool:
|
|
130
|
+
"""Check if GPIO is available."""
|
|
131
|
+
# Check for GPIO character device
|
|
132
|
+
return os.path.exists("/dev/gpiochip0") or os.path.exists("/dev/gpiomem")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _get_usb_devices() -> List[str]:
|
|
136
|
+
"""Get list of USB devices."""
|
|
137
|
+
output = _run_command("lsusb")
|
|
138
|
+
devices = []
|
|
139
|
+
for line in output.split("\n"):
|
|
140
|
+
if line:
|
|
141
|
+
parts = line.split(" ", 6)
|
|
142
|
+
if len(parts) > 6:
|
|
143
|
+
devices.append(parts[6])
|
|
144
|
+
return devices
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _get_audio_devices() -> List[str]:
|
|
148
|
+
"""Get list of audio devices."""
|
|
149
|
+
output = _run_command("aplay -l")
|
|
150
|
+
devices = []
|
|
151
|
+
for line in output.split("\n"):
|
|
152
|
+
if line.startswith("card"):
|
|
153
|
+
devices.append(line.strip())
|
|
154
|
+
return devices
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _get_network_interfaces() -> List[str]:
|
|
158
|
+
"""Get list of network interfaces."""
|
|
159
|
+
output = _run_command("ip -o link show")
|
|
160
|
+
interfaces = []
|
|
161
|
+
for line in output.split("\n"):
|
|
162
|
+
if line:
|
|
163
|
+
parts = line.split(":")
|
|
164
|
+
if len(parts) > 1:
|
|
165
|
+
iface = parts[1].strip()
|
|
166
|
+
if iface != "lo":
|
|
167
|
+
interfaces.append(iface)
|
|
168
|
+
return interfaces
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _analyze_hardware(platform: str, verbose: bool = False) -> HardwareInfo:
|
|
172
|
+
"""Perform comprehensive hardware analysis."""
|
|
173
|
+
platform = platform if platform != "auto" else _detect_platform()
|
|
174
|
+
cpu_model, cpu_cores = _get_cpu_info()
|
|
175
|
+
memory_mb = _get_memory_mb()
|
|
176
|
+
storage_gb = _get_storage_gb()
|
|
177
|
+
gpio_available = _check_gpio()
|
|
178
|
+
usb_devices = _get_usb_devices()
|
|
179
|
+
audio_devices = _get_audio_devices()
|
|
180
|
+
network_interfaces = _get_network_interfaces()
|
|
181
|
+
|
|
182
|
+
issues = []
|
|
183
|
+
recommendations = []
|
|
184
|
+
|
|
185
|
+
# Platform-specific checks
|
|
186
|
+
if platform.startswith("rpi"):
|
|
187
|
+
if memory_mb < 1024:
|
|
188
|
+
issues.append(f"Low memory: {memory_mb}MB (recommended: >= 2GB for RPi)")
|
|
189
|
+
recommendations.append("Consider upgrading to RPi4/5 with 2GB+ RAM")
|
|
190
|
+
if storage_gb < 5:
|
|
191
|
+
issues.append(f"Low storage: {storage_gb}GB (recommended: >= 8GB)")
|
|
192
|
+
recommendations.append("Free up disk space or use larger SD card")
|
|
193
|
+
|
|
194
|
+
if platform == "rpi5":
|
|
195
|
+
if not gpio_available:
|
|
196
|
+
issues.append("GPIO not available on RPi5")
|
|
197
|
+
recommendations.append("Check kernel configuration for GPIO support")
|
|
198
|
+
elif platform in ["rpi3", "rpi4"]:
|
|
199
|
+
if not gpio_available:
|
|
200
|
+
issues.append("GPIO not available")
|
|
201
|
+
recommendations.append("Enable GPIO in config.txt or check permissions")
|
|
202
|
+
|
|
203
|
+
elif platform == "desktop":
|
|
204
|
+
if memory_mb < 4096:
|
|
205
|
+
issues.append(f"Low memory: {memory_mb}MB (recommended: >= 4GB for desktop)")
|
|
206
|
+
recommendations.append("Consider upgrading RAM for better performance")
|
|
207
|
+
|
|
208
|
+
# General checks
|
|
209
|
+
if not network_interfaces:
|
|
210
|
+
issues.append("No network interfaces detected")
|
|
211
|
+
recommendations.append("Check network configuration")
|
|
212
|
+
|
|
213
|
+
if verbose:
|
|
214
|
+
logger.info(f"Platform: {platform}")
|
|
215
|
+
logger.info(f"CPU: {cpu_model} ({cpu_cores} cores)")
|
|
216
|
+
logger.info(f"Memory: {memory_mb}MB")
|
|
217
|
+
logger.info(f"Storage: {storage_gb}GB available")
|
|
218
|
+
logger.info(f"GPIO: {'Available' if gpio_available else 'Not available'}")
|
|
219
|
+
logger.info(f"USB devices: {len(usb_devices)}")
|
|
220
|
+
logger.info(f"Audio devices: {len(audio_devices)}")
|
|
221
|
+
logger.info(f"Network interfaces: {network_interfaces}")
|
|
222
|
+
|
|
223
|
+
return HardwareInfo(
|
|
224
|
+
platform=platform,
|
|
225
|
+
cpu_model=cpu_model,
|
|
226
|
+
cpu_cores=cpu_cores,
|
|
227
|
+
memory_mb=memory_mb,
|
|
228
|
+
storage_gb=storage_gb,
|
|
229
|
+
gpio_available=gpio_available,
|
|
230
|
+
usb_devices=usb_devices,
|
|
231
|
+
audio_devices=audio_devices,
|
|
232
|
+
network_interfaces=network_interfaces,
|
|
233
|
+
issues=issues,
|
|
234
|
+
recommendations=recommendations,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@register_plugin("hardware_diagnostic")
|
|
239
|
+
def hardware_diagnostic(ctx: PluginContext) -> None:
|
|
240
|
+
"""Perform hardware diagnostics and provide recommendations."""
|
|
241
|
+
checks = ctx.params.get("checks", ["platform", "cpu", "memory", "storage"])
|
|
242
|
+
platform = ctx.params.get("platform", "auto")
|
|
243
|
+
verbose = ctx.params.get("verbose", True)
|
|
244
|
+
dry_run = ctx.dry_run
|
|
245
|
+
|
|
246
|
+
if dry_run:
|
|
247
|
+
logger.info("[DRY RUN] Would analyze hardware")
|
|
248
|
+
ctx.step.status = "done"
|
|
249
|
+
ctx.step.result = "dry_run"
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
logger.info("Starting hardware diagnostic...")
|
|
253
|
+
|
|
254
|
+
hw_info = _analyze_hardware(platform, verbose)
|
|
255
|
+
|
|
256
|
+
# Build result message
|
|
257
|
+
result_parts = []
|
|
258
|
+
result_parts.append(f"Platform: {hw_info.platform}")
|
|
259
|
+
result_parts.append(f"CPU: {hw_info.cpu_model} ({hw_info.cpu_cores} cores)")
|
|
260
|
+
result_parts.append(f"Memory: {hw_info.memory_mb}MB")
|
|
261
|
+
result_parts.append(f"Storage: {hw_info.storage_gb}GB")
|
|
262
|
+
result_parts.append(f"GPIO: {'OK' if hw_info.gpio_available else 'N/A'}")
|
|
263
|
+
|
|
264
|
+
if hw_info.issues:
|
|
265
|
+
result_parts.append(f"Issues: {len(hw_info.issues)}")
|
|
266
|
+
for issue in hw_info.issues:
|
|
267
|
+
logger.warning(f" - {issue}")
|
|
268
|
+
|
|
269
|
+
if hw_info.recommendations:
|
|
270
|
+
result_parts.append(f"Recommendations: {len(hw_info.recommendations)}")
|
|
271
|
+
for rec in hw_info.recommendations:
|
|
272
|
+
logger.info(f" - {rec}")
|
|
273
|
+
|
|
274
|
+
ctx.step.result = "; ".join(result_parts)
|
|
275
|
+
ctx.step.description = f"Hardware diagnostic: {hw_info.platform}, {hw_info.memory_mb}MB RAM"
|
|
276
|
+
|
|
277
|
+
if hw_info.issues:
|
|
278
|
+
ctx.step.status = "done" # Still done, but with warnings
|
|
279
|
+
logger.warning(f"Hardware diagnostic completed with {len(hw_info.issues)} issues")
|
|
280
|
+
else:
|
|
281
|
+
ctx.step.status = "done"
|
|
282
|
+
logger.info("Hardware diagnostic completed successfully")
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Process control plugin for redeploy.
|
|
2
|
+
|
|
3
|
+
Automatically kills processes on specified ports before deployment.
|
|
4
|
+
Supports port ranges, process name filtering, and graceful vs force kill.
|
|
5
|
+
|
|
6
|
+
Usage in deployment.yaml:
|
|
7
|
+
extra_steps:
|
|
8
|
+
- id: kill_processes_on_ports
|
|
9
|
+
action: plugin
|
|
10
|
+
plugin_type: process_control
|
|
11
|
+
plugin_params:
|
|
12
|
+
ports: [8100, 8101, 8202]
|
|
13
|
+
strategy: graceful # or 'force'
|
|
14
|
+
timeout: 10
|
|
15
|
+
notify: true
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import socket
|
|
20
|
+
import time
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from loguru import logger
|
|
24
|
+
|
|
25
|
+
from redeploy.models import ConflictSeverity, MigrationStep
|
|
26
|
+
from redeploy.plugins import PluginContext, register_plugin
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _find_pid_on_port(port: int) -> Optional[int]:
|
|
30
|
+
"""Find PID of process listening on port (Linux only)."""
|
|
31
|
+
try:
|
|
32
|
+
with open("/proc/net/tcp", "r") as f:
|
|
33
|
+
for line in f:
|
|
34
|
+
parts = line.strip().split()
|
|
35
|
+
if len(parts) >= 10:
|
|
36
|
+
# Format: local_addr rem_addr st tx_queue rx_queue ...
|
|
37
|
+
# local_addr format: hex_ip:hex_port
|
|
38
|
+
local_addr = parts[1].split(":")[-1]
|
|
39
|
+
hex_port = int(local_addr, 16)
|
|
40
|
+
if hex_port == port:
|
|
41
|
+
inode = parts[9]
|
|
42
|
+
# Find PID by inode in /proc/*/fd
|
|
43
|
+
import os
|
|
44
|
+
for pid_dir in os.listdir("/proc"):
|
|
45
|
+
if pid_dir.isdigit():
|
|
46
|
+
fd_dir = f"/proc/{pid_dir}/fd"
|
|
47
|
+
if os.path.exists(fd_dir):
|
|
48
|
+
for fd in os.listdir(fd_dir):
|
|
49
|
+
try:
|
|
50
|
+
link = os.readlink(os.path.join(fd_dir, fd))
|
|
51
|
+
if f"socket:[{inode}]" in link:
|
|
52
|
+
return int(pid_dir)
|
|
53
|
+
except (OSError, ValueError):
|
|
54
|
+
continue
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.debug(f"Could not find PID on port {port}: {e}")
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _kill_process(pid: int, force: bool = False) -> bool:
|
|
61
|
+
"""Kill a process by PID. Returns True if successful."""
|
|
62
|
+
try:
|
|
63
|
+
import os
|
|
64
|
+
import signal
|
|
65
|
+
sig = signal.SIGKILL if force else signal.SIGTERM
|
|
66
|
+
os.kill(pid, sig)
|
|
67
|
+
return True
|
|
68
|
+
except ProcessLookupError:
|
|
69
|
+
return False
|
|
70
|
+
except PermissionError:
|
|
71
|
+
logger.warning(f"Permission denied to kill PID {pid}")
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@register_plugin("process_control")
|
|
76
|
+
def process_control(ctx: PluginContext) -> None:
|
|
77
|
+
"""Kill processes on specified ports."""
|
|
78
|
+
ports = ctx.params.get("ports", [])
|
|
79
|
+
strategy = ctx.params.get("strategy", "graceful")
|
|
80
|
+
timeout = ctx.params.get("timeout", 10)
|
|
81
|
+
notify = ctx.params.get("notify", True)
|
|
82
|
+
dry_run = ctx.dry_run
|
|
83
|
+
|
|
84
|
+
if not ports:
|
|
85
|
+
logger.warning("process_control: no ports specified")
|
|
86
|
+
ctx.step.status = "done"
|
|
87
|
+
ctx.step.result = "no_ports"
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
if dry_run:
|
|
91
|
+
logger.info(f"[DRY RUN] Would kill processes on ports: {ports}")
|
|
92
|
+
ctx.step.status = "done"
|
|
93
|
+
ctx.step.result = "dry_run"
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
killed = []
|
|
97
|
+
for port in ports:
|
|
98
|
+
pid = _find_pid_on_port(port)
|
|
99
|
+
if pid:
|
|
100
|
+
logger.info(f"Found process PID {pid} on port {port}")
|
|
101
|
+
if strategy == "graceful":
|
|
102
|
+
_kill_process(pid, force=False)
|
|
103
|
+
# Wait for graceful shutdown
|
|
104
|
+
time.sleep(2)
|
|
105
|
+
# Check if still running
|
|
106
|
+
if _find_pid_on_port(port) == pid:
|
|
107
|
+
logger.warning(f"Process {pid} on port {port} did not stop gracefully, force killing")
|
|
108
|
+
_kill_process(pid, force=True)
|
|
109
|
+
else:
|
|
110
|
+
_kill_process(pid, force=True)
|
|
111
|
+
killed.append((port, pid))
|
|
112
|
+
else:
|
|
113
|
+
logger.debug(f"No process found on port {port}")
|
|
114
|
+
|
|
115
|
+
if killed:
|
|
116
|
+
ctx.step.result = f"killed:{len(killed)}_processes"
|
|
117
|
+
if notify:
|
|
118
|
+
ctx.step.description = f"Killed {len(killed)} process(es) on ports {ports}"
|
|
119
|
+
else:
|
|
120
|
+
ctx.step.result = "no_processes_to_kill"
|
|
121
|
+
ctx.step.description = f"No processes found on ports {ports}"
|
|
122
|
+
|
|
123
|
+
ctx.step.status = "done"
|
|
@@ -218,6 +218,37 @@ _GIT_PULL = _step(
|
|
|
218
218
|
rollback_command="git -C ~/app reset --hard HEAD@{1}",
|
|
219
219
|
)
|
|
220
220
|
|
|
221
|
+
# ── process control ─────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
_KILL_PROCESSES_ON_PORTS = _step(
|
|
224
|
+
id="kill_processes_on_ports",
|
|
225
|
+
action=StepAction.PLUGIN,
|
|
226
|
+
plugin_type="process_control",
|
|
227
|
+
description="Kill processes on specified ports (auto-detects PIDs)",
|
|
228
|
+
plugin_params={"ports": [], "strategy": "graceful", "timeout": 10, "notify": True},
|
|
229
|
+
risk=ConflictSeverity.LOW,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
_KILL_DEV_PROCESSES = _step(
|
|
233
|
+
id="kill_dev_processes",
|
|
234
|
+
action=StepAction.PLUGIN,
|
|
235
|
+
plugin_type="process_control",
|
|
236
|
+
description="Kill common dev server processes (vite, uvicorn, python)",
|
|
237
|
+
plugin_params={"ports": [8100, 8101, 8202, 3000, 5173], "strategy": "graceful", "timeout": 5, "notify": True},
|
|
238
|
+
risk=ConflictSeverity.LOW,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# ── hardware diagnostic ─────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
_HARDWARE_DIAGNOSTIC = _step(
|
|
244
|
+
id="hardware_diagnostic",
|
|
245
|
+
action=StepAction.PLUGIN,
|
|
246
|
+
plugin_type="hardware_diagnostic",
|
|
247
|
+
description="Analyze system hardware and provide configuration recommendations",
|
|
248
|
+
plugin_params={"checks": ["platform", "cpu", "memory", "storage"], "platform": "auto", "verbose": True},
|
|
249
|
+
risk=ConflictSeverity.LOW,
|
|
250
|
+
)
|
|
251
|
+
|
|
221
252
|
# ── registry ──────────────────────────────────────────────────────────────────
|
|
222
253
|
|
|
223
254
|
_LIBRARY: dict[str, MigrationStep] = {
|
|
@@ -241,6 +272,9 @@ _LIBRARY: dict[str, MigrationStep] = {
|
|
|
241
272
|
_SYSTEMCTL_RESTART,
|
|
242
273
|
_SYSTEMCTL_DAEMON_RELOAD,
|
|
243
274
|
_GIT_PULL,
|
|
275
|
+
_KILL_PROCESSES_ON_PORTS,
|
|
276
|
+
_KILL_DEV_PROCESSES,
|
|
277
|
+
_HARDWARE_DIAGNOSTIC,
|
|
244
278
|
]
|
|
245
279
|
}
|
|
246
280
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redeploy
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.32
|
|
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
|
-
     
|
|
31
|
+
  
|
|
32
32
|
|
|
33
|
-
- 🤖 **LLM usage:** $7.5000 (
|
|
34
|
-
- 👤 **Human dev:** ~$
|
|
33
|
+
- 🤖 **LLM usage:** $7.5000 (52 commits)
|
|
34
|
+
- 👤 **Human dev:** ~$1755 (17.5h @ $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
|
-
    
|
|
41
41
|
|
|
42
42
|
Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
|
|
43
43
|
|
|
@@ -82,7 +82,9 @@ redeploy/plan/planner.py
|
|
|
82
82
|
redeploy/plugins/__init__.py
|
|
83
83
|
redeploy/plugins/builtin/__init__.py
|
|
84
84
|
redeploy/plugins/builtin/browser_reload.py
|
|
85
|
+
redeploy/plugins/builtin/hardware_diagnostic.py
|
|
85
86
|
redeploy/plugins/builtin/notify.py
|
|
87
|
+
redeploy/plugins/builtin/process_control.py
|
|
86
88
|
redeploy/plugins/builtin/systemd_reload.py
|
|
87
89
|
redeploy/tests/__init__.py
|
|
88
90
|
redeploy/tests/test_audit.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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|