#!/usr/bin/env bash set -euo pipefail # ── Color support ────────────────────────────────────────────────────────────── if [ -t 1 ] && command -v tput &>/dev/null && tput colors &>/dev/null 2>&1 \ && [ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]; then RED=$(tput setaf 1); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3) BLUE=$(tput setaf 4); CYAN=$(tput setaf 6); BOLD=$(tput bold); RESET=$(tput sgr0) else RED=''; GREEN=''; YELLOW=''; BLUE=''; CYAN=''; BOLD=''; RESET='' fi info() { printf "%s%s%s\n" "$CYAN" "$*" "$RESET"; } success() { printf "%s✓ %s%s\n" "$GREEN" "$*" "$RESET"; } warn() { printf "%s⚠ %s%s\n" "$YELLOW" "$*" "$RESET"; } err() { printf "%s✗ %s%s\n" "$RED" "$*" "$RESET" >&2; exit 1; } step() { printf "\n%s%s── %s%s\n" "$BOLD" "$BLUE" "$*" "$RESET"; } ask() { printf "%s%s%s " "$BOLD" "$*" "$RESET"; } generate_secret() { if command -v openssl &>/dev/null; then openssl rand -hex 32 else LC_ALL=C tr -dc 'a-f0-9' /dev/null | head -c 64 fi } trap 'printf "\n%sInterrupted. Exiting.%s\n" "$YELLOW" "$RESET"; exit 1' INT TERM # ── Prerequisites ────────────────────────────────────────────────────────────── check_prereqs() { step "Checking prerequisites" local ok=1 for cmd in docker curl; do if command -v "$cmd" &>/dev/null; then success "$cmd found" else warn "$cmd not found"; ok=0; fi done if docker compose version &>/dev/null 2>&1; then success "docker compose (v2) found" else warn "docker compose v2 not found"; ok=0; fi [ $ok -eq 0 ] && err "Install missing prerequisites and re-run." } # ── Step 1: Basic config ─────────────────────────────────────────────────────── configure_basic() { step "Step 1/7: Basic Configuration" ask "Domain or IP address [localhost]:" read -r OIKOS_HOST; OIKOS_HOST="${OIKOS_HOST:-localhost}" ask "Port [3000]:" read -r OIKOS_PORT; OIKOS_PORT="${OIKOS_PORT:-3000}" local sys_tz="UTC" if [ -f /etc/timezone ]; then sys_tz=$(cat /etc/timezone) elif command -v timedatectl &>/dev/null; then sys_tz=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "UTC") elif [ -L /etc/localtime ]; then sys_tz=$(readlink /etc/localtime 2>/dev/null | sed 's|.*zoneinfo/||' || echo "UTC") fi ask "Timezone [$sys_tz]:" read -r OIKOS_TZ; OIKOS_TZ="${OIKOS_TZ:-$sys_tz}" } # ── Step 2: Secrets ──────────────────────────────────────────────────────────── configure_secrets() { step "Step 2/7: Security Keys" info "Auto-generation is recommended. Store the resulting .env file safely.\n" for varname in SESSION_SECRET DB_ENCRYPTION_KEY; do printf "\n %s%s:%s\n" "$BOLD" "$varname" "$RESET" ask " [G]enerate automatically / [M]anual entry [G]:" read -r choice if [ "${choice,,}" = "m" ]; then ask " Enter value:" local val; read -rs val; printf "\n" eval "$varname='$val'" else local generated; generated=$(generate_secret) eval "$varname='$generated'" success " Generated" fi done } # ── Step 3: Weather ──────────────────────────────────────────────────────────── configure_weather() { step "Step 3/7: Weather Widget (optional)" OPENWEATHER_API_KEY=''; OPENWEATHER_CITY='Berlin' OPENWEATHER_UNITS='metric'; OPENWEATHER_LANG='de' ask "Enable weather widget? [y/N]:" read -r want_weather if [ "${want_weather,,}" = "y" ]; then info " Get a free API key at: https://openweathermap.org/api" ask " API key:"; read -r OPENWEATHER_API_KEY ask " City [Berlin]:"; read -r city; OPENWEATHER_CITY="${city:-Berlin}" ask " Units (metric/imperial) [metric]:"; read -r units; OPENWEATHER_UNITS="${units:-metric}" fi } # ── Step 4: Calendar ─────────────────────────────────────────────────────────── configure_calendar() { step "Step 4/7: Calendar Sync (optional)" GOOGLE_CLIENT_ID=''; GOOGLE_CLIENT_SECRET=''; GOOGLE_REDIRECT_URI='' APPLE_USERNAME=''; APPLE_APP_SPECIFIC_PASSWORD='' ask "Enable Google Calendar sync? [y/N]:" read -r want_google if [ "${want_google,,}" = "y" ]; then info " Create OAuth credentials at: https://console.cloud.google.com" info " Redirect URI: http://${OIKOS_HOST}:${OIKOS_PORT}/api/v1/calendar/google/callback" ask " Client ID:"; read -r GOOGLE_CLIENT_ID ask " Client Secret:"; read -rs GOOGLE_CLIENT_SECRET; printf "\n" GOOGLE_REDIRECT_URI="http://${OIKOS_HOST}:${OIKOS_PORT}/api/v1/calendar/google/callback" fi ask "Enable Apple/iCloud CalDAV sync? [y/N]:" read -r want_apple if [ "${want_apple,,}" = "y" ]; then info " Create an app-specific password at: https://appleid.apple.com" ask " Apple ID (email):"; read -r APPLE_USERNAME ask " App-specific password:"; read -rs APPLE_APP_SPECIFIC_PASSWORD; printf "\n" fi } # ── Step 5: Review ───────────────────────────────────────────────────────────── review_and_confirm() { step "Step 5/7: Review" printf "\n" printf " Host %s%s%s\n" "$CYAN" "$OIKOS_HOST" "$RESET" printf " Port %s%s%s\n" "$CYAN" "$OIKOS_PORT" "$RESET" printf " Timezone %s%s%s\n" "$CYAN" "$OIKOS_TZ" "$RESET" printf " SESSION_SECRET %s***%s\n" "$YELLOW" "$RESET" printf " DB_ENCRYPT_KEY %s***%s\n" "$YELLOW" "$RESET" [ -n "$OPENWEATHER_API_KEY" ] && printf " Weather %s%s (key set)%s\n" "$GREEN" "$OPENWEATHER_CITY" "$RESET" [ -n "$GOOGLE_CLIENT_ID" ] && printf " Google Cal %senabled%s\n" "$GREEN" "$RESET" [ -n "$APPLE_USERNAME" ] && printf " Apple CalDAV %s%s%s\n" "$GREEN" "$APPLE_USERNAME" "$RESET" printf "\n" ask "Proceed? [Y/n]:" read -r confirm [ "${confirm,,}" = "n" ] && { info "Aborted."; exit 0; } } # ── Step 6: Docker ───────────────────────────────────────────────────────────── write_env_and_start() { step "Step 6/7: Starting Docker Container" cat > .env << ENVEOF # Generated by Oikos installer SESSION_SECRET=${SESSION_SECRET} DB_ENCRYPTION_KEY=${DB_ENCRYPTION_KEY} OPENWEATHER_API_KEY=${OPENWEATHER_API_KEY} OPENWEATHER_CITY=${OPENWEATHER_CITY} OPENWEATHER_UNITS=${OPENWEATHER_UNITS} OPENWEATHER_LANG=${OPENWEATHER_LANG} GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} GOOGLE_REDIRECT_URI=${GOOGLE_REDIRECT_URI} APPLE_USERNAME=${APPLE_USERNAME} APPLE_APP_SPECIFIC_PASSWORD=${APPLE_APP_SPECIFIC_PASSWORD} SYNC_INTERVAL_MINUTES=15 ENVEOF success ".env written" if ! docker compose up -d; then warn "Docker failed to start. Recent logs:" docker compose logs --tail 50 exit 1 fi printf " Waiting for container to be ready" local elapsed=0 while [ $elapsed -lt 120 ]; do local http_code http_code=$(curl -s -o /dev/null -w "%{http_code}" \ "http://localhost:${OIKOS_PORT}/health" 2>/dev/null || echo "000") if [ "$http_code" = "200" ]; then printf "\n"; success "Container is healthy"; return 0 fi printf "."; sleep 2; elapsed=$((elapsed + 2)) done printf "\n" warn "Timeout waiting for container. Logs:" docker compose logs --tail 50 exit 1 } # ── Step 7: Admin account ────────────────────────────────────────────────────── create_admin() { step "Step 7/7: Create Admin Account" ask "Username (3-64 chars, letters/numbers/._-):" read -r admin_user ask "Display name (e.g. 'Jane Smith'):" read -r admin_display local admin_pass while true; do ask "Password (min 8 chars):"; read -rs admin_pass; printf "\n" ask "Confirm password:"; local admin_confirm; read -rs admin_confirm; printf "\n" [ "$admin_pass" = "$admin_confirm" ] && break warn "Passwords do not match, try again." done # Build JSON payload (values must not contain " or \) local payload payload=$(printf '{"username":"%s","display_name":"%s","password":"%s"}' \ "$admin_user" "$admin_display" "$admin_pass") local response http_code body response=$(curl -s -w "\n%{http_code}" \ -X POST "http://localhost:${OIKOS_PORT}/api/v1/auth/setup" \ -H "Content-Type: application/json" \ -d "$payload") http_code=$(printf '%s' "$response" | tail -n1) body=$(printf '%s' "$response" | head -n-1) if [ "$http_code" = "201" ]; then success "Admin account created!" printf "\n%s%s━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━%s\n" "$BOLD" "$GREEN" "$RESET" printf "%s%s Oikos is ready!%s\n" "$BOLD" "$GREEN" "$RESET" printf "%s%s━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━%s\n\n" "$BOLD" "$GREEN" "$RESET" printf " Open: %shttp://%s:%s%s\n\n" "$CYAN" "$OIKOS_HOST" "$OIKOS_PORT" "$RESET" elif [ "$http_code" = "403" ]; then warn "An admin account already exists." printf " Open: %shttp://%s:%s%s\n\n" "$CYAN" "$OIKOS_HOST" "$OIKOS_PORT" "$RESET" else warn "Failed to create admin (HTTP $http_code): $body" printf " Create manually:\n" printf " curl -X POST http://localhost:%s/api/v1/auth/setup \\\n" "$OIKOS_PORT" printf " -H 'Content-Type: application/json' \\\n" printf " -d '{\"username\":\"admin\",\"display_name\":\"Admin\",\"password\":\"yourpassword\"}'\n\n" fi } # ── Non-interactive mode (--env-file) ────────────────────────────────────────── run_noninteractive() { local env_file="$1" [ -f "$env_file" ] || err "Env file not found: $env_file" info "Non-interactive mode: using $env_file" cp "$env_file" .env OIKOS_PORT=$(grep -E '^PORT=' .env 2>/dev/null | cut -d= -f2- | head -n1) OIKOS_PORT="${OIKOS_PORT:-3000}" OIKOS_HOST="localhost" if ! docker compose up -d; then docker compose logs --tail 50; exit 1; fi printf " Waiting for container" local elapsed=0 while [ $elapsed -lt 120 ]; do local http_code http_code=$(curl -s -o /dev/null -w "%{http_code}" \ "http://localhost:${OIKOS_PORT}/health" 2>/dev/null || echo "000") [ "$http_code" = "200" ] && { printf "\n"; success "Ready"; break; } printf "."; sleep 2; elapsed=$((elapsed + 2)) done printf "\n%sContainer started.%s Create admin:\n\n" "$GREEN" "$RESET" printf " curl -X POST http://localhost:%s/api/v1/auth/setup \\\n" "$OIKOS_PORT" printf " -H 'Content-Type: application/json' \\\n" printf " -d '{\"username\":\"admin\",\"display_name\":\"Admin\",\"password\":\"yourpassword\"}'\n\n" } # ── Main ─────────────────────────────────────────────────────────────────────── main() { printf "\n%s%s ╔══════════════════════════════╗\n" "$BOLD" "$BLUE" printf " ║ Oikos Installer ║\n" printf " ╚══════════════════════════════╝%s\n\n" "$RESET" if [ "${1:-}" = "--env-file" ]; then [ -n "${2:-}" ] || err "Usage: $0 --env-file /path/to/.env" run_noninteractive "$2"; exit 0 fi check_prereqs configure_basic configure_secrets configure_weather configure_calendar review_and_confirm write_env_and_start create_admin } main "$@"