- Root setup.sh orchestrator with per-phase dispatch (./setup.sh p1..p8 | all | status) - Makefile convenience targets (make infra, make llm, make status, etc.) - scripts/common.sh: shared bash library for OS detection, Docker helpers, service management (launchd/systemd), package install, env management - .env.example + .gitignore: shared config template and secret exclusions P1 (homeai-infra): full implementation - docker-compose.yml: Uptime Kuma, code-server, n8n - Note: Home Assistant, Portainer, Gitea are pre-existing instances - setup.sh: Docker install, homeai network, container health checks P2 (homeai-llm): full implementation - Ollama native install with CUDA/ROCm/Metal auto-detection - launchd plist (macOS) + systemd service (Linux) for auto-start - scripts/pull-models.sh: idempotent model puller from manifest - scripts/benchmark.sh: tokens/sec measurement per model - Open WebUI on port 3030 (avoids Gitea :3000 conflict) P3-P8: working stubs with prerequisite checks and TODO sections Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
332 lines
11 KiB
Bash
332 lines
11 KiB
Bash
#!/usr/bin/env bash
|
|
# scripts/common.sh — Shared bash library for HomeAI setup scripts
|
|
# Source this file: source "$(dirname "$0")/../scripts/common.sh"
|
|
# Do NOT execute directly.
|
|
|
|
set -euo pipefail
|
|
|
|
# ─── Colours ───────────────────────────────────────────────────────────────────
|
|
if [[ -t 1 ]]; then
|
|
RED='\033[0;31m'; YELLOW='\033[0;33m'; GREEN='\033[0;32m'
|
|
BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
|
else
|
|
RED=''; YELLOW=''; GREEN=''; BLUE=''; CYAN=''; BOLD=''; RESET=''
|
|
fi
|
|
|
|
# ─── Logging ───────────────────────────────────────────────────────────────────
|
|
log_info() { echo -e "${BLUE}[INFO]${RESET} $*"; }
|
|
log_success() { echo -e "${GREEN}[OK]${RESET} $*"; }
|
|
log_warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
|
|
log_error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
|
|
log_section() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}"; }
|
|
log_step() { echo -e "${CYAN} →${RESET} $*"; }
|
|
|
|
die() { log_error "$*"; exit 1; }
|
|
|
|
# ─── OS & Architecture Detection ───────────────────────────────────────────────
|
|
detect_os() {
|
|
case "$(uname -s)" in
|
|
Linux*) OS_TYPE=linux ;;
|
|
Darwin*) OS_TYPE=macos ;;
|
|
*) die "Unsupported OS: $(uname -s)" ;;
|
|
esac
|
|
export OS_TYPE
|
|
}
|
|
|
|
detect_arch() {
|
|
case "$(uname -m)" in
|
|
x86_64|amd64) ARCH=amd64 ;;
|
|
arm64|aarch64) ARCH=arm64 ;;
|
|
*) ARCH=unknown ;;
|
|
esac
|
|
export ARCH
|
|
}
|
|
|
|
detect_distro() {
|
|
DISTRO=unknown
|
|
if [[ "$OS_TYPE" == "linux" ]]; then
|
|
if [[ -f /etc/os-release ]]; then
|
|
# shellcheck disable=SC1091
|
|
source /etc/os-release
|
|
DISTRO="${ID:-unknown}"
|
|
fi
|
|
fi
|
|
export DISTRO
|
|
}
|
|
|
|
detect_platform() {
|
|
detect_os
|
|
detect_arch
|
|
detect_distro
|
|
log_info "Platform: ${OS_TYPE}/${ARCH} (${DISTRO})"
|
|
}
|
|
|
|
# ─── GPU Detection ─────────────────────────────────────────────────────────────
|
|
detect_gpu() {
|
|
GPU_TYPE=none
|
|
if [[ "$OS_TYPE" == "macos" && "$ARCH" == "arm64" ]]; then
|
|
GPU_TYPE=metal
|
|
elif command -v nvidia-smi &>/dev/null && nvidia-smi &>/dev/null; then
|
|
GPU_TYPE=cuda
|
|
GPU_INFO=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1 || true)
|
|
elif command -v rocm-smi &>/dev/null; then
|
|
GPU_TYPE=rocm
|
|
GPU_INFO=$(rocm-smi --showproductname 2>/dev/null | head -1 || true)
|
|
fi
|
|
export GPU_TYPE GPU_INFO
|
|
log_info "GPU: ${GPU_TYPE}${GPU_INFO:+ — ${GPU_INFO}}"
|
|
}
|
|
|
|
# ─── Dependency Checking ───────────────────────────────────────────────────────
|
|
require_command() {
|
|
local cmd="$1"
|
|
local hint="${2:-install $cmd}"
|
|
if ! command -v "$cmd" &>/dev/null; then
|
|
die "'$cmd' is required but not found. Hint: $hint"
|
|
fi
|
|
}
|
|
|
|
command_exists() { command -v "$1" &>/dev/null; }
|
|
|
|
require_min_version() {
|
|
local cmd="$1" required="$2"
|
|
local actual
|
|
actual="$("$cmd" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)"
|
|
if [[ "$(printf '%s\n' "$required" "$actual" | sort -V | head -1)" != "$required" ]]; then
|
|
die "$cmd version $required+ required, found $actual"
|
|
fi
|
|
}
|
|
|
|
# ─── Package Management ────────────────────────────────────────────────────────
|
|
install_package() {
|
|
local pkg="$1"
|
|
log_step "Installing $pkg..."
|
|
case "$OS_TYPE" in
|
|
linux)
|
|
case "$DISTRO" in
|
|
ubuntu|debian|linuxmint|pop)
|
|
sudo apt-get install -y -qq "$pkg" ;;
|
|
fedora|rhel|centos|rocky|almalinux)
|
|
sudo dnf install -y -q "$pkg" ;;
|
|
arch|manjaro|endeavouros)
|
|
sudo pacman -S --noconfirm --quiet "$pkg" ;;
|
|
opensuse*)
|
|
sudo zypper install -y -q "$pkg" ;;
|
|
*)
|
|
die "Unknown distro '$DISTRO' — install $pkg manually" ;;
|
|
esac ;;
|
|
macos)
|
|
if ! command_exists brew; then
|
|
die "Homebrew not found. Install from https://brew.sh"
|
|
fi
|
|
brew install "$pkg" ;;
|
|
esac
|
|
}
|
|
|
|
update_package_index() {
|
|
if [[ "$OS_TYPE" == "linux" ]]; then
|
|
case "$DISTRO" in
|
|
ubuntu|debian|linuxmint|pop) sudo apt-get update -qq ;;
|
|
fedora|rhel|centos|rocky|almalinux) sudo dnf check-update -q || true ;;
|
|
esac
|
|
fi
|
|
}
|
|
|
|
# ─── Docker ────────────────────────────────────────────────────────────────────
|
|
check_docker_installed() {
|
|
command_exists docker
|
|
}
|
|
|
|
check_docker_running() {
|
|
docker info &>/dev/null
|
|
}
|
|
|
|
install_docker() {
|
|
if check_docker_installed; then
|
|
log_success "Docker already installed: $(docker --version)"
|
|
return
|
|
fi
|
|
|
|
log_info "Installing Docker..."
|
|
|
|
if [[ "$OS_TYPE" == "macos" ]]; then
|
|
die "On macOS, install Docker Desktop manually: https://www.docker.com/products/docker-desktop/"
|
|
fi
|
|
|
|
# Linux: use the official convenience script
|
|
curl -fsSL https://get.docker.com -o /tmp/get-docker.sh
|
|
sudo sh /tmp/get-docker.sh
|
|
rm /tmp/get-docker.sh
|
|
|
|
# Add current user to docker group
|
|
sudo usermod -aG docker "$USER"
|
|
log_warn "Added $USER to docker group. Log out and back in (or run 'newgrp docker') for it to take effect."
|
|
|
|
# Start and enable Docker
|
|
sudo systemctl enable --now docker
|
|
log_success "Docker installed: $(docker --version)"
|
|
}
|
|
|
|
ensure_docker_running() {
|
|
if ! check_docker_running; then
|
|
if [[ "$OS_TYPE" == "linux" ]]; then
|
|
log_info "Starting Docker..."
|
|
sudo systemctl start docker
|
|
sleep 2
|
|
else
|
|
die "Docker Desktop is not running. Start it from your Applications folder."
|
|
fi
|
|
fi
|
|
if ! check_docker_running; then
|
|
die "Docker is not running. Start Docker and retry."
|
|
fi
|
|
}
|
|
|
|
ensure_docker_network() {
|
|
local network="${1:-homeai}"
|
|
if ! docker network inspect "$network" &>/dev/null; then
|
|
log_step "Creating Docker network '$network'..."
|
|
docker network create "$network"
|
|
log_success "Network '$network' created."
|
|
else
|
|
log_info "Docker network '$network' already exists."
|
|
fi
|
|
}
|
|
|
|
# ─── Docker Compose ────────────────────────────────────────────────────────────
|
|
# Handles both `docker compose` (v2) and `docker-compose` (v1)
|
|
docker_compose() {
|
|
if docker compose version &>/dev/null 2>&1; then
|
|
docker compose "$@"
|
|
elif command_exists docker-compose; then
|
|
docker-compose "$@"
|
|
else
|
|
die "docker compose not found. Ensure Docker is up to date."
|
|
fi
|
|
}
|
|
|
|
# ─── Service Management ────────────────────────────────────────────────────────
|
|
install_service() {
|
|
# Usage: install_service <name> <systemd_unit_file> <launchd_plist_file>
|
|
local name="$1"
|
|
local systemd_file="$2"
|
|
local launchd_file="$3"
|
|
|
|
if [[ "$OS_TYPE" == "linux" ]]; then
|
|
if [[ ! -f "$systemd_file" ]]; then
|
|
log_warn "No systemd unit file at $systemd_file — skipping service install."
|
|
return
|
|
fi
|
|
log_step "Installing systemd service: $name"
|
|
sudo cp "$systemd_file" "/etc/systemd/system/${name}.service"
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl enable --now "$name"
|
|
log_success "Service '$name' enabled and started."
|
|
|
|
elif [[ "$OS_TYPE" == "macos" ]]; then
|
|
if [[ ! -f "$launchd_file" ]]; then
|
|
log_warn "No launchd plist at $launchd_file — skipping service install."
|
|
return
|
|
fi
|
|
local plist_dest="${HOME}/Library/LaunchAgents/$(basename "$launchd_file")"
|
|
log_step "Installing launchd agent: $name"
|
|
cp "$launchd_file" "$plist_dest"
|
|
launchctl load -w "$plist_dest"
|
|
log_success "LaunchAgent '$name' installed and loaded."
|
|
fi
|
|
}
|
|
|
|
uninstall_service() {
|
|
local name="$1"
|
|
local plist_label="${2:-$name}"
|
|
|
|
if [[ "$OS_TYPE" == "linux" ]]; then
|
|
sudo systemctl disable --now "$name" 2>/dev/null || true
|
|
sudo rm -f "/etc/systemd/system/${name}.service"
|
|
sudo systemctl daemon-reload
|
|
elif [[ "$OS_TYPE" == "macos" ]]; then
|
|
local plist_path="${HOME}/Library/LaunchAgents/${plist_label}.plist"
|
|
launchctl unload -w "$plist_path" 2>/dev/null || true
|
|
rm -f "$plist_path"
|
|
fi
|
|
}
|
|
|
|
# ─── Environment Files ─────────────────────────────────────────────────────────
|
|
load_env() {
|
|
local env_file="${1:-.env}"
|
|
if [[ -f "$env_file" ]]; then
|
|
# shellcheck disable=SC1090
|
|
set -a; source "$env_file"; set +a
|
|
fi
|
|
}
|
|
|
|
load_env_services() {
|
|
local services_file="${HOME}/.env.services"
|
|
if [[ -f "$services_file" ]]; then
|
|
set -a; source "$services_file"; set +a
|
|
fi
|
|
}
|
|
|
|
write_env_service() {
|
|
# Append or update KEY=VALUE in ~/.env.services
|
|
local key="$1" value="$2"
|
|
local file="${HOME}/.env.services"
|
|
touch "$file"
|
|
if grep -q "^${key}=" "$file" 2>/dev/null; then
|
|
# Update existing
|
|
sed -i "s|^${key}=.*|${key}=${value}|" "$file"
|
|
else
|
|
echo "${key}=${value}" >> "$file"
|
|
fi
|
|
}
|
|
|
|
# Bootstrap .env from .env.example if not present
|
|
bootstrap_env() {
|
|
local dir="${1:-.}"
|
|
if [[ ! -f "${dir}/.env" && -f "${dir}/.env.example" ]]; then
|
|
cp "${dir}/.env.example" "${dir}/.env"
|
|
log_warn "Created ${dir}/.env from .env.example — fill in secrets before continuing."
|
|
fi
|
|
}
|
|
|
|
# ─── Network Helpers ───────────────────────────────────────────────────────────
|
|
wait_for_http() {
|
|
local url="$1" name="${2:-service}" timeout="${3:-60}"
|
|
log_step "Waiting for $name at $url (up to ${timeout}s)..."
|
|
local elapsed=0
|
|
while ! curl -sf "$url" -o /dev/null; do
|
|
sleep 3; elapsed=$((elapsed + 3))
|
|
if [[ $elapsed -ge $timeout ]]; then
|
|
log_warn "$name did not respond within ${timeout}s. It may still be starting."
|
|
return 1
|
|
fi
|
|
done
|
|
log_success "$name is up."
|
|
}
|
|
|
|
check_port_free() {
|
|
local port="$1"
|
|
if ss -tlnp "sport = :${port}" 2>/dev/null | grep -q ":${port}"; then
|
|
return 1 # port in use
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# ─── Utility ───────────────────────────────────────────────────────────────────
|
|
confirm() {
|
|
local msg="${1:-Continue?}"
|
|
read -rp "$(echo -e "${YELLOW}${msg} [y/N]${RESET} ")" response
|
|
[[ "${response,,}" == "y" ]]
|
|
}
|
|
|
|
print_summary() {
|
|
local title="$1"; shift
|
|
echo ""
|
|
log_section "$title"
|
|
while [[ $# -gt 0 ]]; do
|
|
local key="$1" val="$2"; shift 2
|
|
printf " ${BOLD}%-24s${RESET} %s\n" "$key" "$val"
|
|
done
|
|
echo ""
|
|
}
|