Merge pull request #718 from PicPeak/feat/setup-wizard-unattended

feat: setup wizard + argument-driven unattended install
This commit is contained in:
Paul Nothaft
2026-07-02 17:08:21 +02:00
committed by GitHub
+323 -316
View File
@@ -44,8 +44,13 @@ INSTALL_METHOD="" # docker or native
OS_TYPE=""
OS_VERSION=""
PACKAGE_MANAGER=""
ADMIN_EMAIL="admin@example.com"
ADMIN_EMAIL="" # empty until set by the wizard, --email, or the unattended default
ADMIN_PASSWORD="" # optional. When set, the admin is seeded directly (headless).
# Empty = browser-first: the app prints a one-time /setup token.
DOMAIN_NAME=""
INSTALL_DIR="" # override for the install directory (else per-method default)
PICPEAK_CHANNEL="stable" # GHCR image tag for the production compose (stable|beta)
HTTPS_MODE="" # caddy | proxy | none — how TLS is terminated
SMTP_HOST=""
SMTP_PORT=""
SMTP_USER=""
@@ -80,19 +85,160 @@ run_as_user() {
fi
}
# Prompt for admin email interactively (unless provided or unattended)
prompt_admin_email() {
if [[ -n "$ADMIN_EMAIL" ]]; then
return
# Resolve the Docker install directory (honours --install-dir and sudo).
docker_default_dir() {
if [[ -n "$INSTALL_DIR" ]]; then echo "$INSTALL_DIR"; return; fi
if [[ -n "${SUDO_USER:-}" ]]; then echo "/home/$SUDO_USER/picpeak"; else echo "$DOCKER_APP_DIR"; fi
}
# Best-effort primary IPv4 for building an access URL when there is no domain.
primary_ip() {
hostname -I 2>/dev/null | awk '{print $1}' | grep -E '^[0-9]' || echo "YOUR_HOST_IP"
}
# The user-facing port (frontend for Docker, backend for native).
access_port() {
if [[ "$INSTALL_METHOD" == "docker" ]]; then
echo "${CUSTOM_PORT:-3000}"
else
echo "${CUSTOM_PORT:-$DEFAULT_PORT}"
fi
if [[ "$UNATTENDED" == "true" ]]; then
ADMIN_EMAIL="admin@example.com"
return
}
# The scheme+host users open in a browser. HTTPS when a domain is fronted by
# Caddy or the operator's own reverse proxy; plain HTTP otherwise.
base_url() {
if [[ -n "$DOMAIN_NAME" ]]; then
if [[ "$HTTPS_MODE" == "none" ]]; then echo "http://$DOMAIN_NAME"; else echo "https://$DOMAIN_NAME"; fi
else
echo "http://$(primary_ip):$(access_port)"
fi
}
# --- Interactive wizard -------------------------------------------------------
# Ask how TLS is terminated for the given domain. Caddy auto-HTTPS is only
# offered for native installs (the Docker path has no built-in cert automation
# yet — operators front it with their own proxy).
choose_https_mode() {
echo
echo "Please enter the admin email address (used for the initial admin account):"
read -p "Admin email [admin@example.com]: " ADMIN_EMAIL
ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
echo "How should HTTPS be handled for ${DOMAIN_NAME}?"
if [[ "$INSTALL_METHOD" == "native" ]]; then
echo " 1) Automatic HTTPS via Caddy (installs Caddy + Let's Encrypt) [recommended]"
echo " 2) I run my own reverse proxy (Traefik / nginx / Cloudflare)"
echo " 3) Plain HTTP (no TLS)"
local c; read -p "Choice [1]: " c; c="${c:-1}"
case "$c" in
1) HTTPS_MODE="caddy"; ENABLE_SSL=true;;
2) HTTPS_MODE="proxy";;
*) HTTPS_MODE="none";;
esac
else
echo " 1) I run my own reverse proxy (Traefik / nginx / Cloudflare) [recommended]"
echo " 2) Plain HTTP (no TLS)"
echo " (Built-in Docker HTTPS via a Caddy sidecar is planned separately.)"
local c; read -p "Choice [1]: " c; c="${c:-1}"
case "$c" in
1) HTTPS_MODE="proxy";;
*) HTTPS_MODE="none";;
esac
fi
}
# Final summary + go/no-go before anything is installed.
review_and_confirm() {
print_header "Review"
echo " Install method : ${INSTALL_METHOD}"
echo " Directory : $([[ "$INSTALL_METHOD" == "docker" ]] && docker_default_dir || echo "$NATIVE_APP_DIR")"
[[ "$INSTALL_METHOD" == "docker" ]] && echo " Release channel: ${PICPEAK_CHANNEL}"
echo " Domain : ${DOMAIN_NAME:-none — local IP over HTTP}"
echo " HTTPS : ${HTTPS_MODE}"
echo " Admin email : ${ADMIN_EMAIL}"
echo " Admin account : $([[ -n "$ADMIN_PASSWORD" ]] && echo "seeded from --admin-password" || echo "created in browser (one-time /setup token)")"
echo " Email/SMTP : ${SMTP_HOST:-not configured}"
echo " Access URL : $(base_url)"
echo
if ! confirm "Proceed with installation?" "y"; then
die "Installation cancelled by user."
fi
}
# Full step-by-step wizard. Only runs in interactive mode; each value already
# supplied on the command line is respected and its question skipped.
run_wizard() {
print_header "PicPeak Setup Wizard"
echo "Answer a few questions and PicPeak configures itself."
echo "Press Enter to accept the [default] in brackets."
# 1) Install method (docker/native) — select_install_method handles the prompt
select_install_method
# 2) Install directory (Docker only; native is fixed at $NATIVE_APP_DIR)
if [[ "$INSTALL_METHOD" == "docker" && -z "$INSTALL_DIR" ]]; then
local default_dir; default_dir="$(docker_default_dir)"
echo
read -p "Install directory [$default_dir]: " INSTALL_DIR
INSTALL_DIR="${INSTALL_DIR:-$default_dir}"
fi
# 3) Release channel (Docker uses prebuilt images)
if [[ "$INSTALL_METHOD" == "docker" ]]; then
echo
echo "Release channel: 'stable' (recommended) or 'beta' (newest features)."
local ch; read -p "Channel [${PICPEAK_CHANNEL}]: " ch
PICPEAK_CHANNEL="${ch:-$PICPEAK_CHANNEL}"
fi
# 4) Domain
if [[ -z "$DOMAIN_NAME" ]]; then
echo
echo "Do you have a domain pointed at this server?"
echo "Leave blank to run on the local IP over HTTP (you can add a domain later)."
read -p "Domain (e.g. photos.example.com) [none]: " DOMAIN_NAME
fi
# 5) HTTPS mode
if [[ -n "$DOMAIN_NAME" ]]; then
choose_https_mode
else
HTTPS_MODE="none"
fi
# 6) Admin email (account itself is created in the browser)
if [[ -z "$ADMIN_EMAIL" ]]; then
echo
echo "Your admin account is created in the browser on first visit (no password in a file)."
read -p "Admin email [admin@example.com]: " ADMIN_EMAIL
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@example.com}"
fi
# 7) Optional email/SMTP
configure_email
# 8) Review + confirm
review_and_confirm
}
# Non-interactive path: fill defaults and reject impossible combinations so an
# --unattended run fails fast instead of silently doing the wrong thing.
validate_unattended() {
[[ -z "$INSTALL_METHOD" ]] && INSTALL_METHOD="docker"
[[ -z "$ADMIN_EMAIL" ]] && ADMIN_EMAIL="admin@example.com"
if [[ "$ENABLE_SSL" == "true" && -z "$DOMAIN_NAME" ]]; then
die "--enable-ssl requires --domain in unattended mode."
fi
# Resolve HTTPS mode for unattended runs.
if [[ -n "$DOMAIN_NAME" ]]; then
if [[ "$ENABLE_SSL" == "true" && "$INSTALL_METHOD" == "native" ]]; then
HTTPS_MODE="caddy"
else
# Docker + domain, or native without --enable-ssl: assume a fronting proxy.
HTTPS_MODE="proxy"
fi
else
HTTPS_MODE="none"
fi
}
print_banner() {
@@ -427,8 +573,9 @@ setup_docker_installation() {
git clone "$REPO_URL" "$app_dir"
fi
# Ensure storage layout exists after cloning
mkdir -p "$app_dir"/{storage/events/{active,archived},logs,backup,config,data,events}
# Ensure storage layout exists after cloning (production compose mounts
# ./storage, ./data, ./logs — no separate top-level events dir).
mkdir -p "$app_dir"/{storage/events/{active,archived},storage/thumbnails,logs,backup,data}
# Determine host user for container mapping (PUID/PGID)
local host_uid host_gid
@@ -440,86 +587,80 @@ setup_docker_installation() {
host_gid=$(id -g 2>/dev/null || echo 1000)
fi
# Generate secrets
# Generate machine secrets. Written to .env so they are stable across
# restarts; once PR #714's secrets-init service is present it reuses these
# exact values (explicit env always wins), so this stays correct either way.
local jwt_secret=$(generate_jwt_secret)
local db_password=$(generate_password)
local redis_password=$(generate_password)
# Create .env file
local frontend_port="${CUSTOM_PORT:-3000}"
local site_url; site_url="$(base_url)"
# Create .env for docker-compose.production.yml (prebuilt GHCR images).
log_step "Creating configuration..."
cat > "$app_dir/.env" <<EOF
# PicPeak Docker Configuration
# Generated: $(date)
# PicPeak Configuration — generated by picpeak-setup.sh on $(date)
# Runs docker-compose.production.yml (prebuilt images from GHCR).
# Application
# Make every 'docker compose' command in this directory use the production file.
COMPOSE_FILE=docker-compose.production.yml
# Release channel: stable | beta
PICPEAK_CHANNEL=$PICPEAK_CHANNEL
NODE_ENV=production
PORT=3001
# Machine secrets (generated; reused by the compose secrets-init service).
JWT_SECRET=$jwt_secret
# Admin
ADMIN_EMAIL=$ADMIN_EMAIL
# Runtime user mapping for Docker bind mounts
PUID=$host_uid
PGID=$host_gid
DB_PASSWORD=$db_password
REDIS_PASSWORD=$redis_password
# Database
DB_HOST=postgres
DB_PORT=5432
DB_USER=picpeak
DB_PASSWORD=$db_password
DB_NAME=picpeak
# Redis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=$redis_password
# Host-published ports (frontend is the user-facing one)
FRONTEND_PORT=$frontend_port
BACKEND_PORT=3001
# Storage
PHOTOS_DIR=/app/storage/events
# Host bind-mount paths (required by the production compose)
APP_STORAGE=./storage
APP_DATA=./data
LOGS=./logs
# Email
SMTP_ENABLED=${SMTP_HOST:+true}
# Admin — the account is created in the browser via a one-time /setup token.
ADMIN_EMAIL=$ADMIN_EMAIL
$(if [[ -n "$ADMIN_PASSWORD" ]]; then printf 'ADMIN_PASSWORD=%s\n' "$ADMIN_PASSWORD"; fi)
# Public URLs
FRONTEND_URL=$site_url
ADMIN_URL=$site_url
# Auth cookie behavior — 'auto' emits Secure on HTTPS, omits it on HTTP so a
# first HTTP install (before a reverse proxy) does not silently fail login (#427).
COOKIE_SECURE=auto
# Email (optional; can also be configured later in the admin panel)
SMTP_HOST=$SMTP_HOST
SMTP_PORT=$SMTP_PORT
SMTP_USER=$SMTP_USER
SMTP_PASS=$SMTP_PASS
SMTP_FROM=${SMTP_USER:-noreply@localhost}
# URLs
FRONTEND_URL=${DOMAIN_NAME:+https://$DOMAIN_NAME}
ADMIN_URL=${DOMAIN_NAME:+https://$DOMAIN_NAME}
# Auth cookie behavior — 'auto' emits Secure on HTTPS, omits it on HTTP.
# Without this, first-time HTTP installs (no reverse proxy yet) silently
# fail at login because the browser drops Secure cookies over HTTP (#427).
# On HTTPS via reverse proxy, req.secure is true → Secure flag is still
# emitted, so this is not a security regression for production deploys.
COOKIE_SECURE=auto
# Features
ENABLE_FILE_WATCHER=true
ENABLE_EXPIRATION_CHECKER=true
ENABLE_EMAIL_SERVICE=true
DEFAULT_EXPIRY_DAYS=30
EMAIL_FROM=${SMTP_USER:-noreply@localhost}
EOF
# Ensure bind mounts are writable by mapped user
chown -R "$host_uid":"$host_gid" "$app_dir"/storage "$app_dir"/logs "$app_dir"/backup "$app_dir"/data "$app_dir"/events 2>/dev/null || true
# Ensure bind mounts are writable by the invoking user (the container
# self-heals ownership on boot via wait-for-db.sh, this just lets the
# operator manage the files afterwards).
chown -R "$host_uid":"$host_gid" "$app_dir"/storage "$app_dir"/logs "$app_dir"/backup "$app_dir"/data 2>/dev/null || true
# Create docker-compose.yml if it doesn't exist
if [[ ! -f "$app_dir/docker-compose.yml" ]]; then
log_step "Creating Docker Compose configuration..."
create_docker_compose_file "$app_dir"
fi
# Set up SSL if requested
if [[ "$ENABLE_SSL" == "true" ]] && [[ -n "$DOMAIN_NAME" ]]; then
setup_ssl_docker "$app_dir"
# HTTPS for Docker has no built-in cert automation. When a domain is set we
# assume the operator terminates TLS with their own reverse proxy.
if [[ -n "$DOMAIN_NAME" && "$HTTPS_MODE" == "proxy" ]]; then
log_info "Point your reverse proxy at http://127.0.0.1:${frontend_port} and terminate TLS for ${DOMAIN_NAME} there."
fi
# Clean up the legacy picpeak-workers container from prior installs
# (workers are now in-process — see create_docker_compose_file).
# (workers are now in-process).
# Otherwise the leftover container keeps running its own
# fileWatcher / expirationChecker against the same DB rows the new
# backend container processes.
@@ -554,166 +695,27 @@ EOF
sleep 2
done
# Admin access. Two paths:
# - Seeded (ADMIN_PASSWORD set, or --force-admin-password-reset): the
# migration/reset script writes ADMIN_CREDENTIALS.txt — copy it to the host.
# - Browser-first (default): the backend writes a one-time /setup token to
# data/SETUP_TOKEN (already on the host via the ./data mount). The final
# instructions are printed by print_success_message.
if [[ "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
log_step "Resetting admin credentials..."
if docker compose exec -T backend node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt; then
docker compose cp backend:/app/data/ADMIN_CREDENTIALS.txt "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true
else
log_warn "Automatic admin password reset failed; run reset-admin-password.js inside the backend container."
fi
docker compose exec -T backend node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt \
|| log_warn "Automatic admin password reset failed; run reset-admin-password.js inside the backend container."
fi
# Surface how to finish setup. Two paths:
# - Legacy: if ADMIN_PASSWORD was set, migration seeded an admin and wrote
# data/ADMIN_CREDENTIALS.txt (#427) — print those credentials.
# - Default (no ADMIN_PASSWORD): no admin is seeded; the app shows a
# first-run wizard at /setup guarded by a one-time token
# (data/SETUP_TOKEN). Print the token and point the operator there.
local login_base="${DOMAIN_NAME:+https://$DOMAIN_NAME}${DOMAIN_NAME:-http://YOUR_HOST_IP:3000}"
if docker compose cp backend:/app/data/ADMIN_CREDENTIALS.txt "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null; then
chown "$host_uid":"$host_gid" "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true
chmod 600 "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true
log_step "Admin credentials saved to: $app_dir/data/ADMIN_CREDENTIALS.txt"
echo
echo "--------------------------------------------------"
grep -E '^Email:|^Password:' "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true
echo "--------------------------------------------------"
echo " Login URL: $login_base/admin"
echo " Delete the credentials file after recording the password."
echo
else
# First-run wizard path — surface the one-time setup token.
docker compose cp backend:/app/data/SETUP_TOKEN "$app_dir/data/SETUP_TOKEN" 2>/dev/null || true
chown "$host_uid":"$host_gid" "$app_dir/data/SETUP_TOKEN" 2>/dev/null || true
local setup_token
setup_token="$(cat "$app_dir/data/SETUP_TOKEN" 2>/dev/null)"
[ -z "$setup_token" ] && setup_token="$(docker compose logs backend 2>/dev/null | grep -i 'setup token:' | tail -1 | sed -E 's/.*setup token: *([A-Za-z0-9_-]+).*/\1/')"
echo
echo "--------------------------------------------------"
echo " Finish setup in your browser — create the admin account:"
echo " 1. Open $login_base/admin"
echo " 2. Enter this one-time setup token:"
if [ -n "$setup_token" ]; then
echo " $setup_token"
else
echo " (run: cd $app_dir && docker compose logs backend | grep -i \"setup token\")"
if [[ -n "$ADMIN_PASSWORD" || "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
if docker compose cp backend:/app/data/ADMIN_CREDENTIALS.txt "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null; then
chown "$host_uid":"$host_gid" "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true
chmod 600 "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true
fi
echo " 3. Set your admin email and password."
echo "--------------------------------------------------"
echo
fi
log_success "Docker installation completed!"
}
create_docker_compose_file() {
local app_dir="$1"
cat > "$app_dir/docker-compose.yml" <<'EOF'
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: picpeak-postgres
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- picpeak-network
restart: unless-stopped
healthcheck:
# `pg_isready -U <user>` without -d defaults to probing a database
# whose name matches the user — postgres logs constant `FATAL:
# database "<user>" does not exist` even though the actual DB
# is `${DB_NAME}`. Pinning -d to DB_NAME silences the noise
# that made #484's reporter think the install was broken.
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: picpeak-redis
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis-data:/data
networks:
- picpeak-network
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 10s
timeout: 5s
retries: 5
backend:
build: ./backend
container_name: picpeak-backend
env_file: .env
volumes:
- ./storage:/app/storage
- ./logs:/app/logs
ports:
- "${PORT:-3001}:3001"
networks:
- picpeak-network
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
healthcheck:
# backend image only ships wget (Alpine base) — using curl
# makes `docker ps` show the container as `unhealthy`
# indefinitely even when /api/health responds. Mirrors the
# wget-based HEALTHCHECK in backend/Dockerfile.
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/api/health"]
interval: 30s
timeout: 10s
retries: 3
# Frontend container (#484). Previously absent from the
# script-generated compose, which left the script's docker
# architecture (backend on host port 3001, no separate frontend)
# different from the documented production install
# (docker-compose.production.yml: backend internal-only +
# frontend nginx serving /api proxy on host port 3000). Aligning
# both shapes on the same 3-service shape — postgres + redis +
# backend, plus a frontend nginx — eliminates the doc/script
# divergence MrGabri flagged.
frontend:
build: ./frontend
container_name: picpeak-frontend
ports:
- "${FRONTEND_PORT:-3000}:80"
networks:
- picpeak-network
depends_on:
- backend
restart: unless-stopped
healthcheck:
# frontend Dockerfile installs curl (apk add --no-cache curl)
# — safe to use here unlike the backend.
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
volumes:
postgres-data:
redis-data:
networks:
picpeak-network:
driver: bridge
EOF
}
################################################################################
# Native Installation
################################################################################
@@ -835,9 +837,11 @@ NODE_ENV=production
PORT=${CUSTOM_PORT:-$DEFAULT_PORT}
JWT_SECRET=$jwt_secret
# Admin
# Admin — created in the browser via a one-time /setup token unless a password
# is seeded here (--admin-password).
ADMIN_USERNAME=admin
ADMIN_EMAIL=$ADMIN_EMAIL
$(if [[ -n "$ADMIN_PASSWORD" ]]; then printf 'ADMIN_PASSWORD=%s\n' "$ADMIN_PASSWORD"; fi)
# Database (native uses SQLite by default)
DATABASE_CLIENT=sqlite3
@@ -885,27 +889,18 @@ EOF
cd "$NATIVE_APP_DIR/app/backend"
run_as_user "npm run migrate"
# Seeded-admin path only: reset/secure the credentials file. When no
# password was seeded, the admin is created in the browser via the /setup
# token (surfaced by print_success_message). The migration only writes
# ADMIN_CREDENTIALS.txt when ADMIN_PASSWORD is set.
if [[ "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
log_step "Resetting admin credentials..."
if ! run_as_user "node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt"; then
log_warn "Automatic admin password reset failed; please run reset-admin-password.js manually."
fi
fi
# Always surface the admin credentials file (#427).
local creds_path="$NATIVE_APP_DIR/app/backend/data/ADMIN_CREDENTIALS.txt"
if [[ -f "$creds_path" ]]; then
chmod 600 "$creds_path" 2>/dev/null || true
log_step "Admin credentials saved to: $creds_path"
echo
echo "--------------------------------------------------"
grep -E '^Email:|^Password:' "$creds_path" 2>/dev/null || true
echo "--------------------------------------------------"
echo " Login URL: ${DOMAIN_NAME:+https://$DOMAIN_NAME}${DOMAIN_NAME:-http://YOUR_HOST_IP:${CUSTOM_PORT:-$DEFAULT_PORT}}/admin"
echo " Full credentials file: $creds_path"
echo " Delete the file after recording the password."
echo
fi
[[ -f "$creds_path" ]] && chmod 600 "$creds_path" 2>/dev/null || true
# Create systemd services
create_systemd_services
@@ -1088,73 +1083,70 @@ configure_email() {
}
print_success_message() {
local app_dir port manual_reset_hint
local app_dir site_url token_file
site_url="$(base_url)"
if [[ "$INSTALL_METHOD" == "docker" ]]; then
app_dir="$DOCKER_APP_DIR"
[[ -n "${SUDO_USER:-}" ]] && app_dir="/home/$SUDO_USER/picpeak"
port="${CUSTOM_PORT:-$DEFAULT_PORT}"
manual_reset_hint="cd $(printf %q "$app_dir") && docker compose exec -T backend node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt"
app_dir="$(docker_default_dir)"
token_file="$app_dir/data/SETUP_TOKEN"
else
app_dir="$NATIVE_APP_DIR"
port="${CUSTOM_PORT:-$DEFAULT_PORT}"
manual_reset_hint="cd $(printf %q "${NATIVE_APP_DIR}/app/backend") && sudo -H -u $(printf %q "$NATIVE_APP_USER") node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt"
token_file="$NATIVE_APP_DIR/app/backend/data/SETUP_TOKEN"
fi
print_header "🎉 Installation Complete!"
echo -e "${GREEN}PicPeak has been successfully installed!${NC}\n"
echo "📍 Access Information:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [[ -n "$DOMAIN_NAME" ]] && [[ "$ENABLE_SSL" == "true" ]]; then
echo -e "Admin Panel: ${CYAN}https://$DOMAIN_NAME/admin${NC}"
echo -e "Gallery URL: ${CYAN}https://$DOMAIN_NAME${NC}"
else
echo -e "Admin Panel: ${CYAN}http://$(hostname -I | awk '{print $1}'):$port/admin${NC}"
echo -e "Gallery URL: ${CYAN}http://$(hostname -I | awk '{print $1}'):$port${NC}"
fi
echo -e "Admin Panel: ${CYAN}${site_url}/admin${NC}"
echo -e "Gallery URL: ${CYAN}${site_url}${NC}"
echo
echo "🔐 Admin Credentials:"
echo "🔐 First Login:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Read from ADMIN_CREDENTIALS.txt when available
local cred_file email_line pass_line admin_email_val admin_pass_val
if [[ "$INSTALL_METHOD" == "docker" ]]; then
cred_file="$app_dir/data/ADMIN_CREDENTIALS.txt"
else
cred_file="$NATIVE_APP_DIR/app/backend/data/ADMIN_CREDENTIALS.txt"
fi
if [[ -f "$cred_file" ]]; then
local cred_file="$app_dir/data/ADMIN_CREDENTIALS.txt"
[[ "$INSTALL_METHOD" == "native" ]] && cred_file="$NATIVE_APP_DIR/app/backend/data/ADMIN_CREDENTIALS.txt"
if [[ -n "$ADMIN_PASSWORD" || "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]] && [[ -f "$cred_file" ]]; then
# Seeded-admin path: the credentials file holds email + password.
local email_line pass_line
email_line=$(grep -m1 '^Email:' "$cred_file" || true)
pass_line=$(grep -m1 '^Password:' "$cred_file" || true)
admin_email_val=${email_line#Email: }
admin_pass_val=${pass_line#Password: }
if [[ -n "$admin_email_val" ]]; then
echo -e "Email: ${CYAN}$admin_email_val${NC}"
echo -e "Email: ${CYAN}${email_line#Email: }${NC}"
echo -e "Password: ${CYAN}${pass_line#Password: }${NC}"
echo -e "Saved to: ${cred_file} ${YELLOW}(delete after recording)${NC}"
echo -e "${YELLOW}⚠️ Change this password on first login.${NC}"
else
# Browser-first path: create the admin in the browser via a one-time token.
local token=""
# The token file appears once the backend has booted; wait briefly.
for _ in $(seq 1 10); do [[ -s "$token_file" ]] && break; sleep 1; done
[[ -s "$token_file" ]] && token=$(tr -d '\r\n' < "$token_file" 2>/dev/null || true)
echo "Create your admin account in the browser:"
echo -e " 1. Open ${CYAN}${site_url}/admin${NC}"
echo " 2. Enter your email and a password."
if [[ -n "$token" ]]; then
echo " 3. When prompted, paste this one-time setup token:"
echo -e " ${CYAN}${token}${NC}"
elif [[ "$INSTALL_METHOD" == "docker" ]]; then
echo " 3. Get the one-time setup token from the logs:"
echo -e " ${CYAN}cd $app_dir && docker compose logs backend | grep -i 'setup token'${NC}"
else
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
echo " 3. Get the one-time setup token from the logs:"
echo -e " ${CYAN}sudo journalctl -u picpeak-backend | grep -i 'setup token'${NC}"
fi
if [[ -n "$admin_pass_val" ]]; then
echo -e "Password: ${CYAN}$admin_pass_val${NC}"
else
echo -e "Password: ${YELLOW}(see $cred_file)${NC}"
fi
else
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
echo -e "Password: ${YELLOW}(credentials file not found - rerun setup with --force-admin-password-reset or run '${manual_reset_hint}')${NC}"
fi
echo
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
echo -e " (Also saved to ${token_file}.)"
fi
echo
echo "📁 Installation Details:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Installation directory: $app_dir"
echo "Installation method: ${INSTALL_METHOD^}"
echo "Log file: $LOG_FILE"
if [[ "$INSTALL_METHOD" == "docker" ]]; then
echo
echo "🐳 Docker Commands:"
@@ -1162,7 +1154,7 @@ print_success_message() {
echo "View logs: cd $app_dir && docker compose logs -f"
echo "Stop: cd $app_dir && docker compose down"
echo "Start: cd $app_dir && docker compose up -d"
echo "Update: cd $app_dir && git pull && docker compose up -d --build"
echo "Update: cd $app_dir && git pull && docker compose pull && docker compose up -d"
else
echo
echo "🔧 Service Commands:"
@@ -1227,16 +1219,16 @@ update_docker_installation() {
# Backup current configuration
cp .env .env.backup-$(date +%Y%m%d-%H%M%S)
# Pull latest code
# Pull latest code (new compose file / defaults) and refresh the images.
git pull
# Rebuild and restart containers
# Production compose uses prebuilt GHCR images, so pull rather than build.
# COMPOSE_FILE in .env points every command at docker-compose.production.yml.
docker compose down
docker compose build --no-cache
docker compose pull
# Clean up the legacy picpeak-workers container if it exists
# (workers are now in-process — see comment in
# create_docker_compose_file). Best-effort: ignore if absent.
# (workers are now in-process). Best-effort: ignore if absent.
if docker ps -a --format '{{.Names}}' | grep -q '^picpeak-workers$'; then
log_step "Removing legacy picpeak-workers container (workers now run in-process)..."
docker stop picpeak-workers >/dev/null 2>&1 || true
@@ -1413,6 +1405,18 @@ parse_arguments() {
ADMIN_EMAIL="$2"
shift 2
;;
--admin-password)
ADMIN_PASSWORD="$2"
shift 2
;;
--install-dir)
INSTALL_DIR="$2"
shift 2
;;
--channel)
PICPEAK_CHANNEL="$2"
shift 2
;;
--smtp-host)
SMTP_HOST="$2"
shift 2
@@ -1470,37 +1474,42 @@ Installation Methods:
--docker Use Docker installation (default if unattended)
--native Use native installation (systemd)
Run with no options for the interactive step-by-step wizard, or pass
--unattended plus the options below to install without any prompts.
Options:
--unattended Run without prompts
--domain DOMAIN Set domain name for HTTPS
--email EMAIL Admin email address
--unattended Run without prompts (uses defaults + the flags below)
--domain DOMAIN Domain name (enables HTTPS URLs)
--email EMAIL Admin email address (default: admin@example.com)
--admin-password P Seed the admin account with this password (headless).
Omit to create the admin in the browser via a one-time
/setup token (recommended).
--install-dir DIR Install directory (Docker; default: ~/picpeak)
--channel CHANNEL Image channel for Docker: stable (default) or beta
--smtp-host HOST SMTP server hostname
--smtp-port PORT SMTP server port
--smtp-user USER SMTP username
--smtp-pass PASS SMTP password
--force-admin-password-reset Regenerate admin credentials after setup
--enable-ssl Enable HTTPS with Let's Encrypt
--port PORT Custom port (native only)
--enable-ssl Native only: provision HTTPS via Caddy (needs --domain)
--port PORT Custom user-facing port
--update Update existing installation
--uninstall Remove PicPeak installation
--help Show this help message
Examples:
# Interactive installation
# Interactive wizard (asks every question, then confirms)
sudo $0
# Docker installation with domain
sudo $0 --docker --domain photos.example.com --enable-ssl
# Unattended Docker install, admin created in the browser afterwards
sudo $0 --docker --unattended --email admin@example.com
# Native installation for Raspberry Pi
sudo $0 --native --port 8080
# Fully automated Docker setup
# Unattended Docker install behind your own reverse proxy, seeded admin
sudo $0 --docker --unattended --domain photos.example.com \\
--email admin@example.com \\
--smtp-host smtp.gmail.com --smtp-port 587 \\
--smtp-user user@gmail.com --smtp-pass app-password \\
--enable-ssl
--email admin@example.com --admin-password 'S0me-Str0ng-Pass'
# Native install on a Raspberry Pi with automatic HTTPS via Caddy
sudo $0 --native --domain photos.example.com --enable-ssl
EOF
}
@@ -1527,29 +1536,27 @@ main() {
# Start installation
print_banner
# Detect OS
detect_os
# Select installation method
select_install_method
# Check system requirements
# Gather configuration: full wizard when interactive, flag-driven otherwise.
if [[ "$UNATTENDED" == "true" ]]; then
validate_unattended
else
run_wizard
fi
# Check system requirements (now that the install method is known)
check_system_requirements
# Prompt for admin email (design choice: always ask unless provided)
prompt_admin_email
# Configure email (optional)
configure_email
# Perform installation
if [[ "$INSTALL_METHOD" == "docker" ]]; then
setup_docker_installation
else
setup_native_installation
fi
# Show success message
print_success_message
}