Files
oikos/install.sh
Ulas Kalayci 1ef4783902 feat(installer): add CLI install script
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 13:13:46 +02:00

292 lines
12 KiB
Bash
Executable File

#!/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/urandom 2>/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 "$@"