Ctrl K
ComunidadGuías…

install.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
#
# CliRelay one-click Docker installer
#
# Usage:
#   curl -fsSL https://raw.githubusercontent.com/kittors/CliRelay/main/install.sh | bash
#
set -euo pipefail

C_RESET='\033[0m'; C_BOLD='\033[1m'; C_DIM='\033[2m'
C_RED='\033[0;31m'; C_GREEN='\033[0;32m'; C_YELLOW='\033[0;33m'
C_BLUE='\033[0;34m'; C_CYAN='\033[0;36m'; C_WHITE='\033[1;37m'
C_BG_BLUE='\033[44m'; C_BG_GREEN='\033[42m'; C_BG_RED='\033[41m'

SYM_OK="✓"; SYM_FAIL="✗"; SYM_ARROW="→"; SYM_DOT="·"; SYM_STAR="★"

DOCKER_IMAGE_DEFAULT="ghcr.io/kittors/clirelay:latest"
CONTAINER_NAME="clirelay"
DEFAULT_PORT=8317
TOTAL_STEPS=6
OS_NAME="$(uname -s)"

case "${OS_NAME}" in
    Darwin)
        DEFAULT_INSTALL_DIR="${HOME}/.clirelay"
        ;;
    *)
        DEFAULT_INSTALL_DIR="/opt/clirelay"
        ;;
esac

INSTALL_DIR="${CLIRELAY_DIR:-$DEFAULT_INSTALL_DIR}"

SCRIPT_LOCALE=""
CFG_PORT="${DEFAULT_PORT}"
CFG_SECRET=""
CFG_ADMIN_PASSWORD=""
CFG_API_KEY=""
CFG_REMOTE="true"
TZ_VALUE="${TZ:-Asia/Shanghai}"
DOCKER_PLATFORM=""
DOCKER_ARCH=""
HELPER_PATH=""

can_prompt() {
    [[ -r /dev/tty ]]
}

prompt_input() {
    local prompt="$1"
    local default_value="${2:-}"
    local reply=""

    if ! can_prompt; then
        return 1
    fi

    printf "%b" "$prompt" >/dev/tty
    if ! IFS= read -r reply </dev/tty; then
        return 1
    fi

    if [[ -z "$reply" ]]; then
        reply="$default_value"
    fi

    printf '%s' "$reply"
}

require_interactive_config() {
    if can_prompt; then
        return
    fi
    if is_zh; then
        fail "当前运行方式没有可交互终端,无法完成安装配置。请直接执行 'bash install.sh',或先下载脚本后在交互终端中运行。"
    else
        fail "No interactive terminal is available for configuration. Please run 'bash install.sh' directly, or download the script and run it from an interactive terminal."
    fi
}

normalize_locale() {
    local raw
    raw="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')"
    case "$raw" in
        zh|zh-cn|zh_cn|cn|chinese) echo "zh" ;;
        en|en-us|en_us|en-gb|en_gb|english) echo "en" ;;
        *) echo "" ;;
    esac
}

detect_default_locale() {
    local candidate
    candidate="$(normalize_locale "${CLIRELAY_LOCALE:-}")"
    if [[ -n "$candidate" ]]; then
        echo "$candidate"
        return
    fi
    case "${LC_ALL:-${LANGUAGE:-${LANG:-}}}" in
        zh*|*zh_CN*|*zh_HK*|*zh_TW*|*ZH*) echo "zh" ;;
        *) echo "en" ;;
    esac
}

set_locale() {
    local normalized
    normalized="$(normalize_locale "${1:-}")"
    if [[ -n "$normalized" ]]; then
        SCRIPT_LOCALE="$normalized"
    fi
}

is_zh() {
    [[ "${SCRIPT_LOCALE}" == "zh" ]]
}

banner() {
    echo ""
    echo -e "${C_CYAN}${C_BOLD}"
    cat << 'EOF'
   ██████╗██╗     ██╗██████╗ ███████╗██╗      █████╗ ██╗   ██╗
  ██╔════╝██║     ██║██╔══██╗██╔════╝██║     ██╔══██╗╚██╗ ██╔╝
  ██║     ██║     ██║██████╔╝█████╗  ██║     ███████║ ╚████╔╝
  ██║     ██║     ██║██╔══██╗██╔══╝  ██║     ██╔══██║  ╚██╔╝
  ╚██████╗███████╗██║██║  ██║███████╗███████╗██║  ██║   ██║
   ╚═════╝╚══════╝╚═╝╚═╝  ╚═╝╚══════╝╚══════╝╚═╝  ╚═╝   ╚═╝
EOF
    echo -e "${C_RESET}"
    if is_zh; then
        echo -e "  ${C_DIM}AI Proxy Gateway ${SYM_DOT} Docker 一键部署${C_RESET}"
    else
        echo -e "  ${C_DIM}AI Proxy Gateway ${SYM_DOT} One-click Docker deployment${C_RESET}"
    fi
    echo -e "  ${C_DIM}────────────────────────────────────────────────${C_RESET}"
    echo ""
}

info()    { echo -e "  ${C_BLUE}${SYM_ARROW}${C_RESET} $*"; }
success() { echo -e "  ${C_GREEN}${SYM_OK}${C_RESET} $*"; }
warn()    { echo -e "  ${C_YELLOW}!${C_RESET} $*"; }
fail()    { echo -e "  ${C_RED}${SYM_FAIL}${C_RESET} $*"; exit 1; }
step()    { echo -e "\n${C_WHITE}${C_BOLD}  [$1/$TOTAL_STEPS] $2${C_RESET}"; }

