# 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
<clickhouse>
  <users>
    <default remove="remove">
    </default>

    <default>
      <profile>default</profile>
      <networks>
        <ip>::/0</ip>
      </networks>
      <password from_env="CLICKHOUSE_PASSWORD" />
      <quota>default</quota>
      <access_management>0</access_management>
    </default>
  </users>
</clickhouse>
```

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" <<METAEOF
{"subject":"$SUBJECT","fingerprint":"$FINGERPRINT","selfSigned":$SELF_SIGNED,"hasCa":$HAS_CA,"notBefore":"$NOT_BEFORE","notAfter":"$NOT_AFTER"}
METAEOF

  mkdir -p "$CERTS_DIR/staged" "$CERTS_DIR/prev"
  chmod 775 "$CERTS_DIR" "$CERTS_DIR/staged" "$CERTS_DIR/prev"
  chmod 660 "$CERTS_DIR"/*.pem 2>/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
