- Rustup: Download script to temp file with shebang/size validation before execution, matching mise/ollama pattern (line 1119) - SKIP_EXPORTS: Refactor from embedded shell commands to base64-encoded list decoded safely in VM, eliminating injection risk (line 478) - Playwright symlink: Validate path is executable and within expected cache directory before creating system symlinks (line 1053) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1398 lines
47 KiB
Bash
Executable file
1398 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 "
|
|
export GIT_NAME=\$(echo '$GIT_NAME_B64' | base64 -d)
|
|
export GIT_EMAIL=\$(echo '$GIT_EMAIL_B64' | base64 -d)
|
|
export VNC_PASSWORD=\$(echo '$VNC_PASSWORD_B64' | base64 -d)
|
|
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 ""
|