homeconsole-cli 0.0.4__tar.gz → 0.0.6__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 (67) hide show
  1. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/PKG-INFO +1 -1
  2. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/core.py +3 -19
  3. homeconsole_cli-0.0.6/hc/commands/doctor.py +128 -0
  4. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/env.py +235 -0
  5. homeconsole_cli-0.0.6/hc/commands/status.py +111 -0
  6. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/main.py +4 -1
  7. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/repl.py +9 -0
  8. homeconsole_cli-0.0.6/hc/update_check.py +72 -0
  9. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/homeconsole_cli.egg-info/PKG-INFO +1 -1
  10. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/homeconsole_cli.egg-info/SOURCES.txt +2 -0
  11. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/pyproject.toml +1 -1
  12. homeconsole_cli-0.0.4/hc/commands/status.py +0 -64
  13. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/README.md +0 -0
  14. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/__init__.py +0 -0
  15. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/api.py +0 -0
  16. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/capabilities.py +0 -0
  17. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/client.py +0 -0
  18. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/__init__.py +0 -0
  19. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/_client_helpers.py +0 -0
  20. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/_compose_helpers.py +0 -0
  21. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/auth.py +0 -0
  22. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/connect.py +0 -0
  23. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/deploy.py +0 -0
  24. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/install.py +0 -0
  25. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/logs.py +0 -0
  26. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/marketplace.py +0 -0
  27. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/module.py +0 -0
  28. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/ping.py +0 -0
  29. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/plugin.py +0 -0
  30. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/recovery/__init__.py +0 -0
  31. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/recovery/compose.py +0 -0
  32. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/recovery/config.py +0 -0
  33. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/recovery/core.py +0 -0
  34. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/recovery/db.py +0 -0
  35. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/recovery/mode.py +0 -0
  36. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/recovery/redis.py +0 -0
  37. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/recovery/ui.py +0 -0
  38. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/remove.py +0 -0
  39. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/reset.py +0 -0
  40. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/search.py +0 -0
  41. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/secrets.py +0 -0
  42. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/setup.py +0 -0
  43. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/setup_wizard.py +0 -0
  44. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/commands/update.py +0 -0
  45. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/config.py +0 -0
  46. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/constants.py +0 -0
  47. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/core_ops.py +0 -0
  48. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/core_source.py +0 -0
  49. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/env_bootstrap.py +0 -0
  50. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/errors.py +0 -0
  51. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/marketplace_operation.py +0 -0
  52. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/native_core.py +0 -0
  53. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/setup_runner.py +0 -0
  54. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/hc/shell.py +0 -0
  55. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/homeconsole_cli.egg-info/dependency_links.txt +0 -0
  56. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/homeconsole_cli.egg-info/entry_points.txt +0 -0
  57. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/homeconsole_cli.egg-info/requires.txt +0 -0
  58. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/homeconsole_cli.egg-info/top_level.txt +0 -0
  59. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/setup.cfg +0 -0
  60. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/tests/test_config_roundtrip.py +0 -0
  61. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/tests/test_core_ops.py +0 -0
  62. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/tests/test_env_bootstrap.py +0 -0
  63. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/tests/test_main_root_callback.py +0 -0
  64. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/tests/test_marketplace_operation_parse.py +0 -0
  65. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/tests/test_native_core_helpers.py +0 -0
  66. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/tests/test_nav_cli.py +0 -0
  67. {homeconsole_cli-0.0.4 → homeconsole_cli-0.0.6}/tests/test_setup_runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: homeconsole-cli
3
- Version: 0.0.4
3
+ Version: 0.0.6
4
4
  Summary: HomeConsole CLI (hc) — управление платформой через HTTP API
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -1,7 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import subprocess
4
- import sys
5
3
  from pathlib import Path
6
4
 
7
5
  import typer
@@ -72,9 +70,9 @@ def register(app: typer.Typer) -> None:
72
70
  src = init_core_source(console, repo_url=repo, ref=ref)
73
71
  console.print(f"[green]✓[/green] Core исходники готовы: {src.path}")
74
72
 
75
- @core_app.command("pull-sources")
76
- def pull_sources() -> None:
77
- """Обновить локальную копию исходников Core (git pull). Раньше: `hc core update` только для git."""
73
+ @core_app.command("update")
74
+ def core_update() -> None:
75
+ """Обновить локальную копию исходников Core (git pull --ff-only)."""
78
76
  console = Console()
79
77
  src = update_core_source(console)
80
78
  console.print(f"[green]✓[/green] Обновлено: {src.path}")
