#!/usr/bin/env bash # bootstrap.sh — cross-platform environment check & setup for mcp-esptool # # Verifies system packages, ESP-IDF state, project venv, QEMU support, and # (on Linux) serial-port group membership. Idempotent: safe to re-run. # # Supports: # - Linux (Arch: pacman) # - macOS 13+ (Homebrew) # # Usage: # ./scripts/bootstrap.sh # check-only (default) # ./scripts/bootstrap.sh --check # check-only (explicit) # ./scripts/bootstrap.sh --install # check + install missing pieces # ./scripts/bootstrap.sh --help set -euo pipefail # Make this script self-sufficient regardless of the invoking shell's config: # prepend common user-tool locations to PATH so uv (installed to ~/.local/bin) is # always findable during bootstrap runs. Non-invasive — only affects THIS process, # not the user's login environment. for user_bin in "$HOME/.local/bin" "$HOME/homebrew/bin"; do case ":$PATH:" in *":$user_bin:"*) : ;; *) [[ -d "$user_bin" ]] && PATH="$user_bin:$PATH" ;; esac done export PATH # ─── colors ──────────────────────────────────────────────────────────────── if [[ -t 1 ]]; then C_OK=$'\e[32m'; C_WARN=$'\e[33m'; C_ERR=$'\e[31m' C_DIM=$'\e[2m'; C_BOLD=$'\e[1m'; C_RESET=$'\e[0m' else C_OK=''; C_WARN=''; C_ERR=''; C_DIM=''; C_BOLD=''; C_RESET='' fi ok() { printf " ${C_OK}✓${C_RESET} %s\n" "$*"; } warn() { printf " ${C_WARN}⚠${C_RESET} %s\n" "$*"; } miss() { printf " ${C_ERR}✗${C_RESET} %s\n" "$*"; } hdr() { printf "\n${C_BOLD}%s${C_RESET}\n" "$*"; } dim() { printf " ${C_DIM}%s${C_RESET}\n" "$*"; } # ─── OS detection + per-OS package registry ───────────────────────────────── OS_KIND="$(uname -s | tr '[:upper:]' '[:lower:]')" ARCH="$(uname -m)" MODE="check" MISSING=0 case "$OS_KIND" in linux) if [[ ! -f /etc/arch-release ]]; then miss "This bootstrap only supports Arch Linux and macOS. Detected: $(uname -sr)" exit 2 fi PKG_MGR="pacman" REQUIRED_SYS_PKGS=(ninja ccache dfu-util) OPTIONAL_SYS_PKGS=(libxml2-legacy) # libxml2.so.2 shim for IDF-bundled esp-clang NEEDS_SERIAL_GROUP=1 SERIAL_GROUP="uucp" # Arch convention ;; darwin) PKG_MGR="brew" REQUIRED_SYS_PKGS=(ninja ccache dfu-util cmake libusb) OPTIONAL_SYS_PKGS=() # No libxml2 SONAME issue on macOS NEEDS_SERIAL_GROUP=0 # macOS grants serial to console user SERIAL_GROUP="" ;; *) miss "Unsupported OS: $OS_KIND" exit 2 ;; esac # ─── arg parsing ─────────────────────────────────────────────────────────── while [[ $# -gt 0 ]]; do case "$1" in --check) MODE="check"; shift ;; --install) MODE="install"; shift ;; --help|-h) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; *) miss "Unknown arg: $1"; exit 2 ;; esac done # ─── package-manager abstraction ────────────────────────────────────────── pkg_installed() { case "$PKG_MGR" in pacman) pacman -Qq "$1" >/dev/null 2>&1 ;; brew) brew list --formula --versions "$1" >/dev/null 2>&1 ;; esac } pkg_install() { case "$PKG_MGR" in pacman) need_sudo; sudo pacman -S --noconfirm --needed "$1" ;; brew) brew install "$1" ;; esac } pkg_install_hint() { case "$PKG_MGR" in pacman) printf "sudo pacman -S --needed %s" "$1" ;; brew) printf "brew install %s" "$1" ;; esac } # ─── config ──────────────────────────────────────────────────────────────── PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" IDF_DIR="${IDF_PATH:-$HOME/esp/esp-idf}" IDF_TOOLS_DIR="${IDF_TOOLS_PATH:-$HOME/.espressif}" # ─── helpers ─────────────────────────────────────────────────────────────── need_sudo() { if ! sudo -n true 2>/dev/null; then printf " ${C_DIM}(sudo needed — you may be prompted)${C_RESET}\n" fi } run_or_print() { if [[ "$MODE" == "install" ]]; then printf " ${C_DIM}\$ %s${C_RESET}\n" "$*" eval "$@" else printf " ${C_DIM}fix:${C_RESET} %s\n" "$*" fi } # ─── macOS prerequisites (Xcode CLT + Homebrew + uv) ─────────────────────── if [[ "$OS_KIND" == "darwin" ]]; then hdr "▸ macOS prerequisites" # Xcode Command Line Tools if xcode-select -p >/dev/null 2>&1; then ok "Xcode Command Line Tools ($(xcode-select -p))" else miss "Xcode Command Line Tools missing" MISSING=$((MISSING + 1)) if [[ "$MODE" == "install" ]]; then warn "Launching 'xcode-select --install' — accept the GUI prompt, then re-run this script." xcode-select --install || true else dim "fix: xcode-select --install (opens a GUI prompt)" fi fi # Homebrew — prefer user-owned ~/homebrew by default (cautious: never mutates shared # system state). Set MCESPTOOL_ALLOW_SHARED_BREW=1 to opt into using an existing # writable /opt/homebrew or /usr/local when ~/homebrew doesn't exist. BREW_FOUND="" if [[ -x "$HOME/homebrew/bin/brew" ]]; then # User-side install already exists — always use it. eval "$("$HOME/homebrew/bin/brew" shellenv)" BREW_FOUND="$HOME/homebrew" elif [[ "${MCESPTOOL_ALLOW_SHARED_BREW:-0}" == "1" ]]; then # Opt-in path: check shared prefixes for a writable install. for BREW_CANDIDATE in /opt/homebrew/bin/brew /usr/local/bin/brew; do if [[ -x "$BREW_CANDIDATE" ]]; then BREW_PREFIX="$("$BREW_CANDIDATE" --prefix 2>/dev/null)" if [[ -n "$BREW_PREFIX" ]] && [[ -w "$BREW_PREFIX/Cellar" || -w "$BREW_PREFIX" ]]; then eval "$("$BREW_CANDIDATE" shellenv)" BREW_FOUND="$BREW_PREFIX" break fi fi done fi if [[ -n "$BREW_FOUND" ]]; then ok "Homebrew ($(brew --version | head -1)) at $BREW_FOUND" # Informational: mention any shared brew we're NOT using for OTHER in /opt/homebrew /usr/local; do if [[ "$OTHER" != "$BREW_FOUND" ]] && [[ -x "$OTHER/bin/brew" ]]; then dim "note: also found $OTHER/bin/brew (not used — set MCESPTOOL_ALLOW_SHARED_BREW=1 to prefer it)" fi done else miss "Homebrew not installed in user prefix (~/homebrew)" MISSING=$((MISSING + 1)) # If a shared brew exists but we're skipping it, tell the user for OTHER in /opt/homebrew /usr/local; do if [[ -x "$OTHER/bin/brew" ]]; then dim "note: $OTHER/bin/brew exists (skipped — set MCESPTOOL_ALLOW_SHARED_BREW=1 to use it)" fi done if [[ "$MODE" == "install" ]]; then warn "Installing Homebrew to \$HOME/homebrew (user-owned, no sudo)" git clone --depth=1 https://github.com/Homebrew/brew "$HOME/homebrew" eval "$("$HOME/homebrew/bin/brew" shellenv)" else dim "fix: git clone --depth=1 https://github.com/Homebrew/brew ~/homebrew" dim " (user-owned prefix — no sudo; then add ~/homebrew/bin to PATH)" fi fi # uv (via official installer OR brew — official installer is more current) if command -v uv >/dev/null 2>&1; then ok "uv ($(uv --version 2>&1 | head -1))" else miss "uv missing" MISSING=$((MISSING + 1)) if [[ "$MODE" == "install" ]]; then curl -LsSf https://astral.sh/uv/install.sh | sh export PATH="$HOME/.local/bin:$PATH" else dim "fix: curl -LsSf https://astral.sh/uv/install.sh | sh" fi fi # Note: uv's installer writes its PATH export to ~/.zshrc (interactive-only). # That means `ssh host 'uv ...'` won't find uv unless the user adds the same # line to ~/.zshenv (non-interactive). Bootstrap does NOT modify user shell # config — we make ourselves self-sufficient at the top of the script instead. # If the user wants uv on PATH for direct non-interactive SSH, hint at the fix. if [[ -f "$HOME/.local/bin/env" ]] && ! grep -q "local/bin/env" "$HOME/.zshenv" 2>/dev/null; then dim "note: 'ssh $(hostname) uv ...' won't find uv from a fresh shell — ~/.zshrc" dim " is interactive-only. To make uv available in non-interactive SSH, run:" dim " echo '. \"\$HOME/.local/bin/env\"' >> ~/.zshenv" fi fi # ─── system packages ─────────────────────────────────────────────────────── hdr "▸ System packages ($PKG_MGR, required)" for pkg in "${REQUIRED_SYS_PKGS[@]}"; do if pkg_installed "$pkg"; then ok "$pkg" else miss "$pkg (missing)" MISSING=$((MISSING + 1)) if [[ "$MODE" == "install" ]]; then pkg_install "$pkg" else dim "fix: $(pkg_install_hint "$pkg")" fi fi done if [[ ${#OPTIONAL_SYS_PKGS[@]} -gt 0 ]]; then hdr "▸ System packages ($PKG_MGR, optional)" for pkg in "${OPTIONAL_SYS_PKGS[@]}"; do if pkg_installed "$pkg"; then ok "$pkg" else warn "$pkg (optional — needed only for esp-clang)" if [[ "$MODE" == "install" ]]; then pkg_install "$pkg" else dim "fix: $(pkg_install_hint "$pkg")" fi fi done fi # ─── required CLI tools ─────────────────────────────────────────────────── hdr "▸ Required CLI tools" tool_version() { case "$1" in esptool) esptool version 2>&1 | head -1 ;; *) "$1" --version 2>&1 | head -1 ;; esac } REQUIRED_TOOLS=(esptool cmake python3 uv uvx) for tool in "${REQUIRED_TOOLS[@]}"; do if command -v "$tool" >/dev/null 2>&1; then ok "$tool ($(tool_version "$tool"))" else miss "$tool not in PATH" MISSING=$((MISSING + 1)) # esptool has a package on both platforms if [[ "$tool" == "esptool" ]] && [[ "$MODE" == "install" ]]; then pkg_install esptool || true fi fi done # ─── QEMU (system-provided; optional since IDF ships its own too) ───────── hdr "▸ QEMU (system, optional — IDF also ships its own)" for qbin in qemu-system-xtensa qemu-system-riscv32; do if command -v "$qbin" >/dev/null 2>&1; then ok "$qbin ($("$qbin" --version | head -1 | awk '{print $4}'))" else warn "$qbin not in PATH (IDF-bundled qemu-xtensa/qemu-riscv32 will still work)" fi done # ─── ESP-IDF source ─────────────────────────────────────────────────────── hdr "▸ ESP-IDF source" if [[ -d "$IDF_DIR/.git" ]]; then IDF_VERSION="$(cd "$IDF_DIR" && git describe --tags 2>/dev/null || echo 'unknown')" ok "Cloned at $IDF_DIR ($IDF_VERSION)" else miss "ESP-IDF not found at $IDF_DIR" MISSING=$((MISSING + 1)) if [[ "$MODE" == "install" ]]; then mkdir -p "$(dirname "$IDF_DIR")" git clone --recursive --depth 1 --branch v5.3 https://github.com/espressif/esp-idf.git "$IDF_DIR" else dim "fix: git clone --recursive --depth 1 --branch v5.3 https://github.com/espressif/esp-idf.git $IDF_DIR" fi fi # ─── ESP-IDF tools ──────────────────────────────────────────────────────── hdr "▸ ESP-IDF tools — required (installed by install.sh all)" REQUIRED_IDF_TOOLS=( xtensa-esp-elf xtensa-esp-elf-gdb riscv32-esp-elf riscv32-esp-elf-gdb esp32ulp-elf openocd-esp32 esp-rom-elfs ) NEED_IDF_INSTALL=0 for tool in "${REQUIRED_IDF_TOOLS[@]}"; do if [[ -d "$IDF_TOOLS_DIR/tools/$tool" ]] && [[ -n "$(ls -A "$IDF_TOOLS_DIR/tools/$tool" 2>/dev/null)" ]]; then ok "$tool" else miss "$tool" MISSING=$((MISSING + 1)); NEED_IDF_INSTALL=1 fi done if [[ $NEED_IDF_INSTALL -eq 1 ]]; then if [[ "$MODE" == "install" ]] && [[ -d "$IDF_DIR" ]]; then printf " ${C_DIM}\$ %s${C_RESET}\n" "$IDF_DIR/install.sh all" IDF_TOOLS_PATH="$IDF_TOOLS_DIR" bash "$IDF_DIR/install.sh" all else dim "fix: $IDF_DIR/install.sh all" fi fi hdr "▸ ESP-IDF tools — optional (QEMU + clang static analysis)" OPTIONAL_IDF_TOOLS=(qemu-xtensa qemu-riscv32 esp-clang) NEED_OPTIONAL=() for tool in "${OPTIONAL_IDF_TOOLS[@]}"; do if find "$IDF_TOOLS_DIR/tools/$tool" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | read -r; then ok "$tool" else warn "$tool (optional)" NEED_OPTIONAL+=("$tool") fi done if [[ ${#NEED_OPTIONAL[@]} -gt 0 ]]; then if [[ "$MODE" == "install" ]] && [[ -d "$IDF_DIR" ]]; then printf " ${C_DIM}\$ idf_tools.py install %s${C_RESET}\n" "${NEED_OPTIONAL[*]}" IDF_TOOLS_PATH="$IDF_TOOLS_DIR" python3 "$IDF_DIR/tools/idf_tools.py" install "${NEED_OPTIONAL[@]}" else dim "fix: IDF_TOOLS_PATH=$IDF_TOOLS_DIR python3 $IDF_DIR/tools/idf_tools.py install ${NEED_OPTIONAL[*]}" fi fi # ─── project venv ───────────────────────────────────────────────────────── hdr "▸ Project venv" # Pin Python 3.13 for the venv: pydantic-core 2.33.x has no pre-built 3.14 wheels # yet, so on stock-3.14 hosts (e.g. macOS Tahoe) `uv sync` falls through to a # Rust-toolchain source build and fails. Explicit 3.13 sidesteps that until # wheels catch up. VENV_PYTHON="3.13" if [[ -x "$PROJECT_DIR/.venv/bin/python" ]]; then PYV="$("$PROJECT_DIR/.venv/bin/python" --version 2>&1)" ok "$PROJECT_DIR/.venv ($PYV)" else miss "venv missing or broken" MISSING=$((MISSING + 1)) run_or_print "cd $PROJECT_DIR && rm -rf .venv && uv sync --python $VENV_PYTHON" fi # ─── serial access ──────────────────────────────────────────────────────── hdr "▸ Serial port access" if [[ $NEEDS_SERIAL_GROUP -eq 1 ]]; then if id -nG "$USER" | tr ' ' '\n' | grep -qx "$SERIAL_GROUP"; then ok "User '$USER' is in '$SERIAL_GROUP' group" else miss "User '$USER' NOT in '$SERIAL_GROUP' group (flashing real chips will fail)" MISSING=$((MISSING + 1)) if [[ "$MODE" == "install" ]]; then need_sudo sudo usermod -aG "$SERIAL_GROUP" "$USER" warn "Re-login required for group membership to take effect" else dim "fix: sudo usermod -aG $SERIAL_GROUP $USER (then re-login)" fi fi else # macOS — no group gate. Just show what's plugged in. SERIAL_DEVS=$(ls /dev/cu.usbserial-* /dev/cu.usbmodem* /dev/cu.SLAB_USBtoUART 2>/dev/null || true) if [[ -n "$SERIAL_DEVS" ]]; then ok "Detected serial devices: $(echo "$SERIAL_DEVS" | tr '\n' ' ')" else ok "macOS grants serial access to the console user (no groups to configure)" dim "no serial adapters attached right now — will appear as /dev/cu.usbserial-* when connected" fi fi # ─── IDF env ────────────────────────────────────────────────────────────── hdr "▸ ESP-IDF environment" if [[ -n "${IDF_PATH:-}" ]]; then ok "IDF_PATH exported: $IDF_PATH" else warn "IDF_PATH not exported in this shell" dim "fix: source $IDF_DIR/export.sh (or alias 'get_idf')" fi # ─── summary ─────────────────────────────────────────────────────────────── hdr "═══ Summary ═══" printf " ${C_DIM}Platform: $OS_KIND/$ARCH · Package manager: $PKG_MGR${C_RESET}\n" if [[ $MISSING -eq 0 ]]; then printf " ${C_OK}${C_BOLD}All checks passed.${C_RESET}\n\n" exit 0 elif [[ "$MODE" == "install" ]]; then printf " ${C_WARN}${C_BOLD}Install attempted. Re-run --check to verify.${C_RESET}\n\n" exit 0 else printf " ${C_WARN}${C_BOLD}%d issue(s) found.${C_RESET} Re-run with ${C_BOLD}--install${C_RESET} to fix.\n\n" "$MISSING" exit 1 fi