chore: add install scripts, doctor command, and AGENTS.md
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/api Pipeline was successful

- Add one-line installer (scripts/install.sh) with platform detection
- Add doctor command (scripts/commands/doctor.sh) for environment diagnostics
- Add shared libraries: dependencies, docker, platform, validation
- Update README with quick-start installer instructions
- Add AGENTS.md with codebase patterns for AI agent context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 11:04:36 -06:00
parent 0ca3945061
commit ab52827d9c
8 changed files with 4290 additions and 4 deletions

37
AGENTS.md Normal file
View File

@@ -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`

View File

@@ -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

552
scripts/commands/doctor.sh Executable file
View File

@@ -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 "$@"

834
scripts/install.sh Executable file
View File

@@ -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

908
scripts/lib/dependencies.sh Normal file
View File

@@ -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
}

491
scripts/lib/docker.sh Normal file
View File

@@ -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
}

665
scripts/lib/platform.sh Normal file
View File

@@ -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}"
}

747
scripts/lib/validation.sh Normal file
View File

@@ -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
}