progress_bar() {
    local current=$1 total=$2 width=40
    local filled=$((current * width / total))
    local empty=$((width - filled))
    local bar=""
    for ((i=0; i<filled; i++)); do bar+="█"; done
    for ((i=0; i<empty; i++)); do bar+="░"; done
    printf "\r  ${C_CYAN}[${bar}]${C_RESET} ${C_BOLD}%3d%%${C_RESET}" $((current * 100 / total))
}

spin_exec() {
    local msg="$1"; shift
    local spinchars='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
    local tmplog
    tmplog="$(mktemp)"
    "$@" >"$tmplog" 2>&1 &
    local pid=$! i=0
    while kill -0 "$pid" 2>/dev/null; do
        printf "\r  ${C_CYAN}%s${C_RESET} %s" "${spinchars:i%${#spinchars}:1}" "$msg"
        i=$((i + 1))
        sleep 0.1
    done
    if wait "$pid"; then
        printf '\r\033[K'
        success "$msg"
    else
        printf '\r\033[K'
        fail "$msg ($(tail -3 "$tmplog"))"
    fi
    rm -f "$tmplog"
}

rand_hex() {
    openssl rand -hex "$1" 2>/dev/null || head -c $(( $1 * 2 )) /dev/urandom | xxd -p | head -c $(( $1 * 2 ))
}

# rand_password generates a value the identity bootstrap will accept. Plain hex is not
# enough: HashPassword requires an upper-case letter and a non-alphanumeric character.
# The classes are prepended deterministically so generation can never emit a value that
# fails validation; all of the entropy still comes from the hex part.
rand_password() {
    printf 'Aa1!%s' "$(rand_hex "${1:-16}")"
}

# password_is_valid mirrors internal/identity.HashPassword: at least 12 characters with an
# upper-case letter, a lower-case letter, and a non-alphanumeric character.
password_is_valid() {
    local value="$1"
    [[ ${#value} -ge 12 ]] || return 1
    [[ "$value" == *[[:upper:]]* ]] || return 1
    [[ "$value" == *[[:lower:]]* ]] || return 1
    [[ "$value" == *[^[:alnum:]]* ]] || return 1
    return 0
}

# resolve_admin_password reuses the management secret as the bootstrap admin password when
# it satisfies the identity policy, so the operator keeps the single credential they were
# shown during setup. A secret that fails the policy can never create the admin account,
# so a compliant one is generated instead — and reported at the end, otherwise the panel
# would be unreachable with a password nobody has seen.
resolve_admin_password() {
    if [[ -n "${CLIRELAY_ADMIN_PASSWORD:-}" ]]; then
        printf '%s' "${CLIRELAY_ADMIN_PASSWORD}"
    elif password_is_valid "${CFG_SECRET}"; then
        printf '%s' "${CFG_SECRET}"
    else
        rand_password 16
    fi
}

compose_cmd() {
    docker compose --env-file "${INSTALL_DIR}/.env" -f "${INSTALL_DIR}/docker-compose.yml" "$@"
}

http_code() {
    local url="$1"
    if command -v curl >/dev/null 2>&1; then
        curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$url" 2>/dev/null || echo "000"
        return
    fi
    if command -v wget >/dev/null 2>&1; then
        if wget -q -T 5 --spider "$url" >/dev/null 2>&1; then
            echo "200"
        else
            echo "000"
        fi
        return
    fi
    echo "000"
}

lang_tag() {
    if is_zh; then
        echo "zh_CN.UTF-8"
    else
        echo "en_US.UTF-8"
    fi
}

language_tag() {
    if is_zh; then
        echo "zh_CN:zh"
    else
        echo "en_US:en"
    fi
}

detect_arch() {
    local raw_arch
    raw_arch="$(uname -m)"
    case "$raw_arch" in
        x86_64|amd64)
            DOCKER_ARCH="amd64"
            DOCKER_PLATFORM="linux/amd64"
            ;;
        aarch64|arm64)
            DOCKER_ARCH="arm64"
            DOCKER_PLATFORM="linux/arm64"
            ;;
        *)
            if is_zh; then
                fail "暂不支持的 CPU 架构: ${raw_arch}。当前一键部署仅支持 amd64 和 arm64。"
            else
                fail "Unsupported CPU architecture: ${raw_arch}. One-click deployment currently supports amd64 and arm64 only."
            fi
            ;;
    esac
}

select_language() {
    if [[ -n "${SCRIPT_LOCALE}" ]]; then
        return
    fi
    set_locale "$(detect_default_locale)"
    if ! can_prompt; then
        return
    fi

    local current="${SCRIPT_LOCALE}"
    local default_choice="1"
    if [[ "$current" == "zh" ]]; then
        default_choice="2"
    fi

    echo ""
    echo -e "  ${C_BG_BLUE}${C_WHITE}${C_BOLD} Language / 语言 ${C_RESET}"
    echo ""
    echo "  1) English"
    echo "  2) 中文"
    choice="$(prompt_input "  $(echo -e "${C_CYAN}?${C_RESET}") Select language / 选择语言 [${default_choice}]: " "${default_choice}")" || choice="${default_choice}"
    case "${choice:-$default_choice}" in
        1|en|EN|english|English) set_locale "en" ;;
        2|zh|ZH|cn|CN|中文) set_locale "zh" ;;
        *) ;;
    esac
}

