secure_agent_envs/setup_env.sh
guessthepw 63bcc0aea3 Add error checking for base64 decode in VM bootstrap
Ensures early failure with clear error messages if credential
decoding fails during VM provisioning.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 09:36:02 -05:00

1399 lines
47 KiB
Bash
Executable file

#!/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 erlang chromium vnc ollama claude playwright plugins)
COMP_NAMES=(
"PostgreSQL"
"mise"
"Node.js (LTS)"
"Erlang/Elixir"
"Chromium"
"VNC + XFCE"
"Ollama"
"Claude Code"
"Playwright"
"Claude Plugins"
)
COMP_DESCS=(
"Database server for local development"
"Version manager for Node.js, Erlang, Elixir"
"JavaScript runtime (required for Claude Code)"
"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"
"Browser testing and automation framework"
"Code review, feature dev, browser automation plugins"
)
# All selected by default
COMP_COUNT=${#COMP_IDS[@]}
declare -a COMP_SELECTED
for ((i=0; i<COMP_COUNT; i++)); do
COMP_SELECTED[$i]=1
done
draw_menu() {
# Move cursor up to redraw (except first draw)
if [ "${MENU_DRAWN:-}" = "1" ]; then
printf "\033[%dA" $((COMP_COUNT + 3))
fi
MENU_DRAWN=1
echo -e "${YELLOW}Select components to install:${NC} "
for ((i=0; i<COMP_COUNT; i++)); do
local check=" "
if [ "${COMP_SELECTED[$i]}" = "1" ]; then
check="x"
fi
local prefix=" "
if [ "$i" = "$CURSOR" ]; then
prefix="> "
fi
printf "%s[%s] %-20s %s\033[K\n" "$prefix" "$check" "${COMP_NAMES[$i]}" "${COMP_DESCS[$i]}"
done
echo ""
echo -e " ↑/↓: move space: toggle a: all n: none enter: confirm\033[K"
}
CURSOR=0
draw_menu
while true; do
# Read a single character
IFS= read -rsn1 char
case "$char" in
$'\x1b')
# Escape sequence (arrow keys) — read remaining 2 bytes
IFS= read -rsn2 seq
case "$seq" in
"[A") # Up
((CURSOR > 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<COMP_COUNT; i++)); do
COMP_SELECTED[$i]=1
done
;;
"n"|"N")
# Deselect all
for ((i=0; i<COMP_COUNT; i++)); do
COMP_SELECTED[$i]=0
done
;;
"")
# Enter - confirm
break
;;
esac
draw_menu
done
echo ""
# Build list of skipped component IDs (space-separated, safe characters only)
# Security: Only hardcoded component IDs from COMP_IDS are used, no user input
SKIP_LIST=""
for ((i=0; i<COMP_COUNT; i++)); do
if [ "${COMP_SELECTED[$i]}" = "0" ]; then
SKIP_LIST="${SKIP_LIST}${COMP_IDS[$i]} "
fi
done
# Show summary
echo -e "${YELLOW}Selected components:${NC}"
ANY_SELECTED=false
for ((i=0; i<COMP_COUNT; i++)); do
if [ "${COMP_SELECTED[$i]}" = "1" ]; then
echo " + ${COMP_NAMES[$i]}"
ANY_SELECTED=true
fi
done
if [ "$ANY_SELECTED" = false ]; then
echo " (none selected, only base dependencies will be installed)"
fi
echo ""
# Security: Prompt for VNC password only when VNC is selected (Issue #4)
# Password is never stored to disk - prompted fresh each time
VNC_PASSWORD=""
VNC_INDEX=-1
for ((i=0; i<COMP_COUNT; i++)); do
if [ "${COMP_IDS[$i]}" = "vnc" ]; then
VNC_INDEX=$i
break
fi
done
if [ "$VNC_INDEX" -ge 0 ] && [ "${COMP_SELECTED[$VNC_INDEX]}" = "1" ]; then
while true; do
read -rsp "Enter VNC password (6-64 chars): " VNC_PASSWORD
echo
if validate_vnc_password "$VNC_PASSWORD"; then
break
fi
done
echo ""
fi
# Check if VM already exists
if orb list 2>/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_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_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
}
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 "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 "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 "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 "playwright" && skip_step "playwright"
should_skip "plugins" && skip_step "plugins"
# Elixir skipped if Erlang skipped
[ "${STEP_STATUS[erlang]}" = "skipped" ] && skip_step "elixir"
# Claude/Playwright skipped if Node skipped
if [ "${STEP_STATUS[node]}" = "skipped" ]; then
[ "${STEP_STATUS[mise]}" = "skipped" ] || true # mise can still install
skip_step "claude"
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 + Erlang (parallel, both need mise)
# ============================================================================
if [ "${STEP_STATUS[mise]}" = "done" ]; then
[ "${STEP_STATUS[node]}" != "skipped" ] && start_step "node" install_node
[ "${STEP_STATUS[erlang]}" != "skipped" ] && start_step "erlang" install_erlang
wait_for_running_steps
fi
# ============================================================================
# Phase 4: Elixir + Claude + Playwright (Elixir needs Erlang, others need Node)
# ============================================================================
[ "${STEP_STATUS[erlang]}" = "done" ] && [ "${STEP_STATUS[elixir]}" != "skipped" ] && start_step "elixir" install_elixir
[ "${STEP_STATUS[node]}" = "done" ] && [ "${STEP_STATUS[claude]}" != "skipped" ] && start_step "claude" install_claude
[ "${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[ollama]}" = "done" ]; then
echo -e "${CYAN}Ollama:${NC} ollama run llama3.2"
fi
echo ""