#!/bin/bash # ============================================================================== # # ███╗ ███╗ █████╗ ██████╗███████╗████████╗ █████╗ ██████╗ ████████╗███████╗██████╗ # ████╗ ████║██╔══██╗██╔════╝██╔════╝╚══██╔══╝██╔══██╗██╔══██╗╚══██╔══╝██╔════╝██╔══██╗ # ██╔████╔██║███████║██║ ███████╗ ██║ ███████║██████╔╝ ██║ █████╗ ██████╔╝ # ██║╚██╔╝██║██╔══██║██║ ╚════██║ ██║ ██╔══██║██╔══██╗ ██║ ██╔══╝ ██╔══██╗ # ██║ ╚═╝ ██║██║ ██║╚██████╗███████║ ██║ ██║ ██║██║ ██║ ██║ ███████╗██║ ██║ # ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ # # MACSTARTER — fast first-time setup for Intel & Apple Silicon Macs # Little sibling of UltraBunt, grew out of macpress. Menus, categories, # zero ball-ache. # # Usage: ./macstarter.sh interactive menus # ./macstarter.sh --catalog print the app catalog and exit # # Safe to re-run: everything checks before it installs. # ============================================================================== # ------------------------------------------------------------------------------ # Colors & logging # ------------------------------------------------------------------------------ RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' WHITE='\033[1;37m' NC='\033[0m' LOGFILE="$HOME/macstarter-install.log" log() { echo -e "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOGFILE" echo -e "$*" } ok() { log "${GREEN}✓${NC} $*"; } warn() { log "${YELLOW}⚠${NC} $*"; } err() { log "${RED}✗${NC} $*"; } RESULTS_OK=() RESULTS_FAIL=() RESULTS_SKIP=() # ------------------------------------------------------------------------------ # THE CATALOG # Format: tag|TYPE|payload|default(on/off)|Description # TYPE: BREW (formula) | CASK | MAS (App Store id) | CUSTOM (function name) # ------------------------------------------------------------------------------ CAT_CORE=( "git|BREW|git|on|Version control" "wget|BREW|wget|on|Network downloader" "jq|BREW|jq|on|JSON processor" "ripgrep|BREW|ripgrep|on|Fast text search (rg)" "fd|BREW|fd|on|Modern find" "fzf|BREW|fzf|on|Fuzzy finder" "tree|BREW|tree|on|Directory visualizer" "tmux|BREW|tmux|on|Terminal multiplexer" "ncdu|BREW|ncdu|on|Disk usage analyzer" "bat|BREW|bat|on|Cat with syntax highlighting" "eza|BREW|eza|on|Modern ls" "tldr|BREW|tldr|on|Simplified man pages" "htop|BREW|htop|on|Process viewer" "btop|BREW|btop|on|Prettier process viewer" "rsync|BREW|rsync|on|Real rsync (macOS ships a crusty old one)" "watch|BREW|watch|off|Re-run a command on interval" "coreutils|BREW|coreutils|off|GNU coreutils (gls, gdate, etc)" ) CAT_DEV=( "node|BREW|node|on|Node.js + npm" "gh|BREW|gh|on|GitHub CLI" "python|BREW|python|off|Python 3 via brew" "go|BREW|go|off|Go language" "claude-code|CASK|claude-code|on|Claude Code — terminal AI coding" "antigravity-cli|CASK|antigravity-cli|on|Google Antigravity CLI" "antigravity|CASK|antigravity|off|Google Antigravity IDE" "vscode|CASK|visual-studio-code|off|Visual Studio Code" "lazygit|BREW|lazygit|off|Terminal UI for git" "shellcheck|BREW|shellcheck|off|Shell script linter" ) CAT_NET=( "tailscale|CASK|tailscale-app|on|Tailscale mesh VPN (GUI app + CLI)" "rclone|BREW|rclone|on|Sync to/from cloud storage" "nmap|BREW|nmap|off|Network scanner" "mtr|BREW|mtr|off|Traceroute + ping combo" "speedtest|BREW|speedtest-cli|off|Internet speed test" "wireguard|BREW|wireguard-tools|off|WireGuard CLI tools" ) CAT_MEDIA=( "ffmpeg|BREW|ffmpeg|on|Swiss-army media converter" "yt-dlp|BREW|yt-dlp|off|Video downloader" "imagemagick|BREW|imagemagick|off|Image conversion toolkit" "exiftool|BREW|exiftool|off|Read/write media metadata" "vlc|CASK|vlc|off|Plays literally anything" ) CAT_ANDROID=( "platform-tools|CASK|android-platform-tools|on|adb + fastboot" "scrcpy|BREW|scrcpy|on|Mirror & control Android over adb" ) CAT_APPLE=( "xcode|CUSTOM|install_full_xcode|on|Full Xcode IDE via App Store (~12GB, needs sign-in)" "xcodes|BREW|xcodesorg/made/xcodes|off|Manage multiple Xcode versions" "swiftlint|BREW|swiftlint|off|Swift linter" ) CAT_APPS=( "brave|CASK|brave-browser|on|Brave browser" "warp|CASK|warp|on|Warp terminal" "raycast|CASK|raycast|off|Launcher on steroids" "rectangle|CASK|rectangle|off|Window snapping" "the-unarchiver|CASK|the-unarchiver|off|Opens every archive format" "stats|CASK|stats|off|Menubar system monitor" "keka|CASK|keka|off|Archiver / compressor" ) CAT_TWEAKS=( "scroll-off|CUSTOM|tweak_scroll_off|on|Turn OFF natural scrolling" "show-ext|CUSTOM|tweak_show_extensions|on|Finder: always show file extensions" "show-hidden|CUSTOM|tweak_show_hidden|on|Finder: show hidden files" "finder-bars|CUSTOM|tweak_finder_bars|on|Finder: path bar + status bar" "fast-keys|CUSTOM|tweak_fast_keys|on|Fast key repeat, no press-and-hold" "screenshots|CUSTOM|tweak_screenshots_dir|on|Screenshots to ~/Screenshots" "dock-autohide|CUSTOM|tweak_dock_autohide|off|Auto-hide the Dock" "no-ds-store|CUSTOM|tweak_no_ds_store|on|No .DS_Store on network/USB drives" ) CAT_KEYS=( "ssh-key|CUSTOM|setup_ssh_key|on|Generate ed25519 SSH key + keychain agent" "ssh-config|CUSTOM|setup_ssh_config|on|~/.ssh/config with keychain defaults" "tailscale-join|CUSTOM|tailscale_join|on|Log in to Tailscale + join your tailnet" "tailscale-alias|CUSTOM|tailscale_alias|on|Add 'tailscale' CLI alias to ~/.zshrc" ) CAT_WORDPRESS=( "php|BREW|php|on|PHP" "mariadb|BREW|mariadb|on|MariaDB (MySQL)" "wp-cli|BREW|wp-cli|on|WordPress CLI" "wp-site|CUSTOM|wp_quick_site|off|Spin up a local WP site right now" ) CATEGORIES=( "core|Core CLI tools|CAT_CORE" "dev|Dev tools & AI CLIs|CAT_DEV" "net|Network & sync|CAT_NET" "media|Media|CAT_MEDIA" "android|Android|CAT_ANDROID" "apple|Apple dev|CAT_APPLE" "apps|GUI apps|CAT_APPS" "tweaks|macOS tweaks|CAT_TWEAKS" "keys|Keys & Tailscale network|CAT_KEYS" "wordpress|WordPress (macpress classic)|CAT_WORDPRESS" ) # ------------------------------------------------------------------------------ # Catalog dump (non-interactive, for a quick look / sanity check) # ------------------------------------------------------------------------------ print_catalog() { local cat_entry cat_title cat_var items entry tag type payload def desc for cat_entry in "${CATEGORIES[@]}"; do IFS='|' read -r _ cat_title cat_var <<< "$cat_entry" echo "" echo "== $cat_title ==" eval "items=(\"\${${cat_var}[@]}\")" for entry in "${items[@]}"; do IFS='|' read -r tag type payload def desc <<< "$entry" printf " %-16s %-7s %-28s [%s] %s\n" "$tag" "$type" "$payload" "$def" "$desc" done done } if [[ "${1:-}" == "--catalog" ]]; then print_catalog exit 0 fi # ------------------------------------------------------------------------------ # Bootstrap: CLT -> Homebrew -> dialog + mas # Runs every launch; every step is a no-op if already done. # ------------------------------------------------------------------------------ BREW_PREFIX="/opt/homebrew" [[ "$(uname -m)" == "x86_64" ]] && BREW_PREFIX="/usr/local" bootstrap() { echo -e "${CYAN}── macstarter bootstrap ──${NC}" # Xcode Command Line Tools (Homebrew needs these) if ! xcode-select -p &>/dev/null; then warn "Xcode Command Line Tools missing — triggering install dialog..." xcode-select --install 2>/dev/null echo -e "${WHITE}Click 'Install' in the popup, then wait. I'll poll until it's done.${NC}" until xcode-select -p &>/dev/null; do sleep 10; done ok "Command Line Tools installed" else ok "Command Line Tools present" fi # Homebrew if [[ -x "$BREW_PREFIX/bin/brew" ]]; then ok "Homebrew present" else warn "Installing Homebrew..." /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" || { err "Homebrew install failed — cannot continue"; exit 1; } ok "Homebrew installed" fi eval "$("$BREW_PREFIX/bin/brew" shellenv)" # Make sure future shells get brew too if ! grep -q 'brew shellenv' "$HOME/.zprofile" 2>/dev/null; then echo "eval \"\$($BREW_PREFIX/bin/brew shellenv)\"" >> "$HOME/.zprofile" ok "Added brew shellenv to ~/.zprofile" fi # dialog drives the menus, mas drives the App Store for tool in dialog mas; do if ! command -v "$tool" &>/dev/null; then warn "Installing $tool..." brew install "$tool" >>"$LOGFILE" 2>&1 && ok "$tool installed" || err "$tool failed" fi done } # ------------------------------------------------------------------------------ # dialog helpers # ------------------------------------------------------------------------------ ui_msg() { dialog --title "$1" --msgbox "$2" 14 72 } ui_yesno() { dialog --title "$1" --yesno "$2" 12 72 } # ------------------------------------------------------------------------------ # Installers # ------------------------------------------------------------------------------ install_formula() { local name="$1" if brew list --formula "$name" &>/dev/null; then ok "$name already installed (formula)" RESULTS_SKIP+=("$name") elif brew install "$name" >>"$LOGFILE" 2>&1; then ok "$name installed" RESULTS_OK+=("$name") else err "$name FAILED (see $LOGFILE)" RESULTS_FAIL+=("$name") fi } install_cask() { local name="$1" if brew list --cask "$name" &>/dev/null; then ok "$name already installed (cask)" RESULTS_SKIP+=("$name") elif brew install --cask "$name" >>"$LOGFILE" 2>&1; then ok "$name installed" RESULTS_OK+=("$name") else err "$name FAILED (see $LOGFILE)" RESULTS_FAIL+=("$name") fi } install_mas() { local app_id="$1" name="$2" if mas list 2>/dev/null | grep -q "^$app_id "; then ok "$name already installed (App Store)" RESULTS_SKIP+=("$name") elif mas install "$app_id" >>"$LOGFILE" 2>&1; then ok "$name installed from App Store" RESULTS_OK+=("$name") else err "$name FAILED — are you signed in to the App Store? (open it, sign in, retry)" RESULTS_FAIL+=("$name") fi } run_custom() { local fn="$1" label="$2" if "$fn"; then RESULTS_OK+=("$label") else RESULTS_FAIL+=("$label") fi } dispatch() { local entry="$1" tag type payload def desc IFS='|' read -r tag type payload def desc <<< "$entry" echo "" log "${CYAN}── $tag: $desc ──${NC}" case "$type" in BREW) install_formula "$payload" ;; CASK) install_cask "$payload" ;; MAS) install_mas "$payload" "$tag" ;; CUSTOM) run_custom "$payload" "$tag" ;; esac } # ------------------------------------------------------------------------------ # CUSTOM: Apple dev # ------------------------------------------------------------------------------ install_full_xcode() { if [[ -d "/Applications/Xcode.app" ]]; then ok "Xcode already installed" return 0 fi warn "Installing full Xcode from the App Store — this is a ~12GB download." warn "You must be signed in to the App Store (open it and sign in if this fails)." if mas install 497799835 >>"$LOGFILE" 2>&1; then ok "Xcode downloaded" warn "Accepting license + first-run setup (needs sudo)..." sudo xcodebuild -license accept >>"$LOGFILE" 2>&1 || true sudo xcode-select -s /Applications/Xcode.app/Contents/Developer >>"$LOGFILE" 2>&1 || true xcodebuild -runFirstLaunch >>"$LOGFILE" 2>&1 || true ok "Xcode ready" return 0 fi err "Xcode install failed — sign in to the App Store app, then re-run this item" return 1 } # ------------------------------------------------------------------------------ # CUSTOM: macOS tweaks # ------------------------------------------------------------------------------ tweak_scroll_off() { defaults write NSGlobalDomain com.apple.swipescrolldirection -bool false ok "Natural scrolling OFF (log out/in for it to stick everywhere)" } tweak_show_extensions() { defaults write NSGlobalDomain AppleShowAllExtensions -bool true NEED_FINDER_RESTART=1 ok "File extensions always shown" } tweak_show_hidden() { defaults write com.apple.finder AppleShowAllFiles -bool true NEED_FINDER_RESTART=1 ok "Hidden files shown in Finder" } tweak_finder_bars() { defaults write com.apple.finder ShowPathbar -bool true defaults write com.apple.finder ShowStatusBar -bool true NEED_FINDER_RESTART=1 ok "Finder path bar + status bar on" } tweak_fast_keys() { defaults write NSGlobalDomain KeyRepeat -int 2 defaults write NSGlobalDomain InitialKeyRepeat -int 15 defaults write NSGlobalDomain ApplePressAndHoldEnabled -bool false ok "Fast key repeat, press-and-hold disabled (log out/in to apply)" } tweak_screenshots_dir() { mkdir -p "$HOME/Screenshots" defaults write com.apple.screencapture location -string "$HOME/Screenshots" killall SystemUIServer 2>/dev/null || true ok "Screenshots now land in ~/Screenshots" } tweak_dock_autohide() { defaults write com.apple.dock autohide -bool true killall Dock 2>/dev/null || true ok "Dock auto-hides" } tweak_no_ds_store() { defaults write com.apple.desktopservices DSDontWriteNetworkStores -bool true defaults write com.apple.desktopservices DSDontWriteUSBStores -bool true ok "No more .DS_Store litter on network/USB drives" } # ------------------------------------------------------------------------------ # CUSTOM: Keys & Tailscale network # ------------------------------------------------------------------------------ SSH_KEY="$HOME/.ssh/id_ed25519" setup_ssh_key() { mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh" if [[ -f "$SSH_KEY" ]]; then ok "SSH key already exists: $SSH_KEY" else ssh-keygen -t ed25519 -f "$SSH_KEY" -N "" -C "$USER@$(scutil --get LocalHostName 2>/dev/null || hostname -s)" >>"$LOGFILE" 2>&1 ok "Generated ed25519 key: $SSH_KEY" fi ssh-add --apple-use-keychain "$SSH_KEY" >>"$LOGFILE" 2>&1 && ok "Key added to keychain agent" echo "" echo -e "${WHITE}Your public key — paste this into ~/.ssh/authorized_keys on your tailnet boxes" echo -e "(or GitHub → Settings → SSH keys):${NC}" echo "" cat "$SSH_KEY.pub" echo "" pbcopy < "$SSH_KEY.pub" && ok "Public key copied to clipboard" return 0 } setup_ssh_config() { local cfg="$HOME/.ssh/config" mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh" if [[ -f "$cfg" ]] && grep -q "UseKeychain" "$cfg"; then ok "~/.ssh/config already has keychain defaults" return 0 fi cat >> "$cfg" << 'EOF' # macstarter defaults Host * AddKeysToAgent yes UseKeychain yes IdentityFile ~/.ssh/id_ed25519 ServerAliveInterval 60 EOF chmod 600 "$cfg" ok "Wrote keychain + keepalive defaults to ~/.ssh/config" } TAILSCALE_CLI="/Applications/Tailscale.app/Contents/MacOS/Tailscale" tailscale_join() { if [[ ! -d "/Applications/Tailscale.app" ]]; then err "Tailscale app not installed — install it from the Network & sync menu first" return 1 fi open -a Tailscale if "$TAILSCALE_CLI" status &>/dev/null; then ok "Already connected to your tailnet:" "$TAILSCALE_CLI" status | head -10 return 0 fi echo "" echo -e "${WHITE}Tailscale is open in your menu bar. Log in with your usual account" echo -e "to join your tailnet, then press Enter here.${NC}" read -r if "$TAILSCALE_CLI" status &>/dev/null; then ok "Connected! Your tailnet:" "$TAILSCALE_CLI" status | head -10 return 0 fi warn "Not connected yet — finish login in the menu bar app, it'll join automatically" return 0 } tailscale_alias() { if [[ ! -d "/Applications/Tailscale.app" ]]; then err "Tailscale app not installed yet" return 1 fi if grep -q "alias tailscale=" "$HOME/.zshrc" 2>/dev/null; then ok "tailscale alias already in ~/.zshrc" return 0 fi echo "alias tailscale=\"$TAILSCALE_CLI\"" >> "$HOME/.zshrc" ok "Added tailscale CLI alias to ~/.zshrc (new shells can run: tailscale status)" } # ------------------------------------------------------------------------------ # CUSTOM: macpress classic — a local WordPress in about a minute # ------------------------------------------------------------------------------ wp_quick_site() { for need in php mariadb wp-cli; do if ! brew list --formula "$need" &>/dev/null; then err "$need not installed — tick it in the WordPress menu first" return 1 fi done local site_dir="$HOME/Sites/wp" local db_name="wp_local" warn "Setting up a local WordPress at $site_dir ..." brew services start mariadb >>"$LOGFILE" 2>&1 sleep 3 local mysql_cmd="mariadb" command -v mariadb &>/dev/null || mysql_cmd="mysql" "$mysql_cmd" -u root -e "CREATE DATABASE IF NOT EXISTS $db_name" 2>>"$LOGFILE" || { err "Couldn't create database — check 'brew services info mariadb'" return 1 } mkdir -p "$site_dir" && cd "$site_dir" || return 1 if [[ ! -f wp-settings.php ]]; then wp core download >>"$LOGFILE" 2>&1 || { err "WP download failed"; return 1; } fi if [[ ! -f wp-config.php ]]; then wp config create --dbname="$db_name" --dbuser=root --dbpass="" >>"$LOGFILE" 2>&1 fi if ! wp core is-installed &>/dev/null; then wp core install --url="http://localhost:8080" --title="macstarter" \ --admin_user=admin --admin_password=macstarter \ --admin_email="admin@localhost.local" --skip-email >>"$LOGFILE" 2>&1 fi ok "WordPress ready at $site_dir" echo "" echo -e "${WHITE}Start it any time with: cd ~/Sites/wp && wp server --port=8080" echo -e "Admin: http://localhost:8080/wp-admin (admin / macstarter — change it!)${NC}" cd - >/dev/null || true } # ------------------------------------------------------------------------------ # Menus # ------------------------------------------------------------------------------ NEED_FINDER_RESTART=0 run_entries_by_tags() { # $1 = category array name, $2 = space-separated chosen tags local cat_var="$1" chosen="$2" items entry tag eval "items=(\"\${${cat_var}[@]}\")" for entry in "${items[@]}"; do tag="${entry%%|*}" for c in $chosen; do c="${c%\"}"; c="${c#\"}" [[ "$c" == "$tag" ]] && dispatch "$entry" done done } category_checklist() { # $1 = category array name, $2 = title local cat_var="$1" title="$2" items entry tag type payload def desc args=() chosen eval "items=(\"\${${cat_var}[@]}\")" for entry in "${items[@]}"; do IFS='|' read -r tag type payload def desc <<< "$entry" args+=("$tag" "$desc" "$def") done chosen=$(dialog --title "$title" --checklist \ "Space toggles, Enter installs. Pre-ticked = recommended." \ 22 76 14 "${args[@]}" 3>&1 1>&2 2>&3) || return 0 clear [[ -z "$chosen" ]] && return 0 run_entries_by_tags "$cat_var" "$chosen" echo "" read -r -p "Done. Press Enter to go back to the menu..." } install_all_defaults() { ui_yesno "FULL SEND" "Install every pre-ticked (recommended) item across ALL categories?\n\nThis is the whole M4 setup in one go. Xcode alone is ~12GB.\nTailscale login and SSH key steps will prompt when they need you." || { clear; return 0; } clear local cat_entry cat_var items entry def for cat_entry in "${CATEGORIES[@]}"; do IFS='|' read -r _ _ cat_var <<< "$cat_entry" eval "items=(\"\${${cat_var}[@]}\")" for entry in "${items[@]}"; do IFS='|' read -r _ _ _ def _ <<< "$entry" [[ "$def" == "on" ]] && dispatch "$entry" done done echo "" read -r -p "Full send complete. Press Enter for the summary..." show_summary } show_summary() { local msg="" msg+="Installed: ${#RESULTS_OK[@]}\n" msg+="Skipped (already there): ${#RESULTS_SKIP[@]}\n" msg+="Failed: ${#RESULTS_FAIL[@]}\n" if [[ ${#RESULTS_FAIL[@]} -gt 0 ]]; then msg+="\nFailures:\n" local f; for f in "${RESULTS_FAIL[@]}"; do msg+=" ✗ $f\n"; done msg+="\nDetails in $LOGFILE" fi ui_msg "SUMMARY" "$msg" clear } finish_up() { if [[ "$NEED_FINDER_RESTART" == "1" ]]; then killall Finder 2>/dev/null || true fi clear echo -e "${GREEN}════════════════════════════════════════════════════${NC}" echo -e "${WHITE} macstarter done.${NC}" echo -e " Installed: ${GREEN}${#RESULTS_OK[@]}${NC} Already there: ${YELLOW}${#RESULTS_SKIP[@]}${NC} Failed: ${RED}${#RESULTS_FAIL[@]}${NC}" if [[ ${#RESULTS_FAIL[@]} -gt 0 ]]; then echo -e " ${RED}Failed:${NC} ${RESULTS_FAIL[*]}" fi echo -e " Log: $LOGFILE" echo "" echo -e " ${YELLOW}Heads-up:${NC} scrolling & keyboard tweaks fully apply after you log out/in." echo -e " Open a new terminal (or Warp, once you're in it) to pick up brew + aliases." echo -e "${GREEN}════════════════════════════════════════════════════${NC}" exit 0 } main_menu() { while true; do local args=("all" ">>> FULL SEND — install all recommended <<<") local cat_entry cat_key cat_title for cat_entry in "${CATEGORIES[@]}"; do IFS='|' read -r cat_key cat_title _ <<< "$cat_entry" args+=("$cat_key" "$cat_title") done args+=("summary" "Show install summary") args+=("quit" "Finish & exit") local choice choice=$(dialog --title "MACSTARTER" --menu \ "Fresh Mac to fully-loaded. Pick a category:" \ 22 70 14 "${args[@]}" 3>&1 1>&2 2>&3) || finish_up clear case "$choice" in all) install_all_defaults ;; summary) show_summary ;; quit) finish_up ;; *) for cat_entry in "${CATEGORIES[@]}"; do IFS='|' read -r cat_key cat_title cat_var <<< "$cat_entry" if [[ "$choice" == "$cat_key" ]]; then category_checklist "$cat_var" "$cat_title" fi done ;; esac done } # ------------------------------------------------------------------------------ # GO # ------------------------------------------------------------------------------ echo "macstarter run started $(date)" >> "$LOGFILE" bootstrap main_menu