check_dependencies() {
    local missing=()
    if ! command -v bash >/dev/null 2>&1; then missing+=("bash"); fi
    if ! command -v sed >/dev/null 2>&1; then missing+=("sed"); fi
    if ! command -v awk >/dev/null 2>&1; then missing+=("awk"); fi
    if ! command -v grep >/dev/null 2>&1; then missing+=("grep"); fi
    if [[ ${#missing[@]} -gt 0 ]]; then
        if is_zh; then
            fail "缺少基础命令: ${missing[*]}"
        else
            fail "Missing required base commands: ${missing[*]}"
        fi
    fi
}

check_docker() {
    if ! command -v docker >/dev/null 2>&1; then
        if [[ "${OS_NAME}" == "Darwin" ]]; then
            if is_zh; then
                fail "macOS 不支持通过本脚本自动安装 Docker。请先安装并启动 Docker Desktop、OrbStack 或 Colima,然后重新运行安装脚本。"
            else
                fail "Automatic Docker installation is not supported on macOS. Please install and start Docker Desktop, OrbStack, or Colima first, then rerun the installer."
            fi
        fi

        if is_zh; then
            warn "Docker 未安装,开始自动安装..."
        else
            warn "Docker is not installed. Installing automatically..."
        fi
        if command -v curl >/dev/null 2>&1; then
            curl -fsSL https://get.docker.com | sh
        elif command -v wget >/dev/null 2>&1; then
            wget -qO- https://get.docker.com | sh
        else
            if is_zh; then
                fail "请先安装 curl 或 wget"
            else
                fail "Please install curl or wget first."
            fi
        fi
        if command -v systemctl >/dev/null 2>&1; then
            systemctl enable docker 2>/dev/null || true
            systemctl start docker 2>/dev/null || true
        fi
    fi

    if ! docker info >/dev/null 2>&1; then
        if [[ "${OS_NAME}" == "Darwin" ]]; then
            if is_zh; then
                fail "检测到 Docker 命令,但 Docker 引擎未启动。请先启动 Docker Desktop、OrbStack 或 Colima。"
            else
                fail "Docker CLI is installed, but the Docker engine is not running. Please start Docker Desktop, OrbStack, or Colima first."
            fi
        fi
        if is_zh; then
            fail "Docker 已安装,但 Docker daemon 未启动。请先启动 Docker 服务后重试。"
        else
            fail "Docker is installed, but the Docker daemon is not running. Please start Docker and try again."
        fi
    fi

    if ! docker compose version >/dev/null 2>&1; then
        if [[ "${OS_NAME}" == "Darwin" ]]; then
            if is_zh; then
                fail "当前 Docker 环境缺少 docker compose。请升级 Docker Desktop/OrbStack,或使用 Homebrew 安装 docker-compose 后重试。"
            else
                fail "Your Docker environment does not provide docker compose. Upgrade Docker Desktop/OrbStack or install docker-compose with Homebrew, then retry."
            fi
        fi
        if is_zh; then
            warn "Docker Compose 插件未安装,开始自动安装..."
        else
            warn "Docker Compose plugin is missing. Installing automatically..."
        fi
        local plugin_dir
        plugin_dir="${DOCKER_CONFIG:-${HOME}/.docker}/cli-plugins"
        mkdir -p "$plugin_dir"
        local plugin_arch
        case "$DOCKER_ARCH" in
            amd64) plugin_arch="x86_64" ;;
            arm64) plugin_arch="aarch64" ;;
            *)
                plugin_arch="$DOCKER_ARCH"
                ;;
        esac
        local plugin_url="https://github.com/docker/compose/releases/latest/download/docker-compose-linux-${plugin_arch}"
        if command -v curl >/dev/null 2>&1; then
            curl -fsSL "$plugin_url" -o "${plugin_dir}/docker-compose"
        else
            wget -qO "${plugin_dir}/docker-compose" "$plugin_url"
        fi
        chmod +x "${plugin_dir}/docker-compose"
    fi

    if is_zh; then
        success "Docker 环境已就绪 ($(docker --version | awk '{print $3}' | tr -d ','), ${DOCKER_PLATFORM})"
    else
        success "Docker is ready ($(docker --version | awk '{print $3}' | tr -d ','), ${DOCKER_PLATFORM})"
    fi
}

extract_port() {
    grep -E '^port:' "${INSTALL_DIR}/config.yaml" 2>/dev/null | awk '{print $2}' | head -1
}

extract_secret() {
    grep -E 'secret-key:' "${INSTALL_DIR}/config.yaml" 2>/dev/null | head -1 | sed -E 's/.*secret-key:[[:space:]]*"?(.*)"?/\1/' | tr -d '"'
}

extract_api_key() {
    grep -E '^ *- "sk-' "${INSTALL_DIR}/config.yaml" 2>/dev/null | head -1 | sed -E 's/.*"(.*)".*/\1/'
}

load_existing_state() {
    if [[ -f "${INSTALL_DIR}/.env" ]]; then
        # shellcheck disable=SC1090
        set -a && . "${INSTALL_DIR}/.env" && set +a
        if [[ -n "${CLIRELAY_LOCALE:-}" ]]; then
            set_locale "${CLIRELAY_LOCALE}"
        fi
        if [[ -n "${CLI_PROXY_PLATFORM:-}" ]]; then
            DOCKER_PLATFORM="${CLI_PROXY_PLATFORM}"
        fi
    fi

    local existing_port existing_secret existing_api_key
    existing_port="$(extract_port || true)"
    existing_secret="$(extract_secret || true)"
    existing_api_key="$(extract_api_key || true)"

    if [[ -n "$existing_port" ]]; then CFG_PORT="$existing_port"; fi
    if [[ -n "$existing_secret" ]]; then CFG_SECRET="$existing_secret"; fi
    if [[ -n "$existing_api_key" ]]; then CFG_API_KEY="$existing_api_key"; fi
}

