CLI UX overhaul / Uninstall site

This commit is contained in:
Vladless
2026-04-22 00:47:09 +00:00
parent 6151e25974
commit c6d316f4c8
Executable → Regular
+571 -117
View File
@@ -8,6 +8,8 @@ import subprocess
import sys
from contextlib import contextmanager
import time as time_mod
from datetime import datetime
from time import sleep
from urllib.error import HTTPError, URLError
@@ -317,6 +319,30 @@ if IS_ROOT_DIR:
os.execv(sys.executable, [sys.executable, _target_path, *sys.argv[1:]])
def run_with_status(
cmd,
*,
status_text: str,
cwd: str | None = None,
check: bool = False,
env: dict | None = None,
) -> subprocess.CompletedProcess:
with console.status(f"[bold cyan]{status_text}[/bold cyan]", spinner="dots"):
result = subprocess.run(
cmd, cwd=cwd, env=env, capture_output=True, text=True, check=False
)
if result.returncode != 0:
if result.stdout:
console.print(result.stdout)
if result.stderr:
console.print(f"[red]{result.stderr.rstrip()}[/red]")
if check:
raise subprocess.CalledProcessError(
result.returncode, cmd, result.stdout, result.stderr
)
return result
def is_service_exists(service_name):
result = subprocess.run(["systemctl", "list-unit-files", service_name], capture_output=True, text=True)
return service_name in result.stdout
@@ -345,9 +371,9 @@ def bootstrap_project_files(branch: str = "main") -> bool:
install_rsync_if_needed()
subprocess.run(["rm", "-rf", TEMP_DIR], check=False)
clone_result = subprocess.run(
clone_result = run_with_status(
["git", "clone", "--depth", "1", "--branch", branch, GITHUB_REPO, TEMP_DIR],
check=False,
status_text=f"Клонирование {GITHUB_REPO} (ветка {branch})",
)
if clone_result.returncode != 0:
console.print("[red]❌ Не удалось скачать проект из GitHub.[/red]")
@@ -368,7 +394,7 @@ def bootstrap_project_files(branch: str = "main") -> bool:
rsync_cmd.insert(2, "--exclude=modules")
rsync_cmd.insert(2, "--exclude=.git")
sync_result = subprocess.run(rsync_cmd, check=False)
sync_result = run_with_status(rsync_cmd, status_text="Распаковка файлов проекта")
subprocess.run(["rm", "-rf", TEMP_DIR], check=False)
if sync_result.returncode != 0:
console.print("[red]❌ Не удалось распаковать файлы проекта.[/red]")
@@ -405,8 +431,12 @@ def install_core_packages_if_needed():
unique_packages = list(dict.fromkeys(missing_packages))
console.print(f"[yellow]Устанавливаю системные пакеты: {', '.join(unique_packages)}[/yellow]")
subprocess.run(["sudo", "apt", "update"], check=True)
subprocess.run(["sudo", "apt", "install", "-y", *unique_packages], check=True)
run_with_status(["sudo", "apt", "update"], status_text="apt update", check=True)
run_with_status(
["sudo", "apt", "install", "-y", *unique_packages],
status_text=f"Установка: {', '.join(unique_packages)}",
check=True,
)
def build_systemd_service() -> str:
@@ -635,7 +665,7 @@ def prune_old_backups():
subprocess.run(["sudo", "rm", "-rf", path])
def backup_project():
def backup_project() -> str | None:
from datetime import datetime
os.makedirs(BACK_DIR, exist_ok=True)
@@ -643,9 +673,38 @@ def backup_project():
dst = os.path.join(BACK_DIR, f"backup-{ts}")
console.print("[yellow]Создаётся резервная копия проекта...[/yellow]")
with console.status("[bold cyan]Копирование файлов...[/bold cyan]"):
subprocess.run(["cp", "-r", PROJECT_DIR, dst])
result = subprocess.run(["cp", "-r", PROJECT_DIR, dst], check=False)
if result.returncode != 0:
console.print("[red]❌ Не удалось создать бэкап[/red]")
return None
console.print(f"[green]Бэкап сохранён в: {dst}[/green]")
prune_old_backups()
return dst
def _restore_backup_unattended(backup_path: str) -> bool:
if not backup_path or not os.path.isdir(backup_path):
return False
if is_service_exists(SERVICE_NAME):
subprocess.run(["sudo", "systemctl", "stop", SERVICE_NAME], check=False)
install_rsync_if_needed()
result = run_with_status(
["rsync", "-a", "--delete", f"{backup_path}/", f"{PROJECT_DIR}/"],
status_text="Откат из бэкапа",
)
return result.returncode == 0
def _build_update_rsync_excludes(update_buttons: bool, update_img: bool, update_redis_cache: bool) -> list[str]:
excludes = []
if not update_img:
excludes.append("--exclude=img")
if not update_buttons:
excludes.append("--exclude=handlers/buttons.py")
if not update_redis_cache:
excludes.append("--exclude=core/redis_cache.py")
excludes.append("--exclude=modules")
return excludes
def restore_from_backup():
@@ -1007,62 +1066,81 @@ def update_from_beta():
update_img = safe_confirm("[yellow]Обновлять папку img?[/yellow]", default=False)
update_redis_cache = safe_confirm("[yellow]Обновлять файл core/redis_cache.py?[/yellow]", default=False)
backup_project()
backup_path = backup_project()
if not backup_path and not safe_confirm(
"[yellow]Бэкап не создан. Продолжить обновление БЕЗ бэкапа?[/yellow]", default=False
):
return
install_git_if_needed()
install_rsync_if_needed()
os.chdir(PROJECT_DIR)
console.print("[cyan]Клонируем временный репозиторий...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR])
try:
os.chdir(PROJECT_DIR)
subprocess.run(["rm", "-rf", TEMP_DIR])
if os.system(f"git clone --depth=1000000 -b dev {GITHUB_REPO} {TEMP_DIR}") != 0:
console.print("[red]❌ Ошибка при клонировании. Обновление отменено.[/red]")
return
clone_result = run_with_status(
["git", "clone", "--depth=1000000", "-b", "dev", GITHUB_REPO, TEMP_DIR],
status_text=f"Клонирование dev-ветки {GITHUB_REPO}",
)
if clone_result.returncode != 0:
raise RuntimeError("git clone dev не удался")
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
clean_project_dir_safe(
update_buttons=update_buttons,
update_img=update_img,
update_redis_cache=update_redis_cache,
)
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
clean_project_dir_safe(
update_buttons=update_buttons,
update_img=update_img,
update_redis_cache=update_redis_cache,
)
exclude_options = ""
if not update_img:
exclude_options += "--exclude=img "
if not update_buttons:
exclude_options += "--exclude=handlers/buttons.py "
if not update_redis_cache:
exclude_options += "--exclude=core/redis_cache.py "
exclude_options += "--exclude=modules "
rsync_cmd = (
["rsync", "-a"]
+ _build_update_rsync_excludes(update_buttons, update_img, update_redis_cache)
+ [f"{TEMP_DIR}/", f"{PROJECT_DIR}/"]
)
rsync_result = run_with_status(rsync_cmd, status_text="Применение обновления (rsync)")
if rsync_result.returncode != 0:
raise RuntimeError("rsync обновления не удался")
rsync_cmd = ["rsync", "-a"] + [x for x in exclude_options.split() if x] + [f"{TEMP_DIR}/", f"{PROJECT_DIR}/"]
subprocess.run(rsync_cmd)
modules_path = os.path.join(PROJECT_DIR, "modules")
if not os.path.exists(modules_path):
try:
os.makedirs(modules_path, exist_ok=True)
except Exception:
pass
modules_path = os.path.join(PROJECT_DIR, "modules")
if not os.path.exists(modules_path):
console.print("[yellow]Папка modules отсутствует — создаю вручную...[/yellow]")
try:
os.makedirs(modules_path, exist_ok=True)
console.print("[green]Папка modules успешно создана.[/green]")
except Exception as e:
console.print(f"[red]❌ Не удалось создать папку modules: {e}[/red]")
if os.path.exists(os.path.join(TEMP_DIR, ".git")):
subprocess.run(["cp", "-r", os.path.join(TEMP_DIR, ".git"), PROJECT_DIR])
if os.path.exists(os.path.join(TEMP_DIR, ".git")):
subprocess.run(["cp", "-r", os.path.join(TEMP_DIR, ".git"), PROJECT_DIR])
subprocess.run(["rm", "-rf", TEMP_DIR])
subprocess.run(["rm", "-rf", TEMP_DIR])
install_dependencies()
fix_permissions()
restart_service()
console.print("[green]Обновление с ветки dev завершено.[/green]")
install_dependencies()
fix_permissions()
restart_service()
console.print("[green]Обновление с ветки dev завершено.[/green]")
except Exception as e:
console.print(f"[red]❌ Обновление упало: {e}[/red]")
if backup_path and safe_confirm(
"[yellow]Откатить проект из свежего бэкапа?[/yellow]", default=True
):
if _restore_backup_unattended(backup_path):
console.print(f"[green]✓ Проект восстановлен из {backup_path}[/green]")
restart_service()
else:
console.print(
f"[red]Автооткат не удался. Восстановите вручную: пункт 8 меню → {backup_path}[/red]"
)
else:
console.print(
f"[yellow]Для ручного отката: пункт 8 меню → {backup_path or 'нет бэкапа'}[/yellow]"
)
def _do_update_to_tag(tag_name: str, update_buttons: bool, update_img: bool, update_redis_cache: bool) -> None:
"""Общая логика обновления до указанного тега (релиз или произвольный тег)."""
subprocess.run(["rm", "-rf", TEMP_DIR])
subprocess.run(
run_with_status(
["git", "clone", "--branch", tag_name, "--depth", "1", GITHUB_REPO, TEMP_DIR],
status_text=f"Клонирование тега {tag_name}",
check=True,
)
@@ -1074,17 +1152,14 @@ def _do_update_to_tag(tag_name: str, update_buttons: bool, update_img: bool, upd
update_redis_cache=update_redis_cache,
)
exclude_options = ""
if not update_img:
exclude_options += "--exclude=img "
if not update_buttons:
exclude_options += "--exclude=handlers/buttons.py "
if not update_redis_cache:
exclude_options += "--exclude=core/redis_cache.py "
exclude_options += "--exclude=modules "
rsync_cmd = ["rsync", "-a"] + exclude_options.split() + [f"{TEMP_DIR}/", f"{PROJECT_DIR}/"]
subprocess.run(rsync_cmd)
rsync_cmd = (
["rsync", "-a"]
+ _build_update_rsync_excludes(update_buttons, update_img, update_redis_cache)
+ [f"{TEMP_DIR}/", f"{PROJECT_DIR}/"]
)
rsync_result = run_with_status(rsync_cmd, status_text=f"Применение тега {tag_name} (rsync)")
if rsync_result.returncode != 0:
raise RuntimeError(f"rsync тега {tag_name} не удался")
modules_path = os.path.join(PROJECT_DIR, "modules")
if not os.path.exists(modules_path):
@@ -1119,7 +1194,11 @@ def update_from_release():
update_img = safe_confirm("[yellow]Обновлять папку img?[/yellow]", default=False)
update_redis_cache = safe_confirm("[yellow]Обновлять файл core/redis_cache.py?[/yellow]", default=False)
backup_project()
backup_path = backup_project()
if not backup_path and not safe_confirm(
"[yellow]Бэкап не создан. Продолжить обновление БЕЗ бэкапа?[/yellow]", default=False
):
return
install_git_if_needed()
install_rsync_if_needed()
@@ -1162,11 +1241,24 @@ def update_from_release():
if not safe_confirm(f"[yellow]Установить {tag_name}?[/yellow]"):
return
console.print(f"[cyan]Клонируем {tag_name} во временную папку...[/cyan]")
_do_update_to_tag(tag_name, update_buttons, update_img, update_redis_cache)
except Exception as e:
console.print(f"[red]❌ Ошибка при обновлении: {e}[/red]")
if backup_path and safe_confirm(
"[yellow]Откатить проект из свежего бэкапа?[/yellow]", default=True
):
if _restore_backup_unattended(backup_path):
console.print(f"[green]✓ Проект восстановлен из {backup_path}[/green]")
restart_service()
else:
console.print(
f"[red]Автооткат не удался. Восстановите вручную: пункт 8 меню → {backup_path}[/red]"
)
else:
console.print(
f"[yellow]Для ручного отката: пункт 8 меню → {backup_path or 'нет бэкапа'}[/yellow]"
)
WEB_IMAGE_REPO = "ghcr.io/vladless/solo-brick"
@@ -1462,14 +1554,57 @@ def _ensure_docker():
return False
def _port_owner(port: int) -> str | None:
try:
result = subprocess.run(
["ss", "-ltnp", f"sport = :{port}"],
capture_output=True, text=True, timeout=3,
)
out = (result.stdout or "").strip()
if result.returncode == 0 and "LISTEN" in out:
lines = [l for l in out.splitlines() if "LISTEN" in l]
if lines:
match = re.search(r'users:\(\("([^"]+)"', lines[0])
return match.group(1) if match else "занят"
except Exception:
return None
return None
def _check_http_ports_free() -> bool:
conflicts = []
for port in (80, 443):
owner = _port_owner(port)
if owner and owner != "nginx":
conflicts.append(f"{port}{owner}")
if not conflicts:
return True
console.print(
Panel(
"[white]Порты HTTP/HTTPS заняты не-nginx процессом:[/white]\n"
+ "\n".join(f" • [bold]{c}[/bold]" for c in conflicts)
+ "\n\n[white]Остановите конфликтующий процесс и повторите.[/white]",
border_style="red",
title="[bold red]Порты заняты[/bold red]",
padding=(1, 2),
)
)
return False
def _ensure_nginx():
"""Проверяет/устанавливает nginx."""
if not _check_http_ports_free():
return False
if shutil.which("nginx"):
return True
console.print("[cyan]Установка nginx...[/cyan]")
try:
subprocess.run(["sudo", "apt-get", "update", "-qq"], check=True, stdout=subprocess.DEVNULL)
subprocess.run(["sudo", "apt-get", "install", "-y", "-qq", "nginx"], check=True, stdout=subprocess.DEVNULL)
run_with_status(["sudo", "apt-get", "update"], status_text="apt update", check=True)
run_with_status(
["sudo", "apt-get", "install", "-y", "nginx"],
status_text="Установка nginx",
check=True,
)
subprocess.run(["sudo", "systemctl", "enable", "nginx"], check=False)
subprocess.run(["sudo", "systemctl", "start", "nginx"], check=False)
return True
@@ -1478,6 +1613,97 @@ def _ensure_nginx():
return False
def _public_ip() -> str | None:
for url in ("https://api.ipify.org", "https://ifconfig.me/ip", "https://icanhazip.com"):
try:
response = http_get(url, timeout=5)
ip = (response.text or "").strip()
if response.status_code == 200 and ip:
return ip
except Exception:
continue
return None
def _resolve_domain_ip(domain: str) -> str | None:
try:
import socket
infos = socket.getaddrinfo(domain, None, socket.AF_INET, socket.SOCK_STREAM)
if infos:
return infos[0][4][0]
except Exception:
return None
return None
def _dns_precheck(domain: str) -> bool:
console.print(f"[dim]Проверяю DNS для {domain}...[/dim]")
resolved = _resolve_domain_ip(domain)
if not resolved:
console.print(
Panel(
f"[white]DNS-имя [bold]{domain}[/bold] не резолвится в IP.[/white]\n"
"[white]Добавьте A-запись в DNS и дождитесь пропагации (5–30 мин).[/white]",
border_style="red",
title="[bold red]DNS не настроен[/bold red]",
padding=(1, 2),
)
)
return False
local = _public_ip()
if local and resolved != local:
console.print(
Panel(
f"[white]DNS [bold]{domain}[/bold] указывает на [yellow]{resolved}[/yellow],[/white]\n"
f"[white]а этот сервер имеет IP [yellow]{local}[/yellow].[/white]\n\n"
"[white]Поправьте A-запись, дождитесь пропагации и повторите.[/white]",
border_style="red",
title="[bold red]DNS указывает не на этот сервер[/bold red]",
padding=(1, 2),
)
)
return False
console.print(f"[green]✓ DNS ок: {domain}{resolved}[/green]")
return True
def _wait_for_web_container(web_port: int, timeout_sec: int = 60) -> bool:
import socket
deadline = time_mod.time() + timeout_sec
with console.status(f"[bold cyan]Ожидание контейнера на :{web_port}...[/bold cyan]", spinner="dots"):
while time_mod.time() < deadline:
try:
with socket.create_connection(("127.0.0.1", web_port), timeout=2):
return True
except Exception:
sleep(2)
return False
def _check_bot_api_reachable(api_url: str) -> bool:
probe = api_url.rstrip("/") + "/health"
console.print(f"[dim]Проверяю доступность API: {probe}[/dim]")
try:
response = http_get(probe, timeout=5)
if 200 <= response.status_code < 500:
console.print(f"[green]✓ API отвечает ({response.status_code})[/green]")
return True
console.print(f"[yellow]API ответил {response.status_code}[/yellow]")
return False
except Exception as e:
console.print(
Panel(
f"[white]API [bold]{api_url}[/bold] недоступен: {e}[/white]\n\n"
f"[white]Проверьте: DNS, nginx, SSL, firewall, бот запущен.[/white]",
border_style="red",
title="[bold red]Bot API недоступен[/bold red]",
padding=(1, 2),
)
)
return False
def _web_nginx_snippet(domain: str, web_port: int) -> str:
"""Locations для веб-приложения — можно вставить в существующий server-блок."""
return f""" # --- Solo web-app ({domain}) ---
@@ -1574,12 +1800,14 @@ def _setup_nginx(domain, web_port=3000):
def _setup_ssl(domain):
"""Получает SSL сертификат через certbot."""
if not _dns_precheck(domain):
return False
if not shutil.which("certbot"):
try:
subprocess.run(
["sudo", "apt-get", "install", "-y", "-qq", "certbot", "python3-certbot-nginx"],
run_with_status(
["sudo", "apt-get", "install", "-y", "certbot", "python3-certbot-nginx"],
status_text="Установка certbot",
check=True,
stdout=subprocess.DEVNULL,
)
except subprocess.CalledProcessError:
console.print("[yellow]Не удалось установить certbot.[/yellow]")
@@ -1587,22 +1815,26 @@ def _setup_ssl(domain):
try:
subprocess.run(
[
"sudo",
"certbot",
"--nginx",
"-d",
domain,
"--non-interactive",
"--agree-tos",
"--register-unsafely-without-email",
"--redirect",
"sudo", "certbot", "--nginx", "-d", domain,
"--non-interactive", "--agree-tos",
"--register-unsafely-without-email", "--redirect",
],
check=True,
)
return True
except subprocess.CalledProcessError:
console.print(f"[yellow]Не удалось получить SSL. Убедитесь что {domain} указывает на этот сервер.[/yellow]")
console.print(f"[dim]Повторите вручную: sudo certbot --nginx -d {domain}[/dim]")
console.print(
Panel(
f"[white]Сертификат не удалось выпустить. Причина обычно —[/white]\n"
f"[white]DNS [bold]{domain}[/bold] ещё не указывает на сервер, либо порт 80/443 закрыт.[/white]\n\n"
f"[yellow]Сайт без SSL открывать нельзя.[/yellow] После пропагации DNS:\n"
f" 1. [bold]dig +short {domain}[/bold]\n"
f" 2. [bold]sudo certbot --nginx -d {domain}[/bold]",
border_style="yellow",
title="[bold yellow]⚠ SSL отложен[/bold yellow]",
padding=(1, 2),
)
)
return False
@@ -1643,27 +1875,35 @@ def install_website():
console.print("[dim]Введите логин и пароль от вашего кабинета на сайте Solo.[/dim]")
console.print("[dim]Данные используются только для проверки лицензии и нигде не сохраняются.[/dim]\n")
lc_code = safe_prompt("[cyan]Логин (Client Code)[/cyan]")
if not lc_code or not lc_code.strip():
console.print("[red]Логин обязателен.[/red]")
return
max_attempts = 3
for attempt in range(1, max_attempts + 1):
lc_code = safe_prompt("[cyan]Логин (Client Code)[/cyan]")
if not lc_code or not lc_code.strip():
console.print("[red]Логин обязателен.[/red]")
return
try:
import getpass
lc_pass = getpass.getpass(" Пароль: ")
except Exception:
lc_pass = safe_prompt("[cyan]Пароль[/cyan]")
if not lc_pass or not lc_pass.strip():
console.print("[red]Пароль обязателен.[/red]")
return
try:
import getpass
lc_pass = getpass.getpass(" Пароль: ")
except Exception:
lc_pass = safe_prompt("[cyan]Пароль[/cyan]")
if not lc_pass or not lc_pass.strip():
console.print("[red]Пароль обязателен.[/red]")
return
ok = _authorize_web_install(lc_code.strip(), lc_pass.strip())
lc_code = None
lc_pass = None
if not ok:
return
ok = _authorize_web_install(lc_code.strip(), lc_pass.strip())
lc_code = None
lc_pass = None
if ok:
break
if attempt < max_attempts:
console.print(f"[yellow]Попытка {attempt}/{max_attempts} не прошла.[/yellow]")
if not safe_confirm("[cyan]Повторить ввод?[/cyan]", default=True):
return
else:
console.print(
"[red]Исчерпаны попытки авторизации. Проверьте логин/пароль на сайте Solo и повторите установку.[/red]"
)
return
console.print("\n[bold][1/5] Docker[/bold]")
if not _ensure_docker():
@@ -1734,6 +1974,12 @@ def install_website():
if not safe_confirm("[cyan]Всё настроено на сервере бота?[/cyan]", default=True):
console.print("[yellow]Настройте сервер бота и повторите установку.[/yellow]")
return
if not _check_bot_api_reachable(api_url):
if not safe_confirm(
"[yellow]API недоступен. Продолжить всё равно (сайт не заработает без API)?[/yellow]",
default=False,
):
return
console.print(
"\n[dim]Внутренний порт, на котором запустится сайт.\nNginx проксирует на него запросы. Менять нужно только если порт занят.[/dim]"
@@ -1758,22 +2004,40 @@ def install_website():
else:
vapid_pub, vapid_priv = pair
vapid_key = vapid_pub
vapid_file = os.path.expanduser(f"~/.solobot_vapid_{domain}.txt")
vapid_saved = True
try:
with open(vapid_file, "w", encoding="utf-8") as f:
f.write(
f"VAPID keypair for {domain}\n"
f"Сгенерировано: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
f"VAPID_PUBLIC_KEY = {vapid_pub}\n"
f"VAPID_PRIVATE_KEY = {vapid_priv}\n"
f"VAPID_CLAIMS_EMAIL = mailto:admin@{domain}\n\n"
"Вставьте VAPID_PRIVATE_KEY и VAPID_CLAIMS_EMAIL в config.py бота и перезапустите.\n"
)
os.chmod(vapid_file, 0o600)
except Exception:
vapid_saved = False
saved_hint = (
f"[green]✓ Ключи сохранены в файл:[/green] [bold]{vapid_file}[/bold] [dim](chmod 600)[/dim]\n"
if vapid_saved
else "[red]⚠ Не удалось записать файл — скопируйте ключи из этой панели СЕЙЧАС.[/red]\n"
)
console.print(
Panel(
f"[bold]VAPID_PUBLIC_KEY[/bold] = {vapid_pub}\n"
f"[bold]VAPID_PRIVATE_KEY[/bold] = {vapid_priv}\n"
f"[bold]VAPID_CLAIMS_EMAIL[/bold] = mailto:admin@{domain}\n\n"
f"{saved_hint}"
"[yellow]Публичный ключ CLI пропишет в web .env автоматически.\n"
"Приватный ключ и email добавьте в config.py бота (VAPID_PRIVATE_KEY, VAPID_CLAIMS_EMAIL)\n"
"и перезапустите бота — иначе push слать будет нечем.[/yellow]",
"и перезапустите бота.[/yellow]",
border_style="yellow",
title="[bold yellow]VAPID keypair — сохраните приватный ключ[/bold yellow]",
title="[bold yellow]VAPID keypair[/bold yellow]",
padding=(1, 2),
)
)
if not safe_confirm("[cyan]Сохранили приватный ключ?[/cyan]", default=True):
console.print("[yellow]Повторите установку после сохранения ключа.[/yellow]")
return
elif vapid_action == "2":
vapid_key = safe_prompt("[cyan]VAPID Public Key[/cyan]", default="")
@@ -1901,7 +2165,20 @@ services:
_ensure_web_logs_dir()
console.print("[cyan]Запуск контейнера...[/cyan]")
subprocess.run(["docker", "compose", "up", "-d"], cwd=WEB_DIR, check=True)
console.print(f"[green]✅ Контейнер запущен на порту {web_port}[/green]")
if _wait_for_web_container(int(web_port), timeout_sec=60):
console.print(f"[green]✅ Контейнер запущен и отвечает на порту {web_port}[/green]")
else:
console.print(
Panel(
f"[white]Контейнер запущен, но не отвечает на http://127.0.0.1:{web_port} за 60 сек.[/white]\n"
f"[white]Проверьте логи:[/white]\n"
f" [bold]cd {WEB_DIR} && docker compose logs -f[/bold]",
border_style="yellow",
title="[bold yellow]⚠ Healthcheck не прошёл[/bold yellow]",
padding=(1, 2),
)
)
console.print("\n[bold][4/5] Nginx[/bold]")
nginx_configured = False
@@ -1929,14 +2206,18 @@ services:
_print_manual_nginx_hint(domain, int(web_port))
console.print("\n[bold][5/5] SSL[/bold]")
ssl_deferred = False
if setup_ssl and not nginx_configured:
console.print("[yellow]SSL пропущен: автоконфигурация certbot --nginx требует автонастройки nginx.[/yellow]")
console.print("[dim]После ручной правки nginx запустите: sudo certbot --nginx -d " + domain + "[/dim]")
ssl_deferred = True
setup_ssl = False
if setup_ssl:
if _setup_ssl(domain):
console.print("[green]✅ SSL сертификат установлен[/green]")
else:
else:
ssl_deferred = True
elif not ssl_deferred:
console.print("[dim]SSL пропущен[/dim]")
smtp_hint = ""
@@ -1950,22 +2231,145 @@ services:
f"[dim] После правки перезапустите бота.[/dim]"
)
if ssl_deferred:
header = (
f"[bold yellow]Сайт собран, но SSL ещё не получен.[/bold yellow]\n"
f"[white]Откроется по [bold]{site_url}[/bold] только после выпуска сертификата.[/white]\n\n"
f"[cyan]Что сделать:[/cyan]\n"
f" 1. [bold]dig +short {domain}[/bold] — должен вернуть IP этого сервера\n"
f" 2. [bold]sudo certbot --nginx -d {domain}[/bold]"
)
border = "yellow"
title = "[bold yellow]⚠ Установка почти завершена[/bold yellow]"
else:
header = f"[bold green]Сайт доступен: {site_url}[/bold green]"
border = "green"
title = "[bold green]✅ Установка завершена[/bold green]"
console.print(
Panel(
f"[bold green]Сайт доступен: {site_url}[/bold green]{smtp_hint}{bot_note}\n\n"
f"{header}{smtp_hint}{bot_note}\n\n"
f"[white]Управление:[/white]\n"
f" cd {WEB_DIR}\n"
f" docker compose logs -f [dim]— логи[/dim]\n"
f" docker compose restart [dim]— перезапуск[/dim]\n"
f" docker compose down [dim]— остановка[/dim]\n"
f" nano .env [dim]— настройки[/dim]",
border_style="green",
title="[bold green]✅ Установка завершена[/bold green]",
border_style=border,
title=title,
padding=(1, 2),
)
)
def _read_env_domain() -> str | None:
env_path = os.path.join(WEB_DIR, ".env")
if not os.path.isfile(env_path):
return None
try:
with open(env_path, encoding="utf-8") as f:
for line in f:
if line.startswith("NEXT_PUBLIC_SITE_URL="):
url = line.split("=", 1)[1].strip()
return url.replace("https://", "").replace("http://", "").strip("/") or None
except Exception:
return None
return None
def _web_container_status() -> str:
try:
result = subprocess.run(
["docker", "compose", "ps", "--format", "{{.State}}"],
cwd=WEB_DIR, capture_output=True, text=True, timeout=5,
)
states = [s.strip() for s in (result.stdout or "").splitlines() if s.strip()]
if not states:
return "[dim]не запущен[/dim]"
running = sum(1 for s in states if s.lower() == "running")
total = len(states)
if running == total:
return f"[green]running ({running}/{total})[/green]"
return f"[yellow]{running}/{total} running[/yellow]"
except Exception:
return "[dim]статус неизвестен[/dim]"
def uninstall_website():
if not os.path.exists(WEB_DIR):
console.print("[yellow]Сайт не установлен (папка отсутствует).[/yellow]")
return
domain = _read_env_domain()
console.print(
Panel(
f"[bold red]Вы собираетесь полностью удалить сайт.[/bold red]\n\n"
f"[white]Будет удалено:[/white]\n"
f" • Docker-контейнеры и volumes (данные кабинета)\n"
f" • Docker-образ {_web_image(_get_saved_web_tag())}\n"
f" • Папка проекта [bold]{WEB_DIR}[/bold] (.env, логи)\n"
+ (f" • Nginx-конфиг [bold]/etc/nginx/sites-*/solo-{domain}[/bold]\n" if domain else "")
+ (f" • SSL-сертификат для [bold]{domain}[/bold]\n" if domain else "")
+ "\n[yellow]Действие необратимо. Рекомендуется сделать бэкап БД заранее.[/yellow]",
border_style="red",
title="[bold red]⚠ Удаление сайта[/bold red]",
padding=(1, 2),
)
)
if not safe_confirm("[bold red]Продолжить удаление?[/bold red]", default=False):
return
confirm_text = safe_prompt(
"[red]Введите [bold]DELETE[/bold] заглавными чтобы подтвердить[/red]",
default="",
)
if confirm_text.strip() != "DELETE":
console.print("[yellow]Удаление отменено.[/yellow]")
return
if os.path.exists(os.path.join(WEB_DIR, "docker-compose.yml")):
run_with_status(
["docker", "compose", "down", "-v", "--remove-orphans"],
status_text="Остановка и удаление контейнеров",
cwd=WEB_DIR,
)
try:
tag = _get_saved_web_tag()
run_with_status(
["docker", "image", "rm", "-f", _web_image(tag)],
status_text=f"Удаление образа {_web_image(tag)}",
)
except Exception:
pass
if domain:
for path in (
f"/etc/nginx/sites-enabled/solo-{domain}",
f"/etc/nginx/sites-available/solo-{domain}",
):
subprocess.run(["sudo", "rm", "-f", path], check=False)
subprocess.run(["sudo", "systemctl", "reload", "nginx"], check=False)
if shutil.which("certbot"):
subprocess.run(
["sudo", "certbot", "delete", "--non-interactive", "--cert-name", domain],
check=False,
)
subprocess.run(["sudo", "rm", "-rf", WEB_DIR], check=False)
if domain:
vapid_file = os.path.expanduser(f"~/.solobot_vapid_{domain}.txt")
if os.path.exists(vapid_file):
try:
os.remove(vapid_file)
except Exception:
pass
console.print("[green]✅ Сайт удалён.[/green]")
def manage_website():
"""Меню управления сайтом."""
if not _check_feature("web"):
@@ -1978,6 +2382,12 @@ def manage_website():
install_website()
return
tag = _get_saved_web_tag()
status = _web_container_status()
console.print(
f"[bold]Образ:[/bold] [cyan]{_web_image(tag)}[/cyan] [bold]Статус:[/bold] {status}"
)
table = Table(title="Управление сайтом", title_style="bold cyan", header_style="bold blue")
table.add_column("", justify="center", style="cyan", no_wrap=True)
table.add_column("Действие", style="white")
@@ -1987,12 +2397,15 @@ def manage_website():
table.add_row("4", "Остановить")
table.add_row("5", "Обновить (пересборка + restart)")
table.add_row("6", "Изменить настройки (.env)")
table.add_row("7", "Переустановить")
table.add_row("8", "Назад")
table.add_row("7", "Показать .env")
table.add_row("8", "Переустановить")
table.add_row("9", "[red]Удалить сайт[/red]")
table.add_row("10", "Назад")
console.print(table)
choice = safe_prompt(
"[bold blue]👉 Выберите действие[/bold blue]", choices=[str(i) for i in range(1, 9)], show_choices=False
"[bold blue]👉 Выберите действие[/bold blue]",
choices=[str(i) for i in range(1, 11)], show_choices=False,
)
if choice == "1":
@@ -2058,7 +2471,27 @@ def manage_website():
if safe_confirm("[cyan]Перезапустить сайт с новыми настройками?[/cyan]", default=True):
subprocess.run(["docker", "compose", "restart"], cwd=WEB_DIR)
elif choice == "7":
env_path = os.path.join(WEB_DIR, ".env")
if not os.path.isfile(env_path):
console.print(f"[yellow].env не найден: {env_path}[/yellow]")
else:
try:
with open(env_path, encoding="utf-8") as f:
content = f.read()
console.print(
Panel(
content or "[dim]пусто[/dim]",
border_style="cyan",
title=f"[bold cyan]{env_path}[/bold cyan]",
padding=(1, 2),
)
)
except Exception as e:
console.print(f"[red]Не удалось прочитать .env: {e}[/red]")
elif choice == "8":
install_website()
elif choice == "9":
uninstall_website()
def show_update_menu():
@@ -2181,16 +2614,24 @@ def show_website_version_banner():
def show_menu():
bot_installed = has_project_code()
bot_runtime_ready = (
bot_installed and os.path.exists(VENV_PYTHON) and is_service_exists(SERVICE_NAME)
)
def fmt(text: str, enabled: bool) -> str:
return text if enabled else f"[dim]{text} — нужен пункт 9[/dim]"
table = Table(title="Solobot CLI v0.5.8", title_style="bold magenta", header_style="bold blue")
table.add_column("", justify="center", style="cyan", no_wrap=True)
table.add_column("Операция", style="white")
table.add_row("1", "Запустить бота (systemd)")
table.add_row("2", "Запустить напрямую: venv/bin/python main.py")
table.add_row("3", "Перезапустить бота (systemd)")
table.add_row("4", "Остановить бота (systemd)")
table.add_row("5", "Показать логи (80 строк)")
table.add_row("6", "Показать статус")
table.add_row("7", "Обновить Solobot")
table.add_row("1", fmt("Запустить бота (systemd)", bot_runtime_ready))
table.add_row("2", fmt("Запустить напрямую: venv/bin/python main.py", bot_installed and os.path.exists(VENV_PYTHON)))
table.add_row("3", fmt("Перезапустить бота (systemd)", bot_runtime_ready))
table.add_row("4", fmt("Остановить бота (systemd)", bot_runtime_ready))
table.add_row("5", fmt("Показать логи (80 строк)", bot_runtime_ready))
table.add_row("6", fmt("Показать статус", bot_runtime_ready))
table.add_row("7", fmt("Обновить Solobot", bot_installed))
table.add_row("8", "Восстановить из бэкапа")
table.add_row("9", "Установить / переустановить бота")
table.add_row("10", "🌐 Веб-сайт (установка / управление)")
@@ -2227,6 +2668,19 @@ def main():
):
install_bot()
continue
try:
ver_out = subprocess.run(
[VENV_PYTHON, "-c", "import sys; print(sys.version_info[:2])"],
capture_output=True, text=True, timeout=5,
)
if not any(v in ver_out.stdout for v in ("(3, 12)", "(3, 13)", "(3, 14)")):
console.print(
f"[yellow]⚠ venv использует Python {ver_out.stdout.strip()} — ожидается 3.12+.[/yellow]"
)
if not safe_confirm("[cyan]Запустить всё равно?[/cyan]", default=False):
continue
except Exception:
pass
if safe_confirm("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
subprocess.run(["venv/bin/python", "main.py"])
elif choice == "3":