redeploy 0.2.20__tar.gz → 0.2.22__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.20 → redeploy-0.2.22}/PKG-INFO +6 -6
- {redeploy-0.2.20 → redeploy-0.2.22}/README.md +5 -5
- {redeploy-0.2.20 → redeploy-0.2.22}/pyproject.toml +1 -1
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/__init__.py +1 -1
- redeploy-0.2.22/redeploy/cli/__init__.py +107 -0
- redeploy-0.2.22/redeploy/cli/commands/__init__.py +44 -0
- redeploy-0.2.22/redeploy/cli/commands/audit.py +126 -0
- redeploy-0.2.22/redeploy/cli/commands/detect.py +118 -0
- redeploy-0.2.22/redeploy/cli/commands/devices.py +216 -0
- redeploy-0.2.22/redeploy/cli/commands/diagnose.py +88 -0
- redeploy-0.2.22/redeploy/cli/commands/diff.py +37 -0
- redeploy-0.2.22/redeploy/cli/commands/exec_.py +251 -0
- redeploy-0.2.22/redeploy/cli/commands/export.py +126 -0
- redeploy-0.2.22/redeploy/cli/commands/import_.py +250 -0
- redeploy-0.2.22/redeploy/cli/commands/init.py +111 -0
- redeploy-0.2.22/redeploy/cli/commands/inspect.py +73 -0
- redeploy-0.2.22/redeploy/cli/commands/patterns.py +104 -0
- redeploy-0.2.22/redeploy/cli/commands/plan_apply.py +436 -0
- redeploy-0.2.22/redeploy/cli/commands/plugin.py +79 -0
- redeploy-0.2.22/redeploy/cli/commands/probe.py +144 -0
- redeploy-0.2.22/redeploy/cli/commands/state.py +118 -0
- redeploy-0.2.22/redeploy/cli/commands/status.py +55 -0
- redeploy-0.2.22/redeploy/cli/commands/target.py +97 -0
- redeploy-0.2.22/redeploy/cli/commands/version.py +1405 -0
- redeploy-0.2.22/redeploy/cli/commands/workflow.py +87 -0
- redeploy-0.2.22/redeploy/cli/core.py +179 -0
- redeploy-0.2.22/redeploy/cli/display.py +349 -0
- redeploy-0.2.22/redeploy/cli.py +93 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/markpact/parser.py +58 -33
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/ssh.py +2 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_cli.py +3 -3
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/version/changelog.py +37 -28
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy.egg-info/PKG-INFO +6 -6
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy.egg-info/SOURCES.txt +22 -0
- redeploy-0.2.20/redeploy/cli/__init__.py +0 -66
- redeploy-0.2.20/redeploy/cli.py +0 -3945
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/apply/__init__.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/apply/executor.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/apply/state.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/audit.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/data_sync.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/detect/__init__.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/detect/detector.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/detect/probes.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/detect/remote.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/detect/templates.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/detect/workflow.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/discovery.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/dsl/__init__.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/dsl/loader.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/dsl/parser.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/dsl_python/__init__.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/dsl_python/context.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/dsl_python/decorators.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/dsl_python/docker_steps.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/dsl_python/exceptions.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/dsl_python/runner.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/dsl_python/steps.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/fleet.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/iac/__init__.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/iac/base.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/iac/docker_compose.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/iac/parsers/__init__.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/iac/parsers/compose.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/iac/registry.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/markpact/__init__.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/markpact/compiler.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/markpact/models.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/models.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/observe.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/parse.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/patterns.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/plan/__init__.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/plan/planner.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/plugins/__init__.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/plugins/builtin/__init__.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/plugins/builtin/browser_reload.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/plugins/builtin/notify.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/plugins/builtin/systemd_reload.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/spec_loader.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/steps.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/__init__.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_audit.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_data_sync.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_discovery.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_dsl.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_examples.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_executor.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_fleet.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_git_integration.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_iac.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_manifest.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_markpact_compiler.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_markpact_parser.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_models.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_observe.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_parse.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_patterns.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_planner_edge.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_plugins.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_probes.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_public_api.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_resume.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_spec_loader.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_ssh.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_templates.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_verify.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_version.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_version_changelog.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_version_cli.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_version_manifest.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_version_sources.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/tests/test_workflow.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/verify.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/version/__init__.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/version/bump.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/version/commits.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/version/diff.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/version/git_integration.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/version/git_transaction.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/version/manifest.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/version/sources/__init__.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/version/sources/base.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/version/sources/json_.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/version/sources/plain.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/version/sources/regex.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/version/sources/toml_.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/version/sources/yaml_.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/version/transaction.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy/version.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy.egg-info/dependency_links.txt +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy.egg-info/entry_points.txt +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy.egg-info/requires.txt +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/redeploy.egg-info/top_level.txt +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/setup.cfg +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/tests/test_iac.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/tests/test_parse.py +0 -0
- {redeploy-0.2.20 → redeploy-0.2.22}/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.22
|
|
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:** $
|
|
34
|
-
- 👤 **Human dev:** ~$
|
|
33
|
+
- 🤖 **LLM usage:** $6.1500 (41 commits)
|
|
34
|
+
- 👤 **Human dev:** ~$1363 (13.6h @ $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:** $
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $6.1500 (41 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$1363 (13.6h @ $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.22"
|
|
11
11
|
|
|
12
12
|
# ── Core models ───────────────────────────────────────────────────────────────
|
|
13
13
|
from .models import ( # noqa: F401
|
|
@@ -0,0 +1,107 @@
|
|
|
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 all commands from submodules (not from package level)
|
|
34
|
+
from .commands.audit import audit
|
|
35
|
+
from .commands.detect import detect
|
|
36
|
+
from .commands.devices import devices, scan, device_add, device_rm
|
|
37
|
+
from .commands.diagnose import diagnose
|
|
38
|
+
from .commands.diff import diff
|
|
39
|
+
from .commands.export import export_cmd
|
|
40
|
+
from .commands.import_ import import_cmd
|
|
41
|
+
from .commands.inspect import inspect
|
|
42
|
+
from .commands.patterns import patterns
|
|
43
|
+
from .commands.plugin import plugin_cmd
|
|
44
|
+
from .commands.probe import probe
|
|
45
|
+
from .commands.target import target
|
|
46
|
+
from .commands.version import version_cmd
|
|
47
|
+
from .commands.workflow import workflow_cmd
|
|
48
|
+
from .commands.exec_ import exec_cmd, exec_multi_cmd
|
|
49
|
+
from .commands.plan_apply import plan, apply, migrate, run
|
|
50
|
+
from .commands.state import state_cmd
|
|
51
|
+
from .commands.init import init
|
|
52
|
+
from .commands.status import status
|
|
53
|
+
|
|
54
|
+
# Register commands
|
|
55
|
+
cli.add_command(audit)
|
|
56
|
+
cli.add_command(detect)
|
|
57
|
+
cli.add_command(devices)
|
|
58
|
+
cli.add_command(scan)
|
|
59
|
+
cli.add_command(device_add)
|
|
60
|
+
cli.add_command(device_rm)
|
|
61
|
+
cli.add_command(diagnose)
|
|
62
|
+
cli.add_command(diff)
|
|
63
|
+
cli.add_command(exec_cmd)
|
|
64
|
+
cli.add_command(exec_multi_cmd)
|
|
65
|
+
cli.add_command(export_cmd)
|
|
66
|
+
cli.add_command(import_cmd)
|
|
67
|
+
cli.add_command(init)
|
|
68
|
+
cli.add_command(inspect)
|
|
69
|
+
cli.add_command(patterns)
|
|
70
|
+
cli.add_command(plan)
|
|
71
|
+
cli.add_command(apply)
|
|
72
|
+
cli.add_command(migrate)
|
|
73
|
+
cli.add_command(run)
|
|
74
|
+
cli.add_command(plugin_cmd)
|
|
75
|
+
cli.add_command(probe)
|
|
76
|
+
cli.add_command(state_cmd)
|
|
77
|
+
cli.add_command(status)
|
|
78
|
+
cli.add_command(target)
|
|
79
|
+
cli.add_command(version_cmd)
|
|
80
|
+
cli.add_command(workflow_cmd)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Backward compatibility: _resolve_device for tests
|
|
84
|
+
def _resolve_device(console, device_id: str) -> tuple:
|
|
85
|
+
"""Resolve device from registry or auto-probe. Returns (device, registry) or (None, None)."""
|
|
86
|
+
from ..discovery import auto_probe
|
|
87
|
+
from ..models import DeviceRegistry
|
|
88
|
+
|
|
89
|
+
reg = DeviceRegistry.load()
|
|
90
|
+
dev = reg.get(device_id)
|
|
91
|
+
|
|
92
|
+
if not dev:
|
|
93
|
+
console.print(f"[yellow]⚠ {device_id} not in registry — probing…[/yellow]")
|
|
94
|
+
r = auto_probe(device_id, timeout=8, save=True)
|
|
95
|
+
if r.reachable:
|
|
96
|
+
reg = DeviceRegistry.load()
|
|
97
|
+
dev = reg.get(r.host) or reg.get(r.ip)
|
|
98
|
+
key_name = __import__('os').path.basename(r.ssh_key) if r.ssh_key else 'agent'
|
|
99
|
+
console.print(f" [green]✓[/green] auto-probe OK: {r.host} strategy={r.strategy} key={key_name}")
|
|
100
|
+
else:
|
|
101
|
+
console.print(f" [red]✗ probe failed: {r.error}[/red]")
|
|
102
|
+
console.print("[dim] Add manually: redeploy device-add HOST --strategy STRATEGY[/dim]")
|
|
103
|
+
|
|
104
|
+
return dev, reg
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = ["cli", "_resolve_device"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""CLI commands package."""
|
|
2
|
+
# Commands are registered in cli/__init__.py
|
|
3
|
+
|
|
4
|
+
from . import audit
|
|
5
|
+
from . import detect
|
|
6
|
+
from . import devices
|
|
7
|
+
from . import diagnose
|
|
8
|
+
from . import diff
|
|
9
|
+
from . import exec_
|
|
10
|
+
from . import export
|
|
11
|
+
from . import import_
|
|
12
|
+
from . import init
|
|
13
|
+
from . import inspect
|
|
14
|
+
from . import patterns
|
|
15
|
+
from . import plan_apply
|
|
16
|
+
from . import plugin
|
|
17
|
+
from . import probe
|
|
18
|
+
from . import state
|
|
19
|
+
from . import status
|
|
20
|
+
from . import target
|
|
21
|
+
from . import version
|
|
22
|
+
from . import workflow
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"audit",
|
|
26
|
+
"detect",
|
|
27
|
+
"devices",
|
|
28
|
+
"diagnose",
|
|
29
|
+
"diff",
|
|
30
|
+
"exec_",
|
|
31
|
+
"export",
|
|
32
|
+
"import_",
|
|
33
|
+
"init",
|
|
34
|
+
"inspect",
|
|
35
|
+
"patterns",
|
|
36
|
+
"plan_apply",
|
|
37
|
+
"plugin",
|
|
38
|
+
"probe",
|
|
39
|
+
"state",
|
|
40
|
+
"status",
|
|
41
|
+
"target",
|
|
42
|
+
"version",
|
|
43
|
+
"workflow",
|
|
44
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""audit command — Show deploy audit log."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command()
|
|
13
|
+
@click.option(
|
|
14
|
+
"-n", "--last", default=20, show_default=True,
|
|
15
|
+
help="Number of most-recent entries to show"
|
|
16
|
+
)
|
|
17
|
+
@click.option("--host", default=None, help="Filter by host (substring match)")
|
|
18
|
+
@click.option("--app", default=None, help="Filter by app name")
|
|
19
|
+
@click.option("--failed", "only_failed", is_flag=True, help="Show only failed deployments")
|
|
20
|
+
@click.option("--ok", "only_ok", is_flag=True, help="Show only successful deployments")
|
|
21
|
+
@click.option("--log", default=None, type=click.Path(), help="Custom audit log path")
|
|
22
|
+
@click.option("--json", "as_json", is_flag=True, help="Output raw JSONL")
|
|
23
|
+
@click.option(
|
|
24
|
+
"--report", "show_report", default=None,
|
|
25
|
+
help="Show full DeployReport for entry N (1-based)"
|
|
26
|
+
)
|
|
27
|
+
@click.option("--clear", "do_clear", is_flag=True, help="Truncate audit log (irreversible)")
|
|
28
|
+
def audit(last, host, app, only_failed, only_ok, log, as_json, show_report, do_clear):
|
|
29
|
+
"""Show deploy audit log from ~/.config/redeploy/audit.jsonl.
|
|
30
|
+
|
|
31
|
+
\b
|
|
32
|
+
Examples:
|
|
33
|
+
redeploy audit
|
|
34
|
+
redeploy audit --last 50 --failed
|
|
35
|
+
redeploy audit --app myapp --host prod
|
|
36
|
+
redeploy audit --report 1
|
|
37
|
+
redeploy audit --clear
|
|
38
|
+
"""
|
|
39
|
+
import json as _json
|
|
40
|
+
from ...observe import DeployAuditLog, DeployReport
|
|
41
|
+
|
|
42
|
+
console = Console()
|
|
43
|
+
log_path = Path(log) if log else None
|
|
44
|
+
audit_log = DeployAuditLog(path=log_path)
|
|
45
|
+
|
|
46
|
+
if do_clear:
|
|
47
|
+
if not audit_log.path.exists():
|
|
48
|
+
console.print("[dim]Audit log is already empty.[/dim]")
|
|
49
|
+
return
|
|
50
|
+
click.confirm(f"Truncate {audit_log.path}?", abort=True)
|
|
51
|
+
audit_log.clear()
|
|
52
|
+
console.print(f"[green]✓[/green] Audit log cleared: {audit_log.path}")
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
ok_filter = None
|
|
56
|
+
if only_failed:
|
|
57
|
+
ok_filter = False
|
|
58
|
+
elif only_ok:
|
|
59
|
+
ok_filter = True
|
|
60
|
+
|
|
61
|
+
entries = audit_log.filter(host=host, app=app, ok=ok_filter)
|
|
62
|
+
entries = entries[-last:]
|
|
63
|
+
|
|
64
|
+
if not entries:
|
|
65
|
+
console.print("[dim]No audit entries found.[/dim]")
|
|
66
|
+
console.print(f" Log: {audit_log.path}")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
if show_report:
|
|
70
|
+
try:
|
|
71
|
+
idx = int(show_report) - 1
|
|
72
|
+
entry = entries[idx]
|
|
73
|
+
except (ValueError, IndexError):
|
|
74
|
+
console.print(
|
|
75
|
+
f"[red]Entry {show_report} not found (1–{len(entries)} available)[/red]"
|
|
76
|
+
)
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
console.print(DeployReport(entry).text())
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if as_json:
|
|
82
|
+
for e in entries:
|
|
83
|
+
print(_json.dumps(e.to_dict(), ensure_ascii=False))
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
t = Table(show_header=True, box=None, padding=(0, 2))
|
|
87
|
+
t.add_column("#", style="dim", width=3)
|
|
88
|
+
t.add_column("Time", style="dim")
|
|
89
|
+
t.add_column("Host")
|
|
90
|
+
t.add_column("App", style="bold")
|
|
91
|
+
t.add_column("Strategy", style="cyan")
|
|
92
|
+
t.add_column("Result")
|
|
93
|
+
t.add_column("Steps", style="dim")
|
|
94
|
+
t.add_column("Elapsed", style="dim")
|
|
95
|
+
|
|
96
|
+
for i, e in enumerate(entries, 1):
|
|
97
|
+
ts = e.ts[11:16] if len(e.ts) >= 16 else e.ts
|
|
98
|
+
date = e.ts[:10] if len(e.ts) >= 10 else ""
|
|
99
|
+
strategy = f"{e.from_strategy}→{e.to_strategy}"
|
|
100
|
+
if e.ok:
|
|
101
|
+
result = "[green]ok[/green]"
|
|
102
|
+
else:
|
|
103
|
+
result = "[red]FAIL[/red]"
|
|
104
|
+
if e.dry_run:
|
|
105
|
+
result += " [dim](dry)[/dim]"
|
|
106
|
+
steps_str = f"{e.steps_ok}/{e.steps_total}"
|
|
107
|
+
if e.steps_failed:
|
|
108
|
+
steps_str += f" [red]✗{e.steps_failed}[/red]"
|
|
109
|
+
elapsed = f"{e.elapsed_s:.1f}s"
|
|
110
|
+
t.add_row(
|
|
111
|
+
str(i),
|
|
112
|
+
f"{date} {ts}",
|
|
113
|
+
e.host,
|
|
114
|
+
e.app,
|
|
115
|
+
strategy,
|
|
116
|
+
result,
|
|
117
|
+
steps_str,
|
|
118
|
+
elapsed,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
console.print(t)
|
|
122
|
+
console.print(
|
|
123
|
+
f"\n [dim]{len(entries)} entr{'y' if len(entries) == 1 else 'ies'}"
|
|
124
|
+
f" • {audit_log.path}[/dim]"
|
|
125
|
+
)
|
|
126
|
+
console.print(" [dim]Tip: --report N for full step breakdown[/dim]")
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""detect command — Probe infrastructure and produce infra.yaml."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from ..core import run_detect_workflow
|
|
12
|
+
from ..display import (
|
|
13
|
+
print_infrastructure_summary,
|
|
14
|
+
print_docker_services,
|
|
15
|
+
print_k3s_pods,
|
|
16
|
+
print_conflicts,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.command()
|
|
21
|
+
@click.option("--host", default=None, help="SSH host (user@ip) or 'local'")
|
|
22
|
+
@click.option(
|
|
23
|
+
"--app",
|
|
24
|
+
default=None,
|
|
25
|
+
show_default=True,
|
|
26
|
+
help="Application name (default from redeploy.yaml)",
|
|
27
|
+
)
|
|
28
|
+
@click.option("--domain", default=None, help="Public domain for HTTP health checks")
|
|
29
|
+
@click.option(
|
|
30
|
+
"-o",
|
|
31
|
+
"--output",
|
|
32
|
+
default="infra.yaml",
|
|
33
|
+
show_default=True,
|
|
34
|
+
type=click.Path(),
|
|
35
|
+
help="Output file for InfraState",
|
|
36
|
+
)
|
|
37
|
+
@click.option(
|
|
38
|
+
"--workflow",
|
|
39
|
+
"run_workflow",
|
|
40
|
+
is_flag=True,
|
|
41
|
+
help="Run full multi-host workflow (detect + template scoring)",
|
|
42
|
+
)
|
|
43
|
+
@click.option(
|
|
44
|
+
"--scan", "scan_subnet", default=None, help="Subnet to scan for devices (used with --workflow)"
|
|
45
|
+
)
|
|
46
|
+
@click.option(
|
|
47
|
+
"--no-deep", is_flag=True, help="Workflow: skip deep SSH probe (faster, less accurate)"
|
|
48
|
+
)
|
|
49
|
+
@click.option(
|
|
50
|
+
"--save-yaml",
|
|
51
|
+
default=None,
|
|
52
|
+
type=click.Path(),
|
|
53
|
+
help="Workflow: save generated manifest to file",
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--format",
|
|
57
|
+
"output_fmt",
|
|
58
|
+
default="yaml",
|
|
59
|
+
type=click.Choice(["yaml", "css"]),
|
|
60
|
+
help="Output format for generated manifest (yaml or css)",
|
|
61
|
+
)
|
|
62
|
+
@click.pass_context
|
|
63
|
+
def detect(
|
|
64
|
+
ctx, host, app, domain, output, run_workflow, scan_subnet, no_deep, save_yaml, output_fmt
|
|
65
|
+
):
|
|
66
|
+
"""Probe infrastructure and produce infra.yaml.
|
|
67
|
+
|
|
68
|
+
With --workflow: multi-host detection with template scoring.
|
|
69
|
+
Reads hosts from redeploy.yaml / redeploy.css environments + registry + --scan.
|
|
70
|
+
|
|
71
|
+
\b
|
|
72
|
+
Examples:
|
|
73
|
+
redeploy detect --host pi@192.168.188.108
|
|
74
|
+
redeploy detect --workflow
|
|
75
|
+
redeploy detect --workflow --format css --save-yaml redeploy.css
|
|
76
|
+
redeploy detect --workflow --scan 192.168.188.0/24
|
|
77
|
+
redeploy detect --workflow --no-deep
|
|
78
|
+
"""
|
|
79
|
+
from ...models import ProjectManifest
|
|
80
|
+
from ...detect import Detector
|
|
81
|
+
|
|
82
|
+
console = Console()
|
|
83
|
+
manifest = ProjectManifest.find_and_load(Path.cwd())
|
|
84
|
+
app_name = app or (manifest.app if manifest else "app")
|
|
85
|
+
|
|
86
|
+
if run_workflow or scan_subnet:
|
|
87
|
+
run_detect_workflow(
|
|
88
|
+
console,
|
|
89
|
+
hosts=[host] if host else [],
|
|
90
|
+
manifest=manifest,
|
|
91
|
+
app=app_name,
|
|
92
|
+
scan_subnet=scan_subnet,
|
|
93
|
+
deep=not no_deep,
|
|
94
|
+
save_yaml=save_yaml,
|
|
95
|
+
fmt=output_fmt,
|
|
96
|
+
)
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
if not host:
|
|
100
|
+
console.print("[red]✗ --host required (or use --workflow)[/red]")
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
|
|
103
|
+
out_path = Path(output)
|
|
104
|
+
domain = domain or (manifest.domain if manifest else None)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
d = Detector(host=host, app=app_name, domain=domain)
|
|
108
|
+
state = d.run()
|
|
109
|
+
d.save(state, out_path)
|
|
110
|
+
except ConnectionError as e:
|
|
111
|
+
console.print(f"[red]✗ {e}[/red]")
|
|
112
|
+
sys.exit(1)
|
|
113
|
+
|
|
114
|
+
print_infrastructure_summary(console, state, host)
|
|
115
|
+
print_docker_services(console, state)
|
|
116
|
+
print_k3s_pods(console, state)
|
|
117
|
+
print_conflicts(console, state)
|
|
118
|
+
console.print(f"\n[dim]Saved to {out_path}[/dim]")
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""devices, scan, device-add, device-rm commands — Device management."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command()
|
|
14
|
+
@click.option("--tag", default=None, help="Filter by tag")
|
|
15
|
+
@click.option("--strategy", default=None, help="Filter by strategy")
|
|
16
|
+
@click.option("--reachable", is_flag=True, help="Show only recently-seen devices")
|
|
17
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
18
|
+
def devices(tag, strategy, reachable, as_json):
|
|
19
|
+
"""List known devices from ~/.config/redeploy/devices.yaml.
|
|
20
|
+
|
|
21
|
+
\b
|
|
22
|
+
Example:
|
|
23
|
+
redeploy devices
|
|
24
|
+
redeploy devices --tag kiosk
|
|
25
|
+
redeploy devices --reachable
|
|
26
|
+
"""
|
|
27
|
+
import json as _json
|
|
28
|
+
from ...models import DeviceRegistry
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
reg = DeviceRegistry.load()
|
|
32
|
+
devs = reg.devices
|
|
33
|
+
|
|
34
|
+
if tag:
|
|
35
|
+
devs = [d for d in devs if tag in d.tags]
|
|
36
|
+
if strategy:
|
|
37
|
+
devs = [d for d in devs if d.strategy == strategy]
|
|
38
|
+
if reachable:
|
|
39
|
+
devs = [d for d in devs if d.is_reachable]
|
|
40
|
+
|
|
41
|
+
if as_json:
|
|
42
|
+
print(
|
|
43
|
+
_json.dumps(
|
|
44
|
+
[d.model_dump(mode="json") for d in devs],
|
|
45
|
+
indent=2,
|
|
46
|
+
default=str,
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
if not devs:
|
|
52
|
+
console.print("[dim]No devices found. Run:[/dim] redeploy scan")
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
t = Table(show_header=True, box=None, padding=(0, 2))
|
|
56
|
+
t.add_column("ID", style="bold")
|
|
57
|
+
t.add_column("Host")
|
|
58
|
+
t.add_column("Strategy", style="cyan")
|
|
59
|
+
t.add_column("App")
|
|
60
|
+
t.add_column("Tags", style="dim")
|
|
61
|
+
t.add_column("Last seen", style="dim")
|
|
62
|
+
t.add_column("SSH", style="dim")
|
|
63
|
+
|
|
64
|
+
for d in devs:
|
|
65
|
+
seen = d.last_seen.strftime("%m-%d %H:%M") if d.last_seen else "never"
|
|
66
|
+
ssh = "[green]✓[/green]" if d.last_ssh_ok else "[red]✗[/red]"
|
|
67
|
+
t.add_row(
|
|
68
|
+
d.id,
|
|
69
|
+
d.host,
|
|
70
|
+
d.strategy,
|
|
71
|
+
d.app or "—",
|
|
72
|
+
",".join(d.tags) or "—",
|
|
73
|
+
seen,
|
|
74
|
+
ssh,
|
|
75
|
+
)
|
|
76
|
+
console.print(t)
|
|
77
|
+
console.print(
|
|
78
|
+
f"\n [dim]{len(devs)} device(s) • registry: {DeviceRegistry.default_path()}[/dim]"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@click.command()
|
|
83
|
+
@click.option(
|
|
84
|
+
"--subnet", default=None,
|
|
85
|
+
help="CIDR to scan, e.g. 192.168.1.0/24 (auto-detect if omitted)"
|
|
86
|
+
)
|
|
87
|
+
@click.option(
|
|
88
|
+
"--user", "ssh_users", multiple=True, default=None,
|
|
89
|
+
help="SSH user(s) to try (repeatable). Default: current user + root + pi + ubuntu"
|
|
90
|
+
)
|
|
91
|
+
@click.option("--port", "ssh_port", default=22, show_default=True, help="SSH port")
|
|
92
|
+
@click.option("--ping", is_flag=True, help="Active ICMP ping sweep (sends packets)")
|
|
93
|
+
@click.option("--no-mdns", is_flag=True, help="Disable mDNS discovery")
|
|
94
|
+
@click.option(
|
|
95
|
+
"--timeout", default=5, show_default=True, help="Per-host SSH timeout (seconds)"
|
|
96
|
+
)
|
|
97
|
+
@click.option("--no-save", is_flag=True, help="Do not save results to registry")
|
|
98
|
+
def scan(subnet, ssh_users, ssh_port, ping, no_mdns, timeout, no_save):
|
|
99
|
+
"""Discover SSH-accessible devices on the local network.
|
|
100
|
+
|
|
101
|
+
Sources (passive by default, zero packets unless --ping):
|
|
102
|
+
known_hosts — parse ~/.ssh/known_hosts
|
|
103
|
+
arp — read ARP/neighbor cache
|
|
104
|
+
mdns — query _ssh._tcp via avahi-browse
|
|
105
|
+
ping sweep — ICMP /24 sweep (--ping flag required)
|
|
106
|
+
|
|
107
|
+
Results are saved to ~/.config/redeploy/devices.yaml (chmod 600).
|
|
108
|
+
|
|
109
|
+
\b
|
|
110
|
+
Example:
|
|
111
|
+
redeploy scan
|
|
112
|
+
redeploy scan --ping --subnet 192.168.1.0/24
|
|
113
|
+
redeploy scan --user pi --user ubuntu --timeout 8
|
|
114
|
+
"""
|
|
115
|
+
from ...discovery import discover, update_registry
|
|
116
|
+
from ...models import DeviceRegistry
|
|
117
|
+
|
|
118
|
+
console = Console()
|
|
119
|
+
console.print("[bold]redeploy scan[/bold] discovering devices...")
|
|
120
|
+
|
|
121
|
+
users = list(ssh_users) if ssh_users else None
|
|
122
|
+
found = discover(
|
|
123
|
+
subnet=subnet,
|
|
124
|
+
ssh_users=users,
|
|
125
|
+
ssh_port=ssh_port,
|
|
126
|
+
ping=ping,
|
|
127
|
+
mdns=not no_mdns,
|
|
128
|
+
probe_ssh=True,
|
|
129
|
+
timeout=timeout,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
ssh_ok = [h for h in found if h.ssh_ok]
|
|
133
|
+
console.print(f" found {len(found)} host(s), {len(ssh_ok)} SSH-accessible\n")
|
|
134
|
+
|
|
135
|
+
t = Table(show_header=True, box=None, padding=(0, 2))
|
|
136
|
+
t.add_column("IP")
|
|
137
|
+
t.add_column("Hostname", style="dim")
|
|
138
|
+
t.add_column("MAC", style="dim")
|
|
139
|
+
t.add_column("SSH user", style="cyan")
|
|
140
|
+
t.add_column("Source", style="dim")
|
|
141
|
+
for h in found:
|
|
142
|
+
ssh_col = f"[green]{h.ssh_user}[/green]" if h.ssh_ok else "[red]✗[/red]"
|
|
143
|
+
t.add_row(h.ip, h.hostname or "—", h.mac or "—", ssh_col, h.source)
|
|
144
|
+
console.print(t)
|
|
145
|
+
|
|
146
|
+
if not no_save and ssh_ok:
|
|
147
|
+
reg = update_registry(found, save=True)
|
|
148
|
+
console.print(
|
|
149
|
+
f"\n [dim]registry updated → {DeviceRegistry.default_path()}[/dim]"
|
|
150
|
+
)
|
|
151
|
+
console.print(f" [dim]{len(reg.devices)} device(s) total[/dim]")
|
|
152
|
+
elif not ssh_ok:
|
|
153
|
+
console.print("\n [dim]No SSH-accessible devices — nothing saved.[/dim]")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@click.command("device-add")
|
|
157
|
+
@click.argument("host")
|
|
158
|
+
@click.option("--id", "device_id", default=None, help="Device ID (default: host)")
|
|
159
|
+
@click.option("--name", default="", help="Human-friendly label")
|
|
160
|
+
@click.option("--tag", "tags", multiple=True, help="Tag (repeatable)")
|
|
161
|
+
@click.option(
|
|
162
|
+
"--strategy", default="docker_full", show_default=True,
|
|
163
|
+
type=click.Choice(["docker_full", "podman_quadlet", "native_kiosk", "docker_kiosk", "k3s", "systemd"]),
|
|
164
|
+
help="Deploy strategy"
|
|
165
|
+
)
|
|
166
|
+
@click.option("--app", default="", help="Application name")
|
|
167
|
+
@click.option("--port", "ssh_port", default=22, show_default=True)
|
|
168
|
+
@click.option("--key", "ssh_key", default=None, help="Path to SSH private key")
|
|
169
|
+
def device_add(host, device_id, name, tags, strategy, app, ssh_port, ssh_key):
|
|
170
|
+
"""Add or update a device in the registry.
|
|
171
|
+
|
|
172
|
+
\b
|
|
173
|
+
Example:
|
|
174
|
+
redeploy device-add pi@192.168.1.42 --tag kiosk --strategy native_kiosk --app kiosk-app
|
|
175
|
+
redeploy device-add root@10.0.0.5 --tag prod --strategy docker_full --app myapp
|
|
176
|
+
"""
|
|
177
|
+
from ...models import DeviceRegistry, KnownDevice
|
|
178
|
+
|
|
179
|
+
console = Console()
|
|
180
|
+
reg = DeviceRegistry.load()
|
|
181
|
+
|
|
182
|
+
did = device_id or host
|
|
183
|
+
dev = reg.get(did) or KnownDevice(id=did, host=host)
|
|
184
|
+
dev.host = host
|
|
185
|
+
if name:
|
|
186
|
+
dev.name = name
|
|
187
|
+
if tags:
|
|
188
|
+
dev.tags = list(tags)
|
|
189
|
+
dev.strategy = strategy
|
|
190
|
+
if app:
|
|
191
|
+
dev.app = app
|
|
192
|
+
dev.ssh_port = ssh_port
|
|
193
|
+
if ssh_key:
|
|
194
|
+
dev.ssh_key = ssh_key
|
|
195
|
+
dev.source = "manual"
|
|
196
|
+
|
|
197
|
+
reg.upsert(dev)
|
|
198
|
+
reg.save()
|
|
199
|
+
console.print(
|
|
200
|
+
f"[green]✓[/green] device [bold]{did}[/bold] saved → {DeviceRegistry.default_path()}"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@click.command("device-rm")
|
|
205
|
+
@click.argument("device_id")
|
|
206
|
+
def device_rm(device_id):
|
|
207
|
+
"""Remove a device from the registry."""
|
|
208
|
+
from ...models import DeviceRegistry
|
|
209
|
+
|
|
210
|
+
console = Console()
|
|
211
|
+
reg = DeviceRegistry.load()
|
|
212
|
+
if reg.remove(device_id):
|
|
213
|
+
reg.save()
|
|
214
|
+
console.print(f"[green]✓[/green] removed {device_id}")
|
|
215
|
+
else:
|
|
216
|
+
console.print(f"[yellow]⚠ not found: {device_id}[/yellow]")
|