prompt_config() {
    require_interactive_config
    echo ""
    if is_zh; then
        echo -e "  ${C_BG_BLUE}${C_WHITE}${C_BOLD} 服务配置 ${C_RESET}"
    else
        echo -e "  ${C_BG_BLUE}${C_WHITE}${C_BOLD} Service Configuration ${C_RESET}"
    fi
    echo ""

    local port_prompt secret_prompt key_prompt remote_prompt
    if is_zh; then
        port_prompt="服务端口"
        secret_prompt="管理面板密钥 (留空自动生成)"
        key_prompt="客户端 API Key (留空自动生成)"
        remote_prompt="允许远程管理? [Y/n]"
    else
        port_prompt="Service port"
        secret_prompt="Management secret (leave blank to auto-generate)"
        key_prompt="Client API key (leave blank to auto-generate)"
        remote_prompt="Allow remote management? [Y/n]"
    fi

    CFG_PORT="$(prompt_input "  $(echo -e "${C_CYAN}?${C_RESET}") ${port_prompt} [${DEFAULT_PORT}]: " "${DEFAULT_PORT}")" || CFG_PORT="${DEFAULT_PORT}"
    CFG_PORT="${CFG_PORT:-$DEFAULT_PORT}"

    CFG_SECRET="$(prompt_input "  $(echo -e "${C_CYAN}?${C_RESET}") ${secret_prompt}: " "")" || CFG_SECRET=""
    if [[ -z "$CFG_SECRET" ]]; then
        # rand_password rather than rand_hex: this value doubles as the bootstrap admin
        # password, which must satisfy the identity character-class policy.
        CFG_SECRET="$(rand_password 16)"
        if is_zh; then
            echo -e "  ${C_DIM}  已生成: ${CFG_SECRET}${C_RESET}"
        else
            echo -e "  ${C_DIM}  Generated: ${CFG_SECRET}${C_RESET}"
        fi
    fi

    CFG_API_KEY="$(prompt_input "  $(echo -e "${C_CYAN}?${C_RESET}") ${key_prompt}: " "")" || CFG_API_KEY=""
    if [[ -z "$CFG_API_KEY" ]]; then
        CFG_API_KEY="sk-$(rand_hex 16)"
        if is_zh; then
            echo -e "  ${C_DIM}  已生成: ${CFG_API_KEY}${C_RESET}"
        else
            echo -e "  ${C_DIM}  Generated: ${CFG_API_KEY}${C_RESET}"
        fi
    fi

    yn="$(prompt_input "  $(echo -e "${C_CYAN}?${C_RESET}") ${remote_prompt} " "")" || yn=""
    case "$yn" in
        [Nn]*) CFG_REMOTE="false" ;;
        *) CFG_REMOTE="true" ;;
    esac
}

write_config() {
    cat >"${INSTALL_DIR}/config.yaml" <<YAML
# CliRelay configuration file - generated by install.sh
host: ""
port: ${CFG_PORT}

redis:
  enable: true
  addr: redis:6379

remote-management:
  allow-remote: ${CFG_REMOTE}
  secret-key: "${CFG_SECRET}"
  disable-control-panel: false

auto-update:
  enabled: true
  channel: main
  repository: https://github.com/kittors/CliRelay
  docker-image: ghcr.io/kittors/clirelay
  updater-url: http://clirelay-updater:8320

auth-dir: "/root/.cli-proxy-api"

api-keys:
  - "${CFG_API_KEY}"

debug: false
logging-to-file: true
logs-max-total-size-mb: 100
usage-statistics-enabled: true
request-retry: 3
max-retry-interval: 30
routing:
  strategy: "round-robin"
YAML
}

write_env() {
    local updater_token admin_password postgres_db postgres_user postgres_password postgres_dsn postgres_data_path redis_addr redis_password redis_db redis_data_path
    updater_token="${CLIRELAY_UPDATER_TOKEN:-$(rand_hex 16)}"
    # Without this the identity bootstrap falls back to remote-management.secret-key,
    # which is empty in the generated config, so a fresh install cannot create its
    # first admin and the container crash-loops on startup.
    CFG_ADMIN_PASSWORD="$(resolve_admin_password)"
    admin_password="${CFG_ADMIN_PASSWORD}"
    postgres_db="${CLIRELAY_POSTGRES_DB:-cliproxy}"
    postgres_user="${CLIRELAY_POSTGRES_USER:-cliproxy}"
    postgres_password="${CLIRELAY_POSTGRES_PASSWORD:-$(rand_hex 16)}"
    postgres_dsn="${CLIRELAY_POSTGRES_DSN:-postgres://${postgres_user}:${postgres_password}@postgres:5432/${postgres_db}?sslmode=disable}"
    postgres_data_path="${CLIRELAY_POSTGRES_DATA_PATH:-${INSTALL_DIR}/postgres-data}"
    redis_addr="${CLIRELAY_REDIS_ADDR:-redis:6379}"
    redis_password="${CLIRELAY_REDIS_PASSWORD:-}"
    redis_db="${CLIRELAY_REDIS_DB:-0}"
    redis_data_path="${CLIRELAY_REDIS_DATA_PATH:-${INSTALL_DIR}/redis-data}"
    cat >"${INSTALL_DIR}/.env" <<EOF
CLI_PROXY_IMAGE=${CLI_PROXY_IMAGE:-$DOCKER_IMAGE_DEFAULT}
CLI_PROXY_PLATFORM=${DOCKER_PLATFORM}
CLIRELAY_CONTAINER_NAME=${CONTAINER_NAME}
CLIRELAY_INSTALL_DIR=${INSTALL_DIR}
CLIRELAY_LOCALE=${SCRIPT_LOCALE}
CLIRELAY_LANG=$(lang_tag)
CLIRELAY_LANGUAGE=$(language_tag)
CLIRELAY_PORT=${CFG_PORT}
CLIRELAY_UPDATE_CHANNEL=main
CLIRELAY_UPDATER_URL=http://clirelay-updater:8320
CLIRELAY_UPDATER_TOKEN=${updater_token}
CLIRELAY_ADMIN_PASSWORD=${admin_password}
CLIRELAY_TARGET_SERVICE=clirelay
CLIRELAY_COMPOSE_PROJECT_NAME=$(basename "${INSTALL_DIR}")
CLIRELAY_POSTGRES_DB=${postgres_db}
CLIRELAY_POSTGRES_USER=${postgres_user}
CLIRELAY_POSTGRES_PASSWORD=${postgres_password}
CLIRELAY_POSTGRES_DSN=${postgres_dsn}
CLIRELAY_POSTGRES_DATA_PATH=${postgres_data_path}
CLIRELAY_REDIS_ENABLE=true
CLIRELAY_REDIS_ADDR=${redis_addr}
CLIRELAY_REDIS_PASSWORD=${redis_password}
CLIRELAY_REDIS_DB=${redis_db}
CLIRELAY_REDIS_DATA_PATH=${redis_data_path}
CLI_PROXY_CONFIG_PATH=${INSTALL_DIR}/config.yaml
CLI_PROXY_AUTH_PATH=${INSTALL_DIR}/auths
AUTH_PATH=/root/.cli-proxy-api
CLI_PROXY_LOG_PATH=${INSTALL_DIR}/logs
CLI_PROXY_DATA_PATH=${INSTALL_DIR}/data
TZ=${TZ_VALUE}
EOF
}

