changemaker.lite/config.sh
bunker-admin f2284a9cdf Fix curl|bash install: redirect stdin from /dev/tty for interactive prompts
When piped (curl | bash), stdin is the curl output, not the terminal.
All read prompts in config.sh were reading leftover pipe data or EOF,
causing infinite password validation loops and garbage domain values.

Bunker Admin
2026-03-25 19:45:29 -06:00

1397 lines
45 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# Changemaker Lite V2 — Configuration Wizard
# Produces a working .env file and stages the full application stack.
# =============================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/.env"
ENV_EXAMPLE="$SCRIPT_DIR/.env.example"
MKDOCS_YML="$SCRIPT_DIR/mkdocs/mkdocs.yml"
SERVICES_YAML="$SCRIPT_DIR/configs/homepage/services.yaml"
# --- Detect install mode ---
# Release mode: installed from tarball (has VERSION file, no .git directory)
# Source mode: cloned from git repository
if [[ -f "$SCRIPT_DIR/VERSION" ]] && [[ ! -d "$SCRIPT_DIR/.git" ]]; then
INSTALL_MODE="release"
RELEASE_VERSION=$(head -1 "$SCRIPT_DIR/VERSION")
else
INSTALL_MODE="source"
RELEASE_VERSION=""
fi
# --- Colors (respects NO_COLOR convention) ---
if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m'
DIM='\033[2m' NC='\033[0m'
else
RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' DIM='' NC=''
fi
# =============================================================================
# Ensure stdin is connected to the terminal (handles curl | bash case)
# =============================================================================
if [[ ! -t 0 ]]; then
if [[ -e /dev/tty ]]; then
exec 0</dev/tty
else
echo "[ERR] This script requires an interactive terminal." >&2
echo " Download and run manually: bash config.sh" >&2
exit 1
fi
fi
# =============================================================================
# Utility Functions
# =============================================================================
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERR]${NC} $*" >&2; }
header() {
echo ""
echo -e "${BOLD}${BLUE}── $* ──${NC}"
echo ""
}
generate_secret() {
openssl rand -hex 32
}
generate_password() {
local length=${1:-24}
openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length"
}
# Line-by-line .env replacement — robust with special characters
update_env_var() {
local key=$1
local value=$2
if grep -q "^${key}=" "$ENV_FILE"; then
local tmpfile
tmpfile=$(mktemp)
while IFS= read -r line; do
if [[ "$line" =~ ^${key}= ]]; then
echo "${key}=${value}" >> "$tmpfile"
else
echo "$line" >> "$tmpfile"
fi
done < "$ENV_FILE"
mv "$tmpfile" "$ENV_FILE"
else
echo "${key}=${value}" >> "$ENV_FILE"
fi
}
backup_env_file() {
if [[ -f "$ENV_FILE" ]]; then
local ts
ts=$(date +"%Y%m%d_%H%M%S")
local backup="$ENV_FILE.backup_$ts"
cp "$ENV_FILE" "$backup"
success "Backed up .env to ${backup##*/}"
fi
}
validate_password() {
local pw=$1
[[ ${#pw} -ge 12 ]] || return 1
[[ "$pw" =~ [A-Z] ]] || return 1
[[ "$pw" =~ [a-z] ]] || return 1
[[ "$pw" =~ [0-9] ]] || return 1
return 0
}
prompt_yes_no() {
local prompt=$1 default=${2:-n}
local yn
if [[ "$default" == "y" ]]; then
read -rp "$prompt [Y/n]: " yn
[[ "$yn" =~ ^[Nn]$ ]] && return 1 || return 0
else
read -rp "$prompt [y/N]: " yn
[[ "$yn" =~ ^[Yy]$ ]] && return 0 || return 1
fi
}
# =============================================================================
# Prerequisites
# =============================================================================
check_prerequisites() {
header "Checking Prerequisites"
local ok=true
if command -v docker &>/dev/null; then
success "Docker found: $(docker --version | head -1)"
else
error "Docker is not installed. See https://docs.docker.com/get-docker/"
ok=false
fi
if docker compose version &>/dev/null; then
success "Docker Compose found: $(docker compose version --short)"
else
error "Docker Compose v2 plugin not found. See https://docs.docker.com/compose/install/"
ok=false
fi
if command -v openssl &>/dev/null; then
success "OpenSSL found"
else
error "OpenSSL is not installed (needed for secret generation)"
ok=false
fi
$ok || { echo ""; error "Missing prerequisites. Install them and re-run."; exit 1; }
}
# =============================================================================
# Banner
# =============================================================================
print_banner() {
cat << 'EOF'
██████╗██╗ ██╗ █████╗ ███╗ ██╗ ██████╗ ███████╗
██╔════╝██║ ██║██╔══██╗████╗ ██║██╔════╝ ██╔════╝
██║ ███████║███████║██╔██╗ ██║██║ ███╗█████╗
██║ ██╔══██║██╔══██║██║╚██╗██║██║ ██║██╔══╝
╚██████╗██║ ██║██║ ██║██║ ╚████║╚██████╔╝███████╗
╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚══════╝
███╗ ███╗ █████╗ ██╗ ██╗███████╗██████╗
████╗ ████║██╔══██╗██║ ██╔╝██╔════╝██╔══██╗
██╔████╔██║███████║█████╔╝ █████╗ ██████╔╝
██║╚██╔╝██║██╔══██║██╔═██╗ ██╔══╝ ██╔══██╗
██║ ╚═╝ ██║██║ ██║██║ ██╗███████╗██║ ██║
╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝
V2 Configuration Wizard
EOF
echo ""
info "This wizard will create your .env file, generate secure secrets,"
info "and prepare your system to run the full Changemaker Lite stack."
echo ""
}
# =============================================================================
# .env Initialization
# =============================================================================
initialize_env() {
header "Environment File Setup"
if [[ ! -f "$ENV_EXAMPLE" ]]; then
error ".env.example not found at $ENV_EXAMPLE"
error "Make sure you're running this from the project root."
exit 1
fi
RECONFIGURE_MODE=false
if [[ -f "$ENV_FILE" ]]; then
warn "Existing .env file found at $ENV_FILE"
if prompt_yes_no "Back up existing .env and create a fresh one?"; then
backup_env_file
cp "$ENV_EXAMPLE" "$ENV_FILE"
success "Created fresh .env from .env.example"
else
info "Keeping existing .env. Existing secrets will be preserved."
RECONFIGURE_MODE=true
fi
else
cp "$ENV_EXAMPLE" "$ENV_FILE"
success "Created .env from .env.example"
fi
}
# =============================================================================
# Configuration Sections
# =============================================================================
configure_domain() {
header "Domain Configuration"
info "Root domain serves MkDocs documentation."
info "All application routes are at app.<domain>."
echo ""
read -rp "Enter your domain (e.g., example.org) [default: cmlite.org]: " domain
domain=${domain:-cmlite.org}
update_env_var "DOMAIN" "$domain"
update_env_var "BASE_DOMAIN" "https://$domain"
update_env_var "GITEA_ROOT_URL" "https://git.$domain"
update_env_var "GITEA_DOMAIN" "git.$domain"
update_env_var "N8N_HOST" "n8n.$domain"
update_env_var "SMTP_FROM" "noreply@$domain"
update_env_var "SMTP_FROM_NAME" "Changemaker Lite"
update_env_var "INITIAL_ADMIN_EMAIL" "admin@$domain"
update_env_var "NC_ADMIN_EMAIL" "admin@$domain"
update_env_var "TEST_EMAIL_RECIPIENT" "admin@$domain"
update_env_var "EXCALIDRAW_WS_URL" "wss://draw.$domain"
update_env_var "LISTMONK_SMTP_FROM" "Changemaker Lite <noreply@$domain>"
update_env_var "HOMEPAGE_VAR_BASE_URL" "https://$domain"
update_env_var "VAULTWARDEN_DOMAIN" "https://vault.$domain"
update_env_var "GANCIO_BASE_URL" "https://events.$domain"
# Update mkdocs.yml
if [[ -f "$MKDOCS_YML" ]]; then
sed -i "s|^site_url:.*|site_url: https://$domain|" "$MKDOCS_YML"
sed -i "s|^repo_url:.*|repo_url: https://git.$domain/admin/changemaker.lite|" "$MKDOCS_YML"
success "Updated mkdocs.yml (site_url, repo_url)"
else
warn "mkdocs.yml not found — skipping"
fi
success "Domain set to: $domain"
echo ""
if prompt_yes_no "Is this a production deployment?"; then
update_env_var "NODE_ENV" "production"
success "NODE_ENV set to production"
IS_PRODUCTION="yes"
else
info "NODE_ENV stays as development"
IS_PRODUCTION="no"
fi
# Store for later use
CONFIGURED_DOMAIN="$domain"
}
configure_admin() {
header "Admin Credentials"
local default_email="admin@${CONFIGURED_DOMAIN:-cmlite.org}"
read -rp "Admin email [default: $default_email]: " admin_email
admin_email=${admin_email:-$default_email}
local admin_password=""
while true; do
echo ""
read -rsp "Admin password (min 12 chars, uppercase + lowercase + digit): " admin_password
echo ""
if validate_password "$admin_password"; then
read -rsp "Confirm password: " confirm_password
echo ""
if [[ "$admin_password" == "$confirm_password" ]]; then
break
else
warn "Passwords do not match. Try again."
fi
else
warn "Password must be 12+ characters with uppercase, lowercase, and a digit."
fi
done
update_env_var "INITIAL_ADMIN_EMAIL" "$admin_email"
update_env_var "INITIAL_ADMIN_PASSWORD" "$admin_password"
update_env_var "N8N_USER_EMAIL" "$admin_email"
success "Admin credentials configured"
}
# Check if a key already has a real value in .env (non-empty, not a placeholder)
env_var_is_set() {
local key=$1
local val
val=$(grep "^${key}=" "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2-)
[[ -n "$val" && "$val" != "changeme" && "$val" != *"example"* && "$val" != *"CHANGEME"* ]]
}
# Update an env var only if not already set (for reconfigure mode)
update_env_var_if_empty() {
local key=$1
local value=$2
if [[ "$RECONFIGURE_MODE" == "true" ]] && env_var_is_set "$key"; then
return 1 # signal: kept existing
fi
update_env_var "$key" "$value"
return 0
}
generate_all_secrets() {
header "Generating Secrets"
if [[ "$RECONFIGURE_MODE" == "true" ]]; then
info "Reconfigure mode: existing secrets will be preserved."
else
info "Auto-generating 22 unique secrets and passwords..."
fi
echo ""
local generated=0 kept=0
# JWT & Encryption (64-char hex)
local jwt_access jwt_refresh jwt_invite enc_key
jwt_access=$(generate_secret)
jwt_refresh=$(generate_secret)
jwt_invite=$(generate_secret)
enc_key=$(generate_secret)
local jwt_changed=false
update_env_var_if_empty "JWT_ACCESS_SECRET" "$jwt_access" && jwt_changed=true
update_env_var_if_empty "JWT_REFRESH_SECRET" "$jwt_refresh" && jwt_changed=true
update_env_var_if_empty "JWT_INVITE_SECRET" "$jwt_invite" && jwt_changed=true
update_env_var_if_empty "ENCRYPTION_KEY" "$enc_key" && jwt_changed=true
if [[ "$jwt_changed" == "true" ]]; then
success "JWT secrets + encryption key"
((generated+=4))
else
info "JWT secrets + encryption key (kept existing)"
((kept+=4))
fi
# Database passwords (24-char alphanum)
local pg_pass redis_pass
pg_pass=$(generate_password 24)
redis_pass=$(generate_password 24)
local db_changed=false
if update_env_var_if_empty "V2_POSTGRES_PASSWORD" "$pg_pass"; then
update_env_var "DATABASE_URL" "postgresql://changemaker:${pg_pass}@localhost:5433/changemaker_v2"
db_changed=true
fi
update_env_var_if_empty "REDIS_PASSWORD" "$redis_pass" && db_changed=true
if [[ "$db_changed" == "true" ]]; then
# Rebuild REDIS_URL if password changed
local current_redis_pass
current_redis_pass=$(grep "^REDIS_PASSWORD=" "$ENV_FILE" | cut -d= -f2-)
update_env_var "REDIS_URL" "redis://:${current_redis_pass}@redis-changemaker:6379"
success "PostgreSQL + Redis passwords"
((generated+=2))
else
info "PostgreSQL + Redis passwords (kept existing)"
((kept+=2))
fi
# Listmonk
local lm_db_pass lm_web_pass lm_api_token
lm_db_pass=$(generate_password 24)
lm_web_pass=$(generate_password 20)
lm_api_token=$(openssl rand -hex 16)
local lm_changed=false
update_env_var_if_empty "LISTMONK_DB_PASSWORD" "$lm_db_pass" && lm_changed=true
update_env_var_if_empty "LISTMONK_WEB_ADMIN_PASSWORD" "$lm_web_pass" && lm_changed=true
if update_env_var_if_empty "LISTMONK_API_TOKEN" "$lm_api_token"; then
update_env_var "LISTMONK_ADMIN_PASSWORD" "$lm_api_token"
lm_changed=true
fi
if [[ "$lm_changed" == "true" ]]; then
success "Listmonk passwords + API token"
((generated+=3))
else
info "Listmonk passwords + API token (kept existing)"
((kept+=3))
fi
# NocoDB
local nc_pass
nc_pass=$(generate_password 20)
if update_env_var_if_empty "NC_ADMIN_PASSWORD" "$nc_pass"; then
success "NocoDB admin password"
((generated++))
else
info "NocoDB admin password (kept existing)"
((kept++))
fi
# Gitea
local gitea_db gitea_root
gitea_db=$(generate_password 20)
gitea_root=$(generate_password 20)
local gitea_changed=false
update_env_var_if_empty "GITEA_DB_PASSWD" "$gitea_db" && gitea_changed=true
update_env_var_if_empty "GITEA_DB_ROOT_PASSWORD" "$gitea_root" && gitea_changed=true
if [[ "$gitea_changed" == "true" ]]; then
success "Gitea database passwords"
((generated+=2))
else
info "Gitea database passwords (kept existing)"
((kept+=2))
fi
# n8n
local n8n_enc n8n_pass
n8n_enc=$(generate_password 32)
n8n_pass=$(generate_password 20)
local n8n_changed=false
update_env_var_if_empty "N8N_ENCRYPTION_KEY" "$n8n_enc" && n8n_changed=true
update_env_var_if_empty "N8N_USER_PASSWORD" "$n8n_pass" && n8n_changed=true
if [[ "$n8n_changed" == "true" ]]; then
success "n8n encryption key + admin password"
((generated+=2))
else
info "n8n encryption key + admin password (kept existing)"
((kept+=2))
fi
# Monitoring
local grafana_pass gotify_pass
grafana_pass=$(generate_password 20)
gotify_pass=$(generate_password 20)
local mon_changed=false
update_env_var_if_empty "GRAFANA_ADMIN_PASSWORD" "$grafana_pass" && mon_changed=true
update_env_var_if_empty "GOTIFY_ADMIN_PASSWORD" "$gotify_pass" && mon_changed=true
if [[ "$mon_changed" == "true" ]]; then
success "Grafana + Gotify admin passwords"
((generated+=2))
else
info "Grafana + Gotify admin passwords (kept existing)"
((kept+=2))
fi
# Vaultwarden
local vw_admin_token
vw_admin_token=$(generate_secret)
if update_env_var_if_empty "VAULTWARDEN_ADMIN_TOKEN" "$vw_admin_token"; then
success "Vaultwarden admin token"
((generated++))
else
info "Vaultwarden admin token (kept existing)"
((kept++))
fi
# Rocket.Chat
local rc_pass
rc_pass=$(generate_password 20)
if update_env_var_if_empty "ROCKETCHAT_ADMIN_PASSWORD" "$rc_pass"; then
success "Rocket.Chat admin password"
((generated++))
else
info "Rocket.Chat admin password (kept existing)"
((kept++))
fi
# Gancio
local gancio_pass
gancio_pass=$(generate_password 20)
if update_env_var_if_empty "GANCIO_ADMIN_PASSWORD" "$gancio_pass"; then
success "Gancio admin password"
((generated++))
else
info "Gancio admin password (kept existing)"
((kept++))
fi
# Jitsi Meet
local jitsi_secret jitsi_jicofo jitsi_jvb
jitsi_secret=$(generate_secret)
jitsi_jicofo=$(openssl rand -hex 16)
jitsi_jvb=$(openssl rand -hex 16)
local jitsi_changed=false
update_env_var_if_empty "JITSI_APP_SECRET" "$jitsi_secret" && jitsi_changed=true
update_env_var_if_empty "JITSI_JICOFO_AUTH_PASSWORD" "$jitsi_jicofo" && jitsi_changed=true
update_env_var_if_empty "JITSI_JVB_AUTH_PASSWORD" "$jitsi_jvb" && jitsi_changed=true
if [[ "$jitsi_changed" == "true" ]]; then
success "Jitsi Meet secrets (JWT + XMPP)"
((generated+=3))
else
info "Jitsi Meet secrets (kept existing)"
((kept+=3))
fi
echo ""
if [[ $kept -gt 0 ]]; then
success "Secrets: ${generated} generated, ${kept} preserved from existing .env"
else
success "All 22 secrets generated. No placeholder passwords remain."
fi
}
configure_smtp() {
header "Email Configuration"
info "By default, emails are captured by MailHog (test mode)."
info "You can configure a production SMTP server now or later."
echo ""
if prompt_yes_no "Configure production SMTP now?"; then
read -rp " SMTP host (e.g., smtp.protonmail.ch): " smtp_host
read -rp " SMTP port (e.g., 587): " smtp_port
read -rp " SMTP user: " smtp_user
read -rsp " SMTP password: " smtp_pass
echo ""
update_env_var "SMTP_HOST" "$smtp_host"
update_env_var "SMTP_PORT" "$smtp_port"
update_env_var "SMTP_USER" "$smtp_user"
update_env_var "SMTP_PASS" "$smtp_pass"
update_env_var "EMAIL_TEST_MODE" "false"
if prompt_yes_no " Also use this SMTP for Listmonk newsletters?"; then
update_env_var "LISTMONK_SMTP_HOST" "$smtp_host"
update_env_var "LISTMONK_SMTP_PORT" "$smtp_port"
update_env_var "LISTMONK_SMTP_USER" "$smtp_user"
update_env_var "LISTMONK_SMTP_PASSWORD" "$smtp_pass"
update_env_var "LISTMONK_SMTP_TLS_TYPE" "STARTTLS"
success "Listmonk SMTP configured"
fi
# Vaultwarden needs matching SMTP security
update_env_var "VAULTWARDEN_SMTP_SECURITY" "starttls"
success "Production SMTP configured"
SMTP_MODE="production"
else
info "Using MailHog for email testing (port 8025)"
update_env_var "VAULTWARDEN_SMTP_SECURITY" "off"
SMTP_MODE="mailhog"
fi
}
configure_features() {
header "Feature Flags"
if prompt_yes_no "Enable Media Manager (video library)?"; then
update_env_var "ENABLE_MEDIA_FEATURES" "true"
success "Media Manager enabled"
MEDIA_ENABLED="yes"
else
MEDIA_ENABLED="no"
fi
if prompt_yes_no "Enable Listmonk newsletter sync?"; then
update_env_var "LISTMONK_SYNC_ENABLED" "true"
success "Listmonk sync enabled"
LISTMONK_SYNC="yes"
else
LISTMONK_SYNC="no"
fi
if prompt_yes_no "Enable Payments (Stripe)?"; then
update_env_var "ENABLE_PAYMENTS" "true"
success "Payments enabled"
PAYMENTS_ENABLED="yes"
else
PAYMENTS_ENABLED="no"
fi
if prompt_yes_no "Enable Rocket.Chat (team chat)?"; then
update_env_var "ENABLE_CHAT" "true"
success "Rocket.Chat enabled"
CHAT_ENABLED="yes"
else
CHAT_ENABLED="no"
fi
if prompt_yes_no "Enable Gancio event sync (shift → event)?"; then
update_env_var "GANCIO_SYNC_ENABLED" "true"
success "Gancio sync enabled"
GANCIO_SYNC="yes"
else
GANCIO_SYNC="no"
fi
if prompt_yes_no "Enable Jitsi Meet (video conferencing)?"; then
update_env_var "ENABLE_MEET" "true"
success "Jitsi Meet enabled"
MEET_ENABLED="yes"
echo ""
info "Jitsi requires your server's public IP for media traffic (NAT traversal)."
info "Firewall must allow UDP port 10000 for video/audio."
read -rp " Server public IP [leave blank to set later]: " jvb_ip
if [[ -n "$jvb_ip" ]]; then
update_env_var "JVB_ADVERTISE_IP" "$jvb_ip"
success "JVB advertise IP set to $jvb_ip"
else
warn "Set JVB_ADVERTISE_IP in .env before starting Jitsi containers."
fi
else
MEET_ENABLED="no"
fi
if prompt_yes_no "Enable SMS Campaigns (Termux Android bridge)?"; then
update_env_var "ENABLE_SMS" "true"
success "SMS Campaigns enabled"
SMS_ENABLED="yes"
echo ""
info "SMS uses a Termux-based Android phone as the sending device."
read -rp " Termux API URL [default: http://10.0.0.193:5001]: " termux_url
termux_url=${termux_url:-http://10.0.0.193:5001}
update_env_var "TERMUX_API_URL" "$termux_url"
read -rp " Termux API Key [leave blank to set later]: " termux_key
if [[ -n "$termux_key" ]]; then
update_env_var "TERMUX_API_KEY" "$termux_key"
fi
else
SMS_ENABLED="no"
fi
if prompt_yes_no "Enable Docs Comments (Gitea-backed page comments)?"; then
update_env_var "GITEA_COMMENTS_ENABLED" "true"
success "Docs Comments enabled"
DOCS_COMMENTS_ENABLED="yes"
info "After Gitea is running, create a Personal Access Token and OAuth2 app,"
info "then set GITEA_API_TOKEN, GITEA_OAUTH_CLIENT_ID/SECRET in .env."
else
DOCS_COMMENTS_ENABLED="no"
fi
if prompt_yes_no "Enable Monitoring stack (Prometheus, Grafana, Alertmanager, cAdvisor)?" "y"; then
update_env_var "COMPOSE_PROFILES" "monitoring"
success "Monitoring enabled (COMPOSE_PROFILES=monitoring)"
MONITORING_ENABLED="yes"
else
MONITORING_ENABLED="no"
fi
if prompt_yes_no "Enable Bunker Ops (fleet metrics push to central server)?"; then
update_env_var "BUNKER_OPS_ENABLED" "true"
success "Bunker Ops enabled"
BUNKER_OPS_ENABLED="yes"
echo ""
read -rp " Instance label [default: domain name]: " instance_label
if [[ -n "$instance_label" ]]; then
update_env_var "INSTANCE_LABEL" "$instance_label"
fi
read -rp " Remote write URL (VictoriaMetrics endpoint): " remote_write_url
if [[ -n "$remote_write_url" ]]; then
update_env_var "BUNKER_OPS_REMOTE_WRITE_URL" "$remote_write_url"
fi
else
BUNKER_OPS_ENABLED="no"
fi
}
configure_pangolin() {
header "Tunnel Configuration (Pangolin)"
info "Pangolin provides secure public access to your services."
info "Skip this if you'll configure tunneling later."
echo ""
if prompt_yes_no "Configure Pangolin tunnel now?"; then
read -rp " Pangolin API URL [default: https://api.bnkserve.org/v1]: " pang_url
pang_url=${pang_url:-https://api.bnkserve.org/v1}
read -rp " Pangolin API key: " pang_key
read -rp " Pangolin Organization ID: " pang_org
update_env_var "PANGOLIN_API_URL" "$pang_url"
update_env_var "PANGOLIN_API_KEY" "$pang_key"
update_env_var "PANGOLIN_ORG_ID" "$pang_org"
success "Pangolin configured"
info "Complete tunnel setup in the admin GUI at /app/pangolin after starting services."
PANGOLIN_CONFIGURED="yes"
else
PANGOLIN_CONFIGURED="no"
fi
}
configure_cors() {
local domain="${CONFIGURED_DOMAIN:-cmlite.org}"
# Include app subdomain + root domain (for MkDocs payment widgets) + localhost fallbacks
local origins="http://app.$domain,https://app.$domain,http://$domain,https://$domain,http://localhost:3000,http://localhost,http://localhost:4003"
update_env_var "CORS_ORIGINS" "$origins"
success "CORS origins set for $domain"
}
generate_nginx_configs() {
header "Nginx Configuration"
local domain="${CONFIGURED_DOMAIN:-cmlite.org}"
local template_dir="$SCRIPT_DIR/nginx/conf.d"
local templates_found=0
for template in "$template_dir"/*.conf.template; do
[[ -f "$template" ]] || continue
templates_found=$((templates_found + 1))
local conf_file="${template%.template}"
local conf_name
conf_name=$(basename "$conf_file")
# Substitute ${DOMAIN} while preserving nginx variables ($host, $scheme, etc.)
sed "s/\${DOMAIN}/$domain/g" "$template" > "$conf_file"
success "Generated $conf_name"
done
if [[ $templates_found -eq 0 ]]; then
warn "No nginx .conf.template files found — skipping"
else
success "Generated $templates_found nginx configs for domain: $domain"
info "Restart nginx to apply: docker compose restart nginx"
fi
}
# =============================================================================
# Homepage services.yaml
# =============================================================================
# Read a variable from .env with fallback default
read_env_var() {
local key=$1 default=$2
local val
val=$(grep "^${key}=" "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2-)
echo "${val:-$default}"
}
generate_services_yaml() {
local domain="${CONFIGURED_DOMAIN:-cmlite.org}"
# Read embed proxy ports from .env (supports multi-instance port customization)
local rc_port; rc_port=$(read_env_var ROCKETCHAT_EMBED_PORT 8891)
local jitsi_port; jitsi_port=$(read_env_var JITSI_EMBED_PORT 8893)
mkdir -p "$(dirname "$SERVICES_YAML")"
cat > "$SERVICES_YAML" << YAML
---
# Homepage Services Configuration — Generated by config.sh
# Production tab: public URLs via $domain
# Local tab: localhost URLs with ports
- Production - Core:
- Admin GUI:
icon: mdi-view-dashboard
href: "https://app.$domain"
description: Application dashboard and public pages
container: changemaker-v2-admin
- API:
icon: mdi-api
href: "https://api.$domain"
description: V2 REST API
container: changemaker-v2-api
- Media API:
icon: mdi-video
href: "https://media.$domain"
description: Video library and streaming
container: changemaker-media-api
- Main Site:
icon: mdi-web
href: "https://$domain"
description: Documentation and marketing site
container: mkdocs-site-server-changemaker
- Production - Tools:
- Code Server:
icon: mdi-code-braces
href: "https://code.$domain"
description: VS Code in the browser
container: code-server-changemaker
- NocoDB:
icon: mdi-database
href: "https://db.$domain"
description: Database browser (read-only)
container: changemaker-v2-nocodb
- MkDocs (Live):
icon: mdi-book-open-page-variant
href: "https://docs.$domain"
description: Live documentation with hot reload
container: mkdocs-changemaker
- Mini QR:
icon: mdi-qrcode
href: "https://qr.$domain"
description: QR code generator
container: mini-qr
- Excalidraw:
icon: mdi-draw
href: "https://draw.$domain"
description: Collaborative whiteboard
container: excalidraw-changemaker
- Vaultwarden:
icon: mdi-lock
href: "https://vault.$domain"
description: Password manager (Bitwarden-compatible)
container: vaultwarden-changemaker
- Gancio:
icon: mdi-calendar-multiple
href: "https://events.$domain"
description: Event management platform
container: gancio-changemaker
- Rocket.Chat:
icon: mdi-chat
href: "https://chat.$domain"
description: Team communication platform
container: rocketchat-changemaker
- Jitsi Meet:
icon: mdi-video
href: "https://meet.$domain"
description: Video conferencing
container: jitsi-web-changemaker
- Production - Integrations:
- Listmonk:
icon: mdi-email-newsletter
href: "https://listmonk.$domain"
description: Newsletter and mailing list manager
container: listmonk-app
- MailHog:
icon: mdi-email-check
href: "https://mail.$domain"
description: Email capture for testing
container: mailhog-changemaker
- n8n:
icon: mdi-robot-industrial
href: "https://n8n.$domain"
description: Workflow automation platform
container: n8n-changemaker
- Gitea:
icon: mdi-git
href: "https://git.$domain"
description: Git repository hosting
container: gitea-changemaker
- Production - Infrastructure:
- Nginx:
icon: mdi-web-box
href: "#"
description: Reverse proxy (subdomain routing)
container: changemaker-v2-nginx
- PostgreSQL:
icon: mdi-database-outline
href: "#"
description: Primary database (V2)
container: changemaker-v2-postgres
- Redis:
icon: mdi-database-sync
href: "#"
description: Cache, rate limiting, job queues
container: redis-changemaker
- Production - Monitoring:
- Grafana:
icon: mdi-chart-box
href: "https://grafana.$domain"
description: Monitoring dashboards
container: grafana-changemaker
- Prometheus:
icon: mdi-chart-line
href: "https://prometheus.$domain"
description: Metrics collection
container: prometheus-changemaker
- Alertmanager:
icon: mdi-bell-alert
href: "https://alertmanager.$domain"
description: Alert routing
container: alertmanager-changemaker
- Gotify:
icon: mdi-cellphone-message
href: "https://gotify.$domain"
description: Push notifications
container: gotify-changemaker
- cAdvisor:
icon: mdi-docker
href: "https://cadvisor.$domain"
description: Container metrics
container: cadvisor-changemaker
- Node Exporter:
icon: mdi-server
href: "#"
description: Host system metrics
container: node-exporter-changemaker
- Redis Exporter:
icon: mdi-database-export
href: "#"
description: Redis metrics exporter
container: redis-exporter-changemaker
# ─────────────────────────────────────────────────
# LOCAL DEVELOPMENT
# ─────────────────────────────────────────────────
- Local - Core:
- Admin GUI:
icon: mdi-view-dashboard
href: "http://localhost:3000"
description: Application dashboard (port 3000)
container: changemaker-v2-admin
- API:
icon: mdi-api
href: "http://localhost:4000"
description: V2 REST API (port 4000)
container: changemaker-v2-api
- Media API:
icon: mdi-video
href: "http://localhost:4100"
description: Video library API (port 4100)
container: changemaker-media-api
- Main Site:
icon: mdi-web
href: "http://localhost:4004"
description: Documentation site (port 4004)
container: mkdocs-site-server-changemaker
- Homepage:
icon: mdi-home
href: "http://localhost:3010"
description: This dashboard (port 3010)
container: homepage-changemaker
- Local - Tools:
- Code Server:
icon: mdi-code-braces
href: "http://localhost:8888"
description: VS Code in the browser (port 8888)
container: code-server-changemaker
- NocoDB:
icon: mdi-database
href: "http://localhost:8091"
description: Database browser (port 8091)
container: changemaker-v2-nocodb
- MkDocs (Live):
icon: mdi-book-open-page-variant
href: "http://localhost:4003"
description: Live documentation (port 4003)
container: mkdocs-changemaker
- Mini QR:
icon: mdi-qrcode
href: "http://localhost:8089"
description: QR code generator (port 8089)
container: mini-qr
- Excalidraw:
icon: mdi-draw
href: "http://localhost:8090"
description: Collaborative whiteboard (port 8090)
container: excalidraw-changemaker
- Vaultwarden:
icon: mdi-lock
href: "http://localhost:8445"
description: Password manager (port 8445)
container: vaultwarden-changemaker
- Gancio:
icon: mdi-calendar-multiple
href: "http://localhost:8092"
description: Event management (port 8092)
container: gancio-changemaker
- Rocket.Chat:
icon: mdi-chat
href: "http://localhost:$rc_port"
description: Team chat (port $rc_port)
container: rocketchat-changemaker
- Jitsi Meet:
icon: mdi-video
href: "http://localhost:$jitsi_port"
description: Video conferencing (port $jitsi_port)
container: jitsi-web-changemaker
- Local - Integrations:
- Listmonk:
icon: mdi-email-newsletter
href: "http://localhost:9001"
description: Newsletter manager (port 9001)
container: listmonk-app
- MailHog:
icon: mdi-email-check
href: "http://localhost:8025"
description: Email capture UI (port 8025)
container: mailhog-changemaker
- n8n:
icon: mdi-robot-industrial
href: "http://localhost:5678"
description: Workflow automation (port 5678)
container: n8n-changemaker
- Gitea:
icon: mdi-git
href: "http://localhost:3030"
description: Git repository hosting (port 3030)
container: gitea-changemaker
- Local - Infrastructure:
- Nginx:
icon: mdi-web-box
href: "#"
description: Reverse proxy (port 80)
container: changemaker-v2-nginx
- PostgreSQL:
icon: mdi-database-outline
href: "#"
description: Primary database (port 5433)
container: changemaker-v2-postgres
- Redis:
icon: mdi-database-sync
href: "#"
description: Cache and job queues (port 6379)
container: redis-changemaker
- Local - Monitoring:
- Grafana:
icon: mdi-chart-box
href: "http://localhost:3005"
description: Monitoring dashboards (port 3005)
container: grafana-changemaker
- Prometheus:
icon: mdi-chart-line
href: "http://localhost:9090"
description: Metrics collection (port 9090)
container: prometheus-changemaker
- Alertmanager:
icon: mdi-bell-alert
href: "http://localhost:9093"
description: Alert routing (port 9093)
container: alertmanager-changemaker
- Gotify:
icon: mdi-cellphone-message
href: "http://localhost:8889"
description: Push notifications (port 8889)
container: gotify-changemaker
- cAdvisor:
icon: mdi-docker
href: "http://localhost:8086"
description: Container metrics (port 8086)
container: cadvisor-changemaker
- Node Exporter:
icon: mdi-server
href: "http://localhost:9100/metrics"
description: Host system metrics (port 9100)
container: node-exporter-changemaker
- Redis Exporter:
icon: mdi-database-export
href: "http://localhost:9121/metrics"
description: Redis metrics (port 9121)
container: redis-exporter-changemaker
YAML
success "Generated services.yaml for Homepage dashboard"
}
# =============================================================================
# Directory Permissions
# =============================================================================
fix_container_permissions() {
header "Container Directory Permissions"
local -a dirs=(
"configs/code-server/.config:Code Server config"
"configs/code-server/.local:Code Server local data"
"mkdocs:MkDocs root"
"mkdocs/docs:MkDocs source docs"
"mkdocs/overrides:MkDocs template overrides"
"mkdocs/.cache:MkDocs cache"
"mkdocs/site:MkDocs built site"
"assets/uploads:Listmonk uploads"
"assets/images:Shared images"
"assets/icons:Homepage icons"
"media/local/inbox:Media upload inbox"
"media/local/photos:Media photos"
"media/local/thumbnails:Video thumbnails"
"media/public:Public media files"
"local-files:n8n local files"
"data:NAR import data"
"data/upgrade:Upgrade trigger directory"
)
local errors=0
for entry in "${dirs[@]}"; do
local dir_path="$SCRIPT_DIR/${entry%%:*}"
local dir_desc="${entry#*:}"
mkdir -p "$dir_path"
if chmod 775 "$dir_path" 2>/dev/null; then
success "$dir_desc"
else
warn "$dir_desc — could not set permissions (may need sudo)"
((errors++))
fi
done
echo ""
if [[ $errors -eq 0 ]]; then
success "All directories ready"
else
warn "Some directories may need: sudo chown -R $(id -u):$(id -g) $SCRIPT_DIR"
fi
}
# =============================================================================
# Upgrade Watcher (systemd)
# =============================================================================
install_upgrade_watcher() {
header "System Upgrade Watcher"
info "The upgrade watcher lets you trigger upgrades from the admin Settings page."
info "It installs a systemd path watcher that monitors for trigger files."
echo ""
# Ensure upgrade IPC directory exists
mkdir -p "$SCRIPT_DIR/data/upgrade"
local unit_src="$SCRIPT_DIR/scripts/systemd"
if [[ ! -f "$unit_src/changemaker-upgrade.path" ]] || [[ ! -f "$unit_src/changemaker-upgrade.service" ]]; then
warn "Systemd unit templates not found in scripts/systemd/ — skipping"
UPGRADE_WATCHER="skipped"
return
fi
if ! command -v systemctl &>/dev/null; then
warn "systemctl not found — skipping (not a systemd host?)"
UPGRADE_WATCHER="skipped"
return
fi
if prompt_yes_no "Install the upgrade watcher (requires sudo)?"; then
# Generate units with correct paths substituted
local tmp_path tmp_service
tmp_path=$(mktemp)
tmp_service=$(mktemp)
sed -e "s|__PROJECT_DIR__|$SCRIPT_DIR|g" "$unit_src/changemaker-upgrade.path" > "$tmp_path"
sed -e "s|__PROJECT_DIR__|$SCRIPT_DIR|g" -e "s|__USER__|$(whoami)|g" "$unit_src/changemaker-upgrade.service" > "$tmp_service"
if sudo cp "$tmp_path" /etc/systemd/system/changemaker-upgrade.path \
&& sudo cp "$tmp_service" /etc/systemd/system/changemaker-upgrade.service \
&& sudo systemctl daemon-reload \
&& sudo systemctl enable --now changemaker-upgrade.path; then
success "Upgrade watcher installed and enabled"
UPGRADE_WATCHER="yes"
else
warn "Failed to install systemd units (sudo may have failed)"
warn "Install manually later:"
echo -e " ${CYAN}cd ~/changemaker.lite && sudo ./scripts/systemd/install.sh${NC}"
UPGRADE_WATCHER="manual"
fi
rm -f "$tmp_path" "$tmp_service"
else
info "Skipped. You can install it later from Settings > System."
UPGRADE_WATCHER="skipped"
fi
}
# =============================================================================
# Automated Backups (systemd)
# =============================================================================
install_backup_timer() {
header "Automated Backups"
info "Daily automated backups protect against data loss."
info "Backs up PostgreSQL databases + uploads to ./backups/ with 30-day retention."
echo ""
local unit_src="$SCRIPT_DIR/scripts/systemd"
if [[ ! -f "$unit_src/changemaker-backup.timer" ]] || [[ ! -f "$unit_src/changemaker-backup.service" ]]; then
warn "Systemd backup unit templates not found in scripts/systemd/ — skipping"
BACKUP_TIMER="skipped"
return
fi
if ! command -v systemctl &>/dev/null; then
warn "systemctl not found — skipping (not a systemd host?)"
BACKUP_TIMER="skipped"
return
fi
if prompt_yes_no "Install daily automated backups (requires sudo)?" "y"; then
local tmp_timer tmp_service
tmp_timer=$(mktemp)
tmp_service=$(mktemp)
sed -e "s|__PROJECT_DIR__|$SCRIPT_DIR|g" "$unit_src/changemaker-backup.timer" > "$tmp_timer"
sed -e "s|__PROJECT_DIR__|$SCRIPT_DIR|g" -e "s|__USER__|$(whoami)|g" "$unit_src/changemaker-backup.service" > "$tmp_service"
if sudo cp "$tmp_timer" /etc/systemd/system/changemaker-backup.timer \
&& sudo cp "$tmp_service" /etc/systemd/system/changemaker-backup.service \
&& sudo systemctl daemon-reload \
&& sudo systemctl enable --now changemaker-backup.timer; then
success "Daily backup timer installed and enabled (runs at 02:00)"
BACKUP_TIMER="yes"
else
warn "Failed to install systemd units (sudo may have failed)"
warn "Install manually later:"
echo -e " ${CYAN}sudo cp scripts/systemd/changemaker-backup.* /etc/systemd/system/${NC}"
echo -e " ${CYAN}sudo systemctl daemon-reload && sudo systemctl enable --now changemaker-backup.timer${NC}"
BACKUP_TIMER="manual"
fi
rm -f "$tmp_timer" "$tmp_service"
else
info "Skipped. Run backups manually: ./scripts/backup.sh"
BACKUP_TIMER="skipped"
fi
}
# =============================================================================
# Summary & Next Steps
# =============================================================================
print_summary() {
header "Configuration Complete"
echo -e " ${BOLD}Domain:${NC} ${CONFIGURED_DOMAIN:-cmlite.org}"
echo -e " ${BOLD}Admin email:${NC} (see .env: INITIAL_ADMIN_EMAIL)"
echo -e " ${BOLD}Admin password:${NC} [set]"
echo -e " ${BOLD}SMTP:${NC} ${SMTP_MODE:-mailhog}"
echo -e " ${BOLD}Media Manager:${NC} ${MEDIA_ENABLED:-no}"
echo -e " ${BOLD}Payments:${NC} ${PAYMENTS_ENABLED:-no}"
echo -e " ${BOLD}Listmonk sync:${NC} ${LISTMONK_SYNC:-no}"
echo -e " ${BOLD}Rocket.Chat:${NC} ${CHAT_ENABLED:-no}"
echo -e " ${BOLD}Gancio sync:${NC} ${GANCIO_SYNC:-no}"
echo -e " ${BOLD}Jitsi Meet:${NC} ${MEET_ENABLED:-no}"
echo -e " ${BOLD}SMS Campaigns:${NC} ${SMS_ENABLED:-no}"
echo -e " ${BOLD}Monitoring:${NC} ${MONITORING_ENABLED:-no}"
echo -e " ${BOLD}Docs Comments:${NC} ${DOCS_COMMENTS_ENABLED:-no}"
echo -e " ${BOLD}Bunker Ops:${NC} ${BUNKER_OPS_ENABLED:-no}"
echo -e " ${BOLD}Pangolin:${NC} ${PANGOLIN_CONFIGURED:-no}"
echo -e " ${BOLD}Upgrade watcher:${NC} ${UPGRADE_WATCHER:-skipped}"
echo -e " ${BOLD}Backup timer:${NC} ${BACKUP_TIMER:-skipped}"
echo -e " ${BOLD}Secrets:${NC} 22 auto-generated"
echo ""
echo -e " ${DIM}Config file: $ENV_FILE${NC}"
}
print_next_steps() {
echo ""
echo -e "${BOLD}${BLUE}══════════════════════════════════════${NC}"
echo -e "${BOLD}${BLUE} Next Steps${NC}"
echo -e "${BOLD}${BLUE}══════════════════════════════════════${NC}"
echo ""
if [[ "$INSTALL_MODE" == "release" ]]; then
# Release mode: simpler instructions (production images, auto-migration)
echo -e " ${BOLD}1.${NC} Start all services:"
echo -e " ${CYAN}docker compose up -d${NC}"
echo ""
echo -e " Pre-built images will be pulled from the registry (~2 min first time)."
echo -e " Database migrations and seeding run automatically on startup."
echo ""
echo -e " ${BOLD}2.${NC} Access the application:"
echo -e " Admin GUI: ${CYAN}http://localhost:3000${NC}"
echo -e " API: ${CYAN}http://localhost:4000${NC}"
echo ""
echo -e " ${BOLD}3.${NC} Check status:"
echo -e " ${CYAN}docker compose ps${NC}"
echo -e " ${CYAN}docker compose logs -f api --tail 20${NC}"
echo ""
else
# Source mode: existing instructions
echo -e " ${BOLD}1.${NC} Start core services:"
echo -e " ${CYAN}docker compose up -d v2-postgres redis api admin${NC}"
echo ""
echo -e " ${BOLD}2.${NC} Run database setup:"
echo -e " ${CYAN}docker compose exec api npx prisma migrate deploy${NC}"
echo -e " ${CYAN}docker compose exec api npx prisma db seed${NC}"
echo ""
echo -e " ${BOLD}3.${NC} Access the application:"
echo -e " Admin GUI: ${CYAN}http://localhost:3000${NC}"
echo -e " API: ${CYAN}http://localhost:4000${NC}"
echo ""
echo -e " ${BOLD}4.${NC} Optional — start additional services:"
echo -e " ${CYAN}docker compose up -d nginx${NC} # Reverse proxy"
echo -e " ${CYAN}docker compose up -d media-api${NC} # Video library"
echo -e " ${CYAN}docker compose up -d listmonk-app${NC} # Newsletters"
echo -e " ${CYAN}docker compose up -d rocketchat${NC} # Team chat"
echo -e " ${CYAN}docker compose up -d jitsi-web jitsi-prosody jitsi-jicofo jitsi-jvb${NC} # Video calls"
echo -e " ${CYAN}docker compose up -d homepage${NC} # Service dashboard"
echo ""
echo -e " ${BOLD}5.${NC} Or start everything at once (monitoring included if enabled above):"
echo -e " ${CYAN}docker compose up -d${NC}"
echo ""
fi
echo -e " ${YELLOW}IMPORTANT: Change your admin password after first login!${NC}"
echo -e " ${YELLOW}JITSI: Ensure UDP port 10000 is open in your firewall for video/audio.${NC}"
echo ""
}
# =============================================================================
# Main
# =============================================================================
main() {
print_banner
check_prerequisites
initialize_env
configure_domain
configure_admin
generate_all_secrets
configure_smtp
configure_features
configure_pangolin
configure_cors
generate_nginx_configs
generate_services_yaml
fix_container_permissions
install_upgrade_watcher
install_backup_timer
# Release mode: auto-set production defaults
if [[ "$INSTALL_MODE" == "release" ]]; then
header "Release Mode Settings"
update_env_var "IMAGE_TAG" "latest"
update_env_var "NODE_ENV" "production"
# Ensure monitoring is included if user opted in
if [[ "${MONITORING_ENABLED:-no}" == "yes" ]]; then
update_env_var "COMPOSE_PROFILES" "monitoring"
fi
success "Set IMAGE_TAG=latest, NODE_ENV=production (pre-built images)"
fi
print_summary
print_next_steps
}
main "$@"