OneDev是一个开源的一体化的DevOps平台,原生支持Docker, 详情详见 https://github.com/theonedev/onedev

#!/usr/bin/env bash
#
# OneDev installer for Debian 13
# Supports:
#   1) Docker Compose + PostgreSQL (recommended)
#   2) Bare-metal/binary + PostgreSQL + systemd
#
# Usage:
#   chmod +x install-onedev.sh
#   ./install-onedev.sh docker
#   ./install-onedev.sh binary
#   ./install-onedev.sh nginx
#   ./install-onedev.sh config
#
# All important deployment parameters are centralized below and may also
# be overridden with environment variables.
#

set -Eeuo pipefail
IFS=$'\n\t'
umask 027

###############################################################################
# User variables
###############################################################################

# Main install directory.
# Docker mode: compose.yml, data/, secrets/, backup/ are created here.
# Binary mode: OneDev program itself is extracted here.
INSTALL_DIR="${INSTALL_DIR:-/opt/Docker/OneDev}"

# Public DNS name used by Nginx and displayed in the final setup hints.
DOMAIN="${DOMAIN}"

# Public/host SSH port for OneDev Git SSH.
# If set to 22, move the Debian system sshd to another port BEFORE installing.
ONEDEV_SSH_PORT="${ONEDEV_SSH_PORT:-22}"

# Host/bare-metal HTTP port used by OneDev.
# Nginx proxies HTTPS traffic to 127.0.0.1:${ONEDEV_WEB_PORT}.
ONEDEV_WEB_PORT="${ONEDEV_WEB_PORT:-6610}"

# Bind OneDev Web only to localhost when Docker is used.
# Binary mode writes this value to conf/server.properties as http_host.
ONEDEV_WEB_BIND="${ONEDEV_WEB_BIND:-127.0.0.1}"

# Production database settings.
POSTGRES_MAJOR="${POSTGRES_MAJOR:-17}"
DB_NAME="${DB_NAME:-onedev}"
DB_USER="${DB_USER:-onedev}"

# Leave empty to generate/preserve a random password in:
#   ${INSTALL_DIR}/secrets/postgres_password
DB_PASSWORD="${DB_PASSWORD:-}"

# Bare-metal service account.
ONEDEV_USER="${ONEDEV_USER:-onedev}"
ONEDEV_GROUP="${ONEDEV_GROUP:-onedev}"

# Official latest bare-metal package.
ONEDEV_DOWNLOAD_URL="${ONEDEV_DOWNLOAD_URL:-https://code.onedev.io/onedev/server/~site/onedev-latest.tar.gz}"

# Docker image intentionally has NO version tag.
ONEDEV_DOCKER_IMAGE="${ONEDEV_DOCKER_IMAGE:-1dev/server}"

# Nginx configuration is generated, but not automatically installed/reloaded.
GENERATE_NGINX_CONFIG="${GENERATE_NGINX_CONFIG:-yes}"
SSL_CERT_PATH="${SSL_CERT_PATH:-/etc/letsencrypt/live/${DOMAIN}/fullchain.pem}"
SSL_KEY_PATH="${SSL_KEY_PATH:-/etc/letsencrypt/live/${DOMAIN}/privkey.pem}"

# Whether Docker mode should mount /var/run/docker.sock into OneDev.
# "no" is safer for a production server; use a OneDev Agent for container CI.
MOUNT_DOCKER_SOCKET="${MOUNT_DOCKER_SOCKET:-no}"

###############################################################################
# Derived paths
###############################################################################

COMPOSE_FILE="${INSTALL_DIR}/compose.yml"
DATA_DIR="${INSTALL_DIR}/data"
ONEDEV_DATA_DIR="${DATA_DIR}/onedev"
POSTGRES_DATA_DIR="${DATA_DIR}/postgres"
SECRETS_DIR="${INSTALL_DIR}/secrets"
BACKUP_DIR="${INSTALL_DIR}/backup"
DB_PASSWORD_FILE="${SECRETS_DIR}/postgres_password"
NGINX_DIR="${INSTALL_DIR}/nginx"
NGINX_CONF_OUTPUT="${NGINX_DIR}/${DOMAIN}.conf"

