diff --git a/docs/superpowers/plans/2026-04-13-install-script-plan.md b/docs/superpowers/plans/2026-04-13-install-script-plan.md
new file mode 100644
index 0000000..3843fc3
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-13-install-script-plan.md
@@ -0,0 +1,2662 @@
+# Cameleer SaaS Install Script — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Create a professional installer (bash + PowerShell) for the Cameleer SaaS platform, preceded by consolidating all service init logic into Docker images to eliminate bind-mounted config files.
+
+**Architecture:** Two-phase approach — first consolidate 7 services into 5 by baking init scripts into custom Docker images, then build dual-platform installer scripts that embed compose templates and generate all configuration from user input. The installer supports simple/expert/silent modes with idempotent re-run capability.
+
+**Tech Stack:** Docker, Bash, PowerShell, Traefik v3, PostgreSQL 16, ClickHouse, Logto (OIDC)
+
+**Spec:** `docs/superpowers/specs/2026-04-13-install-script-design.md`
+
+---
+
+## File Map
+
+### Phase 1 — New files (image consolidation)
+
+| File | Purpose |
+|---|---|
+| `docker/cameleer-postgres/Dockerfile` | PostgreSQL with init script baked in |
+| `docker/cameleer-postgres/init-databases.sh` | Copied from `docker/init-databases.sh` |
+| `docker/cameleer-clickhouse/Dockerfile` | ClickHouse with init + config baked in |
+| `docker/cameleer-clickhouse/init.sql` | Copied from `docker/clickhouse-init.sql` |
+| `docker/cameleer-clickhouse/users.xml` | From `docker/clickhouse-users.xml`, password via `from_env` |
+| `docker/cameleer-clickhouse/prometheus.xml` | Copied from `docker/clickhouse-config.xml` |
+| `docker/cameleer-traefik/Dockerfile` | Traefik with configs + cert generation baked in |
+| `docker/cameleer-traefik/entrypoint.sh` | Cert generation + Traefik startup |
+| `docker/cameleer-traefik/traefik.yml` | Copied from root `traefik.yml` |
+| `docker/cameleer-traefik/traefik-dynamic.yml` | Copied from `docker/traefik-dynamic.yml` |
+| `docker/cameleer-logto/logto-entrypoint.sh` | Wrapper: seed → start → bootstrap → wait |
+
+### Phase 1 — Modified files
+
+| File | Change |
+|---|---|
+| `ui/sign-in/Dockerfile` | Add bootstrap deps + scripts, new entrypoint |
+| `docker-compose.yml` | Remove bind mounts, init containers, use new images |
+| `.env.example` | Simplify for new compose structure |
+| `docker/logto-bootstrap.sh` | Make package install conditional (Alpine vs Debian) |
+| `.gitea/workflows/ci.yml` | Add builds for postgres, clickhouse, traefik images; update Logto build context |
+
+### Phase 1 — Deleted files
+
+| File | Reason |
+|---|---|
+| `docker/clickhouse-init.sql` | Moved to `docker/cameleer-clickhouse/init.sql` |
+| `docker/clickhouse-users.xml` | Moved (with modification) to `docker/cameleer-clickhouse/users.xml` |
+| `docker/clickhouse-config.xml` | Moved to `docker/cameleer-clickhouse/prometheus.xml` |
+| `docker/traefik-dynamic.yml` | Moved to `docker/cameleer-traefik/traefik-dynamic.yml` |
+| `docker/vendor-seed.sh` | Logic already integrated in `logto-bootstrap.sh` Phase 12 |
+| `traefik.yml` | Moved to `docker/cameleer-traefik/traefik.yml` |
+| `docker/init-databases.sh` | Moved to `docker/cameleer-postgres/init-databases.sh` |
+
+### Phase 2 & 3 — New files (installer)
+
+| File | Purpose |
+|---|---|
+| `installer/install.sh` | Bash installer for Linux |
+| `installer/install.ps1` | PowerShell installer for Windows |
+
+---
+
+## Phase 1: Platform Image Consolidation
+
+### Task 1: Create cameleer-postgres image
+
+**Files:**
+- Create: `docker/cameleer-postgres/Dockerfile`
+- Create: `docker/cameleer-postgres/init-databases.sh` (copy from `docker/init-databases.sh`)
+
+- [ ] **Step 1: Create the Dockerfile**
+
+```dockerfile
+FROM postgres:16-alpine
+COPY init-databases.sh /docker-entrypoint-initdb.d/init-databases.sh
+RUN chmod +x /docker-entrypoint-initdb.d/init-databases.sh
+```
+
+- [ ] **Step 2: Copy the init script**
+
+```bash
+mkdir -p docker/cameleer-postgres
+cp docker/init-databases.sh docker/cameleer-postgres/init-databases.sh
+```
+
+- [ ] **Step 3: Build and verify**
+
+```bash
+docker build -t cameleer-postgres:test docker/cameleer-postgres/
+docker run --rm -e POSTGRES_USER=cameleer -e POSTGRES_PASSWORD=test -e POSTGRES_DB=cameleer_saas \
+ cameleer-postgres:test postgres --version
+```
+
+Expected: PostgreSQL version output, no build errors.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docker/cameleer-postgres/
+git commit -m "feat: create cameleer-postgres image with init script baked in"
+```
+
+---
+
+### Task 2: Create cameleer-clickhouse image
+
+**Files:**
+- Create: `docker/cameleer-clickhouse/Dockerfile`
+- Create: `docker/cameleer-clickhouse/init.sql` (copy from `docker/clickhouse-init.sql`)
+- Create: `docker/cameleer-clickhouse/users.xml` (from `docker/clickhouse-users.xml`, modified)
+- Create: `docker/cameleer-clickhouse/prometheus.xml` (copy from `docker/clickhouse-config.xml`)
+
+- [ ] **Step 1: Create the Dockerfile**
+
+```dockerfile
+FROM clickhouse/clickhouse-server:latest
+COPY init.sql /docker-entrypoint-initdb.d/init.sql
+COPY users.xml /etc/clickhouse-server/users.d/default-user.xml
+COPY prometheus.xml /etc/clickhouse-server/config.d/prometheus.xml
+```
+
+- [ ] **Step 2: Copy and modify config files**
+
+```bash
+mkdir -p docker/cameleer-clickhouse
+cp docker/clickhouse-init.sql docker/cameleer-clickhouse/init.sql
+cp docker/clickhouse-config.xml docker/cameleer-clickhouse/prometheus.xml
+```
+
+Create `docker/cameleer-clickhouse/users.xml` — same as original but with env-var password:
+
+```xml
+
+
+
+
+
+
+ default
+
+ ::/0
+
+
+ default
+ 0
+
+
+
+```
+
+Note: the `from_env` attribute makes the password configurable via the `CLICKHOUSE_PASSWORD` environment variable instead of being hardcoded.
+
+- [ ] **Step 3: Build and verify**
+
+```bash
+docker build -t cameleer-clickhouse:test docker/cameleer-clickhouse/
+docker run --rm -e CLICKHOUSE_PASSWORD=testpass cameleer-clickhouse:test clickhouse-client --version
+```
+
+Expected: ClickHouse version output, no build errors.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docker/cameleer-clickhouse/
+git commit -m "feat: create cameleer-clickhouse image with init and config baked in"
+```
+
+---
+
+### Task 3: Create cameleer-traefik image
+
+**Files:**
+- Create: `docker/cameleer-traefik/Dockerfile`
+- Create: `docker/cameleer-traefik/entrypoint.sh`
+- Create: `docker/cameleer-traefik/traefik.yml` (copy from root `traefik.yml`)
+- Create: `docker/cameleer-traefik/traefik-dynamic.yml` (copy from `docker/traefik-dynamic.yml`)
+
+- [ ] **Step 1: Copy Traefik config files**
+
+```bash
+mkdir -p docker/cameleer-traefik
+cp traefik.yml docker/cameleer-traefik/traefik.yml
+cp docker/traefik-dynamic.yml docker/cameleer-traefik/traefik-dynamic.yml
+```
+
+- [ ] **Step 2: Create the entrypoint script**
+
+`docker/cameleer-traefik/entrypoint.sh`:
+
+```bash
+#!/bin/sh
+set -e
+
+CERTS_DIR="/certs"
+
+# Skip if certs already exist (idempotent)
+if [ ! -f "$CERTS_DIR/cert.pem" ]; then
+ mkdir -p "$CERTS_DIR"
+
+ if [ -n "$CERT_FILE" ] && [ -n "$KEY_FILE" ]; then
+ # User-supplied certificate
+ echo "[certs] Installing user-supplied certificate..."
+ cp "$CERT_FILE" "$CERTS_DIR/cert.pem"
+ cp "$KEY_FILE" "$CERTS_DIR/key.pem"
+ if [ -n "$CA_FILE" ]; then
+ cp "$CA_FILE" "$CERTS_DIR/ca.pem"
+ fi
+ # Validate key matches cert
+ CERT_MOD=$(openssl x509 -noout -modulus -in "$CERTS_DIR/cert.pem" 2>/dev/null | md5sum)
+ KEY_MOD=$(openssl rsa -noout -modulus -in "$CERTS_DIR/key.pem" 2>/dev/null | md5sum)
+ if [ "$CERT_MOD" != "$KEY_MOD" ]; then
+ echo "[certs] ERROR: Certificate and key do not match!"
+ rm -f "$CERTS_DIR/cert.pem" "$CERTS_DIR/key.pem" "$CERTS_DIR/ca.pem"
+ exit 1
+ fi
+ SELF_SIGNED=false
+ echo "[certs] Installed user-supplied certificate."
+ else
+ # Generate self-signed certificate
+ HOST="${PUBLIC_HOST:-localhost}"
+ echo "[certs] Generating self-signed certificate for $HOST..."
+ openssl req -x509 -newkey rsa:4096 \
+ -keyout "$CERTS_DIR/key.pem" -out "$CERTS_DIR/cert.pem" \
+ -days 365 -nodes \
+ -subj "/CN=$HOST" \
+ -addext "subjectAltName=DNS:$HOST,DNS:*.$HOST"
+ SELF_SIGNED=true
+ echo "[certs] Generated self-signed certificate for $HOST."
+ fi
+
+ # Write metadata for SaaS app to seed DB
+ SUBJECT=$(openssl x509 -noout -subject -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/subject=//')
+ FINGERPRINT=$(openssl x509 -noout -fingerprint -sha256 -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/.*=//')
+ NOT_BEFORE=$(openssl x509 -noout -startdate -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/notBefore=//')
+ NOT_AFTER=$(openssl x509 -noout -enddate -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/notAfter=//')
+ HAS_CA=false
+ [ -f "$CERTS_DIR/ca.pem" ] && HAS_CA=true
+ cat > "$CERTS_DIR/meta.json" </dev/null || true
+else
+ echo "[certs] Certificates already exist, skipping generation."
+fi
+
+# Start Traefik
+exec traefik "$@"
+```
+
+- [ ] **Step 3: Create the Dockerfile**
+
+```dockerfile
+FROM traefik:v3
+RUN apk add --no-cache openssl
+COPY traefik.yml /etc/traefik/traefik.yml
+COPY traefik-dynamic.yml /etc/traefik/dynamic.yml
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+ENTRYPOINT ["/entrypoint.sh"]
+```
+
+- [ ] **Step 4: Build and verify**
+
+```bash
+docker build -t cameleer-traefik:test docker/cameleer-traefik/
+docker run --rm cameleer-traefik:test --version
+```
+
+Expected: Traefik version output, no build errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add docker/cameleer-traefik/
+git commit -m "feat: create cameleer-traefik image with cert generation and config baked in"
+```
+
+---
+
+### Task 4: Merge bootstrap into cameleer-logto
+
+**Files:**
+- Create: `docker/cameleer-logto/logto-entrypoint.sh`
+- Modify: `ui/sign-in/Dockerfile`
+- Modify: `docker/logto-bootstrap.sh` (make package install conditional)
+
+- [ ] **Step 1: Create the entrypoint wrapper**
+
+`docker/cameleer-logto/logto-entrypoint.sh`:
+
+```bash
+#!/bin/sh
+set -e
+
+echo "[entrypoint] Seeding Logto database..."
+npm run cli db seed -- --swe 2>/dev/null || true
+
+echo "[entrypoint] Starting Logto..."
+npm start &
+LOGTO_PID=$!
+
+echo "[entrypoint] Waiting for Logto to be ready..."
+for i in $(seq 1 120); do
+ if node -e "require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" 2>/dev/null; then
+ echo "[entrypoint] Logto is ready."
+ break
+ fi
+ if [ "$i" -eq 120 ]; then
+ echo "[entrypoint] ERROR: Logto not ready after 120s"
+ exit 1
+ fi
+ sleep 1
+done
+
+# Run bootstrap if not already done
+BOOTSTRAP_FILE="/data/logto-bootstrap.json"
+if [ -f "$BOOTSTRAP_FILE" ]; then
+ CACHED_SECRET=$(jq -r '.m2mClientSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
+ CACHED_SPA=$(jq -r '.spaClientId // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
+ if [ -n "$CACHED_SECRET" ] && [ -n "$CACHED_SPA" ]; then
+ echo "[entrypoint] Bootstrap already complete."
+ else
+ echo "[entrypoint] Incomplete bootstrap found, re-running..."
+ /scripts/logto-bootstrap.sh
+ fi
+else
+ echo "[entrypoint] Running bootstrap..."
+ /scripts/logto-bootstrap.sh
+fi
+
+echo "[entrypoint] Logto is running (PID $LOGTO_PID)."
+wait $LOGTO_PID
+```
+
+- [ ] **Step 2: Make bootstrap script package-install conditional**
+
+In `docker/logto-bootstrap.sh`, replace line 51:
+
+```bash
+# Install jq + curl
+apk add --no-cache jq curl >/dev/null 2>&1
+```
+
+With:
+
+```bash
+# Install jq + curl if not already available (deps are baked into cameleer-logto image)
+if ! command -v jq >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then
+ if command -v apk >/dev/null 2>&1; then
+ apk add --no-cache jq curl >/dev/null 2>&1
+ elif command -v apt-get >/dev/null 2>&1; then
+ apt-get update -qq && apt-get install -y -qq jq curl >/dev/null 2>&1
+ fi
+fi
+```
+
+- [ ] **Step 3: Update ui/sign-in/Dockerfile**
+
+Replace the entire Dockerfile content with:
+
+```dockerfile
+# syntax=docker/dockerfile:1
+
+# Stage 1: Build custom sign-in UI
+FROM --platform=$BUILDPLATFORM node:22-alpine AS build
+ARG REGISTRY_TOKEN
+WORKDIR /ui
+COPY ui/sign-in/package.json ui/sign-in/package-lock.json ui/sign-in/.npmrc ./
+RUN --mount=type=cache,target=/root/.npm echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}" >> .npmrc && npm ci
+COPY ui/sign-in/ .
+RUN npm run build
+
+# Stage 2: Logto with sign-in UI + bootstrap
+FROM ghcr.io/logto-io/logto:latest
+
+# Install bootstrap dependencies (curl, jq for API calls; postgresql-client for DB reads)
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl jq postgresql-client \
+ && rm -rf /var/lib/apt/lists/*
+
+# Custom sign-in UI
+COPY --from=build /ui/dist/ /etc/logto/packages/experience/dist/
+
+# Bootstrap scripts
+COPY docker/logto-bootstrap.sh /scripts/logto-bootstrap.sh
+COPY docker/cameleer-logto/logto-entrypoint.sh /scripts/entrypoint.sh
+RUN chmod +x /scripts/*.sh
+
+ENTRYPOINT ["/scripts/entrypoint.sh"]
+```
+
+Note: This Dockerfile now requires the **repo root** as build context (not `ui/sign-in/`). The CI workflow update in Task 6 handles this.
+
+- [ ] **Step 4: Build and verify**
+
+```bash
+mkdir -p docker/cameleer-logto
+docker build -f ui/sign-in/Dockerfile -t cameleer-logto:test .
+```
+
+Expected: successful build (sign-in UI + bootstrap deps installed).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add docker/cameleer-logto/ ui/sign-in/Dockerfile docker/logto-bootstrap.sh
+git commit -m "feat: merge bootstrap into cameleer-logto image
+
+Adds logto-entrypoint.sh that seeds DB, starts Logto, waits for health,
+runs bootstrap, then keeps Logto running. Eliminates the separate
+logto-bootstrap init container."
+```
+
+---
+
+### Task 5: Update docker-compose.yml and clean up old files
+
+**Files:**
+- Modify: `docker-compose.yml`
+- Modify: `.env.example`
+- Delete: `docker/init-databases.sh`, `docker/clickhouse-init.sql`, `docker/clickhouse-users.xml`, `docker/clickhouse-config.xml`, `docker/traefik-dynamic.yml`, `docker/vendor-seed.sh`, `traefik.yml`
+
+- [ ] **Step 1: Replace docker-compose.yml**
+
+Replace the entire `docker-compose.yml` with:
+
+```yaml
+services:
+ traefik:
+ image: ${TRAEFIK_IMAGE:-gitea.siegeln.net/cameleer/cameleer-traefik}:${VERSION:-latest}
+ restart: unless-stopped
+ ports:
+ - "${HTTP_PORT:-80}:80"
+ - "${HTTPS_PORT:-443}:443"
+ - "${LOGTO_CONSOLE_PORT:-3002}:3002"
+ environment:
+ PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
+ CERT_FILE: ${CERT_FILE:-}
+ KEY_FILE: ${KEY_FILE:-}
+ CA_FILE: ${CA_FILE:-}
+ volumes:
+ - certs:/certs
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ networks:
+ - cameleer
+ - cameleer-traefik
+
+ postgres:
+ image: ${POSTGRES_IMAGE:-gitea.siegeln.net/cameleer/cameleer-postgres}:${VERSION:-latest}
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB:-cameleer_saas}
+ POSTGRES_USER: ${POSTGRES_USER:-cameleer}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-cameleer} -d ${POSTGRES_DB:-cameleer_saas}"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ networks:
+ - cameleer
+
+ clickhouse:
+ image: ${CLICKHOUSE_IMAGE:-gitea.siegeln.net/cameleer/cameleer-clickhouse}:${VERSION:-latest}
+ restart: unless-stopped
+ environment:
+ CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-cameleer_ch}
+ volumes:
+ - chdata:/var/lib/clickhouse
+ healthcheck:
+ test: ["CMD-SHELL", "clickhouse-client --password ${CLICKHOUSE_PASSWORD:-cameleer_ch} --query 'SELECT 1'"]
+ interval: 10s
+ timeout: 5s
+ retries: 3
+ labels:
+ - prometheus.scrape=true
+ - prometheus.path=/metrics
+ - prometheus.port=9363
+ networks:
+ - cameleer
+
+ logto:
+ image: ${LOGTO_IMAGE:-gitea.siegeln.net/cameleer/cameleer-logto}:${VERSION:-latest}
+ restart: unless-stopped
+ depends_on:
+ postgres:
+ condition: service_healthy
+ environment:
+ DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD:-cameleer_dev}@postgres:5432/logto
+ ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
+ ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}
+ TRUST_PROXY_HEADER: 1
+ NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}"
+ LOGTO_ENDPOINT: http://logto:3001
+ LOGTO_ADMIN_ENDPOINT: http://logto:3002
+ LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
+ PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
+ PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https}
+ PG_HOST: postgres
+ PG_USER: ${POSTGRES_USER:-cameleer}
+ PG_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
+ PG_DB_SAAS: ${POSTGRES_DB:-cameleer_saas}
+ SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
+ SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:-admin}
+ VENDOR_SEED_ENABLED: "${VENDOR_SEED_ENABLED:-false}"
+ VENDOR_USER: ${VENDOR_USER:-vendor}
+ VENDOR_PASS: ${VENDOR_PASS:-vendor}
+ healthcheck:
+ test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"]
+ interval: 10s
+ timeout: 5s
+ retries: 60
+ start_period: 30s
+ labels:
+ - traefik.enable=true
+ - traefik.http.routers.logto.rule=PathPrefix(`/`)
+ - traefik.http.routers.logto.priority=1
+ - traefik.http.routers.logto.entrypoints=websecure
+ - traefik.http.routers.logto.tls=true
+ - traefik.http.routers.logto.service=logto
+ - traefik.http.routers.logto.middlewares=logto-cors
+ - traefik.http.middlewares.logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}
+ - traefik.http.middlewares.logto-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS
+ - traefik.http.middlewares.logto-cors.headers.accessControlAllowHeaders=Authorization,Content-Type
+ - traefik.http.middlewares.logto-cors.headers.accessControlAllowCredentials=true
+ - traefik.http.services.logto.loadbalancer.server.port=3001
+ - traefik.http.routers.logto-console.rule=PathPrefix(`/`)
+ - traefik.http.routers.logto-console.entrypoints=admin-console
+ - traefik.http.routers.logto-console.tls=true
+ - traefik.http.routers.logto-console.service=logto-console
+ - traefik.http.services.logto-console.loadbalancer.server.port=3002
+ volumes:
+ - bootstrapdata:/data
+ networks:
+ - cameleer
+
+ cameleer-saas:
+ image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
+ restart: unless-stopped
+ depends_on:
+ logto:
+ condition: service_healthy
+ volumes:
+ - bootstrapdata:/data/bootstrap:ro
+ - certs:/certs
+ environment:
+ SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
+ SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
+ SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
+ CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: ${LOGTO_ENDPOINT:-http://logto:3001}
+ CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
+ CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https}
+ CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost}
+ CAMELEER_SAAS_IDENTITY_M2MCLIENTID: ${LOGTO_M2M_CLIENT_ID:-}
+ CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET: ${LOGTO_M2M_CLIENT_SECRET:-}
+ labels:
+ - traefik.enable=true
+ - traefik.http.routers.saas.rule=PathPrefix(`/platform`)
+ - traefik.http.routers.saas.entrypoints=websecure
+ - traefik.http.routers.saas.tls=true
+ - traefik.http.services.saas.loadbalancer.server.port=8080
+ networks:
+ - cameleer
+
+networks:
+ cameleer:
+ driver: bridge
+ cameleer-traefik:
+ name: cameleer-traefik
+ driver: bridge
+
+volumes:
+ pgdata:
+ chdata:
+ certs:
+ bootstrapdata:
+```
+
+Key changes from original:
+- `traefik-certs` init container removed (merged into `cameleer-traefik` entrypoint)
+- `logto-bootstrap` init container removed (merged into `cameleer-logto` entrypoint)
+- All bind-mounted config files removed
+- All images use `${*_IMAGE:-gitea.siegeln.net/cameleer/...}` pattern
+- Logto healthcheck includes `test -f /data/logto-bootstrap.json` (passes only after bootstrap)
+- `cameleer-saas` depends on `logto: service_healthy` (not `logto-bootstrap: service_completed_successfully`)
+- Ports configurable via env vars (`HTTP_PORT`, `HTTPS_PORT`, `LOGTO_CONSOLE_PORT`)
+- `NODE_TLS_REJECT` configurable via env var
+
+- [ ] **Step 2: Update .env.example**
+
+Replace the entire `.env.example` with:
+
+```bash
+# Cameleer SaaS — Environment Configuration
+# Copy to .env and fill in values for production
+
+# Image version
+VERSION=latest
+
+# Public access
+PUBLIC_HOST=localhost
+PUBLIC_PROTOCOL=https
+
+# Ports
+HTTP_PORT=80
+HTTPS_PORT=443
+LOGTO_CONSOLE_PORT=3002
+
+# PostgreSQL
+POSTGRES_USER=cameleer
+POSTGRES_PASSWORD=change_me_in_production
+POSTGRES_DB=cameleer_saas
+
+# ClickHouse
+CLICKHOUSE_PASSWORD=change_me_in_production
+
+# Admin user (created by bootstrap)
+SAAS_ADMIN_USER=admin
+SAAS_ADMIN_PASS=change_me_in_production
+
+# TLS (leave empty for self-signed)
+# NODE_TLS_REJECT=0 # Set to 1 when using real certificates
+# CERT_FILE=
+# KEY_FILE=
+# CA_FILE=
+
+# Vendor account (optional)
+VENDOR_SEED_ENABLED=false
+# VENDOR_USER=vendor
+# VENDOR_PASS=change_me
+
+# Docker images (override for custom registries)
+# TRAEFIK_IMAGE=gitea.siegeln.net/cameleer/cameleer-traefik
+# POSTGRES_IMAGE=gitea.siegeln.net/cameleer/cameleer-postgres
+# CLICKHOUSE_IMAGE=gitea.siegeln.net/cameleer/cameleer-clickhouse
+# LOGTO_IMAGE=gitea.siegeln.net/cameleer/cameleer-logto
+# CAMELEER_IMAGE=gitea.siegeln.net/cameleer/cameleer-saas
+```
+
+- [ ] **Step 3: Delete old files**
+
+```bash
+rm docker/init-databases.sh
+rm docker/clickhouse-init.sql
+rm docker/clickhouse-users.xml
+rm docker/clickhouse-config.xml
+rm docker/traefik-dynamic.yml
+rm docker/vendor-seed.sh
+rm traefik.yml
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docker-compose.yml .env.example
+git rm docker/init-databases.sh docker/clickhouse-init.sql docker/clickhouse-users.xml \
+ docker/clickhouse-config.xml docker/traefik-dynamic.yml docker/vendor-seed.sh traefik.yml
+git commit -m "feat: consolidate docker-compose.yml for baked-in images
+
+Remove all bind-mounted config files and init containers. Services
+reduced from 7 to 5. All configuration via environment variables."
+```
+
+---
+
+### Task 6: Update CI workflows
+
+**Files:**
+- Modify: `.gitea/workflows/ci.yml`
+
+- [ ] **Step 1: Add image builds for postgres, clickhouse, traefik**
+
+Add these build steps after the existing "Build and push Logto image" step in the `docker` job:
+
+```yaml
+ - name: Build and push PostgreSQL image
+ run: |
+ TAGS="-t gitea.siegeln.net/cameleer/cameleer-postgres:${{ github.sha }}"
+ for TAG in $IMAGE_TAGS; do
+ TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-postgres:$TAG"
+ done
+ docker buildx build --platform linux/amd64 \
+ $TAGS \
+ --provenance=false \
+ --push docker/cameleer-postgres/
+
+ - name: Build and push ClickHouse image
+ run: |
+ TAGS="-t gitea.siegeln.net/cameleer/cameleer-clickhouse:${{ github.sha }}"
+ for TAG in $IMAGE_TAGS; do
+ TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-clickhouse:$TAG"
+ done
+ docker buildx build --platform linux/amd64 \
+ $TAGS \
+ --provenance=false \
+ --push docker/cameleer-clickhouse/
+
+ - name: Build and push Traefik image
+ run: |
+ TAGS="-t gitea.siegeln.net/cameleer/cameleer-traefik:${{ github.sha }}"
+ for TAG in $IMAGE_TAGS; do
+ TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-traefik:$TAG"
+ done
+ docker buildx build --platform linux/amd64 \
+ $TAGS \
+ --provenance=false \
+ --push docker/cameleer-traefik/
+```
+
+- [ ] **Step 2: Update Logto build to use repo root context**
+
+Change the existing "Build and push Logto image" step. Replace the `docker buildx build` line:
+
+```yaml
+ - name: Build and push Logto image
+ run: |
+ TAGS="-t gitea.siegeln.net/cameleer/cameleer-logto:${{ github.sha }}"
+ for TAG in $IMAGE_TAGS; do
+ TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-logto:$TAG"
+ done
+ docker buildx build --platform linux/amd64 \
+ --build-arg REGISTRY_TOKEN="$REGISTRY_TOKEN" \
+ -f ui/sign-in/Dockerfile \
+ $TAGS \
+ --cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer-logto:buildcache \
+ --cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer-logto:buildcache,mode=max \
+ --provenance=false \
+ --push .
+ env:
+ REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
+```
+
+Key change: build context is now `.` (repo root) instead of `ui/sign-in/`, and `-f ui/sign-in/Dockerfile` is added.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add .gitea/workflows/ci.yml
+git commit -m "ci: add builds for cameleer-postgres, cameleer-clickhouse, cameleer-traefik
+
+Update Logto build to use repo root context for bootstrap script access."
+```
+
+---
+
+### Task 7: Validate consolidated stack
+
+**Files:** None (testing only)
+
+- [ ] **Step 1: Start the consolidated stack**
+
+```bash
+docker compose up -d
+```
+
+- [ ] **Step 2: Verify all services start and become healthy**
+
+```bash
+docker compose ps
+```
+
+Expected: 5 services, all "Up" or "Up (healthy)". No `traefik-certs` or `logto-bootstrap` containers.
+
+- [ ] **Step 3: Verify bootstrap completed**
+
+```bash
+docker compose exec logto cat /data/logto-bootstrap.json | jq .
+```
+
+Expected: JSON with `spaClientId`, `m2mClientId`, `m2mClientSecret`, `tradAppId`, `tradAppSecret`.
+
+- [ ] **Step 4: Verify Traefik routing**
+
+```bash
+curl -sk https://localhost/platform/api/config | jq .
+```
+
+Expected: JSON with `logtoEndpoint`, `spaClientId`, `scopes`.
+
+- [ ] **Step 5: Verify certs were generated**
+
+```bash
+docker compose exec traefik cat /certs/meta.json | jq .
+```
+
+Expected: JSON with `selfSigned: true`, `subject` containing localhost.
+
+- [ ] **Step 6: Clean up test and commit any fixes**
+
+```bash
+docker compose down
+```
+
+If any issues were found and fixed, commit the fixes.
+
+---
+
+## Phase 2: Bash Installer
+
+> **Note:** Phase 2 can be developed in parallel with Phase 1. The installer generates files and runs docker commands — it doesn't need the actual images until Task 17 (end-to-end testing).
+
+### Task 8: Installer skeleton and utility functions
+
+**Files:**
+- Create: `installer/install.sh`
+
+- [ ] **Step 1: Create the installer with header, constants, and utilities**
+
+`installer/install.sh`:
+
+```bash
+#!/usr/bin/env bash
+set -euo pipefail
+
+CAMELEER_INSTALLER_VERSION="1.0.0"
+CAMELEER_DEFAULT_VERSION="latest"
+REGISTRY="gitea.siegeln.net/cameleer"
+
+# --- Colors ---
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+BOLD='\033[1m'
+NC='\033[0m'
+
+# --- Defaults ---
+DEFAULT_INSTALL_DIR="./cameleer"
+DEFAULT_PUBLIC_PROTOCOL="https"
+DEFAULT_ADMIN_USER="admin"
+DEFAULT_TLS_MODE="self-signed"
+DEFAULT_HTTP_PORT="80"
+DEFAULT_HTTPS_PORT="443"
+DEFAULT_LOGTO_CONSOLE_PORT="3002"
+DEFAULT_LOGTO_CONSOLE_EXPOSED="true"
+DEFAULT_VENDOR_ENABLED="false"
+DEFAULT_VENDOR_USER="vendor"
+DEFAULT_COMPOSE_PROJECT="cameleer-saas"
+DEFAULT_DOCKER_SOCKET="/var/run/docker.sock"
+
+# --- Config values (populated by args/env/config/prompts) ---
+INSTALL_DIR=""
+PUBLIC_HOST=""
+PUBLIC_PROTOCOL=""
+ADMIN_USER=""
+ADMIN_PASS=""
+TLS_MODE=""
+CERT_FILE=""
+KEY_FILE=""
+CA_FILE=""
+POSTGRES_PASSWORD=""
+CLICKHOUSE_PASSWORD=""
+HTTP_PORT=""
+HTTPS_PORT=""
+LOGTO_CONSOLE_PORT=""
+LOGTO_CONSOLE_EXPOSED=""
+VENDOR_ENABLED=""
+VENDOR_USER=""
+VENDOR_PASS=""
+MONITORING_NETWORK=""
+VERSION=""
+COMPOSE_PROJECT=""
+DOCKER_SOCKET=""
+NODE_TLS_REJECT=""
+
+# --- State ---
+MODE="" # simple, expert, silent
+IS_RERUN=false
+RERUN_ACTION="" # upgrade, reconfigure, reinstall
+CONFIG_FILE_PATH=""
+
+# --- Utility functions ---
+
+log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
+log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
+log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
+log_success() { echo -e "${GREEN}[OK]${NC} $1"; }
+
+print_banner() {
+ echo -e "${BOLD}"
+ echo " ____ _ "
+ echo " / ___|__ _ _ __ ___ ___ | | ___ ___ _ __ "
+ echo "| | / _\` | '_ \` _ \\ / _ \\| |/ _ \\/ _ \\ '__|"
+ echo "| |__| (_| | | | | | | __/| | __/ __/ | "
+ echo " \\____\\__,_|_| |_| |_|\\___||_|\\___|\\___||_| "
+ echo ""
+ echo " SaaS Platform Installer v${CAMELEER_INSTALLER_VERSION}"
+ echo -e "${NC}"
+}
+
+prompt() {
+ local var_name="$1" prompt_text="$2" default="${3:-}"
+ local input
+ if [ -n "$default" ]; then
+ read -rp " $prompt_text [$default]: " input
+ eval "$var_name=\"\${input:-$default}\""
+ else
+ read -rp " $prompt_text: " input
+ eval "$var_name=\"\$input\""
+ fi
+}
+
+prompt_password() {
+ local var_name="$1" prompt_text="$2" default="${3:-}"
+ local input
+ if [ -n "$default" ]; then
+ read -rsp " $prompt_text [${default:+********}]: " input
+ echo
+ eval "$var_name=\"\${input:-$default}\""
+ else
+ read -rsp " $prompt_text: " input
+ echo
+ eval "$var_name=\"\$input\""
+ fi
+}
+
+prompt_yesno() {
+ local prompt_text="$1" default="${2:-n}"
+ local input
+ if [ "$default" = "y" ]; then
+ read -rp " $prompt_text [Y/n]: " input
+ case "${input:-y}" in
+ [nN]|[nN][oO]) return 1 ;; *) return 0 ;; esac
+ else
+ read -rp " $prompt_text [y/N]: " input
+ case "${input:-n}" in
+ [yY]|[yY][eE][sS]) return 0 ;; *) return 1 ;; esac
+ fi
+}
+
+generate_password() {
+ openssl rand -base64 24 | tr -d '/+=' | head -c 32
+}
+```
+
+- [ ] **Step 2: Make executable and verify syntax**
+
+```bash
+mkdir -p installer
+chmod +x installer/install.sh
+bash -n installer/install.sh
+```
+
+Expected: no syntax errors.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add installer/install.sh
+git commit -m "feat(installer): scaffold install.sh with constants and utilities"
+```
+
+---
+
+### Task 9: Argument parsing and config file handling
+
+**Files:**
+- Modify: `installer/install.sh`
+
+- [ ] **Step 1: Add parse_args function**
+
+Append to `installer/install.sh` before the closing (there is no closing yet, so just append):
+
+```bash
+# --- Argument parsing ---
+
+parse_args() {
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --silent) MODE="silent" ;;
+ --expert) MODE="expert" ;;
+ --config) CONFIG_FILE_PATH="$2"; shift ;;
+ --install-dir) INSTALL_DIR="$2"; shift ;;
+ --public-host) PUBLIC_HOST="$2"; shift ;;
+ --public-protocol) PUBLIC_PROTOCOL="$2"; shift ;;
+ --admin-user) ADMIN_USER="$2"; shift ;;
+ --admin-password) ADMIN_PASS="$2"; shift ;;
+ --tls-mode) TLS_MODE="$2"; shift ;;
+ --cert-file) CERT_FILE="$2"; shift ;;
+ --key-file) KEY_FILE="$2"; shift ;;
+ --ca-file) CA_FILE="$2"; shift ;;
+ --postgres-password) POSTGRES_PASSWORD="$2"; shift ;;
+ --clickhouse-password) CLICKHOUSE_PASSWORD="$2"; shift ;;
+ --http-port) HTTP_PORT="$2"; shift ;;
+ --https-port) HTTPS_PORT="$2"; shift ;;
+ --logto-console-port) LOGTO_CONSOLE_PORT="$2"; shift ;;
+ --logto-console-exposed) LOGTO_CONSOLE_EXPOSED="$2"; shift ;;
+ --vendor-enabled) VENDOR_ENABLED="$2"; shift ;;
+ --vendor-user) VENDOR_USER="$2"; shift ;;
+ --vendor-password) VENDOR_PASS="$2"; shift ;;
+ --monitoring-network) MONITORING_NETWORK="$2"; shift ;;
+ --version) VERSION="$2"; shift ;;
+ --compose-project) COMPOSE_PROJECT="$2"; shift ;;
+ --docker-socket) DOCKER_SOCKET="$2"; shift ;;
+ --node-tls-reject) NODE_TLS_REJECT="$2"; shift ;;
+ --reconfigure) RERUN_ACTION="reconfigure" ;;
+ --reinstall) RERUN_ACTION="reinstall" ;;
+ --confirm-destroy) CONFIRM_DESTROY=true ;;
+ --help|-h) show_help; exit 0 ;;
+ *)
+ log_error "Unknown option: $1"
+ echo " Run with --help for usage."
+ exit 1
+ ;;
+ esac
+ shift
+ done
+}
+
+show_help() {
+ echo "Usage: install.sh [OPTIONS]"
+ echo ""
+ echo "Modes:"
+ echo " (default) Interactive simple mode (6 questions)"
+ echo " --expert Interactive expert mode (all options)"
+ echo " --silent Non-interactive, use defaults + overrides"
+ echo ""
+ echo "Options:"
+ echo " --install-dir DIR Install directory (default: ./cameleer)"
+ echo " --public-host HOST Public hostname (default: auto-detect)"
+ echo " --admin-user USER Admin username (default: admin)"
+ echo " --admin-password PASS Admin password (default: generated)"
+ echo " --tls-mode MODE self-signed or custom (default: self-signed)"
+ echo " --cert-file PATH TLS certificate file"
+ echo " --key-file PATH TLS key file"
+ echo " --ca-file PATH CA bundle file"
+ echo " --monitoring-network NAME Docker network for Prometheus scraping"
+ echo " --version TAG Image version tag (default: latest)"
+ echo " --config FILE Load config from file"
+ echo " --help Show this help"
+ echo ""
+ echo "Expert options:"
+ echo " --postgres-password, --clickhouse-password, --http-port,"
+ echo " --https-port, --logto-console-port, --logto-console-exposed,"
+ echo " --vendor-enabled, --vendor-user, --vendor-password,"
+ echo " --compose-project, --docker-socket, --node-tls-reject"
+ echo ""
+ echo "Re-run options:"
+ echo " --reconfigure Re-run interactive setup (preserve data)"
+ echo " --reinstall --confirm-destroy Fresh install (destroys data)"
+ echo ""
+ echo "Config precedence: CLI flags > env vars > config file > defaults"
+}
+```
+
+- [ ] **Step 2: Add config file loading**
+
+Append:
+
+```bash
+# --- Config file handling ---
+
+load_config_file() {
+ local file="$1"
+ [ ! -f "$file" ] && return
+ while IFS='=' read -r key value; do
+ # Skip comments and blank lines
+ case "$key" in
+ \#*|"") continue ;;
+ esac
+ # Only set if not already set by CLI args
+ key=$(echo "$key" | tr -d ' ')
+ value=$(echo "$value" | sed 's/^[ ]*//;s/[ ]*$//')
+ case "$key" in
+ install_dir) [ -z "$INSTALL_DIR" ] && INSTALL_DIR="$value" ;;
+ public_host) [ -z "$PUBLIC_HOST" ] && PUBLIC_HOST="$value" ;;
+ public_protocol) [ -z "$PUBLIC_PROTOCOL" ] && PUBLIC_PROTOCOL="$value" ;;
+ admin_user) [ -z "$ADMIN_USER" ] && ADMIN_USER="$value" ;;
+ admin_password) [ -z "$ADMIN_PASS" ] && ADMIN_PASS="$value" ;;
+ tls_mode) [ -z "$TLS_MODE" ] && TLS_MODE="$value" ;;
+ cert_file) [ -z "$CERT_FILE" ] && CERT_FILE="$value" ;;
+ key_file) [ -z "$KEY_FILE" ] && KEY_FILE="$value" ;;
+ ca_file) [ -z "$CA_FILE" ] && CA_FILE="$value" ;;
+ postgres_password) [ -z "$POSTGRES_PASSWORD" ] && POSTGRES_PASSWORD="$value" ;;
+ clickhouse_password) [ -z "$CLICKHOUSE_PASSWORD" ] && CLICKHOUSE_PASSWORD="$value" ;;
+ http_port) [ -z "$HTTP_PORT" ] && HTTP_PORT="$value" ;;
+ https_port) [ -z "$HTTPS_PORT" ] && HTTPS_PORT="$value" ;;
+ logto_console_port) [ -z "$LOGTO_CONSOLE_PORT" ] && LOGTO_CONSOLE_PORT="$value" ;;
+ logto_console_exposed) [ -z "$LOGTO_CONSOLE_EXPOSED" ] && LOGTO_CONSOLE_EXPOSED="$value" ;;
+ vendor_enabled) [ -z "$VENDOR_ENABLED" ] && VENDOR_ENABLED="$value" ;;
+ vendor_user) [ -z "$VENDOR_USER" ] && VENDOR_USER="$value" ;;
+ vendor_password) [ -z "$VENDOR_PASS" ] && VENDOR_PASS="$value" ;;
+ monitoring_network) [ -z "$MONITORING_NETWORK" ] && MONITORING_NETWORK="$value" ;;
+ version) [ -z "$VERSION" ] && VERSION="$value" ;;
+ compose_project) [ -z "$COMPOSE_PROJECT" ] && COMPOSE_PROJECT="$value" ;;
+ docker_socket) [ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$value" ;;
+ node_tls_reject) [ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$value" ;;
+ esac
+ done < "$file"
+}
+
+load_env_overrides() {
+ # Apply env vars for any values not already set by CLI args
+ [ -z "$INSTALL_DIR" ] && INSTALL_DIR="${CAMELEER_INSTALL_DIR:-}"
+ [ -z "$PUBLIC_HOST" ] && PUBLIC_HOST="${PUBLIC_HOST:-}"
+ [ -z "$PUBLIC_PROTOCOL" ] && PUBLIC_PROTOCOL="${PUBLIC_PROTOCOL:-}"
+ [ -z "$ADMIN_USER" ] && ADMIN_USER="${SAAS_ADMIN_USER:-}"
+ [ -z "$ADMIN_PASS" ] && ADMIN_PASS="${SAAS_ADMIN_PASS:-}"
+ [ -z "$TLS_MODE" ] && TLS_MODE="${TLS_MODE:-}"
+ [ -z "$POSTGRES_PASSWORD" ] && POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}"
+ [ -z "$CLICKHOUSE_PASSWORD" ] && CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-}"
+ [ -z "$HTTP_PORT" ] && HTTP_PORT="${HTTP_PORT:-}"
+ [ -z "$HTTPS_PORT" ] && HTTPS_PORT="${HTTPS_PORT:-}"
+ [ -z "$LOGTO_CONSOLE_PORT" ] && LOGTO_CONSOLE_PORT="${LOGTO_CONSOLE_PORT:-}"
+ [ -z "$LOGTO_CONSOLE_EXPOSED" ] && LOGTO_CONSOLE_EXPOSED="${LOGTO_CONSOLE_EXPOSED:-}"
+ [ -z "$VENDOR_ENABLED" ] && VENDOR_ENABLED="${VENDOR_ENABLED:-}"
+ [ -z "$VENDOR_USER" ] && VENDOR_USER="${VENDOR_USER:-}"
+ [ -z "$VENDOR_PASS" ] && VENDOR_PASS="${VENDOR_PASS:-}"
+ [ -z "$MONITORING_NETWORK" ] && MONITORING_NETWORK="${MONITORING_NETWORK:-}"
+ [ -z "$VERSION" ] && VERSION="${CAMELEER_VERSION:-}"
+ [ -z "$COMPOSE_PROJECT" ] && COMPOSE_PROJECT="${COMPOSE_PROJECT:-}"
+ [ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="${DOCKER_SOCKET:-}"
+ [ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="${NODE_TLS_REJECT:-}"
+}
+```
+
+- [ ] **Step 3: Verify syntax**
+
+```bash
+bash -n installer/install.sh
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add installer/install.sh
+git commit -m "feat(installer): add argument parsing and config file handling"
+```
+
+---
+
+### Task 10: Prerequisite checks and auto-detection
+
+**Files:**
+- Modify: `installer/install.sh`
+
+- [ ] **Step 1: Add prerequisite checks**
+
+Append to `installer/install.sh`:
+
+```bash
+# --- Prerequisites ---
+
+check_prerequisites() {
+ log_info "Checking prerequisites..."
+ local errors=0
+
+ # Docker
+ if ! command -v docker >/dev/null 2>&1; then
+ log_error "Docker is not installed."
+ echo " Install Docker Engine: https://docs.docker.com/engine/install/"
+ errors=$((errors + 1))
+ else
+ local docker_version
+ docker_version=$(docker version --format '{{.Server.Version}}' 2>/dev/null || echo "unknown")
+ log_info "Docker version: $docker_version"
+ fi
+
+ # Docker Compose v2
+ if ! docker compose version >/dev/null 2>&1; then
+ log_error "Docker Compose v2 is not available."
+ echo " 'docker compose' subcommand required (not standalone docker-compose)."
+ errors=$((errors + 1))
+ else
+ local compose_version
+ compose_version=$(docker compose version --short 2>/dev/null || echo "unknown")
+ log_info "Docker Compose version: $compose_version"
+ fi
+
+ # OpenSSL (for password generation)
+ if ! command -v openssl >/dev/null 2>&1; then
+ log_error "OpenSSL is not installed (needed for password generation)."
+ errors=$((errors + 1))
+ fi
+
+ # Docker socket
+ local socket="${DOCKER_SOCKET:-$DEFAULT_DOCKER_SOCKET}"
+ if [ ! -S "$socket" ]; then
+ log_error "Docker socket not found at $socket"
+ errors=$((errors + 1))
+ fi
+
+ # Port availability
+ check_port_available "${HTTP_PORT:-$DEFAULT_HTTP_PORT}" "HTTP"
+ check_port_available "${HTTPS_PORT:-$DEFAULT_HTTPS_PORT}" "HTTPS"
+ check_port_available "${LOGTO_CONSOLE_PORT:-$DEFAULT_LOGTO_CONSOLE_PORT}" "Logto Console"
+
+ if [ $errors -gt 0 ]; then
+ log_error "$errors prerequisite(s) not met. Please install missing dependencies and retry."
+ exit 1
+ fi
+ log_success "All prerequisites met."
+}
+
+check_port_available() {
+ local port="$1" name="$2"
+ if ss -tlnp 2>/dev/null | grep -q ":${port} " || \
+ netstat -tlnp 2>/dev/null | grep -q ":${port} "; then
+ log_warn "Port $port ($name) is already in use."
+ fi
+}
+```
+
+- [ ] **Step 2: Add auto-detection**
+
+Append:
+
+```bash
+# --- Auto-detection ---
+
+auto_detect() {
+ # Public hostname
+ if [ -z "$PUBLIC_HOST" ]; then
+ PUBLIC_HOST=$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo "localhost")
+ # Try reverse DNS of primary IP
+ local primary_ip
+ primary_ip=$(ip route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}' || true)
+ if [ -n "$primary_ip" ]; then
+ local rdns
+ rdns=$(dig +short -x "$primary_ip" 2>/dev/null | sed 's/\.$//' || true)
+ [ -n "$rdns" ] && PUBLIC_HOST="$rdns"
+ fi
+ fi
+
+ # Docker socket
+ if [ -z "$DOCKER_SOCKET" ]; then
+ DOCKER_SOCKET="$DEFAULT_DOCKER_SOCKET"
+ fi
+}
+
+detect_existing_install() {
+ local dir="${INSTALL_DIR:-$DEFAULT_INSTALL_DIR}"
+ if [ -f "$dir/cameleer.conf" ]; then
+ IS_RERUN=true
+ INSTALL_DIR="$dir"
+ load_config_file "$dir/cameleer.conf"
+ fi
+}
+```
+
+- [ ] **Step 3: Verify syntax**
+
+```bash
+bash -n installer/install.sh
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add installer/install.sh
+git commit -m "feat(installer): add prerequisite checks and auto-detection"
+```
+
+---
+
+### Task 11: Interactive prompts
+
+**Files:**
+- Modify: `installer/install.sh`
+
+- [ ] **Step 1: Add mode selection and simple prompts**
+
+Append:
+
+```bash
+# --- Interactive prompts ---
+
+select_mode() {
+ if [ -n "$MODE" ]; then return; fi
+ echo ""
+ echo " Installation mode:"
+ echo " [1] Simple — 6 questions, sensible defaults (recommended)"
+ echo " [2] Expert — configure everything"
+ echo ""
+ local choice
+ read -rp " Select mode [1]: " choice
+ case "${choice:-1}" in
+ 2) MODE="expert" ;;
+ *) MODE="simple" ;;
+ esac
+}
+
+run_simple_prompts() {
+ echo ""
+ echo -e "${BOLD}--- Simple Installation ---${NC}"
+ echo ""
+
+ prompt INSTALL_DIR "Install directory" "$DEFAULT_INSTALL_DIR"
+ prompt PUBLIC_HOST "Public hostname" "${PUBLIC_HOST:-localhost}"
+ prompt ADMIN_USER "Admin username" "$DEFAULT_ADMIN_USER"
+
+ if prompt_yesno "Auto-generate admin password?"; then
+ ADMIN_PASS="" # will be generated later
+ else
+ prompt_password ADMIN_PASS "Admin password" ""
+ fi
+
+ echo ""
+ if prompt_yesno "Use custom TLS certificates? (no = self-signed)"; then
+ TLS_MODE="custom"
+ prompt CERT_FILE "Path to certificate file (PEM)" ""
+ prompt KEY_FILE "Path to private key file (PEM)" ""
+ if prompt_yesno "Include CA bundle?"; then
+ prompt CA_FILE "Path to CA bundle (PEM)" ""
+ fi
+ else
+ TLS_MODE="self-signed"
+ fi
+
+ echo ""
+ prompt MONITORING_NETWORK "Monitoring network name (empty = skip)" ""
+}
+
+run_expert_prompts() {
+ echo ""
+ echo -e "${BOLD}--- Expert Installation ---${NC}"
+
+ # Start with all simple-mode questions
+ run_simple_prompts
+
+ echo ""
+ echo -e "${BOLD} Credentials:${NC}"
+ if prompt_yesno "Auto-generate database passwords?" "y"; then
+ POSTGRES_PASSWORD=""
+ CLICKHOUSE_PASSWORD=""
+ else
+ prompt_password POSTGRES_PASSWORD "PostgreSQL password" ""
+ prompt_password CLICKHOUSE_PASSWORD "ClickHouse password" ""
+ fi
+
+ echo ""
+ if prompt_yesno "Enable vendor account?"; then
+ VENDOR_ENABLED="true"
+ prompt VENDOR_USER "Vendor username" "$DEFAULT_VENDOR_USER"
+ if prompt_yesno "Auto-generate vendor password?" "y"; then
+ VENDOR_PASS=""
+ else
+ prompt_password VENDOR_PASS "Vendor password" ""
+ fi
+ else
+ VENDOR_ENABLED="false"
+ fi
+
+ echo ""
+ echo -e "${BOLD} Networking:${NC}"
+ prompt HTTP_PORT "HTTP port" "$DEFAULT_HTTP_PORT"
+ prompt HTTPS_PORT "HTTPS port" "$DEFAULT_HTTPS_PORT"
+ prompt LOGTO_CONSOLE_PORT "Logto admin console port" "$DEFAULT_LOGTO_CONSOLE_PORT"
+
+ echo ""
+ echo -e "${BOLD} Docker:${NC}"
+ prompt VERSION "Image version/tag" "$CAMELEER_DEFAULT_VERSION"
+ prompt COMPOSE_PROJECT "Compose project name" "$DEFAULT_COMPOSE_PROJECT"
+ prompt DOCKER_SOCKET "Docker socket path" "$DEFAULT_DOCKER_SOCKET"
+
+ echo ""
+ echo -e "${BOLD} Logto:${NC}"
+ if prompt_yesno "Expose Logto admin console externally?" "y"; then
+ LOGTO_CONSOLE_EXPOSED="true"
+ else
+ LOGTO_CONSOLE_EXPOSED="false"
+ fi
+}
+```
+
+- [ ] **Step 2: Verify syntax**
+
+```bash
+bash -n installer/install.sh
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add installer/install.sh
+git commit -m "feat(installer): add interactive prompts for simple and expert modes"
+```
+
+---
+
+### Task 12: Config merge, validation, and password generation
+
+**Files:**
+- Modify: `installer/install.sh`
+
+- [ ] **Step 1: Add merge, validate, and password functions**
+
+Append:
+
+```bash
+# --- Config merge and validation ---
+
+merge_config() {
+ # Apply defaults for any remaining empty values
+ : "${INSTALL_DIR:=$DEFAULT_INSTALL_DIR}"
+ : "${PUBLIC_HOST:=localhost}"
+ : "${PUBLIC_PROTOCOL:=$DEFAULT_PUBLIC_PROTOCOL}"
+ : "${ADMIN_USER:=$DEFAULT_ADMIN_USER}"
+ : "${TLS_MODE:=$DEFAULT_TLS_MODE}"
+ : "${HTTP_PORT:=$DEFAULT_HTTP_PORT}"
+ : "${HTTPS_PORT:=$DEFAULT_HTTPS_PORT}"
+ : "${LOGTO_CONSOLE_PORT:=$DEFAULT_LOGTO_CONSOLE_PORT}"
+ : "${LOGTO_CONSOLE_EXPOSED:=$DEFAULT_LOGTO_CONSOLE_EXPOSED}"
+ : "${VENDOR_ENABLED:=$DEFAULT_VENDOR_ENABLED}"
+ : "${VENDOR_USER:=$DEFAULT_VENDOR_USER}"
+ : "${VERSION:=$CAMELEER_DEFAULT_VERSION}"
+ : "${COMPOSE_PROJECT:=$DEFAULT_COMPOSE_PROJECT}"
+ : "${DOCKER_SOCKET:=$DEFAULT_DOCKER_SOCKET}"
+
+ # NODE_TLS_REJECT depends on TLS mode
+ if [ -z "$NODE_TLS_REJECT" ]; then
+ if [ "$TLS_MODE" = "custom" ]; then
+ NODE_TLS_REJECT="1"
+ else
+ NODE_TLS_REJECT="0"
+ fi
+ fi
+}
+
+validate_config() {
+ local errors=0
+
+ if [ "$TLS_MODE" = "custom" ]; then
+ if [ ! -f "$CERT_FILE" ]; then
+ log_error "Certificate file not found: $CERT_FILE"
+ errors=$((errors + 1))
+ fi
+ if [ ! -f "$KEY_FILE" ]; then
+ log_error "Key file not found: $KEY_FILE"
+ errors=$((errors + 1))
+ fi
+ if [ -n "$CA_FILE" ] && [ ! -f "$CA_FILE" ]; then
+ log_error "CA bundle not found: $CA_FILE"
+ errors=$((errors + 1))
+ fi
+ fi
+
+ # Validate port numbers
+ for port_var in HTTP_PORT HTTPS_PORT LOGTO_CONSOLE_PORT; do
+ local port_val
+ eval "port_val=\$$port_var"
+ if ! echo "$port_val" | grep -qE '^[0-9]+$' || [ "$port_val" -lt 1 ] || [ "$port_val" -gt 65535 ]; then
+ log_error "Invalid port for $port_var: $port_val"
+ errors=$((errors + 1))
+ fi
+ done
+
+ if [ $errors -gt 0 ]; then
+ log_error "Configuration validation failed."
+ exit 1
+ fi
+ log_success "Configuration validated."
+}
+
+generate_passwords() {
+ if [ -z "$ADMIN_PASS" ]; then
+ ADMIN_PASS=$(generate_password)
+ log_info "Generated admin password."
+ fi
+ if [ -z "$POSTGRES_PASSWORD" ]; then
+ POSTGRES_PASSWORD=$(generate_password)
+ log_info "Generated PostgreSQL password."
+ fi
+ if [ -z "$CLICKHOUSE_PASSWORD" ]; then
+ CLICKHOUSE_PASSWORD=$(generate_password)
+ log_info "Generated ClickHouse password."
+ fi
+ if [ "$VENDOR_ENABLED" = "true" ] && [ -z "$VENDOR_PASS" ]; then
+ VENDOR_PASS=$(generate_password)
+ log_info "Generated vendor password."
+ fi
+}
+```
+
+- [ ] **Step 2: Verify syntax**
+
+```bash
+bash -n installer/install.sh
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add installer/install.sh
+git commit -m "feat(installer): add config merge, validation, and password generation"
+```
+
+---
+
+### Task 13: File generation (.env, docker-compose.yml, certs)
+
+**Files:**
+- Modify: `installer/install.sh`
+
+- [ ] **Step 1: Add cert copy and .env generation**
+
+Append:
+
+```bash
+# --- File generation ---
+
+copy_certs() {
+ local certs_dir="$INSTALL_DIR/certs"
+ mkdir -p "$certs_dir"
+ cp "$CERT_FILE" "$certs_dir/cert.pem"
+ cp "$KEY_FILE" "$certs_dir/key.pem"
+ if [ -n "$CA_FILE" ]; then
+ cp "$CA_FILE" "$certs_dir/ca.pem"
+ fi
+ log_info "Copied TLS certificates to $certs_dir/"
+}
+
+generate_env_file() {
+ local f="$INSTALL_DIR/.env"
+ cat > "$f" << EOF
+# Cameleer SaaS Configuration
+# Generated by installer v${CAMELEER_INSTALLER_VERSION} on $(date -u '+%Y-%m-%d %H:%M:%S UTC')
+
+# Image version
+VERSION=${VERSION}
+
+# Public access
+PUBLIC_HOST=${PUBLIC_HOST}
+PUBLIC_PROTOCOL=${PUBLIC_PROTOCOL}
+
+# Ports
+HTTP_PORT=${HTTP_PORT}
+HTTPS_PORT=${HTTPS_PORT}
+LOGTO_CONSOLE_PORT=${LOGTO_CONSOLE_PORT}
+
+# PostgreSQL
+POSTGRES_USER=cameleer
+POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
+POSTGRES_DB=cameleer_saas
+
+# ClickHouse
+CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD}
+
+# Admin user
+SAAS_ADMIN_USER=${ADMIN_USER}
+SAAS_ADMIN_PASS=${ADMIN_PASS}
+
+# TLS
+NODE_TLS_REJECT=${NODE_TLS_REJECT}
+EOF
+
+ if [ "$TLS_MODE" = "custom" ]; then
+ cat >> "$f" << 'EOF'
+CERT_FILE=/user-certs/cert.pem
+KEY_FILE=/user-certs/key.pem
+EOF
+ if [ -n "$CA_FILE" ]; then
+ echo "CA_FILE=/user-certs/ca.pem" >> "$f"
+ fi
+ fi
+
+ cat >> "$f" << EOF
+
+# Vendor account
+VENDOR_SEED_ENABLED=${VENDOR_ENABLED}
+VENDOR_USER=${VENDOR_USER}
+VENDOR_PASS=${VENDOR_PASS:-}
+
+# Docker
+DOCKER_SOCKET=${DOCKER_SOCKET}
+
+# Provisioning images
+CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=${REGISTRY}/cameleer3-server:${VERSION}
+CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=${REGISTRY}/cameleer3-server-ui:${VERSION}
+EOF
+
+ log_info "Generated .env"
+ cp "$f" "$INSTALL_DIR/.env.bak"
+}
+```
+
+- [ ] **Step 2: Add docker-compose.yml generation**
+
+Append:
+
+```bash
+generate_compose_file() {
+ local f="$INSTALL_DIR/docker-compose.yml"
+ : > "$f"
+
+ # --- Header ---
+ cat >> "$f" << 'EOF'
+# Cameleer SaaS Platform
+# Generated by Cameleer installer — do not edit manually
+
+services:
+ traefik:
+ image: ${TRAEFIK_IMAGE:-gitea.siegeln.net/cameleer/cameleer-traefik}:${VERSION:-latest}
+ restart: unless-stopped
+ ports:
+ - "${HTTP_PORT:-80}:80"
+ - "${HTTPS_PORT:-443}:443"
+EOF
+
+ # Logto console port (conditional)
+ if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then
+ cat >> "$f" << 'EOF'
+ - "${LOGTO_CONSOLE_PORT:-3002}:3002"
+EOF
+ fi
+
+ # Traefik environment
+ cat >> "$f" << 'EOF'
+ environment:
+ PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
+ CERT_FILE: ${CERT_FILE:-}
+ KEY_FILE: ${KEY_FILE:-}
+ CA_FILE: ${CA_FILE:-}
+ volumes:
+ - certs:/certs
+ - ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro
+EOF
+
+ # Custom cert bind mount
+ if [ "$TLS_MODE" = "custom" ]; then
+ cat >> "$f" << 'EOF'
+ - ./certs:/user-certs:ro
+EOF
+ fi
+
+ # Traefik networks
+ cat >> "$f" << 'EOF'
+ networks:
+ - cameleer
+ - cameleer-traefik
+EOF
+
+ # Monitoring network for traefik
+ if [ -n "$MONITORING_NETWORK" ]; then
+ echo " - ${MONITORING_NETWORK}" >> "$f"
+ fi
+
+ # Monitoring labels for traefik
+ if [ -n "$MONITORING_NETWORK" ]; then
+ cat >> "$f" << 'EOF'
+ labels:
+ - "prometheus.io/scrape=true"
+ - "prometheus.io/port=8082"
+ - "prometheus.io/path=/metrics"
+EOF
+ fi
+
+ # --- PostgreSQL ---
+ cat >> "$f" << 'EOF'
+
+ postgres:
+ image: ${POSTGRES_IMAGE:-gitea.siegeln.net/cameleer/cameleer-postgres}:${VERSION:-latest}
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: cameleer_saas
+ POSTGRES_USER: ${POSTGRES_USER:-cameleer}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-cameleer} -d cameleer_saas"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ networks:
+ - cameleer
+EOF
+
+ if [ -n "$MONITORING_NETWORK" ]; then
+ echo " - ${MONITORING_NETWORK}" >> "$f"
+ fi
+
+ # --- ClickHouse ---
+ cat >> "$f" << 'EOF'
+
+ clickhouse:
+ image: ${CLICKHOUSE_IMAGE:-gitea.siegeln.net/cameleer/cameleer-clickhouse}:${VERSION:-latest}
+ restart: unless-stopped
+ environment:
+ CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD}
+ volumes:
+ - chdata:/var/lib/clickhouse
+ healthcheck:
+ test: ["CMD-SHELL", "clickhouse-client --password $${CLICKHOUSE_PASSWORD} --query 'SELECT 1'"]
+ interval: 10s
+ timeout: 5s
+ retries: 3
+ networks:
+ - cameleer
+EOF
+
+ if [ -n "$MONITORING_NETWORK" ]; then
+ echo " - ${MONITORING_NETWORK}" >> "$f"
+ cat >> "$f" << 'EOF'
+ labels:
+ - "prometheus.io/scrape=true"
+ - "prometheus.io/port=9363"
+ - "prometheus.io/path=/metrics"
+EOF
+ fi
+
+ # --- Logto ---
+ cat >> "$f" << 'EOF'
+
+ logto:
+ image: ${LOGTO_IMAGE:-gitea.siegeln.net/cameleer/cameleer-logto}:${VERSION:-latest}
+ restart: unless-stopped
+ depends_on:
+ postgres:
+ condition: service_healthy
+ environment:
+ DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD}@postgres:5432/logto
+ ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
+ ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}
+ TRUST_PROXY_HEADER: 1
+ NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}"
+ LOGTO_ENDPOINT: http://logto:3001
+ LOGTO_ADMIN_ENDPOINT: http://logto:3002
+ LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
+ PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
+ PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https}
+ PG_HOST: postgres
+ PG_USER: ${POSTGRES_USER:-cameleer}
+ PG_PASSWORD: ${POSTGRES_PASSWORD}
+ PG_DB_SAAS: cameleer_saas
+ SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
+ SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:-admin}
+ VENDOR_SEED_ENABLED: "${VENDOR_SEED_ENABLED:-false}"
+ VENDOR_USER: ${VENDOR_USER:-vendor}
+ VENDOR_PASS: ${VENDOR_PASS:-vendor}
+ healthcheck:
+ test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"]
+ interval: 10s
+ timeout: 5s
+ retries: 60
+ start_period: 30s
+ labels:
+ - traefik.enable=true
+ - traefik.http.routers.logto.rule=PathPrefix(`/`)
+ - traefik.http.routers.logto.priority=1
+ - traefik.http.routers.logto.entrypoints=websecure
+ - traefik.http.routers.logto.tls=true
+ - traefik.http.routers.logto.service=logto
+ - traefik.http.routers.logto.middlewares=logto-cors
+ - "traefik.http.middlewares.logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}"
+ - traefik.http.middlewares.logto-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS
+ - traefik.http.middlewares.logto-cors.headers.accessControlAllowHeaders=Authorization,Content-Type
+ - traefik.http.middlewares.logto-cors.headers.accessControlAllowCredentials=true
+ - traefik.http.services.logto.loadbalancer.server.port=3001
+EOF
+
+ # Logto console labels (conditional)
+ if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then
+ cat >> "$f" << 'EOF'
+ - traefik.http.routers.logto-console.rule=PathPrefix(`/`)
+ - traefik.http.routers.logto-console.entrypoints=admin-console
+ - traefik.http.routers.logto-console.tls=true
+ - traefik.http.routers.logto-console.service=logto-console
+ - traefik.http.services.logto-console.loadbalancer.server.port=3002
+EOF
+ fi
+
+ cat >> "$f" << 'EOF'
+ volumes:
+ - bootstrapdata:/data
+ networks:
+ - cameleer
+EOF
+
+ # --- Cameleer SaaS ---
+ cat >> "$f" << 'EOF'
+
+ cameleer-saas:
+ image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
+ restart: unless-stopped
+ depends_on:
+ logto:
+ condition: service_healthy
+ environment:
+ SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/cameleer_saas
+ SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
+ SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
+ CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: http://logto:3001
+ CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
+ CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https}
+ CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost}
+ CAMELEER_SAAS_PROVISIONING_NETWORKNAME: ${COMPOSE_PROJECT_NAME:-cameleer-saas}_cameleer
+ CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK: cameleer-traefik
+ CAMELEER_SAAS_PROVISIONING_SERVERIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERIMAGE:-gitea.siegeln.net/cameleer/cameleer3-server:latest}
+ CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE:-gitea.siegeln.net/cameleer/cameleer3-server-ui:latest}
+ labels:
+ - traefik.enable=true
+ - traefik.http.routers.saas.rule=PathPrefix(`/platform`)
+ - traefik.http.routers.saas.entrypoints=websecure
+ - traefik.http.routers.saas.tls=true
+ - traefik.http.services.saas.loadbalancer.server.port=8080
+EOF
+
+ # Monitoring labels for SaaS
+ if [ -n "$MONITORING_NETWORK" ]; then
+ cat >> "$f" << 'EOF'
+ - "prometheus.io/scrape=true"
+ - "prometheus.io/port=8080"
+ - "prometheus.io/path=/platform/actuator/prometheus"
+EOF
+ fi
+
+ cat >> "$f" << 'EOF'
+ volumes:
+ - bootstrapdata:/data/bootstrap:ro
+ - certs:/certs
+ - ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
+ networks:
+ - cameleer
+EOF
+
+ if [ -n "$MONITORING_NETWORK" ]; then
+ echo " - ${MONITORING_NETWORK}" >> "$f"
+ fi
+
+ cat >> "$f" << 'EOF'
+ group_add:
+ - "0"
+EOF
+
+ # --- Volumes ---
+ cat >> "$f" << 'EOF'
+
+volumes:
+ pgdata:
+ chdata:
+ certs:
+ bootstrapdata:
+EOF
+
+ # --- Networks ---
+ cat >> "$f" << 'EOF'
+
+networks:
+ cameleer:
+ driver: bridge
+ cameleer-traefik:
+ name: cameleer-traefik
+ driver: bridge
+EOF
+
+ # Monitoring network (external)
+ if [ -n "$MONITORING_NETWORK" ]; then
+ cat >> "$f" << EOF
+ ${MONITORING_NETWORK}:
+ external: true
+EOF
+ fi
+
+ log_info "Generated docker-compose.yml"
+}
+```
+
+- [ ] **Step 3: Verify syntax**
+
+```bash
+bash -n installer/install.sh
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add installer/install.sh
+git commit -m "feat(installer): add .env and docker-compose.yml generation"
+```
+
+---
+
+### Task 14: Docker operations and health verification
+
+**Files:**
+- Modify: `installer/install.sh`
+
+- [ ] **Step 1: Add docker operations and health checks**
+
+Append:
+
+```bash
+# --- Docker operations ---
+
+docker_compose_pull() {
+ log_info "Pulling Docker images..."
+ (cd "$INSTALL_DIR" && docker compose -p "$COMPOSE_PROJECT" pull)
+ log_success "All images pulled."
+}
+
+docker_compose_up() {
+ log_info "Starting Cameleer SaaS platform..."
+ (cd "$INSTALL_DIR" && docker compose -p "$COMPOSE_PROJECT" up -d)
+ log_info "Containers started."
+}
+
+docker_compose_down() {
+ log_info "Stopping Cameleer SaaS platform..."
+ (cd "$INSTALL_DIR" && docker compose -p "$COMPOSE_PROJECT" down)
+}
+
+# --- Health verification ---
+
+check_service_health() {
+ local name="$1" check_cmd="$2" timeout_secs="${3:-120}"
+ local elapsed=0
+ local start_time=$(date +%s)
+
+ while [ $elapsed -lt $timeout_secs ]; do
+ if eval "$check_cmd" >/dev/null 2>&1; then
+ local duration=$(($(date +%s) - start_time))
+ printf " ${GREEN}[ok]${NC} %-20s ready (%ds)\n" "$name" "$duration"
+ return 0
+ fi
+ sleep 5
+ elapsed=$(( $(date +%s) - start_time ))
+ done
+
+ printf " ${RED}[FAIL]${NC} %-20s not ready after %ds\n" "$name" "$timeout_secs"
+ echo " Check: docker compose -p $COMPOSE_PROJECT logs ${name,,}"
+ return 1
+}
+
+verify_health() {
+ echo ""
+ log_info "Verifying installation..."
+ local failed=0
+
+ check_service_health "PostgreSQL" \
+ "cd '$INSTALL_DIR' && docker compose -p '$COMPOSE_PROJECT' exec -T postgres pg_isready -U cameleer" \
+ 120 || failed=1
+
+ [ $failed -eq 0 ] && check_service_health "ClickHouse" \
+ "cd '$INSTALL_DIR' && docker compose -p '$COMPOSE_PROJECT' exec -T clickhouse clickhouse-client --password \"\$(grep CLICKHOUSE_PASSWORD '$INSTALL_DIR/.env' | cut -d= -f2)\" --query 'SELECT 1'" \
+ 120 || failed=1
+
+ [ $failed -eq 0 ] && check_service_health "Logto" \
+ "cd '$INSTALL_DIR' && docker compose -p '$COMPOSE_PROJECT' exec -T logto node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\"" \
+ 120 || failed=1
+
+ [ $failed -eq 0 ] && check_service_health "Bootstrap" \
+ "cd '$INSTALL_DIR' && docker compose -p '$COMPOSE_PROJECT' exec -T logto test -f /data/logto-bootstrap.json" \
+ 120 || failed=1
+
+ [ $failed -eq 0 ] && check_service_health "Cameleer SaaS" \
+ "curl -sfk https://localhost:${HTTPS_PORT}/platform/api/config" \
+ 120 || failed=1
+
+ [ $failed -eq 0 ] && check_service_health "Traefik routing" \
+ "curl -sfk -o /dev/null https://localhost:${HTTPS_PORT}/" \
+ 120 || failed=1
+
+ echo ""
+ if [ $failed -ne 0 ]; then
+ log_error "Installation verification failed. Stack is running — check logs."
+ exit 1
+ fi
+ log_success "All services healthy."
+}
+```
+
+- [ ] **Step 2: Verify syntax**
+
+```bash
+bash -n installer/install.sh
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add installer/install.sh
+git commit -m "feat(installer): add docker operations and health verification"
+```
+
+---
+
+### Task 15: Output file generation
+
+**Files:**
+- Modify: `installer/install.sh`
+
+- [ ] **Step 1: Add credentials, INSTALL.md, and config file generation**
+
+Append:
+
+```bash
+# --- Output file generation ---
+
+write_config_file() {
+ local f="$INSTALL_DIR/cameleer.conf"
+ cat > "$f" << EOF
+# Cameleer installation config
+# Generated by installer v${CAMELEER_INSTALLER_VERSION} on $(date -u '+%Y-%m-%d %H:%M:%S UTC')
+
+install_dir=${INSTALL_DIR}
+public_host=${PUBLIC_HOST}
+public_protocol=${PUBLIC_PROTOCOL}
+admin_user=${ADMIN_USER}
+tls_mode=${TLS_MODE}
+http_port=${HTTP_PORT}
+https_port=${HTTPS_PORT}
+logto_console_port=${LOGTO_CONSOLE_PORT}
+logto_console_exposed=${LOGTO_CONSOLE_EXPOSED}
+vendor_enabled=${VENDOR_ENABLED}
+vendor_user=${VENDOR_USER}
+monitoring_network=${MONITORING_NETWORK}
+version=${VERSION}
+compose_project=${COMPOSE_PROJECT}
+docker_socket=${DOCKER_SOCKET}
+node_tls_reject=${NODE_TLS_REJECT}
+EOF
+ log_info "Saved installer config to cameleer.conf"
+}
+
+generate_credentials_file() {
+ local f="$INSTALL_DIR/credentials.txt"
+ cat > "$f" << EOF
+===========================================
+ CAMELEER PLATFORM CREDENTIALS
+ Generated: $(date -u '+%Y-%m-%d %H:%M:%S UTC')
+
+ SECURE THIS FILE AND DELETE AFTER NOTING
+ THESE CREDENTIALS CANNOT BE RECOVERED
+===========================================
+
+Admin Console: ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/platform/
+Admin User: ${ADMIN_USER}
+Admin Password: ${ADMIN_PASS}
+
+PostgreSQL: cameleer / ${POSTGRES_PASSWORD}
+ClickHouse: default / ${CLICKHOUSE_PASSWORD}
+
+EOF
+
+ if [ "$VENDOR_ENABLED" = "true" ]; then
+ cat >> "$f" << EOF
+Vendor User: ${VENDOR_USER}
+Vendor Password: ${VENDOR_PASS}
+
+EOF
+ else
+ echo "Vendor User: (not enabled)" >> "$f"
+ echo "" >> "$f"
+ fi
+
+ if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then
+ echo "Logto Console: ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}:${LOGTO_CONSOLE_PORT}" >> "$f"
+ else
+ echo "Logto Console: (not exposed)" >> "$f"
+ fi
+
+ chmod 600 "$f"
+ log_info "Saved credentials to credentials.txt"
+}
+
+generate_install_doc() {
+ local f="$INSTALL_DIR/INSTALL.md"
+ local tls_desc="Self-signed (auto-generated)"
+ [ "$TLS_MODE" = "custom" ] && tls_desc="Custom certificate"
+
+ cat > "$f" << EOF
+# Cameleer SaaS — Installation Documentation
+
+## Installation Summary
+
+| | |
+|---|---|
+| **Version** | ${VERSION} |
+| **Date** | $(date -u '+%Y-%m-%d %H:%M:%S UTC') |
+| **Installer** | v${CAMELEER_INSTALLER_VERSION} |
+| **Install Directory** | ${INSTALL_DIR} |
+| **Hostname** | ${PUBLIC_HOST} |
+| **TLS** | ${tls_desc} |
+
+## Service URLs
+
+- **Platform UI:** ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/platform/
+- **API Endpoint:** ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/platform/api/
+EOF
+
+ if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then
+ echo "- **Logto Admin Console:** ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}:${LOGTO_CONSOLE_PORT}" >> "$f"
+ fi
+
+ cat >> "$f" << 'EOF'
+
+## First Steps
+
+1. Open the Platform UI in your browser
+2. Log in with the admin credentials from `credentials.txt`
+3. Create your first tenant via the Vendor console
+4. The platform will provision a dedicated server instance for the tenant
+
+## Architecture
+
+| Container | Purpose |
+|---|---|
+| `traefik` | Reverse proxy, TLS termination, routing |
+| `postgres` | PostgreSQL database (SaaS + Logto + tenant schemas) |
+| `clickhouse` | Time-series storage (traces, metrics, logs) |
+| `logto` | OIDC identity provider + bootstrap |
+| `cameleer-saas` | SaaS platform (Spring Boot + React) |
+
+Per-tenant `cameleer3-server` and `cameleer3-server-ui` containers are provisioned dynamically when tenants are created.
+
+## Networking
+
+EOF
+
+ cat >> "$f" << EOF
+| Port | Service |
+|---|---|
+| ${HTTP_PORT} | HTTP (redirects to HTTPS) |
+| ${HTTPS_PORT} | HTTPS (main entry point) |
+EOF
+
+ if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then
+ echo "| ${LOGTO_CONSOLE_PORT} | Logto Admin Console |" >> "$f"
+ fi
+
+ if [ -n "$MONITORING_NETWORK" ]; then
+ cat >> "$f" << EOF
+
+### Monitoring
+
+Services are connected to the \`${MONITORING_NETWORK}\` Docker network with Prometheus labels for auto-discovery.
+EOF
+ fi
+
+ cat >> "$f" << EOF
+
+## TLS
+
+**Mode:** ${tls_desc}
+EOF
+
+ if [ "$TLS_MODE" = "self-signed" ]; then
+ cat >> "$f" << 'EOF'
+
+The platform generated a self-signed certificate on first boot. To replace it:
+1. Log in as admin and navigate to **Certificates** in the vendor console
+2. Upload your certificate and key via the UI
+3. Activate the new certificate (zero-downtime swap)
+EOF
+ fi
+
+ cat >> "$f" << 'EOF'
+
+## Data & Backups
+
+| Docker Volume | Contains |
+|---|---|
+| `pgdata` | PostgreSQL data (tenants, licenses, audit) |
+| `chdata` | ClickHouse data (traces, metrics, logs) |
+| `certs` | TLS certificates |
+| `bootstrapdata` | Logto bootstrap results |
+
+### Backup Commands
+
+```bash
+# PostgreSQL
+docker compose exec postgres pg_dump -U cameleer cameleer_saas > backup.sql
+
+# ClickHouse
+docker compose exec clickhouse clickhouse-client --query "SELECT * FROM cameleer.traces FORMAT Native" > traces.native
+```
+
+## Upgrading
+
+Re-run the installer with a new version:
+
+```bash
+curl -sfL https://install.cameleer.io | bash -s -- --install-dir DIR --version NEW_VERSION
+```
+
+The installer preserves your `.env`, credentials, and data volumes. Only the compose file and images are updated.
+
+## Troubleshooting
+
+| Issue | Command |
+|---|---|
+| Service not starting | `docker compose logs SERVICE_NAME` |
+| Bootstrap failed | `docker compose logs logto` |
+| Routing issues | `docker compose logs traefik` |
+| Database issues | `docker compose exec postgres psql -U cameleer -d cameleer_saas` |
+
+## Uninstalling
+
+```bash
+# Stop and remove containers
+docker compose down
+
+# Remove data volumes (DESTRUCTIVE)
+docker compose down -v
+
+# Remove install directory
+rm -rf INSTALL_DIR
+```
+EOF
+
+ # Replace INSTALL_DIR placeholder
+ sed -i "s|INSTALL_DIR|${INSTALL_DIR}|g" "$f"
+
+ log_info "Generated INSTALL.md"
+}
+
+print_credentials() {
+ echo ""
+ echo -e "${BOLD}==========================================${NC}"
+ echo -e "${BOLD} CAMELEER PLATFORM CREDENTIALS${NC}"
+ echo -e "${BOLD}==========================================${NC}"
+ echo ""
+ echo -e " Admin Console: ${BLUE}${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/platform/${NC}"
+ echo -e " Admin User: ${BOLD}${ADMIN_USER}${NC}"
+ echo -e " Admin Password: ${BOLD}${ADMIN_PASS}${NC}"
+ echo ""
+ echo -e " PostgreSQL: cameleer / ${POSTGRES_PASSWORD}"
+ echo -e " ClickHouse: default / ${CLICKHOUSE_PASSWORD}"
+ echo ""
+ if [ "$VENDOR_ENABLED" = "true" ]; then
+ echo -e " Vendor User: ${BOLD}${VENDOR_USER}${NC}"
+ echo -e " Vendor Password: ${BOLD}${VENDOR_PASS}${NC}"
+ echo ""
+ fi
+ if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then
+ echo -e " Logto Console: ${BLUE}${PUBLIC_PROTOCOL}://${PUBLIC_HOST}:${LOGTO_CONSOLE_PORT}${NC}"
+ echo ""
+ fi
+ echo -e " Credentials saved to: ${INSTALL_DIR}/credentials.txt"
+ echo -e " ${YELLOW}Secure this file and delete after noting credentials.${NC}"
+ echo ""
+}
+
+print_summary() {
+ echo -e "${GREEN}==========================================${NC}"
+ echo -e "${GREEN} Installation complete!${NC}"
+ echo -e "${GREEN}==========================================${NC}"
+ echo ""
+ echo " Install directory: $INSTALL_DIR"
+ echo " Documentation: $INSTALL_DIR/INSTALL.md"
+ echo ""
+ echo " To manage the stack:"
+ echo " cd $INSTALL_DIR"
+ echo " docker compose ps # status"
+ echo " docker compose logs -f # logs"
+ echo " docker compose down # stop"
+ echo ""
+}
+```
+
+- [ ] **Step 2: Verify syntax**
+
+```bash
+bash -n installer/install.sh
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add installer/install.sh
+git commit -m "feat(installer): add output file generation (credentials, INSTALL.md, config)"
+```
+
+---
+
+### Task 16: Re-run and upgrade logic
+
+**Files:**
+- Modify: `installer/install.sh`
+
+- [ ] **Step 1: Add re-run menu and handlers**
+
+Append:
+
+```bash
+# --- Re-run and upgrade ---
+
+show_rerun_menu() {
+ # Read existing version from config
+ local current_version
+ current_version=$(grep '^version=' "$INSTALL_DIR/cameleer.conf" 2>/dev/null | cut -d= -f2 || echo "unknown")
+ local current_host
+ current_host=$(grep '^public_host=' "$INSTALL_DIR/cameleer.conf" 2>/dev/null | cut -d= -f2 || echo "unknown")
+
+ echo ""
+ echo -e "${BOLD}Existing Cameleer installation detected (v${current_version})${NC}"
+ echo " Install directory: $INSTALL_DIR"
+ echo " Public host: $current_host"
+ echo ""
+
+ # In silent mode, default to upgrade
+ if [ "$MODE" = "silent" ]; then
+ RERUN_ACTION="${RERUN_ACTION:-upgrade}"
+ return
+ fi
+
+ # If --reconfigure or --reinstall was passed, use it
+ if [ -n "$RERUN_ACTION" ]; then return; fi
+
+ local new_version="${VERSION:-$CAMELEER_DEFAULT_VERSION}"
+ echo " [1] Upgrade to v${new_version} (pull new images, update compose)"
+ echo " [2] Reconfigure (re-run interactive setup, preserve data)"
+ echo " [3] Reinstall (fresh install, WARNING: destroys data volumes)"
+ echo " [4] Cancel"
+ echo ""
+
+ local choice
+ read -rp " Select [1]: " choice
+ case "${choice:-1}" in
+ 1) RERUN_ACTION="upgrade" ;;
+ 2) RERUN_ACTION="reconfigure" ;;
+ 3) RERUN_ACTION="reinstall" ;;
+ 4) echo "Cancelled."; exit 0 ;;
+ *) echo "Invalid choice."; exit 1 ;;
+ esac
+}
+
+handle_rerun() {
+ case "$RERUN_ACTION" in
+ upgrade)
+ log_info "Upgrading installation..."
+ # Load existing config, keep everything
+ load_config_file "$INSTALL_DIR/cameleer.conf"
+ load_env_overrides
+ merge_config
+ # Regenerate compose (may have template updates) but keep .env
+ generate_compose_file
+ docker_compose_pull
+ docker_compose_down
+ docker_compose_up
+ verify_health
+ generate_install_doc
+ print_summary
+ exit 0
+ ;;
+ reconfigure)
+ log_info "Reconfiguring installation..."
+ # Reset config values so prompts offer current values as defaults
+ # (values are already loaded from cameleer.conf by detect_existing_install)
+ return # continue with normal flow
+ ;;
+ reinstall)
+ if [ "${CONFIRM_DESTROY:-false}" != "true" ]; then
+ echo ""
+ log_warn "This will destroy ALL data (databases, certificates, bootstrap)."
+ if ! prompt_yesno "Are you sure? This cannot be undone."; then
+ echo "Cancelled."
+ exit 0
+ fi
+ fi
+ log_info "Reinstalling..."
+ docker_compose_down
+ (cd "$INSTALL_DIR" && docker compose -p "$COMPOSE_PROJECT" down -v 2>/dev/null || true)
+ rm -f "$INSTALL_DIR/.env" "$INSTALL_DIR/docker-compose.yml" \
+ "$INSTALL_DIR/cameleer.conf" "$INSTALL_DIR/credentials.txt" \
+ "$INSTALL_DIR/INSTALL.md" "$INSTALL_DIR/.env.bak"
+ rm -rf "$INSTALL_DIR/certs"
+ IS_RERUN=false
+ return # continue with fresh install flow
+ ;;
+ esac
+}
+```
+
+- [ ] **Step 2: Verify syntax**
+
+```bash
+bash -n installer/install.sh
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add installer/install.sh
+git commit -m "feat(installer): add re-run, upgrade, and reinstall logic"
+```
+
+---
+
+### Task 17: Main function and end-to-end testing
+
+**Files:**
+- Modify: `installer/install.sh`
+
+- [ ] **Step 1: Add main function**
+
+Append:
+
+```bash
+# --- Main ---
+
+main() {
+ parse_args "$@"
+
+ print_banner
+
+ # Load config sources (CLI already loaded via parse_args)
+ if [ -n "$CONFIG_FILE_PATH" ]; then
+ load_config_file "$CONFIG_FILE_PATH"
+ fi
+ load_env_overrides
+
+ # Check for existing installation
+ detect_existing_install
+ if [ "$IS_RERUN" = true ]; then
+ show_rerun_menu
+ handle_rerun
+ fi
+
+ # Prerequisites
+ check_prerequisites
+
+ # Auto-detect defaults
+ auto_detect
+
+ # Interactive prompts (unless silent)
+ if [ "$MODE" != "silent" ]; then
+ select_mode
+ if [ "$MODE" = "expert" ]; then
+ run_expert_prompts
+ else
+ run_simple_prompts
+ fi
+ fi
+
+ # Merge remaining defaults and validate
+ merge_config
+ validate_config
+
+ # Generate passwords for any empty values
+ generate_passwords
+
+ # Create install directory
+ mkdir -p "$INSTALL_DIR"
+
+ # Copy custom certs if provided
+ if [ "$TLS_MODE" = "custom" ]; then
+ copy_certs
+ fi
+
+ # Generate configuration files
+ generate_env_file
+ generate_compose_file
+ write_config_file
+
+ # Pull and start
+ docker_compose_pull
+ docker_compose_up
+
+ # Verify health
+ verify_health
+
+ # Generate output files
+ generate_credentials_file
+ generate_install_doc
+
+ # Print results
+ print_credentials
+ print_summary
+}
+
+main "$@"
+```
+
+- [ ] **Step 2: Verify the complete script has no syntax errors**
+
+```bash
+bash -n installer/install.sh
+```
+
+- [ ] **Step 3: Test silent mode with defaults (dry-run inspection)**
+
+Create a test that generates files without actually pulling/starting:
+
+```bash
+# Temporarily disable docker operations for testing
+bash -c '
+ source <(sed "s/docker_compose_pull/# &/;s/docker_compose_up/# &/;s/verify_health/# &/" installer/install.sh | head -n -2)
+ INSTALL_DIR="/tmp/cameleer-test"
+ MODE="silent"
+ merge_config
+ generate_passwords
+ mkdir -p "$INSTALL_DIR"
+ generate_env_file
+ generate_compose_file
+ write_config_file
+ generate_credentials_file
+ echo "=== Generated .env ==="
+ cat "$INSTALL_DIR/.env"
+ echo ""
+ echo "=== Generated compose (first 20 lines) ==="
+ head -20 "$INSTALL_DIR/docker-compose.yml"
+ rm -rf /tmp/cameleer-test
+'
+```
+
+Expected: .env and docker-compose.yml generated with default values.
+
+- [ ] **Step 4: Full end-to-end test (requires Phase 1 complete and images available)**
+
+```bash
+bash installer/install.sh --silent --install-dir /tmp/cameleer-e2e --public-host localhost
+# Wait for health checks to pass
+# Verify: curl -sk https://localhost/platform/api/config
+# Cleanup: cd /tmp/cameleer-e2e && docker compose down -v && rm -rf /tmp/cameleer-e2e
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add installer/install.sh
+git commit -m "feat(installer): add main function and complete install.sh
+
+Fully functional bash installer with simple/expert/silent modes,
+idempotent re-run, health verification, and documentation generation."
+```
+
+---
+
+## Phase 3: PowerShell Installer
+
+### Task 18: PowerShell implementation
+
+**Files:**
+- Create: `installer/install.ps1`
+
+The PowerShell script mirrors the bash installer's structure and logic exactly. It produces identical output files (docker-compose.yml, .env, credentials.txt, INSTALL.md, cameleer.conf).
+
+- [ ] **Step 1: Create install.ps1**
+
+Create `installer/install.ps1` with the same function structure as install.sh, adapted for PowerShell idioms:
+
+| Bash function | PowerShell equivalent | Key differences |
+|---|---|---|
+| `generate_password` | `New-SecurePassword` | Uses `[System.Security.Cryptography.RandomNumberGenerator]` |
+| `parse_args` | `param()` block | PowerShell native parameter handling |
+| `prompt` / `prompt_password` | `Read-Host` / `Read-Host -AsSecureString` | Native PowerShell prompts |
+| `check_prerequisites` | `Test-Prerequisites` | Uses `Get-Command`, `Test-NetConnection` |
+| `auto_detect` | `Get-AutoDetectedDefaults` | Uses `[System.Net.Dns]::GetHostEntry()` |
+| `generate_compose_file` | `New-ComposeFile` | Uses here-strings (`@'...'@`) |
+| `generate_env_file` | `New-EnvFile` | Same format, `Set-Content` |
+| `docker_compose_pull/up` | `Invoke-DockerCompose` | Same docker commands |
+| `verify_health` | `Test-ServiceHealth` | Same docker exec + curl checks |
+| `check_port_available` | `Test-PortAvailable` | Uses `Test-NetConnection -Port` |
+
+Key structural differences for PowerShell:
+- Parameters declared via `param()` block (replaces manual arg parsing)
+- Docker socket default: `//./pipe/docker_engine` (Windows named pipe)
+- Password generation: `[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(24)` → Base64
+- Port checking: `Test-NetConnection -ComputerName localhost -Port $port`
+- Hostname detection: `[System.Net.Dns]::GetHostEntry([System.Net.Dns]::GetHostName()).HostName`
+- Color output: `Write-Host -ForegroundColor` instead of ANSI escape codes
+- Here-strings for templates: `@'...'@` (literal) and `@"..."@` (expanding)
+
+The compose template content must be identical to the bash version — same YAML, same variable references, same conditional sections. Use the same `>> $file` append pattern with PowerShell here-strings.
+
+```powershell
+#Requires -Version 5.1
+[CmdletBinding()]
+param(
+ [switch]$Silent,
+ [switch]$Expert,
+ [string]$Config,
+ [string]$InstallDir,
+ [string]$PublicHost,
+ [string]$PublicProtocol,
+ [string]$AdminUser,
+ [string]$AdminPassword,
+ [ValidateSet('self-signed', 'custom')]
+ [string]$TlsMode,
+ [string]$CertFile,
+ [string]$KeyFile,
+ [string]$CaFile,
+ [string]$PostgresPassword,
+ [string]$ClickhousePassword,
+ [int]$HttpPort,
+ [int]$HttpsPort,
+ [int]$LogtoConsolePort,
+ [string]$LogtoConsoleExposed,
+ [string]$VendorEnabled,
+ [string]$VendorUser,
+ [string]$VendorPassword,
+ [string]$MonitoringNetwork,
+ [string]$Version,
+ [string]$ComposeProject,
+ [string]$DockerSocket,
+ [string]$NodeTlsReject,
+ [switch]$Reconfigure,
+ [switch]$Reinstall,
+ [switch]$ConfirmDestroy,
+ [switch]$Help
+)
+
+$InstallerVersion = "1.0.0"
+$DefaultVersion = "latest"
+$Registry = "gitea.siegeln.net/cameleer"
+
+# ... implement all functions following the bash structure ...
+# Each function mirrors its bash equivalent exactly.
+# The generated files (.env, docker-compose.yml, etc.) must be identical.
+```
+
+Implement each function following the bash structure. The implementing agent should read `installer/install.sh` and translate each function to PowerShell, maintaining identical behavior and output.
+
+- [ ] **Step 2: Verify PowerShell syntax**
+
+```powershell
+$null = [System.Management.Automation.PSParser]::Tokenize((Get-Content installer/install.ps1 -Raw), [ref]$null)
+```
+
+Or on Linux with PowerShell Core:
+
+```bash
+pwsh -Command "& { \$null = [System.Management.Automation.PSParser]::Tokenize((Get-Content 'installer/install.ps1' -Raw), [ref]\$null); Write-Host 'Syntax OK' }"
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add installer/install.ps1
+git commit -m "feat(installer): add PowerShell installer for Windows
+
+Mirrors install.sh structure and produces identical output files.
+Uses native PowerShell idioms for parameters, prompts, and crypto."
+```
+
+---
+
+## Task Dependencies
+
+```
+Tasks 1,2,3,4 ─── can run in parallel (independent images)
+ │
+ Task 5 ─────── depends on 1-4 (compose update)
+ Task 6 ─────── depends on 1-4 (CI update)
+ │
+ Task 7 ─────── depends on 5,6 (stack validation)
+
+Tasks 8-16 ────── can run in parallel with Phase 1
+ (installer generates files, doesn't need images)
+ │
+ Task 17 ────── depends on Task 7 + Task 16
+ (E2E test needs working images + complete installer)
+
+ Task 18 ────── depends on Task 16
+ (PowerShell mirrors completed bash)
+```
+
+## Follow-up (out of scope)
+
+- Bake `docker/server-ui-entrypoint.sh` into the `cameleer3-server-ui` image (separate repo)
+- Set up `install.cameleer.io` distribution endpoint
+- Create release automation (tag → publish installer scripts to distribution endpoint)
+- Add `docker-compose.dev.yml` overlay generation for the installer's expert mode