From d22ed238b4f8ad9b89f6defd486df9195b8ddf52 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Tue, 2 Jun 2026 10:08:59 +0300 Subject: [PATCH] feat: add shell install wizard --- docs/getting-started/deployment.md | 31 + docs/migrations/index.md | 1 + docs/migrations/remnashop.md | 60 ++ scripts/install.sh | 902 +++++++++++++++++++++++++++++ tests/test_install_script.py | 39 ++ 5 files changed, 1033 insertions(+) create mode 100644 docs/migrations/remnashop.md create mode 100644 scripts/install.sh create mode 100644 tests/test_install_script.py diff --git a/docs/getting-started/deployment.md b/docs/getting-started/deployment.md index cee2da1..ae3f09d 100644 --- a/docs/getting-started/deployment.md +++ b/docs/getting-started/deployment.md @@ -13,6 +13,37 @@ docker compose ps docker compose logs -f backend worker frontend ``` +## Интерактивный install wizard + +Для нового сервера можно не клонировать весь репозиторий и не ставить Python. +Скачайте только POSIX `sh`-скрипт через `raw.githubusercontent.com` и +запустите его: + +```bash +curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh +sh install.sh +``` + +Wizard работает через меню с цифрами и подтверждениями `y/n`. Он умеет: + +- скачать выбранный compose-профиль (`Caddy`, `Nginx`, `Pangolin/Newt` или `no-proxy`); +- сгенерировать минимальный `.env`, включая пароли и стабильные secrets; +- сохранить backup существующих файлов перед перезаписью; +- запустить `docker compose pull && docker compose up -d`; +- проверить текущий стек через `docker compose ps` и логи `migrate`; +- запустить миграцию из поддерживаемых legacy-ботов, сейчас - из Remnashop. + +Для тестирования другой ветки или форка задайте источник перед запуском: + +```bash +MINISHOP_INSTALL_REPO=3252a8/remnawave-minishop \ +MINISHOP_INSTALL_REF=main \ +sh install.sh +``` + +Миграция в wizard сначала запускает `dry-run`, показывает JSON-сводку и только +после отдельного подтверждения применяет изменения в целевую БД. + Обычный `docker compose up -d --build` поднимает: - `postgres` и `redis` с проверками здоровья; diff --git a/docs/migrations/index.md b/docs/migrations/index.md index 501a37a..0c20758 100644 --- a/docs/migrations/index.md +++ b/docs/migrations/index.md @@ -5,3 +5,4 @@ | Источник | Поддерживаемый случай | Документы | | --- | --- | --- | | [remnawave-tg-shop](https://github.com/kavore/remnawave-tg-shop/) | Полный перенос всех данных | [Инструкция](remnawave-tg-shop.md) | +| [Remnashop](https://github.com/snoups/remnashop/) | Автоматический импорт пользователей, подписок, платежей, рефералов и промокодов | [Инструкция](remnashop.md) | diff --git a/docs/migrations/remnashop.md b/docs/migrations/remnashop.md new file mode 100644 index 0000000..2387bb9 --- /dev/null +++ b/docs/migrations/remnashop.md @@ -0,0 +1,60 @@ +# Миграция из Remnashop + +Remnashop импортируется через общий legacy-importer `backend/scripts/import_legacy.py`. +Самый удобный путь - интерактивный install wizard: + +```bash +curl -fsSL https://raw.githubusercontent.com/3252a8/remnawave-minishop/main/scripts/install.sh -o install.sh +sh install.sh +``` + +В меню выберите `Install new stack and run legacy migration` для нового сервера +или `Run legacy migration only`, если compose-папка и `.env` уже готовы. + +## Что переносится + +- пользователи Telegram, username, email, Remnawave UUID и метаданные профиля; +- старые referral codes и связи рефералов; +- подписки, сроки, лимиты трафика, HWID/device limit и UUID подписок панели; +- платежи и статусы платежей; +- промокоды на дни подписки и их активации, если таблицы есть в source DB; +- служебные mappings, чтобы повторный запуск мог работать в режиме `merge`; +- настройки совместимости Remnashop в админке: старые ref-ссылки и promo codes. + +Данные, которые не имеют прямого аналога, сохраняются в legacy mappings или +message logs как заметки, чтобы администратор мог проверить их после переноса. + +## Flow wizard + +1. Wizard скачивает compose-профиль и `backend/scripts/import_legacy.py` через + `raw.githubusercontent.com`, без клонирования репозитория. +2. Вы указываете source PostgreSQL DSN Remnashop и schema, обычно `public`. +3. Вы выбираете целевую БД: текущую compose-БД или ручной target DSN. +4. При необходимости указываете JSON map тарифов Remnashop в локальные + `tariff_key`, например `{"basic": "standard_month"}`. +5. Wizard запускает `dry-run` и показывает JSON-сводку. +6. После подтверждения `y` importer применяет изменения и перезапускает + `backend`/`worker`, чтобы настройки совместимости перечитались. + +Если source DB находится на том же Docker host, помните, что DSN выполняется +из backend-контейнера. Для подключения к сервису вне compose-сети может +понадобиться host name вроде `host.docker.internal`, внешний адрес сервера или +ручное подключение контейнеров к общей Docker network. + +## Ручной запуск + +Если нужно запустить importer без wizard: + +```bash +docker compose run --rm backend \ + python backend/scripts/import_legacy.py \ + --source-type remnashop \ + --source-dsn 'postgresql://old_user:old_password@old_host:5432/remnashop' \ + --source-schema public \ + --dry-run +``` + +После успешного `dry-run` повторите команду без `--dry-run`. По умолчанию +режим конфликтов `merge`: существующие пользователи и платежи сопоставляются, +а новые записи добавляются. Для узкого импорта используйте `--only`, например +`--only users,referrals,promocodes`. diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..d9d93dc --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,902 @@ +#!/bin/sh +set -u + +# Dependency-light installer for fresh Linux hosts. +# It intentionally avoids Python and clones only the files selected by the user. + +DEFAULT_REPO="${MINISHOP_INSTALL_REPO:-3252a8/remnawave-minishop}" +DEFAULT_REF="${MINISHOP_INSTALL_REF:-main}" +DEFAULT_IMAGE_TAG="${MINISHOP_IMAGE_TAG:-latest}" +INSTALL_STATE_DIR=".installer" +IMPORTER_CACHE_PATH="$INSTALL_STATE_DIR/import_legacy.py" +APP_UID=10001 +APP_GID=10001 + +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + RESET="$(printf '\033[0m')" + BOLD="$(printf '\033[1m')" + DIM="$(printf '\033[2m')" + RED="$(printf '\033[31m')" + GREEN="$(printf '\033[32m')" + YELLOW="$(printf '\033[33m')" + BLUE="$(printf '\033[34m')" + CYAN="$(printf '\033[36m')" +else + RESET="" + BOLD="" + DIM="" + RED="" + GREEN="" + YELLOW="" + BLUE="" + CYAN="" +fi + +TARGET_DIR="" +SOURCE_REPO="" +SOURCE_REF="" +PROFILE_KEY="" +ENV_PATH="" +COMPOSE_STYLE="" +PROMPT_VALUE="" +CHOICE_VALUE="" + +COMPOSE_PROJECT_NAME_VALUE="" +IMAGE_TAG_VALUE="" +WEBHOOK_HOST_VALUE="" +MINIAPP_HOST_VALUE="" +WEBHOOK_PUBLIC_URL_VALUE="" +MINIAPP_PUBLIC_URL_VALUE="" +HTTP_BIND_VALUE="" +HTTPS_BIND_VALUE="" +WEB_SERVER_BIND_VALUE="" +FRONTEND_BIND_VALUE="" +PANGOLIN_ENDPOINT_VALUE="" +NEWT_ID_VALUE="" +NEWT_SECRET_VALUE="" +BOT_TOKEN_VALUE="" +ADMIN_IDS_VALUE="" +POSTGRES_USER_VALUE="" +POSTGRES_PASSWORD_VALUE="" +POSTGRES_DB_VALUE="" +WEBAPP_ENABLED_VALUE="" +WEBAPP_SESSION_SECRET_VALUE="" +WEBHOOK_SECRET_TOKEN_VALUE="" +TRUSTED_PROXIES_VALUE="" +PANEL_API_URL_VALUE="" +PANEL_API_KEY_VALUE="" +PANEL_WEBHOOK_SECRET_VALUE="" + +KNOWN_ENV_KEYS="COMPOSE_PROJECT_NAME IMAGE_TAG WEBHOOK_HOST MINIAPP_HOST WEBHOOK_PUBLIC_URL MINIAPP_PUBLIC_URL HTTP_BIND HTTPS_BIND WEB_SERVER_BIND FRONTEND_BIND PANGOLIN_ENDPOINT NEWT_ID NEWT_SECRET BOT_TOKEN ADMIN_IDS POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB WEBAPP_ENABLED WEBAPP_SESSION_SECRET WEBHOOK_SECRET_TOKEN TRUSTED_PROXIES PANEL_API_URL PANEL_API_KEY PANEL_WEBHOOK_SECRET" + +color() { + printf '%s%s%s' "$2" "$1" "$RESET" +} + +banner() { + printf '\n' + color "Remnawave MiniShop Install Wizard" "$BOLD$CYAN" + printf '\n' + color "Install, configure, start, and migrate legacy bot data." "$DIM" + printf '\n\n' +} + +section() { + printf '\n' + color "== $1 ==" "$BOLD$BLUE" + printf '\n' +} + +info() { + color "* " "$CYAN" + printf '%s\n' "$1" +} + +warn() { + color "! " "$YELLOW" + printf '%s\n' "$1" +} + +ok() { + color "[ok] " "$GREEN" + printf '%s\n' "$1" +} + +fail() { + color "[x] " "$RED" + printf '%s\n' "$1" >&2 +} + +pause() { + printf '%s' "${DIM}Press Enter to continue...${RESET}" + # shellcheck disable=SC2034 + read -r _ +} + +print_help() { + cat </dev/null | tail -n 1 || true) + if [ -n "$line" ]; then + strip_quotes "${line#*=}" + return 0 + fi + fi + printf '%s' "$default_value" +} + +known_env_key() { + case " $KNOWN_ENV_KEYS " in + *" $1 "*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +secret_hex() { + bytes="${1:-32}" + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex "$bytes" + return 0 + fi + if [ -r /dev/urandom ] && command -v od >/dev/null 2>&1; then + dd if=/dev/urandom bs="$bytes" count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' + return 0 + fi + fail "Could not generate a secure secret. Install openssl and retry." + exit 1 +} + +generated_password() { + secret_hex 24 +} + +raw_url() { + repo=$(printf '%s' "$1" | sed 's#^/*##; s#/*$##') + ref=$(printf '%s' "$2" | sed 's#^/*##; s#/*$##') + path=$(printf '%s' "$3" | sed 's#^/*##') + printf 'https://raw.githubusercontent.com/%s/%s/%s' "$repo" "$ref" "$path" +} + +download_to() { + url="$1" + target="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$target" + return $? + fi + if command -v wget >/dev/null 2>&1; then + wget -qO "$target" "$url" + return $? + fi + fail "curl or wget is required to download files." + exit 1 +} + +backup_path() { + path="$1" + stamp=$(date -u '+%Y%m%d-%H%M%S') + printf '%s.bak-%s' "$path" "$stamp" +} + +write_downloaded_file() { + source_path="$1" + target_path="$2" + mkdir -p "$(dirname "$target_path")" + if [ -e "$target_path" ]; then + if confirm "$target_path exists. Overwrite with backup?" 0; then + backup=$(backup_path "$target_path") + cp "$target_path" "$backup" + info "Backed up $target_path to $(basename "$backup")" + else + warn "Keeping existing $target_path" + rm -f "$source_path" + return 0 + fi + fi + mv "$source_path" "$target_path" + ok "Wrote $target_path" +} + +download_raw_file() { + source="$1" + target="$2" + required="${3:-1}" + url=$(raw_url "$SOURCE_REPO" "$SOURCE_REF" "$source") + tmp="$TARGET_DIR/.download.$$.$(basename "$target")" + if download_to "$url" "$tmp"; then + write_downloaded_file "$tmp" "$TARGET_DIR/$target" + return 0 + fi + rm -f "$tmp" + if [ "$required" = "1" ]; then + fail "Could not download $url" + return 1 + fi + warn "Skipping optional file $source" + return 0 +} + +choose_profile() { + choose "Deployment profile" "1" "1|2|3|4" \ + "1. Caddy HTTPS - recommended public HTTPS with automatic certificates." \ + "2. Nginx HTTPS - TLS certificates are managed manually." \ + "3. Pangolin / Newt - no inbound ports; public routes are configured in Pangolin." \ + "4. No proxy / external TLS - direct HTTP ports or an external TLS terminator." + case "$CHOICE_VALUE" in + 1) PROFILE_KEY="caddy" ;; + 2) PROFILE_KEY="nginx" ;; + 3) PROFILE_KEY="newt" ;; + 4) PROFILE_KEY="no-proxy" ;; + esac +} + +download_profile_files() { + section "Download deployment files" + case "$PROFILE_KEY" in + caddy) + download_raw_file "deploy/examples/caddy/docker-compose.yml" "docker-compose.yml" 1 || return 1 + download_raw_file "deploy/examples/caddy/Caddyfile" "Caddyfile" 1 || return 1 + download_raw_file "deploy/examples/caddy/.env.example" ".env.example" 1 || return 1 + ;; + nginx) + download_raw_file "deploy/examples/nginx/docker-compose.yml" "docker-compose.yml" 1 || return 1 + download_raw_file "deploy/examples/nginx/nginx.conf.template" "nginx.conf.template" 1 || return 1 + download_raw_file "deploy/examples/nginx/.env.example" ".env.example" 1 || return 1 + download_raw_file "deploy/examples/nginx/ssl/README.md" "ssl/README.md" 1 || return 1 + ;; + newt) + download_raw_file "deploy/examples/newt/docker-compose.yml" "docker-compose.yml" 1 || return 1 + download_raw_file "deploy/examples/newt/.env.example" ".env.example" 1 || return 1 + ;; + no-proxy) + download_raw_file "deploy/examples/no-proxy/docker-compose.yml" "docker-compose.yml" 1 || return 1 + download_raw_file "deploy/examples/no-proxy/.env.example" ".env.example" 1 || return 1 + ;; + esac +} + +prompt_common_env() { + section "Minimal .env" + prompt_value "Compose project name" "$(env_get COMPOSE_PROJECT_NAME remnawave-minishop)" 0 0 "" + COMPOSE_PROJECT_NAME_VALUE="$PROMPT_VALUE" + prompt_value "Image tag" "$(env_get IMAGE_TAG "$DEFAULT_IMAGE_TAG")" 0 0 "" + IMAGE_TAG_VALUE="$PROMPT_VALUE" + prompt_value "Telegram bot token" "$(env_get BOT_TOKEN '')" 1 1 "" + BOT_TOKEN_VALUE="$PROMPT_VALUE" + prompt_value "Admin Telegram IDs, comma-separated" "$(env_get ADMIN_IDS '')" 1 0 "" + ADMIN_IDS_VALUE="$PROMPT_VALUE" + prompt_value "Postgres user" "$(env_get POSTGRES_USER remnawave_minishop)" 1 0 "" + POSTGRES_USER_VALUE="$PROMPT_VALUE" + existing_postgres_password=$(env_get POSTGRES_PASSWORD "") + if [ -z "$existing_postgres_password" ]; then + existing_postgres_password=$(generated_password) + fi + prompt_value "Postgres password" "$existing_postgres_password" 1 1 "" + POSTGRES_PASSWORD_VALUE="$PROMPT_VALUE" + prompt_value "Postgres database" "$(env_get POSTGRES_DB remnawave_minishop)" 1 0 "" + POSTGRES_DB_VALUE="$PROMPT_VALUE" + + WEBAPP_ENABLED_VALUE="$(env_get WEBAPP_ENABLED True)" + WEBAPP_SESSION_SECRET_VALUE="$(env_get WEBAPP_SESSION_SECRET "")" + if [ -z "$WEBAPP_SESSION_SECRET_VALUE" ]; then + WEBAPP_SESSION_SECRET_VALUE="$(secret_hex 32)" + fi + WEBHOOK_SECRET_TOKEN_VALUE="$(env_get WEBHOOK_SECRET_TOKEN "")" + if [ -z "$WEBHOOK_SECRET_TOKEN_VALUE" ]; then + WEBHOOK_SECRET_TOKEN_VALUE="$(secret_hex 32)" + fi + + prompt_value "Remnawave Panel API URL" "$(env_get PANEL_API_URL https://panel.example.com/api)" 0 0 "url" + PANEL_API_URL_VALUE="$PROMPT_VALUE" + prompt_value "Remnawave Panel API key" "$(env_get PANEL_API_KEY change_me)" 0 1 "" + PANEL_API_KEY_VALUE="$PROMPT_VALUE" + existing_panel_webhook_secret=$(env_get PANEL_WEBHOOK_SECRET "") + if [ -z "$existing_panel_webhook_secret" ]; then + existing_panel_webhook_secret=$(secret_hex 24) + fi + prompt_value "Remnawave Panel webhook secret" "$existing_panel_webhook_secret" 0 1 "" + PANEL_WEBHOOK_SECRET_VALUE="$PROMPT_VALUE" + + case "$PROFILE_KEY" in + caddy|nginx|newt) + prompt_value "Webhook/API public hostname" "$(env_get WEBHOOK_HOST webhooks.example.com)" 1 0 "hostname" + WEBHOOK_HOST_VALUE="$PROMPT_VALUE" + prompt_value "Mini App public hostname" "$(env_get MINIAPP_HOST app.example.com)" 1 0 "hostname" + MINIAPP_HOST_VALUE="$PROMPT_VALUE" + TRUSTED_PROXIES_VALUE="$(env_get TRUSTED_PROXIES '127.0.0.1,::1,172.16.0.0/12')" + ;; + esac + + case "$PROFILE_KEY" in + caddy|nginx) + prompt_value "HTTP bind" "$(env_get HTTP_BIND '0.0.0.0:80')" 0 0 "" + HTTP_BIND_VALUE="$PROMPT_VALUE" + prompt_value "HTTPS bind" "$(env_get HTTPS_BIND '0.0.0.0:443')" 0 0 "" + HTTPS_BIND_VALUE="$PROMPT_VALUE" + ;; + newt) + prompt_value "Pangolin endpoint" "$(env_get PANGOLIN_ENDPOINT https://pangolin.example.com)" 1 0 "url" + PANGOLIN_ENDPOINT_VALUE="$PROMPT_VALUE" + prompt_value "Newt ID" "$(env_get NEWT_ID '')" 1 0 "" + NEWT_ID_VALUE="$PROMPT_VALUE" + prompt_value "Newt secret" "$(env_get NEWT_SECRET '')" 1 1 "" + NEWT_SECRET_VALUE="$PROMPT_VALUE" + ;; + no-proxy) + prompt_value "Backend bind" "$(env_get WEB_SERVER_BIND '0.0.0.0:8080')" 0 0 "" + WEB_SERVER_BIND_VALUE="$PROMPT_VALUE" + prompt_value "Frontend bind" "$(env_get FRONTEND_BIND '0.0.0.0:8082')" 0 0 "" + FRONTEND_BIND_VALUE="$PROMPT_VALUE" + prompt_value "Webhook public URL" "$(env_get WEBHOOK_PUBLIC_URL 'http://127.0.0.1:8080')" 1 0 "url" + WEBHOOK_PUBLIC_URL_VALUE="$PROMPT_VALUE" + prompt_value "Mini App public URL" "$(env_get MINIAPP_PUBLIC_URL 'http://127.0.0.1:8082/')" 1 0 "url" + MINIAPP_PUBLIC_URL_VALUE="$PROMPT_VALUE" + TRUSTED_PROXIES_VALUE="$(env_get TRUSTED_PROXIES '127.0.0.1,::1')" + ;; + esac +} + +env_line() { + key="$1" + value="$2" + file="$3" + if [ -n "$value" ]; then + printf '%s=%s\n' "$key" "$value" >> "$file" + fi +} + +show_env_value() { + key="$1" + value="$2" + if [ -z "$value" ]; then + return 0 + fi + if is_secret_key "$key"; then + value=$(mask_secret "$value") + fi + printf ' %s=%s\n' "$key" "$value" +} + +display_env_summary() { + show_env_value COMPOSE_PROJECT_NAME "$COMPOSE_PROJECT_NAME_VALUE" + show_env_value IMAGE_TAG "$IMAGE_TAG_VALUE" + show_env_value WEBHOOK_HOST "$WEBHOOK_HOST_VALUE" + show_env_value MINIAPP_HOST "$MINIAPP_HOST_VALUE" + show_env_value WEBHOOK_PUBLIC_URL "$WEBHOOK_PUBLIC_URL_VALUE" + show_env_value MINIAPP_PUBLIC_URL "$MINIAPP_PUBLIC_URL_VALUE" + show_env_value BOT_TOKEN "$BOT_TOKEN_VALUE" + show_env_value ADMIN_IDS "$ADMIN_IDS_VALUE" + show_env_value POSTGRES_USER "$POSTGRES_USER_VALUE" + show_env_value POSTGRES_PASSWORD "$POSTGRES_PASSWORD_VALUE" + show_env_value POSTGRES_DB "$POSTGRES_DB_VALUE" + show_env_value WEBAPP_SESSION_SECRET "$WEBAPP_SESSION_SECRET_VALUE" + show_env_value WEBHOOK_SECRET_TOKEN "$WEBHOOK_SECRET_TOKEN_VALUE" + show_env_value PANEL_API_URL "$PANEL_API_URL_VALUE" + show_env_value PANEL_API_KEY "$PANEL_API_KEY_VALUE" + show_env_value PANEL_WEBHOOK_SECRET "$PANEL_WEBHOOK_SECRET_VALUE" +} + +append_preserved_env() { + output="$1" + [ -f "$ENV_PATH" ] || return 0 + wrote_header=0 + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ""|\#*) + continue + ;; + *=*) + key=${line%%=*} + if known_env_key "$key"; then + continue + fi + if [ "$wrote_header" = "0" ]; then + printf '\n# Preserved from previous .env\n' >> "$output" + wrote_header=1 + fi + printf '%s\n' "$line" >> "$output" + ;; + esac + done < "$ENV_PATH" +} + +render_env_file() { + output="$1" + : > "$output" + printf '# Deployment\n' >> "$output" + env_line COMPOSE_PROJECT_NAME "$COMPOSE_PROJECT_NAME_VALUE" "$output" + env_line IMAGE_TAG "$IMAGE_TAG_VALUE" "$output" + env_line WEBHOOK_HOST "$WEBHOOK_HOST_VALUE" "$output" + env_line MINIAPP_HOST "$MINIAPP_HOST_VALUE" "$output" + env_line WEBHOOK_PUBLIC_URL "$WEBHOOK_PUBLIC_URL_VALUE" "$output" + env_line MINIAPP_PUBLIC_URL "$MINIAPP_PUBLIC_URL_VALUE" "$output" + env_line HTTP_BIND "$HTTP_BIND_VALUE" "$output" + env_line HTTPS_BIND "$HTTPS_BIND_VALUE" "$output" + env_line WEB_SERVER_BIND "$WEB_SERVER_BIND_VALUE" "$output" + env_line FRONTEND_BIND "$FRONTEND_BIND_VALUE" "$output" + env_line PANGOLIN_ENDPOINT "$PANGOLIN_ENDPOINT_VALUE" "$output" + env_line NEWT_ID "$NEWT_ID_VALUE" "$output" + env_line NEWT_SECRET "$NEWT_SECRET_VALUE" "$output" + + printf '\n# Telegram\n' >> "$output" + env_line BOT_TOKEN "$BOT_TOKEN_VALUE" "$output" + env_line ADMIN_IDS "$ADMIN_IDS_VALUE" "$output" + + printf '\n# PostgreSQL\n' >> "$output" + env_line POSTGRES_USER "$POSTGRES_USER_VALUE" "$output" + env_line POSTGRES_PASSWORD "$POSTGRES_PASSWORD_VALUE" "$output" + env_line POSTGRES_DB "$POSTGRES_DB_VALUE" "$output" + + printf '\n# Application\n' >> "$output" + env_line WEBAPP_ENABLED "$WEBAPP_ENABLED_VALUE" "$output" + env_line WEBAPP_SESSION_SECRET "$WEBAPP_SESSION_SECRET_VALUE" "$output" + env_line WEBHOOK_SECRET_TOKEN "$WEBHOOK_SECRET_TOKEN_VALUE" "$output" + env_line TRUSTED_PROXIES "$TRUSTED_PROXIES_VALUE" "$output" + + printf '\n# Remnawave Panel\n' >> "$output" + env_line PANEL_API_URL "$PANEL_API_URL_VALUE" "$output" + env_line PANEL_API_KEY "$PANEL_API_KEY_VALUE" "$output" + env_line PANEL_WEBHOOK_SECRET "$PANEL_WEBHOOK_SECRET_VALUE" "$output" + + append_preserved_env "$output" +} + +write_env_file() { + section "Review .env" + display_env_summary + if ! confirm "Write .env now?" 1; then + warn "Skipped .env write." + return 0 + fi + tmp="$TARGET_DIR/.env.tmp.$$" + render_env_file "$tmp" + if [ -e "$ENV_PATH" ]; then + backup=$(backup_path "$ENV_PATH") + cp "$ENV_PATH" "$backup" + info "Backed up $ENV_PATH to $(basename "$backup")" + fi + mv "$tmp" "$ENV_PATH" + ok "Wrote $ENV_PATH" +} + +prepare_data_directory() { + section "Prepare data directory" + data_dir="$TARGET_DIR/data" + mkdir -p "$data_dir/themes" "$data_dir/webapp-logo" "$data_dir/webapp-emoji" "$data_dir/backups" + if [ ! -f "$data_dir/locales-overrides.json" ]; then + printf '{}\n' > "$data_dir/locales-overrides.json" + fi + if command -v chown >/dev/null 2>&1; then + if ! chown -R "$APP_UID:$APP_GID" "$data_dir" 2>/dev/null; then + warn "Could not chown data files. Run: sudo chown -R $APP_UID:$APP_GID data" + fi + fi + ok "Prepared $data_dir" +} + +require_docker() { + if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then + COMPOSE_STYLE="docker" + elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_STYLE="docker-compose" + else + fail "Docker Compose was not found." + return 1 + fi + if command -v docker >/dev/null 2>&1 && ! docker info >/dev/null 2>&1; then + fail "Docker is installed but not reachable. Check service/user permissions." + return 1 + fi +} + +compose() { + if [ "$COMPOSE_STYLE" = "docker" ]; then + docker compose "$@" + else + docker-compose "$@" + fi +} + +run_compose() { + if [ "$COMPOSE_STYLE" = "docker" ]; then + color "+ docker compose $*" "$DIM" + else + color "+ docker-compose $*" "$DIM" + fi + printf '\n' + compose "$@" +} + +start_stack() { + section "Start Docker stack" + require_docker || return 1 + (cd "$TARGET_DIR" && run_compose pull) || return 1 + (cd "$TARGET_DIR" && run_compose up -d) || return 1 + (cd "$TARGET_DIR" && run_compose ps) || true + ok "Stack command completed." +} + +validate_stack() { + section "Validate stack" + require_docker || return 1 + (cd "$TARGET_DIR" && run_compose ps) || true + (cd "$TARGET_DIR" && run_compose logs --tail 80 migrate) || true + ok "Validation commands completed." +} + +download_importer() { + importer="$TARGET_DIR/$IMPORTER_CACHE_PATH" + mkdir -p "$(dirname "$importer")" + if [ -f "$importer" ] && confirm "Use cached importer at $importer?" 1 >&2; then + printf '%s' "$importer" + return 0 + fi + url=$(raw_url "$SOURCE_REPO" "$SOURCE_REF" "backend/scripts/import_legacy.py") + tmp="$TARGET_DIR/.import_legacy.$$" + download_to "$url" "$tmp" || { + rm -f "$tmp" + fail "Could not download $url" + return 1 + } + if [ -f "$importer" ]; then + backup=$(backup_path "$importer") + cp "$importer" "$backup" + info "Backed up $importer to $(basename "$backup")" >&2 + fi + mv "$tmp" "$importer" + ok "Cached importer at $importer" >&2 + printf '%s' "$importer" +} + +local_target_dsn() { + printf 'postgresql://%s:%s@postgres:5432/%s' "$POSTGRES_USER_VALUE" "$POSTGRES_PASSWORD_VALUE" "$POSTGRES_DB_VALUE" +} + +run_import_command() { + dry="$1" + set -- run --rm \ + -v "$IMPORTER_PATH:/app/backend/scripts/import_legacy.py:ro" + if [ -n "$TARIFF_MAP_PATH" ]; then + set -- "$@" -v "$TARIFF_MAP_PATH:/tmp/tariff-map.json:ro" + fi + set -- "$@" backend python backend/scripts/import_legacy.py \ + --source-type remnashop \ + --source-dsn "$SOURCE_DSN" \ + --source-schema "$SOURCE_SCHEMA" \ + --target-dsn "$TARGET_DSN" + if [ -n "$TARIFF_MAP_PATH" ]; then + set -- "$@" --tariff-map-json /tmp/tariff-map.json + fi + if [ "$dry" = "1" ]; then + set -- "$@" --dry-run + fi + (cd "$TARGET_DIR" && run_compose "$@") +} + +run_legacy_migration() { + section "Legacy migration" + ENV_PATH="$TARGET_DIR/.env" + if [ ! -f "$ENV_PATH" ]; then + fail ".env not found. Install or generate configuration first." + return 1 + fi + require_docker || return 1 + POSTGRES_USER_VALUE="$(env_get POSTGRES_USER '')" + POSTGRES_PASSWORD_VALUE="$(env_get POSTGRES_PASSWORD '')" + POSTGRES_DB_VALUE="$(env_get POSTGRES_DB '')" + + choose "Source bot" "1" "1|2" \ + "1. Remnashop" \ + "2. Skip migration" + [ "$CHOICE_VALUE" = "2" ] && return 0 + + prompt_value "Source Remnashop PostgreSQL DSN" "${REMNASHOP_SOURCE_DSN:-}" 1 0 "" + SOURCE_DSN="$PROMPT_VALUE" + prompt_value "Source schema" "public" 1 0 "" + SOURCE_SCHEMA="$PROMPT_VALUE" + + choose "Target database" "1" "1|2" \ + "1. This Docker Compose stack database (recommended)" \ + "2. Manual target DSN" + if [ "$CHOICE_VALUE" = "1" ]; then + TARGET_DSN="$(local_target_dsn)" + info "Target DSN points to the Compose postgres service." + else + prompt_value "Target PostgreSQL DSN" "" 1 0 "" + TARGET_DSN="$PROMPT_VALUE" + fi + + prompt_value "Optional tariff map JSON path (empty to skip)" "" 0 0 "" + TARIFF_MAP_PATH="$PROMPT_VALUE" + if [ -n "$TARIFF_MAP_PATH" ]; then + tariff_map_dir=$(dirname "$TARIFF_MAP_PATH") + if [ ! -d "$tariff_map_dir" ]; then + fail "Tariff map directory not found: $tariff_map_dir" + return 1 + fi + TARIFF_MAP_PATH=$(cd "$tariff_map_dir" && pwd)/$(basename "$TARIFF_MAP_PATH") + if [ ! -f "$TARIFF_MAP_PATH" ]; then + fail "Tariff map not found: $TARIFF_MAP_PATH" + return 1 + fi + fi + + IMPORTER_PATH="$(download_importer)" || return 1 + + section "Dry-run import" + if ! run_import_command 1; then + fail "Dry-run failed. Fix the connection/settings before importing." + return 1 + fi + if ! confirm "Apply this migration for real?" 0; then + warn "Migration not applied." + return 0 + fi + + section "Apply import" + run_import_command 0 || return 1 + if confirm "Restart backend and worker so setting overrides are reloaded?" 1; then + (cd "$TARGET_DIR" && run_compose restart backend worker) || true + fi + ok "Legacy migration completed." +} + +installation_directory() { + prompt_value "Install directory" "${MINISHOP_INSTALL_DIR:-$(pwd)}" 1 0 "" + mkdir -p "$(dirname "$PROMPT_VALUE")" + TARGET_DIR=$(cd "$(dirname "$PROMPT_VALUE")" && pwd)/$(basename "$PROMPT_VALUE") + mkdir -p "$TARGET_DIR" +} + +github_source() { + prompt_value "GitHub repository" "$DEFAULT_REPO" 1 0 "" + SOURCE_REPO="$PROMPT_VALUE" + prompt_value "Git ref/branch/tag for raw files" "$DEFAULT_REF" 1 0 "" + SOURCE_REF="$PROMPT_VALUE" +} + +install_flow() { + with_migration="$1" + installation_directory || return 1 + github_source || return 1 + choose_profile + ENV_PATH="$TARGET_DIR/.env" + if [ -f "$ENV_PATH" ]; then + warn "Existing .env found at $ENV_PATH; wizard will preserve unknown values." + fi + prompt_common_env || return 1 + download_profile_files || return 1 + write_env_file || return 1 + mkdir -p "$TARGET_DIR/$INSTALL_STATE_DIR" + prepare_data_directory || return 1 + if confirm "Start Docker Compose stack now?" 1; then + start_stack || return 1 + fi + if [ "$with_migration" = "1" ]; then + run_legacy_migration + elif confirm "Run a legacy bot migration now?" 0; then + run_legacy_migration + fi +} + +migration_only_flow() { + installation_directory || return 1 + github_source || return 1 + run_legacy_migration +} + +download_only_flow() { + installation_directory || return 1 + github_source || return 1 + choose_profile + download_profile_files +} + +health_flow() { + installation_directory || return 1 + validate_stack +} + +main_menu() { + while :; do + banner + choose "Main menu" "1" "1|2|3|4|5|6" \ + "1. Install new stack" \ + "2. Install new stack and run legacy migration" \ + "3. Run legacy migration only" \ + "4. Download/update deployment files only" \ + "5. Validate current stack" \ + "6. Exit" + case "$CHOICE_VALUE" in + 1) install_flow 0 ;; + 2) install_flow 1 ;; + 3) migration_only_flow ;; + 4) download_only_flow ;; + 5) health_flow ;; + 6) printf 'Bye.\n'; return 0 ;; + esac + status=$? + if [ "$status" -ne 0 ]; then + fail "Step failed with status $status." + fi + pause + done +} + +case "${1:-}" in + -h|--help) + print_help + exit 0 + ;; +esac + +main_menu diff --git a/tests/test_install_script.py b/tests/test_install_script.py new file mode 100644 index 0000000..b185524 --- /dev/null +++ b/tests/test_install_script.py @@ -0,0 +1,39 @@ +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +INSTALL_SCRIPT = REPO_ROOT / "scripts" / "install.sh" + + +def test_shell_installer_help_does_not_require_python(): + if not shutil.which("sh"): + pytest.skip("sh is not available on this platform") + + result = subprocess.run( + ["sh", str(INSTALL_SCRIPT), "--help"], + check=True, + text=True, + capture_output=True, + ) + + assert "MINISHOP_INSTALL_REPO" in result.stdout + assert "dry-run" in result.stdout + + +def test_shell_installer_is_the_only_install_entrypoint(): + assert INSTALL_SCRIPT.exists() + assert not (REPO_ROOT / "scripts" / "install.py").exists() + + +def test_shell_installer_downloads_raw_files_and_runs_import_in_container(): + script = INSTALL_SCRIPT.read_text(encoding="utf-8") + + assert script.startswith("#!/bin/sh") + assert "raw.githubusercontent.com" in script + assert "git clone" not in script + assert "backend python backend/scripts/import_legacy.py" in script + assert "--dry-run" in script + assert "Install new stack and run legacy migration" in script