@@ -259,19 +257,5 @@ def register(app: typer.Typer) -> None:
259
257
  raise typer.Exit(code=1)
260
258
  _docker_logs(console, follow=follow, tail=tail)
261
259
 
262
- @core_app.command("update")
263
- def core_runtime_update(
264
- tag: str = typer.Option("latest", "--tag", help="Тег образа"),
265
- wait: bool = typer.Option(True, "--wait/--no-wait"),
266
- quiet: bool = typer.Option(False, "--quiet"),
267
- ) -> None:
268
- """Обновить core-runtime до образа (алиас для `hc update core`)."""
269
- cmd = [sys.executable, "-m", "hc.main", "update", "core", "--tag", tag]
270
- if not wait:
271
- cmd.append("--no-wait")
272
- if quiet:
273
- cmd.append("--quiet")
274
- raise SystemExit(subprocess.run(cmd, check=False).returncode)
275
-
276
260
  app.add_typer(core_app, name="core")
277
261
 
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import socket
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from hc.config import Config
13
+ from hc.constants import CONFIG_PATH, CORE_SRC_DIR
14
+ from hc.core_source import COMPOSE_MODES
15
+
16
+
17
+ def register(app: typer.Typer) -> None:
18
+ @app.command("doctor")
19
+ def doctor() -> None:
20
+ """Диагностика системы: Docker, конфиг, исходники, порты, диск."""
21
+ console = Console()
22
+ table = Table(show_header=False, box=None, padding=(0, 1))
23
+ table.add_column(min_width=28)
24
+ table.add_column()
25
+ table.add_column(style="dim")
26
+
27
+ ok = "[green]✓[/green]"
28
+ warn = "[yellow]![/yellow]"
29
+ fail = "[red]✗[/red]"
30
+ issues: list[str] = []
31
+
32
+ def row(label: str, icon: str, detail: str = "") -> None:
33
+ table.add_row(label, icon, detail)
34
+
35
+ # ── Docker ────────────────────────────────────────────────────
36
+ docker_bin = shutil.which("docker")
37
+ if docker_bin:
38
+ ver = subprocess.run( # noqa: S603
39
+ ["docker", "version", "--format", "{{.Server.Version}}"],
40
+ capture_output=True, text=True, check=False, timeout=5,
41
+ )
42
+ docker_ver = ver.stdout.strip() or "?"
43
+ row("Docker", ok, f"v{docker_ver} ({docker_bin})")
44
+ else:
45
+ row("Docker", fail, "не найден — установи Docker или OrbStack")
46
+ issues.append("Docker не найден")
47
+
48
+ compose_ver = subprocess.run( # noqa: S603
49
+ ["docker", "compose", "version", "--short"],
50
+ capture_output=True, text=True, check=False, timeout=5,
51
+ )
52
+ if compose_ver.returncode == 0:
53
+ row("Docker Compose", ok, compose_ver.stdout.strip())
54
+ else:
55
+ row("Docker Compose", warn, "не определена версия")
56
+
57
+ git_bin = shutil.which("git")
58
+ if git_bin:
59
+ git_ver = subprocess.run( # noqa: S603
60
+ ["git", "--version"], capture_output=True, text=True, check=False
61
+ )
62
+ row("git", ok, git_ver.stdout.strip())
63
+ else:
64
+ row("git", warn, "не найден — нужен для hc core init/update")
65
+
66
+ # ── Config ────────────────────────────────────────────────────
67
+ table.add_row("")
68
+ if CONFIG_PATH.exists():
69
+ cfg = Config.load()
70
+ host_ok = bool(cfg.core.host.strip())
71
+ token_ok = bool(cfg.core.token.strip())
72
+ row("Конфиг (~/.config/hc)", ok, str(CONFIG_PATH))
73
+ row(" Core host", ok if host_ok else warn,
74
+ f"{cfg.core.host}:{cfg.core.port}" if host_ok else "не задан")
75
+ row(" Token", ok if token_ok else warn,
76
+ "задан" if token_ok else "не задан — запусти hc connect")
77
+ else:
78
+ row("Конфиг (~/.config/hc)", warn, "не найден — запусти hc connect или hc setup")
79
+ issues.append("Конфиг не найден")
80
+
81
+ # ── Core sources ──────────────────────────────────────────────
82
+ table.add_row("")
83
+ if CORE_SRC_DIR.exists():
84
+ row("Core исходники", ok, str(CORE_SRC_DIR))
85
+ for mode, rel in COMPOSE_MODES.items():
86
+ cf = CORE_SRC_DIR / rel
87
+ row(f" compose [{mode}]", ok if cf.exists() else warn,
88
+ rel if cf.exists() else f"{rel} ← не найден")
89
+ env_file = CORE_SRC_DIR / ".env"
90
+ row(" .env", ok if env_file.exists() else warn,
91
+ ".env готов" if env_file.exists() else "нет — будет создан при hc env up")
92
+ else:
93
+ row("Core исходники", warn, f"не найдены — запусти hc core init")
94
+ issues.append("Core исходники не найдены")
95
+
96
+ # ── Ports ─────────────────────────────────────────────────────
97
+ table.add_row("")
98
+ CHECK_PORTS = {18080: "UI (caddy)", 18000: "Core API", 5432: "PostgreSQL", 6379: "Redis"}
99
+ for port, label in CHECK_PORTS.items():
100
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
101
+ s.settimeout(0.3)
102
+ in_use = s.connect_ex(("127.0.0.1", port)) == 0
103
+ icon = ok if in_use else "[dim]—[/dim]"
104
+ row(f" :{port} {label}", icon, "listening" if in_use else "free")
105
+
106
+ # ── Disk ─────────────────────────────────────────────────────
107
+ table.add_row("")
108
+ try:
109
+ stat = shutil.disk_usage(Path.home())
110
+ free_gb = stat.free / 1024 ** 3
111
+ total_gb = stat.total / 1024 ** 3
112
+ used_pct = (stat.used / stat.total) * 100
113
+ disk_color = "red" if used_pct > 90 else "yellow" if used_pct > 75 else "green"
114
+ row("Диск (home)", f"[{disk_color}]{'!' if used_pct > 75 else '✓'}[/{disk_color}]",
115
+ f"{free_gb:.1f} GB свободно из {total_gb:.1f} GB ({used_pct:.0f}% занято)")
116
+ if used_pct > 90:
117
+ issues.append(f"Диск почти полон ({used_pct:.0f}%)")
118
+ except Exception:
119
+ pass
120
+
121
+ console.print(table)
122
+
123
+ if issues:
124
+ console.print()
125
+ for issue in issues:
126
+ console.print(f"[yellow]![/yellow] {issue}")
127
+ else:
128
+ console.print("\n[green]✓ Всё в порядке[/green]")
@@ -168,6 +168,32 @@ def _run(cmd: list[str], *, cwd: Path | None = None, extra_env: dict[str, str] |
168
168
  raise typer.Exit(code=p.returncode)
169
169
 
170
170
 
171
+ def _try_pull_source(src: CoreSource, console: Console) -> None:
172
+ """git pull --ff-only if the working tree is clean. Silently skips on dirty tree or any error."""
173
+ try:
174
+ status = subprocess.run( # noqa: S603
175
+ ["git", "status", "--porcelain"],
176
+ cwd=str(src.path),
177
+ capture_output=True,
178
+ text=True,
179
+ timeout=5,
180
+ )
181
+ if status.returncode != 0 or status.stdout.strip():
182
+ return # dirty or not a git repo — skip
183
+
184
+ pull = subprocess.run( # noqa: S603
185
+ ["git", "pull", "--ff-only", "--quiet"],
186
+ cwd=str(src.path),
187
+ capture_output=True,
188
+ text=True,
189
+ timeout=15,
190
+ )
191
+ if pull.returncode == 0 and pull.stdout.strip():
192
+ console.print(f"[dim]↑ core-runtime-service обновлён[/dim]")
193
+ except Exception: # noqa: BLE001
194
+ pass # network unavailable, timeout, etc. — not critical
195
+
196
+
171
197
  def _get_running_services(compose_file: Path, cwd: Path) -> set[str]:
172
198
  try:
173
199
  r = subprocess.run( # noqa: S603
@@ -424,6 +450,7 @@ def register(app: typer.Typer) -> None:
424
450
  raise typer.Exit(code=2)
425
451
 
426
452
  src = _resolve_source(console)
453
+ _try_pull_source(src, console)
427
454
  project = compose_project_from_source(console, src, mode=mode)
428
455
  running = _get_running_services(project.compose_file, project.cwd)
429
456
 
@@ -592,6 +619,59 @@ def register(app: typer.Typer) -> None:
592
619
  console.print(f"[dim]Подсказка:[/dim] {e.hint}")
593
620
  raise typer.Exit(code=int(e.exit_code or 1))
594
621
 
622
+ @env_app.command("rebuild")
623
+ def env_rebuild(
624
+ mode: str = typer.Option(_MODE_DEFAULT, "--mode", "-m", help=_MODE_HELP),
625
+ profile: str | None = typer.Option(None, "--profile", "-p", help=_PROFILE_HELP),
626
+ no_cache: bool = typer.Option(False, "--no-cache", help="Сборка без кэша Docker"),
627
+ ) -> None:
628
+ """
629
+ Пересобрать образы и перезапустить сервисы (интерактивный выбор).
630
+
631
+ Примеры:
632
+ hc env rebuild # интерактив
633
+ hc env rebuild --profile base # core + caddy без вопросов
634
+ hc env rebuild --no-cache # интерактив, без кэша
635
+ """
636
+ console = Console()
637
+ try:
638
+ require_docker(console)
639
+ mode = mode.strip().lower()
640
+ if mode not in _SERVICES:
641
+ console.print(f"[red]Ошибка:[/red] неизвестный режим {mode!r}. Допустимые: {' | '.join(_SERVICES)}")
642
+ raise typer.Exit(code=2)
643
+
644
+ src = _resolve_source(console)
645
+ project = compose_project_from_source(console, src, mode=mode)
646
+ running = _get_running_services(project.compose_file, project.cwd)
647
+
648
+ selected = _resolve_services(mode=mode, profile=profile, console=console, running=running)
649
+ service_names = [s.name for s in selected]
650
+
651
+ console.print(
652
+ f"\n[cyan]→[/cyan] env rebuild "
653
+ f"mode=[bold]{mode}[/bold] "
654
+ f"services=[bold]{', '.join(service_names)}[/bold]"
655
+ + (" [dim]--no-cache[/dim]" if no_cache else "")
656
+ )
657
+
658
+ base_cmd = ["docker", "compose", "-f", str(project.compose_file)]
659
+
660
+ build_cmd = [*base_cmd, "build"]
661
+ if no_cache:
662
+ build_cmd.append("--no-cache")
663
+ build_cmd += service_names
664
+ _run(build_cmd, cwd=project.cwd)
665
+
666
+ _run([*base_cmd, "up", "-d", *service_names], cwd=project.cwd)
667
+ console.print(f"[green]✓[/green] rebuild ok")
668
+
669
+ except HcCliError as e:
670
+ console.print(f"[red]Ошибка:[/red] {e.message}")
671
+ if e.hint:
672
+ console.print(f"[dim]Подсказка:[/dim] {e.hint}")
673
+ raise typer.Exit(code=int(e.exit_code or 1))
674
+
595
675
  @env_app.command("status")
596
676
  def env_status(
597
677
  mode: str = typer.Option(_MODE_DEFAULT, "--mode", "-m", help=_MODE_HELP),
@@ -617,4 +697,159 @@ def register(app: typer.Typer) -> None:
617
697
  console.print(f"[dim]Подсказка:[/dim] {e.hint}")
618
698
  raise typer.Exit(code=int(e.exit_code or 1))
619
699
 
700
+ @env_app.command("stats")
701
+ def env_stats(
702
+ mode: str = typer.Option(_MODE_DEFAULT, "--mode", "-m", help=_MODE_HELP),
703
+ watch: bool = typer.Option(False, "--watch", "-w", help="Обновлять каждые N секунд"),
704
+ interval: float = typer.Option(3.0, "--interval", "-n", help="Интервал обновления (сек)"),
705
+ ) -> None:
706
+ """CPU%, RAM, NET I/O контейнеров dev-окружения."""
707
+ import json
708
+ import time
709
+ from rich.live import Live
710
+ from rich.table import Table
711
+
712
+ console = Console()
713
+ try:
714
+ require_docker(console)
715
+ mode = mode.strip().lower()
716
+ src = _resolve_source(console)
717
+ project = compose_project_from_source(console, src, mode=mode)
718
+
719
+ def _stats_table() -> Table:
720
+ r = subprocess.run( # noqa: S603
721
+ ["docker", "compose", "-f", str(project.compose_file),
722
+ "stats", "--no-stream", "--format", "{{json .}}"],
723
+ cwd=str(project.cwd),
724
+ capture_output=True, text=True, check=False,
725
+ )
726
+ table = Table(show_header=True, header_style="bold cyan", box=None, padding=(0, 1))
727
+ table.add_column("Сервис")
728
+ table.add_column("CPU%", justify="right")
729
+ table.add_column("RAM", justify="right")
730
+ table.add_column("RAM%", justify="right")
731
+ table.add_column("NET I/O", justify="right")
732
+ table.add_column("BLOCK I/O",justify="right")
733
+ table.add_column("PIDs", justify="right")
734
+
735
+ for line in r.stdout.strip().splitlines():
736
+ try:
737
+ d = json.loads(line)
738
+ cpu_s = d.get("CPUPerc", "0%")
739
+ try:
740
+ cpu_f = float(cpu_s.rstrip("%"))
741
+ cpu_color = "red" if cpu_f > 80 else "yellow" if cpu_f > 40 else "green"
742
+ except ValueError:
743
+ cpu_color = "white"
744
+ table.add_row(
745
+ d.get("Name", "?"),
746
+ f"[{cpu_color}]{cpu_s}[/{cpu_color}]",
747
+ d.get("MemUsage", "?"),
748
+ d.get("MemPerc", "?"),
749
+ d.get("NetIO", "?"),
750
+ d.get("BlockIO", "?"),
751
+ d.get("PIDs", "?"),
752
+ )
753
+ except (json.JSONDecodeError, KeyError):
754
+ pass
755
+ return table
756
+
757
+ if watch:
758
+ with Live(refresh_per_second=1, screen=False) as live:
759
+ while True:
760
+ live.update(_stats_table())
761
+ time.sleep(interval)
762
+ else:
763
+ console.print(_stats_table())
764
+
765
+ except (KeyboardInterrupt, typer.Abort):
766
+ pass
767
+ except HcCliError as e:
768
+ console.print(f"[red]Ошибка:[/red] {e.message}")
769
+ if e.hint:
770
+ console.print(f"[dim]Подсказка:[/dim] {e.hint}")
771
+ raise typer.Exit(code=int(e.exit_code or 1))
772
+
773
+ @env_app.command("health")
774
+ def env_health(
775
+ mode: str = typer.Option(_MODE_DEFAULT, "--mode", "-m", help=_MODE_HELP),
776
+ ) -> None:
777
+ """Healthcheck статус каждого сервиса окружения."""
778
+ import json
779
+ from rich.table import Table
780
+
781
+ console = Console()
782
+ try:
783
+ require_docker(console)
784
+ mode = mode.strip().lower()
785
+ src = _resolve_source(console)
786
+ project = compose_project_from_source(console, src, mode=mode)
787
+
788
+ r = subprocess.run( # noqa: S603
789
+ ["docker", "compose", "-f", str(project.compose_file),
790
+ "ps", "--format", "json"],
791
+ cwd=str(project.cwd),
792
+ capture_output=True, text=True, check=False,
793
+ )
794
+
795
+ table = Table(show_header=True, header_style="bold cyan", box=None, padding=(0, 1))
796
+ table.add_column("Сервис")
797
+ table.add_column("Статус")
798
+ table.add_column("Health")
799
+ table.add_column("Порты")
800
+
801
+ _STATUS_COLOR = {"running": "green", "exited": "red", "paused": "yellow"}
802
+ _HEALTH_COLOR = {"healthy": "green", "unhealthy": "red",
803
+ "starting": "yellow", "none": "dim"}
804
+
805
+ rows: list[dict] = []
806
+ raw = r.stdout.strip()
807
+ if raw:
808
+ try:
809
+ parsed = json.loads(raw)
810
+ rows = parsed if isinstance(parsed, list) else [parsed]
811
+ except json.JSONDecodeError:
812
+ for line in raw.splitlines():
813
+ try:
814
+ rows.append(json.loads(line))
815
+ except json.JSONDecodeError:
816
+ pass
817
+
818
+ if not rows:
819
+ console.print("[yellow]Нет запущенных контейнеров.[/yellow]")
820
+ console.print(f"[dim]compose:[/dim] {project.compose_file}")
821
+ return
822
+
823
+ for row in rows:
824
+ name = row.get("Service") or row.get("Name") or "?"
825
+ state = str(row.get("State") or row.get("Status") or "?").lower()
826
+ health = str(row.get("Health") or "none").lower()
827
+ ports = row.get("Publishers") or row.get("Ports") or ""
828
+ if isinstance(ports, list):
829
+ ports = ", ".join(
830
+ f"{p.get('PublishedPort', '')}→{p.get('TargetPort', '')}"
831
+ for p in ports if p.get("PublishedPort")
832
+ )
833
+
834
+ sc = _STATUS_COLOR.get(state, "white")
835
+ hc_color = _HEALTH_COLOR.get(health, "white")
836
+ health_icon = {"healthy": "✓", "unhealthy": "✗",
837
+ "starting": "…", "none": "—"}.get(health, health)
838
+
839
+ table.add_row(
840
+ f"[bold]{name}[/bold]",
841
+ f"[{sc}]{state}[/{sc}]",
842
+ f"[{hc_color}]{health_icon} {health}[/{hc_color}]",
843
+ str(ports),
844
+ )
845
+
846
+ console.print(table)
847
+ console.print(f"\n[dim]compose:[/dim] {project.compose_file}")
848
+
849
+ except HcCliError as e:
850
+ console.print(f"[red]Ошибка:[/red] {e.message}")
851
+ if e.hint:
852
+ console.print(f"[dim]Подсказка:[/dim] {e.hint}")
853
+ raise typer.Exit(code=int(e.exit_code or 1))
854
+
620
855
  app.add_typer(env_app, name="env")
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ import anyio
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.live import Live
9
+ from rich.panel import Panel
10
+ from rich.text import Text
11
+
12
+ from hc.commands._client_helpers import require_client
13
+
14
+
15
+ def register(app: typer.Typer) -> None:
16
+ @app.command("status")
17
+ def status(
18
+ watch: bool = typer.Option(False, "--watch", "-w", help="Live-мониторинг (Ctrl+C для выхода)"),
19
+ interval: float = typer.Option(5.0, "--interval", "-n", help="Интервал обновления в секундах"),
20
+ ) -> None:
21
+ """Статус Core: версия, uptime, плагины, модули. С --watch — live-мониторинг."""
22
+ console = Console()
23
+ client = require_client(console)
24
+
25
+ latencies: list[float] = []
26
+
27
+ async def _fetch() -> tuple[dict | None, int | None, tuple[int, int] | None, float]:
28
+ t0 = time.monotonic()
29
+ health = await client.admin_status()
30
+ if not health:
31
+ health = await client.health()
32
+ latency_ms = (time.monotonic() - t0) * 1000
33
+
34
+ plugins = await client.get_plugins()
35
+ modules = await client.get_modules()
36
+
37
+ active_plugins = None
38
+ if isinstance(plugins, list):
39
+ active_plugins = sum(1 for p in plugins if str(p.get("status", "")).lower() == "running")
40
+
41
+ modules_stat = None
42
+ if isinstance(modules, list):
43
+ total = len(modules)
44
+ ok = sum(1 for m in modules if str(m.get("status", "")).lower() in {"running", "ok"})
45
+ modules_stat = (ok, total)
46
+
47
+ return health, active_plugins, modules_stat, latency_ms
48
+
49
+ def _build_panel(
50
+ health: dict | None,
51
+ active_plugins: int | None,
52
+ modules_stat: tuple[int, int] | None,
53
+ latency_ms: float,
54
+ ) -> Panel:
55
+ if not health:
56
+ return Panel(Text("Core недоступен", style="red"), title="HomeConsole", border_style="red")
57
+
58
+ latencies.append(latency_ms)
59
+ if len(latencies) > 10:
60
+ latencies.pop(0)
61
+
62
+ version = str(health.get("version", "unknown"))
63
+ status_value = str(health.get("status", "running"))
64
+ uptime = str(health.get("uptime", "unknown"))
65
+
66
+ status_text = Text(status_value)
67
+ if status_value.lower() in {"running", "ok"}:
68
+ status_text.stylize("green")
69
+ status_text = Text("✓ ") + status_text
70
+ else:
71
+ status_text.stylize("red")
72
+
73
+ lat_color = "green" if latency_ms < 100 else "yellow" if latency_ms < 500 else "red"
74
+ lat_avg = sum(latencies) / len(latencies)
75
+ lat_line = f"{latency_ms:.0f}ms [dim](avg {lat_avg:.0f}ms)[/dim]"
76
+
77
+ lines: list[Text] = [
78
+ Text.assemble(("Версия: ", "bold"), (version, "")),
79
+ Text.assemble(("Статус: ", "bold"), status_text),
80
+ Text.assemble(("Latency: ", "bold"), Text(lat_line, style=lat_color)),
81
+ ]
82
+ if active_plugins is not None:
83
+ lines.append(Text.assemble(("Плагинов: ", "bold"), (f"{active_plugins} активных", "")))
84
+ if modules_stat is not None:
85
+ ok, total = modules_stat
86
+ col = "green" if ok == total else "yellow"
87
+ lines.append(Text.assemble(("Модулей: ", "bold"), Text(f"{ok} / {total}", style=col)))
88
+ lines.append(Text.assemble(("Uptime: ", "bold"), (uptime, "")))
89
+
90
+ title = "HomeConsole"
91
+ if watch:
92
+ ts = time.strftime("%H:%M:%S")
93
+ title += f" [dim]{ts}[/dim]"
94
+
95
+ return Panel(Text("\n").join(lines), title=title, border_style="cyan")
96
+
97
+ if not watch:
98
+ health, active_plugins, modules_stat, latency_ms = anyio.run(_fetch)
99
+ console.print(_build_panel(health, active_plugins, modules_stat, latency_ms))
100
+ if not health:
101
+ raise typer.Exit(code=1)
102
+ return
103
+
104
+ try:
105
+ with Live(refresh_per_second=1, screen=False) as live:
106
+ while True:
107
+ health, active_plugins, modules_stat, latency_ms = anyio.run(_fetch)
108
+ live.update(_build_panel(health, active_plugins, modules_stat, latency_ms))
109
+ time.sleep(interval)
110
+ except (KeyboardInterrupt, typer.Abort):
111
+ pass
@@ -23,6 +23,7 @@ from hc.commands.status import register as register_status
23
23
  from hc.commands.deploy import register as register_deploy
24
24
  from hc.commands.update import register as register_update
25
25
  from hc.commands.ping import register as register_ping
26
+ from hc.commands.doctor import register as register_doctor
26
27
  from hc.commands.marketplace import register as register_marketplace
27
28
  from hc.commands.secrets import register as register_secrets
28
29
  from hc.shell import run_shell
@@ -70,7 +71,8 @@ _NAV_TREE: dict[str, dict[str, object]] = {
70
71
  "secrets": {"desc": "Управление секретами", "children": {}},
71
72
  "recovery": {"desc": "Recovery сценарии", "children": {}},
72
73
  "reset": {"desc": "Сброс состояний", "children": {}},
73
- "ping": {"desc": "Проверка доступности", "children": {}},
74
+ "ping": {"desc": "Проверка доступности", "children": {}},
75
+ "doctor": {"desc": "Диагностика системы (Docker, конфиг, порты, диск)", "children": {}},
74
76
  "marketplace": {"desc": "Маркетплейс", "children": {}},
75
77
  "repl": {"desc": "Интерактивный режим", "children": {}},
76
78
  "shell": {"desc": "Алиас для repl", "children": {}},
@@ -189,6 +191,7 @@ def _register_all() -> None:
189
191
  register_update(app)
190
192
  register_ping(app)
191
193
  register_marketplace(app)
194
+ register_doctor(app)
192
195
  register_secrets(app)
193
196
 
194
197
 
@@ -18,6 +18,7 @@ from hc import __version__
18
18
  from hc.config import Config
19
19
  from hc.constants import APP_NAME, HISTORY_PATH
20
20
  from hc.commands._client_helpers import require_client
21
+ from hc.update_check import get_update_notification
21
22
 
22
23
 
23
24
  _GROUPS = {"core", "auth", "setup", "plugin", "module", "reset", "recovery", "deploy", "update"}
@@ -298,6 +299,14 @@ def run_repl(app: typer.Typer) -> None:
298
299
  console.print(f"{APP_NAME} {__version__} | {status}")
299
300
  console.print("Type 'help' or '?' for commands, 'exit' to quit")
300
301
 
302
+ latest = get_update_notification(__version__)
303
+ if latest:
304
+ console.print(
305
+ f"[yellow]→ Доступна новая версия [bold]{latest}[/bold] "
306
+ f"(текущая {__version__})[/yellow]"
307
+ )
308
+ console.print("[dim] pipx upgrade homeconsole-cli | pip install --upgrade homeconsole-cli[/dim]")
309
+
301
310
  HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
302
311
  session = PromptSession(_prompt(None), history=FileHistory(str(HISTORY_PATH)), completer=completer)
303
312
 
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import time
6
+
7
+ from hc.constants import STATE_DIR
8
+
9
+ _CACHE_FILE = STATE_DIR / "version_check.json"
10
+ _TTL = 86400 # 24 hours
11
+ _PYPI_URL = "https://pypi.org/pypi/homeconsole-cli/json"
12
+
13
+
14
+ def _parse_ver(v: str) -> tuple[int, ...]:
15
+ try:
16
+ return tuple(int(x) for x in v.split(".") if x.isdigit())
17
+ except Exception:
18
+ return (0,)
19
+
20
+
21
+ def _is_newer(latest: str, current: str) -> bool:
22
+ return _parse_ver(latest) > _parse_ver(current)
23
+
24
+
25
+ def _read_cache() -> dict:
26
+ try:
27
+ return json.loads(_CACHE_FILE.read_text())
28
+ except Exception:
29
+ return {}
30
+
31
+
32
+ def _write_cache(latest: str) -> None:
33
+ try:
34
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
35
+ _CACHE_FILE.write_text(json.dumps({"ts": time.time(), "latest": latest}))
36
+ except Exception:
37
+ pass
38
+
39
+
40
+ def _fetch_and_cache() -> None:
41
+ """Background thread: fetch latest version from PyPI and cache it."""
42
+ try:
43
+ import httpx
44
+ with httpx.Client(timeout=5.0) as client:
45
+ resp = client.get(_PYPI_URL)
46
+ if resp.status_code == 200:
47
+ latest = resp.json()["info"]["version"]
48
+ _write_cache(latest)
49
+ except Exception:
50
+ pass
51
+
52
+
53
+ def get_update_notification(current: str) -> str | None:
54
+ """
55
+ Returns a notification string if a newer version is available, else None.
56
+ Uses a 24-hour cache — never blocks startup.
57
+ If cache is missing or expired, starts a background refresh for next session.
58
+ """
59
+ try:
60
+ data = _read_cache()
61
+ ts = data.get("ts", 0)
62
+ latest = data.get("latest", "")
63
+
64
+ if not latest or time.time() - ts > _TTL:
65
+ # Refresh in background — result visible next session
66
+ threading.Thread(target=_fetch_and_cache, daemon=True).start()
67
+
68
+ if latest and _is_newer(latest, current):
69
+ return latest
70
+ except Exception:
71
+ pass
72
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: homeconsole-cli
3
- Version: 0.0.4
3
+ Version: 0.0.6
4
4
  Summary: HomeConsole CLI (hc) — управление платформой через HTTP API
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -16,6 +16,7 @@ hc/native_core.py
16
16
  hc/repl.py
17
17
  hc/setup_runner.py
18
18
  hc/shell.py
19
+ hc/update_check.py
19
20
  hc/commands/__init__.py
20
21
  hc/commands/_client_helpers.py
21
22
  hc/commands/_compose_helpers.py
@@ -23,6 +24,7 @@ hc/commands/auth.py
23
24
  hc/commands/connect.py
24
25
  hc/commands/core.py
25
26
  hc/commands/deploy.py
27
+ hc/commands/doctor.py
26
28
  hc/commands/env.py
27
29
  hc/commands/install.py
28
30
  hc/commands/logs.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "homeconsole-cli"
3
- version = "0.0.4"
3
+ version = "0.0.6"
4
4
  description = "HomeConsole CLI (hc) — управление платформой через HTTP API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,64 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
-
5
- import anyio
6
- import typer
7
- from rich.console import Console
8
- from rich.panel import Panel
9
- from rich.text import Text
10
-
11
- from hc.commands._client_helpers import require_client
12
-
13
-
14
- def register(app: typer.Typer) -> None:
15
- @app.command("status")
16
- def status() -> None:
17
- console = Console()
18
- client = require_client(console)
19
-
20
- async def _run() -> tuple[dict | None, int | None, tuple[int, int] | None]:
21
- health = await client.admin_status()
22
- if not health:
23
- health = await client.health()
24
- plugins = await client.get_plugins()
25
- modules = await client.get_modules()
26
- active_plugins = None
27
- if isinstance(plugins, list):
28
- active_plugins = sum(1 for p in plugins if str(p.get("status", "")).lower() == "running")
29
- modules_stat = None
30
- if isinstance(modules, list):
31
- total = len(modules)
32
- ok = sum(1 for m in modules if str(m.get("status", "")).lower() in {"running", "ok"})
33
- modules_stat = (ok, total)
34
- return health, active_plugins, modules_stat
35
-
36
- health, active_plugins, modules_stat = anyio.run(_run)
37
- if not health:
38
- raise typer.Exit(code=1)
39
-
40
- version = str(health.get("version", "unknown"))
41
- status_value = str(health.get("status", "running"))
42
- uptime = str(health.get("uptime", "unknown"))
43
-
44
- status_text = Text(status_value)
45
- if status_value.lower() in {"running", "ok"}:
46
- status_text.stylize("green")
47
- status_text = Text("✓ ") + status_text
48
- else:
49
- status_text.stylize("red")
50
-
51
- lines: list[Text] = [
52
- Text.assemble(("Версия: ", "bold"), (version, "")),
53
- Text.assemble(("Статус: ", "bold"), status_text),
54
- ]
55
- if active_plugins is not None:
56
- lines.append(Text.assemble(("Плагинов: ", "bold"), (f"{active_plugins} активных", "")))
57
- if modules_stat is not None:
58
- ok, total = modules_stat
59
- lines.append(Text.assemble(("Модулей: ", "bold"), (f"{ok} / {total}", "")))
60
- lines.append(Text.assemble(("Uptime: ", "bold"), (uptime, "")))
61
-
62
- body = Text("\n").join(lines)
63
- console.print(Panel(body, title="HomeConsole", border_style="cyan"))
64
-