Files
stack/scripts/setup.sh
Jason Woltje 98f80eaf51
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix(scripts): Fix awk env parsing for POSIX compatibility
- Use index() instead of regex capture groups for key extraction
- More portable across different awk implementations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 00:24:31 -06:00

2157 lines
63 KiB
Bash
Executable File

#!/bin/bash
# Mosaic Stack Setup Wizard
# Interactive installer for Mosaic Stack personal assistant platform
# Supports Docker and native deployments
set -e
# ============================================================================
# Configuration
# ============================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Set up logging
LOG_DIR="$PROJECT_ROOT/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/setup-$(date +%Y%m%d_%H%M%S).log"
# Redirect stdout/stderr to both console and log file
exec > >(tee -a "$LOG_FILE") 2>&1
# Send trace output (set -x) ONLY to log file on fd 3
exec 3>>"$LOG_FILE"
export BASH_XTRACEFD=3
# Enable verbose command tracing (only goes to log file now)
set -x
echo "==================================================================="
echo "Mosaic Stack Setup Wizard"
echo "Started: $(date)"
echo "Full log: $LOG_FILE"
echo "==================================================================="
echo ""
# Source common functions
# shellcheck source=lib/common.sh
source "$SCRIPT_DIR/lib/common.sh"
# ============================================================================
# Global Variables
# ============================================================================
NON_INTERACTIVE=false
DRY_RUN=false
MODE=""
ENABLE_SSO=false
USE_BUNDLED_AUTHENTIK=false
EXTERNAL_AUTHENTIK_URL=""
OLLAMA_MODE="disabled"
OLLAMA_URL=""
ENABLE_MOLTBOT=false
MOSAIC_BASE_URL=""
API_BASE_URL=""
AUTHENTIK_PUBLIC_URL=""
BASE_URL_MODE=""
BASE_URL_SCHEME="http"
BASE_URL_HOST=""
BASE_URL_PORT=""
TRAEFIK_MODE="none"
TRAEFIK_ENABLE=false
TRAEFIK_TLS_ENABLED=true
TRAEFIK_ENTRYPOINT="websecure"
TRAEFIK_NETWORK=""
TRAEFIK_DOCKER_NETWORK="mosaic-public"
TRAEFIK_ACME_EMAIL=""
TRAEFIK_CERTRESOLVER=""
TRAEFIK_DASHBOARD_ENABLED=true
TRAEFIK_DASHBOARD_PORT=""
MOSAIC_WEB_DOMAIN=""
MOSAIC_API_DOMAIN=""
MOSAIC_AUTH_DOMAIN=""
WEB_PORT=""
API_PORT=""
POSTGRES_PORT=""
VALKEY_PORT=""
AUTHENTIK_PORT_HTTP=""
AUTHENTIK_PORT_HTTPS=""
OLLAMA_PORT=""
TRAEFIK_HTTP_PORT=""
TRAEFIK_HTTPS_PORT=""
DETECTED_OS=""
DETECTED_PKG_MANAGER=""
PORT_OVERRIDES=()
# ============================================================================
# Taglines (OpenClaw-inspired flavor)
# ============================================================================
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."
)
pick_tagline() {
local idx=$((RANDOM % ${#TAGLINES[@]}))
echo "${TAGLINES[$idx]}"
}
# ============================================================================
# Help and Usage
# ============================================================================
show_help() {
cat << EOF
Mosaic Stack Setup Wizard
USAGE:
$0 [OPTIONS]
OPTIONS:
-h, --help Show this help message
--non-interactive Run in non-interactive mode (requires all options)
--dry-run Show what would happen without executing
--mode MODE Deployment mode: docker or native
--enable-sso Enable Authentik SSO (Docker mode only)
--bundled-authentik Use bundled Authentik server (with --enable-sso)
--external-authentik URL Use external Authentik server URL (with --enable-sso)
--ollama-mode MODE Ollama mode: local, remote, disabled (default: disabled)
--ollama-url URL Ollama server URL (if --ollama-mode remote)
--enable-moltbot Enable MoltBot integration
--base-url URL Mosaic base URL (e.g., https://mosaic.example.com)
EXAMPLES:
# Interactive mode (recommended for first-time setup)
$0
# Non-interactive Docker deployment with bundled SSO
$0 --non-interactive --mode docker --enable-sso --bundled-authentik
# Non-interactive Docker with external Authentik and local Ollama
$0 --non-interactive --mode docker --enable-sso --external-authentik "https://auth.example.com" --ollama-mode local
# Dry run to see what would happen
$0 --dry-run --mode docker --enable-sso --bundled-authentik
EOF
}
# ============================================================================
# Argument Parsing
# ============================================================================
parse_arguments() {
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
show_help
exit 0
;;
--non-interactive)
NON_INTERACTIVE=true
;;
--dry-run)
DRY_RUN=true
;;
--mode)
if [[ -z "${2:-}" || "$2" == --* ]]; then
print_error "--mode requires a value (docker or native)"
exit 1
fi
MODE="$2"
shift
;;
--enable-sso)
ENABLE_SSO=true
;;
--bundled-authentik)
USE_BUNDLED_AUTHENTIK=true
;;
--external-authentik)
if [[ -z "${2:-}" || "$2" == --* ]]; then
print_error "--external-authentik requires a URL"
exit 1
fi
EXTERNAL_AUTHENTIK_URL="$2"
shift
;;
--ollama-mode)
if [[ -z "${2:-}" || "$2" == --* ]]; then
print_error "--ollama-mode requires a value (local, remote, or disabled)"
exit 1
fi
OLLAMA_MODE="$2"
shift
;;
--ollama-url)
if [[ -z "${2:-}" || "$2" == --* ]]; then
print_error "--ollama-url requires a URL"
exit 1
fi
OLLAMA_URL="$2"
shift
;;
--enable-moltbot)
ENABLE_MOLTBOT=true
;;
--base-url)
if [[ -z "${2:-}" || "$2" == --* ]]; then
print_error "--base-url requires a URL"
exit 1
fi
MOSAIC_BASE_URL="$2"
shift
;;
*)
print_error "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
shift
done
# Validate non-interactive mode
if [[ "$NON_INTERACTIVE" == true ]]; then
if [[ -z "$MODE" ]]; then
print_error "Non-interactive mode requires --mode"
exit 1
fi
if [[ "$MODE" != "native" && "$MODE" != "docker" ]]; then
print_error "Invalid mode: $MODE (must be 'native' or 'docker')"
exit 1
fi
if [[ "$OLLAMA_MODE" == "remote" && -z "$OLLAMA_URL" ]]; then
print_error "Remote Ollama mode requires --ollama-url"
exit 1
fi
if [[ "$ENABLE_SSO" == true && "$USE_BUNDLED_AUTHENTIK" != true && -z "$EXTERNAL_AUTHENTIK_URL" ]]; then
print_error "SSO enabled in non-interactive mode requires --bundled-authentik or --external-authentik URL"
exit 1
fi
fi
}
# ============================================================================
# Welcome Banner
# ============================================================================
show_banner() {
if [[ "$NON_INTERACTIVE" == true ]]; then
return
fi
cat << "EOF"
__ __ _ ____ _ _
| \/ | ___ ___ __ _(_) ___ / ___| |_ __ _ ___| | __
| |\/| |/ _ \/ __|/ _` | |/ __|\___ | __/ _` |/ __| |/ /
| | | | (_) \__ \ (_| | | (__ ___/ | || (_| | (__| <
|_| |_|\___/|___/\__,_|_|\___|____/ \__\__,_|\___|_|\_\
Multi-Tenant Personal Assistant Platform
EOF
local tagline
tagline=$(pick_tagline)
echo " $tagline"
echo ""
echo "Welcome to the Mosaic Stack Setup Wizard!"
echo ""
echo "This wizard will guide you through setting up Mosaic Stack on your system."
echo "You can choose between Docker (production) or native (development) deployment,"
echo "and configure optional features like Authentik SSO and Ollama LLM integration."
echo ""
}
# ============================================================================
# Platform Detection
# ============================================================================
detect_platform() {
print_header "Detecting Platform"
DETECTED_OS=$(detect_os)
DETECTED_PKG_MANAGER=$(detect_package_manager "$DETECTED_OS")
local os_name
os_name=$(get_os_name "$DETECTED_OS")
if [[ "$DETECTED_OS" == "unknown" ]]; then
print_warning "Could not detect operating system"
print_info "Detected OS type: $OSTYPE"
echo ""
if [[ "$NON_INTERACTIVE" == true ]]; then
print_error "Cannot proceed in non-interactive mode on unknown OS"
exit 1
fi
if ! confirm "Continue anyway?"; then
exit 1
fi
else
print_success "Detected: $os_name ($DETECTED_PKG_MANAGER)"
fi
}
# ============================================================================
# Mode Selection
# ============================================================================
select_deployment_mode() {
if [[ -n "$MODE" ]]; then
print_header "Deployment Mode"
print_info "Using mode: $MODE"
return
fi
print_header "Deployment Mode"
echo ""
echo "How would you like to run Mosaic Stack?"
echo ""
echo " 1) Docker (Recommended)"
echo " - Best for production deployment"
echo " - Isolated environment with all dependencies"
echo " - Includes PostgreSQL, Valkey, all services"
echo " - Optional Authentik SSO integration"
echo " - Turnkey deployment"
echo ""
echo " 2) Native"
echo " - Best for development"
echo " - Runs directly on your system"
echo " - Requires manual dependency installation"
echo " - Easier to debug and modify"
echo ""
local selection
selection=$(select_option "Select deployment mode:" \
"Docker (production, recommended)" \
"Native (development)")
if [[ "$selection" == *"Docker"* ]]; then
MODE="docker"
else
MODE="native"
fi
print_success "Selected: $MODE mode"
}
# ============================================================================
# Dependency Checking
# ============================================================================
check_and_install_dependencies() {
print_header "Checking Dependencies"
local missing_deps=()
local docker_status=0
if [[ "$MODE" == "docker" ]]; then
check_docker
docker_status=$?
if [[ $docker_status -eq 1 ]]; then
# Docker not installed
missing_deps+=("Docker")
elif [[ $docker_status -ne 0 ]]; then
# Docker installed but not accessible (permission/daemon issue)
echo ""
print_error "Docker is installed but not accessible"
print_info "Please fix the issue above and run the setup script again"
exit 1
else
print_success "Docker: OK"
fi
if ! check_docker_compose; then
missing_deps+=("Docker Compose")
else
print_success "Docker Compose: OK"
fi
else
if ! check_node 18; then
missing_deps+=("Node.js 18+")
else
print_success "Node.js: OK"
fi
if ! check_pnpm; then
missing_deps+=("pnpm")
else
print_success "pnpm: OK"
fi
if ! check_postgres; then
missing_deps+=("PostgreSQL")
else
print_success "PostgreSQL: OK"
fi
fi
if [[ ${#missing_deps[@]} -eq 0 ]]; then
echo ""
print_success "All dependencies satisfied"
return 0
fi
echo ""
print_warning "Missing dependencies:"
for dep in "${missing_deps[@]}"; do
echo " - $dep"
done
echo ""
if [[ "$NON_INTERACTIVE" == true ]]; then
print_error "Dependency check failed in non-interactive mode"
exit 1
fi
if confirm "Would you like to install missing dependencies?"; then
install_missing_dependencies
else
print_error "Cannot proceed without required dependencies"
exit 1
fi
}
install_missing_dependencies() {
print_header "Installing Dependencies"
check_sudo
if [[ "$MODE" == "docker" ]]; then
# Install Docker
if ! check_docker; then
local docker_pkg
docker_pkg=$(get_package_name "$DETECTED_PKG_MANAGER" "docker")
if [[ -n "$docker_pkg" ]]; then
install_package "$DETECTED_PKG_MANAGER" "$docker_pkg"
# Start Docker service
case "$DETECTED_PKG_MANAGER" in
pacman|dnf)
print_step "Starting Docker service..."
sudo systemctl enable --now docker
;;
esac
# Add user to docker group
print_step "Adding user to docker group..."
sudo usermod -aG docker "$USER"
print_warning "You may need to log out and back in for docker group membership to take effect"
print_info "Or run: newgrp docker"
fi
fi
# Install Docker Compose
if ! check_docker_compose; then
local compose_pkg
compose_pkg=$(get_package_name "$DETECTED_PKG_MANAGER" "docker-compose")
if [[ -n "$compose_pkg" ]]; then
install_package "$DETECTED_PKG_MANAGER" "$compose_pkg"
fi
fi
else
# Install Node.js
if ! check_node 18; then
local node_pkg
node_pkg=$(get_package_name "$DETECTED_PKG_MANAGER" "node")
if [[ -n "$node_pkg" ]]; then
install_package "$DETECTED_PKG_MANAGER" "$node_pkg"
fi
fi
# Install pnpm
if ! check_pnpm; then
print_step "Installing pnpm via npm..."
npm install -g pnpm
fi
# Install PostgreSQL
if ! check_postgres; then
local postgres_pkg
postgres_pkg=$(get_package_name "$DETECTED_PKG_MANAGER" "postgres")
if [[ -n "$postgres_pkg" ]]; then
install_package "$DETECTED_PKG_MANAGER" "$postgres_pkg"
fi
fi
fi
# Re-check dependencies
echo ""
local still_missing=false
if [[ "$MODE" == "docker" ]]; then
if ! check_docker || ! check_docker_compose; then
still_missing=true
fi
else
if ! check_node 18 || ! check_pnpm; then
still_missing=true
fi
fi
if [[ "$still_missing" == true ]]; then
print_error "Some dependencies are still missing after installation"
print_info "You may need to install them manually"
exit 1
else
print_success "All dependencies installed successfully"
fi
}
# ============================================================================
# Configuration Collection
# ============================================================================
load_existing_env() {
if [[ -f "$PROJECT_ROOT/.env" ]]; then
print_header "Detecting Existing Configuration"
parse_env_file "$PROJECT_ROOT/.env"
print_success "Found existing .env file"
return 0
fi
return 1
}
# ============================================================================
# URL and Port Helpers
# ============================================================================
strip_trailing_slash() {
local value="$1"
echo "${value%/}"
}
format_url() {
local scheme="$1"
local host="$2"
local port="$3"
local always_port="${4:-false}"
if [[ -z "$host" ]]; then
echo ""
return
fi
if [[ -z "$port" ]]; then
echo "${scheme}://${host}"
return
fi
if [[ "$always_port" == true ]]; then
echo "${scheme}://${host}:${port}"
return
fi
if [[ "$scheme" == "http" && "$port" == "80" ]] || [[ "$scheme" == "https" && "$port" == "443" ]]; then
echo "${scheme}://${host}"
else
echo "${scheme}://${host}:${port}"
fi
}
bool_str() {
if [[ "$1" == true ]]; then
echo "true"
else
echo "false"
fi
}
resolve_env_value() {
local key="$1"
local fallback="$2"
local value
value=$(get_env_value "$key")
if [[ -n "$value" ]] && ! is_placeholder "$value"; then
echo "$value"
else
echo "$fallback"
fi
}
resolve_secret_value() {
local key="$1"
local generator="$2"
local length="$3"
local value
value=$(get_env_value "$key")
if [[ -n "$value" ]] && ! is_placeholder "$value"; then
echo "$value"
else
"$generator" "$length"
fi
}
derive_cookie_domain() {
local domain="$1"
if [[ "$domain" == *.* ]]; then
echo ".${domain#*.}"
else
echo ".${domain}"
fi
}
resolve_port_value() {
local key="$1"
local fallback="$2"
local value
value=$(get_env_value "$key")
if validate_port "$value"; then
echo "$value"
else
echo "$fallback"
fi
}
initialize_ports() {
WEB_PORT=$(resolve_port_value "WEB_PORT" "3000")
API_PORT=$(resolve_port_value "API_PORT" "3001")
POSTGRES_PORT=$(resolve_port_value "POSTGRES_PORT" "5432")
VALKEY_PORT=$(resolve_port_value "VALKEY_PORT" "6379")
AUTHENTIK_PORT_HTTP=$(resolve_port_value "AUTHENTIK_PORT_HTTP" "9000")
AUTHENTIK_PORT_HTTPS=$(resolve_port_value "AUTHENTIK_PORT_HTTPS" "9443")
OLLAMA_PORT=$(resolve_port_value "OLLAMA_PORT" "11434")
TRAEFIK_HTTP_PORT=$(resolve_port_value "TRAEFIK_HTTP_PORT" "80")
TRAEFIK_HTTPS_PORT=$(resolve_port_value "TRAEFIK_HTTPS_PORT" "443")
TRAEFIK_DASHBOARD_PORT=$(resolve_port_value "TRAEFIK_DASHBOARD_PORT" "8080")
}
parse_base_url() {
local url="$1"
if [[ "$url" =~ ^(https?)://([^/:]+)(:([0-9]+))?(/.*)?$ ]]; then
BASE_URL_SCHEME="${BASH_REMATCH[1]}"
BASE_URL_HOST="${BASH_REMATCH[2]}"
BASE_URL_PORT="${BASH_REMATCH[4]}"
return 0
fi
return 1
}
recalculate_urls() {
if [[ "$BASE_URL_MODE" == "traefik" ]]; then
local traefik_port=""
if [[ "$TRAEFIK_MODE" == "bundled" ]]; then
if [[ "$BASE_URL_SCHEME" == "https" ]]; then
traefik_port="$TRAEFIK_HTTPS_PORT"
else
traefik_port="$TRAEFIK_HTTP_PORT"
fi
fi
MOSAIC_BASE_URL=$(format_url "$BASE_URL_SCHEME" "$MOSAIC_WEB_DOMAIN" "$traefik_port")
API_BASE_URL=$(format_url "$BASE_URL_SCHEME" "$MOSAIC_API_DOMAIN" "$traefik_port")
else
local always_port=false
if [[ "$BASE_URL_MODE" == "localhost" || "$BASE_URL_MODE" == "ip" ]]; then
always_port=true
fi
MOSAIC_BASE_URL=$(format_url "$BASE_URL_SCHEME" "$BASE_URL_HOST" "$WEB_PORT" "$always_port")
API_BASE_URL=$(format_url "$BASE_URL_SCHEME" "$BASE_URL_HOST" "$API_PORT" true)
fi
if [[ "$ENABLE_SSO" == true ]]; then
if [[ "$USE_BUNDLED_AUTHENTIK" == true ]]; then
if [[ "$BASE_URL_MODE" == "traefik" && -n "$MOSAIC_AUTH_DOMAIN" ]]; then
local auth_port=""
if [[ "$TRAEFIK_MODE" == "bundled" ]]; then
if [[ "$BASE_URL_SCHEME" == "https" ]]; then
auth_port="$TRAEFIK_HTTPS_PORT"
else
auth_port="$TRAEFIK_HTTP_PORT"
fi
fi
AUTHENTIK_PUBLIC_URL=$(format_url "$BASE_URL_SCHEME" "$MOSAIC_AUTH_DOMAIN" "$auth_port")
else
AUTHENTIK_PUBLIC_URL="http://localhost:${AUTHENTIK_PORT_HTTP}"
fi
else
AUTHENTIK_PUBLIC_URL="$EXTERNAL_AUTHENTIK_URL"
fi
fi
}
port_label() {
local key="$1"
case "$key" in
WEB_PORT) echo "Web UI" ;;
API_PORT) echo "API" ;;
POSTGRES_PORT) echo "PostgreSQL" ;;
VALKEY_PORT) echo "Valkey" ;;
AUTHENTIK_PORT_HTTP) echo "Authentik HTTP" ;;
AUTHENTIK_PORT_HTTPS) echo "Authentik HTTPS" ;;
OLLAMA_PORT) echo "Ollama" ;;
TRAEFIK_HTTP_PORT) echo "Traefik HTTP" ;;
TRAEFIK_HTTPS_PORT) echo "Traefik HTTPS" ;;
TRAEFIK_DASHBOARD_PORT) echo "Traefik Dashboard" ;;
*) echo "$key" ;;
esac
}
suggest_port_for_key() {
local key="$1"
local port="$2"
case "$key" in
TRAEFIK_HTTP_PORT)
if ! check_port_in_use 8080; then
echo "8080"
return 0
fi
;;
TRAEFIK_HTTPS_PORT)
if ! check_port_in_use 8443; then
echo "8443"
return 0
fi
;;
esac
suggest_alternative_port "$port"
}
apply_port_override() {
local key="$1"
local value="$2"
case "$key" in
WEB_PORT) WEB_PORT="$value" ;;
API_PORT) API_PORT="$value" ;;
POSTGRES_PORT) POSTGRES_PORT="$value" ;;
VALKEY_PORT) VALKEY_PORT="$value" ;;
AUTHENTIK_PORT_HTTP) AUTHENTIK_PORT_HTTP="$value" ;;
AUTHENTIK_PORT_HTTPS) AUTHENTIK_PORT_HTTPS="$value" ;;
OLLAMA_PORT) OLLAMA_PORT="$value" ;;
TRAEFIK_HTTP_PORT) TRAEFIK_HTTP_PORT="$value" ;;
TRAEFIK_HTTPS_PORT) TRAEFIK_HTTPS_PORT="$value" ;;
TRAEFIK_DASHBOARD_PORT) TRAEFIK_DASHBOARD_PORT="$value" ;;
esac
set_env_value "$key" "$value"
}
apply_port_overrides() {
if [[ ${#PORT_OVERRIDES[@]} -eq 0 ]]; then
return
fi
for override in "${PORT_OVERRIDES[@]}"; do
local key="${override%%=*}"
local value="${override#*=}"
apply_port_override "$key" "$value"
done
recalculate_urls
}
resolve_port_conflicts() {
if [[ "$MODE" != "docker" ]]; then
return
fi
print_header "Port Check"
local port_entries=()
port_entries+=("WEB_PORT=$WEB_PORT")
port_entries+=("API_PORT=$API_PORT")
port_entries+=("POSTGRES_PORT=$POSTGRES_PORT")
port_entries+=("VALKEY_PORT=$VALKEY_PORT")
if [[ "$ENABLE_SSO" == true && "$USE_BUNDLED_AUTHENTIK" == true ]]; then
port_entries+=("AUTHENTIK_PORT_HTTP=$AUTHENTIK_PORT_HTTP")
port_entries+=("AUTHENTIK_PORT_HTTPS=$AUTHENTIK_PORT_HTTPS")
fi
if [[ "$OLLAMA_MODE" == "local" ]]; then
port_entries+=("OLLAMA_PORT=$OLLAMA_PORT")
fi
if [[ "$TRAEFIK_MODE" == "bundled" ]]; then
port_entries+=("TRAEFIK_HTTP_PORT=$TRAEFIK_HTTP_PORT")
port_entries+=("TRAEFIK_HTTPS_PORT=$TRAEFIK_HTTPS_PORT")
if [[ "$TRAEFIK_DASHBOARD_ENABLED" == true ]]; then
port_entries+=("TRAEFIK_DASHBOARD_PORT=$TRAEFIK_DASHBOARD_PORT")
fi
fi
local conflicts=()
local suggestions=()
for entry in "${port_entries[@]}"; do
local key="${entry%%=*}"
local port="${entry#*=}"
if check_port_in_use "$port"; then
local suggested
suggested=$(suggest_port_for_key "$key" "$port")
conflicts+=("${key}=${port}")
suggestions+=("${key}=${suggested}")
fi
done
if [[ ${#conflicts[@]} -eq 0 ]]; then
print_success "No port conflicts detected"
return
fi
echo ""
print_warning "Port conflicts detected:"
for conflict in "${conflicts[@]}"; do
local key="${conflict%%=*}"
local port="${conflict#*=}"
echo " - $(port_label "$key"): $port"
done
echo ""
print_info "Suggested alternatives:"
for suggestion in "${suggestions[@]}"; do
local key="${suggestion%%=*}"
local port="${suggestion#*=}"
echo " - $(port_label "$key"): $port"
done
echo ""
if [[ "$NON_INTERACTIVE" == true ]]; then
PORT_OVERRIDES=("${suggestions[@]}")
print_success "Applying alternative ports automatically (non-interactive)"
apply_port_overrides
return
fi
if confirm "Apply suggested ports automatically?" "y"; then
PORT_OVERRIDES=("${suggestions[@]}")
apply_port_overrides
print_success "Alternative ports applied"
else
print_error "Port conflicts must be resolved to continue"
exit 1
fi
}
collect_configuration() {
print_header "Configuration"
# Try to load existing .env
local has_existing_env=false
if load_existing_env; then
has_existing_env=true
echo ""
print_info "Found existing configuration. I'll ask about each setting."
echo ""
fi
initialize_ports
# Base URL Configuration
configure_base_url
# SSO Configuration (Docker mode only)
if [[ "$MODE" == "docker" ]]; then
configure_sso
fi
# Ollama Configuration
configure_ollama
# MoltBot Configuration
configure_moltbot
# Generate secrets if needed
configure_secrets
}
configure_base_url() {
echo ""
print_step "Base URL Configuration"
echo ""
local current_url
current_url=$(get_env_value "MOSAIC_BASE_URL")
if [[ -n "$current_url" ]] && ! is_placeholder "$current_url"; then
echo "Current base URL: $current_url"
if [[ "$NON_INTERACTIVE" == true ]] || ! confirm "Change base URL?" "n"; then
if parse_base_url "$current_url"; then
BASE_URL_MODE="custom"
if [[ -n "$BASE_URL_PORT" ]]; then
WEB_PORT="$BASE_URL_PORT"
else
if [[ "$BASE_URL_SCHEME" == "https" ]]; then
WEB_PORT="443"
else
WEB_PORT="80"
fi
fi
recalculate_urls
return
fi
fi
fi
if [[ -n "$MOSAIC_BASE_URL" ]]; then
# Already set via CLI argument
print_info "Using base URL from command line: $MOSAIC_BASE_URL"
if ! parse_base_url "$MOSAIC_BASE_URL"; then
print_error "Invalid base URL format. Expected: http(s)://host[:port]"
exit 1
fi
BASE_URL_MODE="custom"
if [[ -n "$BASE_URL_PORT" ]]; then
WEB_PORT="$BASE_URL_PORT"
else
if [[ "$BASE_URL_SCHEME" == "https" ]]; then
WEB_PORT="443"
else
WEB_PORT="80"
fi
fi
recalculate_urls
return
fi
if [[ "$NON_INTERACTIVE" == true ]]; then
BASE_URL_MODE="localhost"
BASE_URL_SCHEME="http"
BASE_URL_HOST="localhost"
BASE_URL_PORT="$WEB_PORT"
recalculate_urls
print_info "Non-interactive mode: using $MOSAIC_BASE_URL"
return
fi
echo "How will Mosaic Stack be accessed?"
echo ""
local options=(
"Localhost (recommended for local install)"
"Local network IP"
"Custom domain (direct ports)"
)
if [[ "$MODE" == "docker" ]]; then
options+=("Traefik reverse proxy (bundled or existing)")
fi
local selection
selection=$(select_option "Select access method:" "${options[@]}")
case "$selection" in
*"Localhost"*)
configure_localhost_url
;;
*"Local network IP"*)
configure_ip_url
;;
*"Custom domain"*)
configure_dns_url
;;
*"Traefik"*)
configure_traefik_url
;;
esac
}
configure_dns_url() {
echo ""
BASE_URL_MODE="domain"
local domain
while true; do
read -r -p "Enter your domain (e.g., mosaic.example.com): " domain
if validate_domain "$domain"; then
break
else
print_error "Invalid domain format"
fi
done
# Ask about SSL
local use_https=true
if ! confirm "Use HTTPS?" "y"; then
use_https=false
fi
if [[ "$use_https" == true ]]; then
BASE_URL_SCHEME="https"
print_warning "Consider using a reverse proxy for TLS certificates"
else
BASE_URL_SCHEME="http"
fi
BASE_URL_HOST="$domain"
local default_port="$WEB_PORT"
local port
while true; do
read -r -p "Enter web port [$default_port]: " port
port=${port:-$default_port}
if validate_port "$port"; then
break
else
print_error "Invalid port number"
fi
done
WEB_PORT="$port"
BASE_URL_PORT="$WEB_PORT"
recalculate_urls
print_success "Base URL set to: $MOSAIC_BASE_URL"
}
configure_localhost_url() {
echo ""
BASE_URL_MODE="localhost"
BASE_URL_SCHEME="http"
BASE_URL_HOST="localhost"
local default_port="$WEB_PORT"
local port
while true; do
read -r -p "Enter web port [$default_port]: " port
port=${port:-$default_port}
if validate_port "$port"; then
break
else
print_error "Invalid port number"
fi
done
WEB_PORT="$port"
BASE_URL_PORT="$WEB_PORT"
recalculate_urls
print_success "Base URL set to: $MOSAIC_BASE_URL"
}
configure_ip_url() {
echo ""
BASE_URL_MODE="ip"
BASE_URL_SCHEME="http"
# Try to detect local IP
local detected_ip
if command -v ip >/dev/null 2>&1; then
detected_ip=$(ip route get 1 2>/dev/null | awk '{print $7; exit}')
elif command -v ifconfig >/dev/null 2>&1; then
detected_ip=$(ifconfig | grep -Eo 'inet (addr:)?([0-9]*\.){3}[0-9]*' | grep -Eo '([0-9]*\.){3}[0-9]*' | grep -v '127.0.0.1' | head -n1)
fi
if [[ -n "$detected_ip" ]]; then
print_info "Detected IP: $detected_ip"
fi
local ip_addr
while true; do
read -r -p "Enter IP address${detected_ip:+ [$detected_ip]}: " ip_addr
ip_addr=${ip_addr:-$detected_ip}
if validate_ipv4 "$ip_addr"; then
break
else
print_error "Invalid IP address (must be 0.0.0.0 - 255.255.255.255)"
fi
done
BASE_URL_HOST="$ip_addr"
local default_port="$WEB_PORT"
local port
while true; do
read -r -p "Enter web port [$default_port]: " port
port=${port:-$default_port}
if validate_port "$port"; then
break
else
print_error "Invalid port number"
fi
done
WEB_PORT="$port"
BASE_URL_PORT="$WEB_PORT"
recalculate_urls
print_success "Base URL set to: $MOSAIC_BASE_URL"
}
configure_traefik_url() {
echo ""
print_info "Configuring Traefik integration"
echo ""
local mode_selection
mode_selection=$(select_option "Traefik mode:" \
"Bundled Traefik (run inside this stack)" \
"Upstream Traefik (existing instance)")
if [[ "$mode_selection" == *"Bundled"* ]]; then
TRAEFIK_MODE="bundled"
TRAEFIK_ENABLE=true
else
TRAEFIK_MODE="upstream"
TRAEFIK_ENABLE=true
fi
if confirm "Enable TLS/HTTPS for Traefik?" "y"; then
TRAEFIK_TLS_ENABLED=true
TRAEFIK_ENTRYPOINT="websecure"
BASE_URL_SCHEME="https"
else
TRAEFIK_TLS_ENABLED=false
TRAEFIK_ENTRYPOINT="web"
BASE_URL_SCHEME="http"
fi
BASE_URL_MODE="traefik"
local default_web_domain="mosaic.local"
while true; do
read -r -p "Web domain [$default_web_domain]: " MOSAIC_WEB_DOMAIN
MOSAIC_WEB_DOMAIN=${MOSAIC_WEB_DOMAIN:-$default_web_domain}
if validate_domain "$MOSAIC_WEB_DOMAIN"; then
break
fi
print_error "Invalid domain format"
done
local default_api_domain="api.${MOSAIC_WEB_DOMAIN}"
while true; do
read -r -p "API domain [$default_api_domain]: " MOSAIC_API_DOMAIN
MOSAIC_API_DOMAIN=${MOSAIC_API_DOMAIN:-$default_api_domain}
if validate_domain "$MOSAIC_API_DOMAIN"; then
break
fi
print_error "Invalid domain format"
done
if [[ "$ENABLE_SSO" == true ]]; then
local default_auth_domain="auth.${MOSAIC_WEB_DOMAIN}"
while true; do
read -r -p "Auth domain [$default_auth_domain]: " MOSAIC_AUTH_DOMAIN
MOSAIC_AUTH_DOMAIN=${MOSAIC_AUTH_DOMAIN:-$default_auth_domain}
if validate_domain "$MOSAIC_AUTH_DOMAIN"; then
break
fi
print_error "Invalid domain format"
done
fi
if [[ "$TRAEFIK_MODE" == "upstream" ]]; then
local default_network="traefik-public"
read -r -p "External Traefik network name [$default_network]: " TRAEFIK_NETWORK
TRAEFIK_NETWORK=${TRAEFIK_NETWORK:-$default_network}
TRAEFIK_DOCKER_NETWORK="$TRAEFIK_NETWORK"
fi
if [[ "$TRAEFIK_MODE" == "bundled" ]]; then
if confirm "Enable Traefik dashboard?" "y"; then
TRAEFIK_DASHBOARD_ENABLED=true
local default_dash_port="$TRAEFIK_DASHBOARD_PORT"
local dash_port
while true; do
read -r -p "Traefik dashboard port [$default_dash_port]: " dash_port
dash_port=${dash_port:-$default_dash_port}
if validate_port "$dash_port"; then
break
else
print_error "Invalid port number"
fi
done
TRAEFIK_DASHBOARD_PORT="$dash_port"
else
TRAEFIK_DASHBOARD_ENABLED=false
fi
fi
recalculate_urls
print_success "Base URL set to: $MOSAIC_BASE_URL"
print_info "Traefik domains configured for web and API"
}
configure_sso() {
echo ""
print_step "SSO Configuration (Authentik)"
echo ""
# Check if SSO already configured
local current_sso_enabled
current_sso_enabled=$(get_env_value "ENABLE_SSO")
if [[ -n "$ENABLE_SSO" ]]; then
# Already set via CLI
print_info "SSO enabled: $ENABLE_SSO"
elif [[ "$current_sso_enabled" == "true" ]]; then
echo "SSO is currently enabled"
if [[ "$NON_INTERACTIVE" == false ]] && ! confirm "Keep SSO enabled?" "y"; then
ENABLE_SSO=false
return
fi
ENABLE_SSO=true
else
if [[ "$NON_INTERACTIVE" == true ]]; then
ENABLE_SSO=false
return
fi
if ! confirm "Enable Authentik SSO?"; then
ENABLE_SSO=false
return
fi
ENABLE_SSO=true
fi
if [[ "$ENABLE_SSO" != true ]]; then
return
fi
# Bundled vs External Authentik
local current_bundled
current_bundled=$(get_env_value "USE_BUNDLED_AUTHENTIK")
if [[ -n "$USE_BUNDLED_AUTHENTIK" ]]; then
# Already set via CLI
print_info "Using bundled Authentik: $USE_BUNDLED_AUTHENTIK"
elif [[ "$current_bundled" == "true" ]]; then
echo "Currently using bundled Authentik"
if [[ "$NON_INTERACTIVE" == false ]] && ! confirm "Keep using bundled Authentik?" "y"; then
USE_BUNDLED_AUTHENTIK=false
else
USE_BUNDLED_AUTHENTIK=true
fi
else
if [[ "$NON_INTERACTIVE" == true ]]; then
USE_BUNDLED_AUTHENTIK=false
elif confirm "Use bundled Authentik server?" "y"; then
USE_BUNDLED_AUTHENTIK=true
else
USE_BUNDLED_AUTHENTIK=false
fi
fi
# If external, get URL
if [[ "$USE_BUNDLED_AUTHENTIK" != true ]]; then
local current_auth_url
current_auth_url=$(get_env_value "AUTHENTIK_BASE_URL")
if [[ -n "$EXTERNAL_AUTHENTIK_URL" ]]; then
EXTERNAL_AUTHENTIK_URL=$(strip_trailing_slash "$EXTERNAL_AUTHENTIK_URL")
elif [[ -n "$current_auth_url" ]] && ! is_placeholder "$current_auth_url"; then
echo "Current Authentik URL: $current_auth_url"
if [[ "$NON_INTERACTIVE" == true ]] || ! confirm "Change Authentik URL?" "n"; then
EXTERNAL_AUTHENTIK_URL=$(strip_trailing_slash "$current_auth_url")
else
read_authentik_url
fi
else
read_authentik_url
fi
if [[ -z "$EXTERNAL_AUTHENTIK_URL" ]]; then
print_error "External Authentik URL is required when not using bundled Authentik"
exit 1
fi
fi
if [[ "$USE_BUNDLED_AUTHENTIK" == true ]]; then
if [[ "$BASE_URL_MODE" == "traefik" && -z "$MOSAIC_AUTH_DOMAIN" ]]; then
if [[ "$NON_INTERACTIVE" == true ]]; then
MOSAIC_AUTH_DOMAIN="auth.${MOSAIC_WEB_DOMAIN:-mosaic.local}"
else
local default_auth_domain="auth.${MOSAIC_WEB_DOMAIN:-mosaic.local}"
read -r -p "Auth domain [$default_auth_domain]: " MOSAIC_AUTH_DOMAIN
MOSAIC_AUTH_DOMAIN=${MOSAIC_AUTH_DOMAIN:-$default_auth_domain}
fi
fi
fi
recalculate_urls
}
read_authentik_url() {
while true; do
read -r -p "Enter external Authentik URL: " EXTERNAL_AUTHENTIK_URL
if validate_url "$EXTERNAL_AUTHENTIK_URL"; then
EXTERNAL_AUTHENTIK_URL=$(strip_trailing_slash "$EXTERNAL_AUTHENTIK_URL")
break
else
print_error "Invalid URL format"
fi
done
}
configure_ollama() {
echo ""
print_step "Ollama Configuration"
echo ""
local current_mode
current_mode=$(get_env_value "OLLAMA_MODE")
if [[ -n "$OLLAMA_MODE" ]] && [[ "$OLLAMA_MODE" != "disabled" ]]; then
# Already set via CLI
print_info "Ollama mode: $OLLAMA_MODE"
elif [[ -n "$current_mode" ]] && [[ "$current_mode" != "disabled" ]]; then
echo "Current Ollama mode: $current_mode"
if [[ "$NON_INTERACTIVE" == false ]] && confirm "Change Ollama configuration?" "n"; then
select_ollama_mode
else
OLLAMA_MODE="$current_mode"
fi
else
select_ollama_mode
fi
# Get Ollama URL if remote mode
if [[ "$OLLAMA_MODE" == "remote" ]]; then
local current_url
current_url=$(get_env_value "OLLAMA_ENDPOINT")
if [[ -n "$OLLAMA_URL" ]]; then
# Already set via CLI
print_info "Ollama URL: $OLLAMA_URL"
elif [[ -n "$current_url" ]] && ! is_placeholder "$current_url"; then
echo "Current Ollama URL: $current_url"
if [[ "$NON_INTERACTIVE" == false ]] && confirm "Change Ollama URL?" "n"; then
read_ollama_url
else
OLLAMA_URL="$current_url"
fi
else
read_ollama_url
fi
fi
}
select_ollama_mode() {
if [[ "$NON_INTERACTIVE" == true ]]; then
OLLAMA_MODE="disabled"
return
fi
local selection
selection=$(select_option "Select Ollama mode:" \
"Disabled (no local LLM)" \
"Local (run Ollama in Docker)" \
"Remote (connect to existing Ollama server)")
case "$selection" in
*"Disabled"*)
OLLAMA_MODE="disabled"
;;
*"Local"*)
OLLAMA_MODE="local"
;;
*"Remote"*)
OLLAMA_MODE="remote"
;;
esac
}
read_ollama_url() {
while true; do
read -r -p "Enter Ollama server URL: " OLLAMA_URL
if validate_url "$OLLAMA_URL"; then
break
else
print_error "Invalid URL format"
fi
done
}
configure_moltbot() {
echo ""
print_step "MoltBot Configuration"
echo ""
local current_enabled
current_enabled=$(get_env_value "ENABLE_MOLTBOT")
if [[ -n "$ENABLE_MOLTBOT" ]]; then
# Already set via CLI
print_info "MoltBot enabled: $ENABLE_MOLTBOT"
elif [[ "$current_enabled" == "true" ]]; then
echo "MoltBot is currently enabled"
if [[ "$NON_INTERACTIVE" == false ]] && ! confirm "Keep MoltBot enabled?" "y"; then
ENABLE_MOLTBOT=false
else
ENABLE_MOLTBOT=true
fi
else
if [[ "$NON_INTERACTIVE" == true ]]; then
ENABLE_MOLTBOT=false
elif confirm "Enable MoltBot integration?"; then
ENABLE_MOLTBOT=true
else
ENABLE_MOLTBOT=false
fi
fi
}
configure_secrets() {
echo ""
print_step "Secret Generation"
echo ""
# Check if secrets exist and are not placeholders
local db_password
local jwt_secret
local auth_db_password
local auth_secret_key
local auth_bootstrap_password
db_password=$(get_env_value "POSTGRES_PASSWORD")
jwt_secret=$(get_env_value "JWT_SECRET")
auth_db_password=$(get_env_value "AUTHENTIK_POSTGRES_PASSWORD")
auth_secret_key=$(get_env_value "AUTHENTIK_SECRET_KEY")
auth_bootstrap_password=$(get_env_value "AUTHENTIK_BOOTSTRAP_PASSWORD")
local needs_generation=false
if is_placeholder "$db_password"; then
print_info "Will generate database password"
needs_generation=true
fi
if is_placeholder "$jwt_secret"; then
print_info "Will generate JWT secret"
needs_generation=true
fi
if [[ "$ENABLE_SSO" == true && "$USE_BUNDLED_AUTHENTIK" == true ]]; then
if is_placeholder "$auth_db_password"; then
print_info "Will generate Authentik database password"
needs_generation=true
fi
if is_placeholder "$auth_secret_key"; then
print_info "Will generate Authentik secret key"
needs_generation=true
fi
if is_placeholder "$auth_bootstrap_password"; then
print_info "Will generate Authentik bootstrap password"
needs_generation=true
fi
fi
if [[ "$needs_generation" == true ]]; then
print_success "Secrets will be generated during .env creation"
else
print_success "Existing secrets will be preserved"
fi
}
# ============================================================================
# .env File Generation
# ============================================================================
declare -gA ENV_OVERRIDES
env_override_set() {
local key="$1"
local value="$2"
ENV_OVERRIDES["$key"]="$value"
}
write_env_with_overrides() {
local template="$1"
local output="$2"
local overrides_file
overrides_file=$(mktemp)
for key in "${!ENV_OVERRIDES[@]}"; do
printf '%s=%s\n' "$key" "${ENV_OVERRIDES[$key]}" >> "$overrides_file"
done
awk -v overrides_file="$overrides_file" '
BEGIN {
while ((getline < overrides_file) > 0) {
line = $0
if (line ~ /^[A-Za-z0-9_]+=*/) {
eq = index(line, "=")
key = substr(line, 1, eq - 1)
value = substr(line, eq + 1)
map[key] = value
}
}
close(overrides_file)
}
{
if ($0 ~ /^[A-Za-z0-9_]+=/) {
eq = index($0, "=")
key = substr($0, 1, eq - 1)
if (key in map) {
print key "=" map[key]
used[key] = 1
next
}
}
print
}
END {
for (key in map) {
if (!(key in used)) {
print key "=" map[key]
}
}
}
' "$template" > "$output"
rm -f "$overrides_file"
}
generate_env_file() {
print_header ".env File Generation"
# Backup existing .env if it exists
if [[ -f "$PROJECT_ROOT/.env" ]]; then
backup_file "$PROJECT_ROOT/.env"
fi
echo ""
print_step "Generating .env file with your configuration..."
echo ""
# Start with .env.example template
local template="$PROJECT_ROOT/.env.example"
local output="$PROJECT_ROOT/.env"
if [[ ! -f "$template" ]]; then
print_error ".env.example template not found"
exit 1
fi
ENV_OVERRIDES=()
for key in "${!ENV_VALUES[@]}"; do
local value="${ENV_VALUES[$key]}"
if [[ -n "$value" ]] && ! is_placeholder "$value"; then
env_override_set "$key" "$value"
fi
done
local xtrace_was_on=false
if [[ "$-" == *x* ]]; then
xtrace_was_on=true
set +x
fi
local postgres_user
local postgres_db
local db_password
local jwt_secret
local jwt_expiration
local api_host
local valkey_url
local database_url
postgres_user=$(resolve_env_value "POSTGRES_USER" "mosaic")
postgres_db=$(resolve_env_value "POSTGRES_DB" "mosaic")
api_host=$(resolve_env_value "API_HOST" "0.0.0.0")
jwt_expiration=$(resolve_env_value "JWT_EXPIRATION" "24h")
local existing_db_password
existing_db_password=$(get_env_value "POSTGRES_PASSWORD")
db_password=$(resolve_secret_value "POSTGRES_PASSWORD" generate_password 32)
if [[ -n "$existing_db_password" ]] && ! is_placeholder "$existing_db_password"; then
print_info "Preserving existing database password"
else
print_info "Generated database password"
fi
local existing_jwt_secret
existing_jwt_secret=$(get_env_value "JWT_SECRET")
jwt_secret=$(resolve_secret_value "JWT_SECRET" generate_secret 50)
if [[ -n "$existing_jwt_secret" ]] && ! is_placeholder "$existing_jwt_secret"; then
print_info "Preserving existing JWT secret"
else
print_info "Generated JWT secret"
fi
if [[ "$MODE" == "docker" ]]; then
database_url="postgresql://${postgres_user}:${db_password}@postgres:5432/${postgres_db}"
valkey_url="redis://valkey:6379"
else
database_url="postgresql://${postgres_user}:${db_password}@localhost:${POSTGRES_PORT}/${postgres_db}"
valkey_url="redis://localhost:${VALKEY_PORT}"
fi
local authentik_db_password=""
local authentik_secret_key=""
local authentik_bootstrap_password=""
local authentik_postgres_user=""
local authentik_postgres_db=""
local authentik_bootstrap_email=""
local authentik_cookie_domain=""
if [[ "$ENABLE_SSO" == true && "$USE_BUNDLED_AUTHENTIK" == true ]]; then
authentik_postgres_user=$(resolve_env_value "AUTHENTIK_POSTGRES_USER" "authentik")
authentik_postgres_db=$(resolve_env_value "AUTHENTIK_POSTGRES_DB" "authentik")
authentik_bootstrap_email=$(resolve_env_value "AUTHENTIK_BOOTSTRAP_EMAIL" "admin@localhost")
local existing_auth_db_password
existing_auth_db_password=$(get_env_value "AUTHENTIK_POSTGRES_PASSWORD")
authentik_db_password=$(resolve_secret_value "AUTHENTIK_POSTGRES_PASSWORD" generate_password 32)
if [[ -n "$existing_auth_db_password" ]] && ! is_placeholder "$existing_auth_db_password"; then
print_info "Preserving existing Authentik database password"
else
print_info "Generated Authentik database password"
fi
local existing_auth_secret
existing_auth_secret=$(get_env_value "AUTHENTIK_SECRET_KEY")
authentik_secret_key=$(resolve_secret_value "AUTHENTIK_SECRET_KEY" generate_secret 64)
if [[ -n "$existing_auth_secret" ]] && ! is_placeholder "$existing_auth_secret"; then
print_info "Preserving existing Authentik secret key"
else
print_info "Generated Authentik secret key"
fi
local existing_auth_bootstrap
existing_auth_bootstrap=$(get_env_value "AUTHENTIK_BOOTSTRAP_PASSWORD")
authentik_bootstrap_password=$(resolve_secret_value "AUTHENTIK_BOOTSTRAP_PASSWORD" generate_password 24)
if [[ -n "$existing_auth_bootstrap" ]] && ! is_placeholder "$existing_auth_bootstrap"; then
print_info "Preserving existing Authentik bootstrap password"
else
print_info "Generated Authentik bootstrap password"
fi
authentik_cookie_domain=$(resolve_env_value "AUTHENTIK_COOKIE_DOMAIN" "")
if [[ -z "$authentik_cookie_domain" ]]; then
if [[ "$BASE_URL_MODE" == "traefik" && -n "$MOSAIC_AUTH_DOMAIN" ]]; then
authentik_cookie_domain=$(derive_cookie_domain "$MOSAIC_AUTH_DOMAIN")
else
authentik_cookie_domain=".localhost"
fi
fi
fi
local oidc_issuer=""
local oidc_client_id=""
local oidc_client_secret=""
local oidc_redirect_uri=""
if [[ "$ENABLE_SSO" == true ]]; then
local auth_url
if [[ "$USE_BUNDLED_AUTHENTIK" == true ]]; then
auth_url=$(strip_trailing_slash "$AUTHENTIK_PUBLIC_URL")
else
auth_url=$(strip_trailing_slash "$EXTERNAL_AUTHENTIK_URL")
fi
oidc_issuer="${auth_url}/application/o/mosaic-stack/"
oidc_client_id=$(resolve_env_value "OIDC_CLIENT_ID" "mosaic-stack")
oidc_client_secret=$(resolve_env_value "OIDC_CLIENT_SECRET" "change-after-authentik-setup")
oidc_redirect_uri="${API_BASE_URL}/auth/callback"
fi
local ollama_mode_env=""
local ollama_endpoint=""
if [[ "$OLLAMA_MODE" == "local" ]]; then
ollama_mode_env="local"
ollama_endpoint="http://ollama:11434"
elif [[ "$OLLAMA_MODE" == "remote" ]]; then
ollama_mode_env="remote"
ollama_endpoint="$OLLAMA_URL"
else
ollama_mode_env="remote"
ollama_endpoint=$(resolve_env_value "OLLAMA_ENDPOINT" "http://localhost:11434")
fi
local compose_profiles=()
if [[ "$ENABLE_SSO" == true && "$USE_BUNDLED_AUTHENTIK" == true ]]; then
compose_profiles+=("authentik")
fi
if [[ "$OLLAMA_MODE" == "local" ]]; then
compose_profiles+=("ollama")
fi
if [[ "$TRAEFIK_MODE" == "bundled" ]]; then
compose_profiles+=("traefik-bundled")
fi
local profiles_string=""
if [[ ${#compose_profiles[@]} -gt 0 ]]; then
profiles_string=$(IFS=,; echo "${compose_profiles[*]}")
fi
env_override_set "API_PORT" "$API_PORT"
env_override_set "API_HOST" "$api_host"
env_override_set "WEB_PORT" "$WEB_PORT"
env_override_set "POSTGRES_USER" "$postgres_user"
env_override_set "POSTGRES_PASSWORD" "$db_password"
env_override_set "POSTGRES_DB" "$postgres_db"
env_override_set "POSTGRES_PORT" "$POSTGRES_PORT"
env_override_set "DATABASE_URL" "$database_url"
env_override_set "VALKEY_URL" "$valkey_url"
env_override_set "VALKEY_PORT" "$VALKEY_PORT"
env_override_set "NEXT_PUBLIC_API_URL" "$API_BASE_URL"
if [[ -n "$MOSAIC_BASE_URL" ]]; then
env_override_set "MOSAIC_BASE_URL" "$MOSAIC_BASE_URL"
fi
env_override_set "JWT_SECRET" "$jwt_secret"
env_override_set "JWT_EXPIRATION" "$jwt_expiration"
env_override_set "OLLAMA_MODE" "$ollama_mode_env"
env_override_set "OLLAMA_ENDPOINT" "$ollama_endpoint"
if [[ -n "$profiles_string" ]]; then
env_override_set "COMPOSE_PROFILES" "$profiles_string"
else
env_override_set "COMPOSE_PROFILES" ""
fi
env_override_set "TRAEFIK_MODE" "$TRAEFIK_MODE"
env_override_set "TRAEFIK_ENABLE" "$(bool_str "$TRAEFIK_ENABLE")"
env_override_set "TRAEFIK_TLS_ENABLED" "$(bool_str "$TRAEFIK_TLS_ENABLED")"
env_override_set "TRAEFIK_ENTRYPOINT" "$TRAEFIK_ENTRYPOINT"
if [[ -n "$TRAEFIK_NETWORK" ]]; then
env_override_set "TRAEFIK_NETWORK" "$TRAEFIK_NETWORK"
fi
if [[ -n "$TRAEFIK_DOCKER_NETWORK" ]]; then
env_override_set "TRAEFIK_DOCKER_NETWORK" "$TRAEFIK_DOCKER_NETWORK"
fi
if [[ -n "$MOSAIC_WEB_DOMAIN" ]]; then
env_override_set "MOSAIC_WEB_DOMAIN" "$MOSAIC_WEB_DOMAIN"
fi
if [[ -n "$MOSAIC_API_DOMAIN" ]]; then
env_override_set "MOSAIC_API_DOMAIN" "$MOSAIC_API_DOMAIN"
fi
if [[ -n "$MOSAIC_AUTH_DOMAIN" ]]; then
env_override_set "MOSAIC_AUTH_DOMAIN" "$MOSAIC_AUTH_DOMAIN"
fi
if [[ "$TRAEFIK_MODE" == "bundled" ]]; then
env_override_set "TRAEFIK_HTTP_PORT" "$TRAEFIK_HTTP_PORT"
env_override_set "TRAEFIK_HTTPS_PORT" "$TRAEFIK_HTTPS_PORT"
env_override_set "TRAEFIK_DASHBOARD_ENABLED" "$(bool_str "$TRAEFIK_DASHBOARD_ENABLED")"
env_override_set "TRAEFIK_DASHBOARD_PORT" "$TRAEFIK_DASHBOARD_PORT"
if [[ -n "$TRAEFIK_ACME_EMAIL" ]]; then
env_override_set "TRAEFIK_ACME_EMAIL" "$TRAEFIK_ACME_EMAIL"
fi
if [[ -n "$TRAEFIK_CERTRESOLVER" ]]; then
env_override_set "TRAEFIK_CERTRESOLVER" "$TRAEFIK_CERTRESOLVER"
fi
fi
if [[ "$ENABLE_SSO" == true ]]; then
env_override_set "OIDC_ISSUER" "$oidc_issuer"
env_override_set "OIDC_CLIENT_ID" "$oidc_client_id"
env_override_set "OIDC_CLIENT_SECRET" "$oidc_client_secret"
env_override_set "OIDC_REDIRECT_URI" "$oidc_redirect_uri"
fi
if [[ "$ENABLE_SSO" == true && "$USE_BUNDLED_AUTHENTIK" == true ]]; then
env_override_set "AUTHENTIK_POSTGRES_USER" "$authentik_postgres_user"
env_override_set "AUTHENTIK_POSTGRES_PASSWORD" "$authentik_db_password"
env_override_set "AUTHENTIK_POSTGRES_DB" "$authentik_postgres_db"
env_override_set "AUTHENTIK_SECRET_KEY" "$authentik_secret_key"
env_override_set "AUTHENTIK_BOOTSTRAP_PASSWORD" "$authentik_bootstrap_password"
env_override_set "AUTHENTIK_BOOTSTRAP_EMAIL" "$authentik_bootstrap_email"
env_override_set "AUTHENTIK_COOKIE_DOMAIN" "$authentik_cookie_domain"
env_override_set "AUTHENTIK_PORT_HTTP" "$AUTHENTIK_PORT_HTTP"
env_override_set "AUTHENTIK_PORT_HTTPS" "$AUTHENTIK_PORT_HTTPS"
fi
if [[ "$OLLAMA_MODE" == "local" ]]; then
env_override_set "OLLAMA_PORT" "$OLLAMA_PORT"
fi
write_env_with_overrides "$template" "$output"
chmod 600 "$output"
print_success "Generated .env file"
# Write credentials file
write_credentials_file "$db_password" "$authentik_bootstrap_password"
if [[ "$xtrace_was_on" == true ]]; then
set -x
fi
}
write_credentials_file() {
local db_password="$1"
local authentik_password="$2"
local creds_file="$PROJECT_ROOT/.admin-credentials"
local db_user
db_user=$(resolve_env_value "POSTGRES_USER" "mosaic")
cat > "$creds_file" << EOF
# Mosaic Stack Admin Credentials
# Generated: $(date)
# KEEP THIS FILE SECURE AND DO NOT COMMIT TO VERSION CONTROL
Database (PostgreSQL):
Username: ${db_user}
Password: $(mask_value "$db_password")
Full password available in .env as POSTGRES_PASSWORD
JWT Secret:
Available in .env as JWT_SECRET
EOF
if [[ "$ENABLE_SSO" == true && -n "$authentik_password" ]]; then
cat >> "$creds_file" << EOF
Authentik Admin:
Email: admin@localhost
Password: $(mask_value "$authentik_password")
Full password available in .env as AUTHENTIK_BOOTSTRAP_PASSWORD
URL: ${AUTHENTIK_PUBLIC_URL:-http://localhost:9000}
IMPORTANT: Change the bootstrap password after first login!
EOF
fi
chmod 600 "$creds_file"
print_success "Saved credentials to .admin-credentials"
}
# ============================================================================
# Deployment
# ============================================================================
run_deployment() {
print_header "Deployment"
if [[ "$MODE" == "docker" ]]; then
deploy_docker
else
deploy_native
fi
}
prepare_traefik_upstream() {
if [[ "$TRAEFIK_MODE" != "upstream" ]]; then
return
fi
print_header "Traefik Upstream Setup"
local network="${TRAEFIK_NETWORK:-traefik-public}"
if ! docker network ls --format '{{.Name}}' | grep -q "^${network}$"; then
print_warning "Traefik network '${network}' not found"
if [[ "$NON_INTERACTIVE" == true ]] || confirm "Create network '${network}' now?" "y"; then
docker network create "$network"
print_success "Created network: ${network}"
else
print_warning "Continuing without creating network"
fi
fi
local override_file="$PROJECT_ROOT/docker-compose.override.yml"
if [[ -f "$override_file" ]]; then
print_info "docker-compose.override.yml already exists. Leaving it unchanged."
return
fi
cat > "$override_file" << 'EOF'
version: '3.9'
services:
api:
networks:
- traefik-public
web:
networks:
- traefik-public
authentik-server:
networks:
- traefik-public
networks:
traefik-public:
external: true
name: ${TRAEFIK_NETWORK:-traefik-public}
EOF
print_success "Created docker-compose.override.yml for upstream Traefik"
}
deploy_docker() {
echo ""
print_step "Starting Docker deployment..."
echo ""
prepare_traefik_upstream
# Check if docker-compose.yml exists
if [[ ! -f "$PROJECT_ROOT/docker-compose.yml" ]]; then
print_error "docker-compose.yml not found"
exit 1
fi
cd "$PROJECT_ROOT" || exit 1
local compose_files=("-f" "docker-compose.yml")
if [[ -f "docker-compose.override.yml" ]]; then
compose_files+=("-f" "docker-compose.override.yml")
fi
# Set up error trap for rollback
local deployment_started=false
rollback_deployment() {
if [[ "$deployment_started" == true ]]; then
print_warning "Deployment failed, rolling back..."
docker compose "${compose_files[@]}" down --remove-orphans 2>/dev/null || true
print_info "Rollback complete"
fi
}
trap rollback_deployment ERR
# Pull images
print_step "Pulling Docker images..."
if ! docker compose "${compose_files[@]}" pull; then
print_warning "Failed to pull some images (will build locally)"
fi
# Build services
print_step "Building services..."
if ! docker compose "${compose_files[@]}" build; then
print_error "Build failed"
trap - ERR
return 1
fi
# Start services
print_step "Starting services..."
deployment_started=true
if ! docker compose "${compose_files[@]}" up -d; then
print_error "Failed to start services"
trap - ERR
rollback_deployment
return 1
fi
# Clear trap on success
trap - ERR
# Wait for services to be healthy
echo ""
print_step "Waiting for services to start..."
sleep 5
# Check service health
local all_healthy=true
local services=("postgres" "valkey" "api" "web")
for service in "${services[@]}"; do
if docker compose "${compose_files[@]}" ps "$service" 2>/dev/null | grep -q "Up"; then
print_success "$service: Running"
else
print_warning "$service: Not running"
all_healthy=false
fi
done
if [[ "$all_healthy" == true ]]; then
echo ""
print_success "All services started successfully"
else
echo ""
print_warning "Some services may not be running correctly"
print_info "Check logs with: docker compose logs"
fi
}
deploy_native() {
echo ""
print_step "Native deployment setup..."
echo ""
cd "$PROJECT_ROOT" || exit 1
# Install dependencies
print_step "Installing Node.js dependencies..."
if ! pnpm install; then
print_error "Failed to install dependencies"
exit 1
fi
# Database setup
print_step "Setting up database..."
print_info "Make sure PostgreSQL is running and accessible"
# Run migrations
print_step "Running database migrations..."
if ! pnpm -F api run prisma:migrate; then
print_warning "Database migration failed - you may need to set this up manually"
fi
# Build
print_step "Building application..."
pnpm run build
print_success "Native setup complete"
echo ""
print_info "To start the application:"
print_info " pnpm dev # Development mode"
print_info " pnpm start # Production mode"
}
# ============================================================================
# Post-Install Information
# ============================================================================
show_post_install_info() {
if [[ "$DRY_RUN" == true ]]; then
print_header "Dry Run Summary"
echo ""
print_info "No files were written and no containers were started."
echo ""
print_step "Planned URLs:"
echo ""
if [[ "$MODE" == "docker" ]]; then
echo " Web Interface: $MOSAIC_BASE_URL"
echo " API: $API_BASE_URL"
if [[ "$ENABLE_SSO" == true ]]; then
echo " Authentik SSO: ${AUTHENTIK_PUBLIC_URL:-http://localhost:9000}"
fi
fi
echo ""
return
fi
print_header "Installation Complete"
echo ""
echo "🎉 Mosaic Stack has been set up successfully!"
echo ""
# Show URLs
print_step "Access URLs:"
echo ""
if [[ "$MODE" == "docker" ]]; then
local web_url="$MOSAIC_BASE_URL"
local api_url="$API_BASE_URL"
echo " Web Interface: $web_url"
echo " API: $api_url"
if [[ "$ENABLE_SSO" == true ]]; then
local auth_url="${AUTHENTIK_PUBLIC_URL:-http://localhost:9000}"
echo " Authentik SSO: $auth_url"
fi
if [[ "$OLLAMA_MODE" == "local" ]]; then
echo " Ollama: http://localhost:${OLLAMA_PORT}"
fi
else
echo " Run 'pnpm dev' to start the development server"
fi
echo ""
print_step "Credentials:"
echo ""
echo " Saved to: .admin-credentials"
print_warning "Keep this file secure!"
echo ""
print_step "Next Steps:"
echo ""
if [[ "$ENABLE_SSO" == true ]]; then
echo " 1. Access Authentik at ${AUTHENTIK_PUBLIC_URL:-http://localhost:9000}"
echo " 2. Log in with credentials from .admin-credentials"
echo " 3. Complete SSO setup (create application, get client secret)"
echo " 4. Update OIDC_CLIENT_SECRET in .env"
echo " 5. Access Mosaic Stack at $MOSAIC_BASE_URL"
else
echo " 1. Access Mosaic Stack at $MOSAIC_BASE_URL"
echo " 2. Create your first user account"
fi
if [[ "$TRAEFIK_MODE" == "upstream" ]]; then
echo ""
print_info "Upstream Traefik: ensure your Traefik is attached to '${TRAEFIK_NETWORK:-traefik-public}'"
elif [[ "$TRAEFIK_MODE" == "bundled" && "$TRAEFIK_DASHBOARD_ENABLED" == true ]]; then
echo ""
print_info "Traefik dashboard: http://localhost:${TRAEFIK_DASHBOARD_PORT}/dashboard/"
fi
echo ""
print_step "Useful Commands:"
echo ""
if [[ "$MODE" == "docker" ]]; then
echo " View logs: docker compose logs -f"
echo " Stop services: docker compose down"
echo " Restart: docker compose restart"
echo " View status: docker compose ps"
else
echo " Development: pnpm dev"
echo " Production: pnpm start"
echo " Database: pnpm -F api run prisma:migrate"
fi
echo ""
print_step "Documentation:"
echo ""
echo " Setup docs: /home/jwoltje/src/mosaic-stack/scripts/README.md"
echo " Architecture: /home/jwoltje/src/mosaic-stack/docs/"
echo ""
}
# ============================================================================
# Main Execution
# ============================================================================
main() {
parse_arguments "$@"
show_banner
detect_platform
select_deployment_mode
check_and_install_dependencies
collect_configuration
resolve_port_conflicts
if [[ "$DRY_RUN" != true ]]; then
generate_env_file
run_deployment
else
echo ""
print_warning "Dry run mode - skipping .env generation and deployment"
echo ""
print_info "Configuration collected but not applied"
fi
show_post_install_info
echo ""
print_success "Setup complete!"
echo ""
echo "==================================================================="
echo "Setup completed: $(date)"
echo "Full log saved to: $LOG_FILE"
echo "==================================================================="
echo ""
# Reset terminal colors before exiting
printf "${NC}"
}
# Run main function
main "$@"