diff --git a/scripts/linux_setup-README.md b/scripts/linux_setup-README.md new file mode 100644 index 0000000..74d1bef --- /dev/null +++ b/scripts/linux_setup-README.md @@ -0,0 +1,66 @@ +# linux_setup.sh + +A single idempotent Bash script that takes a fresh Debian/Ubuntu or RHEL/Fedora/CentOS install and prepares it for development work — essential packages, Python, SSH (server + keypair), Docker, common CLI tools, and a few sane baseline system settings. + +Written after doing this setup manually one too many times across VMs and fresh installs — this is meant to be the one command that gets a box from "just installed" to "ready to work on" in a couple of minutes. + +## What it does + +| Step | Installs / Configures | +|---|---| +| **Essential packages** | `curl`, `wget`, `git`, `vim`, `htop`, `jq`, `tar`, `gzip`, `unzip`, build tools, `ca-certificates`, `gnupg` | +| **Python** | Python 3, `pip` (upgraded), `venv`, `virtualenv`, `pipenv` | +| **SSH** | OpenSSH client + server, enabled and started via `systemctl`, `~/.ssh` created with `700` permissions | +| **SSH key** | Generates an `ed25519` keypair (`~/.ssh/id_ed25519`) if one doesn't already exist, prints the public key at the end for easy copying into GitHub/GitLab | +| **Docker** | Docker CE, CLI, containerd, Buildx and Compose plugins from Docker's official repo — verified with a `hello-world` test run after install | +| **Dev tools** | `tree`, `net-tools`, ping utilities, `ncdu`, `btop` | +| **System config** | UTC timezone (if not already set), helpful `ls` aliases in `/etc/bash.bashrc`, basic firewall (UFW or firewalld): deny incoming except SSH, allow all outgoing | + +Every step checks whether its target is already installed/configured before doing anything — safe to re-run after a partial failure or to pick up a newly-added tool without redoing the whole setup. + +## Supported distributions + +Detected automatically via `/etc/os-release` (with fallbacks to `/etc/redhat-release` / `/etc/debian_version`): + +- **Debian family** — Debian, Ubuntu, and anything with `ID_LIKE=debian` +- **RHEL family** — RHEL, CentOS, Fedora, and anything with `ID_LIKE` matching `rhel` or `fedora` + +Unrecognized distros aren't blocked outright — the script warns and proceeds, but package-manager-specific steps (install, cache update) will no-op rather than guess at a package manager. + +## Usage + +```bash +sudo ./linux_setup.sh +``` + +Must run as root (`sudo`) — it's installing system packages, writing to `/etc`, and enabling system services. + +### Skip specific steps + +```bash +sudo ./linux_setup.sh --skip-docker --skip-dev-tools +``` + +| Flag | Skips | +|---|---| +| `--skip-packages` | Essential package install + cache update | +| `--skip-python` | Python 3 / pip / virtualenv / pipenv | +| `--skip-ssh` | OpenSSH install + service enable/start | +| `--skip-ssh-key` | SSH keypair generation | +| `--skip-docker` | Docker CE install | +| `--skip-dev-tools` | `tree`, `ncdu`, `btop`, etc. | +| `--skip-system` | Timezone, shell aliases, firewall config | +| `--log-file FILE` | Custom log path (default: `/tmp/linux_setup_.log`) | + +Every step is logged to both the terminal (with color-coded `INFO`/`SUCCESS`/`WARNING`/`ERROR` prefixes) and the log file, so a run can be reviewed after the fact without needing to have watched it live. + +## Worth knowing before running + +- **Firewall is enabled by default** (`--skip-system` to avoid this) — UFW/firewalld gets set to deny all incoming except SSH. Safe for a fresh server, but worth knowing if the box already has other services listening that you don't want cut off. +- **Adds the invoking user to the `docker` group** (Debian family only, via `SUDO_USER`) — meaning that user can run `docker` without `sudo` after their next login. This is the standard Docker convention, but is effectively root-equivalent access to the host, worth being aware of on a shared machine. +- **SSH key has no passphrase** (`-N ""` in the `ssh-keygen` call) — intentional for unattended/scripted setup, but means anyone with filesystem access to `~/.ssh/id_ed25519` can use it directly. Fine for a personal dev box; worth adding a passphrase manually afterward on anything more sensitive. +- Individual step failures are logged as warnings and the script continues rather than aborting — check the log file after a run to confirm nothing silently failed. + +## License + +MIT (or update to match the rest of the repo's licensing) diff --git a/scripts/linux_setup.sh b/scripts/linux_setup.sh new file mode 100644 index 0000000..112333b --- /dev/null +++ b/scripts/linux_setup.sh @@ -0,0 +1,764 @@ +#!/bin/bash +# +# Linux Environment Setup Script +# Designed for: Debian-based, CentOS, and Fedora distributions +# Purpose: Prepare a newly installed Linux system for development work +# Features: Idempotent, comprehensive checks, safe execution +# + +set -euo pipefail + +# Color codes for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Script constants +SCRIPT_VERSION="1.0.0" +LOG_FILE="${LOG_FILE:-/tmp/linux_setup_$(date +%Y%m%d_%H%M%S).log}" + +# ============================================================================= +# FUNCTIONS +# ============================================================================= + +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" | tee -a "$LOG_FILE" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" | tee -a "$LOG_FILE" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" | tee -a "$LOG_FILE" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" | tee -a "$LOG_FILE" >&2 +} + +# Check if running as root +check_root() { + if [[ $EUID -ne 0 ]]; then + log_error "This script must be run as root (use sudo)" + exit 1 + fi +} + +# Detect Linux distribution +detect_distro() { + # Defaults so these are always defined under `set -u`, regardless of + # which detection branch below ends up firing. + DISTRO_ID="unknown" + DISTRO_ID_LIKE="" + DISTRO_NAME="Unknown" + DISTRO_VERSION="" + + if [[ -f /etc/os-release ]]; then + source /etc/os-release + DISTRO_ID="${ID:-unknown}" + DISTRO_ID_LIKE="${ID_LIKE:-}" + DISTRO_NAME="${NAME:-Unknown Linux}" + DISTRO_VERSION="${VERSION_ID:-}" + elif [[ -f /etc/redhat-release ]]; then + DISTRO_ID="rhel" + DISTRO_NAME="Red Hat-based" + elif [[ -f /etc/debian_version ]]; then + DISTRO_ID="debian" + DISTRO_NAME="Debian-based" + fi + + # Determine distro family + if [[ "$DISTRO_ID" == "ubuntu" ]] || [[ "$DISTRO_ID" == "debian" ]] || [[ "$DISTRO_ID_LIKE" == *"debian"* ]]; then + DISTRO_FAMILY="debian" + elif [[ "$DISTRO_ID" == "fedora" ]] || [[ "$DISTRO_ID" == "rhel" ]] || [[ "$DISTRO_ID" == "centos" ]] || [[ "$DISTRO_ID_LIKE" == *"rhel"* ]] || [[ "$DISTRO_ID_LIKE" == *"fedora"* ]]; then + DISTRO_FAMILY="rhel" + else + DISTRO_FAMILY="unknown" + fi + + log_info "Detected distribution: $DISTRO_NAME ($DISTRO_ID - $DISTRO_FAMILY family)" +} + +# Check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Check if package is installed (different methods per distro) +is_package_installed() { + local package_name="$1" + + case "$DISTRO_FAMILY" in + debian) + dpkg -l "$package_name" 2>/dev/null | grep -q "^ii" + ;; + rhel) + rpm -q "$package_name" &>/dev/null + ;; + *) + command_exists "$package_name" + ;; + esac +} + +# Update package cache +update_package_cache() { + log_info "Updating package cache..." + + case "$DISTRO_FAMILY" in + debian) + apt-get update -qq + ;; + rhel) + if command_exists dnf; then + dnf makecache -qq + else + yum makecache fast -qq + fi + ;; + *) + log_warning "Unknown distro family, skipping cache update" + ;; + esac + + log_success "Package cache updated" +} + +# Install package(s) with retry logic +install_packages() { + local packages=("$@") + local failed=() + + for pkg in "${packages[@]}"; do + if is_package_installed "$pkg"; then + log_info "Package '$pkg' is already installed, skipping" + continue + fi + + log_info "Installing package: $pkg" + + case "$DISTRO_FAMILY" in + debian) + apt-get install -y -qq "$pkg" || failed+=("$pkg") + ;; + rhel) + if command_exists dnf; then + dnf install -y -q "$pkg" || failed+=("$pkg") + else + yum install -y -q "$pkg" || failed+=("$pkg") + fi + ;; + *) + log_warning "Cannot install '$pkg': Unknown distro family" + failed+=("$pkg") + ;; + esac + + # Verify installation + if is_package_installed "$pkg"; then + log_success "Package '$pkg' installed successfully" + else + log_error "Failed to install package: $pkg" + fi + done + + if [[ ${#failed[@]} -gt 0 ]]; then + log_error "Failed to install: ${failed[*]}" + return 1 + fi + + return 0 +} + +# ============================================================================= +# SETUP FUNCTIONS +# ============================================================================= + +# Setup essential system packages +setup_essential_packages() { + log_info "Setting up essential system packages..." + + local essential_packages=( + "curl" + "wget" + "git" + "vim" + "htop" + "jq" + "tar" + "gzip" + "unzip" + "build-essential" + "ca-certificates" + "gnupg" + "lsb-release" + "software-properties-common" + ) + + # Add distro-specific packages + case "$DISTRO_FAMILY" in + debian) + # Ubuntu/Debian specific + ;; + rhel) + # CentOS/Fedora specific - drop build-essential (Debian-only package) + # and use the RHEL/Fedora equivalents instead. + local filtered_packages=() + for p in "${essential_packages[@]}"; do + [[ "$p" == "build-essential" ]] && continue + filtered_packages+=("$p") + done + essential_packages=("${filtered_packages[@]}") + essential_packages+=("gcc" "gcc-c++" "make") + ;; + esac + + install_packages "${essential_packages[@]}" +} + +# Setup Python +setup_python() { + log_info "Setting up Python..." + + # Check if Python 3 is already installed + if command_exists python3; then + local python_version + python_version=$(python3 --version 2>&1 | awk '{print $2}') + log_info "Python3 is already installed (version: $python_version)" + else + log_info "Installing Python3..." + case "$DISTRO_FAMILY" in + debian) + install_packages "python3" "python3-pip" "python3-venv" + ;; + rhel) + install_packages "python3" "python3-pip" + ;; + esac + fi + + # Verify Python installation + if command_exists python3; then + log_success "Python3 installed: $(python3 --version)" + else + log_error "Python3 installation failed" + return 1 + fi + + # Upgrade pip if needed + if command_exists python3; then + log_info "Upgrading pip..." + python3 -m pip install --upgrade pip --quiet 2>/dev/null || true + log_success "pip upgraded" + fi + + # Install common Python packages + local python_packages=("virtualenv" "pipenv") + for pkg in "${python_packages[@]}"; do + if python3 -c "import ${pkg%:*}" 2>/dev/null; then + log_info "Python package '$pkg' already installed" + else + log_info "Installing Python package: $pkg" + python3 -m pip install "$pkg" --quiet 2>/dev/null || true + fi + done + + log_success "Python setup complete" +} + +# Setup SSH +setup_ssh() { + log_info "Setting up SSH..." + + # Install OpenSSH if not present + if ! command_exists ssh; then + log_info "Installing OpenSSH..." + case "$DISTRO_FAMILY" in + debian) + install_packages "openssh-client" "openssh-server" + ;; + rhel) + install_packages "openssh-clients" "openssh-server" + ;; + esac + else + log_info "OpenSSH is already installed" + fi + + # Check if SSH service is available + if command_exists systemctl; then + # Enable SSH service + if systemctl list-unit-files | grep -q sshd.service; then + log_info "SSH service unit found" + + # Check if SSH service is enabled + if systemctl is-enabled sshd &>/dev/null; then + log_info "SSH service is already enabled" + else + log_info "Enabling SSH service..." + systemctl enable sshd 2>/dev/null || true + fi + + # Start SSH service if not running + if systemctl is-active --quiet sshd; then + log_info "SSH service is already running" + else + log_info "Starting SSH service..." + systemctl start sshd 2>/dev/null || true + fi + else + log_warning "SSH service unit not found" + fi + fi + + # Setup SSH directory and permissions + local ssh_dir="/root/.ssh" + if [[ "$SUDO_USER" ]]; then + ssh_dir="/home/$SUDO_USER/.ssh" + fi + + mkdir -p "$ssh_dir" + chmod 700 "$ssh_dir" + log_success "SSH directory configured: $ssh_dir" + + log_success "SSH setup complete" +} + +# Generate SSH key +setup_ssh_key() { + log_info "Setting up SSH keys..." + + local ssh_dir="/root/.ssh" + local key_type="ed25519" + # Keep this filename fixed (no per-user suffix): `whoami` is always "root" + # while this script runs under sudo, so a "$(whoami)" suffix never + # reflected the invoking user anyway - and print_summary() looks for + # this exact unsuffixed name when it displays the key at the end. + local key_name="id_ed25519" + + if [[ "$SUDO_USER" ]]; then + ssh_dir="/home/$SUDO_USER/.ssh" + fi + + # Ensure SSH directory exists with correct permissions + mkdir -p "$ssh_dir" + chmod 700 "$ssh_dir" + + local key_path="$ssh_dir/$key_name" + local key_pub="$key_path.pub" + + # Check if key already exists + if [[ -f "$key_path" ]]; then + log_info "SSH key already exists at: $key_path" + + # Verify key permissions + if [[ "$(stat -c %a "$key_path")" != "600" ]]; then + log_warning "Fixing SSH key permissions..." + chmod 600 "$key_path" + fi + else + log_info "Generating new SSH key: $key_type" + + # Generate SSH key + ssh-keygen -t "$key_type" -f "$key_path" -C "$(whoami)@$(hostname)-$(date +%Y%m%d)" -N "" + + # Set correct permissions + chmod 600 "$key_path" + chmod 644 "$key_pub" + + log_success "SSH key generated: $key_path" + fi + + # Verify permissions + chmod 600 "$key_path" + chmod 644 "$key_pub" + + # Display public key + if [[ -f "$key_pub" ]]; then + log_info "Public key content:" + echo "---" + cat "$key_pub" + echo "---" + fi + + # Set ownership + if [[ "$SUDO_USER" ]]; then + chown -R "$SUDO_USER:$SUDO_USER" "$ssh_dir" + fi + + log_success "SSH key setup complete" +} + +# Setup Docker +setup_docker() { + log_info "Setting up Docker..." + + # Check if Docker is already installed + if command_exists docker; then + local docker_version + docker_version=$(docker --version 2>&1) + log_info "Docker is already installed: $docker_version" + + # Check if Docker service is running + if command_exists systemctl; then + if systemctl is-active --quiet docker; then + log_info "Docker service is running" + else + log_warning "Docker installed but not running, attempting to start..." + systemctl start docker 2>/dev/null || true + systemctl enable docker 2>/dev/null || true + fi + fi + + log_success "Docker already configured" + return 0 + fi + + log_info "Installing Docker..." + + case "$DISTRO_FAMILY" in + debian) + # Install prerequisites + install_packages "apt-transport-https" "ca-certificates" "curl" "gnupg" "lsb-release" + + # Add Docker GPG key + mkdir -p /etc/apt/keyrings + curl -fsSL "https://download.docker.com/linux/${DISTRO_ID}/gpg" | gpg --dearmor -o /etc/apt/keyrings/docker.gpg 2>/dev/null || true + + # Add Docker repository + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${DISTRO_ID} $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null + + # Update and install + apt-get update -qq + install_packages "docker-ce" "docker-ce-cli" "containerd.io" "docker-buildx-plugin" "docker-compose-plugin" + ;; + + rhel) + if [[ "$DISTRO_ID" == "fedora" ]]; then + # Fedora + dnf install -y dnf-plugins-core || true + dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo || true + dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \ + || log_error "Docker package installation failed" + else + # CentOS/RHEL + yum install -y yum-utils || true + yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo || true + yum install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \ + || log_error "Docker package installation failed" + fi + ;; + esac + + # Configure Docker service + if command_exists systemctl; then + log_info "Configuring Docker service..." + + # Enable Docker + systemctl enable docker 2>/dev/null || true + + # Start Docker + systemctl start docker 2>/dev/null || true + + # Add user to docker group + if [[ "$SUDO_USER" ]] && [[ "$DISTRO_FAMILY" == "debian" ]]; then + if ! getent group docker | grep -q "$SUDO_USER"; then + usermod -aG docker "$SUDO_USER" + log_info "User '$SUDO_USER' added to docker group" + fi + fi + fi + + # Verify Docker installation + if command_exists docker; then + log_success "Docker installed: $(docker --version)" + + # Test Docker + if docker run --rm hello-world &>/dev/null; then + log_success "Docker is working correctly" + else + log_warning "Docker installed but 'hello-world' test failed" + fi + else + log_error "Docker installation failed" + return 1 + fi + + log_success "Docker setup complete" +} + +# Setup common development tools +setup_dev_tools() { + log_info "Setting up development tools..." + + local dev_tools=() + + case "$DISTRO_FAMILY" in + debian) + dev_tools=( + "git" + "tree" + "net-tools" + "iputils-ping" + "ncdu" + "btop" # or htop + ) + ;; + rhel) + dev_tools=( + "git" + "tree" + "net-tools" + "iputils" + "ncdu" + "btop" + ) + ;; + esac + + install_packages "${dev_tools[@]}" + + log_success "Development tools setup complete" +} + +# Configure system settings +configure_system() { + log_info "Configuring system settings..." + + # Set timezone (if not set) + if [[ ! -L /etc/localtime ]]; then + log_info "Setting timezone to UTC..." + ln -sf /usr/share/zoneinfo/UTC /etc/localtime 2>/dev/null || true + fi + + # Configure shell + if command_exists bash && [[ -f /etc/bash.bashrc ]]; then + # Add helpful bash aliases if not present + if ! grep -q "# User defined aliases" /etc/bash.bashrc; then + echo "" >> /etc/bash.bashrc + echo "# User defined aliases" >> /etc/bash.bashrc + echo "alias ll='ls -lah'" >> /etc/bash.bashrc + echo "alias la='ls -A'" >> /etc/bash.bashrc + echo "alias l='ls -CF'" >> /etc/bash.bashrc + log_info "Added shell aliases" + fi + fi + + # Configure firewall (if available) + if command_exists ufw; then + log_info "Configuring UFW firewall..." + # Allow SSH *before* enabling, so a remote/SSH session doesn't get + # locked out the moment the firewall comes up. + ufw allow OpenSSH 2>/dev/null || ufw allow 22/tcp 2>/dev/null || true + ufw default deny incoming 2>/dev/null || true + ufw default allow outgoing 2>/dev/null || true + ufw --force enable 2>/dev/null || true + log_info "UFW firewall configured (deny incoming except SSH, allow outgoing)" + elif command_exists firewall-cmd; then + log_info "Configuring firewalld..." + firewall-cmd --permanent --add-service=ssh 2>/dev/null || true + firewall-cmd --reload 2>/dev/null || true + log_info "firewalld configured" + fi + + log_success "System configuration complete" +} + +# Print summary +print_summary() { + echo "" + echo "==============================================" + echo " Linux Setup Complete!" + echo "==============================================" + echo "" + echo "Installed/Configured:" + echo " - Essential system packages" + echo " - Python 3 with pip" + echo " - OpenSSH server and client" + echo " - SSH key: ~/.ssh/id_ed25519" + echo " - Docker CE" + echo " - Development tools" + echo "" + echo "Log file: $LOG_FILE" + echo "" + + # Show SSH public key for easy copying + local ssh_dir="/root/.ssh" + if [[ "$SUDO_USER" ]]; then + ssh_dir="/home/$SUDO_USER/.ssh" + fi + + if [[ -f "$ssh_dir/id_ed25519.pub" ]]; then + echo "Your SSH public key (add to GitHub/GitLab/etc.):" + echo "---" + cat "$ssh_dir/id_ed25519.pub" + echo "---" + echo "" + fi + + if [[ -f "$ssh_dir/id_ed25519" ]]; then + echo "To use SSH key, run:" + echo " eval \"\$(ssh-agent -s)\"" + echo " ssh-add $ssh_dir/id_ed25519" + echo "" + fi + + echo "==============================================" +} + +# Show usage +show_usage() { + cat << EOF +Usage: $0 [OPTIONS] + +Linux Environment Setup Script v${SCRIPT_VERSION} + +OPTIONS: + -h, --help Show this help message + --skip-packages Skip package installation + --skip-python Skip Python installation + --skip-ssh Skip SSH installation + --skip-ssh-key Skip SSH key generation + --skip-docker Skip Docker installation + --skip-dev-tools Skip development tools + --skip-system Skip system configuration + --log-file FILE Custom log file path + +EXAMPLES: + $0 # Run full setup + $0 --skip-docker # Setup without Docker + $0 --log-file /tmp/mylog.log + +EOF +} + +# ============================================================================= +# MAIN SCRIPT +# ============================================================================= + +main() { + # Parse arguments + SKIP_PACKAGES=false + SKIP_PYTHON=false + SKIP_SSH=false + SKIP_SSH_KEY=false + SKIP_DOCKER=false + SKIP_DEV_TOOLS=false + SKIP_SYSTEM=false + + while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) + show_usage + exit 0 + ;; + --skip-packages) + SKIP_PACKAGES=true + shift + ;; + --skip-python) + SKIP_PYTHON=true + shift + ;; + --skip-ssh) + SKIP_SSH=true + shift + ;; + --skip-ssh-key) + SKIP_SSH_KEY=true + shift + ;; + --skip-docker) + SKIP_DOCKER=true + shift + ;; + --skip-dev-tools) + SKIP_DEV_TOOLS=true + shift + ;; + --skip-system) + SKIP_SYSTEM=true + shift + ;; + --log-file) + LOG_FILE="$2" + shift 2 + ;; + *) + log_error "Unknown option: $1" + show_usage + exit 1 + ;; + esac + done + + # Print header + echo "" + echo "==============================================" + echo " Linux Environment Setup Script v${SCRIPT_VERSION}" + echo "==============================================" + echo "" + mkdir -p "$(dirname "$LOG_FILE")" + log_info "Log file: $LOG_FILE" + + # Initial checks + check_root + detect_distro + + # Check for supported distribution + if [[ "$DISTRO_FAMILY" == "unknown" ]]; then + log_warning "Unsupported distribution. Script may not work correctly." + log_warning "Proceeding anyway..." + fi + + # Update package cache + if [[ "$SKIP_PACKAGES" == "false" ]]; then + update_package_cache + fi + + # Setup essential packages + if [[ "$SKIP_PACKAGES" == "false" ]]; then + setup_essential_packages || log_warning "Some packages may have failed to install" + fi + + # Setup Python + if [[ "$SKIP_PYTHON" == "false" ]]; then + setup_python || log_warning "Python setup had issues" + fi + + # Setup SSH + if [[ "$SKIP_SSH" == "false" ]]; then + setup_ssh || log_warning "SSH setup had issues" + fi + + # Setup SSH Key + if [[ "$SKIP_SSH_KEY" == "false" ]]; then + setup_ssh_key || log_warning "SSH key setup had issues" + fi + + # Setup Docker + if [[ "$SKIP_DOCKER" == "false" ]]; then + setup_docker || log_warning "Docker setup had issues" + fi + + # Setup Development Tools + if [[ "$SKIP_DEV_TOOLS" == "false" ]]; then + setup_dev_tools || log_warning "Dev tools setup had issues" + fi + + # Configure System + if [[ "$SKIP_SYSTEM" == "false" ]]; then + configure_system || log_warning "System configuration had issues" + fi + + # Print summary + print_summary + + log_success "All tasks completed!" +} + +# Run main function +main "$@" \ No newline at end of file