diff --git a/cobot/cli.py b/cobot/cli.py index 961f947..2e3d383 100644 --- a/cobot/cli.py +++ b/cobot/cli.py @@ -1,11 +1,13 @@ import argparse import sys +from cobot.commands import delete as cmd_delete from cobot.commands import docker_setup as cmd_docker_setup from cobot.commands import doc_setup as cmd_doc_setup from cobot.commands import local_setup as cmd_local_setup from cobot.commands import robot_setup as cmd_robot_setup from cobot.commands import setup as cmd_setup +from cobot.commands import update as cmd_update # Command groups shown in --help output. # Add new commands here when introducing other categories. @@ -17,6 +19,10 @@ _GROUPS = [ ("doc-setup", "deploy or stop the MkDocs documentation server"), ("robot-setup", "configure cobot-setting.yaml interactively"), ]), + ("Management", [ + ("update", "pull latest changes from the remote git branch and reinstall cobot"), + ("delete", "remove the project, Docker images, containers, and optionally ROS2"), + ]), ] _DESCRIPTION = "Lightweight Cobot" @@ -74,3 +80,5 @@ def _register_commands(subparsers): cmd_docker_setup.register(subparsers) cmd_doc_setup.register(subparsers) cmd_robot_setup.register(subparsers) + cmd_update.register(subparsers) + cmd_delete.register(subparsers) diff --git a/cobot/commands/delete.py b/cobot/commands/delete.py new file mode 100644 index 0000000..42e380f --- /dev/null +++ b/cobot/commands/delete.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +from pathlib import Path +from typing import Optional + +from textual.app import App + +from cobot.tui import SCREEN_CSS, LogScreen, PickScreen + +_PROJECT_DIR = Path(__file__).parent.parent.parent + + +def _stop_docker_containers(write) -> None: + write("[cyan][*][/cyan] Stopping Docker containers...") + result = subprocess.run( + ["docker", "ps", "-a", "--filter", "name=lwc", "--format", "{{.Names}}"], + capture_output=True, text=True, + ) + containers = [c for c in result.stdout.strip().splitlines() if c] + if not containers: + write("[dim]No project containers found.[/dim]") + return + for name in containers: + subprocess.run(["docker", "rm", "-f", name], capture_output=True) + write(f"[green][ok][/green] Removed container: {name}") + + +def _remove_docker_images(write) -> None: + write("[cyan][*][/cyan] Removing Docker images...") + result = subprocess.run( + ["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"], + capture_output=True, text=True, + ) + project_images = [ + img for img in result.stdout.strip().splitlines() + if "lwc" in img.lower() + ] + if not project_images: + write("[dim]No project images found.[/dim]") + return + for img in project_images: + subprocess.run(["docker", "rmi", "-f", img], capture_output=True) + write(f"[green][ok][/green] Removed image: {img}") + + +def _remove_ros2(write) -> None: + write("[cyan][*][/cyan] Removing ROS2 Jazzy...") + if Path("/opt/ros/jazzy").exists(): + subprocess.run(["sudo", "rm", "-rf", "/opt/ros/jazzy"]) + write("[green][ok][/green] Removed /opt/ros/jazzy") + else: + write("[dim]ROS2 Jazzy not found, skipping.[/dim]") + + source_line = "source /opt/ros/jazzy/setup.bash" + for rc_name in [".bashrc", ".zshrc"]: + rc = Path.home() / rc_name + if not rc.exists(): + continue + content = rc.read_text() + if source_line not in content: + continue + new_content = content.replace(f"\n# ROS2 Jazzy\n{source_line}\n", "\n") + new_content = new_content.replace(source_line, "") + rc.write_text(new_content) + write(f"[green][ok][/green] Cleaned up ~/{rc_name}") + + +def _uninstall_cobot(write) -> None: + write("[cyan][*][/cyan] Uninstalling cobot CLI...") + result = subprocess.run( + ["uv", "tool", "uninstall", "lightweight-cobot"], + capture_output=True, text=True, + ) + if result.returncode == 0: + write("[green][ok][/green] cobot uninstalled") + else: + write(f"[yellow]Warning:[/yellow] {result.stderr.strip() or 'could not uninstall cobot'}") + + +def _remove_project_dir(write) -> None: + write(f"[cyan][*][/cyan] Removing project directory...") + try: + shutil.rmtree(_PROJECT_DIR) + write(f"[green][ok][/green] Removed {_PROJECT_DIR}") + except Exception as exc: + write(f"[red]Failed:[/red] {exc}") + raise + + + +def _task_delete(screen: LogScreen, remove_ros: bool) -> None: + try: + _stop_docker_containers(screen.write) + _remove_docker_images(screen.write) + + if remove_ros: + _remove_ros2(screen.write) + + _uninstall_cobot(screen.write) + _remove_project_dir(screen.write) + + screen.write("\n[green]Project fully removed.[/green]") + screen.finish(True) + + except Exception as exc: + screen.write(f"\n[red]Error:[/red] {exc}") + screen.finish(False) + + + +class _DeleteApp(App[None]): + CSS = SCREEN_CSS + + def on_mount(self) -> None: + self.push_screen( + PickScreen( + "Confirm deletion", + "This will permanently delete the project, Docker images and containers. Are you sure?", + ["No, cancel", "Yes, delete everything"], + "No, cancel", + ), + self._on_confirm, + ) + + def _on_confirm(self, choice: Optional[str]) -> None: + if choice is None or choice.startswith("No"): + self.exit() + return + self.push_screen( + PickScreen( + "ROS2 Jazzy", + "Also remove ROS2 Jazzy (/opt/ros/jazzy)?", + ["No, keep ROS2", "Yes, remove ROS2 Jazzy"], + "No, keep ROS2", + ), + self._on_ros_choice, + ) + + def _on_ros_choice(self, choice: Optional[str]) -> None: + remove_ros = choice is not None and choice.startswith("Yes") + self.push_screen( + LogScreen("Deleting project", lambda s: _task_delete(s, remove_ros)), + lambda _: self.exit(), + ) + + +def register(subparsers: argparse._SubParsersAction) -> None: + p = subparsers.add_parser( + "delete", + help="Remove the project, Docker images, containers, and optionally ROS2", + ) + p.set_defaults(func=run) + + +def run(args: argparse.Namespace) -> None: + _DeleteApp().run() diff --git a/cobot/commands/update.py b/cobot/commands/update.py new file mode 100644 index 0000000..cecc728 --- /dev/null +++ b/cobot/commands/update.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import argparse +import subprocess +from pathlib import Path + +from textual.app import App + +from cobot.tui import SCREEN_CSS, LogScreen + +_PROJECT_DIR = Path(__file__).parent.parent.parent + + +def _task_update(screen: LogScreen) -> None: + try: + # Current branch + branch = subprocess.check_output( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=_PROJECT_DIR, text=True, + ).strip() + screen.write(f"[cyan][*][/cyan] Branch: [bold]{branch}[/bold]") + + # Fetch + screen.write("[cyan][*][/cyan] Fetching from remote...") + fetch = subprocess.run( + ["git", "fetch", "origin"], + cwd=_PROJECT_DIR, capture_output=True, text=True, + ) + if fetch.returncode != 0: + screen.write(f"[red]Fetch failed:[/red] {fetch.stderr.strip()}") + screen.finish(False) + return + + # Check how many commits behind + behind = subprocess.check_output( + ["git", "rev-list", f"HEAD..origin/{branch}", "--count"], + cwd=_PROJECT_DIR, text=True, + ).strip() + + if behind == "0": + screen.write("[green][ok][/green] Already up to date.") + screen.finish(True) + return + + # Show incoming commits + screen.write(f"\n[bold]{behind} new commit(s):[/bold]") + log_lines = subprocess.check_output( + ["git", "log", f"HEAD..origin/{branch}", "--oneline"], + cwd=_PROJECT_DIR, text=True, + ).strip().splitlines() + for line in log_lines: + screen.write(f" [dim]{line}[/dim]") + + # Pull + screen.write("\n[cyan][*][/cyan] Pulling changes...") + proc = subprocess.Popen( + ["git", "pull", "origin", branch], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, cwd=_PROJECT_DIR, + ) + for line in proc.stdout: + s = line.rstrip() + if s: + screen.write(s) + proc.wait() + if proc.returncode != 0: + screen.write("[red]Pull failed.[/red]") + screen.finish(False) + return + + # Reinstall in case dependencies changed + screen.write("\n[cyan][*][/cyan] Reinstalling cobot CLI...") + reinstall = subprocess.run( + ["uv", "tool", "install", "--editable", str(_PROJECT_DIR)], + capture_output=True, text=True, + ) + if reinstall.returncode == 0: + screen.write("[green][ok][/green] cobot reinstalled") + else: + screen.write(f"[yellow]Warning:[/yellow] reinstall failed — {reinstall.stderr.strip()}") + + screen.write("\n[green]Project updated successfully.[/green]") + screen.finish(True) + + except Exception as exc: + screen.write(f"\n[red]Error:[/red] {exc}") + screen.finish(False) + + +class _UpdateApp(App[None]): + CSS = SCREEN_CSS + + def on_mount(self) -> None: + self.push_screen( + LogScreen("Updating project", _task_update), + lambda _: self.exit(), + ) + + +def register(subparsers: argparse._SubParsersAction) -> None: + p = subparsers.add_parser("update", help="Pull latest changes from the remote git branch") + p.set_defaults(func=run) + + +def run(args: argparse.Namespace) -> None: + _UpdateApp().run() diff --git a/install.sh b/install.sh index bcaeda7..f4b82c2 100644 --- a/install.sh +++ b/install.sh @@ -41,7 +41,6 @@ print_banner() { echo -e "${BOLD}" echo "┌─────────────────────────────────────────────────────────┐" echo "│ Lightweight Cobot installer │" - echo "│ KUKA iiwa7 ROS2 Control Framework │" echo "└─────────────────────────────────────────────────────────┘" echo -e "${NC}" } @@ -171,19 +170,34 @@ check_python() { resolve_install_dir() { local script_dir script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || pwd)" - # Если рядом со скриптом есть setup.py — значит мы уже внутри репозитория - if [ -f "$script_dir/setup.py" ]; then + + # Running directly from inside the cloned repo (not via curl|bash) + if [ -f "$script_dir/setup.py" ] && [ -d "$script_dir/.git" ]; then INSTALL_DIR="$script_dir" - log_info "Repo: $INSTALL_DIR" - else - # Иначе клонируем в ~/.lwc/ros2_iiwa7 - log_info "Cloning repo..." - mkdir -p "$(dirname "$INSTALL_DIR")" - # TODO: Убрать ветку dev и юзать main после мержа - run_quiet git clone --branch dev "$REPO_URL" "$INSTALL_DIR" \ - || log_error "Failed to clone repo" - log_success "Repo cloned to $INSTALL_DIR" + log_info "Using existing repo: $INSTALL_DIR" + return fi + + # Repo already cloned — pull instead of re-clone + if [ -d "$INSTALL_DIR/.git" ]; then + log_info "Repo already exists at $INSTALL_DIR — pulling latest..." + git -C "$INSTALL_DIR" pull --ff-only origin dev \ + && log_success "Repo updated" \ + || log_warn "Could not pull latest — using existing version" + return + fi + + # Directory exists but is not a git repo (broken/partial) — remove it + if [ -d "$INSTALL_DIR" ]; then + log_warn "Removing incomplete directory $INSTALL_DIR..." + rm -rf "$INSTALL_DIR" + fi + + log_info "Cloning repo into $INSTALL_DIR..." + mkdir -p "$(dirname "$INSTALL_DIR")" + git clone --branch dev "$REPO_URL" "$INSTALL_DIR" \ + || log_error "Failed to clone repo from $REPO_URL" + log_success "Repo cloned to $INSTALL_DIR" } install_cobot() { @@ -215,10 +229,12 @@ setup_path() { print_success() { echo "" - echo -e "${GREEN}${BOLD}Done!${NC}" + echo -e "${GREEN}${BOLD}Installation complete!${NC}" echo "" - echo " cobot setup - first-time setup" - echo " cobot --help - list all commands" + echo " Next steps:" + echo "" + echo -e " ${CYAN}cobot setup${NC} — install ROS2, build the project and configure the robot" + echo -e " ${CYAN}cobot --help${NC} — show all available commands" echo "" }