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
1492 lines
51 KiB
Bash
Executable file
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 ""
|