write_compose() {
    cat >"${INSTALL_DIR}/docker-compose.yml" <<'YAML'
services:
  clirelay:
    image: ${CLI_PROXY_IMAGE}
    platform: ${CLI_PROXY_PLATFORM}
    container_name: ${CLIRELAY_CONTAINER_NAME}
    pull_policy: always
    ports:
      - "${CLIRELAY_PORT}:${CLIRELAY_PORT}"
    volumes:
      - ${CLI_PROXY_CONFIG_PATH}:/CLIProxyAPI/config.yaml
      - ${CLI_PROXY_AUTH_PATH}:${AUTH_PATH}
      - ${CLI_PROXY_LOG_PATH}:/CLIProxyAPI/logs
      - ${CLI_PROXY_DATA_PATH}:/CLIProxyAPI/data
    environment:
      TZ: ${TZ}
      CLIRELAY_LOCALE: ${CLIRELAY_LOCALE}
      CLIRELAY_UPDATE_CHANNEL: ${CLIRELAY_UPDATE_CHANNEL}
      CLIRELAY_UPDATER_URL: ${CLIRELAY_UPDATER_URL}
      CLIRELAY_UPDATER_TOKEN: ${CLIRELAY_UPDATER_TOKEN}
      CLIRELAY_ADMIN_PASSWORD: ${CLIRELAY_ADMIN_PASSWORD}
      CLIRELAY_TARGET_SERVICE: ${CLIRELAY_TARGET_SERVICE}
      CLIRELAY_POSTGRES_DSN: ${CLIRELAY_POSTGRES_DSN}
      CLIRELAY_REDIS_ENABLE: ${CLIRELAY_REDIS_ENABLE}
      CLIRELAY_REDIS_ADDR: ${CLIRELAY_REDIS_ADDR}
      CLIRELAY_REDIS_PASSWORD: ${CLIRELAY_REDIS_PASSWORD}
      CLIRELAY_REDIS_DB: ${CLIRELAY_REDIS_DB}
      AUTH_PATH: ${AUTH_PATH}
      LANG: ${CLIRELAY_LANG}
      LANGUAGE: ${CLIRELAY_LANGUAGE}
      LC_ALL: ${CLIRELAY_LANG}
      PORT: ${CLIRELAY_PORT}
    healthcheck:
      test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:${CLIRELAY_PORT}/ >/dev/null 2>&1 || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 20s
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped

  clirelay-updater:
    image: ${CLI_PROXY_IMAGE}
    platform: ${CLI_PROXY_PLATFORM}
    command: ["./clirelay-updater"]
    environment:
      CLIRELAY_UPDATER_TOKEN: ${CLIRELAY_UPDATER_TOKEN}
      CLIRELAY_COMPOSE_FILE: ${CLIRELAY_INSTALL_DIR}/docker-compose.yml
      CLIRELAY_ENV_FILE: ${CLIRELAY_INSTALL_DIR}/.env
      CLIRELAY_COMPOSE_PROJECT_NAME: ${CLIRELAY_COMPOSE_PROJECT_NAME}
      CLIRELAY_TARGET_SERVICE: ${CLIRELAY_TARGET_SERVICE}
      CLIRELAY_UPDATER_STATE_FILE: ${CLIRELAY_INSTALL_DIR}/.clirelay-updater-status.json
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - .:${CLIRELAY_INSTALL_DIR}
    healthcheck:
      disable: true
    restart: unless-stopped

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: ${CLIRELAY_POSTGRES_DB}
      POSTGRES_USER: ${CLIRELAY_POSTGRES_USER}
      POSTGRES_PASSWORD: ${CLIRELAY_POSTGRES_PASSWORD}
    volumes:
      - ${CLIRELAY_POSTGRES_DATA_PATH}:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 20
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - ${CLIRELAY_REDIS_DATA_PATH}:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 20
    restart: unless-stopped
YAML
}