###############################################################################
# Helpers
###############################################################################

C_RESET='\033[0m'
C_GREEN='\033[0;32m'
C_YELLOW='\033[0;33m'
C_RED='\033[0;31m'
C_BLUE='\033[0;34m'

log()  { printf "${C_GREEN}[INFO]${C_RESET} %s\n" "$*"; }
warn() { printf "${C_YELLOW}[WARN]${C_RESET} %s\n" "$*" >&2; }
err()  { printf "${C_RED}[ERROR]${C_RESET} %s\n" "$*" >&2; }
die()  { err "$*"; exit 1; }

trap 'err "Installation failed at line ${LINENO}. Command: ${BASH_COMMAND}"' ERR

require_root() {
    [[ ${EUID} -eq 0 ]] || die "Please run this script as root."
}

check_debian() {
    [[ -r /etc/os-release ]] || die "/etc/os-release not found."
    # shellcheck disable=SC1091
    . /etc/os-release
    [[ "${ID:-}" == "debian" ]] || die "This installer targets Debian. Detected: ${ID:-unknown}"
    local major="${VERSION_ID%%.*}"
    [[ "$major" =~ ^[0-9]+$ ]] || die "Unable to determine Debian version."
    if (( major < 13 )); then
        warn "Detected Debian ${VERSION_ID}. This script is designed and tested for Debian 13."
    fi
}

validate_port() {
    local p="$1" name="$2"
    [[ "$p" =~ ^[0-9]+$ ]] || die "${name} must be an integer."
    (( p >= 1 && p <= 65535 )) || die "${name} must be between 1 and 65535."
}

validate_name() {
    local value="$1" name="$2"
    [[ "$value" =~ ^[a-zA-Z_][a-zA-Z0-9_-]*$ ]] || \
        die "${name} contains unsupported characters: ${value}"
}

