secure_agent_envs/setup_env.sh
guessthepw 77093a0ce6 Adds Python support via mise version manager
Integrates Python as a selectable component alongside existing Node.js and Erlang options
Updates component descriptions to reflect mise's expanded language support
Includes pip upgrade during Python installation for package management

Fixes Tidewave CLI download URL and architecture detection for improved reliability
2026-01-25 12:47:45 -05:00

1492 lines
51 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 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<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_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-<arch>-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 ""