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.
This commit is contained in:
Ryan Malloy 2026-07-04 19:00:34 -06:00
parent 28b12909ca
commit 349351deec
2 changed files with 424 additions and 3 deletions

View File

@ -1,14 +1,16 @@
# MCP ESPTool Server Makefile # 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 # Default target
help: help:
@echo "MCP ESPTool Server Development Commands" @echo "MCP ESPTool Server Development Commands"
@echo "" @echo ""
@echo "Setup & Installation:" @echo "Setup & Installation:"
@echo " install Install project with uv" @echo " bootstrap-check Verify host has everything mcesptool needs (read-only)"
@echo " dev Install in development mode" @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 ""
@echo "Development:" @echo "Development:"
@echo " test Run test suite" @echo " test Run test suite"
@ -26,6 +28,13 @@ help:
@echo " mcp-install Install server with Claude Code" @echo " mcp-install Install server with Claude Code"
@echo " mcp-test Test MCP server integration" @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 # Installation
install: install:
uv sync uv sync

412
scripts/bootstrap.sh Executable file
View File

@ -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