#!/usr/bin/env bash set -euo pipefail PREFIX="[gorseecode]" INSTALLER_TITLE="GorseeCode" INSTALLER_VERSION="3.0.0" MIN_DOCKER_VERSION="24.0.0" MIN_COMPOSE_VERSION="2.20.0" MIN_CADDY_VERSION="2.7.0" CONTAINER_NAME="gorseecode" IMAGE_TAG="registry.gorseecode.ru/gorseecode:latest" DOCKER_NETWORK="gorseecode" LICENSE_HOST="${GORSEECODE_LICENSE_HOST:-gorseecode.ru}" INSTALL_BASE_DIR="/opt/gorseecode" PROJECTS_DIR_DEFAULT="${INSTALL_BASE_DIR}/projects" DATA_DIR_DEFAULT="${INSTALL_BASE_DIR}/data" CADDYFILE_PATH="/etc/caddy/Caddyfile" CADDY_BLOCK_BEGIN="# BEGIN GORSEECODE" CADDY_BLOCK_END="# END GORSEECODE" DRY_RUN=0 DOMAIN="" VERBOSE=0 INSTALL_LOG_FILE="" USE_GUM=0 GUM_BIN="" GUM_VERSION="0.14.0" PKG_MANAGER="unknown" APP_PORT="" PROXY_PORT="" ACCESS_URL="" APP_HEALTH_URL="" PROJECT_ROOT="" PROGRESS_BAR=0 USER_EMAIL="" PANEL_USERNAME="admin" PANEL_PASSWORD="" STEP=0 TOTAL_STEPS=11 # ── Colors ────────────────────────────────────────────── # Fallback for unknown TERM (e.g. xterm-ghostty) if ! tput colors >/dev/null 2>&1; then export TERM=xterm-256color fi supports_color() { [[ -t 1 ]] && command -v tput >/dev/null && [[ "$(tput colors 2>/dev/null)" -ge 8 ]] } if supports_color; then C_RESET="$(tput sgr0)" C_BOLD="$(tput bold)" C_RED="$(tput setaf 1)" C_GREEN="$(tput setaf 2)" C_YELLOW="$(tput setaf 3)" C_BLUE="$(tput setaf 4)" C_MAGENTA="$(tput setaf 5)" C_CYAN="$(tput setaf 6)" C_DIM="$(tput dim)" else C_RESET="" C_BOLD="" C_RED="" C_GREEN="" C_YELLOW="" C_BLUE="" C_MAGENTA="" C_CYAN="" C_DIM="" fi supports_tty_ui() { [[ -t 1 ]]; } # ── Output (suppressed during progress bar) ───────────── info() { if [[ "$PROGRESS_BAR" -eq 1 ]]; then printf '[INFO] %s\n' "$*" >> "$INSTALL_LOG_FILE" 2>/dev/null || true; return fi printf '%s %s\n' "${PREFIX}" "$*" } warn() { if [[ "$PROGRESS_BAR" -eq 1 ]]; then printf '[WARN] %s\n' "$*" >> "$INSTALL_LOG_FILE" 2>/dev/null || true; return fi printf '%s %s[WARN]%s %s\n' "${PREFIX}" "${C_YELLOW}" "${C_RESET}" "$*" } ok() { if [[ "$PROGRESS_BAR" -eq 1 ]]; then printf '[ OK ] %s\n' "$*" >> "$INSTALL_LOG_FILE" 2>/dev/null || true; return fi printf '%s %s[ OK ]%s %s\n' "${PREFIX}" "${C_GREEN}" "${C_RESET}" "$*" } error() { printf '\n%s %s[ERROR]%s %s\n' "${PREFIX}" "${C_RED}" "${C_RESET}" "$*" >&2 } die() { [[ "$PROGRESS_BAR" -eq 1 ]] && printf '\n' >&2 error "$*" if [[ -n "${INSTALL_LOG_FILE:-}" && -f "${INSTALL_LOG_FILE:-}" ]]; then printf '%s Лог: %s\n' "${PREFIX}" "${INSTALL_LOG_FILE}" >&2 tail -n 30 "$INSTALL_LOG_FILE" >&2 || true fi exit 1 } # ── Progress bar ──────────────────────────────────────── render_progress() { local current=$1 total=$2 label="$3" local width=30 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\033[2K %s%s%s %s%d/%d%s %s' \ "${C_CYAN}" "$bar" "${C_RESET}" \ "${C_DIM}" "$current" "$total" "${C_RESET}" \ "$label" } # ── Gum ───────────────────────────────────────────────── detect_tui_backend() { if supports_tty_ui && command -v gum >/dev/null; then USE_GUM=1 GUM_BIN="$(command -v gum)" fi } install_gum_from_release() { local arch tarball url tmp_dir archive arch="$(uname -m)" case "$arch" in x86_64|amd64) arch="x86_64" ;; aarch64|arm64) arch="arm64" ;; *) die "Unsupported arch for gum: ${arch}" ;; esac tarball="gum_${GUM_VERSION}_Linux_${arch}.tar.gz" url="https://github.com/charmbracelet/gum/releases/download/v${GUM_VERSION}/${tarball}" tmp_dir="$(mktemp -d)" archive="${tmp_dir}/${tarball}" if ! curl -fsSL "$url" -o "$archive"; then rm -rf "$tmp_dir" die "Failed to download gum: ${url}" fi tar -xzf "$archive" -C "$tmp_dir" local gum_bin="" if [[ -f "${tmp_dir}/gum" ]]; then gum_bin="${tmp_dir}/gum" else gum_bin="$(find "$tmp_dir" -name 'gum' -type f ! -name '*.tar.gz' | head -1)" fi [[ -n "$gum_bin" && -f "$gum_bin" ]] || { rm -rf "$tmp_dir"; die "gum binary not found in archive"; } as_root install -m 0755 "$gum_bin" /usr/local/bin/gum rm -rf "$tmp_dir" } ensure_gum() { if command -v gum >/dev/null; then USE_GUM=1; GUM_BIN="$(command -v gum)"; return 0 fi if [[ "$DRY_RUN" -eq 1 ]]; then return 0; fi need_root_tooling case "$PKG_MANAGER" in nix) run_shell "nix profile install nixpkgs#gum" ;; pacman) as_root pacman -Sy --noconfirm --needed gum || install_gum_from_release ;; dnf) as_root dnf install -y gum || install_gum_from_release ;; apt) as_root apt-get update -qq; as_root apt-get install -y -qq gum || install_gum_from_release ;; *) install_gum_from_release ;; esac command -v gum >/dev/null || die "gum installation failed" USE_GUM=1 GUM_BIN="$(command -v gum)" } # ── Command runners ───────────────────────────────────── init_log_file() { INSTALL_LOG_FILE="/tmp/gorseecode-install-$(date +%Y%m%d-%H%M%S).log" : > "$INSTALL_LOG_FILE" } build_shell_command() { local escaped="" printf -v escaped '%q ' "$@" printf '%s' "${escaped% }" } run_logged_with_spinner() { local label="$1"; shift if [[ "$PROGRESS_BAR" -eq 1 ]]; then "$@" >>"$INSTALL_LOG_FILE" 2>&1 return $? fi if [[ "$USE_GUM" -eq 1 ]]; then local shell_cmd shell_cmd="$(build_shell_command "$@")" "$GUM_BIN" spin --spinner dot --title "$label" -- bash -lc "${shell_cmd} >> \"$INSTALL_LOG_FILE\" 2>&1" return $? fi "$@" >>"$INSTALL_LOG_FILE" 2>&1 & local pid=$! wait "$pid" } run_quiet() { local label="$1"; shift [[ "$DRY_RUN" -eq 1 ]] && { info "DRY-RUN: ${label}"; return 0; } [[ "$VERBOSE" -eq 1 ]] && { "$@"; return $?; } if run_logged_with_spinner "$label" "$@"; then ok "${label}" return 0 fi [[ "$PROGRESS_BAR" -eq 1 ]] && printf '\n' error "${label} (подробности: ${INSTALL_LOG_FILE})" tail -n 40 "$INSTALL_LOG_FILE" >&2 || true return 1 } run_quiet_shell() { run_quiet "$1" bash -lc "$2"; } run_quiet_root() { local label="$1"; shift [[ "$DRY_RUN" -eq 1 ]] && { info "DRY-RUN(root): ${label}"; return 0; } local cmd=("$@") if [[ "${cmd[0]:-}" == "apt-get" || "${cmd[0]:-}" == "apt" ]]; then cmd=(env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a "${cmd[@]}") fi if [[ "$(id -u)" -eq 0 ]]; then run_quiet "$label" "${cmd[@]}" else run_quiet "$label" sudo "${cmd[@]}"; fi } run_quiet_root_shell() { local label="$1" cmd="$2" [[ "$DRY_RUN" -eq 1 ]] && { info "DRY-RUN(root): ${label}"; return 0; } if [[ "$(id -u)" -eq 0 ]]; then run_quiet "$label" bash -lc "$cmd" else run_quiet "$label" sudo bash -lc "$cmd"; fi } run_quiet_docker() { local label="$1"; shift [[ "$DRY_RUN" -eq 1 ]] && { info "DRY-RUN(docker): ${label}"; return 0; } if docker info >/dev/null 2>&1; then run_quiet "$label" docker "$@" elif [[ "$(id -u)" -eq 0 ]]; then run_quiet "$label" docker "$@" else run_quiet "$label" sudo docker "$@"; fi } # ── Args ──────────────────────────────────────────────── parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --domain) [[ $# -ge 2 ]] || die "--domain requires a value"; DOMAIN="$2"; shift 2 ;; --dry-run) DRY_RUN=1; shift ;; --verbose) VERBOSE=1; shift ;; -h|--help) cat <<'EOF' GorseeCode Installer v3.0 Usage: bash install-panel.sh [--domain example.com] [--dry-run] [--verbose] Options: --domain Domain for Caddy reverse proxy (auto-HTTPS) --dry-run Preview steps without executing --verbose Show full command output EOF exit 0 ;; *) die "Unknown argument: $1" ;; esac done } # ── Helpers ───────────────────────────────────────────── sanitize_version() { local r="${1:-}"; r="${r#v}"; r="${r%%-*}"; r="${r%%+*}" printf '%s' "$r" | sed -E 's/[^0-9.].*$//' } version_ge() { local c; c="$(sanitize_version "$1")" local m; m="$(sanitize_version "$2")" [[ -n "$c" && -n "$m" ]] || return 1 [[ "$(printf '%s\n%s\n' "$m" "$c" | sort -V | head -n 1)" == "$m" ]] } need_root_tooling() { [[ "$(id -u)" -eq 0 ]] && return 0 command -v sudo >/dev/null || die "Need root or sudo" } run_shell() { [[ "$DRY_RUN" -eq 1 ]] && { info "DRY-RUN: $1"; return 0; } bash -lc "$1" } as_root() { [[ "$DRY_RUN" -eq 1 ]] && { info "DRY-RUN(root): $*"; return 0; } local cmd=("$@") if [[ "${cmd[0]:-}" == "apt-get" || "${cmd[0]:-}" == "apt" ]]; then cmd=(env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a "${cmd[@]}") fi if [[ "$(id -u)" -eq 0 ]]; then "${cmd[@]}"; else sudo "${cmd[@]}"; fi } docker_exec() { [[ "$DRY_RUN" -eq 1 ]] && { info "DRY-RUN(docker): docker $*"; return 0; } if docker info >/dev/null 2>&1; then docker "$@" elif [[ "$(id -u)" -eq 0 ]]; then docker "$@" else sudo docker "$@"; fi } detect_pkg_manager() { if command -v apt-get >/dev/null; then PKG_MANAGER="apt" elif command -v dnf >/dev/null; then PKG_MANAGER="dnf" elif command -v pacman >/dev/null; then PKG_MANAGER="pacman" elif command -v nix >/dev/null; then PKG_MANAGER="nix" else PKG_MANAGER="unknown"; fi } pick_free_port() { python3 - "$1" <<'PY' import socket, sys for port in range(int(sys.argv[1]), 65535): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: s.bind(("127.0.0.1", port)); print(port); raise SystemExit(0) except OSError: pass raise SystemExit(1) PY } # ── License verification ───────────────────────────────── prompt_email() { if [[ "$USE_GUM" -eq 1 ]]; then USER_EMAIL="$("$GUM_BIN" input --placeholder "Введите ваш email" --prompt " Email: " --width 50 < /dev/tty)" || true else printf ' Email: ' read -r USER_EMAIL < /dev/tty fi USER_EMAIL="$(echo "$USER_EMAIL" | tr '[:upper:]' '[:lower:]' | xargs)" } ask_retry_or_exit() { local msg="$1" printf '\n' if [[ "$USE_GUM" -eq 1 ]]; then "$GUM_BIN" style --foreground 196 --bold --padding "0 2" " $msg" 2>/dev/null \ || printf ' %s%s%s%s\n' "${C_BOLD}" "${C_RED}" "$msg" "${C_RESET}" else printf ' %s%s%s%s\n' "${C_BOLD}" "${C_RED}" "$msg" "${C_RESET}" fi printf '\n Нажмите %sEnter%s чтобы попробовать ещё раз или %sEsc%s чтобы выйти ' \ "${C_BOLD}" "${C_RESET}" "${C_BOLD}" "${C_RESET}" local key="" read -rsn1 key < /dev/tty || true if [[ "$key" == $'\x1b' ]]; then printf '\n\n'; exit 0; fi printf '\n' } step_email_and_verify() { if [[ "$DRY_RUN" -eq 1 ]]; then USER_EMAIL="test@example.com" return 0 fi local license_url="https://${LICENSE_HOST}/api/verify" if [[ "$LICENSE_HOST" =~ ^[0-9]+\. ]]; then license_url="http://${LICENSE_HOST}/api/verify"; fi while true; do printf '\n' prompt_email # Validate email format if [[ -z "$USER_EMAIL" ]]; then ask_retry_or_exit "Email не указан" continue fi if [[ ! "$USER_EMAIL" =~ ^[^@]+@[^@]+\.[^@]+$ ]]; then ask_retry_or_exit "Некорректный email: ${USER_EMAIL}" continue fi # Verify with license server local response="" response="$(curl -sf --max-time 10 \ -H 'Content-Type: application/json' \ -d "{\"email\":\"${USER_EMAIL}\"}" \ "$license_url" 2>/dev/null)" \ || { ask_retry_or_exit "Не удалось связаться с сервером лицензий"; continue; } # Parse JSON without python3 if printf '%s' "$response" | grep -q '"allowed"[[:space:]]*:[[:space:]]*true'; then # Success return 0 fi # Extract reason from JSON local reason="" reason="$(printf '%s' "$response" | grep -o '"reason"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:.*"\(.*\)"/\1/' || true)" ask_retry_or_exit "${reason:-Доступ запрещён}" continue done } step_register_install() { if [[ "$DRY_RUN" -eq 1 ]]; then info "DRY-RUN: register installation" return 0 fi local hn hn="$(hostname 2>/dev/null || echo "unknown")" local install_url="https://${LICENSE_HOST}/api/install" if [[ "$LICENSE_HOST" =~ ^[0-9]+\. ]]; then install_url="http://${LICENSE_HOST}/api/install"; fi curl -sf --max-time 10 \ -H 'Content-Type: application/json' \ -d "{\"email\":\"${USER_EMAIL}\",\"hostname\":\"${hn}\"}" \ "$install_url" >/dev/null 2>&1 || true } # ── Installation steps ────────────────────────────────── step_core_tools() { local missing=() required=(curl tar git python3 awk sed grep) for tool in "${required[@]}"; do command -v "$tool" >/dev/null || missing+=("$tool"); done if [[ "${#missing[@]}" -eq 0 ]]; then # record_summary "Утилиты" "OK" "curl, tar, git, python3, awk, sed, grep" return 0 fi need_root_tooling case "$PKG_MANAGER" in apt) run_quiet_root "apt update" apt-get update -qq run_quiet_root "Базовые утилиты" apt-get install -y -qq ca-certificates curl tar git python3 gawk sed grep gnupg lsb-release apt-transport-https ;; dnf) run_quiet_root "Базовые утилиты" dnf install -y curl tar git python3 gawk sed grep gnupg2 ;; pacman) run_quiet_root "Базовые утилиты" pacman -Sy --noconfirm --needed curl tar git python gawk sed grep gnupg ;; nix) run_quiet_shell "Базовые утилиты" "nix profile install nixpkgs#curl nixpkgs#gnutar nixpkgs#git nixpkgs#python3 nixpkgs#gawk nixpkgs#gnused nixpkgs#gnugrep" ;; *) die "Unknown package manager" ;; esac for tool in "${required[@]}"; do command -v "$tool" >/dev/null || die "Failed to install ${tool}"; done # record_summary "Утилиты" "OK" "installed: ${missing[*]}" } step_docker() { local current="" status="OK" command -v docker >/dev/null && current="$(docker --version 2>/dev/null | sed -E 's/^Docker version ([^,]+).*/\1/' || true)" if [[ -n "$current" ]] && version_ge "$current" "$MIN_DOCKER_VERSION"; then : # already OK else status="INSTALLED" need_root_tooling case "$PKG_MANAGER" in apt|dnf) run_quiet_root_shell "Docker" "curl -fsSL https://get.docker.com | sh" ;; pacman) run_quiet_root "Docker" pacman -Sy --noconfirm --needed docker docker-compose ;; nix) run_quiet_shell "Docker" "nix profile install nixpkgs#docker nixpkgs#docker-compose" ;; *) die "Cannot install Docker on this system" ;; esac fi if command -v systemctl >/dev/null; then as_root systemctl enable docker >/dev/null 2>&1 || true as_root systemctl start docker >/dev/null 2>&1 || true fi command -v docker >/dev/null || die "Docker not installed" current="$(docker --version 2>/dev/null | sed -E 's/^Docker version ([^,]+).*/\1/' || true)" version_ge "$current" "$MIN_DOCKER_VERSION" || die "Docker ${current:-unknown} < ${MIN_DOCKER_VERSION}" # record_summary "Docker" "$status" "${current}" } step_compose() { local current="" if docker compose version --short >/dev/null 2>&1; then current="$(docker compose version --short)" elif command -v docker-compose >/dev/null; then current="$(docker-compose version --short 2>/dev/null || true)" fi if [[ -n "$current" ]] && version_ge "$current" "$MIN_COMPOSE_VERSION"; then # record_summary "Compose" "OK" "${current}" return 0 fi need_root_tooling case "$PKG_MANAGER" in apt) run_quiet_root "apt update" apt-get update -qq; run_quiet_root "Docker Compose" apt-get install -y -qq docker-compose-plugin ;; dnf) run_quiet_root_shell "Docker Compose" "dnf install -y docker-compose-plugin || dnf install -y docker-compose" ;; pacman) run_quiet_root "Docker Compose" pacman -Sy --noconfirm --needed docker-compose ;; nix) run_quiet_shell "Docker Compose" "nix profile install nixpkgs#docker-compose" ;; *) die "Cannot install Docker Compose" ;; esac if docker compose version --short >/dev/null 2>&1; then current="$(docker compose version --short)"; fi [[ -n "$current" ]] || die "Docker Compose not found after install" # record_summary "Compose" "INSTALLED" "${current}" } step_caddy() { local current="" status="OK" command -v caddy >/dev/null && current="$(caddy version 2>/dev/null | awk '{print $1}' || true)" if [[ -n "$current" ]] && version_ge "$current" "$MIN_CADDY_VERSION"; then : # already OK else status="INSTALLED" need_root_tooling case "$PKG_MANAGER" in apt) run_quiet_root "apt update" apt-get update -qq run_quiet_root "Caddy prereqs" apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https curl gnupg run_quiet_root_shell "Caddy GPG key" "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg" run_quiet_root_shell "Caddy apt repo" "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' > /etc/apt/sources.list.d/caddy-stable.list" run_quiet_root "Caddy permissions" chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg /etc/apt/sources.list.d/caddy-stable.list run_quiet_root "apt update" apt-get update -qq run_quiet_root "Caddy" apt-get install -y -qq caddy ;; dnf) run_quiet_root "Caddy" dnf install -y caddy ;; pacman) run_quiet_root "Caddy" pacman -Sy --noconfirm --needed caddy ;; nix) run_quiet_shell "Caddy" "nix profile install nixpkgs#caddy" ;; *) die "Cannot install Caddy" ;; esac fi command -v caddy >/dev/null || die "Caddy not installed" current="$(caddy version 2>/dev/null | awk '{print $1}' || true)" # record_summary "Caddy" "$status" "${current}" } step_ports() { local host_ip="" host_ip="$(hostname -I 2>/dev/null | awk '{print $1}')" || true [[ -n "$host_ip" ]] || host_ip="127.0.0.1" APP_PORT="$(pick_free_port 3001)" || die "No free port for app" APP_HEALTH_URL="http://127.0.0.1:${APP_PORT}/health" if [[ -n "$DOMAIN" ]]; then ACCESS_URL="https://${DOMAIN}" else PROXY_PORT="$(pick_free_port 8080)" || die "No free port for Caddy" ACCESS_URL="http://${host_ip}:${PROXY_PORT}" fi # record_summary "Порты" "OK" "app=${APP_PORT}${PROXY_PORT:+, proxy=${PROXY_PORT}}" } step_dirs_network() { local projects_dir="${GORSEECODE_PROJECTS_DIR:-$PROJECTS_DIR_DEFAULT}" local data_dir="${GORSEECODE_DATA_DIR:-$DATA_DIR_DEFAULT}" run_quiet_root "Директории" mkdir -p "$projects_dir" "$data_dir" PROJECTS_DIR_DEFAULT="$projects_dir" DATA_DIR_DEFAULT="$data_dir" # record_summary "Данные" "OK" "projects=${projects_dir}" # Docker network if [[ "$DRY_RUN" -eq 1 ]]; then return 0; fi if ! docker_exec network inspect "$DOCKER_NETWORK" >/dev/null 2>&1; then run_quiet_docker "Docker-сеть" network create "$DOCKER_NETWORK" || true fi } step_pull() { run_quiet_docker "Pull образа" pull "$IMAGE_TAG" } step_run() { local auth_secret="${AUTH_SECRET:-}" if [[ -z "$auth_secret" ]]; then if command -v openssl >/dev/null; then auth_secret="$(openssl rand -hex 32)" elif command -v xxd >/dev/null; then auth_secret="$(head -c 32 /dev/urandom | xxd -p | tr -d '\n')" else auth_secret="$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')"; fi fi # Generate random panel password if [[ -z "$PANEL_PASSWORD" ]]; then if command -v openssl >/dev/null; then PANEL_PASSWORD="$(openssl rand -base64 12 | tr -d '/+=' | head -c 12)" else PANEL_PASSWORD="$(head -c 9 /dev/urandom | od -An -tx1 | tr -d ' \n' | head -c 12)"; fi fi local cors_origin if [[ -n "$DOMAIN" ]]; then cors_origin="https://${DOMAIN}" else cors_origin="http://localhost:${APP_PORT}"; fi local run_args=(run -d --name "$CONTAINER_NAME" --restart unless-stopped --network "$DOCKER_NETWORK" -p "127.0.0.1:${APP_PORT}:3001" -v /var/run/docker.sock:/var/run/docker.sock -v "${PROJECTS_DIR_DEFAULT}:/projects" -v "${DATA_DIR_DEFAULT}:/home/bun/.gorseecode" -e NODE_ENV=production -e PROJECTS_ROOT=/projects -e "AUTH_SECRET=${auth_secret}" -e "CORS_ORIGIN=${cors_origin}" -e "AUTH_PASSWORD=${PANEL_PASSWORD}") run_args+=(-e "INSTALL_EMAIL=${USER_EMAIL}") [[ -n "${ANTHROPIC_API_KEY:-}" ]] && run_args+=(-e "ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}") [[ -n "${OPENAI_API_KEY:-}" ]] && run_args+=(-e "OPENAI_API_KEY=${OPENAI_API_KEY}") run_args+=("$IMAGE_TAG") if [[ "$DRY_RUN" -eq 1 ]]; then info "DRY-RUN(docker): docker ${run_args[*]}" return 0 fi if docker_exec ps -a --format '{{.Names}}' | grep -Fx "$CONTAINER_NAME" >/dev/null 2>&1; then run_quiet_docker "Удаляю старый контейнер" rm -f "$CONTAINER_NAME" fi run_quiet_docker "Запуск контейнера" "${run_args[@]}" || { docker_exec network inspect "$DOCKER_NETWORK" >/dev/null 2>&1 || run_quiet_docker "Docker-сеть" network create "$DOCKER_NETWORK" run_quiet_docker "Повторный запуск" "${run_args[@]}" || die "Failed to start container" } # Health check if [[ "$DRY_RUN" -eq 0 ]]; then local i for ((i = 1; i <= 45; i++)); do curl -fsS --max-time 2 "$APP_HEALTH_URL" >/dev/null 2>&1 && break sleep 2 done curl -fsS --max-time 2 "$APP_HEALTH_URL" >/dev/null 2>&1 || { docker_exec logs --tail 60 "$CONTAINER_NAME" >> "$INSTALL_LOG_FILE" 2>&1 || true die "Health check failed: ${APP_HEALTH_URL}" } fi # record_summary "Контейнер" "OK" "${CONTAINER_NAME}" } step_caddy_proxy() { local site_label if [[ -n "$DOMAIN" ]]; then site_label="${DOMAIN}" else site_label=":${PROXY_PORT}"; fi if [[ "$DRY_RUN" -eq 1 ]]; then info "DRY-RUN: Caddyfile ${site_label} → 127.0.0.1:${APP_PORT}" # record_summary "Caddy proxy" "OK" "${site_label}" return 0 fi local tmp_file clean_file tmp_file="$(mktemp)" clean_file="$(mktemp)" if [[ -f "$CADDYFILE_PATH" ]]; then awk -v b="$CADDY_BLOCK_BEGIN" -v e="$CADDY_BLOCK_END" '$0==b{s=1;next}$0==e{s=0;next}!s{print}' "$CADDYFILE_PATH" > "$clean_file" else : > "$clean_file" fi cat "$clean_file" > "$tmp_file" cat <> "$tmp_file" $CADDY_BLOCK_BEGIN $site_label { encode zstd gzip reverse_proxy 127.0.0.1:${APP_PORT} } $CADDY_BLOCK_END EOF run_quiet_root "Caddyfile dir" mkdir -p "$(dirname "$CADDYFILE_PATH")" run_quiet_root "Caddyfile" cp "$tmp_file" "$CADDYFILE_PATH" rm -f "$tmp_file" "$clean_file" if command -v systemctl >/dev/null; then as_root systemctl enable caddy >/dev/null 2>&1 || true if as_root systemctl is-active --quiet caddy 2>/dev/null; then as_root systemctl reload caddy >/dev/null 2>&1 || as_root systemctl restart caddy >/dev/null 2>&1 else as_root systemctl start caddy >/dev/null 2>&1 fi else caddy validate --config "$CADDYFILE_PATH" >>"$INSTALL_LOG_FILE" 2>&1 || true if pgrep -x caddy >/dev/null 2>&1; then caddy reload --config "$CADDYFILE_PATH" >>"$INSTALL_LOG_FILE" 2>&1 else caddy start --config "$CADDYFILE_PATH" >>"$INSTALL_LOG_FILE" 2>&1 fi fi # record_summary "Caddy proxy" "OK" "${site_label} → :${APP_PORT}" } # ── Banner & Summary ──────────────────────────────────── show_banner() { printf '\n' if [[ "$USE_GUM" -eq 1 ]]; then "$GUM_BIN" style \ --border rounded --padding "0 2" --margin "0 0 1 2" \ --border-foreground 99 --foreground 255 --bold \ "Установка AI-панели ${INSTALLER_TITLE}" else printf ' %s╭─────────────────────────────────────────╮%s\n' "${C_CYAN}" "${C_RESET}" printf ' %s│%s %sУстановка AI-панели %s%s %s│%s\n' "${C_CYAN}" "${C_RESET}" "${C_BOLD}" "${INSTALLER_TITLE}" "${C_RESET}" "${C_CYAN}" "${C_RESET}" printf ' %s╰─────────────────────────────────────────╯%s\n' "${C_CYAN}" "${C_RESET}" fi } show_config() { local domain_display if [[ -n "$DOMAIN" ]]; then domain_display="${DOMAIN} (auto-HTTPS)" else domain_display="auto (IP + свободный порт)" fi if [[ "$USE_GUM" -eq 1 ]]; then printf ' %s%s Доступ:%s %s\n' "${C_DIM}" "●" "${C_RESET}" "$domain_display" printf ' %s%s Данные:%s %s\n\n' "${C_DIM}" "●" "${C_RESET}" "$INSTALL_BASE_DIR" else printf '\n %sДоступ:%s %s\n' "${C_DIM}" "${C_RESET}" "$domain_display" printf ' %sДанные:%s %s\n\n' "${C_DIM}" "${C_RESET}" "$INSTALL_BASE_DIR" fi } show_summary() { printf '\n' if [[ "$DRY_RUN" -eq 1 ]]; then printf ' %s[dry-run] Установка не выполнялась%s\n\n' "${C_YELLOW}" "${C_RESET}" return fi if [[ "$USE_GUM" -eq 1 ]]; then "$GUM_BIN" style --foreground 82 --bold --border double --padding "1 3" \ "✓ GorseeCode установлен!" "" \ "Панель: ${ACCESS_URL}" \ "Логин: ${PANEL_USERNAME}" \ "Пароль: ${PANEL_PASSWORD}" else printf ' %s%s✓ GorseeCode установлен!%s\n\n' "${C_BOLD}" "${C_GREEN}" "${C_RESET}" printf ' Панель: %s%s%s\n' "${C_GREEN}" "${ACCESS_URL}" "${C_RESET}" printf ' Логин: %s%s%s\n' "${C_BOLD}" "${PANEL_USERNAME}" "${C_RESET}" printf ' Пароль: %s%s%s\n\n' "${C_BOLD}" "${PANEL_PASSWORD}" "${C_RESET}" fi } # ── Main ──────────────────────────────────────────────── main() { parse_args "$@" init_log_file detect_pkg_manager ensure_gum detect_tui_backend show_banner # Email verification (before progress bar) step_email_and_verify printf '\n' TOTAL_STEPS=9 PROGRESS_BAR=1 local s=0 s=$((s+1)); render_progress $s $TOTAL_STEPS "Базовые утилиты"; step_core_tools s=$((s+1)); render_progress $s $TOTAL_STEPS "Docker"; step_docker s=$((s+1)); render_progress $s $TOTAL_STEPS "Docker Compose"; step_compose s=$((s+1)); render_progress $s $TOTAL_STEPS "Caddy"; step_caddy s=$((s+1)); render_progress $s $TOTAL_STEPS "Порты и URL"; step_ports s=$((s+1)); render_progress $s $TOTAL_STEPS "Директории и сеть"; step_dirs_network s=$((s+1)); render_progress $s $TOTAL_STEPS "Загрузка образа"; step_pull s=$((s+1)); render_progress $s $TOTAL_STEPS "Запуск контейнера"; step_run s=$((s+1)); render_progress $s $TOTAL_STEPS "Caddy reverse proxy"; step_caddy_proxy render_progress $TOTAL_STEPS $TOTAL_STEPS "Готово" PROGRESS_BAR=0 printf '\n\n' step_register_install show_summary } main "$@"