install_helper_command() {
    local target
    if [[ -d "/usr/local/bin" && -w "/usr/local/bin" ]]; then
        target="/usr/local/bin/clirelay"
    elif [[ ! -e "/usr/local/bin" ]] && mkdir -p /usr/local/bin 2>/dev/null; then
        target="/usr/local/bin/clirelay"
    else
        target="${HOME}/.local/bin/clirelay"
        mkdir -p "$(dirname "$target")"
    fi

    cat >"$target" <<EOF
#!/usr/bin/env bash
set -euo pipefail

INSTALL_DIR="${INSTALL_DIR}"
ENV_FILE="\${INSTALL_DIR}/.env"
COMPOSE_FILE="\${INSTALL_DIR}/docker-compose.yml"
CONFIG_FILE="\${INSTALL_DIR}/config.yaml"

if [[ ! -f "\${COMPOSE_FILE}" ]] || [[ ! -f "\${ENV_FILE}" ]]; then
  echo "CliRelay is not installed at \${INSTALL_DIR}." >&2
  exit 1
fi

normalize_locale() {
  local raw
  raw="\$(printf '%s' "\${1:-}" | tr '[:upper:]' '[:lower:]')"
  case "\$raw" in
    zh|zh-cn|zh_cn|cn|chinese) echo "zh" ;;
    en|en-us|en_us|en-gb|en_gb|english) echo "en" ;;
    *) echo "" ;;
  esac
}

load_env() {
  # shellcheck disable=SC1090
  set -a && . "\${ENV_FILE}" && set +a
  CLIRELAY_LOCALE="\$(normalize_locale "\${CLIRELAY_LOCALE:-en}")"
  if [[ -z "\${CLIRELAY_LOCALE}" ]]; then
    CLIRELAY_LOCALE="en"
  fi
}

is_zh() {
  [[ "\${CLIRELAY_LOCALE}" == "zh" ]]
}

say() {
  local zh_text="\$1"
  local en_text="\$2"
  if is_zh; then
    printf '%s\n' "\$zh_text"
  else
    printf '%s\n' "\$en_text"
  fi
}

compose() {
  docker compose --env-file "\${ENV_FILE}" -f "\${COMPOSE_FILE}" "\$@"
}

extract_port() {
  grep -E '^port:' "\${CONFIG_FILE}" 2>/dev/null | awk '{print \$2}' | head -1
}

extract_secret() {
  grep -E 'secret-key:' "\${CONFIG_FILE}" 2>/dev/null | head -1 | sed -E 's/.*secret-key:[[:space:]]*"?(.*)"?/\1/' | tr -d '"'
}

http_code() {
  local url="\$1"
  if command -v curl >/dev/null 2>&1; then
    curl -s -o /dev/null -w "%{http_code}" --max-time 5 "\$url" 2>/dev/null || echo "000"
    return
  fi
  if command -v wget >/dev/null 2>&1; then
    if wget -q -T 5 --spider "\$url" >/dev/null 2>&1; then
      echo "200"
    else
      echo "000"
    fi
    return
  fi
  echo "000"
}

status_cmd() {
  load_env
  local port secret root_code panel_code latest
  port="\$(extract_port)"
  port="\${port:-\${CLIRELAY_PORT:-8317}}"
  secret="\$(extract_secret)"
  root_code="\$(http_code "http://127.0.0.1:\${port}/")"
  panel_code="\$(http_code "http://127.0.0.1:\${port}/manage")"

  say "CliRelay 部署状态" "CliRelay deployment status"
  echo "  Install Dir : \${INSTALL_DIR}"
  echo "  Image       : \${CLI_PROXY_IMAGE}"
  echo "  Platform    : \${CLI_PROXY_PLATFORM}"
  echo "  Locale      : \${CLIRELAY_LOCALE}"
  echo "  Port        : \${port}"
  echo "  Container   : \${CLIRELAY_CONTAINER_NAME}"
  echo "  API /       : HTTP \${root_code}"
  echo "  Panel /manage: HTTP \${panel_code}"

  if docker inspect "\${CLIRELAY_CONTAINER_NAME}" >/dev/null 2>&1; then
    echo "  Runtime     : \$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "\${CLIRELAY_CONTAINER_NAME}")"
    echo "  Started At  : \$(docker inspect -f '{{.State.StartedAt}}' "\${CLIRELAY_CONTAINER_NAME}")"
  else
    say "  Runtime     : 容器不存在" "  Runtime     : container not found"
  fi

  if [[ -n "\$secret" ]] && command -v curl >/dev/null 2>&1; then
    latest="\$(curl -fsSL -H "Authorization: Bearer \$secret" "http://127.0.0.1:\${port}/v0/management/latest-version" 2>/dev/null | sed -n 's/.*"latest-version":"\([^"]*\)".*/\1/p' | head -1 || true)"
    if [[ -n "\$latest" ]]; then
      echo "  Latest      : \${latest}"
    fi
  fi

  echo ""
  compose ps || true
}

update_cmd() {
  load_env
  say "正在拉取最新镜像并更新服务..." "Pulling the latest image and updating the service..."
  compose pull
  compose up -d --remove-orphans
  status_cmd
}

restart_cmd() {
  load_env
  say "正在重启服务..." "Restarting service..."
  compose restart
  status_cmd
}

logs_cmd() {
  load_env
  compose logs -f --tail=200
}

tui_cmd() {
  load_env
  local secret
  secret="\$(extract_secret)"
  if [[ -z "\$secret" ]]; then
    say "无法启动 TUI:未找到 management secret。" "Unable to start TUI: management secret not found."
    exit 1
  fi
  exec docker exec -e CLIRELAY_LOCALE="\${CLIRELAY_LOCALE}" -it "\${CLIRELAY_CONTAINER_NAME}" /CLIProxyAPI/CLIProxyAPI -config /CLIProxyAPI/config.yaml -password "\${secret}" -tui
}