validate_config() {
    [[ "$INSTALL_DIR" == /* ]] || die "INSTALL_DIR must be an absolute path."
    [[ -n "$DOMAIN" && "$DOMAIN" != *" "* ]] || die "DOMAIN is invalid."
    validate_port "$ONEDEV_SSH_PORT" "ONEDEV_SSH_PORT"
    validate_port "$ONEDEV_WEB_PORT" "ONEDEV_WEB_PORT"
    [[ "$ONEDEV_SSH_PORT" != "$ONEDEV_WEB_PORT" ]] || \
        die "ONEDEV_SSH_PORT and ONEDEV_WEB_PORT cannot be the same."
    validate_name "$DB_NAME" "DB_NAME"
    validate_name "$DB_USER" "DB_USER"
    validate_name "$ONEDEV_USER" "ONEDEV_USER"
    validate_name "$ONEDEV_GROUP" "ONEDEV_GROUP"
}

port_in_use() {
    local port="$1"
    ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq ":${port}$"
}

check_required_ports() {
    local mode="$1"
    if port_in_use "$ONEDEV_SSH_PORT"; then
        die "TCP port ${ONEDEV_SSH_PORT} is already in use. If it is Debian sshd, move the system SSH service to another port first."
    fi

    if port_in_use "$ONEDEV_WEB_PORT"; then
        die "TCP port ${ONEDEV_WEB_PORT} is already in use."
    fi

    if [[ "$mode" == "binary" && "$ONEDEV_SSH_PORT" -lt 1024 ]]; then
        log "Binary mode will grant only CAP_NET_BIND_SERVICE to the OneDev systemd service so user '${ONEDEV_USER}' can bind TCP/${ONEDEV_SSH_PORT}."
    fi
}

apt_update_once() {
    if [[ "${APT_UPDATED:-no}" != "yes" ]]; then
        apt-get update
        APT_UPDATED=yes
    fi
}

install_common_tools() {
    apt_update_once
    DEBIAN_FRONTEND=noninteractive apt-get install -y \
        ca-certificates curl git openssl tar gzip xz-utils \
        gnupg lsb-release iproute2
}

ensure_layout() {
    install -d -m 0750 "$INSTALL_DIR" "$SECRETS_DIR" "$BACKUP_DIR"
}

ensure_db_password() {
    ensure_layout

    if [[ -n "$DB_PASSWORD" ]]; then
        printf '%s\n' "$DB_PASSWORD" > "$DB_PASSWORD_FILE"
        chmod 0600 "$DB_PASSWORD_FILE"
        return
    fi

    if [[ -s "$DB_PASSWORD_FILE" ]]; then
        DB_PASSWORD="$(tr -d '\r\n' < "$DB_PASSWORD_FILE")"
        [[ -n "$DB_PASSWORD" ]] || die "Database password file is empty: $DB_PASSWORD_FILE"
        return
    fi

    DB_PASSWORD="$(openssl rand -hex 32)"
    printf '%s\n' "$DB_PASSWORD" > "$DB_PASSWORD_FILE"
    chmod 0600 "$DB_PASSWORD_FILE"
}

server_url() {
    printf 'https://%s' "$DOMAIN"
}

ssh_root_url() {
    if [[ "$ONEDEV_SSH_PORT" == "22" ]]; then
        printf 'ssh://%s' "$DOMAIN"
    else
        printf 'ssh://%s:%s' "$DOMAIN" "$ONEDEV_SSH_PORT"
    fi
}

wait_for_http() {
    local url="http://127.0.0.1:${ONEDEV_WEB_PORT}/"
    local i
    for i in $(seq 1 120); do
        if curl -fsS --max-time 3 "$url" >/dev/null 2>&1; then
            log "OneDev Web is responding on ${url}"
            return 0
        fi
        sleep 2
    done
    warn "OneDev did not answer HTTP health checks. Check service/container logs."
    return 1
}

###############################################################################
# Docker installation
###############################################################################

install_docker_engine() {
    if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
        log "Docker Engine and Docker Compose plugin are already installed."
        systemctl enable --now docker >/dev/null 2>&1 || true
        return
    fi

    log "Installing Docker Engine from Docker's official Debian repository..."

    apt_update_once
    DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl

    # Remove known conflicting distribution packages only when Docker CE is not
    # already usable. Existing images/data under /var/lib/docker are not deleted.
    DEBIAN_FRONTEND=noninteractive apt-get remove -y \
        docker.io docker-compose docker-doc docker-buildx podman-docker \
        containerd runc 2>/dev/null || true

    install -m 0755 -d /etc/apt/keyrings
    curl -fsSL https://download.docker.com/linux/debian/gpg \
        -o /etc/apt/keyrings/docker.asc
    chmod a+r /etc/apt/keyrings/docker.asc

    # shellcheck disable=SC1091
    . /etc/os-release

    cat > /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: ${VERSION_CODENAME}
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF

    apt-get update
    DEBIAN_FRONTEND=noninteractive apt-get install -y \
        docker-ce docker-ce-cli containerd.io \
        docker-buildx-plugin docker-compose-plugin

    systemctl enable --now docker
    docker version >/dev/null
    docker compose version >/dev/null
    log "Docker Engine installed successfully."
}

write_compose_file() {
    local docker_socket_line=""

    if [[ "$MOUNT_DOCKER_SOCKET" == "yes" ]]; then
        docker_socket_line='      - /var/run/docker.sock:/var/run/docker.sock'
    fi

    install -d -m 0750 \
        "$DATA_DIR" "$ONEDEV_DATA_DIR" "$POSTGRES_DATA_DIR" \
        "$SECRETS_DIR" "$BACKUP_DIR"

    cat > "$COMPOSE_FILE" <<EOF
services:
  postgres:
    image: postgres:${POSTGRES_MAJOR}
    container_name: onedev-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${DB_NAME}
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
    secrets:
      - postgres_password
    volumes:
      - ./data/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
      interval: 10s
      timeout: 5s
      retries: 12
      start_period: 30s
    networks:
      - onedev

  onedev:
    image: ${ONEDEV_DOCKER_IMAGE}
    container_name: onedev
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      hibernate_dialect: io.onedev.server.persistence.PostgreSQLDialect
      hibernate_connection_driver_class: org.postgresql.Driver
      hibernate_connection_url: jdbc:postgresql://postgres:5432/${DB_NAME}
      hibernate_connection_username: ${DB_USER}
      hibernate_connection_password_file: /run/secrets/postgres_password
    secrets:
      - postgres_password
    volumes:
      - ./data/onedev:/opt/onedev
${docker_socket_line}
    ports:
      - "${ONEDEV_WEB_BIND}:${ONEDEV_WEB_PORT}:6610"
      - "${ONEDEV_SSH_PORT}:6611"
    ulimits:
      nofile:
        soft: 65536
        hard: 65536
    stop_grace_period: 2m
    networks:
      - onedev

networks:
  onedev:
    driver: bridge

secrets:
  postgres_password:
    file: ./secrets/postgres_password
EOF

    chmod 0640 "$COMPOSE_FILE"
}

install_docker_mode() {
    require_root
    check_debian
    validate_config
    install_common_tools

    if [[ -e "$COMPOSE_FILE" ]]; then
        die "${COMPOSE_FILE} already exists. Refusing to overwrite an existing deployment."
    fi

    check_required_ports docker
    ensure_db_password
    install_docker_engine

    log "Generating Docker Compose deployment..."
    write_compose_file

    (
        cd "$INSTALL_DIR"
        docker compose config -q
        docker compose pull
        docker compose up -d
    )

    if [[ "$GENERATE_NGINX_CONFIG" == "yes" ]]; then
        generate_nginx_config
    fi

    wait_for_http || true

    log "Docker deployment completed."
    print_post_install docker
}

###############################################################################
# Binary / bare-metal installation
###############################################################################

install_binary_dependencies() {
    apt_update_once
    DEBIAN_FRONTEND=noninteractive apt-get install -y \
        default-jre-headless git curl postgresql postgresql-client \
        ca-certificates openssl tar gzip
    systemctl enable --now postgresql
}

create_onedev_user() {
    if ! getent group "$ONEDEV_GROUP" >/dev/null; then
        groupadd --system "$ONEDEV_GROUP"
    fi

    if ! id "$ONEDEV_USER" >/dev/null 2>&1; then
        useradd \
            --system \
            --gid "$ONEDEV_GROUP" \
            --home-dir "$INSTALL_DIR" \
            --shell /usr/sbin/nologin \
            "$ONEDEV_USER"
    fi
}

setup_local_postgres() {
    ensure_db_password

    log "Preparing PostgreSQL database '${DB_NAME}' and role '${DB_USER}'..."

    if runuser -u postgres -- psql -tAc \
        "SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'" | grep -q 1; then
        printf "ALTER ROLE %s WITH LOGIN PASSWORD :'dbpass';\n" "$DB_USER" | \
            runuser -u postgres -- psql \
                -v ON_ERROR_STOP=1 \
                -v dbpass="$DB_PASSWORD" \
                >/dev/null
    else
        printf "CREATE ROLE %s WITH LOGIN PASSWORD :'dbpass';\n" "$DB_USER" | \
            runuser -u postgres -- psql \
                -v ON_ERROR_STOP=1 \
                -v dbpass="$DB_PASSWORD" \
                >/dev/null
    fi

    if ! runuser -u postgres -- psql -tAc \
        "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" | grep -q 1; then
        runuser -u postgres -- createdb -O "$DB_USER" "$DB_NAME"
    fi
}

download_and_extract_onedev() {
    [[ ! -e "${INSTALL_DIR}/bin/server.sh" ]] || \
        die "OneDev already appears to be installed at ${INSTALL_DIR}."

    local tmpdir archive first_component
    tmpdir="$(mktemp -d)"
    archive="${tmpdir}/onedev-latest.tar.gz"

    log "Downloading latest OneDev bare-metal package..."
    curl -fL --retry 3 --retry-delay 2 \
        "$ONEDEV_DOWNLOAD_URL" \
        -o "$archive"

    tar -tzf "$archive" >/dev/null

    first_component="$(
        tar -tzf "$archive" |
        head -n1 |
        cut -d/ -f1
    )"

    [[ -n "$first_component" ]] || die "Unable to inspect OneDev archive."

    install -d -m 0750 "$INSTALL_DIR"

    # Official archive contains a single top-level onedev-<version> directory.
    tar -xzf "$archive" -C "$INSTALL_DIR" --strip-components=1

    rm -rf "$tmpdir"

    [[ -x "${INSTALL_DIR}/bin/server.sh" ]] || \
        die "Extracted package does not contain bin/server.sh."
}

set_property() {
    local file="$1" key="$2" value="$3"
    if grep -Eq "^[[:space:]]*${key}=" "$file"; then
        sed -ri "s|^[[:space:]]*${key}=.*|${key}=${value}|" "$file"
    else
        printf '\n%s=%s\n' "$key" "$value" >> "$file"
    fi
}

configure_binary_ports() {
    local file="${INSTALL_DIR}/conf/server.properties"
    [[ -f "$file" ]] || die "Missing ${file}"

    set_property "$file" "http_host" "$ONEDEV_WEB_BIND"
    set_property "$file" "http_port" "$ONEDEV_WEB_PORT"
    set_property "$file" "ssh_port" "$ONEDEV_SSH_PORT"
}

configure_binary_database() {
    local file="${INSTALL_DIR}/conf/hibernate.properties"
    [[ -f "$file" ]] || die "Missing ${file}"

    # Recreate this file with the official PostgreSQL dialect and the standard
    # Hikari/cache settings shipped by OneDev.
    cat > "$file" <<EOF
# Generated by install-onedev.sh
hibernate.dialect=io.onedev.server.persistence.PostgreSQLDialect
hibernate.connection.driver_class=org.postgresql.Driver
hibernate.connection.url=jdbc:postgresql://127.0.0.1:5432/${DB_NAME}
hibernate.connection.username=${DB_USER}
hibernate.connection.password=${DB_PASSWORD}

hibernate.connection.provider_class=org.hibernate.hikaricp.internal.HikariCPConnectionProvider
hibernate.hikari.transactionIsolation=TRANSACTION_READ_COMMITTED
hibernate.hikari.autoCommit=true
hibernate.hikari.maximumPoolSize=25
hibernate.show_sql=false
hibernate.format_sql=true

javax.persistence.validation.mode=none
hibernate.validator.apply_to_ddl=false

hibernate.cache.region.factory_class=com.hazelcast.hibernate.HazelcastLocalCacheRegionFactory
hibernate.cache.use_query_cache=true
hibernate.cache.auto_evict_collection_cache=true
hibernate.query.plan_cache_max_size=2048
hibernate.query.plan_parameter_metadata_max_size=128
EOF

    chmod 0600 "$file"
}

install_binary_systemd_service() {
    local capability_lines=""

    if (( ONEDEV_SSH_PORT < 1024 || ONEDEV_WEB_PORT < 1024 )); then
        capability_lines=$'AmbientCapabilities=CAP_NET_BIND_SERVICE\nCapabilityBoundingSet=CAP_NET_BIND_SERVICE'
    fi

    cat > /etc/systemd/system/onedev.service <<EOF
[Unit]
Description=OneDev Server
Wants=network-online.target
After=network-online.target postgresql.service
Requires=postgresql.service

[Service]
Type=simple
User=${ONEDEV_USER}
Group=${ONEDEV_GROUP}
WorkingDirectory=${INSTALL_DIR}
ExecStart=${INSTALL_DIR}/bin/server.sh console
Restart=on-failure
RestartSec=10
TimeoutStopSec=120
LimitNOFILE=65536
${capability_lines}

[Install]
WantedBy=multi-user.target
EOF

    systemctl daemon-reload
    systemctl enable --now onedev
}

install_binary_mode() {
    require_root
    check_debian
    validate_config
    install_common_tools

    [[ ! -e "${INSTALL_DIR}/bin/server.sh" ]] || \
        die "Existing binary OneDev installation detected at ${INSTALL_DIR}."

    check_required_ports binary
    install_binary_dependencies
    create_onedev_user
    ensure_db_password
    setup_local_postgres
    download_and_extract_onedev
    configure_binary_ports
    configure_binary_database

    # OneDev needs full write access to its installation directory.
    chown -R "${ONEDEV_USER}:${ONEDEV_GROUP}" "$INSTALL_DIR"
    chmod 0700 "$SECRETS_DIR"
    chmod 0600 "$DB_PASSWORD_FILE"

    install_binary_systemd_service

    if [[ "$GENERATE_NGINX_CONFIG" == "yes" ]]; then
        generate_nginx_config
        chown -R "${ONEDEV_USER}:${ONEDEV_GROUP}" "$NGINX_DIR" 2>/dev/null || true
    fi

    wait_for_http || {
        systemctl status onedev --no-pager || true
        journalctl -u onedev -n 100 --no-pager || true
    }

    log "Binary deployment completed."
    print_post_install binary
}

###############################################################################
# Nginx configuration generator
###############################################################################

generate_nginx_config() {
    install -d -m 0750 "$NGINX_DIR"

    cat > "$NGINX_CONF_OUTPUT" <<EOF
# OneDev reverse proxy generated by install-onedev.sh
# Copy/include this file from your Nginx vhost configuration after confirming
# that SSL_CERT_PATH and SSL_KEY_PATH exist.

server {
    listen 443 ssl;
    listen [::]:443 ssl;

    server_name ${DOMAIN};

    ssl_certificate     ${SSL_CERT_PATH};
    ssl_certificate_key ${SSL_KEY_PATH};

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:10m;
    ssl_session_tickets off;

    client_max_body_size 0;

    proxy_connect_timeout 60s;
    proxy_send_timeout 600s;
    proxy_read_timeout 600s;

    location /wicket/websocket {
        proxy_pass http://127.0.0.1:${ONEDEV_WEB_PORT}/wicket/websocket;
        proxy_http_version 1.1;
        proxy_set_header Upgrade \$http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
        proxy_read_timeout 1800s;
    }

    location /~server {
        proxy_pass http://127.0.0.1:${ONEDEV_WEB_PORT}/~server;
        proxy_http_version 1.1;
        proxy_set_header Upgrade \$http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
    }

    location /~api/streaming {
        proxy_pass http://127.0.0.1:${ONEDEV_WEB_PORT}/~api/streaming;
        proxy_http_version 1.1;
        proxy_buffering off;
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
    }

    location / {
        proxy_pass http://127.0.0.1:${ONEDEV_WEB_PORT};
        proxy_http_version 1.1;
        proxy_request_buffering off;
        proxy_buffering off;
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
    }
}

server {
    listen 80;
    listen [::]:80;

    server_name ${DOMAIN};

    return 301 https://\$host\$request_uri;
}
EOF

    chmod 0640 "$NGINX_CONF_OUTPUT"
    log "Nginx configuration generated: ${NGINX_CONF_OUTPUT}"

    if [[ ! -f "$SSL_CERT_PATH" || ! -f "$SSL_KEY_PATH" ]]; then
        warn "TLS certificate/key not found at the configured paths. Obtain/install the certificate before enabling this Nginx vhost."
    fi
}

###############################################################################
# Output / CLI
###############################################################################

print_config() {
    cat <<EOF
OneDev installer configuration
------------------------------
INSTALL_DIR          = ${INSTALL_DIR}
DOMAIN               = ${DOMAIN}
ONEDEV_SSH_PORT      = ${ONEDEV_SSH_PORT}
ONEDEV_WEB_PORT      = ${ONEDEV_WEB_PORT}
ONEDEV_WEB_BIND      = ${ONEDEV_WEB_BIND}
POSTGRES_MAJOR       = ${POSTGRES_MAJOR}
DB_NAME              = ${DB_NAME}
DB_USER              = ${DB_USER}
ONEDEV_USER          = ${ONEDEV_USER}
ONEDEV_DOCKER_IMAGE  = ${ONEDEV_DOCKER_IMAGE}
MOUNT_DOCKER_SOCKET  = ${MOUNT_DOCKER_SOCKET}
SERVER_URL           = $(server_url)
SSH_ROOT_URL         = $(ssh_root_url)
NGINX_CONF_OUTPUT    = ${NGINX_CONF_OUTPUT}
EOF
}

print_post_install() {
    local mode="$1"

    cat <<EOF

======================================================================
OneDev installation finished
======================================================================

Mode:
  ${mode}

Install directory:
  ${INSTALL_DIR}

OneDev local Web:
  http://127.0.0.1:${ONEDEV_WEB_PORT}

Public Server URL:
  $(server_url)

OneDev SSH Root URL:
  $(ssh_root_url)

Nginx configuration:
  ${NGINX_CONF_OUTPUT}

IMPORTANT:
  1. If TCP/${ONEDEV_SSH_PORT} is the public Git SSH port, make sure the Debian
     system sshd is NOT listening on the same port.
  2. Open the public URL and finish OneDev's first-run administrator setup.
  3. During first-run setup use:
       Server URL:    $(server_url)
       SSH Root URL:  $(ssh_root_url)
  4. The generated Nginx file is NOT automatically enabled or reloaded.
  5. PostgreSQL password is stored at:
       ${DB_PASSWORD_FILE}

EOF

    if [[ "$mode" == "docker" ]]; then
        cat <<EOF
Useful Docker commands:
  cd ${INSTALL_DIR}
  docker compose ps
  docker compose logs -f onedev
  docker compose logs -f postgres

OneDev upgrades remain explicit:
  cd ${INSTALL_DIR}
  docker compose pull onedev
  docker compose up -d --no-deps onedev

EOF
    else
        cat <<EOF
Useful binary/systemd commands:
  systemctl status onedev
  journalctl -u onedev -f
  systemctl restart onedev

EOF
    fi
}

usage() {
    cat <<EOF
Usage:
  $0 docker
      Install OneDev using Docker Compose + PostgreSQL.

  $0 binary
      Install latest OneDev bare-metal package + PostgreSQL + systemd.

  $0 nginx
      Only generate the Nginx reverse-proxy configuration.

  $0 config
      Print resolved variables.

Variables are defined at the top of this script and may also be overridden:
  INSTALL_DIR=/opt/OneDev DOMAIN=dev.example.com \\
  ONEDEV_SSH_PORT=22 ONEDEV_WEB_PORT=6610 \\
  $0 docker
EOF
}

main() {
    local action="${1:-}"

    case "$action" in
        docker)
            install_docker_mode
            ;;
        binary|baremetal|bare-metal)
            install_binary_mode
            ;;
        nginx)
            require_root
            validate_config
            generate_nginx_config
            ;;
        config)
            validate_config
            print_config
            ;;
        -h|--help|help|"")
            usage
            ;;
        *)
            usage
            die "Unknown action: ${action}"
            ;;
    esac
}

main "$@"