#!/usr/bin/env bash # # Pre-setup script — idempotent. Re-running it skips anything already done. # # Usage: ! bash ./setup.sh # # Steps: # 1. iTerm2 + oh-my-zsh (with Homebrew + Xcode CLT as prerequisites) # 2. git identity (prompts if unset) # 3. SSH key + ~/.ssh/config + known_hosts + agent/keychain # 4. Register public key with GitHub (manual paste, browser opens) # 5. Clone the project repo # 6. pyenv + Python 3.12 (project requires >=3.11,<3.14) # 7. pipx (instala Poetry e pre-commit em venvs isoladas — evita os bugs # do brew install poetry, em particular o `poetry self add` quebrado) # 8. Poetry (via pipx; máquinas com brew-poetry são migradas automaticamente) # 9. pre-commit (via pipx) # 10. Docker Desktop # 11. Claude Code set -euo pipefail # Project — override via env if needed. REPO_URL="${REPO_URL:-git@github.com:Figueira-Tech/fig3-monorepo.git}" REPO_PARENT_DIR="${REPO_PARENT_DIR:-$(cd "$(dirname "$0")" && pwd)}" REPO_DIR_NAME="$(basename "${REPO_URL%.git}")" REPO_DIR="$REPO_PARENT_DIR/$REPO_DIR_NAME" # ─── output helpers ──────────────────────────────────────────────────────────── if [[ -t 1 ]]; then BOLD=$'\033[1m'; DIM=$'\033[2m'; RED=$'\033[31m'; GREEN=$'\033[32m' YELLOW=$'\033[33m'; BLUE=$'\033[34m'; RESET=$'\033[0m' else BOLD=""; DIM=""; RED=""; GREEN=""; YELLOW=""; BLUE=""; RESET="" fi step() { printf "\n${BOLD}${BLUE}==>${RESET} ${BOLD}%s${RESET}\n" "$*"; } info() { printf " %s\n" "$*"; } ok() { printf " ${GREEN}✓${RESET} %s\n" "$*"; } skip() { printf " ${DIM}↷ %s${RESET}\n" "$*"; } warn() { printf " ${YELLOW}!${RESET} %s\n" "$*"; } err() { printf " ${RED}✗${RESET} %s\n" "$*" >&2; } die() { err "$*"; exit 1; } # ─── preflight ───────────────────────────────────────────────────────────────── [[ "$(uname -s)" == "Darwin" ]] || die "This script targets macOS only." [[ $EUID -ne 0 ]] || die "Do not run as root. Run as your normal user; sudo will be requested per-step when needed." running_in_iterm() { [[ "${TERM_PROGRAM:-}" == "iTerm.app" ]]; } # Gate: every step after iTerm install requires we're actually running in iTerm. # On first run from Terminal.app we exit here so the user can switch over. require_iterm_or_stop() { if running_in_iterm; then ok "running inside iTerm — continuing." return fi printf "\n${YELLOW}${BOLD}Step 1 complete.${RESET} ${BOLD}Open iTerm now${RESET} and re-run this script\n" printf "to continue with the remaining steps.\n\n" printf " ${DIM}bash %s${RESET}\n\n" "$0" exit 0 } # ─── step 1: iTerm2 + oh-my-zsh ─────────────────────────────────────────────── ensure_xcode_clt() { step "Xcode Command Line Tools" if xcode-select -p >/dev/null 2>&1; then skip "already installed ($(xcode-select -p))" return fi info "triggering install — a system dialog will appear; click Install and wait for it to finish." xcode-select --install || true until xcode-select -p >/dev/null 2>&1; do sleep 5; done ok "Xcode CLT installed." } brew_shellenv_path() { # Apple Silicon: /opt/homebrew/bin/brew · Intel: /usr/local/bin/brew if [[ -x /opt/homebrew/bin/brew ]]; then echo /opt/homebrew/bin/brew elif [[ -x /usr/local/bin/brew ]]; then echo /usr/local/bin/brew else echo ""; fi } ensure_homebrew() { step "Homebrew" local brew_bin; brew_bin="$(brew_shellenv_path)" if [[ -n "$brew_bin" ]]; then eval "$("$brew_bin" shellenv)" skip "already installed ($("$brew_bin" --version | head -1))" else info "installing via official script (will prompt for sudo password)…" NONINTERACTIVE=1 /bin/bash -c \ "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" brew_bin="$(brew_shellenv_path)" [[ -n "$brew_bin" ]] || die "Homebrew install finished but brew not found on disk." eval "$("$brew_bin" shellenv)" ok "Homebrew installed." fi # Ensure brew is on PATH in future zsh sessions (idempotent). local zprofile="$HOME/.zprofile" local line="eval \"\$($brew_bin shellenv)\"" if [[ ! -f "$zprofile" ]] || ! grep -Fqx "$line" "$zprofile"; then printf '\n# Added by setup.sh — load Homebrew into login shells\n%s\n' "$line" >> "$zprofile" ok "added brew shellenv to ~/.zprofile" else skip "brew shellenv already in ~/.zprofile" fi } ensure_iterm() { step "iTerm2" if [[ -d "/Applications/iTerm.app" ]]; then skip "already installed (/Applications/iTerm.app)" return fi info "installing via Homebrew cask…" brew install --cask iterm2 ok "iTerm2 installed." } ensure_oh_my_zsh() { step "oh-my-zsh" if [[ -d "$HOME/.oh-my-zsh" ]]; then skip "already installed (~/.oh-my-zsh)" else info "installing unattended…" # RUNZSH=no: don't drop us into a zsh subshell. # KEEP_ZSHRC=yes: don't overwrite an existing ~/.zshrc. RUNZSH=no KEEP_ZSHRC=yes CHSH=no \ sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" ok "oh-my-zsh installed." fi # Make sure the user's login shell is zsh (it usually is on modern macOS). local current_shell; current_shell="$(dscl . -read "/Users/$USER" UserShell | awk '{print $2}')" if [[ "$current_shell" == */zsh ]]; then skip "login shell already zsh ($current_shell)" else info "changing login shell to /bin/zsh (sudo may be required)…" chsh -s /bin/zsh ok "login shell set to /bin/zsh" fi } # ─── step 2: git identity ───────────────────────────────────────────────────── ensure_git_identity() { step "Git identity" local name email name="$(git config --global user.name || true)" email="$(git config --global user.email || true)" if [[ -n "$name" && -n "$email" ]]; then skip "already set: $name <$email>" return fi info "git needs a name and email for commits. These are stored in ~/.gitconfig." if [[ -z "$name" ]]; then read -r -p " Full name: " name [[ -n "$name" ]] || die "Name cannot be empty." git config --global user.name "$name" fi if [[ -z "$email" ]]; then read -r -p " Email: " email [[ -n "$email" ]] || die "Email cannot be empty." git config --global user.email "$email" fi ok "set: $name <$email>" } # ─── step 3: SSH key, config, known_hosts, agent ───────────────────────────── SSH_KEY="$HOME/.ssh/id_ed25519" ensure_ssh_dir() { if [[ ! -d "$HOME/.ssh" ]]; then mkdir -p "$HOME/.ssh" chmod 700 "$HOME/.ssh" fi } ensure_ssh_key() { step "SSH key (ed25519)" ensure_ssh_dir if [[ -f "$SSH_KEY" ]]; then skip "already exists at $SSH_KEY" return fi local email; email="$(git config --global user.email)" info "generating new key. You will be prompted for a passphrase — leave empty for none." ssh-keygen -t ed25519 -C "$email" -f "$SSH_KEY" ok "key generated." } ensure_ssh_config() { step "SSH config (~/.ssh/config) for github.com" ensure_ssh_dir local cfg="$HOME/.ssh/config" touch "$cfg"; chmod 600 "$cfg" if grep -Eq '^\s*Host\s+github\.com\b' "$cfg"; then skip "Host github.com block already present" return fi cat >> "$cfg" </dev/null 2>&1; then skip "github.com already in known_hosts" return fi info "fetching GitHub host keys via ssh-keyscan…" ssh-keyscan -t ed25519,rsa github.com 2>/dev/null >> "$kh" ok "github.com added to known_hosts." } ensure_key_in_agent() { step "SSH key loaded into agent + keychain" # If the public key fingerprint is already known to the agent, skip. local fp; fp="$(ssh-keygen -lf "$SSH_KEY.pub" | awk '{print $2}')" if ssh-add -l 2>/dev/null | grep -Fq "$fp"; then skip "key already loaded in ssh-agent" return fi info "adding key (passphrase, if any, will be stored in macOS keychain)…" ssh-add --apple-use-keychain "$SSH_KEY" ok "key loaded." } # ─── step 4: register pubkey with GitHub ────────────────────────────────────── github_ssh_works() { # `ssh -T git@github.com` ALWAYS exits 1 — GitHub closes the session with # "does not provide shell access" right after auth. Combined with `set -o # pipefail`, `ssh … | grep -q …` would always return 1 even on a successful # greeting. So capture the output and pattern-match it directly. local out out="$(ssh -T -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new git@github.com 2>&1 || true)" [[ "$out" == *"successfully authenticated"* ]] } ensure_github_pubkey_uploaded() { step "GitHub SSH access" if github_ssh_works; then skip "ssh -T git@github.com already authenticates" return fi info "your public key needs to be added to GitHub." if command -v pbcopy >/dev/null 2>&1; then pbcopy < "$SSH_KEY.pub" ok "public key copied to clipboard." fi printf "\n${DIM}---- %s.pub ----${RESET}\n" "$SSH_KEY" cat "$SSH_KEY.pub" printf "${DIM}--------------------------------${RESET}\n\n" info "opening github.com/settings/ssh/new in your browser…" open "https://github.com/settings/ssh/new" || true printf "\n %sPaste the key on the GitHub page, save it, then press Enter here.%s " "$BOLD" "$RESET" read -r _ if github_ssh_works; then ok "authenticated to github.com via SSH." else die "still cannot authenticate. Verify the key was added at https://github.com/settings/keys and re-run." fi } # ─── step 5: clone the project repo ─────────────────────────────────────────── ensure_repo_cloned() { step "Clone $REPO_URL" if [[ -d "$REPO_DIR/.git" ]]; then skip "already cloned at $REPO_DIR" return fi if [[ -e "$REPO_DIR" ]]; then die "$REPO_DIR exists but is not a git repo — refusing to overwrite." fi info "cloning into ${REPO_DIR}…" git clone "$REPO_URL" "$REPO_DIR" ok "cloned." } # ─── step 6: pyenv + Python 3.12 ────────────────────────────────────────────── PYTHON_SERIES="${PYTHON_SERIES:-3.12}" ensure_pyenv() { step "pyenv" if command -v pyenv >/dev/null 2>&1; then skip "already installed ($(pyenv --version))" else info "installing via brew…" brew install pyenv ok "pyenv installed." fi # Activate pyenv inside this script's process so later steps can call it. export PYENV_ROOT="${PYENV_ROOT:-$HOME/.pyenv}" case ":$PATH:" in *":$PYENV_ROOT/bin:"*) ;; *) export PATH="$PYENV_ROOT/bin:$PATH";; esac eval "$(pyenv init -)" # Persist init in ~/.zshrc for future interactive shells. local zshrc="$HOME/.zshrc" local marker="# Added by setup.sh — pyenv" if [[ -f "$zshrc" ]] && grep -Fq "$marker" "$zshrc"; then skip "pyenv init already in ~/.zshrc" else cat >> "$zshrc" <<'EOF' # Added by setup.sh — pyenv export PYENV_ROOT="$HOME/.pyenv" [[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH" eval "$(pyenv init -)" EOF ok "added pyenv init to ~/.zshrc" fi } ensure_python_build_deps() { step "Python build dependencies" # pyenv compiles CPython from source. These C libs are needed for the # corresponding stdlib modules (_lzma, _sqlite3, _ssl, readline, _tkinter). # If any is missing at build time the build silently skips that module — # you only find out at runtime with ModuleNotFoundError. local deps=(xz openssl@3 readline sqlite3 tcl-tk) local to_install=() for dep in "${deps[@]}"; do if brew list --formula "$dep" >/dev/null 2>&1; then skip "$dep already installed" else to_install+=("$dep") fi done if (( ${#to_install[@]} > 0 )); then info "installing: ${to_install[*]}" brew install "${to_install[@]}" ok "build deps installed." fi } ensure_python_series() { step "Python ${PYTHON_SERIES}" # Find the latest patch available for the requested series (e.g. 3.12.7). local target target="$(pyenv install --list 2>/dev/null \ | awk '{$1=$1}1' \ | grep -E "^${PYTHON_SERIES//./\\.}\\.[0-9]+$" \ | tail -1)" [[ -n "$target" ]] || die "no installable ${PYTHON_SERIES}.x found via pyenv." local py="$PYENV_ROOT/versions/$target/bin/python" # If a prior pyenv install is missing critical stdlib modules (because build # deps were absent at that time), nuke it so we rebuild cleanly. if pyenv versions --bare | grep -Fxq "$target"; then if "$py" -c "import _lzma, _sqlite3, ssl, readline" >/dev/null 2>&1; then skip "Python $target already installed via pyenv (stdlib OK)" else warn "Python $target is installed but missing stdlib modules — removing and rebuilding." pyenv uninstall -f "$target" fi fi if ! pyenv versions --bare | grep -Fxq "$target"; then info "installing Python $target (compiles from source; takes a few minutes)…" pyenv install -s "$target" ok "Python $target installed." fi [[ -x "$py" ]] || die "expected $py to exist after install." # Verify the stdlib modules whose absence motivates the build-deps step. if ! "$py" -c "import _lzma, _sqlite3, ssl, readline" 2>/dev/null; then die "Python $target built but a critical stdlib module is missing. Check build deps and re-run." fi ok "stdlib OK (_lzma, _sqlite3, ssl, readline)" ok "pip: $("$py" -m pip --version)" # Set as global default only if user has no global Python set yet. local current_global; current_global="$(pyenv global 2>/dev/null || true)" if [[ "$current_global" == "system" || -z "$current_global" ]]; then pyenv global "$target" ok "pyenv global set to $target" else skip "pyenv global already set to $current_global" fi } # ─── step 7: pipx + Poetry ──────────────────────────────────────────────────── # # Poetry é instalado via pipx (recomendação oficial do Poetry team) em vez de # brew. Brew gerencia o cellar como imutável; `poetry self add` e # `poetry self update` falham silenciosamente nesse modo, e o Makefile de # fig-core depende deles. Pipx dá ao poetry uma venv isolada que ele controla, # então self-add/self-update funcionam. # # Self-healing: máquinas que já têm poetry via brew são migradas # automaticamente (uninstall do brew → install via pipx) na próxima execução. ensure_pipx() { step "pipx" if command -v pipx >/dev/null 2>&1; then skip "already installed ($(pipx --version))" return fi info "installing via brew…" brew install pipx # ensurepath edita ~/.zshrc adicionando ~/.local/bin no PATH. Sem isso, # tools instalados por pipx não ficam disponíveis em novas shells. pipx ensurepath >/dev/null ok "pipx installed ($(pipx --version))" } ensure_poetry() { step "Poetry" # Detecta poetry-via-brew (caminho frágil) e migra pra pipx. if command -v poetry >/dev/null 2>&1; then local poetry_path poetry_path=$(command -v poetry) if [[ "$poetry_path" == */Cellar/* || "$poetry_path" == */homebrew/* ]]; then warn "poetry foi instalado via brew (caminho frágil) — migrando pra pipx" brew uninstall poetry 2>/dev/null || true else skip "already installed at $poetry_path ($(poetry --version))" return fi fi info "installing via pipx…" pipx install poetry ok "Poetry installed ($(poetry --version))" } # ─── step 7.5: pre-commit ───────────────────────────────────────────────────── # # Pre-commit hooks são usados em fig-core, fig3-ai e (eventualmente) outros # repos. Instalar globalmente via pipx significa um único binário disponível # em qualquer pasta, sem depender de venv ativada. `pre-commit install` em # cada repo cria os hooks que usam esse binário global. ensure_precommit() { step "pre-commit" if command -v pre-commit >/dev/null 2>&1; then local pc_path pc_path=$(command -v pre-commit) # Se veio do brew, migra. Se veio de algum venv local (qualquer caminho com # /.venv/), respeita — pode ser intencional ter versão pinned por projeto. if [[ "$pc_path" == */Cellar/* || "$pc_path" == */homebrew/* ]]; then warn "pre-commit veio do brew — migrando pra pipx" brew uninstall pre-commit 2>/dev/null || true else skip "already installed at $pc_path ($(pre-commit --version))" return fi fi info "installing via pipx…" pipx install pre-commit ok "pre-commit installed ($(pre-commit --version))" } # ─── step 8: Docker Desktop ─────────────────────────────────────────────────── ensure_docker_desktop() { step "Docker Desktop" if [[ -d "/Applications/Docker.app" ]]; then skip "already installed (/Applications/Docker.app)" else info "installing via Homebrew cask…" brew install --cask docker-desktop ok "Docker Desktop installed." fi # Daemon readiness: `docker info` succeeds only when the engine is running. if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then skip "Docker engine is running ($(docker --version))" return fi info "launching Docker Desktop — accept the license dialog if it appears." open -a Docker || true warn "the Docker engine takes ~30s to start. Wait for the whale icon in your menu bar," warn "then verify with: ${BOLD}docker info${RESET}" } # ─── step 9: Claude Code ────────────────────────────────────────────────────── ensure_claude_code() { step "Claude Code" # The installer drops the binary in ~/.local/bin, which isn't on PATH in this # process yet — check that location too so re-runs correctly skip. if command -v claude >/dev/null 2>&1 || [[ -x "$HOME/.local/bin/claude" ]]; then skip "already installed ($(claude --version 2>/dev/null || echo "$HOME/.local/bin/claude"))" return fi info "installing via official script…" curl -fsSL https://claude.ai/install.sh | bash if command -v claude >/dev/null 2>&1 || [[ -x "$HOME/.local/bin/claude" ]]; then ok "Claude Code installed." else die "Claude Code install finished but 'claude' not found. Re-run, or check the installer output above." fi } # ─── run ─────────────────────────────────────────────────────────────────────── main() { printf "${BOLD}Pre-setup${RESET} — idempotent bootstrap for a fresh Mac.\n" printf "${DIM}Run it like this:${RESET} ${BOLD}! bash ./setup.sh${RESET}\n" ensure_xcode_clt ensure_homebrew ensure_iterm ensure_oh_my_zsh step "Environment check" require_iterm_or_stop ensure_git_identity ensure_ssh_key ensure_ssh_config ensure_known_hosts ensure_key_in_agent ensure_github_pubkey_uploaded ensure_repo_cloned ensure_pyenv ensure_python_build_deps ensure_python_series ensure_pipx ensure_poetry ensure_precommit ensure_docker_desktop ensure_claude_code printf "\n${GREEN}${BOLD}Done.${RESET} Repo is ready at ${BOLD}%s${RESET}\n" "$REPO_DIR" # ~/.zshrc was edited above (pyenv init). This script runs as a subprocess, # so it can't change the PATH of the shell you launched it from — only # sourcing ~/.zshrc IN that interactive shell makes pyenv's python3 shim take # effect. We fold `source ~/.zshrc` into the next command so it runs in your # shell, right before `make setup-all`. if ! command -v python3 >/dev/null 2>&1 \ || [[ "$(command -v python3)" != "$PYENV_ROOT/shims/"* ]]; then printf "\n${YELLOW}!${RESET} Your current shell still resolves ${BOLD}python3${RESET} to %s\n" "$(command -v python3 2>/dev/null || echo '(none)')" printf " (not pyenv's shim) because ~/.zshrc was edited after this shell started.\n" printf " Run ${BOLD}source ~/.zshrc${RESET} to load it (or open a new iTerm window).\n" fi printf "\nNext step — re-source your shell so the new python is active, then run setup:\n" printf " ${BOLD}source ~/.zshrc && cd %s && make setup-all${RESET}\n\n" "$REPO_DIR" } main "$@"