help_cmd() {
  load_env
  say "用法: clirelay <status|update|restart|logs|tui>" "Usage: clirelay <status|update|restart|logs|tui>"
}

case "\${1:-status}" in
  status) status_cmd ;;
  update) update_cmd ;;
  restart) restart_cmd ;;
  logs) logs_cmd ;;
  tui) tui_cmd ;;
  help|--help|-h) help_cmd ;;
  *)
    help_cmd
    exit 1
    ;;
esac
EOF

    chmod +x "$target"
    HELPER_PATH="$target"
}

wait_for_ready() {
    local ready=false
    local i
    for i in $(seq 1 30); do
        progress_bar "$i" 30
        if [[ "$(http_code "http://127.0.0.1:${CFG_PORT}/")" == "200" ]]; then
            ready=true
            break
        fi
        sleep 1
    done
    echo ""
    [[ "$ready" == "true" ]]
}

show_result() {
    local public_ip root_code panel_code
    if command -v curl >/dev/null 2>&1; then
        public_ip="$(curl -s --max-time 5 https://api.ipify.org 2>/dev/null || true)"
    elif command -v wget >/dev/null 2>&1; then
        public_ip="$(wget -qO- --timeout=5 https://api.ipify.org 2>/dev/null || true)"
    else
        public_ip=""
    fi
    public_ip="${public_ip:-YOUR_SERVER_IP}"
    root_code="$(http_code "http://127.0.0.1:${CFG_PORT}/")"
    panel_code="$(http_code "http://127.0.0.1:${CFG_PORT}/manage")"

    echo ""
    if is_zh; then
        echo -e "  ${C_BG_GREEN}${C_WHITE}${C_BOLD} 部署完成 ${C_RESET}"
        echo ""
        echo -e "  ${C_GREEN}${SYM_STAR}${C_RESET} ${C_BOLD}CliRelay 已成功部署${C_RESET}"
    else
        echo -e "  ${C_BG_GREEN}${C_WHITE}${C_BOLD} Deployment Complete ${C_RESET}"
        echo ""
        echo -e "  ${C_GREEN}${SYM_STAR}${C_RESET} ${C_BOLD}CliRelay has been deployed successfully${C_RESET}"
    fi
    echo ""
    echo "  Install Dir : ${INSTALL_DIR}"
    echo "  Image       : ${CLI_PROXY_IMAGE:-$DOCKER_IMAGE_DEFAULT}"
    echo "  Platform    : ${DOCKER_PLATFORM}"
    echo "  Locale      : ${SCRIPT_LOCALE}"
    echo "  API         : http://${public_ip}:${CFG_PORT}/v1/chat/completions"
    echo "  Panel       : http://${public_ip}:${CFG_PORT}/manage"
    # The panel admin account is created from this value on first start; without printing
    # it, an operator whose management secret failed the password policy has no way in.
    if [[ -n "${CFG_ADMIN_PASSWORD}" ]]; then
        if [[ "${CFG_ADMIN_PASSWORD}" == "${CFG_SECRET}" ]]; then
            echo "  Panel login : admin / <the management key above>"
        else
            echo "  Panel login : admin / ${CFG_ADMIN_PASSWORD}"
        fi
    fi
    echo "  API /       : HTTP ${root_code}"
    echo "  Panel /manage: HTTP ${panel_code}"
    echo "  Helper      : ${HELPER_PATH}"
    echo ""
    if is_zh; then
        echo -e "  ${C_DIM}常用命令:${C_RESET}"
        echo -e "    ${C_YELLOW}clirelay status${C_RESET}   查看部署状态"
        echo -e "    ${C_YELLOW}clirelay update${C_RESET}   拉取最新镜像并更新"
        echo -e "    ${C_YELLOW}clirelay restart${C_RESET}  重启服务"
        echo -e "    ${C_YELLOW}clirelay logs${C_RESET}     查看实时日志"
        echo -e "    ${C_YELLOW}clirelay tui${C_RESET}      进入容器内 TUI(语言随部署配置)"
    else
        echo -e "  ${C_DIM}Common commands:${C_RESET}"
        echo -e "    ${C_YELLOW}clirelay status${C_RESET}   Show deployment status"
        echo -e "    ${C_YELLOW}clirelay update${C_RESET}   Pull the latest image and update"
        echo -e "    ${C_YELLOW}clirelay restart${C_RESET}  Restart the service"
        echo -e "    ${C_YELLOW}clirelay logs${C_RESET}     Tail service logs"
        echo -e "    ${C_YELLOW}clirelay tui${C_RESET}      Open the container TUI in the selected language"
    fi
    echo ""
}

uninstall() {
    select_language
    banner
    if is_zh; then
        echo -e "  ${C_BG_RED}${C_WHITE}${C_BOLD} 卸载 CliRelay ${C_RESET}"
        yn="$(prompt_input "  $(echo -e "${C_RED}?${C_RESET}") 确认卸载? 配置和数据将被删除 [y/N]: " "N")" || yn="N"
    else
        echo -e "  ${C_BG_RED}${C_WHITE}${C_BOLD} Uninstall CliRelay ${C_RESET}"
        yn="$(prompt_input "  $(echo -e "${C_RED}?${C_RESET}") Confirm uninstall? Configuration and data will be deleted [y/N]: " "N")" || yn="N"
    fi
    case "$yn" in
        [Yy]*)
            if [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] && command -v docker >/dev/null 2>&1; then
                compose_cmd down --remove-orphans 2>/dev/null || true
            fi
            rm -rf "${INSTALL_DIR}"
            rm -f "/usr/local/bin/clirelay" "${HOME}/.local/bin/clirelay" 2>/dev/null || true
            if is_zh; then
                success "CliRelay 已完全卸载"
            else
                success "CliRelay has been removed completely"
            fi
            ;;
        *)
            if is_zh; then
                info "取消卸载"
            else
                info "Uninstall cancelled"
            fi
            ;;
    esac
}

