feat: update robot setup to use ruamel.yaml for preserving YAML comments and formatting

feat: enhance run command with Docker volume for Webots asset caching and GPU detection

feat: improve setup command to sequentially run sub-commands for documentation, environment, and robot parameters

fix: update update command to provide clearer logging during git operations and installation

refactor: enhance TUI components with better threading and logging for long-running tasks

chore: improve install script with better error handling, logging, and interactive setup wizard
This commit is contained in:
Даниил Грабарь
2026-05-21 12:46:54 +10:00
parent e18ff53532
commit 6be6032838
11 changed files with 647 additions and 124 deletions
+8
View File
@@ -1,6 +1,8 @@
import argparse import argparse
import sys import sys
# Import each command module so we can register its subparser.
# Импортируем каждый модуль команды, чтобы зарегистрировать его подпарсер.
from cobot.commands import delete as cmd_delete from cobot.commands import delete as cmd_delete
from cobot.commands import docker_setup as cmd_docker_setup from cobot.commands import docker_setup as cmd_docker_setup
from cobot.commands import doc_setup as cmd_doc_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. # Command groups shown in --help output.
# Add new commands here when introducing other categories. # Add new commands here when introducing other categories.
# Группы команд, отображаемые в --help.
# Добавляйте новые команды сюда при создании новых категорий.
_GROUPS = [ _GROUPS = [
("Setup", [ ("Setup", [
("setup", "first-time setup: docs, build environment, robot config"), ("setup", "first-time setup: docs, build environment, robot config"),
@@ -32,6 +36,8 @@ _GROUPS = [
_DESCRIPTION = "Lightweight Cobot" _DESCRIPTION = "Lightweight Cobot"
# Custom --help action that prints commands grouped by category instead of a flat list.
# Кастомный обработчик --help, который выводит команды по категориям, а не одним списком.
class _GroupedHelpAction(argparse.Action): class _GroupedHelpAction(argparse.Action):
def __init__(self, option_strings, dest, default=None, required=False, help=None): def __init__(self, option_strings, dest, default=None, required=False, help=None):
super().__init__( super().__init__(
@@ -79,6 +85,8 @@ def main():
def _register_commands(subparsers): 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_setup.register(subparsers)
cmd_local_setup.register(subparsers) cmd_local_setup.register(subparsers)
cmd_docker_setup.register(subparsers) cmd_docker_setup.register(subparsers)
+116 -28
View File
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import os
import shutil import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -14,6 +13,8 @@ from cobot.tui import SCREEN_CSS, LogScreen, PickScreen
_PROJECT_DIR = Path(__file__).parent.parent.parent _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: def _stop_docker_containers(write) -> None:
write("[cyan][*][/cyan] Stopping Docker containers...") write("[cyan][*][/cyan] Stopping Docker containers...")
result = subprocess.run( result = subprocess.run(
@@ -29,6 +30,8 @@ def _stop_docker_containers(write) -> None:
write(f"[green][ok][/green] Removed container: {name}") 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: def _remove_docker_images(write) -> None:
write("[cyan][*][/cyan] Removing Docker images...") write("[cyan][*][/cyan] Removing Docker images...")
result = subprocess.run( result = subprocess.run(
@@ -47,14 +50,50 @@ def _remove_docker_images(write) -> None:
write(f"[green][ok][/green] Removed image: {img}") write(f"[green][ok][/green] Removed image: {img}")
def _remove_ros2(write) -> None: # Remove the Docker volume that stores the Webots asset cache.
write("[cyan][*][/cyan] Removing ROS2 Jazzy...") # Удаляем Docker volume с кэшем ассетов Webots.
if Path("/opt/ros/jazzy").exists(): def _remove_webots_volume(write) -> None:
subprocess.run(["sudo", "rm", "-rf", "/opt/ros/jazzy"]) result = subprocess.run(
write("[green][ok][/green] Removed /opt/ros/jazzy") ["docker", "volume", "inspect", "lwc-webots-cache"],
else: capture_output=True,
write("[dim]ROS2 Jazzy not found, skipping.[/dim]") )
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<name>.
# Удаляем все пакеты 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" source_line = "source /opt/ros/jazzy/setup.bash"
for rc_name in [".bashrc", ".zshrc"]: for rc_name in [".bashrc", ".zshrc"]:
rc = Path.home() / rc_name rc = Path.home() / rc_name
@@ -63,12 +102,28 @@ def _remove_ros2(write) -> None:
content = rc.read_text() content = rc.read_text()
if source_line not in content: if source_line not in content:
continue 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 = content.replace(f"\n# ROS2 Jazzy\n{source_line}\n", "\n")
new_content = new_content.replace(source_line, "") new_content = new_content.replace(source_line, "")
rc.write_text(new_content) rc.write_text(new_content)
write(f"[green][ok][/green] Cleaned up ~/{rc_name}") 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: def _uninstall_cobot(write) -> None:
write("[cyan][*][/cyan] Uninstalling cobot CLI...") write("[cyan][*][/cyan] Uninstalling cobot CLI...")
result = subprocess.run( 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'}") 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: def _remove_project_dir(write) -> None:
write(f"[cyan][*][/cyan] Removing project directory...") write(f"[cyan][*][/cyan] Removing project directory...")
try: try:
@@ -91,30 +148,34 @@ def _remove_project_dir(write) -> None:
raise raise
# Run all deletion steps in order.
def _task_delete(screen: LogScreen, remove_ros: bool) -> None: # 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: try:
if remove_ros:
# stop(0-20) images(20-45) ros2(45-70) cobot(70-85) dir(85-100)
screen.set_progress(0, "Stopping containers...") screen.set_progress(0, "Stopping containers...")
_stop_docker_containers(screen.write) _stop_docker_containers(screen.write)
_remove_webots_volume(screen.write)
screen.set_progress(20, "Removing Docker images...") screen.set_progress(20, "Removing Docker images...")
_remove_docker_images(screen.write) _remove_docker_images(screen.write)
screen.set_progress(45, "Removing ROS2 Jazzy...")
pct = 40
if remove_ros:
screen.set_progress(pct, "Removing ROS2 Jazzy...")
_remove_ros2(screen.write) _remove_ros2(screen.write)
screen.set_progress(70, "Uninstalling cobot CLI...") 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) _uninstall_cobot(screen.write)
screen.set_progress(85, "Removing project directory...")
_remove_project_dir(screen.write) screen.set_progress(88, "Removing project directory...")
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) _remove_project_dir(screen.write)
screen.set_progress(100, "Done") screen.set_progress(100, "Done")
@@ -126,7 +187,10 @@ def _task_delete(screen: LogScreen, remove_ros: bool) -> None:
screen.finish(False) 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]): class _DeleteApp(App[None]):
CSS = SCREEN_CSS CSS = SCREEN_CSS
@@ -148,7 +212,7 @@ class _DeleteApp(App[None]):
self.push_screen( self.push_screen(
PickScreen( PickScreen(
"ROS2 Jazzy", "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", "Yes, remove ROS2 Jazzy"],
"No, keep ROS2", "No, keep ROS2",
), ),
@@ -157,8 +221,32 @@ class _DeleteApp(App[None]):
def _on_ros_choice(self, choice: Optional[str]) -> None: def _on_ros_choice(self, choice: Optional[str]) -> None:
remove_ros = choice is not None and choice.startswith("Yes") 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( self.push_screen(
LogScreen("Deleting project", lambda s: _task_delete(s, remove_ros), show_progress=True), 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, remove_webots),
show_progress=True,
),
lambda _: self.exit(), lambda _: self.exit(),
) )
+29
View File
@@ -14,6 +14,11 @@ from textual.app import App
from cobot.tui import SCREEN_CSS, InputScreen, LogScreen from cobot.tui import SCREEN_CSS, InputScreen, LogScreen
_PROJECT_DIR = Path(__file__).parent.parent.parent _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" _DOC_DIR = _PROJECT_DIR / "doc" / "lwc-doc"
_IMAGE_NAME = "lwc-docs" _IMAGE_NAME = "lwc-docs"
_CONTAINER_NAME = "lwc-docs" _CONTAINER_NAME = "lwc-docs"
@@ -22,24 +27,36 @@ _DEFAULT_PORT = "8000"
Write = Callable[[str], None] 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: def _docker(*args: str, capture: bool = False) -> subprocess.CompletedProcess:
return subprocess.run(["docker", *args], capture_output=capture, text=True) return subprocess.run(["docker", *args], capture_output=capture, text=True)
# Check whether the docs container is currently running.
# Проверяем, запущен ли сейчас контейнер с документацией.
def _is_running() -> bool: def _is_running() -> bool:
r = _docker("ps", "--filter", f"name={_CONTAINER_NAME}", "--format", "{{.Names}}", capture=True) r = _docker("ps", "--filter", f"name={_CONTAINER_NAME}", "--format", "{{.Names}}", capture=True)
return _CONTAINER_NAME in r.stdout return _CONTAINER_NAME in r.stdout
# Check whether the docs Docker image has already been built.
# Проверяем, был ли уже собран Docker-образ для документации.
def _image_exists() -> bool: def _image_exists() -> bool:
return bool(_docker("images", "-q", _IMAGE_NAME, capture=True).stdout.strip()) 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( def _build_docs_image(
write: Write, write: Write,
on_progress: Optional[Callable[[float], None]] = None, on_progress: Optional[Callable[[float], None]] = None,
) -> bool: ) -> bool:
write("[cyan][*][/cyan] Building documentation image (runs once)...") 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"} env = {**os.environ, "DOCKER_BUILDKIT": "0"}
proc = subprocess.Popen( proc = subprocess.Popen(
["docker", "build", "-t", _IMAGE_NAME, str(_DOC_DIR)], ["docker", "build", "-t", _IMAGE_NAME, str(_DOC_DIR)],
@@ -62,6 +79,8 @@ def _build_docs_image(
return False return False
# Start the docs server. Builds the image first if it does not exist yet.
# Запускаем сервер документации. Сначала собирает образ, если он ещё не существует.
def _task_up(screen: LogScreen, port: str) -> None: def _task_up(screen: LogScreen, port: str) -> None:
try: try:
if _is_running(): if _is_running():
@@ -91,6 +110,8 @@ def _task_up(screen: LogScreen, port: str) -> None:
result = _docker( result = _docker(
"run", "-d", "--name", _CONTAINER_NAME, "--rm", "run", "-d", "--name", _CONTAINER_NAME, "--rm",
"-p", f"{port}:8000", "-p", f"{port}:8000",
# Mount the docs directory so edits appear live without restarting the container.
# Монтируем директорию с документацией, чтобы изменения появлялись сразу без перезапуска.
"-v", f"{_DOC_DIR}:/docs", "-v", f"{_DOC_DIR}:/docs",
_IMAGE_NAME, "serve", "--dev-addr=0.0.0.0:8000", _IMAGE_NAME, "serve", "--dev-addr=0.0.0.0:8000",
capture=True, capture=True,
@@ -111,6 +132,8 @@ def _task_up(screen: LogScreen, port: str) -> None:
screen.finish(False) screen.finish(False)
# Stop the running docs container.
# Останавливаем работающий контейнер с документацией.
def _task_down(screen: LogScreen) -> None: def _task_down(screen: LogScreen) -> None:
try: try:
if not _is_running(): if not _is_running():
@@ -128,6 +151,8 @@ def _task_down(screen: LogScreen) -> None:
screen.finish(False) 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: def _task_rebuild(screen: LogScreen, port: str) -> None:
try: try:
if _is_running(): if _is_running():
@@ -174,6 +199,8 @@ def _task_rebuild(screen: LogScreen, port: str) -> None:
screen.finish(False) 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]): class _DocApp(App[None]):
CSS = SCREEN_CSS CSS = SCREEN_CSS
@@ -202,6 +229,8 @@ class _DocApp(App[None]):
if port is None: if port is None:
self.exit() self.exit()
return 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 p = (port.strip() or _DEFAULT_PORT) if port.isdigit() or not port.strip() else _DEFAULT_PORT
self.push_screen( self.push_screen(
LogScreen("Documentation server", lambda s: _task_up(s, p), show_progress=True), LogScreen("Documentation server", lambda s: _task_up(s, p), show_progress=True),
+37
View File
@@ -16,12 +16,19 @@ from cobot.tui import SCREEN_CSS, InputScreen, LogScreen, PickScreen
_PROJECT_DIR = Path(__file__).parent.parent.parent _PROJECT_DIR = Path(__file__).parent.parent.parent
_DOCKER_DIR = _PROJECT_DIR / "docker" _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_HUB_REPO = "evilfisru/lwc"
_DEFAULT_PREFIX = "lwc-local" _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"] _CONTROLLER_CHAIN = ["ros-core", "ros-base", "ros-iiwa7"]
_WEBOTS_CHAIN = ["ros-core", "ros-base", "ros-iiwa7-webots"] _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] = { _IMAGE_PARENT: dict[str, str | None] = {
"ros-core": None, "ros-core": None,
"ros-base": "ros-core", "ros-base": "ros-core",
@@ -29,11 +36,15 @@ _IMAGE_PARENT: dict[str, str | None] = {
"ros-iiwa7-webots": "ros-base", "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"} _NEEDS_PROJECT_CTX = {"ros-iiwa7", "ros-iiwa7-webots"}
Write = Callable[[str], None] Write = Callable[[str], None]
# All the choices the user makes in the wizard are stored here before we start the actual build.
# Все выборы пользователя в мастере хранятся здесь перед началом фактической сборки.
@dataclass @dataclass
class _Config: class _Config:
ros_version: str ros_version: str
@@ -44,6 +55,10 @@ class _Config:
hub_repo: str 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( def _build_image(
name: str, name: str,
tag: str, tag: str,
@@ -55,6 +70,8 @@ def _build_image(
build_type: str = "release", build_type: str = "release",
) -> bool: ) -> bool:
write(f"[cyan][*][/cyan] Building [bold]{name}[/bold]...") 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"} env = {**os.environ, "DOCKER_BUILDKIT": "0"}
cmd = [ cmd = [
"docker", "build", "-t", tag, "-f", str(dockerfile), "docker", "build", "-t", tag, "-f", str(dockerfile),
@@ -84,6 +101,8 @@ def _build_image(
return False return False
# Pull a Docker image from Hub and track progress by counting downloaded layers.
# Скачиваем Docker-образ с Hub и отслеживаем прогресс по количеству скачанных слоёв.
def _pull_image( def _pull_image(
name: str, name: str,
tag: str, tag: str,
@@ -104,6 +123,8 @@ def _pull_image(
s = line.rstrip() s = line.rstrip()
if s: if s:
write(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: if "Pulling fs layer" in line or "Waiting" in line:
layers_total += 1 layers_total += 1
elif "Pull complete" in line or "Already exists" in line: elif "Pull complete" in line or "Already exists" in line:
@@ -120,6 +141,10 @@ def _pull_image(
return False 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: def _task_execute(screen: LogScreen, cfg: _Config) -> None:
try: try:
chain = _WEBOTS_CHAIN if cfg.variant == "webots" else _CONTROLLER_CHAIN 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) 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]: def _discover_versions() -> List[str]:
if not _DOCKER_DIR.exists(): if not _DOCKER_DIR.exists():
return ["jazzy"] return ["jazzy"]
dirs = sorted(d.name for d in _DOCKER_DIR.iterdir() if d.is_dir()) 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: if "jazzy" in dirs:
dirs = ["jazzy"] + [d for d in dirs if d != "jazzy"] dirs = ["jazzy"] + [d for d in dirs if d != "jazzy"]
return dirs or ["jazzy"] return dirs or ["jazzy"]
# Multi-step wizard that collects all build options before starting the actual image build.
# Многошаговый мастер, который собирает все параметры сборки перед запуском фактической сборки образа.
class _Wizard(App[None]): class _Wizard(App[None]):
CSS = SCREEN_CSS CSS = SCREEN_CSS
@@ -264,6 +297,8 @@ class _Wizard(App[None]):
self.exit() self.exit()
return return
self._state["build_type"] = v or "release" 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": if self._state["source"] == "pull":
self.push_screen( self.push_screen(
InputScreen("Step 5 of 5", "Docker Hub repository:", _DEFAULT_HUB_REPO), InputScreen("Step 5 of 5", "Docker Hub repository:", _DEFAULT_HUB_REPO),
@@ -290,6 +325,8 @@ class _Wizard(App[None]):
self._finish() self._finish()
def _finish(self) -> None: def _finish(self) -> None:
# Assemble the config and hand it off to the log screen that does the actual work.
# Собираем конфиг и передаём его экрану лога, который выполняет фактическую работу.
s = self._state s = self._state
cfg = _Config( cfg = _Config(
ros_version=s["ros_version"], ros_version=s["ros_version"],
+167 -15
View File
@@ -17,12 +17,19 @@ from cobot.commands.docker_setup import run as _docker_setup
_PROJECT_DIR = Path(__file__).parent.parent.parent _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_KEYRING = Path("/usr/share/keyrings/ros-archive-keyring.gpg")
_ROS_SOURCES = Path("/etc/apt/sources.list.d/ros2.list") _ROS_SOURCES = Path("/etc/apt/sources.list.d/ros2.list")
_ROS_KEY_URL = "https://raw.githubusercontent.com/ros/rosdistro/master/ros.key" _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"} _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: def _detect_ubuntu_2404() -> bool:
path = Path("/etc/os-release") path = Path("/etc/os-release")
if not path.exists(): 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" 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: def _detect_ros2_jazzy() -> bool:
return Path("/opt/ros/jazzy").is_dir() return Path("/opt/ros/jazzy").is_dir()
@@ -42,6 +51,8 @@ def _detect_ros2_jazzy() -> bool:
Write = Callable[[str], None] 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: def _run_quiet(cmd: List[str], write: Write | None = None, env: dict | None = None, cwd=None) -> None:
result = subprocess.run( result = subprocess.run(
cmd, capture_output=True, text=True, 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]}") 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: def _run_logged(cmd: List[str], write: Write, env: dict | None = None, cwd=None) -> None:
proc = subprocess.Popen( proc = subprocess.Popen(
cmd, cmd,
@@ -80,6 +93,10 @@ def _run_apt_with_progress(
env: dict | None = None, env: dict | None = None,
) -> None: ) -> None:
"""Run an apt command and feed real percentage from APT::Status-Fd to on_progress(0-100).""" """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() r_fd, w_fd = os.pipe()
try: try:
proc = subprocess.Popen( proc = subprocess.Popen(
@@ -91,6 +108,8 @@ def _run_apt_with_progress(
pass_fds=(w_fd,), pass_fds=(w_fd,),
) )
finally: finally:
# Close the write end in the parent process so the reader thread gets EOF when apt exits.
# Закрываем пишущий конец в родительском процессе, чтобы читающий поток получил EOF при выходе apt.
os.close(w_fd) os.close(w_fd)
def _read_status() -> None: def _read_status() -> None:
@@ -116,10 +135,8 @@ def _run_apt_with_progress(
raise RuntimeError(f"Command failed: {cmd[0]}") raise RuntimeError(f"Command failed: {cmd[0]}")
# --------------------------------------------------------------------------- # Make sure the system has a UTF-8 locale, which ROS2 requires to work correctly.
# Installation steps # Убеждаемся, что в системе есть локаль UTF-8, которая требуется ROS2 для корректной работы.
# ---------------------------------------------------------------------------
def _setup_locale(write: Write) -> None: def _setup_locale(write: Write) -> None:
write("[cyan][*][/cyan] Checking locale...") write("[cyan][*][/cyan] Checking locale...")
if "UTF-8" in subprocess.run(["locale"], capture_output=True, text=True).stdout: 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") 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 _add_ros2_repo(write: Write, on_progress: Optional[Callable[[float], None]] = None) -> None:
def _prog(p: float) -> None: def _prog(p: float) -> None:
if on_progress: if on_progress:
@@ -159,6 +178,8 @@ def _add_ros2_repo(write: Write, on_progress: Optional[Callable[[float], None]]
try: try:
urllib.request.urlretrieve(_ROS_KEY_URL, tmp_path) urllib.request.urlretrieve(_ROS_KEY_URL, tmp_path)
_prog(60) _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]) _run_quiet(["sudo", "gpg", "--dearmor", "--yes", "-o", str(_ROS_KEYRING), tmp_path])
finally: finally:
os.unlink(tmp_path) os.unlink(tmp_path)
@@ -192,6 +213,8 @@ def _add_ros2_repo(write: Write, on_progress: Optional[Callable[[float], None]]
_prog(100) _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: 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...") write("[cyan][*][/cyan] Installing ros-jazzy-desktop and ros-dev-tools...")
_run_apt_with_progress( _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") 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: def _install_colcon(write: Write) -> None:
if shutil.which("colcon"): if shutil.which("colcon"):
write("[green][ok][/green] colcon already available") write("[green][ok][/green] colcon already available")
@@ -216,6 +241,10 @@ def _install_colcon(write: Write) -> None:
write("[green][ok][/green] colcon installed") 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: def _setup_shell_rc(write: Write) -> None:
shell_name = Path(os.environ.get("SHELL", "/bin/bash")).name shell_name = Path(os.environ.get("SHELL", "/bin/bash")).name
rc = Path.home() / (".zshrc" if shell_name == "zsh" else ".bashrc") 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}") write(f"[green][ok][/green] Added ROS2 setup to ~/{rc.name}")
# --------------------------------------------------------------------------- # Full ROS2 Jazzy installation split into 5 clearly visible steps with individual progress ranges.
# Background tasks (run inside LogScreen worker) # Полная установка ROS2 Jazzy, разбитая на 5 наглядных шагов с отдельными диапазонами прогресса.
# ---------------------------------------------------------------------------
def _task_install_jazzy(screen: LogScreen) -> None: def _task_install_jazzy(screen: LogScreen) -> None:
try: try:
# Step 1 — locale (0 → 5 %) # Step 1 — locale (0 → 5 %)
@@ -275,6 +302,8 @@ def _task_install_jazzy(screen: LogScreen) -> None:
screen.finish(False) screen.finish(False)
# Build all project packages with colcon and track progress by counting finished packages.
# Собираем все пакеты проекта с помощью colcon и отслеживаем прогресс по количеству завершённых пакетов.
def _task_build(screen: LogScreen) -> None: def _task_build(screen: LogScreen) -> None:
try: try:
if not shutil.which("colcon"): if not shutil.which("colcon"):
@@ -283,7 +312,8 @@ def _task_build(screen: LogScreen) -> None:
screen.finish(False) screen.finish(False)
return 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( list_result = subprocess.run(
["colcon", "list"], capture_output=True, text=True, cwd=_PROJECT_DIR, ["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: def _track(line: str) -> None:
nonlocal built nonlocal built
screen.write(line) screen.write(line)
# colcon prints "Finished <<<" or "Failed <<<" when each package is done.
# colcon печатает "Finished <<<" или "Failed <<<" когда каждый пакет готов.
if "Finished <<<" in line or "Failed <<<" in line: if "Finished <<<" in line or "Failed <<<" in line:
built += 1 built += 1
screen.set_progress(built / total * 100, f"{built} / {total} packages done") screen.set_progress(built / total * 100, f"{built} / {total} packages done")
@@ -310,10 +342,109 @@ def _task_build(screen: LogScreen) -> None:
screen.finish(False) screen.finish(False)
# --------------------------------------------------------------------------- # Webots version that matches the Docker images used in this project.
# Textual apps # Версия 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]): class _InstallJazzyApp(App[None]):
CSS = SCREEN_CSS 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]): class _BuildApp(App[None]):
CSS = SCREEN_CSS 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]): class _DockerPromptApp(App[bool]):
CSS = SCREEN_CSS CSS = SCREEN_CSS
@@ -363,9 +498,6 @@ class _DockerPromptApp(App[bool]):
) )
# ---------------------------------------------------------------------------
# CLI registration
# ---------------------------------------------------------------------------
def register(subparsers: argparse._SubParsersAction) -> None: def register(subparsers: argparse._SubParsersAction) -> None:
p = subparsers.add_parser( p = subparsers.add_parser(
@@ -376,13 +508,33 @@ def register(subparsers: argparse._SubParsersAction) -> None:
def run(args: argparse.Namespace) -> 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 not _detect_ubuntu_2404():
if _DockerPromptApp().run(): if _DockerPromptApp().run():
_docker_setup(args) _docker_setup(args)
return 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(): if not _detect_ros2_jazzy():
_InstallJazzyApp().run() _InstallJazzyApp().run()
return return
# ROS2 is ready - build the project.
# ROS2 готов - собираем проект.
_BuildApp().run() _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()
+38 -8
View File
@@ -17,10 +17,16 @@ from cobot.tui import SCREEN_CSS, InputScreen, PickScreen
_PROJECT_DIR = Path(__file__).parent.parent.parent _PROJECT_DIR = Path(__file__).parent.parent.parent
_CONFIG_PATH = _PROJECT_DIR / "cobot-setting.yaml" _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 = YAML()
_yaml.preserve_quotes = True _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 @dataclass
class _Field: class _Field:
key: str # dot-separated path within the block, e.g. "webots.world" key: str # dot-separated path within the block, e.g. "webots.world"
@@ -33,6 +39,8 @@ class _Field:
return self.key.split(".")[-1] return self.key.split(".")[-1]
# A group of related fields shown together under one "Configure X?" question.
# Группа связанных полей, показываемая вместе под одним вопросом "Настроить X?".
@dataclass @dataclass
class _Block: class _Block:
yaml_key: str # top-level key in cobot-setting.yaml yaml_key: str # top-level key in cobot-setting.yaml
@@ -40,6 +48,8 @@ class _Block:
fields: List[_Field] fields: List[_Field]
# All configuration blocks. Each block maps to a top-level key in cobot-setting.yaml.
# Все блоки конфигурации. Каждый блок соответствует ключу верхнего уровня в cobot-setting.yaml.
_BLOCKS: List[_Block] = [ _BLOCKS: List[_Block] = [
_Block( _Block(
yaml_key="foxglove", yaml_key="foxglove",
@@ -101,17 +111,20 @@ _BLOCKS: List[_Block] = [
_Field("active_controller", "Active ROS controller:", "jtc", _Field("active_controller", "Active ROS controller:", "jtc",
note="jtc = JointTrajectoryController (MoveIt), forward = ForwardCommandController", note="jtc = JointTrajectoryController (MoveIt), forward = ForwardCommandController",
options=["jtc", "forward"]), 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"), 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"), 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: def _coerce(value: str, original: Any) -> Any:
"""Try to preserve the original YAML scalar type."""
if isinstance(original, bool): if isinstance(original, bool):
return value.lower() == "true" return value.lower() == "true"
if isinstance(original, int): if isinstance(original, int):
@@ -127,6 +140,8 @@ def _coerce(value: str, original: Any) -> Any:
return value 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: def _get_nested(mapping: Any, path: str) -> Any:
keys = path.split(".") keys = path.split(".")
cur = mapping cur = mapping
@@ -137,6 +152,8 @@ def _get_nested(mapping: Any, path: str) -> Any:
return cur 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: def _set_nested(mapping: Any, path: str, value: Any) -> None:
keys = path.split(".") keys = path.split(".")
cur = mapping cur = mapping
@@ -146,6 +163,8 @@ def _set_nested(mapping: Any, path: str, value: Any) -> None:
cur[keys[-1]] = _coerce(value, original) cur[keys[-1]] = _coerce(value, original)
# Shown after all blocks have been configured to confirm the file was saved.
# Показывается после настройки всех блоков для подтверждения сохранения файла.
class _SavedScreen(Screen[None]): class _SavedScreen(Screen[None]):
BINDINGS = [Binding("enter,escape", "close", "Close")] BINDINGS = [Binding("enter,escape", "close", "Close")]
@@ -159,13 +178,17 @@ class _SavedScreen(Screen[None]):
self.dismiss(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]): class _Wizard(App[None]):
CSS = SCREEN_CSS CSS = SCREEN_CSS
def __init__(self, data: Any): def __init__(self, data: Any):
super().__init__() super().__init__()
self._data = data self._data = data
self._blocks = list(_BLOCKS) # copy so we can pop self._blocks = list(_BLOCKS)
self._block_idx = 0 self._block_idx = 0
self._field_idx = 0 self._field_idx = 0
self._current_block: Optional[_Block] = None self._current_block: Optional[_Block] = None
@@ -174,9 +197,10 @@ class _Wizard(App[None]):
def on_mount(self) -> None: def on_mount(self) -> None:
self._next_block() self._next_block()
def _next_block(self) -> None: def _next_block(self) -> None:
if self._block_idx >= len(self._blocks): if self._block_idx >= len(self._blocks):
# All blocks done - save and show the confirmation screen.
# Все блоки пройдены - сохраняем и показываем экран подтверждения.
_save_config(self._data) _save_config(self._data)
self.push_screen(_SavedScreen(), lambda _: self.exit()) self.push_screen(_SavedScreen(), lambda _: self.exit())
return return
@@ -204,9 +228,10 @@ class _Wizard(App[None]):
self._field_idx = 0 self._field_idx = 0
self._next_field() self._next_field()
else: else:
# Skip all fields in this block and jump to the next block.
# Пропускаем все поля этого блока и переходим к следующему.
self._next_block() self._next_block()
def _next_field(self) -> None: def _next_field(self) -> None:
if not self._pending_fields: if not self._pending_fields:
self._next_block() self._next_block()
@@ -220,7 +245,7 @@ class _Wizard(App[None]):
field_num = self._field_idx field_num = self._field_idx
total_fields = len(block.fields) 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 # Resolve current value from loaded YAML as the pre-filled default
yaml_val = _get_nested(self._data[block.yaml_key], f.key) yaml_val = _get_nested(self._data[block.yaml_key], f.key)
@@ -241,16 +266,21 @@ class _Wizard(App[None]):
return return
block = self._current_block block = self._current_block
_set_nested(self._data[block.yaml_key], f.key, v) _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._pending_fields.pop(0)
self._next_field() self._next_field()
# Load the config file preserving all comments and key order.
# Загружаем конфиг-файл, сохраняя все комментарии и порядок ключей.
def _load_config() -> Any: def _load_config() -> Any:
with open(_CONFIG_PATH, "r", encoding="utf-8") as fh: with open(_CONFIG_PATH, "r", encoding="utf-8") as fh:
return _yaml.load(fh) return _yaml.load(fh)
# Write the modified config back to disk preserving comments and formatting.
# Записываем изменённый конфиг обратно на диск, сохраняя комментарии и форматирование.
def _save_config(data: Any) -> None: def _save_config(data: Any) -> None:
with open(_CONFIG_PATH, "w", encoding="utf-8") as fh: with open(_CONFIG_PATH, "w", encoding="utf-8") as fh:
_yaml.dump(data, fh) _yaml.dump(data, fh)
+98 -35
View File
@@ -11,17 +11,32 @@ from typing import Callable, List, Optional
from textual.app import App from textual.app import App
from cobot.tui import SCREEN_CSS, LogScreen, PickScreen, RunScreen 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 _PROJECT_DIR = Path(__file__).parent.parent.parent
_CONFIG_PATH = _PROJECT_DIR / "cobot-setting.yaml" _CONFIG_PATH = _PROJECT_DIR / "cobot-setting.yaml"
_INSTALL_DIR = _PROJECT_DIR / "install" _INSTALL_DIR = _PROJECT_DIR / "install"
_JAZZY_DIR = Path("/opt/ros/jazzy") _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" _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_CONTROLLER = "lwc-controller"
_CONTAINER_WEBOTS = "lwc-webots" _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 = [ _CONTROLLER_IMAGES = [
"lwc-local:ros-iiwa7-jazzy", "lwc-local:ros-iiwa7-jazzy",
"evilfisru/lwc:iiwa-jazzy", "evilfisru/lwc:iiwa-jazzy",
@@ -37,10 +52,10 @@ _WEBOTS_IMAGES = [
] ]
# --------------------------------------------------------------------------- # A minimal app that asks one question and exits immediately with the chosen value.
# Small utilities # We need a full App because Textual screens cannot run outside one.
# --------------------------------------------------------------------------- # Минимальное приложение, которое задаёт один вопрос и сразу выходит с выбранным значением.
# Нам нужен полноценный App, потому что экраны Textual не могут работать вне него.
class _Ask(App[Optional[str]]): class _Ask(App[Optional[str]]):
CSS = SCREEN_CSS 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]: 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() 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: def _detect_gpu() -> str:
if shutil.which("nvidia-smi"): if shutil.which("nvidia-smi"):
if subprocess.run(["nvidia-smi"], capture_output=True).returncode == 0: if subprocess.run(["nvidia-smi"], capture_output=True).returncode == 0:
@@ -71,6 +90,8 @@ def _detect_gpu() -> str:
return "software" return "software"
# List all Docker images currently available on this machine.
# Получаем список всех Docker-образов доступных на этой машине.
def _docker_images() -> set: def _docker_images() -> set:
r = subprocess.run( r = subprocess.run(
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"], ["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
@@ -79,6 +100,8 @@ def _docker_images() -> set:
return set(r.stdout.strip().splitlines()) 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]: def _find_image(candidates: List[str]) -> Optional[str]:
available = _docker_images() available = _docker_images()
for img in candidates: for img in candidates:
@@ -87,14 +110,16 @@ def _find_image(candidates: List[str]) -> Optional[str]:
return None return None
# --------------------------------------------------------------------------- # Build the ROS2 project locally with colcon. Used when launching in local mode
# Build project (colcon build --mixin release) # and the install/ directory does not exist yet.
# --------------------------------------------------------------------------- # Собираем ROS2-проект локально с помощью colcon. Используется при запуске в локальном режиме,
# если директория install/ ещё не существует.
def _task_build(screen: LogScreen) -> None: def _task_build(screen: LogScreen) -> None:
try: try:
screen.write("[bold]Building project with colcon[/bold]\n") screen.write("[bold]Building project with colcon[/bold]\n")
# Count packages first so we can show X/total progress.
# Сначала считаем пакеты, чтобы показывать X/всего в прогрессе.
list_proc = subprocess.run( list_proc = subprocess.run(
["bash", "-c", f"source {_JAZZY_DIR}/setup.bash && colcon list"], ["bash", "-c", f"source {_JAZZY_DIR}/setup.bash && colcon list"],
capture_output=True, text=True, cwd=_PROJECT_DIR, capture_output=True, text=True, cwd=_PROJECT_DIR,
@@ -113,6 +138,8 @@ def _task_build(screen: LogScreen) -> None:
s = line.rstrip() s = line.rstrip()
if s: if s:
screen.write(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: if "Finished <<<" in line or "Failed <<<" in line:
built += 1 built += 1
screen.set_progress(built / total * 100, f"{built} / {total} packages done") screen.set_progress(built / total * 100, f"{built} / {total} packages done")
@@ -141,10 +168,10 @@ class _BuildApp(App[bool]):
) )
# --------------------------------------------------------------------------- # Start the ROS2 launch file directly on this machine without Docker.
# Local launch task # 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: def _task_run_local(screen: RunScreen, mode: str) -> None:
config = str(_CONFIG_PATH) config = str(_CONFIG_PATH)
ros_cmd = f"ros2 launch iiwa_bringup iiwa.launch.py setting:={config}" 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, start_new_session=True,
) )
screen.set_proc(proc) 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)) screen.set_kill_fn(lambda: os.killpg(os.getpgid(proc.pid), signal.SIGTERM))
for line in proc.stdout: for line in proc.stdout:
@@ -179,18 +208,22 @@ def _task_run_local(screen: RunScreen, mode: str) -> None:
screen.finish(stopped=screen._stopped) screen.finish(stopped=screen._stopped)
# --------------------------------------------------------------------------- # Start the ROS2 launch file inside a Docker container.
# Docker launch task # 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: def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None:
container = _CONTAINER_WEBOTS if mode == "webots" else _CONTAINER_CONTROLLER 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": if mode == "webots":
ros_cmd += " simulate:=1" 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) subprocess.run(["docker", "rm", "-f", container], capture_output=True)
cmd = [ cmd = [
@@ -201,11 +234,16 @@ def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None
] ]
if mode == "webots": if mode == "webots":
# Allow the container to open windows on the host display.
# Разрешаем контейнеру открывать окна на дисплее хоста.
subprocess.run(["xhost", "+local:docker"], capture_output=True) subprocess.run(["xhost", "+local:docker"], capture_output=True)
cmd += [ cmd += [
"-e", f"DISPLAY={os.environ.get('DISPLAY', ':0')}", "-e", f"DISPLAY={os.environ.get('DISPLAY', ':0')}",
"-e", "QT_X11_NO_MITSHM=1", "-e", "QT_X11_NO_MITSHM=1",
"-v", "/tmp/.X11-unix:/tmp/.X11-unix:rw", "-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": if gpu == "nvidia":
cmd += [ 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", "-e", "NVIDIA_DRIVER_CAPABILITIES=graphics,utility,compute",
] ]
elif gpu == "mesa": elif gpu == "mesa":
# Pass through the DRI device for Intel/AMD hardware acceleration.
# Пробрасываем DRI-устройство для аппаратного ускорения Intel/AMD.
cmd += ["--device", "/dev/dri"] cmd += ["--device", "/dev/dri"]
else: else:
# No GPU found - fall back to software rendering via llvmpipe.
# GPU не найден - используем программный рендеринг через llvmpipe.
cmd += [ cmd += [
"-e", "LIBGL_ALWAYS_SOFTWARE=1", "-e", "LIBGL_ALWAYS_SOFTWARE=1",
"-e", "GALLIUM_DRIVER=llvmpipe", "-e", "GALLIUM_DRIVER=llvmpipe",
] ]
# Mount the config file so the container uses our local cobot-setting.yaml.
# Монтируем конфиг-файл, чтобы контейнер использовал наш локальный cobot-setting.yaml.
if _CONFIG_PATH.exists(): if _CONFIG_PATH.exists():
cmd += ["-v", f"{_CONFIG_PATH}:{_CONFIG_IN_CONTAINER}:ro"] 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, text=True,
) )
screen.set_proc(proc) 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: for line in proc.stdout:
s = line.rstrip() 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) screen.finish(stopped=screen._stopped)
# --------------------------------------------------------------------------- # Wraps a RunScreen in an App so it can be launched with .run().
# RunApp wrapper # Оборачивает RunScreen в App, чтобы его можно было запустить через .run().
# ---------------------------------------------------------------------------
class _RunApp(App[None]): class _RunApp(App[None]):
CSS = SCREEN_CSS CSS = SCREEN_CSS
@@ -270,10 +317,10 @@ class _RunApp(App[None]):
self.push_screen(RunScreen(self._title, self._run_fn), lambda _: self.exit()) self.push_screen(RunScreen(self._title, self._run_fn), lambda _: self.exit())
# --------------------------------------------------------------------------- # Guide the user through launching locally - asks what to run, checks prerequisites,
# Local flow # installs Webots and builds the project if needed, then launches.
# --------------------------------------------------------------------------- # Ведёт пользователя через локальный запуск - спрашивает что запустить, проверяет
# предварительные условия, устанавливает Webots и собирает проект при необходимости, затем запускает.
def _local_flow(args: argparse.Namespace) -> None: def _local_flow(args: argparse.Namespace) -> None:
mode_v = _ask( mode_v = _ask(
"Run local", "Run local",
@@ -285,6 +332,20 @@ def _local_flow(args: argparse.Namespace) -> None:
return return
mode = "webots" if mode_v == "Webots simulator" else "controller" 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 # Check ROS2 Jazzy
if not _JAZZY_DIR.is_dir(): if not _JAZZY_DIR.is_dir():
v = _ask( 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() _RunApp(f"Running {label} — local", lambda s: _task_run_local(s, mode)).run()
# --------------------------------------------------------------------------- # Guide the user through launching in Docker - asks what to run, finds a suitable image,
# Docker flow # detects the GPU for Webots, and launches.
# --------------------------------------------------------------------------- # Ведёт пользователя через запуск в Docker - спрашивает что запустить, ищет подходящий образ,
# определяет GPU для Webots и запускает.
def _docker_flow(args: argparse.Namespace) -> None: def _docker_flow(args: argparse.Namespace) -> None:
if not shutil.which("docker"): if not shutil.which("docker"):
from rich.console import Console from rich.console import Console
@@ -340,6 +401,8 @@ def _docker_flow(args: argparse.Namespace) -> None:
image = _find_image(candidates) image = _find_image(candidates)
if image is None: 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" what = "Webots" if mode == "webots" else "controller or Webots"
v = _ask( v = _ask(
"No image found", "No image found",
@@ -352,6 +415,8 @@ def _docker_flow(args: argparse.Namespace) -> None:
_docker_setup(args) _docker_setup(args)
return return
# Only detect GPU for Webots - the controller does not need a display.
# GPU определяем только для Webots - контроллеру дисплей не нужен.
gpu = _detect_gpu() if mode == "webots" else "software" gpu = _detect_gpu() if mode == "webots" else "software"
label = "Webots simulator" if mode == "webots" else "Controller" label = "Webots simulator" if mode == "webots" else "Controller"
@@ -361,10 +426,6 @@ def _docker_flow(args: argparse.Namespace) -> None:
).run() ).run()
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def register(subparsers: argparse._SubParsersAction) -> None: def register(subparsers: argparse._SubParsersAction) -> None:
p = subparsers.add_parser( p = subparsers.add_parser(
"run", "run",
@@ -388,6 +449,8 @@ def run(args: argparse.Namespace) -> None:
elif mode == "docker": elif mode == "docker":
_docker_flow(args) _docker_flow(args)
else: else:
# No mode given - ask the user how they want to run.
# Режим не указан - спрашиваем пользователя как он хочет запустить.
v = _ask( v = _ask(
"Run", "Run",
"How do you want to run the project?", "How do you want to run the project?",
+14 -4
View File
@@ -3,6 +3,8 @@ from typing import List, Optional
from textual.app import App 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.doc_setup import run as _doc_setup
from cobot.commands.docker_setup import run as _docker_setup from cobot.commands.docker_setup import run as _docker_setup
from cobot.commands.local_setup import run as _local_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 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]]): class _Ask(App[Optional[str]]):
"""Single-question picker that exits immediately with the chosen value."""
CSS = SCREEN_CSS CSS = SCREEN_CSS
def __init__(self, step: str, question: str, options: List[str], default: str): 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]: 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() return _Ask(step, question, options, default).run()
@@ -38,14 +45,16 @@ def register(subparsers):
def run(args: argparse.Namespace) -> None: 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") v = _ask("Step 1 of 3", "Set up the documentation server?", ["Yes", "No"], "Yes")
if v is None: if v is None:
return return
if v == "Yes": if v == "Yes":
_doc_setup(args) _doc_setup(args)
# Step 2 build environment # Step 2 - build environment: local ROS2 or Docker.
# Шаг 2 - среда сборки: локальный ROS2 или Docker.
v = _ask( v = _ask(
"Step 2 of 3", "Step 2 of 3",
"How do you want to set up the build environment?", "How do you want to set up the build environment?",
@@ -62,7 +71,8 @@ def run(args: argparse.Namespace) -> None:
else: else:
_docker_setup(args) _docker_setup(args)
# Step 3 robot parameters # Step 3 - robot parameters in cobot-setting.yaml.
# Шаг 3 - параметры робота в cobot-setting.yaml.
v = _ask( v = _ask(
"Step 3 of 3", "Step 3 of 3",
"Configure robot parameters (cobot-setting.yaml)?", "Configure robot parameters (cobot-setting.yaml)?",
+12 -3
View File
@@ -11,9 +11,14 @@ from cobot.tui import SCREEN_CSS, LogScreen
_PROJECT_DIR = Path(__file__).parent.parent.parent _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: def _task_update(screen: LogScreen) -> None:
try: 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( branch = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"], ["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=_PROJECT_DIR, text=True, cwd=_PROJECT_DIR, text=True,
@@ -33,7 +38,8 @@ def _task_update(screen: LogScreen) -> None:
return return
screen.set_progress(30) screen.set_progress(30)
# Check how many commits behind # Count how many commits the remote is ahead of us.
# Считаем сколько коммитов нас опережает удалённый репозиторий.
behind = subprocess.check_output( behind = subprocess.check_output(
["git", "rev-list", f"HEAD..origin/{branch}", "--count"], ["git", "rev-list", f"HEAD..origin/{branch}", "--count"],
cwd=_PROJECT_DIR, text=True, cwd=_PROJECT_DIR, text=True,
@@ -45,7 +51,8 @@ def _task_update(screen: LogScreen) -> None:
screen.finish(True) screen.finish(True)
return 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]") screen.write(f"\n[bold]{behind} new commit(s):[/bold]")
log_lines = subprocess.check_output( log_lines = subprocess.check_output(
["git", "log", f"HEAD..origin/{branch}", "--oneline"], ["git", "log", f"HEAD..origin/{branch}", "--oneline"],
@@ -71,6 +78,8 @@ def _task_update(screen: LogScreen) -> None:
screen.set_progress(80) screen.set_progress(80)
# Reinstall (80 → 100 %) # 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.set_progress(80, "Reinstalling cobot CLI...")
screen.write("\n[cyan][*][/cyan] Reinstalling cobot CLI...") screen.write("\n[cyan][*][/cyan] Reinstalling cobot CLI...")
reinstall = subprocess.run( reinstall = subprocess.run(
+53 -12
View File
@@ -8,6 +8,8 @@ from textual.binding import Binding
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Footer, Input, LoadingIndicator, ProgressBar, RadioButton, RadioSet, RichLog, Static 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_CSS = """
Screen { Screen {
padding: 2 4; 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]]): class PickScreen(Screen[Optional[str]]):
BINDINGS = [ BINDINGS = [
Binding("enter", "submit", "Confirm", priority=True), Binding("enter", "submit", "Confirm", priority=True),
@@ -98,6 +104,8 @@ class PickScreen(Screen[Optional[str]]):
yield Static(self._note, id="note") yield Static(self._note, id="note")
with RadioSet(id="choices"): with RadioSet(id="choices"):
for opt in self._options: 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 RadioButton(opt, value=(opt == self._default))
yield Footer() yield Footer()
@@ -115,9 +123,15 @@ class PickScreen(Screen[Optional[str]]):
self.dismiss(str(btn.label) if btn else self._default) self.dismiss(str(btn.label) if btn else self._default)
def action_abort(self) -> None: 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) 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]]): class InputScreen(Screen[Optional[str]]):
BINDINGS = [ BINDINGS = [
Binding("enter", "submit", "Confirm", priority=True), Binding("enter", "submit", "Confirm", priority=True),
@@ -154,9 +168,13 @@ class InputScreen(Screen[Optional[str]]):
self.app.exit(None) 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]): 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)] BINDINGS = [Binding("enter,escape", "close", "Close", show=False)]
def __init__(self, title: str, task: Callable[[LogScreen], None], show_progress: bool = 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: def on_mount(self) -> None:
self.query_one(RichLog).focus() 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) self.app.run_worker(lambda: self._run_fn(self), thread=True)
def set_progress(self, pct: float, label: str = "") -> None: 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: if self._show_progress:
self.app.call_from_thread(self._do_set_progress, pct, label) 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) self.query_one("#step-label", Static).update(label)
def write(self, line: str) -> None: 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) self.app.call_from_thread(self._append, line)
def _append(self, line: str) -> None: def _append(self, line: str) -> None:
self.query_one(RichLog).write(line) self.query_one(RichLog).write(line)
def finish(self, success: bool) -> None: 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) self.app.call_from_thread(self._do_finish, success)
def _do_finish(self, success: bool) -> None: def _do_finish(self, success: bool) -> None:
@@ -214,13 +237,17 @@ class LogScreen(Screen[bool]):
self.query_one("#hint", Static).update(msg) self.query_one("#hint", Static).update(msg)
def action_close(self) -> None: def action_close(self) -> None:
# Only allow closing after the task has finished, not while it is still running.
# Разрешаем закрытие только после завершения задачи, а не во время её работы.
if self._finished: if self._finished:
self.dismiss(self._success) 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]): class RunScreen(Screen[None]):
"""Streams a long-running process. S/Enter/Escape stops or closes."""
BINDINGS = [ BINDINGS = [
Binding("s", "stop_close", "Stop", show=True, priority=True), Binding("s", "stop_close", "Stop", show=True, priority=True),
Binding("enter", "stop_close", "Close", show=False), Binding("enter", "stop_close", "Close", show=False),
@@ -231,8 +258,8 @@ class RunScreen(Screen[None]):
super().__init__() super().__init__()
self._title = title self._title = title
self._run_fn = task self._run_fn = task
self._proc = None # set via set_proc() self._proc = None # the subprocess, set via set_proc()
self._kill_fn = None # optional custom kill callable self._kill_fn = None # optional custom kill callable, set via set_kill_fn()
self._finished = False self._finished = False
self._stopped = False self._stopped = False
@@ -245,23 +272,33 @@ class RunScreen(Screen[None]):
def on_mount(self) -> None: def on_mount(self) -> None:
self.query_one(RichLog).focus() 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) self.app.run_worker(lambda: self._run_fn(self), thread=True)
def set_proc(self, proc) -> None: 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 self._proc = proc
def set_kill_fn(self, fn: Callable) -> None: 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 self._kill_fn = fn
def write(self, line: str) -> None: 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) self.app.call_from_thread(self._append, line)
def _append(self, line: str) -> None: def _append(self, line: str) -> None:
self.query_one(RichLog).write(line) self.query_one(RichLog).write(line)
def finish(self, stopped: bool = False) -> None: 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) self.app.call_from_thread(self._do_finish, stopped)
def _do_finish(self, stopped: bool) -> None: def _do_finish(self, stopped: bool) -> None:
@@ -274,6 +311,10 @@ class RunScreen(Screen[None]):
self.query_one("#hint", Static).update(msg) self.query_one("#hint", Static).update(msg)
def action_stop_close(self) -> None: 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: if self._finished:
self.dismiss(None) self.dismiss(None)
return return
@@ -288,4 +329,4 @@ class RunScreen(Screen[None]):
self._proc.terminate() self._proc.terminate()
except Exception: except Exception:
pass pass
self.write("\n[yellow]Stopping process...[/yellow]") self._append("\n[yellow]Stopping process...[/yellow]")
+70 -14
View File
@@ -1,9 +1,15 @@
#!/bin/bash #!/bin/bash
# Stop the script immediately if any command exits with an error.
# Останавливаем скрипт сразу, если какая-либо команда завершилась с ошибкой.
set -e set -e
# Disable uv user/project config files so the environment is always clean.
# Отключаем пользовательские и проектные конфиги uv, чтобы среда всегда была чистой.
export UV_NO_CONFIG=1 export UV_NO_CONFIG=1
# Terminal color codes for nicer output.
# Коды цветов для красивого вывода в терминал.
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
YELLOW='\033[0;33m' YELLOW='\033[0;33m'
@@ -11,19 +17,30 @@ CYAN='\033[0;36m'
NC='\033[0m' NC='\033[0m'
BOLD='\033[1m' BOLD='\033[1m'
# Python version that the cobot CLI requires.
# Версия Python, которая нужна для работы cobot CLI.
PYTHON_VERSION="3.11" PYTHON_VERSION="3.11"
REPO_URL="https://gitverse.ru/daniel-robotics/lightweight-cobot.git" 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}" 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 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_info() { echo -e "${CYAN}[*]${NC} $1"; }
log_success() { echo -e "${GREEN}[ok]${NC} $1"; } log_success() { echo -e "${GREEN}[ok]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[!]${NC} $1"; } log_warn() { echo -e "${YELLOW}[!]${NC} $1"; }
log_error() { echo -e "${RED}[err]${NC} $1"; exit 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() { run_quiet() {
local _log local _log
_log="$(mktemp /tmp/lwc-cmd.XXXXXX.log)" _log="$(mktemp /tmp/lwc-cmd.XXXXXX.log)"
@@ -45,11 +62,14 @@ print_banner() {
echo -e "${NC}" echo -e "${NC}"
} }
# Detect the current OS and package manager so later steps know how to install things.
# Определяем текущую ОС и пакетный менеджер, чтобы следующие шаги знали как устанавливать пакеты.
detect_os() { detect_os() {
case "$(uname -s)" in case "$(uname -s)" in
Linux*) Linux*)
OS="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" if command -v apt-get &>/dev/null; then PKG_MANAGER="apt"
elif command -v dnf &>/dev/null; then PKG_MANAGER="dnf" elif command -v dnf &>/dev/null; then PKG_MANAGER="dnf"
elif command -v pacman &>/dev/null; then PKG_MANAGER="pacman" elif command -v pacman &>/dev/null; then PKG_MANAGER="pacman"
@@ -62,7 +82,8 @@ detect_os() {
log_info "OS: $OS" log_info "OS: $OS"
} }
# Устанавливает системные пакеты через найденный пакетный менеджер # Install system packages using whatever package manager was detected above.
# Устанавливает системные пакеты через найденный пакетный менеджер.
pkg_install() { pkg_install() {
case "$PKG_MANAGER" in case "$PKG_MANAGER" in
apt) run_quiet sudo apt-get update -qq && run_quiet sudo apt-get install -y --no-install-recommends "$@" ;; 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 esac
} }
# Check if git is installed and install it if not.
# Проверяем наличие git и устанавливаем его если он отсутствует.
check_git() { check_git() {
log_info "Checking git..." log_info "Checking git..."
if command -v git &>/dev/null; then if command -v git &>/dev/null; then
@@ -87,6 +110,8 @@ check_git() {
log_success "git $(git --version | awk '{print $3}') installed" log_success "git $(git --version | awk '{print $3}') installed"
} }
# Check if Docker is installed and install it if not.
# Проверяем наличие Docker и устанавливаем его если он отсутствует.
check_docker() { check_docker() {
log_info "Checking Docker..." log_info "Checking Docker..."
if command -v docker &>/dev/null; then if command -v docker &>/dev/null; then
@@ -94,8 +119,10 @@ check_docker() {
return return
fi fi
log_info "Installing Docker..." 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 local _installer
_installer="$(mktemp /tmp/lwc-docker.XXXXXX.sh)" _installer="$(mktemp /tmp/lwc-docker.XXXXXX.sh)"
if ! curl -fsSL https://get.docker.com -o "$_installer"; then if ! curl -fsSL https://get.docker.com -o "$_installer"; then
@@ -106,16 +133,20 @@ check_docker() {
rm -f "$_installer" rm -f "$_installer"
command -v docker &>/dev/null || log_error "Docker not found after installation" command -v docker &>/dev/null || log_error "Docker not found after installation"
log_success "Docker $(docker --version | awk '{print $3}' | tr -d ',') installed" 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 if [ "$(id -u)" -ne 0 ] && command -v usermod &>/dev/null; then
sudo usermod -aG docker "$USER" sudo usermod -aG docker "$USER"
log_warn "Added $USER to docker group — re-login to apply" log_warn "Added $USER to docker group — re-login to apply"
fi fi
} }
# Install the uv package manager. We need it to create isolated Python environments.
# Устанавливаем пакетный менеджер uv. Он нужен для создания изолированных Python-окружений.
install_uv() { install_uv() {
log_info "Checking 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="" UV_CMD=""
for candidate in "uv" "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv"; do for candidate in "uv" "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv"; do
if command -v "$candidate" &>/dev/null 2>&1; then if command -v "$candidate" &>/dev/null 2>&1; then
@@ -128,7 +159,10 @@ install_uv() {
return return
fi fi
log_info "Installing uv..." 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 local _log _installer
_log="$(mktemp /tmp/lwc-uv.XXXXXX.log)" _log="$(mktemp /tmp/lwc-uv.XXXXXX.log)"
_installer="$(mktemp /tmp/lwc-uv-installer.XXXXXX.sh)" _installer="$(mktemp /tmp/lwc-uv-installer.XXXXXX.sh)"
@@ -153,6 +187,8 @@ install_uv() {
fi fi
} }
# Check that the required Python version is available via uv and install it if not.
# Проверяем наличие нужной версии Python через uv и устанавливаем её если она отсутствует.
check_python() { check_python() {
log_info "Checking Python $PYTHON_VERSION..." log_info "Checking Python $PYTHON_VERSION..."
local py_path local py_path
@@ -168,6 +204,14 @@ check_python() {
log_success "Python $PYTHON_VERSION installed" 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() { resolve_install_dir() {
local script_dir local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || pwd)" 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..." log_info "Cloning repo into $INSTALL_DIR..."
mkdir -p "$(dirname "$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 local attempt=1
while [ $attempt -le 5 ]; do while [ $attempt -le 5 ]; do
# TODO: Изменить на --depth 1 --branch main после слияния dev в main.
if git clone --depth 1 --branch dev "$REPO_URL" "$INSTALL_DIR" </dev/null; then if git clone --depth 1 --branch dev "$REPO_URL" "$INSTALL_DIR" </dev/null; then
log_success "Repo cloned to $INSTALL_DIR" log_success "Repo cloned to $INSTALL_DIR"
return return
@@ -209,15 +256,20 @@ resolve_install_dir() {
log_error "Failed to clone repo after 5 attempts" log_error "Failed to clone repo after 5 attempts"
} }
# Install the cobot CLI as an editable uv tool so changes in the source are reflected immediately.
# Устанавливаем cobot CLI как редактируемый uv-инструмент, чтобы изменения в исходниках применялись сразу.
install_cobot() { install_cobot() {
log_info "Installing cobot CLI..." log_info "Installing cobot CLI..."
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
# uv tool install создаёт изолированное окружение и кладёт бинарник cobot в ~/.local/bin # uv tool install creates an isolated environment and puts the cobot binary into ~/.local/bin.
# uv tool install создаёт изолированное окружение и кладёт бинарник cobot в ~/.local/bin.
run_quiet "$UV_CMD" tool install --python "$PYTHON_VERSION" --editable . \ run_quiet "$UV_CMD" tool install --python "$PYTHON_VERSION" --editable . \
|| log_error "Failed to install cobot" || log_error "Failed to install cobot"
log_success "cobot installed" log_success "cobot installed"
} }
# Add ~/.local/bin to PATH in the user's shell config if it is not there yet.
# Добавляем ~/.local/bin в PATH в конфиге оболочки пользователя, если его там ещё нет.
setup_path() { setup_path() {
local bin_dir="$HOME/.local/bin" local bin_dir="$HOME/.local/bin"
local shell_rc local shell_rc
@@ -226,11 +278,11 @@ setup_path() {
*/fish) shell_rc="$HOME/.config/fish/config.fish" ;; */fish) shell_rc="$HOME/.config/fish/config.fish" ;;
*) shell_rc="$HOME/.bashrc" ;; *) shell_rc="$HOME/.bashrc" ;;
esac esac
# Добавляем ~/.local/bin в PATH, если его там ещё нет # Also export into the current session right now so cobot setup works below without a re-login.
# Также экспортируем прямо сейчас, чтобы cobot setup заработал ниже без перезахода.
if [[ ":$PATH:" != *":$bin_dir:"* ]]; then if [[ ":$PATH:" != *":$bin_dir:"* ]]; then
echo "" >> "$shell_rc" echo "" >> "$shell_rc"
echo "export PATH=\"$bin_dir:\$PATH\"" >> "$shell_rc" echo "export PATH=\"$bin_dir:\$PATH\"" >> "$shell_rc"
# Обновляем PATH внутри скрипта — нужно чтобы cobot setup сработал ниже
export PATH="$bin_dir:$PATH" export PATH="$bin_dir:$PATH"
fi fi
command -v cobot &>/dev/null && log_success "cobot -> $(command -v cobot)" command -v cobot &>/dev/null && log_success "cobot -> $(command -v cobot)"
@@ -247,8 +299,11 @@ print_success() {
echo "" echo ""
} }
# Launch the interactive setup wizard right after installation.
# Запускаем интерактивный мастер настройки сразу после установки.
run_setup() { 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 if ! command -v cobot &>/dev/null; then
log_warn "cobot not found on PATH, trying full path..." log_warn "cobot not found on PATH, trying full path..."
local cobot_bin="$HOME/.local/bin/cobot" local cobot_bin="$HOME/.local/bin/cobot"
@@ -262,7 +317,8 @@ run_setup() {
log_info "Running cobot setup..." log_info "Running cobot setup..."
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 if [ "$IS_INTERACTIVE" = false ]; then
echo "" echo ""
echo " To apply PATH changes in this terminal, run:" echo " To apply PATH changes in this terminal, run:"