diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e72a383 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# Mosaic Stack — Agent Guidelines + +> **Any AI model, coding assistant, or framework working in this codebase MUST read and follow `CLAUDE.md` in the project root.** + +`CLAUDE.md` is the authoritative source for: + +- Technology stack and versions +- TypeScript strict mode requirements +- ESLint Quality Rails (error-level enforcement) +- Prettier formatting rules +- Testing requirements (85% coverage, TDD) +- API conventions and database patterns +- Commit format and branch strategy +- PDA-friendly design principles + +## Quick Rules (Read CLAUDE.md for Details) + +- **No `any` types** — use `unknown`, generics, or proper types +- **Explicit return types** on all functions +- **Type-only imports** — `import type { Foo }` for types +- **Double quotes**, semicolons, 2-space indent, 100 char width +- **`??` not `||`** for defaults, **`?.`** not `&&` chains +- **All promises** must be awaited or returned +- **85% test coverage** minimum, tests before implementation + +## Updating Conventions + +If you discover new patterns, gotchas, or conventions while working in this codebase, **update `CLAUDE.md`** — not this file. This file exists solely to redirect agents that look for `AGENTS.md` to the canonical source. + +## Per-App Context + +Each app directory has its own `AGENTS.md` for app-specific patterns: + +- `apps/api/AGENTS.md` +- `apps/web/AGENTS.md` +- `apps/coordinator/AGENTS.md` +- `apps/orchestrator/AGENTS.md` diff --git a/README.md b/README.md index 36eddcf..65b2ab2 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,65 @@ Mosaic Stack is a modern, PDA-friendly platform designed to help users manage th ## Quick Start +### One-Line Install (Recommended) + +The fastest way to get Mosaic Stack running on macOS or Linux: + +```bash +curl -fsSL https://get.mosaicstack.dev | bash +``` + +This installer: + +- ✅ Detects your platform (macOS, Debian/Ubuntu, Arch, Fedora) +- ✅ Installs all required dependencies (Docker, Node.js, etc.) +- ✅ Generates secure secrets automatically +- ✅ Configures the environment for you +- ✅ Starts all services with Docker Compose +- ✅ Validates the installation with health checks + +**Installer Options:** + +```bash +# Non-interactive Docker deployment +curl -fsSL https://get.mosaicstack.dev | bash -s -- --non-interactive --mode docker + +# Preview installation without making changes +curl -fsSL https://get.mosaicstack.dev | bash -s -- --dry-run + +# With SSO and local Ollama +curl -fsSL https://get.mosaicstack.dev | bash -s -- \ + --mode docker \ + --enable-sso --bundled-authentik \ + --ollama-mode local + +# Skip dependency installation (if already installed) +curl -fsSL https://get.mosaicstack.dev | bash -s -- --skip-deps +``` + +**After Installation:** + +```bash +# Check system health +./scripts/commands/doctor.sh + +# View service logs +docker compose logs -f + +# Stop services +docker compose down +``` + ### Prerequisites -- Node.js 20+ and pnpm 9+ -- PostgreSQL 17+ (or use Docker) -- Docker & Docker Compose (optional, for turnkey deployment) +If you prefer manual installation, you'll need: -### Installation +- **Docker mode:** Docker 24+ and Docker Compose +- **Native mode:** Node.js 22+, pnpm 10+, PostgreSQL 17+ + +The installer handles these automatically. + +### Manual Installation ```bash # Clone the repository diff --git a/scripts/commands/doctor.sh b/scripts/commands/doctor.sh new file mode 100755 index 0000000..65011bc --- /dev/null +++ b/scripts/commands/doctor.sh @@ -0,0 +1,552 @@ +#!/bin/bash +set -euo pipefail + +# ============================================================================ +# Mosaic Stack Doctor +# ============================================================================ +# Diagnostic and repair tool for Mosaic Stack installations. +# Run without arguments for interactive mode, or use flags for CI/CD. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +# Source library files +# shellcheck source=../lib/platform.sh +source "$SCRIPT_DIR/../lib/platform.sh" +# shellcheck source=../lib/dependencies.sh +source "$SCRIPT_DIR/../lib/dependencies.sh" +# shellcheck source=../lib/docker.sh +source "$SCRIPT_DIR/../lib/docker.sh" +# shellcheck source=../lib/validation.sh +source "$SCRIPT_DIR/../lib/validation.sh" + +# ============================================================================ +# Configuration +# ============================================================================ + +FIX_MODE=false +JSON_OUTPUT=false +VERBOSE=false +ENV_FILE="$PROJECT_ROOT/.env" +COMPOSE_FILE="$PROJECT_ROOT/docker-compose.yml" +MODE="" + +# ============================================================================ +# Help +# ============================================================================ + +print_usage() { + cat << EOF +Mosaic Stack Doctor - Diagnostic and repair tool + +USAGE: + ./scripts/commands/doctor.sh [OPTIONS] + +OPTIONS: + -h, --help Show this help message + --fix Attempt to automatically fix issues + --json Output results in JSON format + --verbose Show detailed output + --env FILE Path to .env file (default: .env) + --compose FILE Path to docker-compose.yml (default: docker-compose.yml) + --mode MODE Deployment mode: docker or native (auto-detected) + +EXIT CODES: + 0 All checks passed + 1 Some checks failed + 2 Critical failure + +EXAMPLES: + # Run all checks + ./scripts/commands/doctor.sh + + # Attempt automatic fixes + ./scripts/commands/doctor.sh --fix + + # JSON output for CI/CD + ./scripts/commands/doctor.sh --json + + # Verbose output + ./scripts/commands/doctor.sh --verbose + +EOF +} + +# ============================================================================ +# Argument Parsing +# ============================================================================ + +parse_arguments() { + while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + print_usage + exit 0 + ;; + --fix) + FIX_MODE=true + shift + ;; + --json) + JSON_OUTPUT=true + shift + ;; + --verbose) + VERBOSE=true + shift + ;; + --env) + ENV_FILE="$2" + shift 2 + ;; + --compose) + COMPOSE_FILE="$2" + shift 2 + ;; + --mode) + MODE="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac + done + + # Auto-detect mode if not specified + if [[ -z "$MODE" ]]; then + if [[ -f "$COMPOSE_FILE" ]] && command -v docker &>/dev/null && docker info &>/dev/null; then + MODE="docker" + else + MODE="native" + fi + fi +} + +# ============================================================================ +# JSON Output Helpers +# ============================================================================ + +json_start() { + if [[ "$JSON_OUTPUT" == true ]]; then + echo "{" + echo ' "timestamp": "'$(date -u +"%Y-%m-%dT%H:%M:%SZ")'",' + echo ' "version": "1.0.0",' + echo ' "mode": "'$MODE'",' + echo ' "checks": [' + fi +} + +json_end() { + local errors="$1" + local warnings="$2" + + if [[ "$JSON_OUTPUT" == true ]]; then + echo "" + echo " ]," + echo ' "summary": {' + echo ' "errors": '$errors',' + echo ' "warnings": '$warnings',' + echo ' "status": "'$([ "$errors" -gt 0 ] && echo "failed" || ([ "$warnings" -gt 0 ] && echo "warning" || echo "passed"))'"' + echo ' }' + echo "}" + fi +} + +json_check() { + local name="$1" + local status="$2" + local message="$3" + local first="${4:-true}" + + if [[ "$JSON_OUTPUT" == true ]]; then + [[ "$first" != "true" ]] && echo "," + echo -n ' {"name": "'$name'", "status": "'$status'", "message": "'$message'"}' + fi +} + +# ============================================================================ +# Fix Functions +# ============================================================================ + +fix_env_permissions() { + echo -e "${WARN}→${NC} Fixing .env permissions..." + chmod 600 "$ENV_FILE" + echo -e "${SUCCESS}✓${NC} Fixed" +} + +fix_docker_permissions() { + echo -e "${WARN}→${NC} Adding user to docker group..." + maybe_sudo usermod -aG docker "$USER" + echo -e "${SUCCESS}✓${NC} User added to docker group" + echo -e "${INFO}ℹ${NC} Run 'newgrp docker' or log out/in for changes to take effect" +} + +start_docker_daemon() { + echo -e "${WARN}→${NC} Starting Docker daemon..." + maybe_sudo systemctl start docker + sleep 3 + if docker info &>/dev/null; then + echo -e "${SUCCESS}✓${NC} Docker started" + else + echo -e "${ERROR}✗${NC} Failed to start Docker" + return 1 + fi +} + +restart_containers() { + echo -e "${WARN}→${NC} Restarting containers..." + docker_compose_down "$COMPOSE_FILE" "$ENV_FILE" + docker_compose_up "$COMPOSE_FILE" "$ENV_FILE" + echo -e "${SUCCESS}✓${NC} Containers restarted" +} + +generate_missing_secrets() { + echo -e "${WARN}→${NC} Generating missing secrets..." + + # Load existing env + if [[ -f "$ENV_FILE" ]]; then + set -a + # shellcheck source=/dev/null + source "$ENV_FILE" 2>/dev/null || true + set +a + fi + + local updated=false + + # Check each secret + if [[ -z "${JWT_SECRET:-}" ]] || is_placeholder "${JWT_SECRET:-}"; then + JWT_SECRET=$(openssl rand -base64 32) + echo "JWT_SECRET=$JWT_SECRET" >> "$ENV_FILE" + updated=true + fi + + if [[ -z "${BETTER_AUTH_SECRET:-}" ]] || is_placeholder "${BETTER_AUTH_SECRET:-}"; then + BETTER_AUTH_SECRET=$(openssl rand -base64 32) + echo "BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET" >> "$ENV_FILE" + updated=true + fi + + if [[ -z "${ENCRYPTION_KEY:-}" ]] || is_placeholder "${ENCRYPTION_KEY:-}"; then + ENCRYPTION_KEY=$(openssl rand -hex 32) + echo "ENCRYPTION_KEY=$ENCRYPTION_KEY" >> "$ENV_FILE" + updated=true + fi + + if [[ -z "${POSTGRES_PASSWORD:-}" ]] || is_placeholder "${POSTGRES_PASSWORD:-}"; then + POSTGRES_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' | head -c 32) + # Update DATABASE_URL too + DATABASE_URL="postgresql://mosaic:${POSTGRES_PASSWORD}@postgres:5432/mosaic" + sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$POSTGRES_PASSWORD|" "$ENV_FILE" 2>/dev/null || \ + echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" >> "$ENV_FILE" + sed -i "s|^DATABASE_URL=.*|DATABASE_URL=$DATABASE_URL|" "$ENV_FILE" 2>/dev/null || \ + echo "DATABASE_URL=$DATABASE_URL" >> "$ENV_FILE" + updated=true + fi + + if [[ "$updated" == true ]]; then + echo -e "${SUCCESS}✓${NC} Secrets generated" + else + echo -e "${INFO}ℹ${NC} All secrets already set" + fi +} + +# ============================================================================ +# Check Functions +# ============================================================================ + +run_checks() { + local errors=0 + local warnings=0 + local first=true + + json_start + + # System requirements + echo -e "${BOLD}━━━ System Requirements ━━━${NC}" + check_system_requirements 2048 10 + local result=$? + [[ $result -eq $CHECK_FAIL ]] && ((errors++)) + [[ $result -eq $CHECK_WARN ]] && ((warnings++)) + json_check "system_requirements" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") )" "RAM and disk check" "$first" + first=false + echo "" + + # Environment file + echo -e "${BOLD}━━━ Environment File ━━━${NC}" + check_env_file "$ENV_FILE" + result=$? + [[ $result -eq $CHECK_FAIL ]] && ((errors++)) + [[ $result -eq $CHECK_WARN ]] && ((warnings++)) + json_check "env_file" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") )" ".env file exists" "$first" + echo "" + + # Required variables + check_required_env "$ENV_FILE" + result=$? + [[ $result -eq $CHECK_FAIL ]] && ((errors++)) + [[ $result -eq $CHECK_WARN ]] && ((warnings++)) + json_check "required_env" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") )" "Required environment variables" "$first" + echo "" + + # Secret strength + check_secrets "$ENV_FILE" + result=$? + [[ $result -eq $CHECK_FAIL ]] && ((errors++)) + [[ $result -eq $CHECK_WARN ]] && ((warnings++)) + json_check "secrets" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") ")" "Secret strength and validity" "$first" + echo "" + + # File permissions + check_env_permissions "$ENV_FILE" + result=$? + [[ $result -eq $CHECK_FAIL ]] && ((errors++)) + [[ $result -eq $CHECK_WARN ]] && ((warnings++)) + json_check "env_permissions" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") ")" ".env file permissions" "$first" + echo "" + + if [[ "$MODE" == "docker" ]]; then + # Docker checks + echo -e "${BOLD}━━━ Docker ━━━${NC}" + + check_docker + result=$? + [[ $result -ne 0 ]] && ((errors++)) + json_check "docker" "$( [[ $result -eq 0 ]] && echo "pass" || "fail")" "Docker availability" "$first" + echo "" + + if [[ $result -eq 0 ]]; then + check_docker_compose + result=$? + [[ $result -ne 0 ]] && ((errors++)) + json_check "docker_compose" "$( [[ $result -eq 0 ]] && echo "pass" || "fail")" "Docker Compose availability" "$first" + echo "" + + # Container status + check_docker_containers "$COMPOSE_FILE" + result=$? + [[ $result -eq $CHECK_FAIL ]] && ((errors++)) + [[ $result -eq $CHECK_WARN ]] && ((warnings++)) + json_check "containers" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") ")" "Container status" "$first" + echo "" + + # Container health + check_container_health + result=$? + [[ $result -eq $CHECK_FAIL ]] && ((errors++)) + [[ $result -eq $CHECK_WARN ]] && ((warnings++)) + json_check "container_health" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") ")" "Container health checks" "$first" + echo "" + + # Database + check_database_connection + result=$? + [[ $result -eq $CHECK_FAIL ]] && ((errors++)) + [[ $result -eq $CHECK_WARN ]] && ((warnings++)) + json_check "database" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") ")" "Database connectivity" "$first" + echo "" + + # Valkey + check_valkey_connection + result=$? + [[ $result -eq $CHECK_FAIL ]] && ((errors++)) + [[ $result -eq $CHECK_WARN ]] && ((warnings++)) + json_check "cache" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") ")" "Valkey/Redis connectivity" "$first" + echo "" + + # API + check_api_health + result=$? + [[ $result -eq $CHECK_FAIL ]] && ((errors++)) + [[ $result -eq $CHECK_WARN ]] && ((warnings++)) + json_check "api" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") ")" "API health endpoint" "$first" + echo "" + + # Web + check_web_health + result=$? + [[ $result -eq $CHECK_FAIL ]] && ((errors++)) + [[ $result -eq $CHECK_WARN ]] && ((warnings++)) + json_check "web" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") ")" "Web frontend" "$first" + echo "" + fi + else + # Native mode checks + echo -e "${BOLD}━━━ Native Dependencies ━━━${NC}" + + check_node + result=$? + [[ $result -ne 0 ]] && ((errors++)) + json_check "nodejs" "$( [[ $result -eq 0 ]] && echo "pass" || "fail")" "Node.js" "$first" + echo "" + + check_pnpm + result=$? + [[ $result -ne 0 ]] && ((errors++)) + json_check "pnpm" "$( [[ $result -eq 0 ]] && echo "pass" || "fail")" "pnpm" "$first" + echo "" + + check_postgres + result=$? + [[ $result -ne 0 ]] && ((warnings++)) + json_check "postgresql" "$( [[ $result -eq 0 ]] && echo "pass" || "warn")" "PostgreSQL" "$first" + echo "" + fi + + json_end $errors $warnings + + return $([ $errors -eq 0 ] && echo 0 || echo 1) +} + +# ============================================================================ +# Interactive Fix Mode +# ============================================================================ + +interactive_fix() { + local issues=() + + # Collect issues + if [[ ! -f "$ENV_FILE" ]]; then + issues+=("env_missing:Missing .env file") + fi + + if [[ -f "$ENV_FILE" ]]; then + # Check permissions + local perms + perms=$(stat -c "%a" "$ENV_FILE" 2>/dev/null || stat -f "%OLp" "$ENV_FILE" 2>/dev/null) + if [[ "$perms" =~ [0-7][0-7][4-7]$ ]]; then + issues+=("env_perms:.env is world-readable") + fi + + # Check secrets + set -a + # shellcheck source=/dev/null + source "$ENV_FILE" 2>/dev/null || true + set +a + + if [[ -z "${JWT_SECRET:-}" ]] || is_placeholder "${JWT_SECRET:-}"; then + issues+=("secrets:Missing or invalid secrets") + fi + fi + + if [[ "$MODE" == "docker" ]]; then + if ! docker info &>/dev/null; then + local docker_result + docker_result=$(docker info 2>&1) + if [[ "$docker_result" =~ "permission denied" ]]; then + issues+=("docker_perms:Docker permission denied") + elif [[ "$docker_result" =~ "Cannot connect to the Docker daemon" ]]; then + issues+=("docker_daemon:Docker daemon not running") + fi + fi + fi + + if [[ ${#issues[@]} -eq 0 ]]; then + echo -e "${SUCCESS}✓${NC} No fixable issues found" + return 0 + fi + + echo -e "${BOLD}Found ${#issues[@]} fixable issue(s):${NC}" + echo "" + + for issue in "${issues[@]}"; do + local code="${issue%%:*}" + local desc="${issue#*:}" + echo " - $desc" + done + + echo "" + read -r -p "Fix these issues? [Y/n]: " fix_them + case "$fix_them" in + n|N) + echo -e "${INFO}ℹ${NC} Skipping fixes" + return 0 + ;; + esac + + # Apply fixes + for issue in "${issues[@]}"; do + local code="${issue%%:*}" + + case "$code" in + env_perms) + fix_env_permissions + ;; + docker_perms) + fix_docker_permissions + ;; + docker_daemon) + start_docker_daemon + ;; + secrets) + generate_missing_secrets + ;; + *) + echo -e "${WARN}⚠${NC} Unknown issue: $code" + ;; + esac + done + + echo "" + echo -e "${SUCCESS}✓${NC} Fixes applied" +} + +# ============================================================================ +# Main +# ============================================================================ + +main() { + parse_arguments "$@" + + # Configure verbose mode + if [[ "$VERBOSE" == true ]]; then + set -x + fi + + # Show banner (unless JSON mode) + if [[ "$JSON_OUTPUT" != true ]]; then + echo "" + echo -e "${BOLD}════════════════════════════════════════════════════════════${NC}" + echo -e "${BOLD} Mosaic Stack Doctor${NC}" + echo -e "${BOLD}════════════════════════════════════════════════════════════${NC}" + echo "" + echo -e " Mode: ${INFO}$MODE${NC}" + echo -e " Env: ${INFO}$ENV_FILE${NC}" + echo -e " Compose: ${INFO}$COMPOSE_FILE${NC}" + echo "" + fi + + # Run checks + run_checks + local check_result=$? + + # Fix mode + if [[ "$FIX_MODE" == true && "$JSON_OUTPUT" != true && $check_result -ne 0 ]]; then + echo "" + echo -e "${BOLD}━━━ Fix Mode ━━━${NC}" + echo "" + interactive_fix + fi + + # Summary (unless JSON mode) + if [[ "$JSON_OUTPUT" != true ]]; then + echo "" + echo -e "${BOLD}════════════════════════════════════════════════════════════${NC}" + + if [[ $check_result -eq 0 ]]; then + echo -e "${SUCCESS}✓ All checks passed${NC}" + else + echo -e "${WARN}⚠ Some checks failed${NC}" + echo "" + echo "Run with --fix to attempt automatic repairs" + fi + + echo -e "${BOLD}════════════════════════════════════════════════════════════${NC}" + fi + + exit $([ $check_result -eq 0 ] && echo 0 || echo 1) +} + +# Run +main "$@" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..42c1ab9 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,834 @@ +#!/bin/bash +set -euo pipefail + +# ============================================================================ +# Mosaic Stack Installer +# ============================================================================ +# Usage: curl -fsSL https://get.mosaicstack.dev | bash +# +# A comprehensive installer that "just works" across platforms. +# Automatically detects the OS, installs dependencies, and configures +# the system for running Mosaic Stack. + +# Script version +INSTALLER_VERSION="1.0.0" + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Source library files +# shellcheck source=lib/platform.sh +source "$SCRIPT_DIR/lib/platform.sh" +# shellcheck source=lib/dependencies.sh +source "$SCRIPT_DIR/lib/dependencies.sh" +# shellcheck source=lib/docker.sh +source "$SCRIPT_DIR/lib/docker.sh" +# shellcheck source=lib/validation.sh +source "$SCRIPT_DIR/lib/validation.sh" + +# Set up cleanup trap +setup_cleanup_trap + +# ============================================================================ +# Configuration +# ============================================================================ + +# Default values +NON_INTERACTIVE=false +DRY_RUN=false +VERBOSE=false +MODE="" +ENABLE_SSO=false +USE_BUNDLED_AUTHENTIK=false +EXTERNAL_AUTHENTIK_URL="" +OLLAMA_MODE="disabled" +OLLAMA_URL="" +MOSAIC_BASE_URL="" +COMPOSE_PROFILES="full" +SKIP_DEPS=false +NO_PORT_CHECK=false + +# Ports (defaults, can be overridden) +WEB_PORT="${WEB_PORT:-3000}" +API_PORT="${API_PORT:-3001}" +POSTGRES_PORT="${POSTGRES_PORT:-5432}" +VALKEY_PORT="${VALKEY_PORT:-6379}" + +# ============================================================================ +# Taglines +# ============================================================================ + +TAGLINES=( + "Claws out, configs in — let's ship a calm, clean stack." + "Less yak-shaving, more uptime." + "Turnkey today, productive tonight." + "Ports resolved. Secrets sealed. Stack ready." + "All signal, no ceremony." + "Your .env is safe with me." + "One curl away from your personal AI assistant." + "Infrastructure that stays out of your way." + "From zero to AI assistant in under 5 minutes." + "Because you have better things to do than configure Docker." +) + +pick_tagline() { + local count=${#TAGLINES[@]} + local idx=$((RANDOM % count)) + echo "${TAGLINES[$idx]}" +} + +TAGLINE=$(pick_tagline) + +# ============================================================================ +# Help and Usage +# ============================================================================ + +print_usage() { + cat << EOF +Mosaic Stack Installer v${INSTALLER_VERSION} + +USAGE: + curl -fsSL https://get.mosaicstack.dev | bash + ./install.sh [OPTIONS] + +OPTIONS: + -h, --help Show this help message + --non-interactive Run without prompts (requires --mode) + --dry-run Preview changes without executing + --verbose Enable debug output + --mode MODE Deployment mode: docker or native + --enable-sso Enable Authentik SSO (Docker only) + --bundled-authentik Use bundled Authentik server + --external-authentik URL Use external Authentik server + --ollama-mode MODE Ollama: local, remote, disabled + --ollama-url URL Remote Ollama server URL + --base-url URL Mosaic base URL + --profiles PROFILES Docker Compose profiles (default: full) + --skip-deps Skip dependency installation + --no-port-check Skip port conflict detection + +ENVIRONMENT VARIABLES: + All options can be set via environment variables: + MOSAIC_MODE, MOSAIC_ENABLE_SSO, MOSAIC_OLLAMA_MODE, etc. + +EXAMPLES: + # Interactive installation (recommended) + curl -fsSL https://get.mosaicstack.dev | bash + + # Non-interactive Docker deployment + curl -fsSL https://get.mosaicstack.dev | bash -s -- --non-interactive --mode docker + + # With SSO and local Ollama + curl -fsSL https://get.mosaicstack.dev | bash -s -- \\ + --mode docker \\ + --enable-sso --bundled-authentik \\ + --ollama-mode local + + # Preview installation + curl -fsSL https://get.mosaicstack.dev | bash -s -- --dry-run + +DOCUMENTATION: + https://docs.mosaicstack.dev + +EOF +} + +# ============================================================================ +# Argument Parsing +# ============================================================================ + +parse_arguments() { + while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + print_usage + exit 0 + ;; + --non-interactive) + NON_INTERACTIVE=true + shift + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --verbose) + VERBOSE=true + shift + ;; + --mode) + if [[ -z "${2:-}" || "$2" == --* ]]; then + echo -e "${ERROR}Error: --mode requires a value (docker or native)${NC}" + exit 1 + fi + MODE="$2" + shift 2 + ;; + --enable-sso) + ENABLE_SSO=true + shift + ;; + --bundled-authentik) + USE_BUNDLED_AUTHENTIK=true + shift + ;; + --external-authentik) + if [[ -z "${2:-}" || "$2" == --* ]]; then + echo -e "${ERROR}Error: --external-authentik requires a URL${NC}" + exit 1 + fi + EXTERNAL_AUTHENTIK_URL="$2" + shift 2 + ;; + --ollama-mode) + if [[ -z "${2:-}" || "$2" == --* ]]; then + echo -e "${ERROR}Error: --ollama-mode requires a value (local, remote, disabled)${NC}" + exit 1 + fi + OLLAMA_MODE="$2" + shift 2 + ;; + --ollama-url) + if [[ -z "${2:-}" || "$2" == --* ]]; then + echo -e "${ERROR}Error: --ollama-url requires a URL${NC}" + exit 1 + fi + OLLAMA_URL="$2" + shift 2 + ;; + --base-url) + if [[ -z "${2:-}" || "$2" == --* ]]; then + echo -e "${ERROR}Error: --base-url requires a URL${NC}" + exit 1 + fi + MOSAIC_BASE_URL="$2" + shift 2 + ;; + --profiles) + if [[ -z "${2:-}" || "$2" == --* ]]; then + echo -e "${ERROR}Error: --profiles requires a value${NC}" + exit 1 + fi + COMPOSE_PROFILES="$2" + shift 2 + ;; + --skip-deps) + SKIP_DEPS=true + shift + ;; + --no-port-check) + NO_PORT_CHECK=true + shift + ;; + *) + echo -e "${ERROR}Error: Unknown option: $1${NC}" + echo "Use --help for usage information" + exit 1 + ;; + esac + done + + # Validate non-interactive mode + if [[ "$NON_INTERACTIVE" == true ]]; then + if [[ -z "$MODE" ]]; then + echo -e "${ERROR}Error: Non-interactive mode requires --mode${NC}" + exit 1 + fi + + if [[ "$MODE" != "native" && "$MODE" != "docker" ]]; then + echo -e "${ERROR}Error: Invalid mode: $MODE (must be 'docker' or 'native')${NC}" + exit 1 + fi + + if [[ "$OLLAMA_MODE" == "remote" && -z "$OLLAMA_URL" ]]; then + echo -e "${ERROR}Error: Remote Ollama mode requires --ollama-url${NC}" + exit 1 + fi + + if [[ "$ENABLE_SSO" == true && "$USE_BUNDLED_AUTHENTIK" != true && -z "$EXTERNAL_AUTHENTIK_URL" ]]; then + echo -e "${ERROR}Error: SSO enabled but no Authentik configuration provided${NC}" + echo "Use --bundled-authentik or --external-authentik URL" + exit 1 + fi + fi +} + +# ============================================================================ +# Banner +# ============================================================================ + +show_banner() { + echo "" + echo -e "${ACCENT}${BOLD}" + cat << "EOF" + __ __ _ ____ _ _ + | \/ | ___ ___ __ _(_) ___ / ___| |_ __ _ ___| | __ + | |\/| |/ _ \/ __|/ _` | |/ __|\___ | __/ _` |/ __| |/ / + | | | | (_) \__ \ (_| | | (__ ___/ | || (_| | (__| < + |_| |_|\___/|___/\__,_|_|\___|____/ \__\__,_|\___|_|\_\ + +EOF + echo -e "${NC}${MUTED} ${TAGLINE}${NC}" + echo "" +} + +# ============================================================================ +# Mode Selection +# ============================================================================ + +select_mode() { + if [[ -n "$MODE" ]]; then + return + fi + + if [[ "$NON_INTERACTIVE" == true ]]; then + MODE="docker" + return + fi + + echo -e "${BOLD}How would you like to run Mosaic Stack?${NC}" + echo "" + echo " 1) Docker (Recommended)" + echo " - Best for production deployment" + echo " - Isolated environment with all dependencies" + echo " - Includes PostgreSQL, Valkey, all services" + echo "" + echo " 2) Native" + echo " - Best for development" + echo " - Runs directly on your system" + echo " - Requires manual dependency installation" + echo "" + + local selection + read -r -p "Select deployment mode [1-2]: " selection + + case "$selection" in + 1) MODE="docker" ;; + 2) MODE="native" ;; + *) + echo -e "${INFO}i${NC} Defaulting to Docker mode" + MODE="docker" + ;; + esac + + echo "" +} + +# ============================================================================ +# Configuration Collection +# ============================================================================ + +collect_configuration() { + echo -e "${BOLD}Configuration${NC}" + echo "" + + # Check for existing .env + if [[ -f "$PROJECT_ROOT/.env" ]]; then + echo -e "${SUCCESS}✓${NC} Found existing .env file" + + if [[ "$NON_INTERACTIVE" != true ]]; then + read -r -p "Use existing configuration? [Y/n]: " use_existing + case "$use_existing" in + n|N) + echo -e "${INFO}i${NC} Will reconfigure..." + ;; + *) + echo -e "${INFO}i${NC} Using existing configuration" + return + ;; + esac + fi + fi + + # Base URL + if [[ -z "$MOSAIC_BASE_URL" ]]; then + if [[ "$NON_INTERACTIVE" == true ]]; then + MOSAIC_BASE_URL="http://localhost:${WEB_PORT}" + else + echo -e "${INFO}i${NC} Base URL configuration" + echo " - Localhost: http://localhost:${WEB_PORT}" + echo " - Custom: Enter your domain URL" + read -r -p "Base URL [http://localhost:${WEB_PORT}]: " MOSAIC_BASE_URL + MOSAIC_BASE_URL="${MOSAIC_BASE_URL:-http://localhost:${WEB_PORT}}" + fi + fi + + echo -e "${SUCCESS}✓${NC} Base URL: ${INFO}$MOSAIC_BASE_URL${NC}" + + # SSO Configuration (Docker mode only) + if [[ "$MODE" == "docker" && "$ENABLE_SSO" != true ]]; then + if [[ "$NON_INTERACTIVE" != true ]]; then + echo "" + read -r -p "Enable Authentik SSO? [y/N]: " enable_sso + case "$enable_sso" in + y|Y) + ENABLE_SSO=true + read -r -p "Use bundled Authentik? [Y/n]: " bundled + case "$bundled" in + n|N) + read -r -p "External Authentik URL: " EXTERNAL_AUTHENTIK_URL + ;; + *) + USE_BUNDLED_AUTHENTIK=true + ;; + esac + ;; + esac + fi + fi + + # Ollama Configuration + if [[ "$OLLAMA_MODE" == "disabled" ]]; then + if [[ "$NON_INTERACTIVE" != true ]]; then + echo "" + echo -e "${INFO}i${NC} Ollama Configuration" + echo " 1) Local (bundled Ollama service)" + echo " 2) Remote (connect to existing Ollama)" + echo " 3) Disabled" + read -r -p "Ollama mode [1-3]: " ollama_choice + + case "$ollama_choice" in + 1) OLLAMA_MODE="local" ;; + 2) + OLLAMA_MODE="remote" + read -r -p "Ollama URL: " OLLAMA_URL + ;; + *) OLLAMA_MODE="disabled" ;; + esac + fi + fi + + echo "" +} + +# ============================================================================ +# Environment File Generation +# ============================================================================ + +generate_secrets() { + echo -e "${WARN}→${NC} Generating secrets..." + + # Generate all required secrets + POSTGRES_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' | head -c 32) + JWT_SECRET=$(openssl rand -base64 32) + BETTER_AUTH_SECRET=$(openssl rand -base64 32) + ENCRYPTION_KEY=$(openssl rand -hex 32) + AUTHENTIK_SECRET_KEY=$(openssl rand -base64 50) + AUTHENTIK_BOOTSTRAP_PASSWORD=$(openssl rand -base64 16 | tr -d '/+=' | head -c 16) + COORDINATOR_API_KEY=$(openssl rand -base64 32) + ORCHESTRATOR_API_KEY=$(openssl rand -base64 32) + GITEA_WEBHOOK_SECRET=$(openssl rand -hex 32) + + echo -e "${SUCCESS}✓${NC} Secrets generated" +} + +generate_env_file() { + local env_file="$PROJECT_ROOT/.env" + + echo -e "${WARN}→${NC} Generating .env file..." + + # Parse base URL + local scheme="http" + local host="localhost" + local port="$WEB_PORT" + + if [[ "$MOSAIC_BASE_URL" =~ ^(https?)://([^/:]+)(:([0-9]+))? ]]; then + scheme="${BASH_REMATCH[1]}" + host="${BASH_REMATCH[2]}" + port="${BASH_REMATCH[4]:-$WEB_PORT}" + fi + + # Determine profiles + local profiles="$COMPOSE_PROFILES" + + # Start with example file if it exists + if [[ -f "$PROJECT_ROOT/.env.example" ]]; then + cp "$PROJECT_ROOT/.env.example" "$env_file" + fi + + # Write configuration + cat >> "$env_file" << EOF + +# ============================================== +# Generated by Mosaic Stack Installer v${INSTALLER_VERSION} +# Generated at: $(date -u +"%Y-%m-%dT%H:%M:%SZ") +# ============================================== + +# Application Ports +WEB_PORT=$port +API_PORT=$API_PORT +POSTGRES_PORT=$POSTGRES_PORT +VALKEY_PORT=$VALKEY_PORT + +# Web Configuration +NEXT_PUBLIC_APP_URL=$MOSAIC_BASE_URL +NEXT_PUBLIC_API_URL=${scheme}://${host}:${API_PORT} + +# Database +DATABASE_URL=postgresql://mosaic:${POSTGRES_PASSWORD}@postgres:5432/mosaic +POSTGRES_PASSWORD=$POSTGRES_PASSWORD + +# Authentication +JWT_SECRET=$JWT_SECRET +BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET + +# Encryption +ENCRYPTION_KEY=$ENCRYPTION_KEY + +# Compose Profiles +COMPOSE_PROFILES=$profiles + +EOF + + # Add SSO configuration if enabled + if [[ "$ENABLE_SSO" == true ]]; then + cat >> "$env_file" << EOF + +# Authentik SSO +OIDC_ENABLED=true +AUTHENTIK_SECRET_KEY=$AUTHENTIK_SECRET_KEY +AUTHENTIK_BOOTSTRAP_PASSWORD=$AUTHENTIK_BOOTSTRAP_PASSWORD + +EOF + if [[ "$USE_BUNDLED_AUTHENTIK" == true ]]; then + echo "AUTHENTIK_PUBLIC_URL=http://localhost:\${AUTHENTIK_PORT_HTTP:-9000}" >> "$env_file" + else + echo "AUTHENTIK_PUBLIC_URL=$EXTERNAL_AUTHENTIK_URL" >> "$env_file" + fi + fi + + # Add Ollama configuration if enabled + if [[ "$OLLAMA_MODE" != "disabled" ]]; then + cat >> "$env_file" << EOF + +# Ollama +OLLAMA_MODE=$OLLAMA_MODE +EOF + if [[ "$OLLAMA_MODE" == "local" ]]; then + echo "OLLAMA_ENDPOINT=http://ollama:11434" >> "$env_file" + else + echo "OLLAMA_ENDPOINT=$OLLAMA_URL" >> "$env_file" + fi + fi + + # Add API keys + cat >> "$env_file" << EOF + +# API Keys +COORDINATOR_API_KEY=$COORDINATOR_API_KEY +ORCHESTRATOR_API_KEY=$ORCHESTRATOR_API_KEY +GITEA_WEBHOOK_SECRET=$GITEA_WEBHOOK_SECRET + +EOF + + # Set restrictive permissions + chmod 600 "$env_file" + + echo -e "${SUCCESS}✓${NC} .env file generated at ${INFO}$env_file${NC}" +} + +# ============================================================================ +# Port Conflict Resolution +# ============================================================================ + +check_port_conflicts() { + if [[ "$NO_PORT_CHECK" == true ]]; then + return + fi + + echo -e "${BOLD}Checking for port conflicts...${NC}" + echo "" + + local conflicts=() + local ports_to_check=("WEB_PORT:$WEB_PORT" "API_PORT:$API_PORT" "POSTGRES_PORT:$POSTGRES_PORT" "VALKEY_PORT:$VALKEY_PORT") + + for entry in "${ports_to_check[@]}"; do + local name="${entry%%:*}" + local port="${entry#*:}" + + if check_port_in_use "$port"; then + conflicts+=("$name:$port") + fi + done + + if [[ ${#conflicts[@]} -eq 0 ]]; then + echo -e "${SUCCESS}✓${NC} No port conflicts detected" + return + fi + + echo -e "${WARN}⚠${NC} Port conflicts detected:" + for conflict in "${conflicts[@]}"; do + local name="${conflict%%:*}" + local port="${conflict#*:}" + local process + process=$(get_process_on_port "$port") + echo " - $name: Port $port is in use (PID: $process)" + done + + if [[ "$NON_INTERACTIVE" == true ]]; then + echo -e "${INFO}i${NC} Non-interactive mode: Please free the ports and try again" + exit 1 + fi + + echo "" + read -r -p "Continue anyway? [y/N]: " continue_anyway + case "$continue_anyway" in + y|Y) + echo -e "${WARN}⚠${NC} Continuing with port conflicts - services may fail to start" + ;; + *) + echo -e "${ERROR}Error: Port conflicts must be resolved${NC}" + exit 1 + ;; + esac +} + +# ============================================================================ +# Installation Steps +# ============================================================================ + +install_docker_mode() { + echo -e "${BOLD}Installing Mosaic Stack (Docker mode)${NC}" + echo "" + + # Check and install dependencies + if [[ "$SKIP_DEPS" != true ]]; then + if ! check_docker_dependencies; then + echo "" + if [[ "$NON_INTERACTIVE" == true ]] || \ + confirm "Install missing dependencies?" "y"; then + install_dependencies "docker" + else + echo -e "${ERROR}Error: Cannot proceed without dependencies${NC}" + exit 1 + fi + fi + fi + + # Ensure Docker is running + start_docker + + # Check port conflicts + check_port_conflicts + + # Generate secrets and .env + generate_secrets + generate_env_file + + # Pull images + if [[ "$DRY_RUN" != true ]]; then + echo "" + docker_pull_images "$PROJECT_ROOT/docker-compose.yml" "$PROJECT_ROOT/.env" + fi + + # Start services + if [[ "$DRY_RUN" != true ]]; then + echo "" + docker_compose_up "$PROJECT_ROOT/docker-compose.yml" "$PROJECT_ROOT/.env" "$COMPOSE_PROFILES" + + # Wait for services to be healthy + echo "" + echo -e "${INFO}ℹ${NC} Waiting for services to start..." + sleep 10 + + # Run health checks + wait_for_healthy_container "mosaic-postgres" 60 || true + wait_for_healthy_container "mosaic-valkey" 30 || true + fi +} + +install_native_mode() { + echo -e "${BOLD}Installing Mosaic Stack (Native mode)${NC}" + echo "" + + # Check and install dependencies + if [[ "$SKIP_DEPS" != true ]]; then + if ! check_native_dependencies; then + echo "" + if [[ "$NON_INTERACTIVE" == true ]] || \ + confirm "Install missing dependencies?" "y"; then + install_dependencies "native" + else + echo -e "${ERROR}Error: Cannot proceed without dependencies${NC}" + exit 1 + fi + fi + fi + + # Generate secrets and .env + generate_secrets + generate_env_file + + # Install npm dependencies + if [[ "$DRY_RUN" != true ]]; then + echo "" + echo -e "${WARN}→${NC} Installing npm dependencies..." + pnpm install + + # Run database migrations + echo "" + echo -e "${WARN}→${NC} Running database setup..." + echo -e "${INFO}ℹ${NC} Make sure PostgreSQL is running and accessible" + fi +} + +# ============================================================================ +# Post-Install +# ============================================================================ + +run_post_install_checks() { + echo "" + echo -e "${BOLD}Post-Installation Checks${NC}" + echo "" + + if [[ "$DRY_RUN" == true ]]; then + echo -e "${INFO}ℹ${NC} Dry run - skipping checks" + return + fi + + # Run doctor + run_doctor "$PROJECT_ROOT/.env" "$PROJECT_ROOT/docker-compose.yml" "$MODE" + local doctor_result=$? + + if [[ $doctor_result -eq $CHECK_FAIL ]]; then + echo "" + echo -e "${WARN}⚠${NC} Some checks failed. Review the output above." + fi +} + +show_success_message() { + local web_url="$MOSAIC_BASE_URL" + local api_url="${MOSAIC_BASE_URL/http:\/\//http:\/\/}:${API_PORT}" + + # If using Traefik, adjust URLs + if [[ "$COMPOSE_PROFILES" == *"traefik"* ]]; then + api_url="${web_url/api./}" + fi + + echo "" + echo -e "${BOLD}${SUCCESS}════════════════════════════════════════════════════════════${NC}" + echo -e "${BOLD}${SUCCESS} Mosaic Stack is ready!${NC}" + echo -e "${BOLD}${SUCCESS}════════════════════════════════════════════════════════════${NC}" + echo "" + echo -e " ${INFO}Web UI:${NC} $web_url" + echo -e " ${INFO}API:${NC} $api_url" + echo -e " ${INFO}Database:${NC} localhost:$POSTGRES_PORT" + echo "" + echo -e " ${BOLD}Next steps:${NC}" + echo " 1. Open $web_url in your browser" + echo " 2. Create your first workspace" + echo " 3. Configure AI providers in Settings" + echo "" + echo -e " ${BOLD}Useful commands:${NC}" + if [[ "$MODE" == "docker" ]]; then + echo " To stop: docker compose down" + echo " To restart: docker compose restart" + echo " To view logs: docker compose logs -f" + else + echo " Start API: pnpm --filter api dev" + echo " Start Web: pnpm --filter web dev" + fi + echo "" + echo -e " ${INFO}Documentation:${NC} https://docs.mosaicstack.dev" + echo -e " ${INFO}Support:${NC} https://github.com/mosaicstack/stack/issues" + echo "" +} + +# ============================================================================ +# Dry Run +# ============================================================================ + +show_dry_run_summary() { + echo "" + echo -e "${BOLD}${INFO}════════════════════════════════════════════════════════════${NC}" + echo -e "${BOLD}${INFO} Dry Run Summary${NC}" + echo -e "${BOLD}${INFO}════════════════════════════════════════════════════════════${NC}" + echo "" + echo -e " ${INFO}Mode:${NC} $MODE" + echo -e " ${INFO}Base URL:${NC} $MOSAIC_BASE_URL" + echo -e " ${INFO}Profiles:${NC} $COMPOSE_PROFILES" + echo "" + echo -e " ${INFO}SSO:${NC} $([ "$ENABLE_SSO" == true ] && echo "Enabled" || echo "Disabled")" + echo -e " ${INFO}Ollama:${NC} $OLLAMA_MODE" + echo "" + echo -e " ${MUTED}This was a dry run. No changes were made.${NC}" + echo -e " ${MUTED}Run without --dry-run to perform installation.${NC}" + echo "" +} + +# ============================================================================ +# Main +# ============================================================================ + +main() { + # Configure verbose mode + if [[ "$VERBOSE" == true ]]; then + set -x + fi + + # Show banner + show_banner + + # Detect platform + print_platform_summary + echo "" + + # Select deployment mode + select_mode + echo -e "${SUCCESS}✓${NC} Selected: ${INFO}$MODE${NC} mode" + echo "" + + # Dry run check + if [[ "$DRY_RUN" == true ]]; then + collect_configuration + show_dry_run_summary + exit 0 + fi + + # Collect configuration + collect_configuration + + # Install based on mode + case "$MODE" in + docker) + install_docker_mode + ;; + native) + install_native_mode + ;; + esac + + # Post-installation checks + run_post_install_checks + + # Show success message + show_success_message +} + +# Confirm helper +confirm() { + local prompt="$1" + local default="${2:-n}" + local response + + if [[ "$default" == "y" ]]; then + prompt="$prompt [Y/n]: " + else + prompt="$prompt [y/N]: " + fi + + read -r -p "$prompt" response + response=${response:-$default} + + case "$response" in + [Yy]|[Yy][Ee][Ss]) return 0 ;; + *) return 1 ;; + esac +} + +# Run if not being sourced +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + parse_arguments "$@" + main +fi diff --git a/scripts/lib/dependencies.sh b/scripts/lib/dependencies.sh new file mode 100644 index 0000000..c511a91 --- /dev/null +++ b/scripts/lib/dependencies.sh @@ -0,0 +1,908 @@ +#!/bin/bash +# Dependency management functions for Mosaic Stack installer +# Handles installation and verification of all required dependencies + +# shellcheck source=lib/platform.sh +source "${BASH_SOURCE[0]%/*}/platform.sh" + +# ============================================================================ +# Dependency Version Requirements +# ============================================================================ + +MIN_NODE_VERSION=22 +MIN_DOCKER_VERSION=24 +MIN_PNPM_VERSION=10 +MIN_POSTGRES_VERSION=17 + +# ============================================================================ +# Generic Command Checking +# ============================================================================ + +# Check if a command exists +check_command() { + command -v "$1" &>/dev/null +} + +# Get version of a command (generic) +get_command_version() { + local cmd="$1" + local flag="${2:---version}" + + "$cmd" "$flag" 2>/dev/null | head -1 +} + +# Extract major version number from version string +extract_major_version() { + local version="$1" + echo "$version" | grep -oE '[0-9]+' | head -1 +} + +# ============================================================================ +# Git +# ============================================================================ + +check_git() { + if check_command git; then + local version + version=$(git --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + echo -e "${SUCCESS}✓${NC} Git: ${INFO}$version${NC}" + return 0 + fi + echo -e "${WARN}→${NC} Git not found" + return 1 +} + +install_git() { + local os pkg + os=$(detect_os) + pkg=$(detect_package_manager "$os") + + echo -e "${WARN}→${NC} Installing Git..." + + case "$pkg" in + brew) + brew install git + ;; + apt) + maybe_sudo apt-get update -y + maybe_sudo apt-get install -y git + ;; + pacman) + maybe_sudo pacman -Sy --noconfirm git + ;; + dnf) + maybe_sudo dnf install -y git + ;; + yum) + maybe_sudo yum install -y git + ;; + *) + echo -e "${ERROR}Error: Unknown package manager for Git installation${NC}" + return 1 + ;; + esac + + echo -e "${SUCCESS}✓${NC} Git installed" +} + +ensure_git() { + if ! check_git; then + install_git + fi +} + +# ============================================================================ +# Homebrew (macOS) +# ============================================================================ + +check_homebrew() { + if check_command brew; then + local prefix + prefix=$(brew --prefix 2>/dev/null) + echo -e "${SUCCESS}✓${NC} Homebrew: ${INFO}$prefix${NC}" + return 0 + fi + return 1 +} + +install_homebrew() { + echo -e "${WARN}→${NC} Installing Homebrew..." + + # Download and run the Homebrew installer + local tmp + tmp=$(create_temp_file) + + if ! download_file "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" "$tmp"; then + echo -e "${ERROR}Error: Failed to download Homebrew installer${NC}" + return 1 + fi + + NONINTERACTIVE=1 /bin/bash "$tmp" + local ret=$? + rm -f "$tmp" + + if [[ $ret -ne 0 ]]; then + echo -e "${ERROR}Error: Homebrew installation failed${NC}" + return 1 + fi + + # Add Homebrew to PATH for this session + if [[ -f "/opt/homebrew/bin/brew" ]]; then + eval "$(/opt/homebrew/bin/brew shellenv)" + elif [[ -f "/usr/local/bin/brew" ]]; then + eval "$(/usr/local/bin/brew shellenv)" + fi + + echo -e "${SUCCESS}✓${NC} Homebrew installed" +} + +ensure_homebrew() { + local os + os=$(detect_os) + + if [[ "$os" != "macos" ]]; then + return 0 + fi + + if ! check_homebrew; then + install_homebrew + fi +} + +# ============================================================================ +# Node.js +# ============================================================================ + +check_node() { + local min_version="${1:-$MIN_NODE_VERSION}" + + if ! check_command node; then + echo -e "${WARN}→${NC} Node.js not found" + return 1 + fi + + local version + version=$(node --version 2>/dev/null | sed 's/v//') + local major + major=$(extract_major_version "$version") + + if [[ "$major" -ge "$min_version" ]]; then + echo -e "${SUCCESS}✓${NC} Node.js: ${INFO}v$version${NC}" + return 0 + else + echo -e "${WARN}→${NC} Node.js v$version found, but v${min_version}+ required" + return 1 + fi +} + +install_node_macos() { + echo -e "${WARN}→${NC} Installing Node.js via Homebrew..." + + ensure_homebrew + + # Install node@22 + brew install node@22 + + # Link it + brew link node@22 --overwrite --force 2>/dev/null || true + + # Ensure it's on PATH + local prefix + prefix=$(brew --prefix node@22 2>/dev/null) + if [[ -n "$prefix" && -d "${prefix}/bin" ]]; then + export PATH="${prefix}/bin:$PATH" + fi + + echo -e "${SUCCESS}✓${NC} Node.js installed" +} + +install_node_debian() { + echo -e "${WARN}→${NC} Installing Node.js via NodeSource..." + + require_sudo + + local tmp + tmp=$(create_temp_file) + + # Download NodeSource setup script for Node.js 22 + if ! download_file "https://deb.nodesource.com/setup_22.x" "$tmp"; then + echo -e "${ERROR}Error: Failed to download NodeSource setup script${NC}" + return 1 + fi + + maybe_sudo -E bash "$tmp" + maybe_sudo apt-get install -y nodejs + + rm -f "$tmp" + echo -e "${SUCCESS}✓${NC} Node.js installed" +} + +install_node_fedora() { + echo -e "${WARN}→${NC} Installing Node.js via NodeSource..." + + require_sudo + + local tmp + tmp=$(create_temp_file) + + if ! download_file "https://rpm.nodesource.com/setup_22.x" "$tmp"; then + echo -e "${ERROR}Error: Failed to download NodeSource setup script${NC}" + return 1 + fi + + maybe_sudo bash "$tmp" + + if command -v dnf &>/dev/null; then + maybe_sudo dnf install -y nodejs + else + maybe_sudo yum install -y nodejs + fi + + rm -f "$tmp" + echo -e "${SUCCESS}✓${NC} Node.js installed" +} + +install_node_arch() { + echo -e "${WARN}→${NC} Installing Node.js via pacman..." + + maybe_sudo pacman -Sy --noconfirm nodejs npm + echo -e "${SUCCESS}✓${NC} Node.js installed" +} + +install_node() { + local os + os=$(detect_os) + + case "$os" in + macos) + install_node_macos + ;; + debian) + install_node_debian + ;; + arch) + install_node_arch + ;; + fedora) + install_node_fedora + ;; + *) + echo -e "${ERROR}Error: Unsupported OS for Node.js installation: $os${NC}" + echo "Please install Node.js ${MIN_NODE_VERSION}+ manually: https://nodejs.org" + return 1 + ;; + esac +} + +ensure_node() { + if ! check_node; then + install_node + fi +} + +# ============================================================================ +# pnpm +# ============================================================================ + +check_pnpm() { + if check_command pnpm; then + local version + version=$(pnpm --version 2>/dev/null) + local major + major=$(extract_major_version "$version") + + if [[ "$major" -ge "$MIN_PNPM_VERSION" ]]; then + echo -e "${SUCCESS}✓${NC} pnpm: ${INFO}v$version${NC}" + return 0 + else + echo -e "${WARN}→${NC} pnpm v$version found, but v${MIN_PNPM_VERSION}+ recommended" + return 1 + fi + fi + echo -e "${WARN}→${NC} pnpm not found" + return 1 +} + +install_pnpm() { + # Try corepack first (comes with Node.js 22+) + if check_command corepack; then + echo -e "${WARN}→${NC} Installing pnpm via Corepack..." + corepack enable 2>/dev/null || true + corepack prepare pnpm@${MIN_PNPM_VERSION} --activate + echo -e "${SUCCESS}✓${NC} pnpm installed via Corepack" + return 0 + fi + + # Fall back to npm + echo -e "${WARN}→${NC} Installing pnpm via npm..." + + # Fix npm permissions on Linux first + local os + os=$(detect_os) + if [[ "$os" != "macos" ]]; then + fix_npm_permissions + fi + + npm install -g pnpm@${MIN_PNPM_VERSION} + echo -e "${SUCCESS}✓${NC} pnpm installed via npm" +} + +ensure_pnpm() { + if ! check_pnpm; then + install_pnpm + fi +} + +# ============================================================================ +# npm Permissions (Linux) +# ============================================================================ + +fix_npm_permissions() { + local os + os=$(detect_os) + + if [[ "$os" == "macos" ]]; then + return 0 + fi + + local npm_prefix + npm_prefix=$(npm config get prefix 2>/dev/null || true) + + if [[ -z "$npm_prefix" ]]; then + return 0 + fi + + # Check if we can write to the npm prefix + if [[ -w "$npm_prefix" || -w "${npm_prefix}/lib" ]]; then + return 0 + fi + + echo -e "${WARN}→${NC} Configuring npm for user-local installs..." + + # Create user-local npm directory + mkdir -p "$HOME/.npm-global" + + # Configure npm to use it + npm config set prefix "$HOME/.npm-global" + + # Add to shell config + local rc + for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do + if [[ -f "$rc" ]]; then + # shellcheck disable=SC2016 + if ! grep -q ".npm-global" "$rc" 2>/dev/null; then + echo "" >> "$rc" + echo "# Added by Mosaic Stack installer" >> "$rc" + echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> "$rc" + fi + fi + done + + # Update PATH for current session + export PATH="$HOME/.npm-global/bin:$PATH" + + echo -e "${SUCCESS}✓${NC} npm configured for user installs" +} + +# Get npm global bin directory +npm_global_bin_dir() { + local prefix + prefix=$(npm prefix -g 2>/dev/null || true) + + if [[ -n "$prefix" && "$prefix" == /* ]]; then + echo "${prefix%/}/bin" + return 0 + fi + + prefix=$(npm config get prefix 2>/dev/null || true) + if [[ -n "$prefix" && "$prefix" != "undefined" && "$prefix" != "null" && "$prefix" == /* ]]; then + echo "${prefix%/}/bin" + return 0 + fi + + return 1 +} + +# ============================================================================ +# Docker +# ============================================================================ + +check_docker() { + if ! check_command docker; then + echo -e "${WARN}→${NC} Docker not found" + return 1 + fi + + # Check if daemon is accessible + if ! docker info &>/dev/null; then + local error_msg + error_msg=$(docker info 2>&1) + + if [[ "$error_msg" =~ "permission denied" ]]; then + echo -e "${WARN}→${NC} Docker installed but permission denied" + echo -e " ${INFO}Fix: sudo usermod -aG docker \$USER${NC}" + echo -e " Then log out and back in" + return 2 + elif [[ "$error_msg" =~ "Cannot connect to the Docker daemon" ]]; then + echo -e "${WARN}→${NC} Docker installed but daemon not running" + return 3 + else + echo -e "${WARN}→${NC} Docker installed but not accessible" + return 4 + fi + fi + + local version + version=$(docker --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + local major + major=$(extract_major_version "$version") + + if [[ "$major" -ge "$MIN_DOCKER_VERSION" ]]; then + echo -e "${SUCCESS}✓${NC} Docker: ${INFO}$version${NC}" + return 0 + else + echo -e "${WARN}→${NC} Docker v$version found, but v${MIN_DOCKER_VERSION}+ recommended" + return 1 + fi +} + +check_docker_compose() { + # Check for docker compose plugin first + if docker compose version &>/dev/null; then + local version + version=$(docker compose version --short 2>/dev/null || docker compose version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + echo -e "${SUCCESS}✓${NC} Docker Compose: ${INFO}$version (plugin)${NC}" + return 0 + fi + + # Check for standalone docker-compose + if check_command docker-compose; then + local version + version=$(docker-compose --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + echo -e "${SUCCESS}✓${NC} Docker Compose: ${INFO}$version (standalone)${NC}" + return 0 + fi + + echo -e "${WARN}→${NC} Docker Compose not found" + return 1 +} + +install_docker_macos() { + echo -e "${WARN}→${NC} Installing Docker Desktop for macOS..." + + ensure_homebrew + + brew install --cask docker + + echo -e "${SUCCESS}✓${NC} Docker Desktop installed" + echo -e "${INFO}i${NC} Please open Docker Desktop to complete setup" +} + +install_docker_debian() { + echo -e "${WARN}→${NC} Installing Docker via docker.com apt repo..." + + require_sudo + + # Install dependencies + maybe_sudo apt-get update + maybe_sudo apt-get install -y ca-certificates curl gnupg + + # Add Docker's official GPG key + local keyring="/usr/share/keyrings/docker-archive-keyring.gpg" + maybe_sudo install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/debian/gpg | maybe_sudo gpg --dearmor -o "$keyring" + maybe_sudo chmod a+r "$keyring" + + # Add Docker repository + echo "deb [arch=$(dpkg --print-architecture) signed-by=$keyring] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | \ + maybe_sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + + # Install Docker + maybe_sudo apt-get update + maybe_sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + + # Start Docker + maybe_sudo systemctl enable --now docker + + # Add user to docker group + maybe_sudo usermod -aG docker "$USER" + + echo -e "${SUCCESS}✓${NC} Docker installed" + echo -e "${INFO}i${NC} Run 'newgrp docker' or log out/in for group membership" +} + +install_docker_ubuntu() { + echo -e "${WARN}→${NC} Installing Docker via docker.com apt repo..." + + require_sudo + + # Install dependencies + maybe_sudo apt-get update + maybe_sudo apt-get install -y ca-certificates curl gnupg + + # Add Docker's official GPG key + local keyring="/usr/share/keyrings/docker-archive-keyring.gpg" + maybe_sudo install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | maybe_sudo gpg --dearmor -o "$keyring" + maybe_sudo chmod a+r "$keyring" + + # Add Docker repository + echo "deb [arch=$(dpkg --print-architecture) signed-by=$keyring] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \ + maybe_sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + + # Install Docker + maybe_sudo apt-get update + maybe_sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + + # Start Docker + maybe_sudo systemctl enable --now docker + + # Add user to docker group + maybe_sudo usermod -aG docker "$USER" + + echo -e "${SUCCESS}✓${NC} Docker installed" + echo -e "${INFO}i${NC} Run 'newgrp docker' or log out/in for group membership" +} + +install_docker_fedora() { + echo -e "${WARN}→${NC} Installing Docker via dnf..." + + require_sudo + + # Add Docker repository + maybe_sudo dnf -y install dnf-plugins-core + maybe_sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo + + # Install Docker + maybe_sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin + + # Start Docker + maybe_sudo systemctl enable --now docker + + # Add user to docker group + maybe_sudo usermod -aG docker "$USER" + + echo -e "${SUCCESS}✓${NC} Docker installed" + echo -e "${INFO}i${NC} Run 'newgrp docker' or log out/in for group membership" +} + +install_docker_arch() { + echo -e "${WARN}→${NC} Installing Docker via pacman..." + + maybe_sudo pacman -Sy --noconfirm docker docker-compose + + # Start Docker + maybe_sudo systemctl enable --now docker + + # Add user to docker group + maybe_sudo usermod -aG docker "$USER" + + echo -e "${SUCCESS}✓${NC} Docker installed" + echo -e "${INFO}i${NC} Run 'newgrp docker' or log out/in for group membership" +} + +install_docker() { + local os + os=$(detect_os) + + case "$os" in + macos) + install_docker_macos + ;; + debian) + # Check if Ubuntu specifically + if [[ -f /etc/os-release ]]; then + source /etc/os-release + if [[ "$ID" == "ubuntu" ]]; then + install_docker_ubuntu + return $? + fi + fi + install_docker_debian + ;; + arch) + install_docker_arch + ;; + fedora) + install_docker_fedora + ;; + *) + echo -e "${ERROR}Error: Unsupported OS for Docker installation: $os${NC}" + echo "Please install Docker manually: https://docs.docker.com/get-docker/" + return 1 + ;; + esac +} + +ensure_docker() { + local check_result + check_docker + check_result=$? + + if [[ $check_result -eq 0 ]]; then + return 0 + fi + + if [[ $check_result -eq 2 ]]; then + # Permission issue - try to fix + echo -e "${WARN}→${NC} Attempting to fix Docker permissions..." + maybe_sudo usermod -aG docker "$USER" + echo -e "${INFO}i${NC} Run 'newgrp docker' or log out/in for group membership" + return 1 + fi + + if [[ $check_result -eq 3 ]]; then + # Daemon not running - try to start + echo -e "${WARN}→${NC} Starting Docker daemon..." + maybe_sudo systemctl start docker + sleep 3 + check_docker + return $? + fi + + # Docker not installed + install_docker +} + +start_docker() { + local os + os=$(detect_os) + + if [[ "$os" == "macos" ]]; then + if ! pgrep -x "Docker Desktop" &>/dev/null; then + echo -e "${WARN}→${NC} Starting Docker Desktop..." + open -a "Docker Desktop" + sleep 10 + fi + else + if ! docker info &>/dev/null; then + echo -e "${WARN}→${NC} Starting Docker daemon..." + maybe_sudo systemctl start docker + sleep 3 + fi + fi +} + +# ============================================================================ +# PostgreSQL +# ============================================================================ + +check_postgres() { + if check_command psql; then + local version + version=$(psql --version 2>/dev/null | grep -oE '[0-9]+') + echo -e "${SUCCESS}✓${NC} PostgreSQL: ${INFO}v$version${NC}" + return 0 + fi + echo -e "${WARN}→${NC} PostgreSQL not found" + return 1 +} + +install_postgres_macos() { + echo -e "${WARN}→${NC} Installing PostgreSQL via Homebrew..." + + ensure_homebrew + + brew install postgresql@17 + brew link postgresql@17 --overwrite --force 2>/dev/null || true + + # Start PostgreSQL + brew services start postgresql@17 + + echo -e "${SUCCESS}✓${NC} PostgreSQL installed" +} + +install_postgres_debian() { + echo -e "${WARN}→${NC} Installing PostgreSQL via apt..." + + require_sudo + + # Add PostgreSQL APT repository + maybe_sudo apt-get install -y curl ca-certificates + maybe_sudo install -d /usr/share/postgresql-common/pgdg + curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | maybe_sudo gpg --dearmor -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg + + echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" | \ + maybe_sudo tee /etc/apt/sources.list.d/pgdg.list > /dev/null + + maybe_sudo apt-get update + maybe_sudo apt-get install -y postgresql-17 postgresql-17-pgvector + + # Start PostgreSQL + maybe_sudo systemctl enable --now postgresql + + echo -e "${SUCCESS}✓${NC} PostgreSQL installed" +} + +install_postgres_arch() { + echo -e "${WARN}→${NC} Installing PostgreSQL via pacman..." + + maybe_sudo pacman -Sy --noconfirm postgresql + + # Initialize database + maybe_sudo -u postgres initdb -D /var/lib/postgres/data 2>/dev/null || true + + # Start PostgreSQL + maybe_sudo systemctl enable --now postgresql + + echo -e "${SUCCESS}✓${NC} PostgreSQL installed" +} + +install_postgres_fedora() { + echo -e "${WARN}→${NC} Installing PostgreSQL via dnf..." + + maybe_sudo dnf install -y postgresql-server postgresql-contrib + + # Initialize database + maybe_sudo postgresql-setup --initdb 2>/dev/null || true + + # Start PostgreSQL + maybe_sudo systemctl enable --now postgresql + + echo -e "${SUCCESS}✓${NC} PostgreSQL installed" +} + +install_postgres() { + local os + os=$(detect_os) + + case "$os" in + macos) + install_postgres_macos + ;; + debian) + install_postgres_debian + ;; + arch) + install_postgres_arch + ;; + fedora) + install_postgres_fedora + ;; + *) + echo -e "${ERROR}Error: Unsupported OS for PostgreSQL installation: $os${NC}" + echo "Please install PostgreSQL ${MIN_POSTGRES_VERSION}+ manually" + return 1 + ;; + esac +} + +# ============================================================================ +# Dependency Summary +# ============================================================================ + +# Check all dependencies for Docker mode +check_docker_dependencies() { + local errors=0 + + echo -e "${BOLD}Checking Docker dependencies...${NC}" + echo "" + + # Git (optional but recommended) + check_git || ((errors++)) + + # Docker + local docker_result + check_docker + docker_result=$? + if [[ $docker_result -ne 0 ]]; then + ((errors++)) + fi + + # Docker Compose + if [[ $docker_result -eq 0 ]]; then + check_docker_compose || ((errors++)) + fi + + echo "" + + if [[ $errors -gt 0 ]]; then + return 1 + fi + + return 0 +} + +# Check all dependencies for Native mode +check_native_dependencies() { + local errors=0 + + echo -e "${BOLD}Checking native dependencies...${NC}" + echo "" + + check_git || ((errors++)) + check_node || ((errors++)) + check_pnpm || ((errors++)) + check_postgres || ((errors++)) + + echo "" + + if [[ $errors -gt 0 ]]; then + return 1 + fi + + return 0 +} + +# Install missing dependencies based on mode +install_dependencies() { + local mode="$1" + + echo -e "${BOLD}Installing dependencies...${NC}" + echo "" + + ensure_git + + if [[ "$mode" == "docker" ]]; then + ensure_docker + start_docker + else + ensure_node + ensure_pnpm + + local os + os=$(detect_os) + if [[ "$os" != "macos" ]]; then + fix_npm_permissions + fi + + # PostgreSQL is optional for native mode (can use Docker or external) + if ! check_postgres; then + echo -e "${INFO}i${NC} PostgreSQL not installed - you can use Docker or external database" + fi + fi + + echo "" + echo -e "${SUCCESS}✓${NC} Dependencies installed" +} + +# ============================================================================ +# Package Name Mapping +# ============================================================================ + +# Get platform-specific package name +get_package_name() { + local pkg_manager="$1" + local package="$2" + + case "$pkg_manager" in + apt) + case "$package" in + docker) echo "docker-ce" ;; + docker-compose) echo "docker-compose-plugin" ;; + node) echo "nodejs" ;; + postgres) echo "postgresql-17" ;; + *) echo "$package" ;; + esac + ;; + pacman) + case "$package" in + docker) echo "docker" ;; + docker-compose) echo "docker-compose" ;; + node) echo "nodejs" ;; + postgres) echo "postgresql" ;; + *) echo "$package" ;; + esac + ;; + dnf) + case "$package" in + docker) echo "docker-ce" ;; + docker-compose) echo "docker-compose-plugin" ;; + node) echo "nodejs" ;; + postgres) echo "postgresql-server" ;; + *) echo "$package" ;; + esac + ;; + brew) + case "$package" in + docker) echo "docker" ;; + node) echo "node@22" ;; + postgres) echo "postgresql@17" ;; + *) echo "$package" ;; + esac + ;; + *) + echo "$package" + ;; + esac +} diff --git a/scripts/lib/docker.sh b/scripts/lib/docker.sh new file mode 100644 index 0000000..74a399d --- /dev/null +++ b/scripts/lib/docker.sh @@ -0,0 +1,491 @@ +#!/bin/bash +# Docker-specific functions for Mosaic Stack installer +# Handles Docker Compose operations, health checks, and service management + +# shellcheck source=lib/platform.sh +source "${BASH_SOURCE[0]%/*}/platform.sh" + +# ============================================================================ +# Docker Compose Helpers +# ============================================================================ + +# Get the docker compose command (handles both plugin and standalone) +docker_compose_cmd() { + if docker compose version &>/dev/null; then + echo "docker compose" + elif command -v docker-compose &>/dev/null; then + echo "docker-compose" + else + return 1 + fi +} + +# Run docker compose with all arguments +docker_compose() { + local cmd + cmd=$(docker_compose_cmd) || { + echo -e "${ERROR}Error: Docker Compose not available${NC}" + return 1 + } + + # shellcheck disable=SC2086 + $cmd "$@" +} + +# ============================================================================ +# Service Management +# ============================================================================ + +# Pull all images defined in docker-compose.yml +docker_pull_images() { + local compose_file="${1:-docker-compose.yml}" + local env_file="${2:-.env}" + + echo -e "${WARN}→${NC} Pulling Docker images..." + + if [[ -f "$env_file" ]]; then + docker_compose -f "$compose_file" --env-file "$env_file" pull + else + docker_compose -f "$compose_file" pull + fi +} + +# Start services with Docker Compose +docker_compose_up() { + local compose_file="${1:-docker-compose.yml}" + local env_file="${2:-.env}" + local profiles="${3:-}" + local detached="${4:-true}" + + echo -e "${WARN}→${NC} Starting services..." + + local args=("-f" "$compose_file") + + if [[ -f "$env_file" ]]; then + args+=("--env-file" "$env_file") + fi + + if [[ -n "$profiles" ]]; then + args+=("--profile" "$profiles") + fi + + if [[ "$detached" == "true" ]]; then + args+=("up" "-d") + else + args+=("up") + fi + + docker_compose "${args[@]}" +} + +# Stop services +docker_compose_down() { + local compose_file="${1:-docker-compose.yml}" + local env_file="${2:-.env}" + + echo -e "${WARN}→${NC} Stopping services..." + + if [[ -f "$env_file" ]]; then + docker_compose -f "$compose_file" --env-file "$env_file" down + else + docker_compose -f "$compose_file" down + fi +} + +# Restart services +docker_compose_restart() { + local compose_file="${1:-docker-compose.yml}" + local env_file="${2:-.env}" + + echo -e "${WARN}→${NC} Restarting services..." + + if [[ -f "$env_file" ]]; then + docker_compose -f "$compose_file" --env-file "$env_file" restart + else + docker_compose -f "$compose_file" restart + fi +} + +# ============================================================================ +# Health Checks +# ============================================================================ + +# Wait for a container to be healthy +wait_for_healthy_container() { + local container_name="$1" + local timeout="${2:-120}" + local interval="${3:-5}" + + echo -e "${INFO}i${NC} Waiting for ${INFO}$container_name${NC} to be healthy..." + + local elapsed=0 + while [[ $elapsed -lt $timeout ]]; do + local status + status=$(docker inspect --format='{{.State.Health.Status}}' "$container_name" 2>/dev/null || echo "not_found") + + case "$status" in + healthy) + echo -e "${SUCCESS}✓${NC} $container_name is healthy" + return 0 + ;; + unhealthy) + echo -e "${ERROR}✗${NC} $container_name is unhealthy" + return 1 + ;; + not_found) + echo -e "${WARN}→${NC} Container $container_name not found" + return 1 + ;; + esac + + sleep "$interval" + ((elapsed += interval)) + done + + echo -e "${ERROR}✗${NC} Timeout waiting for $container_name to be healthy" + return 1 +} + +# Wait for multiple containers to be healthy +wait_for_healthy_containers() { + local containers=("$@") + local timeout="${containers[-1]}" + unset 'containers[-1]' + + for container in "${containers[@]}"; do + if ! wait_for_healthy_container "$container" "$timeout"; then + return 1 + fi + done + + return 0 +} + +# Wait for a service to respond on a port +wait_for_service() { + local host="$1" + local port="$2" + local name="$3" + local timeout="${4:-60}" + + echo -e "${INFO}i${NC} Waiting for ${INFO}$name${NC} at $host:$port..." + + local elapsed=0 + while [[ $elapsed -lt $timeout ]]; do + if docker run --rm --network host alpine:latest nc -z "$host" "$port" 2>/dev/null; then + echo -e "${SUCCESS}✓${NC} $name is responding" + return 0 + fi + + sleep 2 + ((elapsed += 2)) + done + + echo -e "${ERROR}✗${NC} Timeout waiting for $name" + return 1 +} + +# ============================================================================ +# Container Status +# ============================================================================ + +# Get container status +get_container_status() { + local container_name="$1" + + docker inspect --format='{{.State.Status}}' "$container_name" 2>/dev/null || echo "not_found" +} + +# Check if container is running +is_container_running() { + local container_name="$1" + local status + status=$(get_container_status "$container_name") + [[ "$status" == "running" ]] +} + +# List all Mosaic containers +list_mosaic_containers() { + docker ps -a --filter "name=mosaic-" --format "{{.Names}}\t{{.Status}}" +} + +# Get container logs +get_container_logs() { + local container_name="$1" + local lines="${2:-100}" + + docker logs --tail "$lines" "$container_name" 2>&1 +} + +# Tail container logs +tail_container_logs() { + local container_name="$1" + + docker logs -f "$container_name" +} + +# ============================================================================ +# Database Operations +# ============================================================================ + +# Wait for PostgreSQL to be ready +wait_for_postgres() { + local container_name="${1:-mosaic-postgres}" + local user="${2:-mosaic}" + local database="${3:-mosaic}" + local timeout="${4:-60}" + + echo -e "${INFO}i${NC} Waiting for PostgreSQL to be ready..." + + local elapsed=0 + while [[ $elapsed -lt $timeout ]]; do + if docker exec "$container_name" pg_isready -U "$user" -d "$database" &>/dev/null; then + echo -e "${SUCCESS}✓${NC} PostgreSQL is ready" + return 0 + fi + + sleep 2 + ((elapsed += 2)) + done + + echo -e "${ERROR}✗${NC} Timeout waiting for PostgreSQL" + return 1 +} + +# Run database migrations +run_database_migrations() { + local api_container="${1:-mosaic-api}" + + echo -e "${WARN}→${NC} Running database migrations..." + + if ! docker exec "$api_container" npx prisma migrate deploy &>/dev/null; then + echo -e "${WARN}→${NC} Could not run migrations via API container" + echo -e "${INFO}i${NC} Migrations will run automatically when API starts" + return 0 + fi + + echo -e "${SUCCESS}✓${NC} Database migrations complete" +} + +# ============================================================================ +# Service URLs +# ============================================================================ + +# Get the URL for a service +get_service_url() { + local service="$1" + local port="${2:-}" + + local host="localhost" + + # Check if we're in WSL and need to use Windows host + if is_wsl; then + host=$(cat /etc/resolv.conf 2>/dev/null | grep nameserver | awk '{print $2}' | head -1) + fi + + if [[ -n "$port" ]]; then + echo "http://${host}:${port}" + else + echo "http://${host}" + fi +} + +# Get all service URLs +get_all_service_urls() { + local env_file="${1:-.env}" + + declare -A urls=() + + if [[ -f "$env_file" ]]; then + # shellcheck source=/dev/null + source "$env_file" + fi + + urls[web]="http://localhost:${WEB_PORT:-3000}" + urls[api]="http://localhost:${API_PORT:-3001}" + urls[postgres]="localhost:${POSTGRES_PORT:-5432}" + urls[valkey]="localhost:${VALKEY_PORT:-6379}" + + if [[ "${OIDC_ENABLED:-false}" == "true" ]]; then + urls[authentik]="http://localhost:${AUTHENTIK_PORT_HTTP:-9000}" + fi + + if [[ "${OLLAMA_MODE:-disabled}" != "disabled" ]]; then + urls[ollama]="http://localhost:${OLLAMA_PORT:-11434}" + fi + + for service in "${!urls[@]}"; do + echo "$service: ${urls[$service]}" + done +} + +# ============================================================================ +# Docker Cleanup +# ============================================================================ + +# Remove unused Docker resources +docker_cleanup() { + echo -e "${WARN}→${NC} Cleaning up unused Docker resources..." + + # Remove dangling images + docker image prune -f + + # Remove unused networks + docker network prune -f + + echo -e "${SUCCESS}✓${NC} Docker cleanup complete" +} + +# Remove all Mosaic containers and volumes +docker_remove_all() { + local compose_file="${1:-docker-compose.yml}" + local env_file="${2:-.env}" + + echo -e "${WARN}→${NC} Removing all Mosaic containers and volumes..." + + if [[ -f "$env_file" ]]; then + docker_compose -f "$compose_file" --env-file "$env_file" down -v --remove-orphans + else + docker_compose -f "$compose_file" down -v --remove-orphans + fi + + echo -e "${SUCCESS}✓${NC} All containers and volumes removed" +} + +# ============================================================================ +# Docker Info +# ============================================================================ + +# Print Docker system info +print_docker_info() { + echo -e "${BOLD}Docker Information:${NC}" + echo "" + + echo -e " Docker Version:" + docker --version 2>/dev/null | sed 's/^/ /' + + echo "" + echo -e " Docker Compose:" + docker_compose version 2>/dev/null | sed 's/^/ /' + + echo "" + echo -e " Docker Storage:" + docker system df 2>/dev/null | sed 's/^/ /' + + echo "" + echo -e " Running Containers:" + docker ps --format " {{.Names}}\t{{.Status}}" 2>/dev/null | head -10 +} + +# ============================================================================ +# Volume Management +# ============================================================================ + +# List all Mosaic volumes +list_mosaic_volumes() { + docker volume ls --filter "name=mosaic" --format "{{.Name}}" +} + +# Backup a Docker volume +backup_volume() { + local volume_name="$1" + local backup_file="${2:-${volume_name}-backup-$(date +%Y%m%d-%H%M%S).tar.gz}" + + echo -e "${WARN}→${NC} Backing up volume ${INFO}$volume_name${NC}..." + + docker run --rm \ + -v "$volume_name":/source:ro \ + -v "$(pwd)":/backup \ + alpine:latest \ + tar czf "/backup/$backup_file" -C /source . + + echo -e "${SUCCESS}✓${NC} Backup created: $backup_file" +} + +# Restore a Docker volume +restore_volume() { + local volume_name="$1" + local backup_file="$2" + + echo -e "${WARN}→${NC} Restoring volume ${INFO}$volume_name${NC} from $backup_file..." + + # Create volume if it doesn't exist + docker volume create "$volume_name" &>/dev/null || true + + docker run --rm \ + -v "$volume_name":/target \ + -v "$(pwd)":/backup \ + alpine:latest \ + tar xzf "/backup/$backup_file" -C /target + + echo -e "${SUCCESS}✓${NC} Volume restored" +} + +# ============================================================================ +# Network Management +# ============================================================================ + +# Create a Docker network if it doesn't exist +ensure_network() { + local network_name="$1" + + if ! docker network inspect "$network_name" &>/dev/null; then + echo -e "${WARN}→${NC} Creating network ${INFO}$network_name${NC}..." + docker network create "$network_name" + echo -e "${SUCCESS}✓${NC} Network created" + fi +} + +# Check if a network exists +network_exists() { + local network_name="$1" + docker network inspect "$network_name" &>/dev/null +} + +# ============================================================================ +# Build Operations +# ============================================================================ + +# Build Docker images +docker_build() { + local compose_file="${1:-docker-compose.yml}" + local env_file="${2:-.env}" + local parallel="${3:-true}" + + echo -e "${WARN}→${NC} Building Docker images..." + + local args=("-f" "$compose_file") + + if [[ -f "$env_file" ]]; then + args+=("--env-file" "$env_file") + fi + + args+=("build") + + if [[ "$parallel" == "true" ]]; then + args+=("--parallel") + fi + + docker_compose "${args[@]}" +} + +# Check if buildx is available +check_buildx() { + docker buildx version &>/dev/null +} + +# Set up buildx builder +setup_buildx() { + if ! check_buildx; then + echo -e "${WARN}→${NC} buildx not available" + return 1 + fi + + # Create or use existing builder + if ! docker buildx inspect mosaic-builder &>/dev/null; then + echo -e "${WARN}→${NC} Creating buildx builder..." + docker buildx create --name mosaic-builder --use + else + docker buildx use mosaic-builder + fi +} diff --git a/scripts/lib/platform.sh b/scripts/lib/platform.sh new file mode 100644 index 0000000..09753d0 --- /dev/null +++ b/scripts/lib/platform.sh @@ -0,0 +1,665 @@ +#!/bin/bash +# Platform detection functions for Mosaic Stack installer +# Provides OS, package manager, architecture, and environment detection + +# ============================================================================ +# Colors (if terminal supports them) +# ============================================================================ + +if [[ -t 1 ]]; then + BOLD='\033[1m' + ACCENT='\033[38;2;128;90;213m' + SUCCESS='\033[38;2;47;191;113m' + WARN='\033[38;2;255;176;32m' + ERROR='\033[38;2;226;61;45m' + INFO='\033[38;2;100;149;237m' + MUTED='\033[38;2;139;127;119m' + NC='\033[0m' +else + BOLD='' + ACCENT='' + SUCCESS='' + WARN='' + ERROR='' + INFO='' + MUTED='' + NC='' +fi + +# ============================================================================ +# OS Detection +# ============================================================================ + +# Detect operating system type +# Returns: macos, debian, arch, fedora, linux, unknown +detect_os() { + case "$OSTYPE" in + darwin*) + echo "macos" + return 0 + ;; + linux-gnu*) + if [[ -f /etc/os-release ]]; then + # shellcheck source=/dev/null + source /etc/os-release + case "$ID" in + ubuntu|debian|linuxmint|pop|elementary) + echo "debian" + return 0 + ;; + arch|manjaro|endeavouros|garuda|arcolinux) + echo "arch" + return 0 + ;; + fedora|rhel|centos|rocky|almalinux|ol) + echo "fedora" + return 0 + ;; + *) + echo "linux" + return 0 + ;; + esac + else + echo "linux" + return 0 + fi + ;; + *) + echo "unknown" + return 1 + ;; + esac +} + +# Detect if running under WSL (Windows Subsystem for Linux) +# Returns WSL_DISTRO_NAME if in WSL, empty string otherwise +detect_wsl() { + if [[ -n "${WSL_DISTRO_NAME:-}" ]]; then + echo "$WSL_DISTRO_NAME" + return 0 + fi + + # Check for WSL in /proc/version + if [[ -f /proc/version ]]; then + if grep -qi "microsoft\|wsl" /proc/version 2>/dev/null; then + # Try to get distro name from os-release + if [[ -f /etc/os-release ]]; then + # shellcheck source=/dev/null + source /etc/os-release + echo "${NAME:-WSL}" + return 0 + fi + echo "WSL" + return 0 + fi + fi + + return 1 +} + +# Check if running in WSL +is_wsl() { + [[ -n "${WSL_DISTRO_NAME:-}" ]] && return 0 + [[ -f /proc/version ]] && grep -qi "microsoft\|wsl" /proc/version 2>/dev/null +} + +# Get human-readable OS name +get_os_name() { + local os="$1" + + case "$os" in + macos) + echo "macOS" + ;; + debian) + echo "Debian/Ubuntu" + ;; + arch) + echo "Arch Linux" + ;; + fedora) + echo "Fedora/RHEL" + ;; + linux) + echo "Linux" + ;; + *) + echo "Unknown" + ;; + esac +} + +# ============================================================================ +# Package Manager Detection +# ============================================================================ + +# Detect the system's package manager +# Returns: brew, apt, pacman, dnf, yum, unknown +detect_package_manager() { + local os="$1" + + # First check for Homebrew (available on macOS and Linux) + if command -v brew &>/dev/null; then + echo "brew" + return 0 + fi + + # Fall back to OS-specific package managers + case "$os" in + macos) + # macOS without Homebrew + echo "none" + return 1 + ;; + debian) + if command -v apt-get &>/dev/null; then + echo "apt" + return 0 + fi + ;; + arch) + if command -v pacman &>/dev/null; then + echo "pacman" + return 0 + fi + ;; + fedora) + if command -v dnf &>/dev/null; then + echo "dnf" + return 0 + elif command -v yum &>/dev/null; then + echo "yum" + return 0 + fi + ;; + esac + + echo "unknown" + return 1 +} + +# ============================================================================ +# Architecture Detection +# ============================================================================ + +# Get system architecture +# Returns: x86_64, aarch64, armv7l, armv6l, unknown +get_arch() { + local arch + arch=$(uname -m) + + case "$arch" in + x86_64|amd64) + echo "x86_64" + ;; + aarch64|arm64) + echo "aarch64" + ;; + armv7l|armhf) + echo "armv7l" + ;; + armv6l) + echo "armv6l" + ;; + *) + echo "unknown" + ;; + esac +} + +# Check if running on Apple Silicon +is_apple_silicon() { + [[ "$(detect_os)" == "macos" ]] && [[ "$(get_arch)" == "aarch64" ]] +} + +# ============================================================================ +# Init System Detection +# ============================================================================ + +# Detect the init system +# Returns: systemd, openrc, launchd, sysvinit, unknown +detect_init_system() { + local os + os=$(detect_os) + + case "$os" in + macos) + echo "launchd" + return 0 + ;; + esac + + # Check for systemd + if command -v systemctl &>/dev/null && pidof systemd &>/dev/null; then + echo "systemd" + return 0 + fi + + # Check for OpenRC + if command -v rc-status &>/dev/null; then + echo "openrc" + return 0 + fi + + # Check for SysVinit + if command -v service &>/dev/null; then + echo "sysvinit" + return 0 + fi + + echo "unknown" + return 1 +} + +# ============================================================================ +# Privilege Helpers +# ============================================================================ + +# Check if running as root +is_root() { + [[ "$(id -u)" -eq 0 ]] +} + +# Run command with sudo only if not already root +maybe_sudo() { + if is_root; then + # Skip -E flag when root (env is already preserved) + if [[ "${1:-}" == "-E" ]]; then + shift + fi + "$@" + else + sudo "$@" + fi +} + +# Ensure sudo is available (Linux only) +require_sudo() { + local os + os=$(detect_os) + + if [[ "$os" == "macos" ]]; then + return 0 + fi + + if is_root; then + return 0 + fi + + if command -v sudo &>/dev/null; then + return 0 + fi + + echo -e "${ERROR}Error: sudo is required for system installs on Linux${NC}" + echo "Install sudo or re-run as root." + return 1 +} + +# Validate sudo credentials (cache them early) +validate_sudo() { + if is_root; then + return 0 + fi + + if command -v sudo &>/dev/null; then + sudo -v 2>/dev/null + return $? + fi + + return 1 +} + +# ============================================================================ +# TTY and Interactive Detection +# ============================================================================ + +# Check if running in an interactive terminal +is_interactive() { + [[ -t 0 && -t 1 ]] +} + +# Check if we can prompt the user +is_promptable() { + [[ -r /dev/tty && -w /dev/tty ]] +} + +# Read input from TTY (for prompts when stdin is piped) +read_from_tty() { + local prompt="$1" + local var_name="$2" + + if is_promptable; then + echo -e "$prompt" > /dev/tty + read -r "$var_name" < /dev/tty + else + return 1 + fi +} + +# ============================================================================ +# Shell Detection +# ============================================================================ + +# Get current shell name +get_shell() { + basename "${SHELL:-/bin/sh}" +} + +# Get shell configuration file +get_shell_rc() { + local shell + shell=$(get_shell) + + case "$shell" in + zsh) + echo "$HOME/.zshrc" + ;; + bash) + echo "$HOME/.bashrc" + ;; + fish) + echo "$HOME/.config/fish/config.fish" + ;; + *) + echo "$HOME/.profile" + ;; + esac +} + +# ============================================================================ +# Network and Connectivity +# ============================================================================ + +# Check if we have internet connectivity +has_internet() { + local timeout="${1:-5}" + + # Try to reach common endpoints + if command -v curl &>/dev/null; then + curl -s --max-time "$timeout" https://api.github.com &>/dev/null + return $? + elif command -v wget &>/dev/null; then + wget -q --timeout="$timeout" --spider https://api.github.com + return $? + fi + + # Fall back to ping + ping -c 1 -W "$timeout" 8.8.8.8 &>/dev/null 2>&1 +} + +# Get local IP address +get_local_ip() { + local ip + + # Try hostname command first (macOS) + if command -v hostname &>/dev/null; then + ip=$(hostname -I 2>/dev/null | cut -d' ' -f1) + if [[ -n "$ip" ]]; then + echo "$ip" + return 0 + fi + fi + + # Try ip command (Linux) + if command -v ip &>/dev/null; then + ip=$(ip route get 1 2>/dev/null | awk '{print $7; exit}') + if [[ -n "$ip" ]]; then + echo "$ip" + return 0 + fi + fi + + # Try ifconfig + if command -v ifconfig &>/dev/null; then + ip=$(ifconfig 2>/dev/null | grep -Eo 'inet (addr:)?([0-9]*\.){3}[0-9]*' | grep -Eo '([0-9]*\.){3}[0-9]*' | grep -v '127.0.0.1' | head -1) + if [[ -n "$ip" ]]; then + echo "$ip" + return 0 + fi + fi + + return 1 +} + +# ============================================================================ +# System Resources +# ============================================================================ + +# Get total RAM in MB +get_total_ram() { + local os + os=$(detect_os) + + case "$os" in + macos) + sysctl -n hw.memsize 2>/dev/null | awk '{print int($1/1024/1024)}' + ;; + *) + if [[ -f /proc/meminfo ]]; then + awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo + else + echo "0" + fi + ;; + esac +} + +# Get available disk space in GB for a path +get_available_disk() { + local path="${1:-.}" + + if command -v df &>/dev/null; then + df -BG "$path" 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G' + else + echo "0" + fi +} + +# Check if system meets minimum requirements +check_minimum_requirements() { + local min_ram="${1:-2048}" # MB + local min_disk="${2:-10}" # GB + + local ram disk + ram=$(get_total_ram) + disk=$(get_available_disk "$HOME") + + local errors=() + + if [[ "$ram" -lt "$min_ram" ]]; then + errors+=("RAM: ${ram}MB (minimum: ${min_ram}MB)") + fi + + if [[ "$disk" -lt "$min_disk" ]]; then + errors+=("Disk: ${disk}GB (minimum: ${min_disk}GB)") + fi + + if [[ ${#errors[@]} -gt 0 ]]; then + echo "System does not meet minimum requirements:" + printf ' - %s\n' "${errors[@]}" + return 1 + fi + + return 0 +} + +# ============================================================================ +# Downloader Detection +# ============================================================================ + +DOWNLOADER="" + +detect_downloader() { + if command -v curl &>/dev/null; then + DOWNLOADER="curl" + return 0 + fi + if command -v wget &>/dev/null; then + DOWNLOADER="wget" + return 0 + fi + echo -e "${ERROR}Error: Missing downloader (curl or wget required)${NC}" + return 1 +} + +# Download a file securely +download_file() { + local url="$1" + local output="$2" + + if [[ -z "$DOWNLOADER" ]]; then + detect_downloader || return 1 + fi + + if [[ "$DOWNLOADER" == "curl" ]]; then + curl -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused -o "$output" "$url" + return $? + fi + + wget -q --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout=20 -O "$output" "$url" + return $? +} + +# Download and execute a script +run_remote_script() { + local url="$1" + local tmp + tmp=$(mktemp) + + if ! download_file "$url" "$tmp"; then + rm -f "$tmp" + return 1 + fi + + /bin/bash "$tmp" + local ret=$? + rm -f "$tmp" + return $ret +} + +# ============================================================================ +# PATH Management +# ============================================================================ + +# Store original PATH for later comparison +ORIGINAL_PATH="${PATH:-}" + +# Check if a directory is in PATH +path_has_dir() { + local path="$1" + local dir="${2%/}" + + [[ -z "$dir" ]] && return 1 + + case ":${path}:" in + *":${dir}:"*) return 0 ;; + *) return 1 ;; + esac +} + +# Add directory to PATH in shell config +add_to_path() { + local dir="$1" + local rc="${2:-$(get_shell_rc)}" + + if [[ ! -f "$rc" ]]; then + return 1 + fi + + # shellcheck disable=SC2016 + local path_line="export PATH=\"${dir}:\$PATH\"" + + if ! grep -qF "$dir" "$rc" 2>/dev/null; then + echo "" >> "$rc" + echo "# Added by Mosaic Stack installer" >> "$rc" + echo "$path_line" >> "$rc" + return 0 + fi + + return 1 +} + +# Warn about missing PATH entries +warn_path_missing() { + local dir="$1" + local label="$2" + + if [[ -z "$dir" ]]; then + return 0 + fi + + if path_has_dir "$ORIGINAL_PATH" "$dir"; then + return 0 + fi + + echo "" + echo -e "${WARN}→${NC} PATH warning: missing ${label}: ${INFO}${dir}${NC}" + echo -e "This can make commands show as \"not found\" in new terminals." + echo -e "Fix by adding to your shell config:" + echo -e " export PATH=\"${dir}:\$PATH\"" +} + +# ============================================================================ +# Temp File Management +# ============================================================================ + +TMPFILES=() + +# Create a tracked temp file +create_temp_file() { + local f + f=$(mktemp) + TMPFILES+=("$f") + echo "$f" +} + +# Create a tracked temp directory +create_temp_dir() { + local d + d=$(mktemp -d) + TMPFILES+=("$d") + echo "$d" +} + +# Cleanup all temp files +cleanup_temp_files() { + local f + for f in "${TMPFILES[@]:-}"; do + rm -rf "$f" 2>/dev/null || true + done +} + +# Set up cleanup trap +setup_cleanup_trap() { + trap cleanup_temp_files EXIT +} + +# ============================================================================ +# Platform Summary +# ============================================================================ + +# Print a summary of the detected platform +print_platform_summary() { + local os pkg arch init wsl + + os=$(detect_os) + pkg=$(detect_package_manager "$os") + arch=$(get_arch) + init=$(detect_init_system) + wsl=$(detect_wsl) + + echo -e "${BOLD}Platform Detection:${NC}" + echo -e " OS: ${INFO}$(get_os_name "$os")${NC}" + echo -e " Architecture: ${INFO}$arch${NC}" + echo -e " Package Mgr: ${INFO}$pkg${NC}" + echo -e " Init System: ${INFO}$init${NC}" + + if [[ -n "$wsl" ]]; then + echo -e " WSL: ${INFO}$wsl${NC}" + fi + + echo -e " Shell: ${INFO}$(get_shell)${NC}" + echo -e " RAM: ${INFO}$(get_total_ram)MB${NC}" + echo -e " Disk: ${INFO}$(get_available_disk)GB available${NC}" +} diff --git a/scripts/lib/validation.sh b/scripts/lib/validation.sh new file mode 100644 index 0000000..5066a9b --- /dev/null +++ b/scripts/lib/validation.sh @@ -0,0 +1,747 @@ +#!/bin/bash +# Validation functions for Mosaic Stack installer +# Post-install validation and health checks + +# shellcheck source=lib/platform.sh +source "${BASH_SOURCE[0]%/*}/platform.sh" + +# ============================================================================ +# Validation Result Codes +# ============================================================================ + +readonly CHECK_PASS=0 +readonly CHECK_WARN=1 +readonly CHECK_FAIL=2 + +# ============================================================================ +# Port Validation +# ============================================================================ + +# Check if a port is in use +check_port_in_use() { + local port="$1" + + # Try ss first (most common on modern Linux) + if command -v ss &>/dev/null; then + ss -tuln 2>/dev/null | grep -q ":${port} " + return $? + fi + + # Fall back to netstat + if command -v netstat &>/dev/null; then + netstat -tuln 2>/dev/null | grep -q ":${port} " + return $? + fi + + # Fall back to lsof + if command -v lsof &>/dev/null; then + lsof -i ":$port" &>/dev/null + return $? + fi + + # Can't check, assume port is free + return 1 +} + +# Get process using a port +get_process_on_port() { + local port="$1" + + if command -v lsof &>/dev/null; then + lsof -i ":$port" -t 2>/dev/null | head -1 + elif command -v ss &>/dev/null; then + ss -tulnp 2>/dev/null | grep ":${port} " | grep -oP 'pid=\K[0-9]+' | head -1 + else + echo "unknown" + fi +} + +# Validate port number +validate_port() { + local port="$1" + + if [[ "$port" =~ ^[0-9]+$ ]] && [[ "$port" -ge 1 ]] && [[ "$port" -le 65535 ]]; then + return 0 + fi + return 1 +} + +# Check all configured ports +check_all_ports() { + local env_file="${1:-.env}" + local errors=0 + local warnings=0 + + # Load env file if it exists + if [[ -f "$env_file" ]]; then + set -a + # shellcheck source=/dev/null + source "$env_file" 2>/dev/null || true + set +a + fi + + # Default ports + declare -A default_ports=( + [WEB_PORT]=3000 + [API_PORT]=3001 + [POSTGRES_PORT]=5432 + [VALKEY_PORT]=6379 + [AUTHENTIK_PORT_HTTP]=9000 + [AUTHENTIK_PORT_HTTPS]=9443 + [OLLAMA_PORT]=11434 + [TRAEFIK_HTTP_PORT]=80 + [TRAEFIK_HTTPS_PORT]=443 + [TRAEFIK_DASHBOARD_PORT]=8080 + ) + + echo -e "${BOLD}Checking ports...${NC}" + echo "" + + for port_var in "${!default_ports[@]}"; do + local port="${!port_var:-${default_ports[$port_var]}}" + + if check_port_in_use "$port"; then + local process + process=$(get_process_on_port "$port") + echo -e "${WARN}⚠${NC} $port_var: Port $port is in use (PID: $process)" + ((warnings++)) + else + echo -e "${SUCCESS}✓${NC} $port_var: Port $port available" + fi + done + + echo "" + + if [[ $warnings -gt 0 ]]; then + return $CHECK_WARN + fi + return $CHECK_PASS +} + +# ============================================================================ +# Environment Validation +# ============================================================================ + +# Required environment variables +REQUIRED_ENV_VARS=( + "DATABASE_URL" + "JWT_SECRET" + "BETTER_AUTH_SECRET" + "ENCRYPTION_KEY" +) + +# Optional but recommended environment variables +RECOMMENDED_ENV_VARS=( + "POSTGRES_PASSWORD" + "VALKEY_URL" + "NEXT_PUBLIC_API_URL" + "NEXT_PUBLIC_APP_URL" +) + +# Check if env file exists +check_env_file() { + local env_file="${1:-.env}" + + if [[ -f "$env_file" ]]; then + echo -e "${SUCCESS}✓${NC} .env file exists" + return 0 + else + echo -e "${ERROR}✗${NC} .env file not found" + return $CHECK_FAIL + fi +} + +# Check required environment variables +check_required_env() { + local env_file="${1:-.env}" + local errors=0 + + echo -e "${BOLD}Checking required environment variables...${NC}" + echo "" + + # Load env file + if [[ -f "$env_file" ]]; then + set -a + # shellcheck source=/dev/null + source "$env_file" 2>/dev/null || true + set +a + fi + + for var in "${REQUIRED_ENV_VARS[@]}"; do + local value="${!var:-}" + if [[ -z "$value" ]]; then + echo -e "${ERROR}✗${NC} $var: Not set" + ((errors++)) + elif is_placeholder "$value"; then + echo -e "${WARN}⚠${NC} $var: Contains placeholder value" + ((errors++)) + else + echo -e "${SUCCESS}✓${NC} $var: Set" + fi + done + + echo "" + + if [[ $errors -gt 0 ]]; then + return $CHECK_FAIL + fi + return $CHECK_PASS +} + +# Check recommended environment variables +check_recommended_env() { + local env_file="${1:-.env}" + local warnings=0 + + echo -e "${BOLD}Checking recommended environment variables...${NC}" + echo "" + + # Load env file + if [[ -f "$env_file" ]]; then + set -a + # shellcheck source=/dev/null + source "$env_file" 2>/dev/null || true + set +a + fi + + for var in "${RECOMMENDED_ENV_VARS[@]}"; do + local value="${!var:-}" + if [[ -z "$value" ]]; then + echo -e "${WARN}⚠${NC} $var: Not set (using default)" + ((warnings++)) + elif is_placeholder "$value"; then + echo -e "${WARN}⚠${NC} $var: Contains placeholder value" + ((warnings++)) + else + echo -e "${SUCCESS}✓${NC} $var: Set" + fi + done + + echo "" + + if [[ $warnings -gt 0 ]]; then + return $CHECK_WARN + fi + return $CHECK_PASS +} + +# Check if a value is a placeholder +is_placeholder() { + local value="$1" + + if [[ -z "$value" ]]; then + return 0 + fi + + # Common placeholder patterns + case "$value" in + *"REPLACE_WITH"*|*"CHANGE_ME"*|*"changeme"*|*"your-"*|*"example"*|*"placeholder"*|*"TODO"*|*"FIXME"*) + return 0 + ;; + *"xxx"*|*"<"*">"*|*"\${"*|*"$${"*) + return 0 + ;; + esac + + return 1 +} + +# ============================================================================ +# Secret Validation +# ============================================================================ + +# Minimum secret lengths +declare -A MIN_SECRET_LENGTHS=( + [JWT_SECRET]=32 + [BETTER_AUTH_SECRET]=32 + [ENCRYPTION_KEY]=64 + [AUTHENTIK_SECRET_KEY]=50 + [COORDINATOR_API_KEY]=32 + [ORCHESTRATOR_API_KEY]=32 +) + +# Check secret strength +check_secrets() { + local env_file="${1:-.env}" + local errors=0 + local warnings=0 + + echo -e "${BOLD}Checking secret strength...${NC}" + echo "" + + # Load env file + if [[ -f "$env_file" ]]; then + set -a + # shellcheck source=/dev/null + source "$env_file" 2>/dev/null || true + set +a + fi + + for secret_var in "${!MIN_SECRET_LENGTHS[@]}"; do + local value="${!secret_var:-}" + local min_len="${MIN_SECRET_LENGTHS[$secret_var]}" + + if [[ -z "$value" ]]; then + echo -e "${WARN}⚠${NC} $secret_var: Not set" + ((warnings++)) + elif is_placeholder "$value"; then + echo -e "${ERROR}✗${NC} $secret_var: Contains placeholder (MUST change)" + ((errors++)) + elif [[ ${#value} -lt $min_len ]]; then + echo -e "${WARN}⚠${NC} $secret_var: Too short (${#value} chars, minimum $min_len)" + ((warnings++)) + else + echo -e "${SUCCESS}✓${NC} $secret_var: Strong (${#value} chars)" + fi + done + + echo "" + + if [[ $errors -gt 0 ]]; then + return $CHECK_FAIL + fi + if [[ $warnings -gt 0 ]]; then + return $CHECK_WARN + fi + return $CHECK_PASS +} + +# ============================================================================ +# Docker Validation +# ============================================================================ + +# Check Docker containers are running +check_docker_containers() { + local compose_file="${1:-docker-compose.yml}" + local errors=0 + + echo -e "${BOLD}Checking Docker containers...${NC}" + echo "" + + # Expected container names + local containers=("mosaic-postgres" "mosaic-valkey" "mosaic-api" "mosaic-web") + + for container in "${containers[@]}"; do + local status + status=$(docker inspect --format='{{.State.Status}}' "$container" 2>/dev/null || echo "not_found") + + case "$status" in + running) + echo -e "${SUCCESS}✓${NC} $container: Running" + ;; + exited) + echo -e "${ERROR}✗${NC} $container: Exited" + ((errors++)) + ;; + not_found) + # Container might not be in current profile + echo -e "${MUTED}○${NC} $container: Not found (may not be in profile)" + ;; + *) + echo -e "${WARN}⚠${NC} $container: $status" + ((errors++)) + ;; + esac + done + + echo "" + + if [[ $errors -gt 0 ]]; then + return $CHECK_FAIL + fi + return $CHECK_PASS +} + +# Check container health +check_container_health() { + local errors=0 + + echo -e "${BOLD}Checking container health...${NC}" + echo "" + + # Get all mosaic containers + local containers + containers=$(docker ps --filter "name=mosaic-" --format "{{.Names}}" 2>/dev/null) + + for container in $containers; do + local health + health=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || echo "no_healthcheck") + + case "$health" in + healthy) + echo -e "${SUCCESS}✓${NC} $container: Healthy" + ;; + unhealthy) + echo -e "${ERROR}✗${NC} $container: Unhealthy" + ((errors++)) + ;; + starting) + echo -e "${WARN}⚠${NC} $container: Starting..." + ;; + no_healthcheck) + echo -e "${INFO}ℹ${NC} $container: No health check" + ;; + *) + echo -e "${WARN}⚠${NC} $container: $health" + ;; + esac + done + + echo "" + + if [[ $errors -gt 0 ]]; then + return $CHECK_FAIL + fi + return $CHECK_PASS +} + +# ============================================================================ +# Service Connectivity +# ============================================================================ + +# Check if a URL responds +check_url_responds() { + local url="$1" + local expected_status="${2:-200}" + local timeout="${3:-10}" + + if command -v curl &>/dev/null; then + local status + status=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$timeout" "$url" 2>/dev/null) + + if [[ "$status" == "$expected_status" ]]; then + return 0 + fi + fi + + return 1 +} + +# Check API health endpoint +check_api_health() { + local api_url="${1:-http://localhost:3001}" + + echo -e "${BOLD}Checking API health...${NC}" + echo "" + + if check_url_responds "${api_url}/health" 200 10; then + echo -e "${SUCCESS}✓${NC} API health check passed" + return $CHECK_PASS + else + echo -e "${ERROR}✗${NC} API health check failed" + return $CHECK_FAIL + fi +} + +# Check Web frontend +check_web_health() { + local web_url="${1:-http://localhost:3000}" + + echo -e "${BOLD}Checking Web frontend...${NC}" + echo "" + + if check_url_responds "$web_url" 200 10; then + echo -e "${SUCCESS}✓${NC} Web frontend responding" + return $CHECK_PASS + else + echo -e "${WARN}⚠${NC} Web frontend not responding (may still be starting)" + return $CHECK_WARN + fi +} + +# Check database connectivity +check_database_connection() { + local host="${1:-localhost}" + local port="${2:-5432}" + local user="${3:-mosaic}" + local database="${4:-mosaic}" + + echo -e "${BOLD}Checking database connection...${NC}" + echo "" + + # Try via Docker if postgres container exists + if docker exec mosaic-postgres pg_isready -U "$user" -d "$database" &>/dev/null; then + echo -e "${SUCCESS}✓${NC} Database connection successful" + return $CHECK_PASS + fi + + # Try via psql if available + if command -v psql &>/dev/null; then + if PGPASSWORD="${POSTGRES_PASSWORD:-}" psql -h "$host" -p "$port" -U "$user" -d "$database" -c "SELECT 1" &>/dev/null; then + echo -e "${SUCCESS}✓${NC} Database connection successful" + return $CHECK_PASS + fi + fi + + # Try via TCP + if command -v nc &>/dev/null; then + if nc -z "$host" "$port" 2>/dev/null; then + echo -e "${WARN}⚠${NC} Database port open but could not verify connection" + return $CHECK_WARN + fi + fi + + echo -e "${ERROR}✗${NC} Database connection failed" + return $CHECK_FAIL +} + +# Check Valkey/Redis connectivity +check_valkey_connection() { + local host="${1:-localhost}" + local port="${2:-6379}" + + echo -e "${BOLD}Checking Valkey/Redis connection...${NC}" + echo "" + + # Try via Docker if valkey container exists + if docker exec mosaic-valkey valkey-cli ping 2>/dev/null | grep -q PONG; then + echo -e "${SUCCESS}✓${NC} Valkey connection successful" + return $CHECK_PASS + fi + + # Try via redis-cli if available + if command -v redis-cli &>/dev/null; then + if redis-cli -h "$host" -p "$port" ping 2>/dev/null | grep -q PONG; then + echo -e "${SUCCESS}✓${NC} Valkey/Redis connection successful" + return $CHECK_PASS + fi + fi + + # Try via TCP + if command -v nc &>/dev/null; then + if nc -z "$host" "$port" 2>/dev/null; then + echo -e "${WARN}⚠${NC} Valkey port open but could not verify connection" + return $CHECK_WARN + fi + fi + + echo -e "${ERROR}✗${NC} Valkey/Redis connection failed" + return $CHECK_FAIL +} + +# ============================================================================ +# System Requirements +# ============================================================================ + +# Check minimum system requirements +check_system_requirements() { + local min_ram="${1:-2048}" + local min_disk="${2:-10}" + local errors=0 + local warnings=0 + + echo -e "${BOLD}Checking system requirements...${NC}" + echo "" + + # RAM check + local ram + ram=$(get_total_ram) + if [[ "$ram" -lt "$min_ram" ]]; then + echo -e "${ERROR}✗${NC} RAM: ${ram}MB (minimum: ${min_ram}MB)" + ((errors++)) + else + echo -e "${SUCCESS}✓${NC} RAM: ${ram}MB" + fi + + # Disk check + local disk + disk=$(get_available_disk "$HOME") + if [[ "$disk" -lt "$min_disk" ]]; then + echo -e "${WARN}⚠${NC} Disk: ${disk}GB available (recommended: ${min_disk}GB+)" + ((warnings++)) + else + echo -e "${SUCCESS}✓${NC} Disk: ${disk}GB available" + fi + + # Docker disk (if using Docker) + if command -v docker &>/dev/null && docker info &>/dev/null; then + local docker_disk + docker_disk=$(docker system df --format "{{.Total}}" 2>/dev/null | head -1 || echo "unknown") + echo -e "${INFO}ℹ${NC} Docker storage: $docker_disk" + fi + + echo "" + + if [[ $errors -gt 0 ]]; then + return $CHECK_FAIL + fi + if [[ $warnings -gt 0 ]]; then + return $CHECK_WARN + fi + return $CHECK_PASS +} + +# ============================================================================ +# File Permissions +# ============================================================================ + +# Check .env file permissions +check_env_permissions() { + local env_file="${1:-.env}" + + echo -e "${BOLD}Checking file permissions...${NC}" + echo "" + + if [[ ! -f "$env_file" ]]; then + echo -e "${WARN}⚠${NC} .env file not found" + return $CHECK_WARN + fi + + local perms + perms=$(stat -c "%a" "$env_file" 2>/dev/null || stat -f "%OLp" "$env_file" 2>/dev/null) + + # Check if world-readable + if [[ "$perms" =~ [0-7][0-7][4-7]$ ]]; then + echo -e "${WARN}⚠${NC} .env is world-readable (permissions: $perms)" + echo -e " ${INFO}Fix: chmod 600 $env_file${NC}" + return $CHECK_WARN + fi + + echo -e "${SUCCESS}✓${NC} .env permissions: $perms" + return $CHECK_PASS +} + +# ============================================================================ +# Comprehensive Doctor Check +# ============================================================================ + +# Run all checks and report results +run_doctor() { + local env_file="${1:-.env}" + local compose_file="${2:-docker-compose.yml}" + local mode="${3:-docker}" + + local errors=0 + local warnings=0 + + echo "" + echo -e "${BOLD}════════════════════════════════════════════════════════════${NC}" + echo -e "${BOLD} Mosaic Stack Doctor${NC}" + echo -e "${BOLD}════════════════════════════════════════════════════════════${NC}" + echo "" + + # System requirements + run_doctor_check "System Requirements" check_system_requirements 2048 10 + collect_result $? + + # Environment file + run_doctor_check "Environment File" check_env_file "$env_file" + collect_result $? + + # Required environment variables + run_doctor_check "Required Variables" check_required_env "$env_file" + collect_result $? + + # Secret strength + run_doctor_check "Secret Strength" check_secrets "$env_file" + collect_result $? + + # File permissions + run_doctor_check "File Permissions" check_env_permissions "$env_file" + collect_result $? + + if [[ "$mode" == "docker" ]]; then + # Docker containers + run_doctor_check "Docker Containers" check_docker_containers "$compose_file" + collect_result $? + + # Container health + run_doctor_check "Container Health" check_container_health + collect_result $? + + # Database connection + run_doctor_check "Database" check_database_connection + collect_result $? + + # Valkey connection + run_doctor_check "Cache (Valkey)" check_valkey_connection + collect_result $? + + # API health + run_doctor_check "API" check_api_health + collect_result $? + + # Web frontend + run_doctor_check "Web Frontend" check_web_health + collect_result $? + fi + + echo "" + echo -e "${BOLD}════════════════════════════════════════════════════════════${NC}" + + # Summary + if [[ $errors -gt 0 ]]; then + echo -e "${ERROR}✗${NC} ${BOLD}Failed${NC}: $errors errors, $warnings warnings" + echo "" + echo "Fix the errors above and run doctor again." + return $CHECK_FAIL + elif [[ $warnings -gt 0 ]]; then + echo -e "${WARN}⚠${NC} ${BOLD}Warnings${NC}: $warnings warnings" + echo "" + echo "System is operational but some optimizations are recommended." + return $CHECK_WARN + else + echo -e "${SUCCESS}✓${NC} ${BOLD}All checks passed${NC}" + echo "" + echo "Mosaic Stack is healthy and ready to use." + return $CHECK_PASS + fi +} + +# Helper to run a check and print result +run_doctor_check() { + local name="$1" + shift + + echo -e "${BOLD}Checking: $name${NC}" + echo "" + + "$@" + return $? +} + +# Helper to collect check results +collect_result() { + local result=$1 + + case $result in + $CHECK_PASS) ;; + $CHECK_WARN) ((warnings++)) ;; + $CHECK_FAIL) ((errors++)) ;; + esac +} + +# ============================================================================ +# Quick Health Check +# ============================================================================ + +# Quick check for CI/CD or scripts +quick_health_check() { + local api_url="${1:-http://localhost:3001}" + + check_url_responds "${api_url}/health" 200 5 +} + +# Wait for healthy state +wait_for_healthy() { + local timeout="${1:-120}" + local interval="${2:-5}" + + echo -e "${INFO}ℹ${NC} Waiting for healthy state..." + + local elapsed=0 + while [[ $elapsed -lt $timeout ]]; do + if quick_health_check &>/dev/null; then + echo -e "${SUCCESS}✓${NC} System is healthy" + return 0 + fi + + sleep "$interval" + ((elapsed += interval)) + echo -n "." + done + + echo "" + echo -e "${ERROR}✗${NC} Timeout waiting for healthy state" + return 1 +}