main() {
    check_dependencies
    select_language
    detect_arch
    banner

    if is_zh; then
        info "系统: $(uname -s)/$(uname -m)"
    else
        info "System: $(uname -s)/$(uname -m)"
    fi

    local is_update=false
    if [[ -d "${INSTALL_DIR}" ]] && [[ -f "${INSTALL_DIR}/docker-compose.yml" ]]; then
        load_existing_state
        if [[ -z "${SCRIPT_LOCALE}" ]]; then
            set_locale "$(detect_default_locale)"
        fi
        banner
        if is_zh; then
            warn "检测到已有安装: ${INSTALL_DIR}"
            choice="$(prompt_input "  $(echo -e "${C_CYAN}?${C_RESET}") 选择操作 [1=更新 2=重装 3=取消]: " "1")" || choice="1"
        else
            warn "Existing installation detected at ${INSTALL_DIR}"
            choice="$(prompt_input "  $(echo -e "${C_CYAN}?${C_RESET}") Choose action [1=update 2=reinstall 3=cancel]: " "1")" || choice="1"
        fi
        case "$choice" in
            1) is_update=true ;;
            3)
                if is_zh; then
                    info "已取消"
                else
                    info "Cancelled"
                fi
                exit 0
                ;;
            *) is_update=false ;;
        esac
    fi

    step 1 "$(is_zh && echo "检查架构与基础命令" || echo "Checking architecture and base commands")"
    if is_zh; then
        success "已识别部署平台: ${DOCKER_PLATFORM}"
    else
        success "Detected deployment platform: ${DOCKER_PLATFORM}"
    fi

    step 2 "$(is_zh && echo "检查 Docker 环境" || echo "Checking Docker environment")"
    check_docker

    step 3 "$(is_zh && echo "准备配置与部署元数据" || echo "Preparing configuration and deployment metadata")"
    mkdir -p "${INSTALL_DIR}" "${INSTALL_DIR}/auths" "${INSTALL_DIR}/logs" "${INSTALL_DIR}/data" "${INSTALL_DIR}/postgres-data" "${INSTALL_DIR}/redis-data"
    if [[ "${OS_NAME}" != "Darwin" ]]; then
        chown -R 10001:10001 "${INSTALL_DIR}/auths" "${INSTALL_DIR}/logs" "${INSTALL_DIR}/data" || warn "Could not chown data directories; the container entrypoint will retry at startup."
    fi
    if [[ "$is_update" == "true" ]]; then
        if is_zh; then
            success "保留现有配置: ${INSTALL_DIR}/config.yaml"
        else
            success "Keeping existing configuration: ${INSTALL_DIR}/config.yaml"
        fi
    else
        prompt_config
        write_config
        if is_zh; then
            success "已写入配置文件"
        else
            success "Configuration file written"
        fi
    fi
    if [[ "${OS_NAME}" != "Darwin" ]] && [[ -f "${INSTALL_DIR}/config.yaml" ]]; then
        chown 10001:10001 "${INSTALL_DIR}/config.yaml" || warn "Could not chown config.yaml; management config writes may require the container entrypoint to repair it."
    fi
    write_env
    write_compose
    install_helper_command

    step 4 "$(is_zh && echo "拉取 Docker 镜像" || echo "Pulling Docker image")"
    spin_exec "${CLI_PROXY_IMAGE:-$DOCKER_IMAGE_DEFAULT}" docker pull "${CLI_PROXY_IMAGE:-$DOCKER_IMAGE_DEFAULT}"

    step 5 "$(is_zh && echo "启动或更新服务" || echo "Starting or updating service")"
    if [[ "$is_update" == "true" ]]; then
        compose_cmd down --remove-orphans 2>/dev/null || true
    fi
    compose_cmd up -d --remove-orphans
    if is_zh; then
        info "等待服务就绪..."
    else
        info "Waiting for the service to become ready..."
    fi
    if wait_for_ready; then
        if is_zh; then
            success "服务已启动并就绪"
        else
            success "Service is up and ready"
        fi
    else
        if is_zh; then
            warn "服务未在预期时间内完成启动,可稍后执行 clirelay status / clirelay logs 检查。"
        else
            warn "Service did not become ready in time. Run clirelay status or clirelay logs to inspect it."
        fi
    fi

    step 6 "$(is_zh && echo "验证部署结果" || echo "Verifying deployment result")"
    show_result
}

case "${1:-}" in
    --uninstall|uninstall) uninstall ;;
    --help|-h|help)
        set_locale "$(detect_default_locale)"
        banner
        if is_zh; then
            echo "  用法:"
            echo "    bash install.sh              安装或更新"
            echo "    bash install.sh uninstall    卸载"
            echo ""
            echo "  一键安装:"
            echo "    curl -fsSL https://raw.githubusercontent.com/kittors/CliRelay/main/install.sh | bash"
        else
            echo "  Usage:"
            echo "    bash install.sh              Install or update"
            echo "    bash install.sh uninstall    Uninstall"
            echo ""
            echo "  One-click install:"
            echo "    curl -fsSL https://raw.githubusercontent.com/kittors/CliRelay/main/install.sh | bash"
        fi
        ;;
    *)
        set_locale "$(detect_default_locale)"
        main
        ;;
esac