#!/usr/bin/env bash # Деплой по SSH: отдельно фронт (сборка + rsync dist), бэк (rsync + npm ci + prisma migrate). # # Требования: bash, ssh; на Linux/macOS используется rsync, на Git Bash/WIN — tar|ssh (обход проблем cwRsync с путами). # # Конфиг: переменные окружения или файл scripts/deploy.env (скопируйте из scripts/deploy.env.example). # # Примеры: # ./scripts/deploy-ssh.sh --backend-only # ./scripts/deploy-ssh.sh --frontend-only # ./scripts/deploy-ssh.sh --all # DEPLOY_HOST=10.0.0.5 ./scripts/deploy-ssh.sh -b set -euo pipefail # Git Bash вызывает Win32 ssh.exe: аргументы вроде /opt/... иначе подменяются → «mkdir: missing operand». case "$(uname -s 2>/dev/null)" in MINGW* | MSYS*) export MSYS2_ARG_CONV_EXCL="*" ;; esac SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" # Загрузка deploy.env без перезаписи уже экспортированных переменных if [[ -f "$SCRIPT_DIR/deploy.env" ]]; then set -a # shellcheck source=/dev/null source "$SCRIPT_DIR/deploy.env" set +a fi DEPLOY_HOST="${DEPLOY_HOST:-}" DEPLOY_USER="${DEPLOY_USER:-root}" DEPLOY_PATH="${DEPLOY_PATH:-/opt/craftshop}" DEPLOY_FRONTEND_DIST="${DEPLOY_FRONTEND_DIST:-$DEPLOY_PATH/www}" DEPLOY_SSH_IDENTITY="${DEPLOY_SSH_IDENTITY:-}" DEPLOY_RESTART_CMD="${DEPLOY_RESTART_CMD:-}" # от root: systemctl restart craftshop-api; от непривилегированного: sudo … # При SSH от root файлы после rsync оказываются root:root; если API в systemd под другим пользователем (bootstrap: deploy), нужен chown: DEPLOY_SERVER_OWNER="${DEPLOY_SERVER_OWNER:-deploy}" DEPLOY_SKIP_CHOWN="${DEPLOY_SKIP_CHOWN:-0}" # 1 — не вызывать chown (например API тоже под root) RSYNC_OPTS=(-az --delete --human-readable --progress) SSH_OPTS=() if [[ -n "${DEPLOY_SSH_IDENTITY}" ]]; then SSH_OPTS+=(-i "$DEPLOY_SSH_IDENTITY") fi REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}" SSH_BASE=(ssh "${SSH_OPTS[@]}" -o StrictHostKeyChecking=accept-new "$REMOTE") should_use_tar_transport() { case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN_NT*) return 0 ;; *) return 1 ;; esac } usage() { cat <&2; usage >&2; exit 1 ;; esac shift done if [[ -z "$DEPLOY_HOST" ]]; then echo "Укажите DEPLOY_HOST (или добавьте в scripts/deploy.env)" >&2 exit 1 fi remote_exec() { "${SSH_BASE[@]}" "$@" } if [[ -n "$DRY_RUN" ]]; then RSYNC_OPTS+=(--dry-run) fi # Строка для rsync -e (одна подкоманда; пути без пробелов в -i надёжнее) build_rsync_rsh() { printf '%q ' ssh "${SSH_OPTS[@]}" -o StrictHostKeyChecking=accept-new } deploy_backend() { remote_exec mkdir -p "$DEPLOY_PATH/server" remote_exec mkdir -p "$DEPLOY_PATH/shared" if should_use_tar_transport; then echo ">>> Бэкенд (server): tar|ssh → $REMOTE:$DEPLOY_PATH/server/" if [[ -n "$DRY_RUN" ]]; then echo "(dry-run) без передачи tar" else ( cd "$ROOT/server" || exit 1 tar -czf - \ --exclude=node_modules \ --exclude=uploads \ --exclude=.git \ --exclude='*.db' \ --exclude=.env \ --exclude=.dev_env \ . ) | "${SSH_BASE[@]}" "mkdir -p ${DEPLOY_PATH}/server && tar xzf - -C ${DEPLOY_PATH}/server" echo ">>> Бэкенд (shared): tar|ssh → $REMOTE:$DEPLOY_PATH/shared/" ( cd "$ROOT/shared" || exit 1 tar -czf - \ --exclude=.git \ . ) | "${SSH_BASE[@]}" "mkdir -p ${DEPLOY_PATH}/shared && tar xzf - -C ${DEPLOY_PATH}/shared" fi else echo ">>> Бэкенд (server): rsync → $REMOTE:$DEPLOY_PATH/server/" local rsh rsh="$(build_rsync_rsh)" rsync "${RSYNC_OPTS[@]}" \ -e "$rsh" \ --exclude node_modules \ --exclude uploads \ --exclude .git \ --exclude '*.db' \ --exclude .env \ --exclude .dev_env \ "${ROOT}/server/" "${REMOTE}:${DEPLOY_PATH}/server/" echo ">>> Бэкенд (shared): rsync → $REMOTE:$DEPLOY_PATH/shared/" rsync "${RSYNC_OPTS[@]}" \ -e "$rsh" \ --exclude .git \ "${ROOT}/shared/" "${REMOTE}:${DEPLOY_PATH}/shared/" fi if [[ -n "$DRY_RUN" ]]; then echo "(dry-run) пропуск удалённых команд npm/prisma" return 0 fi echo ">>> Бэкенд: npm ci, prisma generate, migrate deploy на сервере" remote_exec bash -lc "set -e cd \"$DEPLOY_PATH/server\" npm ci npx prisma generate npx prisma migrate deploy " if [[ "${DEPLOY_USER}" == "root" && "${DEPLOY_SKIP_CHOWN}" != "1" ]]; then echo ">>> Права на серверный каталог: chown ${DEPLOY_SERVER_OWNER} (деплой от root)" remote_exec chown -R "${DEPLOY_SERVER_OWNER}:${DEPLOY_SERVER_OWNER}" "$DEPLOY_PATH/server" remote_exec chown -R "${DEPLOY_SERVER_OWNER}:${DEPLOY_SERVER_OWNER}" "$DEPLOY_PATH/shared" fi if [[ -n "${DEPLOY_RESTART_CMD}" ]]; then echo ">>> Рестарт: $DEPLOY_RESTART_CMD" remote_exec bash -lc "$DEPLOY_RESTART_CMD" elif [[ -z "$DRY_RUN" ]]; then echo "" >&2 echo "ВНИМАНИЕ: код сервера обновлён, но процесс Node не перезапущен." >&2 echo " Без рестарта новые маршруты (и правки API) не появятся." >&2 echo " Задайте в deploy.env: DEPLOY_RESTART_CMD='systemctl restart <ваш-сервис-api>'" >&2 echo "" >&2 fi } deploy_frontend() { if [[ -z "$SKIP_BUILD" ]]; then echo ">>> Фронт: npm ci и npm run build (локально)" # Windows: ESLint/typescript-eslint тянут @unrs/*.node — npm ci часто получает EPERM unlink, если файл держит Node/IDE или остался мусор .resolver-binding-* после сбоя. if should_use_tar_transport; then echo ">>> (Windows/Git Bash) перед npm ci: снимаем блокировки нативных .node (@unrs, @rolldown, .resolver-binding-*)" echo ">>> Подсказка: остановите «npm run dev» / dev-серверы и IDE, если EPERM останется." rm -rf "$ROOT/client/node_modules/@unrs" "$ROOT/client/node_modules/@rolldown" 2>/dev/null || true ( cd "$ROOT/client/node_modules" 2>/dev/null || exit 0 shopt -s nullglob for x in ./.resolver-binding-*; do [[ -d "$x" ]] && rm -rf "$x" done ) fi (cd "$ROOT/client" && npm ci && npm run build) || { echo "" >&2 echo "Сборка фронта не удалась. На Windows часто EPERM на .node — закройте процессы Node (dev-сервер), повторите." >&2 echo "Или соберите фронт вручную (cd client && npm run build), затем: $0 --frontend-only --skip-build" >&2 exit 1 } else echo ">>> Фронт: сборка пропущена (--skip-build)" if [[ ! -d "$ROOT/client/dist" ]]; then echo "Нет $ROOT/client/dist — выполните сборку без --skip-build" >&2 exit 1 fi fi remote_exec mkdir -p "$DEPLOY_FRONTEND_DIST" if should_use_tar_transport; then echo ">>> Фронт: tar|ssh dist → $REMOTE:$DEPLOY_FRONTEND_DIST/" if [[ -n "$DRY_RUN" ]]; then echo "(dry-run) без передачи tar (www)" else remote_exec "mkdir -p ${DEPLOY_FRONTEND_DIST} && find ${DEPLOY_FRONTEND_DIST} -mindepth 1 -delete 2>/dev/null || true" ( cd "$ROOT/client/dist" || exit 1 tar -czf - . ) | "${SSH_BASE[@]}" "mkdir -p ${DEPLOY_FRONTEND_DIST} && tar xzf - -C ${DEPLOY_FRONTEND_DIST}" fi else echo ">>> Фронт: rsync dist → $REMOTE:$DEPLOY_FRONTEND_DIST/" local rsh rsh="$(build_rsync_rsh)" rsync "${RSYNC_OPTS[@]}" \ -e "$rsh" \ "${ROOT}/client/dist/" "${REMOTE}:${DEPLOY_FRONTEND_DIST}/" fi echo ">>> Фронт готов (проверьте nginx/root на путь $DEPLOY_FRONTEND_DIST)" } case "$TARGET" in backend) deploy_backend ;; frontend) deploy_frontend ;; all) deploy_backend deploy_frontend ;; *) echo "internal: bad TARGET=$TARGET" >&2; exit 1 ;; esac echo "Готово."