From 349351deec7dcf6c724261e24b51fc44c2cd90e6 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 4 Jul 2026 19:00:34 -0600 Subject: [PATCH] Add cross-platform bootstrap script for host environment setup scripts/bootstrap.sh verifies and (optionally) installs everything mcesptool needs on a host: system packages, ESP-IDF toolchains including the optional QEMU + esp-clang that install.sh all skips, uv, and the project venv. Supports Arch Linux (pacman) and macOS (Homebrew, with cautious defaults that prefer user-owned ~/homebrew). Runs idempotent: safe to re-run after a system reinstall or when extending to a new dev machine. make bootstrap-check for read-only verification; make bootstrap for install. --- Makefile | 15 +- scripts/bootstrap.sh | 412 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 424 insertions(+), 3 deletions(-) create mode 100755 scripts/bootstrap.sh diff --git a/Makefile b/Makefile index f1be323..faed464 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,16 @@ # MCP ESPTool Server Makefile -.PHONY: help install dev test lint format clean docker-build docker-up docker-down docker-logs +.PHONY: help bootstrap bootstrap-check install dev test lint format clean docker-build docker-up docker-down docker-logs # Default target help: @echo "MCP ESPTool Server Development Commands" @echo "" @echo "Setup & Installation:" - @echo " install Install project with uv" - @echo " dev Install in development mode" + @echo " bootstrap-check Verify host has everything mcesptool needs (read-only)" + @echo " bootstrap Install missing system pkgs, IDF tools, venv, group access" + @echo " install Install project with uv" + @echo " dev Install in development mode" @echo "" @echo "Development:" @echo " test Run test suite" @@ -26,6 +28,13 @@ help: @echo " mcp-install Install server with Claude Code" @echo " mcp-test Test MCP server integration" +# Bootstrap (host-level environment setup: pacman/brew, ESP-IDF, uv, venv) +bootstrap-check: + @bash scripts/bootstrap.sh --check + +bootstrap: + @bash scripts/bootstrap.sh --install + # Installation install: uv sync diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100755 index 0000000..06f2dcf --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,412 @@ +#!/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