diff --git a/cobot/cli.py b/cobot/cli.py index f7ff4fb..8a71da5 100644 --- a/cobot/cli.py +++ b/cobot/cli.py @@ -1,6 +1,8 @@ import argparse import sys +# Import each command module so we can register its subparser. +# Импортируем каждый модуль команды, чтобы зарегистрировать его подпарсер. from cobot.commands import delete as cmd_delete from cobot.commands import docker_setup as cmd_docker_setup from cobot.commands import doc_setup as cmd_doc_setup @@ -12,6 +14,8 @@ from cobot.commands import update as cmd_update # Command groups shown in --help output. # Add new commands here when introducing other categories. +# Группы команд, отображаемые в --help. +# Добавляйте новые команды сюда при создании новых категорий. _GROUPS = [ ("Setup", [ ("setup", "first-time setup: docs, build environment, robot config"), @@ -32,6 +36,8 @@ _GROUPS = [ _DESCRIPTION = "Lightweight Cobot" +# Custom --help action that prints commands grouped by category instead of a flat list. +# Кастомный обработчик --help, который выводит команды по категориям, а не одним списком. class _GroupedHelpAction(argparse.Action): def __init__(self, option_strings, dest, default=None, required=False, help=None): super().__init__( @@ -79,6 +85,8 @@ def main(): def _register_commands(subparsers): + # Each module registers its own subparser and sets args.func to its run() function. + # Каждый модуль регистрирует свой подпарсер и устанавливает args.func на свою функцию run(). cmd_setup.register(subparsers) cmd_local_setup.register(subparsers) cmd_docker_setup.register(subparsers) diff --git a/cobot/commands/delete.py b/cobot/commands/delete.py index 6bf9bce..2f99049 100644 --- a/cobot/commands/delete.py +++ b/cobot/commands/delete.py @@ -1,7 +1,6 @@ from __future__ import annotations import argparse -import os import shutil import subprocess from pathlib import Path @@ -14,6 +13,8 @@ from cobot.tui import SCREEN_CSS, LogScreen, PickScreen _PROJECT_DIR = Path(__file__).parent.parent.parent +# Stop and remove all Docker containers whose name contains "lwc". +# Останавливаем и удаляем все Docker-контейнеры, чьё имя содержит "lwc". def _stop_docker_containers(write) -> None: write("[cyan][*][/cyan] Stopping Docker containers...") result = subprocess.run( @@ -29,6 +30,8 @@ def _stop_docker_containers(write) -> None: write(f"[green][ok][/green] Removed container: {name}") +# Remove all Docker images whose repository or tag contains "lwc". +# Удаляем все Docker-образы, репозиторий или тег которых содержит "lwc". def _remove_docker_images(write) -> None: write("[cyan][*][/cyan] Removing Docker images...") result = subprocess.run( @@ -47,14 +50,50 @@ def _remove_docker_images(write) -> None: write(f"[green][ok][/green] Removed image: {img}") -def _remove_ros2(write) -> None: - write("[cyan][*][/cyan] Removing ROS2 Jazzy...") - if Path("/opt/ros/jazzy").exists(): - subprocess.run(["sudo", "rm", "-rf", "/opt/ros/jazzy"]) - write("[green][ok][/green] Removed /opt/ros/jazzy") - else: - write("[dim]ROS2 Jazzy not found, skipping.[/dim]") +# Remove the Docker volume that stores the Webots asset cache. +# Удаляем Docker volume с кэшем ассетов Webots. +def _remove_webots_volume(write) -> None: + result = subprocess.run( + ["docker", "volume", "inspect", "lwc-webots-cache"], + capture_output=True, + ) + if result.returncode != 0: + write("[dim]Webots cache volume not found, skipping.[/dim]") + return + subprocess.run(["docker", "volume", "rm", "lwc-webots-cache"], capture_output=True) + write("[green][ok][/green] Removed Docker volume: lwc-webots-cache") + +# Remove ROS2 Jazzy packages via apt and clean up the source line from shell configs. +# Uses the official removal commands to also unregister the ROS2 apt repository. +# Удаляем пакеты ROS2 Jazzy через apt и очищаем строку source из конфигов оболочки. +# Используем официальные команды удаления, которые также снимают регистрацию apt-репозитория ROS2. +def _remove_ros2(write) -> None: + write("[cyan][*][/cyan] Removing ROS2 Jazzy packages...") + if not Path("/opt/ros/jazzy").exists(): + write("[dim]ROS2 Jazzy not found, skipping.[/dim]") + else: + # Remove all ros-jazzy-* packages matched by the apt regex pattern ~n. + # Удаляем все пакеты ros-jazzy-* по regex-паттерну apt ~n<имя>. + subprocess.run( + ["sudo", "apt", "remove", "-y", "~nros-jazzy-*"], + capture_output=True, + ) + subprocess.run(["sudo", "apt", "autoremove", "-y"], capture_output=True) + write("[green][ok][/green] ROS2 Jazzy packages removed") + + # Remove the ROS2 apt source package that added the repository. + # Удаляем пакет apt-источника ROS2, который добавил репозиторий. + subprocess.run( + ["sudo", "apt", "remove", "-y", "ros2-apt-source"], + capture_output=True, + ) + subprocess.run(["sudo", "apt", "update", "-qq"], capture_output=True) + subprocess.run(["sudo", "apt", "autoremove", "-y"], capture_output=True) + write("[green][ok][/green] ROS2 apt repository removed") + + # Clean up the source line that local-setup added to the shell config. + # Очищаем строку source, добавленную local-setup в конфиг оболочки. source_line = "source /opt/ros/jazzy/setup.bash" for rc_name in [".bashrc", ".zshrc"]: rc = Path.home() / rc_name @@ -63,12 +102,28 @@ def _remove_ros2(write) -> None: content = rc.read_text() if source_line not in content: continue + # Remove the whole block that was added by local-setup, not just the single line. + # Удаляем весь блок добавленный local-setup, а не только одну строку. new_content = content.replace(f"\n# ROS2 Jazzy\n{source_line}\n", "\n") new_content = new_content.replace(source_line, "") rc.write_text(new_content) write(f"[green][ok][/green] Cleaned up ~/{rc_name}") +# Remove Webots from the system via apt. +# Удаляем Webots из системы через apt. +def _remove_webots(write) -> None: + write("[cyan][*][/cyan] Removing Webots...") + if not shutil.which("webots"): + write("[dim]Webots not found, skipping.[/dim]") + return + subprocess.run(["sudo", "apt", "remove", "-y", "webots"], capture_output=True) + subprocess.run(["sudo", "apt", "autoremove", "-y"], capture_output=True) + write("[green][ok][/green] Webots removed") + + +# Uninstall the cobot CLI from the uv tool store. +# Удаляем cobot CLI из хранилища инструментов uv. def _uninstall_cobot(write) -> None: write("[cyan][*][/cyan] Uninstalling cobot CLI...") result = subprocess.run( @@ -81,6 +136,8 @@ def _uninstall_cobot(write) -> None: write(f"[yellow]Warning:[/yellow] {result.stderr.strip() or 'could not uninstall cobot'}") +# Delete the entire project directory from disk. +# Удаляем всю директорию проекта с диска. def _remove_project_dir(write) -> None: write(f"[cyan][*][/cyan] Removing project directory...") try: @@ -91,31 +148,35 @@ def _remove_project_dir(write) -> None: raise - -def _task_delete(screen: LogScreen, remove_ros: bool) -> None: +# Run all deletion steps in order. +# Progress ranges are split evenly across the active steps so the bar always reaches 100%. +# Выполняем все шаги удаления по порядку. +# Диапазоны прогресса делятся равномерно между активными шагами, чтобы бар всегда доходил до 100%. +def _task_delete(screen: LogScreen, remove_ros: bool, remove_webots: bool) -> None: try: + screen.set_progress(0, "Stopping containers...") + _stop_docker_containers(screen.write) + _remove_webots_volume(screen.write) + + screen.set_progress(20, "Removing Docker images...") + _remove_docker_images(screen.write) + + pct = 40 if remove_ros: - # stop(0-20) images(20-45) ros2(45-70) cobot(70-85) dir(85-100) - screen.set_progress(0, "Stopping containers...") - _stop_docker_containers(screen.write) - screen.set_progress(20, "Removing Docker images...") - _remove_docker_images(screen.write) - screen.set_progress(45, "Removing ROS2 Jazzy...") + screen.set_progress(pct, "Removing ROS2 Jazzy...") _remove_ros2(screen.write) - screen.set_progress(70, "Uninstalling cobot CLI...") - _uninstall_cobot(screen.write) - screen.set_progress(85, "Removing project directory...") - _remove_project_dir(screen.write) - else: - # stop(0-25) images(25-60) cobot(60-85) dir(85-100) - screen.set_progress(0, "Stopping containers...") - _stop_docker_containers(screen.write) - screen.set_progress(25, "Removing Docker images...") - _remove_docker_images(screen.write) - screen.set_progress(60, "Uninstalling cobot CLI...") - _uninstall_cobot(screen.write) - screen.set_progress(85, "Removing project directory...") - _remove_project_dir(screen.write) + pct = 65 + + if remove_webots: + screen.set_progress(pct, "Removing Webots...") + _remove_webots(screen.write) + pct = 75 + + screen.set_progress(pct, "Uninstalling cobot CLI...") + _uninstall_cobot(screen.write) + + screen.set_progress(88, "Removing project directory...") + _remove_project_dir(screen.write) screen.set_progress(100, "Done") screen.write("\n[green]Project fully removed.[/green]") @@ -126,7 +187,10 @@ def _task_delete(screen: LogScreen, remove_ros: bool) -> None: screen.finish(False) - +# Multi-step confirmation wizard before anything is deleted. +# Shows extra questions only when the relevant software is actually installed. +# Многошаговый мастер подтверждения перед удалением. +# Дополнительные вопросы показываются только если соответствующее ПО действительно установлено. class _DeleteApp(App[None]): CSS = SCREEN_CSS @@ -148,7 +212,7 @@ class _DeleteApp(App[None]): self.push_screen( PickScreen( "ROS2 Jazzy", - "Also remove ROS2 Jazzy (/opt/ros/jazzy)?", + "Also remove ROS2 Jazzy from the system?", ["No, keep ROS2", "Yes, remove ROS2 Jazzy"], "No, keep ROS2", ), @@ -157,8 +221,32 @@ class _DeleteApp(App[None]): def _on_ros_choice(self, choice: Optional[str]) -> None: remove_ros = choice is not None and choice.startswith("Yes") + # Only ask about Webots if it is actually installed on this machine. + # Спрашиваем про Webots только если он действительно установлен на этой машине. + if shutil.which("webots"): + self.push_screen( + PickScreen( + "Webots", + "Also remove Webots from the system?", + ["No, keep Webots", "Yes, remove Webots"], + "No, keep Webots", + ), + lambda c: self._on_webots_choice(c, remove_ros), + ) + else: + self._start_deletion(remove_ros, remove_webots=False) + + def _on_webots_choice(self, choice: Optional[str], remove_ros: bool) -> None: + remove_webots = choice is not None and choice.startswith("Yes") + self._start_deletion(remove_ros, remove_webots) + + def _start_deletion(self, remove_ros: bool, remove_webots: bool) -> None: self.push_screen( - LogScreen("Deleting project", lambda s: _task_delete(s, remove_ros), show_progress=True), + LogScreen( + "Deleting project", + lambda s: _task_delete(s, remove_ros, remove_webots), + show_progress=True, + ), lambda _: self.exit(), ) diff --git a/cobot/commands/doc_setup.py b/cobot/commands/doc_setup.py index bfdaf4c..ca650a7 100644 --- a/cobot/commands/doc_setup.py +++ b/cobot/commands/doc_setup.py @@ -14,6 +14,11 @@ from textual.app import App from cobot.tui import SCREEN_CSS, InputScreen, LogScreen _PROJECT_DIR = Path(__file__).parent.parent.parent + +# The documentation source lives inside the project. We mount it into the container so +# MkDocs can pick up live edits without rebuilding the image. +# Исходники документации находятся внутри проекта. Монтируем директорию в контейнер, чтобы +# MkDocs мог подхватывать изменения вживую без пересборки образа. _DOC_DIR = _PROJECT_DIR / "doc" / "lwc-doc" _IMAGE_NAME = "lwc-docs" _CONTAINER_NAME = "lwc-docs" @@ -22,24 +27,36 @@ _DEFAULT_PORT = "8000" Write = Callable[[str], None] +# Thin wrapper around docker so we do not repeat ["docker", ...] everywhere. +# Тонкая обёртка вокруг docker, чтобы не повторять ["docker", ...] везде. def _docker(*args: str, capture: bool = False) -> subprocess.CompletedProcess: return subprocess.run(["docker", *args], capture_output=capture, text=True) +# Check whether the docs container is currently running. +# Проверяем, запущен ли сейчас контейнер с документацией. def _is_running() -> bool: r = _docker("ps", "--filter", f"name={_CONTAINER_NAME}", "--format", "{{.Names}}", capture=True) return _CONTAINER_NAME in r.stdout +# Check whether the docs Docker image has already been built. +# Проверяем, был ли уже собран Docker-образ для документации. def _image_exists() -> bool: return bool(_docker("images", "-q", _IMAGE_NAME, capture=True).stdout.strip()) +# Build the MkDocs Docker image. Only needs to run once. +# Progress comes from parsing "Step X/Y" lines in the docker build output. +# Собираем Docker-образ MkDocs. Нужно сделать только один раз. +# Прогресс получаем, парся строки "Step X/Y" из вывода docker build. def _build_docs_image( write: Write, on_progress: Optional[Callable[[float], None]] = None, ) -> bool: write("[cyan][*][/cyan] Building documentation image (runs once)...") + # DOCKER_BUILDKIT=0 gives us "Step X/Y" lines that we can parse for progress. + # DOCKER_BUILDKIT=0 даёт нам строки "Step X/Y", которые можно парсить для прогресса. env = {**os.environ, "DOCKER_BUILDKIT": "0"} proc = subprocess.Popen( ["docker", "build", "-t", _IMAGE_NAME, str(_DOC_DIR)], @@ -62,6 +79,8 @@ def _build_docs_image( return False +# Start the docs server. Builds the image first if it does not exist yet. +# Запускаем сервер документации. Сначала собирает образ, если он ещё не существует. def _task_up(screen: LogScreen, port: str) -> None: try: if _is_running(): @@ -91,6 +110,8 @@ def _task_up(screen: LogScreen, port: str) -> None: result = _docker( "run", "-d", "--name", _CONTAINER_NAME, "--rm", "-p", f"{port}:8000", + # Mount the docs directory so edits appear live without restarting the container. + # Монтируем директорию с документацией, чтобы изменения появлялись сразу без перезапуска. "-v", f"{_DOC_DIR}:/docs", _IMAGE_NAME, "serve", "--dev-addr=0.0.0.0:8000", capture=True, @@ -111,6 +132,8 @@ def _task_up(screen: LogScreen, port: str) -> None: screen.finish(False) +# Stop the running docs container. +# Останавливаем работающий контейнер с документацией. def _task_down(screen: LogScreen) -> None: try: if not _is_running(): @@ -128,6 +151,8 @@ def _task_down(screen: LogScreen) -> None: screen.finish(False) +# Stop the container, remove the old image, rebuild it, and start a new container. +# Останавливаем контейнер, удаляем старый образ, пересобираем и запускаем новый контейнер. def _task_rebuild(screen: LogScreen, port: str) -> None: try: if _is_running(): @@ -174,6 +199,8 @@ def _task_rebuild(screen: LogScreen, port: str) -> None: screen.finish(False) +# One app handles all three actions (up/down/rebuild) by branching in on_mount. +# Одно приложение обрабатывает все три действия (up/down/rebuild), разветвляясь в on_mount. class _DocApp(App[None]): CSS = SCREEN_CSS @@ -202,6 +229,8 @@ class _DocApp(App[None]): if port is None: self.exit() return + # Use the default port if the user cleared the input or typed something that is not a number. + # Используем порт по умолчанию если пользователь очистил ввод или написал не число. p = (port.strip() or _DEFAULT_PORT) if port.isdigit() or not port.strip() else _DEFAULT_PORT self.push_screen( LogScreen("Documentation server", lambda s: _task_up(s, p), show_progress=True), diff --git a/cobot/commands/docker_setup.py b/cobot/commands/docker_setup.py index 412d7db..d708f21 100644 --- a/cobot/commands/docker_setup.py +++ b/cobot/commands/docker_setup.py @@ -16,12 +16,19 @@ from cobot.tui import SCREEN_CSS, InputScreen, LogScreen, PickScreen _PROJECT_DIR = Path(__file__).parent.parent.parent _DOCKER_DIR = _PROJECT_DIR / "docker" + +# Default Docker Hub repository and local image prefix used when building locally. +# Репозиторий Docker Hub по умолчанию и локальный префикс образов при локальной сборке. _DEFAULT_HUB_REPO = "evilfisru/lwc" _DEFAULT_PREFIX = "lwc-local" +# The images must be built in this order because each one is based on the previous. +# Образы должны собираться в этом порядке, потому что каждый основан на предыдущем. _CONTROLLER_CHAIN = ["ros-core", "ros-base", "ros-iiwa7"] _WEBOTS_CHAIN = ["ros-core", "ros-base", "ros-iiwa7-webots"] +# Maps each image to the image it is built FROM. None means it starts from scratch (base Ubuntu). +# Сопоставляет каждый образ с тем, на основе которого он собирается. None - начинает с нуля (базовый Ubuntu). _IMAGE_PARENT: dict[str, str | None] = { "ros-core": None, "ros-base": "ros-core", @@ -29,11 +36,15 @@ _IMAGE_PARENT: dict[str, str | None] = { "ros-iiwa7-webots": "ros-base", } +# These images need the full project source as Docker build context because they copy source files. +# Эти образы требуют полный исходный код проекта как контекст сборки, потому что копируют файлы. _NEEDS_PROJECT_CTX = {"ros-iiwa7", "ros-iiwa7-webots"} Write = Callable[[str], None] +# All the choices the user makes in the wizard are stored here before we start the actual build. +# Все выборы пользователя в мастере хранятся здесь перед началом фактической сборки. @dataclass class _Config: ros_version: str @@ -44,6 +55,10 @@ class _Config: hub_repo: str +# Build one Docker image and stream its output to the log. +# Progress is tracked by parsing "Step X/Y" lines that Docker prints during the build. +# Собирает один Docker-образ и транслирует его вывод в лог. +# Прогресс отслеживается по строкам "Step X/Y", которые Docker печатает во время сборки. def _build_image( name: str, tag: str, @@ -55,6 +70,8 @@ def _build_image( build_type: str = "release", ) -> bool: write(f"[cyan][*][/cyan] Building [bold]{name}[/bold]...") + # DOCKER_BUILDKIT=0 gives us "Step X/Y" lines in the output which we parse for progress. + # DOCKER_BUILDKIT=0 даёт нам строки "Step X/Y" в выводе, которые мы парсим для прогресса. env = {**os.environ, "DOCKER_BUILDKIT": "0"} cmd = [ "docker", "build", "-t", tag, "-f", str(dockerfile), @@ -84,6 +101,8 @@ def _build_image( return False +# Pull a Docker image from Hub and track progress by counting downloaded layers. +# Скачиваем Docker-образ с Hub и отслеживаем прогресс по количеству скачанных слоёв. def _pull_image( name: str, tag: str, @@ -104,6 +123,8 @@ def _pull_image( s = line.rstrip() if s: write(s) + # Count layers as they appear and mark them done when Docker confirms they are pulled. + # Считаем слои по мере их появления и отмечаем завершёнными когда Docker подтверждает скачивание. if "Pulling fs layer" in line or "Waiting" in line: layers_total += 1 elif "Pull complete" in line or "Already exists" in line: @@ -120,6 +141,10 @@ def _pull_image( return False +# The actual work - either build or pull all images depending on what the user chose. +# Each image gets its own slice of the progress bar so the overall bar advances smoothly. +# Основная работа - собираем или скачиваем все образы в зависимости от выбора пользователя. +# Каждый образ получает свой кусок прогресс-бара, чтобы общий бар двигался равномерно. def _task_execute(screen: LogScreen, cfg: _Config) -> None: try: chain = _WEBOTS_CHAIN if cfg.variant == "webots" else _CONTROLLER_CHAIN @@ -190,15 +215,23 @@ def _task_execute(screen: LogScreen, cfg: _Config) -> None: screen.finish(False) +# Scan the docker/ directory for subdirectories named after ROS versions (e.g. jazzy). +# If nothing is found we fall back to "jazzy" so the wizard still works. +# Сканируем директорию docker/ на наличие поддиректорий с именами версий ROS (например jazzy). +# Если ничего не найдено, используем "jazzy" по умолчанию, чтобы мастер всё равно работал. def _discover_versions() -> List[str]: if not _DOCKER_DIR.exists(): return ["jazzy"] dirs = sorted(d.name for d in _DOCKER_DIR.iterdir() if d.is_dir()) + # Put jazzy first so it is the pre-selected default in the wizard. + # Ставим jazzy первым, чтобы он был предвыбранным по умолчанию в мастере. if "jazzy" in dirs: dirs = ["jazzy"] + [d for d in dirs if d != "jazzy"] return dirs or ["jazzy"] +# Multi-step wizard that collects all build options before starting the actual image build. +# Многошаговый мастер, который собирает все параметры сборки перед запуском фактической сборки образа. class _Wizard(App[None]): CSS = SCREEN_CSS @@ -264,6 +297,8 @@ class _Wizard(App[None]): self.exit() return self._state["build_type"] = v or "release" + # Pull needs a Hub repo name, build needs a local image prefix. + # Для pull нужно имя репозитория на Hub, для build - локальный префикс образов. if self._state["source"] == "pull": self.push_screen( InputScreen("Step 5 of 5", "Docker Hub repository:", _DEFAULT_HUB_REPO), @@ -290,6 +325,8 @@ class _Wizard(App[None]): self._finish() def _finish(self) -> None: + # Assemble the config and hand it off to the log screen that does the actual work. + # Собираем конфиг и передаём его экрану лога, который выполняет фактическую работу. s = self._state cfg = _Config( ros_version=s["ros_version"], diff --git a/cobot/commands/local_setup.py b/cobot/commands/local_setup.py index ddeca4f..ba18dcc 100644 --- a/cobot/commands/local_setup.py +++ b/cobot/commands/local_setup.py @@ -17,12 +17,19 @@ from cobot.commands.docker_setup import run as _docker_setup _PROJECT_DIR = Path(__file__).parent.parent.parent +# Paths used for the ROS2 apt repository signing key and sources list. +# Пути для ключа подписи apt-репозитория ROS2 и файла sources list. _ROS_KEYRING = Path("/usr/share/keyrings/ros-archive-keyring.gpg") _ROS_SOURCES = Path("/etc/apt/sources.list.d/ros2.list") _ROS_KEY_URL = "https://raw.githubusercontent.com/ros/rosdistro/master/ros.key" + +# Suppress apt interactive prompts such as "restart services?". +# Подавляем интерактивные запросы apt, например "перезапустить службы?". _APT_ENV = {**os.environ, "DEBIAN_FRONTEND": "noninteractive"} +# Check whether we are running on Ubuntu 24.04, which is required for ROS2 Jazzy. +# Проверяем, запущены ли мы на Ubuntu 24.04, которая требуется для ROS2 Jazzy. def _detect_ubuntu_2404() -> bool: path = Path("/etc/os-release") if not path.exists(): @@ -35,6 +42,8 @@ def _detect_ubuntu_2404() -> bool: return info.get("ID") == "ubuntu" and info.get("VERSION_ID") == "24.04" +# Check whether ROS2 Jazzy is already installed by looking for its directory. +# Проверяем, установлен ли ROS2 Jazzy, проверяя наличие его директории. def _detect_ros2_jazzy() -> bool: return Path("/opt/ros/jazzy").is_dir() @@ -42,6 +51,8 @@ def _detect_ros2_jazzy() -> bool: Write = Callable[[str], None] +# Run a command and capture output. Print it to the log only if the command fails. +# Запускаем команду и перехватываем вывод. Выводим в лог только если команда завершилась с ошибкой. def _run_quiet(cmd: List[str], write: Write | None = None, env: dict | None = None, cwd=None) -> None: result = subprocess.run( cmd, capture_output=True, text=True, @@ -55,6 +66,8 @@ def _run_quiet(cmd: List[str], write: Write | None = None, env: dict | None = No raise RuntimeError(f"Command failed: {cmd[0]}") +# Run a command and stream every output line to the log in real time. +# Запускаем команду и транслируем каждую строку вывода в лог в реальном времени. def _run_logged(cmd: List[str], write: Write, env: dict | None = None, cwd=None) -> None: proc = subprocess.Popen( cmd, @@ -80,6 +93,10 @@ def _run_apt_with_progress( env: dict | None = None, ) -> None: """Run an apt command and feed real percentage from APT::Status-Fd to on_progress(0-100).""" + # APT::Status-Fd makes apt write progress lines to a pipe descriptor instead of stdout. + # We read that pipe in a background thread so we can update the progress bar live. + # APT::Status-Fd заставляет apt писать строки прогресса в дескриптор канала, а не в stdout. + # Читаем этот канал в фоновом потоке, чтобы обновлять прогресс-бар в реальном времени. r_fd, w_fd = os.pipe() try: proc = subprocess.Popen( @@ -91,6 +108,8 @@ def _run_apt_with_progress( pass_fds=(w_fd,), ) finally: + # Close the write end in the parent process so the reader thread gets EOF when apt exits. + # Закрываем пишущий конец в родительском процессе, чтобы читающий поток получил EOF при выходе apt. os.close(w_fd) def _read_status() -> None: @@ -116,10 +135,8 @@ def _run_apt_with_progress( raise RuntimeError(f"Command failed: {cmd[0]}") -# --------------------------------------------------------------------------- -# Installation steps -# --------------------------------------------------------------------------- - +# Make sure the system has a UTF-8 locale, which ROS2 requires to work correctly. +# Убеждаемся, что в системе есть локаль UTF-8, которая требуется ROS2 для корректной работы. def _setup_locale(write: Write) -> None: write("[cyan][*][/cyan] Checking locale...") if "UTF-8" in subprocess.run(["locale"], capture_output=True, text=True).stdout: @@ -133,6 +150,8 @@ def _setup_locale(write: Write) -> None: write("[green][ok][/green] Locale configured") +# Add the official ROS2 apt repository and its signing key so we can install ROS2 packages. +# Добавляем официальный apt-репозиторий ROS2 и его ключ подписи, чтобы можно было установить пакеты ROS2. def _add_ros2_repo(write: Write, on_progress: Optional[Callable[[float], None]] = None) -> None: def _prog(p: float) -> None: if on_progress: @@ -159,6 +178,8 @@ def _add_ros2_repo(write: Write, on_progress: Optional[Callable[[float], None]] try: urllib.request.urlretrieve(_ROS_KEY_URL, tmp_path) _prog(60) + # Convert the ASCII-armored key to binary GPG format that apt understands. + # Конвертируем ключ из ASCII-armor формата в бинарный GPG, который понимает apt. _run_quiet(["sudo", "gpg", "--dearmor", "--yes", "-o", str(_ROS_KEYRING), tmp_path]) finally: os.unlink(tmp_path) @@ -192,6 +213,8 @@ def _add_ros2_repo(write: Write, on_progress: Optional[Callable[[float], None]] _prog(100) +# Install the full ROS2 Jazzy Desktop and the developer tools (colcon, rosdep, etc.). +# Устанавливаем полный ROS2 Jazzy Desktop и инструменты разработчика (colcon, rosdep и т.д.). def _install_ros2_jazzy(write: Write, on_progress: Optional[Callable[[float], None]] = None) -> None: write("[cyan][*][/cyan] Installing ros-jazzy-desktop and ros-dev-tools...") _run_apt_with_progress( @@ -203,6 +226,8 @@ def _install_ros2_jazzy(write: Write, on_progress: Optional[Callable[[float], No write("[green][ok][/green] ROS2 Jazzy Desktop installed") +# Install colcon if it is not already available. It is used to build the project packages. +# Устанавливаем colcon если он ещё не доступен. Он используется для сборки пакетов проекта. def _install_colcon(write: Write) -> None: if shutil.which("colcon"): write("[green][ok][/green] colcon already available") @@ -216,6 +241,10 @@ def _install_colcon(write: Write) -> None: write("[green][ok][/green] colcon installed") +# Add "source /opt/ros/jazzy/setup.bash" to the user's shell config file. +# This makes ROS2 commands available in every new terminal session. +# Добавляем "source /opt/ros/jazzy/setup.bash" в конфиг оболочки пользователя. +# Это делает команды ROS2 доступными в каждой новой сессии терминала. def _setup_shell_rc(write: Write) -> None: shell_name = Path(os.environ.get("SHELL", "/bin/bash")).name rc = Path.home() / (".zshrc" if shell_name == "zsh" else ".bashrc") @@ -228,10 +257,8 @@ def _setup_shell_rc(write: Write) -> None: write(f"[green][ok][/green] Added ROS2 setup to ~/{rc.name}") -# --------------------------------------------------------------------------- -# Background tasks (run inside LogScreen worker) -# --------------------------------------------------------------------------- - +# Full ROS2 Jazzy installation split into 5 clearly visible steps with individual progress ranges. +# Полная установка ROS2 Jazzy, разбитая на 5 наглядных шагов с отдельными диапазонами прогресса. def _task_install_jazzy(screen: LogScreen) -> None: try: # Step 1 — locale (0 → 5 %) @@ -275,6 +302,8 @@ def _task_install_jazzy(screen: LogScreen) -> None: screen.finish(False) +# Build all project packages with colcon and track progress by counting finished packages. +# Собираем все пакеты проекта с помощью colcon и отслеживаем прогресс по количеству завершённых пакетов. def _task_build(screen: LogScreen) -> None: try: if not shutil.which("colcon"): @@ -283,7 +312,8 @@ def _task_build(screen: LogScreen) -> None: screen.finish(False) return - # Count packages so we can show X/total progress + # Count packages first so we can show X/total in the progress label. + # Сначала считаем пакеты, чтобы показывать X/всего в подписи прогресса. list_result = subprocess.run( ["colcon", "list"], capture_output=True, text=True, cwd=_PROJECT_DIR, ) @@ -296,6 +326,8 @@ def _task_build(screen: LogScreen) -> None: def _track(line: str) -> None: nonlocal built screen.write(line) + # colcon prints "Finished <<<" or "Failed <<<" when each package is done. + # colcon печатает "Finished <<<" или "Failed <<<" когда каждый пакет готов. if "Finished <<<" in line or "Failed <<<" in line: built += 1 screen.set_progress(built / total * 100, f"{built} / {total} packages done") @@ -310,10 +342,109 @@ def _task_build(screen: LogScreen) -> None: screen.finish(False) -# --------------------------------------------------------------------------- -# Textual apps -# --------------------------------------------------------------------------- +# Webots version that matches the Docker images used in this project. +# Версия Webots, соответствующая Docker-образам используемым в этом проекте. +_WEBOTS_VERSION = "2025a" +_WEBOTS_DEB_URL = ( + f"https://github.com/cyberbotics/webots/releases/download/" + f"R{_WEBOTS_VERSION}/webots_{_WEBOTS_VERSION}_amd64.deb" +) + +def webots_installed() -> bool: + """Return True if Webots is available on PATH.""" + return shutil.which("webots") is not None + + +# Download the Webots .deb from GitHub and install it with apt. +# Progress: download (0-65%), apt install (65-100%). +# Скачиваем .deb Webots с GitHub и устанавливаем через apt. +# Прогресс: скачивание (0-65%), установка apt (65-100%). +def _task_install_webots(screen: LogScreen) -> None: + try: + screen.write(f"[bold]Installing Webots {_WEBOTS_VERSION}[/bold]\n") + + with tempfile.TemporaryDirectory() as tmp: + deb_path = Path(tmp) / f"webots_{_WEBOTS_VERSION}_amd64.deb" + + screen.write(f"[dim]{_WEBOTS_DEB_URL}[/dim]\n") + screen.set_progress(0, "Downloading Webots...") + + # urllib calls this hook periodically with how many bytes have been downloaded. + # urllib вызывает этот обратный вызов периодически с количеством скачанных байт. + def _hook(blocks: int, block_size: int, total: int) -> None: + if total > 0: + pct = min(blocks * block_size / total * 65, 65) + mb = blocks * block_size / 1_048_576 + total_mb = total / 1_048_576 + screen.set_progress(pct, f"Downloading... {mb:.0f} / {total_mb:.0f} MB") + + urllib.request.urlretrieve(_WEBOTS_DEB_URL, deb_path, _hook) + screen.write("[green]Download complete.[/green]") + screen.set_progress(65, "Installing package...") + + proc = subprocess.Popen( + ["sudo", "apt-get", "install", "-y", str(deb_path)], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, + ) + for line in proc.stdout: + s = line.rstrip() + if s: + screen.write(s) + proc.wait() + + if proc.returncode != 0: + screen.write("\n[red]Installation failed.[/red]") + screen.finish(False) + return + + screen.set_progress(100, "Done") + screen.write("\n[green]Webots installed successfully.[/green]") + screen.finish(True) + except Exception as exc: + screen.write(f"\n[red]Error:[/red] {exc}") + screen.finish(False) + + +# Minimal single-question app used between steps where a full wizard is not needed. +# Минимальное приложение с одним вопросом, используемое между шагами где полный мастер не нужен. +class _Ask(App[Optional[str]]): + CSS = SCREEN_CSS + + def __init__(self, step: str, question: str, options: list, default: str): + super().__init__() + self._step = step + self._question = question + self._options = options + self._default = default + + def on_mount(self) -> None: + self.push_screen( + PickScreen(self._step, self._question, self._options, self._default), + self.exit, + ) + + +def _ask(step: str, question: str, options: list, default: str) -> Optional[str]: + return _Ask(step, question, options, default).run() + + +# Public app used by run.py to install Webots before launching locally. +# Публичное приложение, используемое run.py для установки Webots перед локальным запуском. +class WebotsInstallApp(App[bool]): + CSS = SCREEN_CSS + + def on_mount(self) -> None: + self.push_screen( + LogScreen(f"Installing Webots {_WEBOTS_VERSION}", _task_install_webots, show_progress=True), + self.exit, + ) + + + +# Ask the user if they want to install ROS2 Jazzy, then run the installer if they say yes. +# Спрашиваем пользователя хочет ли он установить ROS2 Jazzy, и запускаем установщик если да. class _InstallJazzyApp(App[None]): CSS = SCREEN_CSS @@ -338,6 +469,8 @@ class _InstallJazzyApp(App[None]): ) +# Run the colcon build without asking any questions - used when ROS2 is already installed. +# Запускаем сборку colcon без лишних вопросов - используется когда ROS2 уже установлен. class _BuildApp(App[None]): CSS = SCREEN_CSS @@ -348,6 +481,8 @@ class _BuildApp(App[None]): ) +# Shown when the OS is not Ubuntu 24.04. Offers to fall back to docker-setup instead. +# Показывается когда ОС не Ubuntu 24.04. Предлагает перейти к docker-setup вместо этого. class _DockerPromptApp(App[bool]): CSS = SCREEN_CSS @@ -363,9 +498,6 @@ class _DockerPromptApp(App[bool]): ) -# --------------------------------------------------------------------------- -# CLI registration -# --------------------------------------------------------------------------- def register(subparsers: argparse._SubParsersAction) -> None: p = subparsers.add_parser( @@ -376,13 +508,33 @@ def register(subparsers: argparse._SubParsersAction) -> None: def run(args: argparse.Namespace) -> None: + # If this is not Ubuntu 24.04 we cannot install ROS2 Jazzy natively - offer Docker instead. + # Если это не Ubuntu 24.04 мы не можем установить ROS2 Jazzy нативно - предлагаем Docker вместо этого. if not _detect_ubuntu_2404(): if _DockerPromptApp().run(): _docker_setup(args) return + # ROS2 not installed yet - show the installer. + # After installation the user must restart the terminal, so we stop here. + # ROS2 ещё не установлен - показываем установщик. + # После установки пользователь должен перезапустить терминал, поэтому останавливаемся здесь. if not _detect_ros2_jazzy(): _InstallJazzyApp().run() return + # ROS2 is ready - build the project. + # ROS2 готов - собираем проект. _BuildApp().run() + + # Ask about Webots only after a successful build, and only if it is not already installed. + # Спрашиваем про Webots только после успешной сборки и только если он ещё не установлен. + if not webots_installed(): + v = _ask( + "Optional: Webots", + f"Install Webots {_WEBOTS_VERSION} simulator? (can also be done later via cobot run)", + [f"Yes, install Webots {_WEBOTS_VERSION}", "No, skip"], + "No, skip", + ) + if v and v.startswith("Yes"): + WebotsInstallApp().run() diff --git a/cobot/commands/robot_setup.py b/cobot/commands/robot_setup.py index 7db29b4..7d5f43e 100644 --- a/cobot/commands/robot_setup.py +++ b/cobot/commands/robot_setup.py @@ -17,10 +17,16 @@ from cobot.tui import SCREEN_CSS, InputScreen, PickScreen _PROJECT_DIR = Path(__file__).parent.parent.parent _CONFIG_PATH = _PROJECT_DIR / "cobot-setting.yaml" +# Use ruamel.yaml instead of PyYAML so comments and formatting in the config file are preserved. +# Используем ruamel.yaml вместо PyYAML, чтобы комментарии и форматирование в конфиге сохранялись. _yaml = YAML() _yaml.preserve_quotes = True +# One question inside a configuration block. +# A field can either show a pick list (options) or a free-text input (no options). +# Один вопрос внутри блока конфигурации. +# Поле может показывать список вариантов (options) или поле для ввода текста (без options). @dataclass class _Field: key: str # dot-separated path within the block, e.g. "webots.world" @@ -33,6 +39,8 @@ class _Field: return self.key.split(".")[-1] +# A group of related fields shown together under one "Configure X?" question. +# Группа связанных полей, показываемая вместе под одним вопросом "Настроить X?". @dataclass class _Block: yaml_key: str # top-level key in cobot-setting.yaml @@ -40,6 +48,8 @@ class _Block: fields: List[_Field] +# All configuration blocks. Each block maps to a top-level key in cobot-setting.yaml. +# Все блоки конфигурации. Каждый блок соответствует ключу верхнего уровня в cobot-setting.yaml. _BLOCKS: List[_Block] = [ _Block( yaml_key="foxglove", @@ -101,17 +111,20 @@ _BLOCKS: List[_Block] = [ _Field("active_controller", "Active ROS controller:", "jtc", note="jtc = JointTrajectoryController (MoveIt), forward = ForwardCommandController", options=["jtc", "forward"]), - _Field("joint_position_tau", "Position EMA filter τ (s):", "0.04", + _Field("joint_position_tau", "Position EMA filter tau (s):", "0.04", note="Smooths position commands before sending to FRI"), - _Field("joint_velocity_tau", "Velocity EMA filter τ (s):", "0.01", + _Field("joint_velocity_tau", "Velocity EMA filter tau (s):", "0.01", note="Removes spikes from finite-difference velocity estimation"), ], ), ] +# Try to keep the original YAML type (bool, int, float) when saving a value back. +# Trying to preserve type prevents "true" from becoming a plain string in the YAML file. +# Пытаемся сохранить исходный тип YAML (bool, int, float) при записи значения обратно. +# Сохранение типа предотвращает превращение "true" в обычную строку в YAML-файле. def _coerce(value: str, original: Any) -> Any: - """Try to preserve the original YAML scalar type.""" if isinstance(original, bool): return value.lower() == "true" if isinstance(original, int): @@ -127,6 +140,8 @@ def _coerce(value: str, original: Any) -> Any: return value +# Read a value from a nested YAML mapping using a dot-separated key like "webots.transform". +# Читаем значение из вложенного YAML-словаря по ключу с точками, например "webots.transform". def _get_nested(mapping: Any, path: str) -> Any: keys = path.split(".") cur = mapping @@ -137,6 +152,8 @@ def _get_nested(mapping: Any, path: str) -> Any: return cur +# Write a value into a nested YAML mapping using a dot-separated key. +# Записываем значение в вложенный YAML-словарь по ключу с точками. def _set_nested(mapping: Any, path: str, value: Any) -> None: keys = path.split(".") cur = mapping @@ -146,6 +163,8 @@ def _set_nested(mapping: Any, path: str, value: Any) -> None: cur[keys[-1]] = _coerce(value, original) +# Shown after all blocks have been configured to confirm the file was saved. +# Показывается после настройки всех блоков для подтверждения сохранения файла. class _SavedScreen(Screen[None]): BINDINGS = [Binding("enter,escape", "close", "Close")] @@ -159,13 +178,17 @@ class _SavedScreen(Screen[None]): self.dismiss(None) +# The main configuration wizard. Goes through each block in order. +# For each block it first asks "Configure X?" then steps through all its fields. +# Главный мастер конфигурации. Проходит по каждому блоку по порядку. +# Для каждого блока сначала спрашивает "Настроить X?" а затем проходит по всем его полям. class _Wizard(App[None]): CSS = SCREEN_CSS def __init__(self, data: Any): super().__init__() self._data = data - self._blocks = list(_BLOCKS) # copy so we can pop + self._blocks = list(_BLOCKS) self._block_idx = 0 self._field_idx = 0 self._current_block: Optional[_Block] = None @@ -174,9 +197,10 @@ class _Wizard(App[None]): def on_mount(self) -> None: self._next_block() - def _next_block(self) -> None: if self._block_idx >= len(self._blocks): + # All blocks done - save and show the confirmation screen. + # Все блоки пройдены - сохраняем и показываем экран подтверждения. _save_config(self._data) self.push_screen(_SavedScreen(), lambda _: self.exit()) return @@ -204,9 +228,10 @@ class _Wizard(App[None]): self._field_idx = 0 self._next_field() else: + # Skip all fields in this block and jump to the next block. + # Пропускаем все поля этого блока и переходим к следующему. self._next_block() - def _next_field(self) -> None: if not self._pending_fields: self._next_block() @@ -220,7 +245,7 @@ class _Wizard(App[None]): field_num = self._field_idx total_fields = len(block.fields) - step = f"Block {block_num} of {total_blocks} · Field {field_num} of {total_fields}" + step = f"Block {block_num} of {total_blocks} - Field {field_num} of {total_fields}" # Resolve current value from loaded YAML as the pre-filled default yaml_val = _get_nested(self._data[block.yaml_key], f.key) @@ -241,16 +266,21 @@ class _Wizard(App[None]): return block = self._current_block _set_nested(self._data[block.yaml_key], f.key, v) + # Remove the field we just handled and move on to the next one. + # Удаляем только что обработанное поле и переходим к следующему. self._pending_fields.pop(0) self._next_field() - +# Load the config file preserving all comments and key order. +# Загружаем конфиг-файл, сохраняя все комментарии и порядок ключей. def _load_config() -> Any: with open(_CONFIG_PATH, "r", encoding="utf-8") as fh: return _yaml.load(fh) +# Write the modified config back to disk preserving comments and formatting. +# Записываем изменённый конфиг обратно на диск, сохраняя комментарии и форматирование. def _save_config(data: Any) -> None: with open(_CONFIG_PATH, "w", encoding="utf-8") as fh: _yaml.dump(data, fh) diff --git a/cobot/commands/run.py b/cobot/commands/run.py index 9cd002f..0afe5c4 100644 --- a/cobot/commands/run.py +++ b/cobot/commands/run.py @@ -11,17 +11,32 @@ from typing import Callable, List, Optional from textual.app import App from cobot.tui import SCREEN_CSS, LogScreen, PickScreen, RunScreen +from cobot.commands.local_setup import webots_installed, WebotsInstallApp, _WEBOTS_VERSION _PROJECT_DIR = Path(__file__).parent.parent.parent _CONFIG_PATH = _PROJECT_DIR / "cobot-setting.yaml" _INSTALL_DIR = _PROJECT_DIR / "install" _JAZZY_DIR = Path("/opt/ros/jazzy") + +# Path where the config file is mounted inside the Docker container. +# Путь по которому конфиг-файл монтируется внутри Docker-контейнера. _CONFIG_IN_CONTAINER = "/ros2_ws/cobot-setting.yaml" +# Container names used for docker run and docker kill. +# Имена контейнеров, используемые для docker run и docker kill. _CONTAINER_CONTROLLER = "lwc-controller" _CONTAINER_WEBOTS = "lwc-webots" -# Candidates checked in order; for controller the webots image is a valid fallback +# Named Docker volume that stores the Webots asset cache between container runs. +# Without it Webots re-downloads all 3D assets from the internet on every launch. +# Именованный Docker volume для хранения кэша ассетов Webots между запусками контейнера. +# Без него Webots заново скачивает все 3D-ассеты из интернета при каждом запуске. +_WEBOTS_CACHE_VOLUME = "lwc-webots-cache" + +# Candidates checked in order - for the controller the webots image is a valid fallback +# because it already contains all controller packages too. +# Кандидаты проверяются по порядку - для контроллера образ webots является допустимым запасным, +# так как он уже содержит все пакеты контроллера. _CONTROLLER_IMAGES = [ "lwc-local:ros-iiwa7-jazzy", "evilfisru/lwc:iiwa-jazzy", @@ -37,10 +52,10 @@ _WEBOTS_IMAGES = [ ] -# --------------------------------------------------------------------------- -# Small utilities -# --------------------------------------------------------------------------- - +# A minimal app that asks one question and exits immediately with the chosen value. +# We need a full App because Textual screens cannot run outside one. +# Минимальное приложение, которое задаёт один вопрос и сразу выходит с выбранным значением. +# Нам нужен полноценный App, потому что экраны Textual не могут работать вне него. class _Ask(App[Optional[str]]): CSS = SCREEN_CSS @@ -59,9 +74,13 @@ class _Ask(App[Optional[str]]): def _ask(step: str, question: str, options: List[str], default: str) -> Optional[str]: + # Returns None when the user pressed Escape to cancel. + # Возвращает None когда пользователь нажал Escape для отмены. return _Ask(step, question, options, default).run() +# Detect the GPU type so we can pass the right flags to docker run for Webots rendering. +# Определяем тип GPU, чтобы передать нужные флаги в docker run для рендеринга Webots. def _detect_gpu() -> str: if shutil.which("nvidia-smi"): if subprocess.run(["nvidia-smi"], capture_output=True).returncode == 0: @@ -71,6 +90,8 @@ def _detect_gpu() -> str: return "software" +# List all Docker images currently available on this machine. +# Получаем список всех Docker-образов доступных на этой машине. def _docker_images() -> set: r = subprocess.run( ["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"], @@ -79,6 +100,8 @@ def _docker_images() -> set: return set(r.stdout.strip().splitlines()) +# Return the first image from the candidates list that is already present locally. +# Возвращаем первый образ из списка кандидатов, который уже присутствует локально. def _find_image(candidates: List[str]) -> Optional[str]: available = _docker_images() for img in candidates: @@ -87,14 +110,16 @@ def _find_image(candidates: List[str]) -> Optional[str]: return None -# --------------------------------------------------------------------------- -# Build project (colcon build --mixin release) -# --------------------------------------------------------------------------- - +# Build the ROS2 project locally with colcon. Used when launching in local mode +# and the install/ directory does not exist yet. +# Собираем ROS2-проект локально с помощью colcon. Используется при запуске в локальном режиме, +# если директория install/ ещё не существует. def _task_build(screen: LogScreen) -> None: try: screen.write("[bold]Building project with colcon[/bold]\n") + # Count packages first so we can show X/total progress. + # Сначала считаем пакеты, чтобы показывать X/всего в прогрессе. list_proc = subprocess.run( ["bash", "-c", f"source {_JAZZY_DIR}/setup.bash && colcon list"], capture_output=True, text=True, cwd=_PROJECT_DIR, @@ -113,6 +138,8 @@ def _task_build(screen: LogScreen) -> None: s = line.rstrip() if s: screen.write(s) + # colcon prints "Finished <<<" or "Failed <<<" when each package is done. + # colcon печатает "Finished <<<" или "Failed <<<" когда каждый пакет готов. if "Finished <<<" in line or "Failed <<<" in line: built += 1 screen.set_progress(built / total * 100, f"{built} / {total} packages done") @@ -141,10 +168,10 @@ class _BuildApp(App[bool]): ) -# --------------------------------------------------------------------------- -# Local launch task -# --------------------------------------------------------------------------- - +# Start the ROS2 launch file directly on this machine without Docker. +# Uses start_new_session so we can kill the whole process group with one signal. +# Запускаем launch-файл ROS2 напрямую на этой машине без Docker. +# Используем start_new_session, чтобы можно было убить всю группу процессов одним сигналом. def _task_run_local(screen: RunScreen, mode: str) -> None: config = str(_CONFIG_PATH) ros_cmd = f"ros2 launch iiwa_bringup iiwa.launch.py setting:={config}" @@ -168,6 +195,8 @@ def _task_run_local(screen: RunScreen, mode: str) -> None: start_new_session=True, ) screen.set_proc(proc) + # Kill the entire process group so all child processes (nodes) are terminated together. + # Убиваем всю группу процессов, чтобы все дочерние процессы (узлы) завершились вместе. screen.set_kill_fn(lambda: os.killpg(os.getpgid(proc.pid), signal.SIGTERM)) for line in proc.stdout: @@ -179,18 +208,22 @@ def _task_run_local(screen: RunScreen, mode: str) -> None: screen.finish(stopped=screen._stopped) -# --------------------------------------------------------------------------- -# Docker launch task -# --------------------------------------------------------------------------- - +# Start the ROS2 launch file inside a Docker container. +# For Webots mode we also forward X11 and GPU access so the simulator window can appear on screen. +# Запускаем launch-файл ROS2 внутри Docker-контейнера. +# Для режима Webots также пробрасываем X11 и доступ к GPU, чтобы окно симулятора появилось на экране. def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None: container = _CONTAINER_WEBOTS if mode == "webots" else _CONTAINER_CONTROLLER - ros_cmd = f"ros2 launch iiwa_bringup iiwa.launch.py setting:={_CONFIG_IN_CONTAINER}" + ros_cmd = ( + "source /ros2_ws/install/setup.bash && " + f"ros2 launch iiwa_bringup iiwa.launch.py setting:={_CONFIG_IN_CONTAINER}" + ) if mode == "webots": ros_cmd += " simulate:=1" - # Remove stale container with the same name + # Remove any stale container with the same name left from a previous run. + # Удаляем устаревший контейнер с таким же именем, оставшийся от предыдущего запуска. subprocess.run(["docker", "rm", "-f", container], capture_output=True) cmd = [ @@ -201,11 +234,16 @@ def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None ] if mode == "webots": + # Allow the container to open windows on the host display. + # Разрешаем контейнеру открывать окна на дисплее хоста. subprocess.run(["xhost", "+local:docker"], capture_output=True) cmd += [ "-e", f"DISPLAY={os.environ.get('DISPLAY', ':0')}", "-e", "QT_X11_NO_MITSHM=1", "-v", "/tmp/.X11-unix:/tmp/.X11-unix:rw", + # Persist the Webots asset cache so it is not re-downloaded on every launch. + # Сохраняем кэш ассетов Webots, чтобы он не скачивался заново при каждом запуске. + "-v", f"{_WEBOTS_CACHE_VOLUME}:/root/.cache/Cyberbotics/Webots", ] if gpu == "nvidia": cmd += [ @@ -214,13 +252,19 @@ def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None "-e", "NVIDIA_DRIVER_CAPABILITIES=graphics,utility,compute", ] elif gpu == "mesa": + # Pass through the DRI device for Intel/AMD hardware acceleration. + # Пробрасываем DRI-устройство для аппаратного ускорения Intel/AMD. cmd += ["--device", "/dev/dri"] else: + # No GPU found - fall back to software rendering via llvmpipe. + # GPU не найден - используем программный рендеринг через llvmpipe. cmd += [ "-e", "LIBGL_ALWAYS_SOFTWARE=1", "-e", "GALLIUM_DRIVER=llvmpipe", ] + # Mount the config file so the container uses our local cobot-setting.yaml. + # Монтируем конфиг-файл, чтобы контейнер использовал наш локальный cobot-setting.yaml. if _CONFIG_PATH.exists(): cmd += ["-v", f"{_CONFIG_PATH}:{_CONFIG_IN_CONTAINER}:ro"] @@ -244,6 +288,11 @@ def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None text=True, ) screen.set_proc(proc) + # Use docker kill instead of proc.terminate() so the container is stopped immediately. + # Terminating only the docker CLI process leaves the container itself running. + # Используем docker kill вместо proc.terminate(), чтобы контейнер остановился немедленно. + # Завершение только процесса docker CLI оставляет сам контейнер работающим. + screen.set_kill_fn(lambda: subprocess.run(["docker", "kill", container], capture_output=True)) for line in proc.stdout: s = line.rstrip() @@ -254,10 +303,8 @@ def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None screen.finish(stopped=screen._stopped) -# --------------------------------------------------------------------------- -# RunApp wrapper -# --------------------------------------------------------------------------- - +# Wraps a RunScreen in an App so it can be launched with .run(). +# Оборачивает RunScreen в App, чтобы его можно было запустить через .run(). class _RunApp(App[None]): CSS = SCREEN_CSS @@ -270,10 +317,10 @@ class _RunApp(App[None]): self.push_screen(RunScreen(self._title, self._run_fn), lambda _: self.exit()) -# --------------------------------------------------------------------------- -# Local flow -# --------------------------------------------------------------------------- - +# Guide the user through launching locally - asks what to run, checks prerequisites, +# installs Webots and builds the project if needed, then launches. +# Ведёт пользователя через локальный запуск - спрашивает что запустить, проверяет +# предварительные условия, устанавливает Webots и собирает проект при необходимости, затем запускает. def _local_flow(args: argparse.Namespace) -> None: mode_v = _ask( "Run local", @@ -285,6 +332,20 @@ def _local_flow(args: argparse.Namespace) -> None: return mode = "webots" if mode_v == "Webots simulator" else "controller" + # Check Webots installed (local mode only) + if mode == "webots" and not webots_installed(): + v = _ask( + "Webots not found", + f"Webots {_WEBOTS_VERSION} is not installed. Install it now?", + [f"Yes, install Webots {_WEBOTS_VERSION}", "No, cancel"], + f"Yes, install Webots {_WEBOTS_VERSION}", + ) + if v is None or v.startswith("No"): + return + ok = WebotsInstallApp().run() + if not ok: + return + # Check ROS2 Jazzy if not _JAZZY_DIR.is_dir(): v = _ask( @@ -316,10 +377,10 @@ def _local_flow(args: argparse.Namespace) -> None: _RunApp(f"Running {label} — local", lambda s: _task_run_local(s, mode)).run() -# --------------------------------------------------------------------------- -# Docker flow -# --------------------------------------------------------------------------- - +# Guide the user through launching in Docker - asks what to run, finds a suitable image, +# detects the GPU for Webots, and launches. +# Ведёт пользователя через запуск в Docker - спрашивает что запустить, ищет подходящий образ, +# определяет GPU для Webots и запускает. def _docker_flow(args: argparse.Namespace) -> None: if not shutil.which("docker"): from rich.console import Console @@ -340,6 +401,8 @@ def _docker_flow(args: argparse.Namespace) -> None: image = _find_image(candidates) if image is None: + # No image available - offer to run docker-setup to get one. + # Образ не найден - предлагаем запустить docker-setup чтобы его получить. what = "Webots" if mode == "webots" else "controller or Webots" v = _ask( "No image found", @@ -352,6 +415,8 @@ def _docker_flow(args: argparse.Namespace) -> None: _docker_setup(args) return + # Only detect GPU for Webots - the controller does not need a display. + # GPU определяем только для Webots - контроллеру дисплей не нужен. gpu = _detect_gpu() if mode == "webots" else "software" label = "Webots simulator" if mode == "webots" else "Controller" @@ -361,10 +426,6 @@ def _docker_flow(args: argparse.Namespace) -> None: ).run() -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - def register(subparsers: argparse._SubParsersAction) -> None: p = subparsers.add_parser( "run", @@ -388,6 +449,8 @@ def run(args: argparse.Namespace) -> None: elif mode == "docker": _docker_flow(args) else: + # No mode given - ask the user how they want to run. + # Режим не указан - спрашиваем пользователя как он хочет запустить. v = _ask( "Run", "How do you want to run the project?", diff --git a/cobot/commands/setup.py b/cobot/commands/setup.py index 1f82ea3..a0a5d0c 100644 --- a/cobot/commands/setup.py +++ b/cobot/commands/setup.py @@ -3,6 +3,8 @@ from typing import List, Optional from textual.app import App +# Import each sub-command's run() so we can call them in sequence. +# Импортируем run() каждой подкоманды, чтобы вызывать их по порядку. from cobot.commands.doc_setup import run as _doc_setup from cobot.commands.docker_setup import run as _docker_setup from cobot.commands.local_setup import run as _local_setup @@ -10,8 +12,11 @@ from cobot.commands.robot_setup import run as _robot_setup from cobot.tui import SCREEN_CSS, PickScreen +# A minimal Textual app that asks a single question and exits with the chosen value. +# We need this because Textual screens cannot run outside of an App context. +# Минимальное Textual-приложение, которое задаёт один вопрос и выходит с выбранным значением. +# Нам это нужно, потому что экраны Textual не могут работать вне контекста приложения. class _Ask(App[Optional[str]]): - """Single-question picker that exits immediately with the chosen value.""" CSS = SCREEN_CSS def __init__(self, step: str, question: str, options: List[str], default: str): @@ -29,6 +34,8 @@ class _Ask(App[Optional[str]]): def _ask(step: str, question: str, options: List[str], default: str) -> Optional[str]: + # Returns None if the user pressed Escape to cancel the whole wizard. + # Возвращает None если пользователь нажал Escape для отмены всего мастера. return _Ask(step, question, options, default).run() @@ -38,14 +45,16 @@ def register(subparsers): def run(args: argparse.Namespace) -> None: - # Step 1 — documentation + # Step 1 - documentation server. + # Шаг 1 - сервер документации. v = _ask("Step 1 of 3", "Set up the documentation server?", ["Yes", "No"], "Yes") if v is None: return if v == "Yes": _doc_setup(args) - # Step 2 — build environment + # Step 2 - build environment: local ROS2 or Docker. + # Шаг 2 - среда сборки: локальный ROS2 или Docker. v = _ask( "Step 2 of 3", "How do you want to set up the build environment?", @@ -62,7 +71,8 @@ def run(args: argparse.Namespace) -> None: else: _docker_setup(args) - # Step 3 — robot parameters + # Step 3 - robot parameters in cobot-setting.yaml. + # Шаг 3 - параметры робота в cobot-setting.yaml. v = _ask( "Step 3 of 3", "Configure robot parameters (cobot-setting.yaml)?", diff --git a/cobot/commands/update.py b/cobot/commands/update.py index edcd646..3505755 100644 --- a/cobot/commands/update.py +++ b/cobot/commands/update.py @@ -11,9 +11,14 @@ from cobot.tui import SCREEN_CSS, LogScreen _PROJECT_DIR = Path(__file__).parent.parent.parent +# Pull the latest commits from the remote and reinstall the cobot CLI in one go. +# Progress bar: fetch (0-30%), pull (30-80%), reinstall (80-100%). +# Скачиваем последние коммиты с удалённого репозитория и переустанавливаем cobot CLI за один раз. +# Прогресс-бар: fetch (0-30%), pull (30-80%), переустановка (80-100%). def _task_update(screen: LogScreen) -> None: try: - # Current branch + # Find out which branch we are on so we can fetch and pull the right one. + # Определяем на какой ветке мы находимся, чтобы делать fetch и pull нужной ветки. branch = subprocess.check_output( ["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=_PROJECT_DIR, text=True, @@ -33,7 +38,8 @@ def _task_update(screen: LogScreen) -> None: return screen.set_progress(30) - # Check how many commits behind + # Count how many commits the remote is ahead of us. + # Считаем сколько коммитов нас опережает удалённый репозиторий. behind = subprocess.check_output( ["git", "rev-list", f"HEAD..origin/{branch}", "--count"], cwd=_PROJECT_DIR, text=True, @@ -45,7 +51,8 @@ def _task_update(screen: LogScreen) -> None: screen.finish(True) return - # Show incoming commits + # Show which commits are coming in so the user knows what changed. + # Показываем какие коммиты приходят, чтобы пользователь знал что изменилось. screen.write(f"\n[bold]{behind} new commit(s):[/bold]") log_lines = subprocess.check_output( ["git", "log", f"HEAD..origin/{branch}", "--oneline"], @@ -71,6 +78,8 @@ def _task_update(screen: LogScreen) -> None: screen.set_progress(80) # Reinstall (80 → 100 %) + # Reinstall so the cobot binary picks up any new dependencies from pyproject.toml. + # Переустанавливаем, чтобы бинарник cobot подхватил новые зависимости из pyproject.toml. screen.set_progress(80, "Reinstalling cobot CLI...") screen.write("\n[cyan][*][/cyan] Reinstalling cobot CLI...") reinstall = subprocess.run( diff --git a/cobot/tui.py b/cobot/tui.py index 1b45076..1d62d1a 100644 --- a/cobot/tui.py +++ b/cobot/tui.py @@ -8,6 +8,8 @@ from textual.binding import Binding from textual.screen import Screen from textual.widgets import Footer, Input, LoadingIndicator, ProgressBar, RadioButton, RadioSet, RichLog, Static +# Shared CSS applied to every screen in the app. +# Общий CSS, применяемый ко всем экранам приложения. SCREEN_CSS = """ Screen { padding: 2 4; @@ -77,6 +79,10 @@ RunScreen #hint { """ +# A screen that shows a question and a list of radio button options. +# The user picks one and presses Enter - the chosen string is returned as the result. +# Экран с вопросом и списком вариантов в виде радио-кнопок. +# Пользователь выбирает один и нажимает Enter - выбранная строка возвращается как результат. class PickScreen(Screen[Optional[str]]): BINDINGS = [ Binding("enter", "submit", "Confirm", priority=True), @@ -98,6 +104,8 @@ class PickScreen(Screen[Optional[str]]): yield Static(self._note, id="note") with RadioSet(id="choices"): for opt in self._options: + # Pre-select the default option so the user can just press Enter to accept it. + # Заранее выделяем вариант по умолчанию, чтобы пользователь мог просто нажать Enter. yield RadioButton(opt, value=(opt == self._default)) yield Footer() @@ -115,9 +123,15 @@ class PickScreen(Screen[Optional[str]]): self.dismiss(str(btn.label) if btn else self._default) def action_abort(self) -> None: + # Exit the whole app, not just this screen, so the calling code knows the user cancelled. + # Выходим из всего приложения, а не только из этого экрана, чтобы вызывающий код знал об отмене. self.app.exit(None) +# A screen that shows a question with a free-text input field. +# The user types a value, presses Enter, and the text is returned as the result. +# Экран с вопросом и полем для ввода произвольного текста. +# Пользователь вводит значение, нажимает Enter, и текст возвращается как результат. class InputScreen(Screen[Optional[str]]): BINDINGS = [ Binding("enter", "submit", "Confirm", priority=True), @@ -154,9 +168,13 @@ class InputScreen(Screen[Optional[str]]): self.app.exit(None) +# A screen that streams output from a background task into a scrollable log. +# Used for long-running operations like installs and builds. +# Press Enter or Escape to close once the task finishes. +# Экран, который транслирует вывод фоновой задачи в прокручиваемый лог. +# Используется для долгих операций, таких как установка и сборка. +# После завершения задачи закрывается по нажатию Enter или Escape. class LogScreen(Screen[bool]): - """Streams task output into a scrollable log; press Enter to close when done.""" - BINDINGS = [Binding("enter,escape", "close", "Close", show=False)] def __init__(self, title: str, task: Callable[[LogScreen], None], show_progress: bool = False): @@ -179,10 +197,13 @@ class LogScreen(Screen[bool]): def on_mount(self) -> None: self.query_one(RichLog).focus() + # Run the task in a worker thread so the UI stays responsive. + # Запускаем задачу в отдельном потоке, чтобы интерфейс не зависал. self.app.run_worker(lambda: self._run_fn(self), thread=True) def set_progress(self, pct: float, label: str = "") -> None: - """Thread-safe: update the progress bar and optional step label.""" + # Thread-safe - this is called from the worker thread, not the UI thread. + # Потокобезопасно - вызывается из рабочего потока, а не из потока интерфейса. if self._show_progress: self.app.call_from_thread(self._do_set_progress, pct, label) @@ -192,14 +213,16 @@ class LogScreen(Screen[bool]): self.query_one("#step-label", Static).update(label) def write(self, line: str) -> None: - """Thread-safe: append a line to the log.""" + # Thread-safe - append a line to the log from a worker thread. + # Потокобезопасно - добавляет строку в лог из рабочего потока. self.app.call_from_thread(self._append, line) def _append(self, line: str) -> None: self.query_one(RichLog).write(line) def finish(self, success: bool) -> None: - """Thread-safe: mark task done and prompt the user to close.""" + # Thread-safe - called by the task when it is done to show the close hint. + # Потокобезопасно - вызывается задачей по завершении, чтобы показать подсказку о закрытии. self.app.call_from_thread(self._do_finish, success) def _do_finish(self, success: bool) -> None: @@ -214,13 +237,17 @@ class LogScreen(Screen[bool]): self.query_one("#hint", Static).update(msg) def action_close(self) -> None: + # Only allow closing after the task has finished, not while it is still running. + # Разрешаем закрытие только после завершения задачи, а не во время её работы. if self._finished: self.dismiss(self._success) +# A screen for a long-running process that the user can stop at any time. +# Shows a live log and offers S / Enter / Escape to stop or close. +# Экран для долго работающего процесса, который пользователь может остановить в любой момент. +# Показывает живой лог и предлагает S / Enter / Escape для остановки или закрытия. class RunScreen(Screen[None]): - """Streams a long-running process. S/Enter/Escape stops or closes.""" - BINDINGS = [ Binding("s", "stop_close", "Stop", show=True, priority=True), Binding("enter", "stop_close", "Close", show=False), @@ -231,8 +258,8 @@ class RunScreen(Screen[None]): super().__init__() self._title = title self._run_fn = task - self._proc = None # set via set_proc() - self._kill_fn = None # optional custom kill callable + self._proc = None # the subprocess, set via set_proc() + self._kill_fn = None # optional custom kill callable, set via set_kill_fn() self._finished = False self._stopped = False @@ -245,23 +272,33 @@ class RunScreen(Screen[None]): def on_mount(self) -> None: self.query_one(RichLog).focus() + # Run the process task in a worker thread so the UI stays responsive. + # Запускаем задачу с процессом в отдельном потоке, чтобы интерфейс не зависал. self.app.run_worker(lambda: self._run_fn(self), thread=True) def set_proc(self, proc) -> None: - """Register the running subprocess so Stop can terminate it.""" + # Register the subprocess so the Stop button knows what to terminate. + # Регистрируем subprocess, чтобы кнопка Stop знала что завершать. self._proc = proc def set_kill_fn(self, fn: Callable) -> None: - """Override the default terminate() with a custom kill function.""" + # Override the default proc.terminate() with a custom kill function. + # For example, docker kill or os.killpg for process groups. + # Заменяем стандартный proc.terminate() кастомной функцией завершения. + # Например, docker kill или os.killpg для групп процессов. self._kill_fn = fn def write(self, line: str) -> None: + # Thread-safe - called from the worker thread to append a log line. + # Потокобезопасно - вызывается из рабочего потока для добавления строки в лог. self.app.call_from_thread(self._append, line) def _append(self, line: str) -> None: self.query_one(RichLog).write(line) def finish(self, stopped: bool = False) -> None: + # Thread-safe - called by the task when the process exits naturally. + # Потокобезопасно - вызывается задачей когда процесс завершается естественным образом. self.app.call_from_thread(self._do_finish, stopped) def _do_finish(self, stopped: bool) -> None: @@ -274,6 +311,10 @@ class RunScreen(Screen[None]): self.query_one("#hint", Static).update(msg) def action_stop_close(self) -> None: + # This runs in the UI thread, so we call _append() directly instead of write() + # because write() uses call_from_thread() which only works from other threads. + # Выполняется в потоке UI, поэтому вызываем _append() напрямую, а не write(), + # потому что write() использует call_from_thread(), который работает только из других потоков. if self._finished: self.dismiss(None) return @@ -288,4 +329,4 @@ class RunScreen(Screen[None]): self._proc.terminate() except Exception: pass - self.write("\n[yellow]Stopping process...[/yellow]") + self._append("\n[yellow]Stopping process...[/yellow]") diff --git a/install.sh b/install.sh index bc52dd3..05d913c 100644 --- a/install.sh +++ b/install.sh @@ -1,9 +1,15 @@ #!/bin/bash +# Stop the script immediately if any command exits with an error. +# Останавливаем скрипт сразу, если какая-либо команда завершилась с ошибкой. set -e +# Disable uv user/project config files so the environment is always clean. +# Отключаем пользовательские и проектные конфиги uv, чтобы среда всегда была чистой. export UV_NO_CONFIG=1 +# Terminal color codes for nicer output. +# Коды цветов для красивого вывода в терминал. RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' @@ -11,19 +17,30 @@ CYAN='\033[0;36m' NC='\033[0m' BOLD='\033[1m' +# Python version that the cobot CLI requires. +# Версия Python, которая нужна для работы cobot CLI. PYTHON_VERSION="3.11" REPO_URL="https://gitverse.ru/daniel-robotics/lightweight-cobot.git" + +# Where to clone the project. Can be overridden by the user with COBOT_INSTALL_DIR. +# Куда клонировать проект. Пользователь может переопределить через COBOT_INSTALL_DIR. INSTALL_DIR="${COBOT_INSTALL_DIR:-$HOME/.lwc}" -# Определяем интерактивный режим: при запуске через curl | bash stdin не является терминалом +# Detect interactive mode - when run via curl | bash, stdin is not a terminal. +# Определяем интерактивный режим - при запуске через curl | bash stdin не является терминалом. if [ -t 0 ]; then IS_INTERACTIVE=true; else IS_INTERACTIVE=false; fi +# Logging helpers - one line per severity level. +# Вспомогательные функции логирования - одна строка на уровень важности. log_info() { echo -e "${CYAN}[*]${NC} $1"; } log_success() { echo -e "${GREEN}[ok]${NC} $1"; } log_warn() { echo -e "${YELLOW}[!]${NC} $1"; } log_error() { echo -e "${RED}[err]${NC} $1"; exit 1; } -# Запускает команду тихо, показывает вывод только при ошибке +# Run a command silently and only print its output if it fails. +# This makes the normal install look clean while still showing errors when something breaks. +# Запускает команду тихо и показывает вывод только если она завершилась с ошибкой. +# Это делает обычную установку аккуратной, но при ошибке мы всё равно видим детали. run_quiet() { local _log _log="$(mktemp /tmp/lwc-cmd.XXXXXX.log)" @@ -45,11 +62,14 @@ print_banner() { echo -e "${NC}" } +# Detect the current OS and package manager so later steps know how to install things. +# Определяем текущую ОС и пакетный менеджер, чтобы следующие шаги знали как устанавливать пакеты. detect_os() { case "$(uname -s)" in Linux*) OS="linux" - # Определяем пакетный менеджер для установки зависимостей + # Pick the first package manager we can find on this system. + # Выбираем первый найденный пакетный менеджер. if command -v apt-get &>/dev/null; then PKG_MANAGER="apt" elif command -v dnf &>/dev/null; then PKG_MANAGER="dnf" elif command -v pacman &>/dev/null; then PKG_MANAGER="pacman" @@ -62,7 +82,8 @@ detect_os() { log_info "OS: $OS" } -# Устанавливает системные пакеты через найденный пакетный менеджер +# Install system packages using whatever package manager was detected above. +# Устанавливает системные пакеты через найденный пакетный менеджер. pkg_install() { case "$PKG_MANAGER" in apt) run_quiet sudo apt-get update -qq && run_quiet sudo apt-get install -y --no-install-recommends "$@" ;; @@ -72,6 +93,8 @@ pkg_install() { esac } +# Check if git is installed and install it if not. +# Проверяем наличие git и устанавливаем его если он отсутствует. check_git() { log_info "Checking git..." if command -v git &>/dev/null; then @@ -87,6 +110,8 @@ check_git() { log_success "git $(git --version | awk '{print $3}') installed" } +# Check if Docker is installed and install it if not. +# Проверяем наличие Docker и устанавливаем его если он отсутствует. check_docker() { log_info "Checking Docker..." if command -v docker &>/dev/null; then @@ -94,8 +119,10 @@ check_docker() { return fi log_info "Installing Docker..." - # Скачиваем установщик во временный файл, а не запускаем через pipe — - # так видны ошибки сети отдельно от ошибок самого установщика + # Download the installer to a temp file instead of piping directly through bash. + # This way network errors and installer errors are shown separately. + # Скачиваем установщик во временный файл, а не запускаем через pipe. + # Так ошибки сети и ошибки самого установщика видны по отдельности. local _installer _installer="$(mktemp /tmp/lwc-docker.XXXXXX.sh)" if ! curl -fsSL https://get.docker.com -o "$_installer"; then @@ -106,16 +133,20 @@ check_docker() { rm -f "$_installer" command -v docker &>/dev/null || log_error "Docker not found after installation" log_success "Docker $(docker --version | awk '{print $3}' | tr -d ',') installed" - # Добавляем пользователя в группу docker, чтобы не требовался sudo + # Add the current user to the docker group so sudo is not needed every time. + # Добавляем текущего пользователя в группу docker, чтобы не требовался sudo каждый раз. if [ "$(id -u)" -ne 0 ] && command -v usermod &>/dev/null; then sudo usermod -aG docker "$USER" log_warn "Added $USER to docker group — re-login to apply" fi } +# Install the uv package manager. We need it to create isolated Python environments. +# Устанавливаем пакетный менеджер uv. Он нужен для создания изолированных Python-окружений. install_uv() { log_info "Checking uv..." - # uv может быть установлен в ~/.local/bin или ~/.cargo/bin, проверяем оба + # uv can end up in ~/.local/bin or ~/.cargo/bin depending on how it was installed. + # uv может оказаться в ~/.local/bin или ~/.cargo/bin в зависимости от способа установки. UV_CMD="" for candidate in "uv" "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv"; do if command -v "$candidate" &>/dev/null 2>&1; then @@ -128,7 +159,10 @@ install_uv() { return fi log_info "Installing uv..." - # Два отдельных файла: лог и установщик — чтобы различать ошибки скачивания и установки + # Two separate temp files - one for the installer script and one for its log. + # This lets us tell apart "download failed" from "installer failed". + # Два отдельных временных файла - для установщика и для его лога. + # Это позволяет различить ошибку скачивания и ошибку самого установщика. local _log _installer _log="$(mktemp /tmp/lwc-uv.XXXXXX.log)" _installer="$(mktemp /tmp/lwc-uv-installer.XXXXXX.sh)" @@ -153,6 +187,8 @@ install_uv() { fi } +# Check that the required Python version is available via uv and install it if not. +# Проверяем наличие нужной версии Python через uv и устанавливаем её если она отсутствует. check_python() { log_info "Checking Python $PYTHON_VERSION..." local py_path @@ -168,6 +204,14 @@ check_python() { log_success "Python $PYTHON_VERSION installed" } +# Figure out where the project lives. Three cases are handled: +# 1. We are already inside the cloned repo - use it directly. +# 2. The repo was cloned before - just pull the latest changes. +# 3. First time - clone the repo fresh. +# Определяем где находится проект. Обрабатываем три случая: +# 1. Мы уже внутри клонированного репозитория - используем его напрямую. +# 2. Репозиторий уже был клонирован ранее - просто тянем последние изменения. +# 3. Первый запуск - клонируем репозиторий заново. resolve_install_dir() { local script_dir script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || pwd)" @@ -196,8 +240,11 @@ resolve_install_dir() { log_info "Cloning repo into $INSTALL_DIR..." mkdir -p "$(dirname "$INSTALL_DIR")" + # Retry up to 5 times because the git server can be unreliable on slow connections. + # Повторяем до 5 раз, потому что git-сервер может быть нестабильным на медленных соединениях. local attempt=1 while [ $attempt -le 5 ]; do + # TODO: Изменить на --depth 1 --branch main после слияния dev в main. if git clone --depth 1 --branch dev "$REPO_URL" "$INSTALL_DIR" > "$shell_rc" echo "export PATH=\"$bin_dir:\$PATH\"" >> "$shell_rc" - # Обновляем PATH внутри скрипта — нужно чтобы cobot setup сработал ниже export PATH="$bin_dir:$PATH" fi command -v cobot &>/dev/null && log_success "cobot -> $(command -v cobot)" @@ -247,8 +299,11 @@ print_success() { echo "" } +# Launch the interactive setup wizard right after installation. +# Запускаем интерактивный мастер настройки сразу после установки. run_setup() { - # Явная проверка — PATH мог не подхватиться если uv положил бинарник в нестандартное место + # Explicit check because PATH might not include ~/.local/bin yet in this shell session. + # Явная проверка, потому что PATH может ещё не включать ~/.local/bin в этой сессии. if ! command -v cobot &>/dev/null; then log_warn "cobot not found on PATH, trying full path..." local cobot_bin="$HOME/.local/bin/cobot" @@ -262,7 +317,8 @@ run_setup() { log_info "Running cobot setup..." cobot setup - # curl | bash: дочерний процесс не может обновить терминал родителя + # When run via curl | bash the child process cannot update the parent terminal's environment. + # При запуске через curl | bash дочерний процесс не может обновить окружение родительского терминала. if [ "$IS_INTERACTIVE" = false ]; then echo "" echo " To apply PATH changes in this terminal, run:"