#!/bin/bash # setup_env.sh # ============================================================================ # OrbStack Development Sandbox Setup Script # ============================================================================ # # Dual-mode script: # - On macOS: Orchestrates VM creation via OrbStack # - On Linux: Provisions the VM with all development tools # # USAGE (from macOS): # ./setup_env.sh [vm-name] # Creates and provisions a new VM # ./setup_env.sh my-project # Custom VM name # # On first run, prompts for git credentials and VNC password, saves to config.env. # Subsequent runs reuse config.env (shared across all VMs). # # USAGE (from inside a VM): # ./setup_env.sh # Interactive mode (prompts per component) # ./setup_env.sh -y # Accept all components without prompting # ./setup_env.sh --non-interactive # Requires env vars, implies -y # # ============================================================================ set -euo pipefail # ============================================================================ # Configuration # ============================================================================ # Use 'latest' for mise-managed tools to always get the newest version ERLANG_VERSION="latest" ELIXIR_VERSION="latest" # ============================================================================ # Helpers # ============================================================================ RED=$'\033[0;31m' GREEN=$'\033[0;32m' YELLOW=$'\033[1;33m' NC=$'\033[0m' log_info() { echo -e "${GREEN}=== $1 ===${NC}" if [ -n "${LOG_FILE:-}" ]; then echo "[$(date +%H:%M:%S)] INFO: $1" >> "$LOG_FILE" fi } log_warn() { echo -e "${YELLOW}WARNING: $1${NC}" if [ -n "${LOG_FILE:-}" ]; then echo "[$(date +%H:%M:%S)] WARN: $1" >> "$LOG_FILE" fi } log_error() { echo -e "${RED}ERROR: $1${NC}" >&2 if [ -n "${LOG_FILE:-}" ]; then echo "[$(date +%H:%M:%S)] ERROR: $1" >> "$LOG_FILE" fi } command_exists() { command -v "$1" &>/dev/null } # Security: Validate input contains only safe characters for config storage # Allows alphanumeric, spaces, common punctuation, but blocks shell metacharacters validate_safe_input() { local input="$1" local field_name="$2" # Block dangerous shell metacharacters: backticks, $, (), {}, ;, |, &, <, >, newlines if [[ "$input" =~ [\`\$\(\)\{\}\;\|\&\<\>\"] ]] || [[ "$input" == *$'\n'* ]]; then log_error "$field_name contains invalid characters (shell metacharacters not allowed)" return 1 fi return 0 } # Security: Validate VM name contains only safe characters validate_vm_name() { local name="$1" if [[ ! "$name" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]]; then log_error "VM name must start with a letter and contain only alphanumeric characters, hyphens, and underscores" return 1 fi if [ ${#name} -gt 64 ]; then log_error "VM name must be 64 characters or less" return 1 fi return 0 } # Security: Validate VNC password validate_vnc_password() { local password="$1" if [ ${#password} -lt 6 ]; then echo "Password must be at least 6 characters." return 1 fi if [ ${#password} -gt 64 ]; then echo "Password must be 64 characters or less." return 1 fi # Block characters that could cause shell injection if [[ "$password" =~ [\`\$\(\)\{\}\;\|\&\<\>\'\"] ]]; then echo "Password contains invalid characters." return 1 fi return 0 } # Security: Detect system architecture and return normalized name # Returns: amd64, arm64, or exits with error for unsupported architectures detect_architecture() { local arch arch=$(uname -m) case "$arch" in x86_64|amd64) echo "amd64" ;; aarch64|arm64) echo "arm64" ;; *) log_error "Unsupported architecture: $arch" return 1 ;; esac } # Security: Download and verify a binary with optional checksum # Args: $1=url, $2=output_path, $3=expected_checksum (optional, sha256) download_verified_binary() { local url="$1" local output="$2" local expected_checksum="${3:-}" local temp_file temp_file=$(mktemp) # Download to temp file first if ! curl -fsSL "$url" -o "$temp_file"; then log_error "Failed to download: $url" rm -f "$temp_file" return 1 fi # Verify checksum if provided if [ -n "$expected_checksum" ]; then local actual_checksum actual_checksum=$(sha256sum "$temp_file" | awk '{print $1}') if [ "$actual_checksum" != "$expected_checksum" ]; then log_error "Checksum mismatch for $url" log_error "Expected: $expected_checksum" log_error "Got: $actual_checksum" rm -f "$temp_file" return 1 fi fi # Move to final location if [[ "$output" == /usr/* ]]; then sudo mv "$temp_file" "$output" sudo chmod +x "$output" else mv "$temp_file" "$output" chmod +x "$output" fi return 0 } # ============================================================================ # macOS Host Mode # ============================================================================ if [[ "$(uname -s)" == "Darwin" ]]; then echo -e "${GREEN}" echo "============================================================================" echo " OrbStack Development Sandbox - Host Setup" echo "============================================================================" echo -e "${NC}" # Check for OrbStack if ! command_exists orb; then log_error "OrbStack is not installed." echo "" echo "Install OrbStack from: https://orbstack.dev" echo " brew install orbstack" echo "" echo "After installing, run this script again." exit 1 fi # VM name from argument or default VM_NAME="${1:-dev-sandbox}" # Security: Validate VM name (Issue #6) if ! validate_vm_name "$VM_NAME"; then exit 1 fi # Locate config.env relative to this script SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONFIG_FILE="$SCRIPT_DIR/config.env" # Security: Load config safely without executing code (Issue #2) # Instead of sourcing, parse key=value pairs manually load_config_safely() { local config_file="$1" while IFS='=' read -r key value || [ -n "$key" ]; do # Skip empty lines and comments [[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue # Remove leading/trailing whitespace from key key=$(echo "$key" | xargs) # Remove surrounding quotes from value value=$(echo "$value" | sed 's/^"//;s/"$//;s/^'"'"'//;s/'"'"'$//') case "$key" in GIT_NAME) GIT_NAME="$value" ;; GIT_EMAIL) GIT_EMAIL="$value" ;; # VNC_PASSWORD intentionally not stored - prompted each time for security esac done < "$config_file" } # Create or load config if [ -f "$CONFIG_FILE" ]; then load_config_safely "$CONFIG_FILE" echo -e "${YELLOW}Using saved config from: $CONFIG_FILE${NC}" echo " Name: $GIT_NAME" echo " Email: $GIT_EMAIL" echo "" else echo "First run — creating config file for shared credentials." echo "" # Security: Validate git name (Issue #5, #10, #11) while true; do read -rp "Git commit author name: " GIT_NAME if [ -z "$GIT_NAME" ]; then echo "Name is required." continue fi if [ ${#GIT_NAME} -gt 256 ]; then echo "Name must be 256 characters or less." continue fi if validate_safe_input "$GIT_NAME" "Name"; then break fi done # Security: Validate git email (Issue #5, #10, #11) while true; do read -rp "Git commit author email: " GIT_EMAIL if [ -z "$GIT_EMAIL" ]; then echo "Email is required." continue fi if [ ${#GIT_EMAIL} -gt 256 ]; then echo "Email must be 256 characters or less." continue fi if ! [[ "$GIT_EMAIL" =~ ^[^@]+@[^@]+\.[^@]+$ ]]; then echo "Please enter a valid email address." continue fi if validate_safe_input "$GIT_EMAIL" "Email"; then break fi done # Security: Write config with proper escaping (Issue #10) # VNC password is NOT stored - will be prompted when VNC is selected { printf 'GIT_NAME="%s"\n' "$GIT_NAME" printf 'GIT_EMAIL="%s"\n' "$GIT_EMAIL" } > "$CONFIG_FILE" chmod 600 "$CONFIG_FILE" echo "" echo "Config saved to: $CONFIG_FILE" echo "" fi # ---- Interactive component selection ---- # Component IDs and descriptions COMP_IDS=(postgresql mise node python erlang chromium vnc ollama claude opencode playwright plugins) COMP_NAMES=( "PostgreSQL" "mise" "Node.js (LTS)" "Python (latest)" "Erlang/Elixir" "Chromium" "VNC + XFCE" "Ollama" "Claude Code" "OpenCode" "Playwright" "Claude Plugins + Tidewave" ) COMP_DESCS=( "Database server for local development" "Version manager for Node.js, Python, Erlang, Elixir" "JavaScript runtime (required for Claude/OpenCode)" "Python runtime managed by mise" "BEAM VM + Elixir functional language" "Browser for automation and testing" "Remote desktop for browser-based login flows" "Local LLM runner for offline inference" "AI coding assistant CLI from Anthropic" "Open-source AI coding assistant (multi-provider)" "Browser testing and automation framework" "Code review, Tidewave MCP, browser automation" ) # All selected by default COMP_COUNT=${#COMP_IDS[@]} declare -a COMP_SELECTED for ((i=0; i 0)) && ((CURSOR--)) || true ;; "[B") # Down ((CURSOR < COMP_COUNT - 1)) && ((CURSOR++)) || true ;; esac ;; " ") # Toggle if [ "${COMP_SELECTED[$CURSOR]}" = "1" ]; then COMP_SELECTED[$CURSOR]=0 else COMP_SELECTED[$CURSOR]=1 fi ;; "a"|"A") # Select all for ((i=0; i/dev/null | grep -qw "$VM_NAME"; then log_error "VM '$VM_NAME' already exists." echo " To delete it: orb delete $VM_NAME" echo " To shell in: ssh $VM_NAME@orb" exit 1 fi echo -e "${YELLOW}Creating VM: $VM_NAME${NC}" orb create ubuntu "$VM_NAME" echo "" echo -e "${YELLOW}Disabling host filesystem access inside VM...${NC}" # Remove macOS home directory access for security isolation # OrbStack mounts the host home at /mnt/mac and symlinks /Users -> /mnt/mac/Users orb run -m "$VM_NAME" sudo bash -c " umount /mnt/mac 2>/dev/null || true rm -rf /mnt/mac rm -f /Users mkdir -p /Users # Prevent remount on reboot by masking the mount echo '# Disabled by setup_env.sh for security isolation' > /etc/fstab.d/orbstack-mac 2>/dev/null || true " echo "" echo -e "${YELLOW}Copying setup script into VM...${NC}" orb push -m "$VM_NAME" "$SCRIPT_DIR/setup_env.sh" /tmp/setup_env.sh echo "" if [ -n "$SKIP_LIST" ]; then echo -e "${YELLOW}Skipping:${NC} $SKIP_LIST" fi echo -e "${YELLOW}Running provisioning inside VM (this will take a while)...${NC}" echo "" # Security: Use base64 encoding to safely pass all values to VM # This prevents any shell injection through special characters GIT_NAME_B64=$(printf '%s' "$GIT_NAME" | base64) GIT_EMAIL_B64=$(printf '%s' "$GIT_EMAIL" | base64) VNC_PASSWORD_B64=$(printf '%s' "$VNC_PASSWORD" | base64) SKIP_LIST_B64=$(printf '%s' "$SKIP_LIST" | base64) # Security: All user-controlled values are base64-encoded before passing to VM # The decode script sets SKIP_* env vars from the safe SKIP_LIST orb run -m "$VM_NAME" bash -c " set -e export GIT_NAME=\$(echo '$GIT_NAME_B64' | base64 -d) || { echo 'Failed to decode GIT_NAME'; exit 1; } export GIT_EMAIL=\$(echo '$GIT_EMAIL_B64' | base64 -d) || { echo 'Failed to decode GIT_EMAIL'; exit 1; } export VNC_PASSWORD=\$(echo '$VNC_PASSWORD_B64' | base64 -d) || { echo 'Failed to decode VNC_PASSWORD'; exit 1; } for comp in \$(echo '$SKIP_LIST_B64' | base64 -d); do upper=\$(echo \"\$comp\" | tr '[:lower:]' '[:upper:]') export \"SKIP_\${upper}=1\" done bash /tmp/setup_env.sh --non-interactive " echo "" echo -e "${GREEN}============================================================================${NC}" echo -e "${GREEN} VM '$VM_NAME' is ready!${NC}" echo -e "${GREEN}============================================================================${NC}" echo "" echo -e "${YELLOW}CONNECT:${NC}" echo " SSH: ssh $VM_NAME@orb" echo " Zed: Open Remote Folder -> $VM_NAME@orb:~/projects" echo " VS Code: Remote-SSH -> $VM_NAME@orb" echo " Files: /Volumes/OrbStack/$VM_NAME/home/\$(whoami)/" echo "" echo -e "${YELLOW}VNC (for browser login):${NC}" echo " Start: ssh $VM_NAME@orb -- vnc-start" echo " Connect: open vnc://$VM_NAME.orb.local:5901" echo " Stop: ssh $VM_NAME@orb -- vnc-stop" echo "" echo -e "${YELLOW}POSTGRESQL:${NC}" echo " From VM: psql dev" echo " From macOS: psql -h $VM_NAME.orb.local -d dev" echo "" echo -e "${YELLOW}CLAUDE CODE:${NC}" echo " ssh $VM_NAME@orb -- claude" echo "" exit 0 fi # ============================================================================ # Linux VM Mode (everything below runs inside the VM) # ============================================================================ # Security: Create log file with restrictive permissions (Issue #13) LOG_FILE="/tmp/setup_env_$(date +%Y%m%d_%H%M%S).log" touch "$LOG_FILE" chmod 600 "$LOG_FILE" # Security: Use mktemp for unpredictable temp directory (Issue #8, #9) LOG_DIR=$(mktemp -d "/tmp/setup_env_steps.XXXXXXXXXX") if [ ! -d "$LOG_DIR" ]; then log_error "Failed to create secure temp directory" exit 1 fi chmod 700 "$LOG_DIR" # Prevent mise from reading host config via OrbStack mount (/Users/john/.mise.toml) export MISE_GLOBAL_CONFIG_FILE="$HOME/.config/mise/config.toml" export MISE_CONFIG_DIR="$HOME/.config/mise" export MISE_DATA_DIR="$HOME/.local/share/mise" export MISE_IGNORED_CONFIG_PATHS="/Users" export MISE_YES=1 # ============================================================================ # Argument parsing # ============================================================================ NON_INTERACTIVE=false AUTO_ACCEPT=false for arg in "$@"; do case $arg in --non-interactive) NON_INTERACTIVE=true AUTO_ACCEPT=true ;; --yes|-y) AUTO_ACCEPT=true ;; *) log_error "Unknown argument: $arg" echo "Usage: $0 [--non-interactive] [--yes|-y]" exit 1 ;; esac done # ============================================================================ # Dashboard and parallel execution framework # ============================================================================ SPINNER_CHARS='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' SPINNER_IDX=0 START_TIME=$(date +%s) DIM=$'\033[2m' BOLD=$'\033[1m' CYAN=$'\033[0;36m' CLEAR_LINE=$'\033[K' # Step tracking arrays declare -a ALL_STEP_IDS=() declare -A STEP_NAMES=() declare -A STEP_STATUS=() # pending/running/done/failed/skipped declare -A STEP_PIDS=() # PID for background jobs declare -A STEP_LOGS=() # log file path declare -A STEP_START=() # start timestamp declare -A STEP_ELAPSED=() # elapsed time when completed declare -A STEP_ERRORS=() # error messages for failed steps DASHBOARD_LINES=0 format_time() { local secs=$1 if [ "$secs" -ge 60 ]; then printf "%dm %02ds" $((secs / 60)) $((secs % 60)) else printf "%ds" "$secs" fi } get_step_detail() { local id="$1" local log="${STEP_LOGS[$id]:-}" [ -z "$log" ] || [ ! -f "$log" ] && return # Get last meaningful line, filter noise (empty lines, shell trace, progress bars) tail -5 "$log" 2>/dev/null | \ grep -v '^\s*$' | \ grep -v '^+' | \ grep -v '^#' | \ grep -v '^\s*#' | \ grep -v '%$' | \ grep -v '^\s*[0-9.]*%' | \ tail -1 | \ sed 's/\x1b\[[0-9;]*m//g' | \ cut -c1-30 | \ tr -d '\n' } draw_dashboard() { local now=$(date +%s) local total_elapsed=$((now - START_TIME)) # Count statuses local running=0 done=0 failed=0 pending=0 skipped=0 for id in "${ALL_STEP_IDS[@]}"; do case "${STEP_STATUS[$id]:-pending}" in running) ((running++)) || true ;; done) ((done++)) || true ;; failed) ((failed++)) || true ;; skipped) ((skipped++)) || true ;; *) ((pending++)) || true ;; esac done # Calculate lines to draw local num_steps=${#ALL_STEP_IDS[@]} local new_lines=$((num_steps + 4)) # Move cursor up to redraw if [ "$DASHBOARD_LINES" -gt 0 ]; then printf "\033[%dA" "$DASHBOARD_LINES" fi DASHBOARD_LINES=$new_lines # Header printf " ${BOLD}OrbStack Sandbox Setup${NC}%30s${CLEAR_LINE}\n" "[$(format_time $total_elapsed)]" printf " ───────────────────────────────────────────────────────────${CLEAR_LINE}\n" # Steps for id in "${ALL_STEP_IDS[@]}"; do local name="${STEP_NAMES[$id]:-$id}" local status="${STEP_STATUS[$id]:-pending}" case "$status" in done) local elapsed="${STEP_ELAPSED[$id]:-0}" printf " ${GREEN}✓${NC} %-40s %10s${CLEAR_LINE}\n" "$name" "$(format_time $elapsed)" ;; running) local start="${STEP_START[$id]:-$now}" local elapsed=$((now - start)) local detail=$(get_step_detail "$id") local spin="${SPINNER_CHARS:SPINNER_IDX%10:1}" printf " ${YELLOW}%s${NC} %-40s %10s${CLEAR_LINE}\n" "$spin" "$name" "$(format_time $elapsed)" ;; failed) printf " ${RED}✗${NC} %-40s %10s${CLEAR_LINE}\n" "$name" "FAILED" ;; skipped) printf " ${DIM}-${NC} ${DIM}%-40s${NC}${CLEAR_LINE}\n" "$name" ;; *) printf " ${DIM}○${NC} ${DIM}%-40s${NC}${CLEAR_LINE}\n" "$name" ;; esac done # Footer printf "${CLEAR_LINE}\n" local status_line=" " [ $running -gt 0 ] && status_line+="${CYAN}⟳${NC} $running running " [ $done -gt 0 ] && status_line+="${GREEN}✓${NC} $done done " [ $failed -gt 0 ] && status_line+="${RED}✗${NC} $failed failed " [ $pending -gt 0 ] && status_line+="${DIM}○ $pending queued${NC}" printf "%s${CLEAR_LINE}\n" "$status_line" ((SPINNER_IDX++)) || true } register_step() { local id="$1" local name="$2" ALL_STEP_IDS+=("$id") STEP_NAMES[$id]="$name" STEP_STATUS[$id]="pending" STEP_LOGS[$id]="$LOG_DIR/${id}.log" } skip_step() { local id="$1" STEP_STATUS[$id]="skipped" } # Run a function in background start_step() { local id="$1" shift local log="${STEP_LOGS[$id]}" STEP_STATUS[$id]="running" STEP_START[$id]=$(date +%s) # Run in subshell ( set +e "$@" > "$log" 2>&1 echo $? > "${log}.exit" ) & STEP_PIDS[$id]=$! } # Run synchronously (for steps that must complete before others can start) run_step_sync() { local id="$1" shift local log="${STEP_LOGS[$id]}" STEP_STATUS[$id]="running" STEP_START[$id]=$(date +%s) # Show dashboard before running draw_dashboard # Run synchronously in a subshell to capture exit code reliably ( set +e "$@" > "$log" 2>&1 echo $? > "${log}.exit" ) # Read exit code from file local exit_code=0 if [ -f "${log}.exit" ]; then exit_code=$(cat "${log}.exit") fi local now=$(date +%s) STEP_ELAPSED[$id]=$((now - ${STEP_START[$id]:-$now})) if [ "$exit_code" -eq 0 ]; then STEP_STATUS[$id]="done" else STEP_STATUS[$id]="failed" STEP_ERRORS[$id]="Exit code $exit_code" fi # Append to main log echo "=== $id ===" >> "$LOG_FILE" cat "$log" >> "$LOG_FILE" 2>/dev/null || true echo "" >> "$LOG_FILE" draw_dashboard return "$exit_code" } step_running() { local id="$1" local pid="${STEP_PIDS[$id]:-}" [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null } finish_step() { local id="$1" local log="${STEP_LOGS[$id]}" local now=$(date +%s) STEP_ELAPSED[$id]=$((now - ${STEP_START[$id]:-$now})) if [ -f "${log}.exit" ]; then local exit_code=$(cat "${log}.exit") if [ "$exit_code" = "0" ]; then STEP_STATUS[$id]="done" else STEP_STATUS[$id]="failed" STEP_ERRORS[$id]="Exit code $exit_code" fi else STEP_STATUS[$id]="failed" STEP_ERRORS[$id]="No exit status" fi # Append to main log echo "=== $id ===" >> "$LOG_FILE" cat "$log" >> "$LOG_FILE" 2>/dev/null || true echo "" >> "$LOG_FILE" } # Wait for all currently running steps wait_for_running_steps() { while true; do local any_running=false for id in "${ALL_STEP_IDS[@]}"; do if [ "${STEP_STATUS[$id]}" = "running" ]; then if step_running "$id"; then any_running=true else finish_step "$id" fi fi done draw_dashboard [ "$any_running" = false ] && break sleep 0.2 done } should_skip() { local id="$1" local skip_var="SKIP_$(echo "$id" | tr '[:lower:]' '[:upper:]')" [ "${!skip_var:-}" = "1" ] } cleanup() { local exit_code=$? # Kill any remaining background jobs for id in "${ALL_STEP_IDS[@]}"; do local pid="${STEP_PIDS[$id]:-}" [ -n "$pid" ] && kill "$pid" 2>/dev/null || true done # Clean up temp logs rm -rf "$LOG_DIR" 2>/dev/null || true if [ $exit_code -ne 0 ]; then echo "" log_error "Setup failed (exit code: $exit_code). Log saved to: $LOG_FILE" fi } # Note: trap is set later to include both cleanup and stop_sudo_keepalive # ============================================================================ # Step functions (each is a self-contained install) # ============================================================================ install_postgresql() { sudo apt-get install -y -q postgresql postgresql-contrib libpq-dev sudo systemctl enable postgresql sudo systemctl start postgresql # Create user if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$USER'" | grep -q 1; then sudo -u postgres createuser -s "$USER" echo "Created PostgreSQL superuser: $USER" fi # Create dev database if ! psql -lqt 2>/dev/null | cut -d \| -f 1 | grep -qw dev; then createdb dev echo "Created database: dev" fi # Configure pg_hba.conf PG_HBA=$(find /etc/postgresql -name pg_hba.conf 2>/dev/null | head -1) PG_CONF=$(find /etc/postgresql -name postgresql.conf 2>/dev/null | head -1) [ -z "$PG_HBA" ] || [ -z "$PG_CONF" ] && return 1 # Security: Use 'peer' for local connections (authenticates via OS user) # This is more secure than 'trust' while still convenient for development # Users connect via: psql -U $USER dbname (no password needed if OS user matches DB user) sudo sed -i 's/^local\s\+all\s\+all\s\+peer$/local all all peer/' "$PG_HBA" # For TCP localhost, keep scram-sha-256 (requires password) instead of trust # This prevents any local process from accessing the DB without credentials if ! sudo grep -q "# OrbStack host access" "$PG_HBA"; then echo "# OrbStack host access" | sudo tee -a "$PG_HBA" > /dev/null echo "host all all 192.168.0.0/16 scram-sha-256" | sudo tee -a "$PG_HBA" > /dev/null fi sudo sed -i 's/^#listen_addresses = .*/listen_addresses = '"'"'*'"'"'/' "$PG_CONF" if ! sudo grep -q "^listen_addresses" "$PG_CONF"; then echo "listen_addresses = '*'" | sudo tee -a "$PG_CONF" > /dev/null fi sudo systemctl restart postgresql } install_mise() { if command_exists mise; then echo "mise already installed" return 0 fi # Security: Download script first, validate, then execute (Issue #3) # This allows for inspection and avoids direct pipe to shell local mise_script mise_script=$(mktemp) curl -fsSL https://mise.run -o "$mise_script" # Verify it's a shell script with multiple checks if ! head -1 "$mise_script" | grep -qE '^#!\s*/(bin|usr/bin)/(env\s+)?(ba)?sh'; then log_error "Downloaded mise script doesn't have a valid shell shebang" rm -f "$mise_script" return 1 fi # Check file size is reasonable (not empty, not huge) local script_size script_size=$(wc -c < "$mise_script") if [ "$script_size" -lt 100 ] || [ "$script_size" -gt 1000000 ]; then log_error "Downloaded mise script has suspicious size: $script_size bytes" rm -f "$mise_script" return 1 fi sh "$mise_script" rm -f "$mise_script" # Add to bashrc if ! grep -q 'mise activate bash' ~/.bashrc; then echo 'eval "$(~/.local/bin/mise activate bash)"' >> ~/.bashrc fi # Add to profile for SSH if ! grep -q 'mise/shims' ~/.profile 2>/dev/null; then echo 'export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH"' >> ~/.profile fi } install_node() { export PATH="$HOME/.local/bin:$PATH" eval "$(~/.local/bin/mise activate bash)" mise use -g node@lts export PATH="$HOME/.local/share/mise/shims:$PATH" } install_python() { export PATH="$HOME/.local/bin:$PATH" eval "$(~/.local/bin/mise activate bash)" mise use -g python@latest export PATH="$HOME/.local/share/mise/shims:$PATH" # Ensure pip is available via mise python python -m pip install --upgrade pip } install_erlang() { export PATH="$HOME/.local/bin:$PATH" eval "$(~/.local/bin/mise activate bash)" # Try precompiled first MISE_ERLANG_INSTALL_TYPE=precompiled mise use -g "erlang@$ERLANG_VERSION" 2>/dev/null || { echo "Precompiled unavailable, compiling from source..." mise use -g "erlang@$ERLANG_VERSION" } } install_elixir() { export PATH="$HOME/.local/bin:$PATH" eval "$(~/.local/bin/mise activate bash)" mise use -g "elixir@$ELIXIR_VERSION" export PATH="$HOME/.local/share/mise/shims:$PATH" } install_chromium() { if command_exists chromium-browser || command_exists chromium; then echo "Chromium already installed" return 0 fi # Try apt (may fail due to snap) if apt-cache showpkg chromium 2>/dev/null | grep -q "^Package:"; then sudo apt-get install -y -q chromium --no-install-recommends 2>/dev/null || true fi # Symlink if installed (check for existing file, not just symlink) local bin="" command_exists chromium-browser && bin=$(which chromium-browser) command_exists chromium && bin=$(which chromium) if [ -n "$bin" ] && [ ! -e /usr/bin/google-chrome ]; then sudo ln -sf "$bin" /usr/bin/google-chrome fi } install_vnc() { sudo apt-get install -y -q tigervnc-standalone-server xfce4 xfce4-terminal dbus-x11 # Create .vnc directory with secure permissions mkdir -p ~/.vnc chmod 700 ~/.vnc # Security: Create password file atomically with correct permissions # Use umask to ensure file is created with 600 permissions from the start ( umask 077 echo "$VNC_PASSWORD" | vncpasswd -f > ~/.vnc/passwd ) # Ensure permissions are correct (belt and suspenders) chmod 600 ~/.vnc/passwd cat > ~/.vnc/xstartup <<'XEOF' #!/bin/sh unset SESSION_MANAGER unset DBUS_SESSION_BUS_ADDRESS exec startxfce4 XEOF chmod +x ~/.vnc/xstartup mkdir -p ~/bin cat > ~/bin/vnc-start <<'VEOF' #!/bin/bash # Security note: VNC listens on all interfaces (-localhost no) to allow # connections from the macOS host via OrbStack's network. The VM is only # accessible from the host machine, not external networks. # VNC is password-protected (set during setup). VNC_HOST="$(hostname -s).orb.local" if pgrep -f "Xtigervnc :1" > /dev/null 2>&1; then echo "VNC is already running on display :1" echo "Connect: open vnc://${VNC_HOST}:5901" exit 0 fi vncserver :1 -geometry 1280x800 -depth 24 -localhost no echo "VNC started on display :1" echo "Connect: open vnc://${VNC_HOST}:5901" VEOF chmod +x ~/bin/vnc-start cat > ~/bin/vnc-stop <<'SEOF' #!/bin/bash vncserver -kill :1 2>/dev/null && echo "VNC stopped" || echo "VNC was not running" SEOF chmod +x ~/bin/vnc-stop if ! grep -q 'export PATH="$HOME/bin:$PATH"' ~/.bashrc; then echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bashrc fi } install_ollama() { if command_exists ollama; then echo "Ollama already installed" return 0 fi # Security: Download script first, validate, then execute with sudo (Issue #3) local ollama_script ollama_script=$(mktemp) curl -fsSL https://ollama.com/install.sh -o "$ollama_script" # Verify it's a shell script with multiple checks if ! head -1 "$ollama_script" | grep -qE '^#!\s*/(bin|usr/bin)/(env\s+)?(ba)?sh'; then log_error "Downloaded Ollama script doesn't have a valid shell shebang" rm -f "$ollama_script" return 1 fi # Check file size is reasonable (not empty, not huge) local script_size script_size=$(wc -c < "$ollama_script") if [ "$script_size" -lt 100 ] || [ "$script_size" -gt 1000000 ]; then log_error "Downloaded Ollama script has suspicious size: $script_size bytes" rm -f "$ollama_script" return 1 fi sudo sh "$ollama_script" rm -f "$ollama_script" } install_claude() { export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH" npm install -g @anthropic-ai/claude-code } install_playwright() { export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH" npm install -g playwright playwright install --with-deps chromium # Symlink Playwright's Chromium if no system one (check for existing files) if ! command_exists chromium-browser && ! command_exists chromium; then local pw_chrome pw_chrome=$(find "$HOME/.cache/ms-playwright" -name "chrome" -type f 2>/dev/null | head -1) # Security: Validate the found path before creating symlinks # - Must not be empty # - Must be an executable file # - Must be within the expected playwright cache directory if [ -n "$pw_chrome" ] && [ -x "$pw_chrome" ] && [[ "$pw_chrome" == "$HOME/.cache/ms-playwright"* ]]; then [ ! -e /usr/local/bin/chromium ] && sudo ln -sf "$pw_chrome" /usr/local/bin/chromium [ ! -e /usr/local/bin/google-chrome ] && sudo ln -sf "$pw_chrome" /usr/local/bin/google-chrome echo "Using Playwright's bundled Chromium" fi fi } install_opencode() { export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH" if command_exists opencode; then echo "OpenCode already installed" return 0 fi npm install -g opencode-ai } install_tidewave() { if command_exists tidewave; then echo "Tidewave already installed" return 0 fi # Download Tidewave CLI binary from tidewave_app releases # Binary naming: tidewave-cli--unknown-linux-musl local machine_arch machine_arch=$(uname -m) local tidewave_arch="" case "$machine_arch" in x86_64) tidewave_arch="x86_64" ;; aarch64) tidewave_arch="aarch64" ;; *) log_error "Unsupported architecture for Tidewave: $machine_arch" return 1 ;; esac # Get latest release URL from GitHub (tidewave_app repo) local release_url="https://github.com/tidewave-ai/tidewave_app/releases/latest/download/tidewave-cli-${tidewave_arch}-unknown-linux-musl" # Security: Download to temp file first, validate, then install local temp_file temp_file=$(mktemp) if curl -fsSL "$release_url" -o "$temp_file"; then # Verify it's an executable (ELF binary) if file "$temp_file" | grep -q "ELF"; then sudo mv "$temp_file" /usr/local/bin/tidewave sudo chmod +x /usr/local/bin/tidewave echo "Tidewave CLI installed" else log_error "Downloaded tidewave is not a valid binary" rm -f "$temp_file" return 1 fi else log_error "Failed to download Tidewave CLI" rm -f "$temp_file" return 1 fi } install_plugins() { export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH" mkdir -p ~/.config/claude echo "Adding marketplaces..." claude plugin marketplace add anthropics/claude-code 2>/dev/null || true claude plugin marketplace add obra/superpowers 2>/dev/null || true echo "Installing plugins..." for p in code-review code-simplifier feature-dev pr-review-toolkit security-guidance frontend-design; do claude plugin install "${p}@claude-code-plugins" --scope user 2>/dev/null || true done for p in double-shot-latte elements-of-style superpowers superpowers-chrome superpowers-lab; do claude plugin install "${p}@superpowers-marketplace" --scope user 2>/dev/null || true done echo "Adding MCP servers..." claude mcp add playwright --scope user -- npx @anthropic-ai/mcp-server-playwright 2>/dev/null || true claude mcp add superpowers-chrome --scope user -- npx github:obra/superpowers-chrome --headless 2>/dev/null || true # Note: Tidewave MCP is configured per-project when running a Phoenix app # Use: claude mcp add --transport http tidewave http://localhost:4000/tidewave/mcp echo "" echo "Tidewave MCP: Add per-project with your Phoenix app running:" echo " claude mcp add --transport http tidewave http://localhost:PORT/tidewave/mcp" } install_base_deps() { sudo apt-get update -q sudo apt-get upgrade -y -q sudo apt-get install -y -q \ curl wget git build-essential autoconf \ libncurses5-dev libssl-dev libwxgtk3.2-dev \ libgl1-mesa-dev libglu1-mesa-dev libpng-dev libssh-dev \ unixodbc-dev xsltproc fop libxml2-utils unzip zstd \ inotify-tools jq ripgrep fd-find direnv tmux \ python3 python3-pip python3-venv # fd symlink (fd-find installs as 'fdfind', symlink to 'fd') # Check for existing file, not just symlink if [ ! -e /usr/local/bin/fd ] && command_exists fdfind; then sudo ln -sf "$(which fdfind)" /usr/local/bin/fd fi # yq - with architecture detection (always latest) if ! command_exists yq; then local yq_arch local arch arch=$(detect_architecture) case "$arch" in amd64) yq_arch="amd64" ;; arm64) yq_arch="arm64" ;; esac local yq_url="https://github.com/mikefarah/yq/releases/latest/download/yq_linux_${yq_arch}" download_verified_binary "$yq_url" "/usr/local/bin/yq" fi # watchexec - install via cargo (always latest) if ! command_exists watchexec; then if command_exists cargo; then cargo install watchexec-cli else # Security: Download rustup script first, validate, then execute (Issue #3) # Same pattern as mise/ollama - never pipe directly to shell local rustup_script rustup_script=$(mktemp) curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o "$rustup_script" # Verify it's a shell script if ! head -1 "$rustup_script" | grep -qE '^#!\s*/(bin|usr/bin)/(env\s+)?(ba)?sh'; then log_error "Downloaded rustup script doesn't have a valid shell shebang" rm -f "$rustup_script" return 1 fi # Check file size is reasonable (not empty, not huge) local script_size script_size=$(wc -c < "$rustup_script") if [ "$script_size" -lt 100 ] || [ "$script_size" -gt 2000000 ]; then log_error "Downloaded rustup script has suspicious size: $script_size bytes" rm -f "$rustup_script" return 1 fi sh "$rustup_script" -y rm -f "$rustup_script" source "$HOME/.cargo/env" cargo install watchexec-cli fi fi # direnv hook if ! grep -q 'direnv hook bash' ~/.bashrc; then echo 'eval "$(direnv hook bash)"' >> ~/.bashrc fi return 0 } # ============================================================================ # Pre-flight checks # ============================================================================ if [ "$(id -u)" -eq 0 ]; then log_error "Do not run this script as root. It will use sudo where needed." exit 1 fi if ! command_exists sudo; then log_error "sudo is required but not installed." exit 1 fi # Security: Sudo keepalive - prevent sudo timeout during long operations # Runs in background and refreshes sudo timestamp every 50 seconds SUDO_KEEPALIVE_PID="" start_sudo_keepalive() { # In non-interactive mode (VM), sudo should be passwordless - skip keepalive if [ "$NON_INTERACTIVE" = true ]; then return 0 fi # Validate sudo access first (allow password prompt to show) if ! sudo -v; then log_error "Failed to obtain sudo privileges" return 1 fi # Start background process to refresh sudo timestamp ( while true; do sudo -n true 2>/dev/null sleep 50 done ) & SUDO_KEEPALIVE_PID=$! } stop_sudo_keepalive() { if [ -n "$SUDO_KEEPALIVE_PID" ] && kill -0 "$SUDO_KEEPALIVE_PID" 2>/dev/null; then kill "$SUDO_KEEPALIVE_PID" 2>/dev/null || true wait "$SUDO_KEEPALIVE_PID" 2>/dev/null || true fi } # Ensure keepalive is stopped on exit # Update trap to include both cleanup functions trap 'stop_sudo_keepalive; cleanup' EXIT # ============================================================================ # Git configuration prompts (VM-side, non-interactive mode only) # ============================================================================ if [ "$NON_INTERACTIVE" = true ]; then if [ -z "${GIT_NAME:-}" ] || [ -z "${GIT_EMAIL:-}" ]; then log_error "In non-interactive mode, GIT_NAME and GIT_EMAIL env vars are required." exit 1 fi # Only require VNC_PASSWORD if VNC is being installed if [ -z "${SKIP_VNC:-}" ] && [ -z "${VNC_PASSWORD:-}" ]; then log_error "In non-interactive mode, VNC_PASSWORD env var is required when VNC is selected (min 6 chars)." exit 1 fi fi # ============================================================================ # Register all steps # ============================================================================ register_step "base" "System packages (git, build tools, curl)" register_step "postgresql" "PostgreSQL database server" register_step "mise" "mise version manager" register_step "node" "Node.js LTS runtime" register_step "python" "Python (mise-managed)" register_step "erlang" "Erlang/OTP VM" register_step "elixir" "Elixir language" register_step "chromium" "Chromium browser" register_step "vnc" "VNC server + XFCE desktop" register_step "ollama" "Ollama local LLM runner" register_step "claude" "Claude Code CLI" register_step "opencode" "OpenCode CLI" register_step "tidewave" "Tidewave CLI (Elixir MCP)" register_step "playwright" "Playwright browser automation" register_step "plugins" "Claude Code plugins" # Mark skipped steps should_skip "postgresql" && skip_step "postgresql" should_skip "mise" && skip_step "mise" should_skip "node" && skip_step "node" should_skip "python" && skip_step "python" should_skip "erlang" && skip_step "erlang" should_skip "chromium" && skip_step "chromium" should_skip "vnc" && skip_step "vnc" should_skip "ollama" && skip_step "ollama" should_skip "claude" && skip_step "claude" should_skip "opencode" && skip_step "opencode" should_skip "playwright" && skip_step "playwright" should_skip "plugins" && skip_step "plugins" # Elixir skipped if Erlang skipped [ "${STEP_STATUS[erlang]}" = "skipped" ] && skip_step "elixir" # Tidewave skipped if Erlang skipped (it's for Phoenix/Elixir projects) [ "${STEP_STATUS[erlang]}" = "skipped" ] && skip_step "tidewave" # Python skipped if mise skipped [ "${STEP_STATUS[mise]}" = "skipped" ] && skip_step "python" # Claude/OpenCode/Playwright skipped if Node skipped if [ "${STEP_STATUS[node]}" = "skipped" ]; then skip_step "claude" skip_step "opencode" skip_step "playwright" skip_step "plugins" fi # Playwright skipped if Chromium skipped [ "${STEP_STATUS[chromium]}" = "skipped" ] && skip_step "playwright" # Plugins skipped if Claude skipped [ "${STEP_STATUS[claude]}" = "skipped" ] && skip_step "plugins" # Initial dashboard draw echo "" draw_dashboard echo "" # ============================================================================ # Start sudo keepalive to prevent timeout during long operations # ============================================================================ start_sudo_keepalive # ============================================================================ # Phase 1: Base dependencies (sequential, everything depends on this) # ============================================================================ run_step_sync "base" install_base_deps || true # ============================================================================ # Phase 2: Independent installs (parallel) # Mise, PostgreSQL, VNC, Ollama, Chromium can all run at the same time # ============================================================================ [ "${STEP_STATUS[postgresql]}" != "skipped" ] && start_step "postgresql" install_postgresql [ "${STEP_STATUS[mise]}" != "skipped" ] && start_step "mise" install_mise [ "${STEP_STATUS[vnc]}" != "skipped" ] && start_step "vnc" install_vnc [ "${STEP_STATUS[ollama]}" != "skipped" ] && start_step "ollama" install_ollama [ "${STEP_STATUS[chromium]}" != "skipped" ] && start_step "chromium" install_chromium wait_for_running_steps # ============================================================================ # Phase 3: Node + Python + Erlang (parallel, all need mise) # ============================================================================ if [ "${STEP_STATUS[mise]}" = "done" ]; then [ "${STEP_STATUS[node]}" != "skipped" ] && start_step "node" install_node [ "${STEP_STATUS[python]}" != "skipped" ] && start_step "python" install_python [ "${STEP_STATUS[erlang]}" != "skipped" ] && start_step "erlang" install_erlang wait_for_running_steps fi # ============================================================================ # Phase 4: Elixir + Claude + OpenCode + Playwright + Tidewave # (Elixir/Tidewave need Erlang, others need Node) # ============================================================================ [ "${STEP_STATUS[erlang]}" = "done" ] && [ "${STEP_STATUS[elixir]}" != "skipped" ] && start_step "elixir" install_elixir [ "${STEP_STATUS[erlang]}" = "done" ] && [ "${STEP_STATUS[tidewave]}" != "skipped" ] && start_step "tidewave" install_tidewave [ "${STEP_STATUS[node]}" = "done" ] && [ "${STEP_STATUS[claude]}" != "skipped" ] && start_step "claude" install_claude [ "${STEP_STATUS[node]}" = "done" ] && [ "${STEP_STATUS[opencode]}" != "skipped" ] && start_step "opencode" install_opencode [ "${STEP_STATUS[node]}" = "done" ] && [ "${STEP_STATUS[playwright]}" != "skipped" ] && start_step "playwright" install_playwright wait_for_running_steps # ============================================================================ # Phase 5: Claude plugins (needs Claude) # ============================================================================ [ "${STEP_STATUS[claude]}" = "done" ] && [ "${STEP_STATUS[plugins]}" != "skipped" ] && start_step "plugins" install_plugins wait_for_running_steps # ============================================================================ # Git configuration (always runs) # ============================================================================ git config --global user.name "$GIT_NAME" git config --global user.email "$GIT_EMAIL" git config --global init.defaultBranch main git config --global pull.rebase false # Projects directory mkdir -p ~/projects # ============================================================================ # Final status # ============================================================================ echo "" echo "" # Count results DONE_COUNT=0 FAILED_COUNT=0 SKIPPED_COUNT=0 for id in "${ALL_STEP_IDS[@]}"; do case "${STEP_STATUS[$id]}" in done) ((DONE_COUNT++)) || true ;; failed) ((FAILED_COUNT++)) || true ;; skipped) ((SKIPPED_COUNT++)) || true ;; esac done TOTAL_TIME=$(($(date +%s) - START_TIME)) if [ $FAILED_COUNT -gt 0 ]; then echo -e "${YELLOW}════════════════════════════════════════════════════════════${NC}" echo -e "${YELLOW} Setup completed with errors [$(format_time $TOTAL_TIME)]${NC}" echo -e "${YELLOW}════════════════════════════════════════════════════════════${NC}" echo "" echo -e "${RED}Failed steps:${NC}" for id in "${ALL_STEP_IDS[@]}"; do if [ "${STEP_STATUS[$id]}" = "failed" ]; then echo " ✗ ${STEP_NAMES[$id]}" echo " Log: ${STEP_LOGS[$id]}" fi done else echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}" echo -e "${GREEN} Setup complete! [$(format_time $TOTAL_TIME)]${NC}" echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}" fi echo "" echo "Log file: $LOG_FILE" echo "" # Show quick start info based on what was installed if [ "${STEP_STATUS[vnc]}" = "done" ]; then echo -e "${CYAN}VNC:${NC} vnc-start / vnc-stop" fi if [ "${STEP_STATUS[postgresql]}" = "done" ]; then echo -e "${CYAN}PostgreSQL:${NC} psql dev" fi if [ "${STEP_STATUS[claude]}" = "done" ]; then echo -e "${CYAN}Claude:${NC} claude" fi if [ "${STEP_STATUS[opencode]}" = "done" ]; then echo -e "${CYAN}OpenCode:${NC} opencode" fi if [ "${STEP_STATUS[tidewave]}" = "done" ]; then echo -e "${CYAN}Tidewave:${NC} tidewave (run in Phoenix project dir)" fi if [ "${STEP_STATUS[ollama]}" = "done" ]; then echo -e "${CYAN}Ollama:${NC} ollama run llama3.2" fi echo ""