1169 lines
36 KiB
Bash
1169 lines
36 KiB
Bash
#!/bin/bash
|
|
|
|
# ============================================
|
|
# CaStAD Production Server - 완전 자동 배포 스크립트
|
|
# ============================================
|
|
# 도메인:
|
|
# - castad.ktenterprise.net
|
|
# - ado2.whitedonkey.kr
|
|
# 서버: qm391-0282.cafe24.com (Ubuntu 24.04)
|
|
# ============================================
|
|
|
|
set -e # 오류 시 중단
|
|
|
|
# 색상 정의
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
CYAN='\033[0;36m'
|
|
BLUE='\033[0;34m'
|
|
BOLD='\033[1m'
|
|
NC='\033[0m'
|
|
|
|
log() { echo -e "${GREEN}[✓]${NC} $1"; }
|
|
error() { echo -e "${RED}[✗]${NC} $1"; }
|
|
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
|
info() { echo -e "${CYAN}[i]${NC} $1"; }
|
|
header() { echo -e "\n${BOLD}${BLUE}═══ $1 ═══${NC}\n"; }
|
|
|
|
# ============================================
|
|
# 설정
|
|
# ============================================
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
cd "$SCRIPT_DIR"
|
|
|
|
# 포트 설정 (기본값 - 충돌 시 자동으로 다른 포트 탐색)
|
|
BACKEND_PORT_DEFAULT=3001
|
|
INSTAGRAM_PORT_DEFAULT=5003
|
|
FRONTEND_PORT=3002 # 빌드 시에만 사용
|
|
|
|
# 실제 사용할 포트 (find_available_ports에서 설정)
|
|
BACKEND_PORT=$BACKEND_PORT_DEFAULT
|
|
INSTAGRAM_PORT=$INSTAGRAM_PORT_DEFAULT
|
|
|
|
# 도메인
|
|
DOMAIN_PRIMARY="castad.ktenterprise.net"
|
|
DOMAIN_SECONDARY="ado2.whitedonkey.kr"
|
|
|
|
# 디렉토리
|
|
APP_DIR="$SCRIPT_DIR"
|
|
NGINX_AVAILABLE="/etc/nginx/sites-available"
|
|
NGINX_ENABLED="/etc/nginx/sites-enabled"
|
|
LOG_DIR="$APP_DIR/logs"
|
|
|
|
# 명령어
|
|
ACTION=${1:-help}
|
|
|
|
# ============================================
|
|
# 배너 출력
|
|
# ============================================
|
|
show_banner() {
|
|
echo ""
|
|
echo -e "${BOLD}${CYAN}╔════════════════════════════════════════════════════════════╗${NC}"
|
|
echo -e "${BOLD}${CYAN}║ 🍮 CaStAD (카스타드) Production Server ║${NC}"
|
|
echo -e "${BOLD}${CYAN}║ AI-Powered Pension Marketing Video Platform ║${NC}"
|
|
echo -e "${BOLD}${CYAN}╠════════════════════════════════════════════════════════════╣${NC}"
|
|
echo -e "${BOLD}${CYAN}║ 도메인: ${DOMAIN_PRIMARY} ║${NC}"
|
|
echo -e "${BOLD}${CYAN}║ ${DOMAIN_SECONDARY} ║${NC}"
|
|
echo -e "${BOLD}${CYAN}╚════════════════════════════════════════════════════════════╝${NC}"
|
|
echo ""
|
|
}
|
|
|
|
# ============================================
|
|
# 환경 검사
|
|
# ============================================
|
|
check_environment() {
|
|
header "환경 검사"
|
|
|
|
local ERRORS=0
|
|
|
|
# Node.js
|
|
if command -v node &> /dev/null; then
|
|
local NODE_VER=$(node -v)
|
|
log "Node.js: $NODE_VER"
|
|
else
|
|
error "Node.js가 설치되지 않았습니다"
|
|
ERRORS=$((ERRORS + 1))
|
|
fi
|
|
|
|
# npm
|
|
if command -v npm &> /dev/null; then
|
|
local NPM_VER=$(npm -v)
|
|
log "npm: v$NPM_VER"
|
|
else
|
|
error "npm이 설치되지 않았습니다"
|
|
ERRORS=$((ERRORS + 1))
|
|
fi
|
|
|
|
# PM2
|
|
if command -v pm2 &> /dev/null; then
|
|
local PM2_VER=$(pm2 -v)
|
|
log "PM2: v$PM2_VER"
|
|
else
|
|
warn "PM2가 설치되지 않았습니다. 설치합니다..."
|
|
npm install -g pm2
|
|
log "PM2 설치 완료"
|
|
fi
|
|
|
|
# Python3
|
|
if command -v python3 &> /dev/null; then
|
|
local PY_VER=$(python3 --version)
|
|
log "Python: $PY_VER"
|
|
else
|
|
error "Python3이 설치되지 않았습니다"
|
|
ERRORS=$((ERRORS + 1))
|
|
fi
|
|
|
|
# pip3
|
|
if command -v pip3 &> /dev/null; then
|
|
log "pip3: 설치됨"
|
|
else
|
|
warn "pip3이 설치되지 않았습니다"
|
|
fi
|
|
|
|
# FFmpeg
|
|
if command -v ffmpeg &> /dev/null; then
|
|
log "FFmpeg: 설치됨"
|
|
else
|
|
warn "FFmpeg가 설치되지 않았습니다 - 영상 렌더링 불가"
|
|
info "설치: sudo apt-get install -y ffmpeg"
|
|
fi
|
|
|
|
# Chromium (Puppeteer용)
|
|
if command -v chromium-browser &> /dev/null || command -v chromium &> /dev/null; then
|
|
log "Chromium: 설치됨"
|
|
else
|
|
warn "Chromium이 설치되지 않았습니다 - 영상 렌더링 불가"
|
|
info "설치: sudo apt-get install -y chromium-browser"
|
|
fi
|
|
|
|
# nginx
|
|
if command -v nginx &> /dev/null; then
|
|
local NGINX_VER=$(nginx -v 2>&1 | cut -d'/' -f2)
|
|
log "nginx: $NGINX_VER"
|
|
else
|
|
error "nginx가 설치되지 않았습니다"
|
|
ERRORS=$((ERRORS + 1))
|
|
fi
|
|
|
|
# Git
|
|
if command -v git &> /dev/null; then
|
|
log "Git: 설치됨"
|
|
else
|
|
error "Git이 설치되지 않았습니다"
|
|
ERRORS=$((ERRORS + 1))
|
|
fi
|
|
|
|
if [ $ERRORS -gt 0 ]; then
|
|
error "필수 요구사항 $ERRORS 개가 누락되었습니다"
|
|
return 1
|
|
fi
|
|
|
|
log "환경 검사 완료"
|
|
return 0
|
|
}
|
|
|
|
# ============================================
|
|
# 포트 사용 가능 여부 확인
|
|
# ============================================
|
|
is_port_available() {
|
|
local PORT=$1
|
|
# ss 명령어로 포트 사용 여부 확인
|
|
if ss -tlnp 2>/dev/null | grep -q ":${PORT} "; then
|
|
return 1 # 사용 중
|
|
fi
|
|
return 0 # 사용 가능
|
|
}
|
|
|
|
# ============================================
|
|
# 사용 가능한 포트 찾기
|
|
# ============================================
|
|
find_free_port() {
|
|
local START_PORT=$1
|
|
local MAX_TRIES=${2:-20}
|
|
local PORT=$START_PORT
|
|
|
|
for ((i=0; i<MAX_TRIES; i++)); do
|
|
if is_port_available $PORT; then
|
|
echo $PORT
|
|
return 0
|
|
fi
|
|
PORT=$((PORT + 1))
|
|
done
|
|
|
|
# 찾지 못함
|
|
echo ""
|
|
return 1
|
|
}
|
|
|
|
# ============================================
|
|
# 포트 자동 탐색 및 설정
|
|
# ============================================
|
|
find_available_ports() {
|
|
header "포트 자동 탐색"
|
|
|
|
# CaStAD PM2 프로세스가 사용 중인 포트는 재사용 가능 (재시작할 것이므로)
|
|
local CASTAD_BACKEND_PID=$(pm2 pid castad-backend 2>/dev/null || echo "")
|
|
local CASTAD_INSTA_PID=$(pm2 pid castad-instagram 2>/dev/null || echo "")
|
|
|
|
# Backend 포트 찾기
|
|
if is_port_available $BACKEND_PORT_DEFAULT; then
|
|
BACKEND_PORT=$BACKEND_PORT_DEFAULT
|
|
log "Backend 포트: ${BACKEND_PORT} (기본값 사용)"
|
|
else
|
|
# 기존 CaStAD 프로세스인지 확인
|
|
local PORT_PID=$(ss -tlnp 2>/dev/null | grep ":${BACKEND_PORT_DEFAULT} " | grep -oP 'pid=\K[0-9]+' | head -1)
|
|
if [ -n "$CASTAD_BACKEND_PID" ] && [ "$PORT_PID" == "$CASTAD_BACKEND_PID" ]; then
|
|
BACKEND_PORT=$BACKEND_PORT_DEFAULT
|
|
info "Backend 포트: ${BACKEND_PORT} (기존 CaStAD 프로세스, 재시작 시 교체)"
|
|
else
|
|
# 다른 프로세스가 사용 중 - 새 포트 찾기
|
|
local NEW_PORT=$(find_free_port $BACKEND_PORT_DEFAULT)
|
|
if [ -z "$NEW_PORT" ]; then
|
|
error "사용 가능한 Backend 포트를 찾을 수 없습니다 (${BACKEND_PORT_DEFAULT}~$((BACKEND_PORT_DEFAULT+19)))"
|
|
return 1
|
|
fi
|
|
BACKEND_PORT=$NEW_PORT
|
|
warn "Backend 포트 ${BACKEND_PORT_DEFAULT} 사용 중 → ${BACKEND_PORT} 로 변경"
|
|
fi
|
|
fi
|
|
|
|
# Instagram 서비스 포트 찾기
|
|
if is_port_available $INSTAGRAM_PORT_DEFAULT; then
|
|
INSTAGRAM_PORT=$INSTAGRAM_PORT_DEFAULT
|
|
log "Instagram 포트: ${INSTAGRAM_PORT} (기본값 사용)"
|
|
else
|
|
# 기존 CaStAD 프로세스인지 확인
|
|
local PORT_PID=$(ss -tlnp 2>/dev/null | grep ":${INSTAGRAM_PORT_DEFAULT} " | grep -oP 'pid=\K[0-9]+' | head -1)
|
|
if [ -n "$CASTAD_INSTA_PID" ] && [ "$PORT_PID" == "$CASTAD_INSTA_PID" ]; then
|
|
INSTAGRAM_PORT=$INSTAGRAM_PORT_DEFAULT
|
|
info "Instagram 포트: ${INSTAGRAM_PORT} (기존 CaStAD 프로세스, 재시작 시 교체)"
|
|
else
|
|
# 다른 프로세스가 사용 중 - 새 포트 찾기
|
|
local NEW_PORT=$(find_free_port $INSTAGRAM_PORT_DEFAULT)
|
|
if [ -z "$NEW_PORT" ]; then
|
|
error "사용 가능한 Instagram 포트를 찾을 수 없습니다 (${INSTAGRAM_PORT_DEFAULT}~$((INSTAGRAM_PORT_DEFAULT+19)))"
|
|
return 1
|
|
fi
|
|
INSTAGRAM_PORT=$NEW_PORT
|
|
warn "Instagram 포트 ${INSTAGRAM_PORT_DEFAULT} 사용 중 → ${INSTAGRAM_PORT} 로 변경"
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
info " 최종 포트 설정:"
|
|
info " Backend: ${BACKEND_PORT}"
|
|
info " Instagram: ${INSTAGRAM_PORT}"
|
|
info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
|
|
log "포트 탐색 완료"
|
|
return 0
|
|
}
|
|
|
|
# ============================================
|
|
# 의존성 설치
|
|
# ============================================
|
|
install_dependencies() {
|
|
header "의존성 설치"
|
|
|
|
# Node.js 의존성
|
|
if [ ! -d "node_modules" ] || [ "$1" == "force" ]; then
|
|
log "Frontend 패키지 설치 중..."
|
|
npm install --legacy-peer-deps --silent
|
|
else
|
|
info "Frontend 패키지 이미 설치됨 (강제 설치: --force)"
|
|
fi
|
|
|
|
if [ ! -d "server/node_modules" ] || [ "$1" == "force" ]; then
|
|
log "Backend 패키지 설치 중..."
|
|
cd server && npm install --legacy-peer-deps --silent && cd ..
|
|
else
|
|
info "Backend 패키지 이미 설치됨"
|
|
fi
|
|
|
|
# Python 의존성 (Instagram 서비스)
|
|
if [ -f "server/instagram/requirements.txt" ]; then
|
|
if ! python3 -c "import instagrapi" 2>/dev/null; then
|
|
log "Instagram 서비스 Python 패키지 설치 중..."
|
|
pip3 install -q -r server/instagram/requirements.txt 2>/dev/null || \
|
|
pip3 install --break-system-packages -q -r server/instagram/requirements.txt 2>/dev/null || true
|
|
else
|
|
info "Instagram Python 패키지 이미 설치됨"
|
|
fi
|
|
fi
|
|
|
|
log "의존성 설치 완료"
|
|
}
|
|
|
|
# ============================================
|
|
# 디렉토리 설정
|
|
# ============================================
|
|
setup_directories() {
|
|
header "디렉토리 설정"
|
|
|
|
mkdir -p "$LOG_DIR"
|
|
mkdir -p server/downloads
|
|
mkdir -p server/temp
|
|
mkdir -p server/uploads
|
|
|
|
log "디렉토리 생성 완료"
|
|
}
|
|
|
|
# ============================================
|
|
# 환경 변수 검사
|
|
# ============================================
|
|
check_env_file() {
|
|
header "환경 변수 검사"
|
|
|
|
if [ ! -f ".env" ]; then
|
|
if [ -f ".env.production.example" ]; then
|
|
warn ".env 파일이 없습니다. 템플릿에서 생성합니다..."
|
|
cp .env.production.example .env
|
|
warn ".env 파일을 편집하여 API 키를 설정하세요!"
|
|
warn "nano .env"
|
|
else
|
|
error ".env 파일이 없습니다!"
|
|
return 1
|
|
fi
|
|
else
|
|
log ".env 파일 확인됨"
|
|
fi
|
|
|
|
# 필수 환경변수 검사
|
|
source .env 2>/dev/null || true
|
|
|
|
if [ -z "$JWT_SECRET" ] || [ "$JWT_SECRET" == "your-super-secret-jwt-key-change-this-in-production" ]; then
|
|
warn "JWT_SECRET이 기본값입니다. 변경하세요!"
|
|
fi
|
|
|
|
if [ -z "$VITE_GEMINI_API_KEY" ] || [ "$VITE_GEMINI_API_KEY" == "your-gemini-api-key" ]; then
|
|
warn "VITE_GEMINI_API_KEY가 설정되지 않았습니다"
|
|
fi
|
|
|
|
# 포트 환경변수 업데이트
|
|
local ENV_UPDATED=0
|
|
|
|
if grep -q "^PORT=" .env; then
|
|
local OLD_PORT=$(grep "^PORT=" .env | cut -d'=' -f2)
|
|
if [ "$OLD_PORT" != "${BACKEND_PORT}" ]; then
|
|
sed -i "s/^PORT=.*/PORT=${BACKEND_PORT}/" .env
|
|
info ".env PORT: ${OLD_PORT} → ${BACKEND_PORT}"
|
|
ENV_UPDATED=1
|
|
fi
|
|
else
|
|
echo "PORT=${BACKEND_PORT}" >> .env
|
|
info ".env PORT: ${BACKEND_PORT} (추가)"
|
|
ENV_UPDATED=1
|
|
fi
|
|
|
|
if grep -q "^INSTAGRAM_SERVICE_PORT=" .env; then
|
|
local OLD_PORT=$(grep "^INSTAGRAM_SERVICE_PORT=" .env | cut -d'=' -f2)
|
|
if [ "$OLD_PORT" != "${INSTAGRAM_PORT}" ]; then
|
|
sed -i "s/^INSTAGRAM_SERVICE_PORT=.*/INSTAGRAM_SERVICE_PORT=${INSTAGRAM_PORT}/" .env
|
|
info ".env INSTAGRAM_SERVICE_PORT: ${OLD_PORT} → ${INSTAGRAM_PORT}"
|
|
ENV_UPDATED=1
|
|
fi
|
|
else
|
|
echo "INSTAGRAM_SERVICE_PORT=${INSTAGRAM_PORT}" >> .env
|
|
info ".env INSTAGRAM_SERVICE_PORT: ${INSTAGRAM_PORT} (추가)"
|
|
ENV_UPDATED=1
|
|
fi
|
|
|
|
if grep -q "^INSTAGRAM_SERVICE_URL=" .env; then
|
|
sed -i "s|^INSTAGRAM_SERVICE_URL=.*|INSTAGRAM_SERVICE_URL=http://localhost:${INSTAGRAM_PORT}|" .env
|
|
else
|
|
echo "INSTAGRAM_SERVICE_URL=http://localhost:${INSTAGRAM_PORT}" >> .env
|
|
fi
|
|
|
|
if [ $ENV_UPDATED -eq 1 ]; then
|
|
log ".env 파일 포트 설정 업데이트 완료"
|
|
else
|
|
log ".env 파일 포트 설정 확인 완료 (변경 없음)"
|
|
fi
|
|
}
|
|
|
|
# ============================================
|
|
# 프론트엔드 빌드
|
|
# ============================================
|
|
build_frontend() {
|
|
header "프론트엔드 빌드"
|
|
|
|
export NODE_ENV=production
|
|
export VITE_BACKEND_PORT=$BACKEND_PORT
|
|
|
|
log "Vite 빌드 중..."
|
|
npm run build
|
|
|
|
if [ ! -d "dist" ]; then
|
|
error "프론트엔드 빌드 실패!"
|
|
return 1
|
|
fi
|
|
|
|
log "프론트엔드 빌드 완료 (dist/)"
|
|
}
|
|
|
|
# ============================================
|
|
# PM2 Ecosystem 파일 생성
|
|
# ============================================
|
|
create_ecosystem() {
|
|
header "PM2 설정 생성"
|
|
|
|
cat > ecosystem.config.cjs << ECOSYSTEM
|
|
module.exports = {
|
|
apps: [
|
|
{
|
|
name: 'castad-backend',
|
|
script: 'server/index.js',
|
|
cwd: '${APP_DIR}',
|
|
instances: 1,
|
|
exec_mode: 'fork',
|
|
autorestart: true,
|
|
watch: false,
|
|
max_memory_restart: '1G',
|
|
env: {
|
|
NODE_ENV: 'production',
|
|
PORT: ${BACKEND_PORT}
|
|
},
|
|
error_file: '${LOG_DIR}/backend-error.log',
|
|
out_file: '${LOG_DIR}/backend-out.log',
|
|
log_date_format: 'YYYY-MM-DD HH:mm:ss Z'
|
|
},
|
|
{
|
|
name: 'castad-instagram',
|
|
script: 'server/instagram/instagram_service.py',
|
|
cwd: '${APP_DIR}',
|
|
interpreter: 'python3',
|
|
instances: 1,
|
|
autorestart: true,
|
|
watch: false,
|
|
env: {
|
|
INSTAGRAM_SERVICE_PORT: ${INSTAGRAM_PORT}
|
|
},
|
|
error_file: '${LOG_DIR}/instagram-error.log',
|
|
out_file: '${LOG_DIR}/instagram-out.log',
|
|
log_date_format: 'YYYY-MM-DD HH:mm:ss Z'
|
|
}
|
|
]
|
|
};
|
|
ECOSYSTEM
|
|
|
|
log "ecosystem.config.cjs 생성됨"
|
|
}
|
|
|
|
# ============================================
|
|
# nginx 설정 생성
|
|
# ============================================
|
|
create_nginx_config() {
|
|
header "nginx 설정 생성"
|
|
|
|
local NGINX_CONF="nginx/castad-server.conf"
|
|
|
|
cat > "$NGINX_CONF" << 'NGINX_CONFIG'
|
|
# ============================================
|
|
# CaStAD Nginx Configuration
|
|
# Generated by startserver.sh
|
|
# ============================================
|
|
|
|
upstream castad_backend {
|
|
server 127.0.0.1:BACKEND_PORT_PLACEHOLDER;
|
|
keepalive 64;
|
|
}
|
|
|
|
# HTTP → HTTPS 리다이렉트
|
|
server {
|
|
listen 80;
|
|
listen [::]:80;
|
|
server_name DOMAIN_PRIMARY_PLACEHOLDER DOMAIN_SECONDARY_PLACEHOLDER;
|
|
|
|
location /.well-known/acme-challenge/ {
|
|
root /var/www/certbot;
|
|
}
|
|
|
|
location / {
|
|
return 301 https://$host$request_uri;
|
|
}
|
|
}
|
|
|
|
# HTTPS 서버
|
|
server {
|
|
listen 443 ssl http2;
|
|
listen [::]:443 ssl http2;
|
|
server_name DOMAIN_PRIMARY_PLACEHOLDER DOMAIN_SECONDARY_PLACEHOLDER;
|
|
|
|
# SSL 인증서 (certbot이 자동 설정)
|
|
ssl_certificate /etc/letsencrypt/live/DOMAIN_PRIMARY_PLACEHOLDER/fullchain.pem;
|
|
ssl_certificate_key /etc/letsencrypt/live/DOMAIN_PRIMARY_PLACEHOLDER/privkey.pem;
|
|
|
|
ssl_protocols TLSv1.2 TLSv1.3;
|
|
ssl_prefer_server_ciphers on;
|
|
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
|
ssl_session_cache shared:SSL:10m;
|
|
ssl_session_timeout 10m;
|
|
|
|
# 보안 헤더
|
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
add_header X-Content-Type-Options "nosniff" always;
|
|
add_header X-XSS-Protection "1; mode=block" always;
|
|
|
|
# 로그
|
|
access_log /var/log/nginx/castad_access.log;
|
|
error_log /var/log/nginx/castad_error.log;
|
|
|
|
# 파일 업로드 크기
|
|
client_max_body_size 500M;
|
|
client_body_timeout 300s;
|
|
|
|
# 정적 파일 (프론트엔드)
|
|
root APP_DIR_PLACEHOLDER/dist;
|
|
index index.html;
|
|
|
|
# Gzip
|
|
gzip on;
|
|
gzip_vary on;
|
|
gzip_min_length 1024;
|
|
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml;
|
|
|
|
# 정적 자원 캐싱
|
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
|
expires 1y;
|
|
add_header Cache-Control "public, immutable";
|
|
try_files $uri =404;
|
|
}
|
|
|
|
# API 요청
|
|
location /api/ {
|
|
proxy_pass http://castad_backend;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection 'upgrade';
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_cache_bypass $http_upgrade;
|
|
proxy_read_timeout 300s;
|
|
}
|
|
|
|
# 렌더링 요청 (긴 타임아웃)
|
|
location /render {
|
|
proxy_pass http://castad_backend;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_read_timeout 600s;
|
|
proxy_buffering off;
|
|
}
|
|
|
|
# 다운로드/업로드/임시 파일
|
|
location ~ ^/(downloads|temp|uploads)/ {
|
|
proxy_pass http://castad_backend;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
}
|
|
|
|
# SPA 라우팅
|
|
location / {
|
|
try_files $uri $uri/ /index.html;
|
|
}
|
|
}
|
|
NGINX_CONFIG
|
|
|
|
# 플레이스홀더 치환
|
|
sed -i "s|BACKEND_PORT_PLACEHOLDER|${BACKEND_PORT}|g" "$NGINX_CONF"
|
|
sed -i "s|DOMAIN_PRIMARY_PLACEHOLDER|${DOMAIN_PRIMARY}|g" "$NGINX_CONF"
|
|
sed -i "s|DOMAIN_SECONDARY_PLACEHOLDER|${DOMAIN_SECONDARY}|g" "$NGINX_CONF"
|
|
sed -i "s|APP_DIR_PLACEHOLDER|${APP_DIR}|g" "$NGINX_CONF"
|
|
|
|
log "nginx 설정 파일 생성: $NGINX_CONF"
|
|
}
|
|
|
|
# ============================================
|
|
# nginx 설정 설치
|
|
# ============================================
|
|
install_nginx_config() {
|
|
header "nginx 설정 설치"
|
|
|
|
local NGINX_CONF="nginx/castad-server.conf"
|
|
local NGINX_NAME="castad"
|
|
|
|
if [ ! -f "$NGINX_CONF" ]; then
|
|
create_nginx_config
|
|
fi
|
|
|
|
# root 권한 확인
|
|
if [ "$EUID" -ne 0 ]; then
|
|
warn "nginx 설정 설치에 sudo 권한이 필요합니다"
|
|
info "수동 설치:"
|
|
info " sudo cp $NGINX_CONF $NGINX_AVAILABLE/$NGINX_NAME"
|
|
info " sudo ln -sf $NGINX_AVAILABLE/$NGINX_NAME $NGINX_ENABLED/"
|
|
info " sudo nginx -t && sudo systemctl reload nginx"
|
|
return 0
|
|
fi
|
|
|
|
# 설정 복사
|
|
cp "$NGINX_CONF" "$NGINX_AVAILABLE/$NGINX_NAME"
|
|
|
|
# 심볼릭 링크
|
|
ln -sf "$NGINX_AVAILABLE/$NGINX_NAME" "$NGINX_ENABLED/"
|
|
|
|
# 설정 검증
|
|
if nginx -t 2>/dev/null; then
|
|
systemctl reload nginx
|
|
log "nginx 설정 설치 및 적용 완료"
|
|
else
|
|
error "nginx 설정 오류!"
|
|
nginx -t
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# ============================================
|
|
# DNS 확인
|
|
# ============================================
|
|
check_dns() {
|
|
header "DNS 확인"
|
|
|
|
local SERVER_IP=$(curl -s ifconfig.me 2>/dev/null || curl -s icanhazip.com 2>/dev/null)
|
|
log "서버 IP: ${SERVER_IP}"
|
|
|
|
local DNS_OK=1
|
|
|
|
# Primary 도메인 확인
|
|
local PRIMARY_IP=$(dig +short "$DOMAIN_PRIMARY" 2>/dev/null | head -1)
|
|
if [ -z "$PRIMARY_IP" ]; then
|
|
error "${DOMAIN_PRIMARY}: DNS 레코드 없음"
|
|
DNS_OK=0
|
|
elif [ "$PRIMARY_IP" == "$SERVER_IP" ]; then
|
|
log "${DOMAIN_PRIMARY} → ${PRIMARY_IP} ✓"
|
|
else
|
|
warn "${DOMAIN_PRIMARY} → ${PRIMARY_IP} (서버 IP와 다름: ${SERVER_IP})"
|
|
DNS_OK=0
|
|
fi
|
|
|
|
# Secondary 도메인 확인
|
|
local SECONDARY_IP=$(dig +short "$DOMAIN_SECONDARY" 2>/dev/null | head -1)
|
|
if [ -z "$SECONDARY_IP" ]; then
|
|
error "${DOMAIN_SECONDARY}: DNS 레코드 없음"
|
|
DNS_OK=0
|
|
elif [ "$SECONDARY_IP" == "$SERVER_IP" ]; then
|
|
log "${DOMAIN_SECONDARY} → ${SECONDARY_IP} ✓"
|
|
else
|
|
warn "${DOMAIN_SECONDARY} → ${SECONDARY_IP} (서버 IP와 다름: ${SERVER_IP})"
|
|
DNS_OK=0
|
|
fi
|
|
|
|
if [ $DNS_OK -eq 0 ]; then
|
|
warn "DNS 설정을 확인하세요. SSL 발급이 실패할 수 있습니다."
|
|
return 1
|
|
fi
|
|
|
|
log "DNS 확인 완료"
|
|
return 0
|
|
}
|
|
|
|
# ============================================
|
|
# HTTP 전용 nginx 설정 생성 (SSL 발급 전용)
|
|
# ============================================
|
|
create_nginx_http_only() {
|
|
header "HTTP 전용 nginx 설정 생성"
|
|
|
|
local NGINX_CONF="nginx/castad-http.conf"
|
|
|
|
cat > "$NGINX_CONF" << NGINX_HTTP
|
|
# CaStAD HTTP-only config (for SSL certificate issuance)
|
|
server {
|
|
listen 80;
|
|
listen [::]:80;
|
|
server_name ${DOMAIN_PRIMARY} ${DOMAIN_SECONDARY};
|
|
|
|
# Let's Encrypt 인증용
|
|
location /.well-known/acme-challenge/ {
|
|
root /var/www/certbot;
|
|
}
|
|
|
|
# API 프록시
|
|
location /api/ {
|
|
proxy_pass http://127.0.0.1:${BACKEND_PORT};
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host \$host;
|
|
proxy_set_header X-Real-IP \$remote_addr;
|
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
|
}
|
|
|
|
# 렌더링
|
|
location /render {
|
|
proxy_pass http://127.0.0.1:${BACKEND_PORT};
|
|
proxy_read_timeout 600s;
|
|
}
|
|
|
|
# 다운로드/업로드/임시
|
|
location ~ ^/(downloads|temp|uploads)/ {
|
|
proxy_pass http://127.0.0.1:${BACKEND_PORT};
|
|
}
|
|
|
|
# 정적 파일
|
|
root ${APP_DIR}/dist;
|
|
index index.html;
|
|
|
|
location / {
|
|
try_files \$uri \$uri/ /index.html;
|
|
}
|
|
}
|
|
NGINX_HTTP
|
|
|
|
log "HTTP 전용 nginx 설정 생성: $NGINX_CONF"
|
|
}
|
|
|
|
# ============================================
|
|
# SSL 인증서 설치 (전체 자동화)
|
|
# ============================================
|
|
install_ssl() {
|
|
header "SSL 인증서 설치"
|
|
|
|
# root 권한 확인
|
|
if [ "$EUID" -ne 0 ]; then
|
|
warn "SSL 인증서 설치에 sudo 권한이 필요합니다"
|
|
info "실행: sudo ./startserver.sh ssl"
|
|
return 0
|
|
fi
|
|
|
|
# DNS 확인
|
|
check_dns || {
|
|
warn "DNS 문제가 있지만 계속 진행합니다..."
|
|
}
|
|
|
|
# certbot 설치 확인 및 자동 설치
|
|
if ! command -v certbot &> /dev/null; then
|
|
log "certbot 설치 중..."
|
|
apt-get update -qq
|
|
apt-get install -y -qq certbot python3-certbot-nginx
|
|
log "certbot 설치 완료"
|
|
else
|
|
log "certbot: 이미 설치됨"
|
|
fi
|
|
|
|
# certbot 디렉토리 생성
|
|
mkdir -p /var/www/certbot
|
|
|
|
# 기존 SSL 인증서 확인
|
|
if [ -f "/etc/letsencrypt/live/${DOMAIN_PRIMARY}/fullchain.pem" ]; then
|
|
log "기존 SSL 인증서 발견"
|
|
info "갱신 시도 중..."
|
|
certbot renew --quiet
|
|
log "SSL 인증서 갱신 완료"
|
|
else
|
|
# HTTP 전용 설정으로 먼저 nginx 구성
|
|
log "HTTP 전용 nginx 설정 적용 중..."
|
|
create_nginx_http_only
|
|
|
|
# nginx에 HTTP 설정 적용
|
|
cp nginx/castad-http.conf /etc/nginx/sites-available/castad
|
|
ln -sf /etc/nginx/sites-available/castad /etc/nginx/sites-enabled/
|
|
rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true
|
|
|
|
if nginx -t 2>/dev/null; then
|
|
systemctl reload nginx
|
|
log "HTTP nginx 설정 적용 완료"
|
|
else
|
|
error "nginx 설정 오류"
|
|
nginx -t
|
|
return 1
|
|
fi
|
|
|
|
# 잠시 대기 (nginx 재시작 완료 대기)
|
|
sleep 2
|
|
|
|
# SSL 인증서 발급
|
|
log "SSL 인증서 발급 중..."
|
|
if certbot --nginx \
|
|
-d "$DOMAIN_PRIMARY" \
|
|
-d "$DOMAIN_SECONDARY" \
|
|
--non-interactive \
|
|
--agree-tos \
|
|
--email "admin@${DOMAIN_PRIMARY}" \
|
|
--redirect; then
|
|
log "SSL 인증서 발급 성공!"
|
|
else
|
|
error "SSL 인증서 발급 실패"
|
|
info "수동으로 시도하세요: sudo certbot --nginx -d $DOMAIN_PRIMARY -d $DOMAIN_SECONDARY"
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
# HTTPS nginx 설정 적용
|
|
log "HTTPS nginx 설정 적용 중..."
|
|
create_nginx_config
|
|
cp nginx/castad-server.conf /etc/nginx/sites-available/castad
|
|
|
|
if nginx -t 2>/dev/null; then
|
|
systemctl reload nginx
|
|
log "HTTPS nginx 설정 적용 완료"
|
|
else
|
|
warn "HTTPS 설정 오류 - certbot이 자동 생성한 설정 사용"
|
|
fi
|
|
|
|
# 자동 갱신 확인
|
|
log "자동 갱신 테스트..."
|
|
certbot renew --dry-run --quiet && log "자동 갱신 테스트 성공" || warn "자동 갱신 테스트 실패"
|
|
|
|
echo ""
|
|
log "🔒 SSL 설치 완료!"
|
|
info " https://${DOMAIN_PRIMARY}"
|
|
info " https://${DOMAIN_SECONDARY}"
|
|
}
|
|
|
|
# ============================================
|
|
# 포트를 사용 중인 프로세스 강제 종료
|
|
# ============================================
|
|
kill_port_process() {
|
|
local PORT=$1
|
|
local PID=$(lsof -t -i:${PORT} 2>/dev/null || ss -tlnp 2>/dev/null | grep ":${PORT} " | grep -oP 'pid=\K[0-9]+' | head -1)
|
|
|
|
if [ -n "$PID" ]; then
|
|
info "포트 ${PORT} 사용 중인 프로세스(PID: ${PID}) 종료 중..."
|
|
kill -9 $PID 2>/dev/null || true
|
|
sleep 1
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# ============================================
|
|
# PM2로 서비스 시작
|
|
# ============================================
|
|
start_services() {
|
|
header "서비스 시작"
|
|
|
|
# 기존 PM2 프로세스 정리
|
|
log "기존 PM2 프로세스 정리 중..."
|
|
pm2 delete castad-backend 2>/dev/null || true
|
|
pm2 delete castad-instagram 2>/dev/null || true
|
|
|
|
# PM2 데몬 완전 정리 (좀비 프로세스 제거)
|
|
pm2 kill 2>/dev/null || true
|
|
sleep 2
|
|
|
|
# 포트를 점유하고 있는 모든 프로세스 강제 종료
|
|
log "포트 점유 프로세스 정리 중..."
|
|
kill_port_process ${BACKEND_PORT} || true
|
|
kill_port_process ${INSTAGRAM_PORT} || true
|
|
|
|
# 추가로 3003 포트도 정리 (과거 설정으로 인한 좀비 프로세스)
|
|
kill_port_process 3003 || true
|
|
kill_port_process 3001 || true
|
|
kill_port_process 5003 || true
|
|
kill_port_process 5001 || true
|
|
|
|
sleep 1
|
|
|
|
# PM2로 시작
|
|
log "PM2 서비스 시작 중..."
|
|
pm2 start ecosystem.config.cjs
|
|
|
|
# 상태 저장
|
|
pm2 save --force
|
|
|
|
# 잠시 대기 (서비스 안정화)
|
|
sleep 5
|
|
|
|
# 헬스 체크
|
|
local RETRY=0
|
|
local MAX_RETRY=3
|
|
while [ $RETRY -lt $MAX_RETRY ]; do
|
|
if curl -s "http://localhost:${BACKEND_PORT}/api/admin/stats" > /dev/null 2>&1; then
|
|
log "Backend 서비스 정상 동작 (포트: ${BACKEND_PORT})"
|
|
break
|
|
fi
|
|
RETRY=$((RETRY + 1))
|
|
if [ $RETRY -lt $MAX_RETRY ]; then
|
|
info "Backend 응답 대기 중... (${RETRY}/${MAX_RETRY})"
|
|
sleep 3
|
|
fi
|
|
done
|
|
|
|
if [ $RETRY -eq $MAX_RETRY ]; then
|
|
warn "Backend 서비스 응답 없음 - 로그를 확인하세요"
|
|
warn "pm2 logs castad-backend --lines 20"
|
|
fi
|
|
|
|
# nginx 포트 자동 동기화
|
|
sync_nginx_port
|
|
|
|
log "서비스 시작 완료"
|
|
}
|
|
|
|
# ============================================
|
|
# nginx 포트 자동 동기화
|
|
# ============================================
|
|
sync_nginx_port() {
|
|
if [ ! -f "/etc/nginx/sites-enabled/castad" ]; then
|
|
return 0
|
|
fi
|
|
|
|
local CURRENT_NGINX_PORT=$(grep -oP 'server 127\.0\.0\.1:\K[0-9]+' /etc/nginx/sites-enabled/castad 2>/dev/null | head -1)
|
|
|
|
if [ -z "$CURRENT_NGINX_PORT" ]; then
|
|
return 0
|
|
fi
|
|
|
|
if [ "$CURRENT_NGINX_PORT" == "$BACKEND_PORT" ]; then
|
|
log "nginx 포트: ${BACKEND_PORT} (정상)"
|
|
return 0
|
|
fi
|
|
|
|
warn "nginx 포트 불일치 감지: ${CURRENT_NGINX_PORT} → ${BACKEND_PORT}"
|
|
|
|
if [ "$EUID" -eq 0 ]; then
|
|
sed -i "s/127.0.0.1:${CURRENT_NGINX_PORT}/127.0.0.1:${BACKEND_PORT}/g" /etc/nginx/sites-enabled/castad
|
|
if nginx -t 2>/dev/null; then
|
|
systemctl reload nginx
|
|
log "nginx 포트 자동 수정 완료: ${BACKEND_PORT}"
|
|
else
|
|
error "nginx 설정 오류"
|
|
fi
|
|
else
|
|
info "nginx 포트 수정이 필요합니다 (sudo 권한 필요):"
|
|
echo ""
|
|
echo " sudo sed -i 's/127.0.0.1:${CURRENT_NGINX_PORT}/127.0.0.1:${BACKEND_PORT}/g' /etc/nginx/sites-enabled/castad"
|
|
echo " sudo nginx -t && sudo systemctl reload nginx"
|
|
echo ""
|
|
fi
|
|
}
|
|
|
|
# ============================================
|
|
# 서비스 중지
|
|
# ============================================
|
|
stop_services() {
|
|
header "서비스 중지"
|
|
|
|
pm2 delete castad-backend 2>/dev/null || true
|
|
pm2 delete castad-instagram 2>/dev/null || true
|
|
pm2 save --force
|
|
|
|
log "서비스 중지 완료"
|
|
}
|
|
|
|
# ============================================
|
|
# 상태 표시
|
|
# ============================================
|
|
show_status() {
|
|
header "서비스 상태"
|
|
|
|
pm2 status
|
|
|
|
echo ""
|
|
echo -e "${CYAN}─────────────────────────────────────────────────────────${NC}"
|
|
echo -e " ${BOLD}Backend${NC}: http://localhost:${BACKEND_PORT}"
|
|
echo -e " ${BOLD}Instagram${NC}: http://localhost:${INSTAGRAM_PORT}"
|
|
echo -e " ${BOLD}도메인${NC}:"
|
|
echo -e " - https://${DOMAIN_PRIMARY}"
|
|
echo -e " - https://${DOMAIN_SECONDARY}"
|
|
echo -e " ${BOLD}관리자${NC}: admin / admin123"
|
|
echo -e "${CYAN}─────────────────────────────────────────────────────────${NC}"
|
|
echo ""
|
|
}
|
|
|
|
# ============================================
|
|
# 전체 설치 및 시작
|
|
# ============================================
|
|
full_install() {
|
|
show_banner
|
|
|
|
check_environment || exit 1
|
|
find_available_ports || exit 1
|
|
setup_directories
|
|
check_env_file || exit 1
|
|
install_dependencies
|
|
build_frontend || exit 1
|
|
create_ecosystem
|
|
|
|
# nginx 설정 (SSL 인증서 존재 여부에 따라 다름)
|
|
if [ "$EUID" -eq 0 ]; then
|
|
# SSL 인증서가 이미 있으면 HTTPS 설정, 없으면 HTTP만
|
|
if [ -f "/etc/letsencrypt/live/${DOMAIN_PRIMARY}/fullchain.pem" ]; then
|
|
log "기존 SSL 인증서 발견 - HTTPS 설정 적용"
|
|
create_nginx_config
|
|
install_nginx_config
|
|
else
|
|
log "SSL 인증서 없음 - HTTP 설정만 적용 (SSL은 별도 실행)"
|
|
create_nginx_http_only
|
|
cp nginx/castad-http.conf /etc/nginx/sites-available/castad
|
|
ln -sf /etc/nginx/sites-available/castad /etc/nginx/sites-enabled/
|
|
rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true
|
|
if nginx -t 2>/dev/null; then
|
|
systemctl reload nginx
|
|
log "HTTP nginx 설정 적용 완료"
|
|
fi
|
|
info "SSL 발급: sudo ./startserver.sh ssl"
|
|
fi
|
|
else
|
|
warn "nginx/SSL 설정은 sudo로 별도 실행하세요:"
|
|
info " sudo ./startserver.sh ssl"
|
|
fi
|
|
|
|
start_services
|
|
show_status
|
|
|
|
log "🍮 CaStAD 배포 완료!"
|
|
}
|
|
|
|
# ============================================
|
|
# 업데이트
|
|
# ============================================
|
|
update_server() {
|
|
header "서버 업데이트"
|
|
|
|
# 로컬 변경사항 체크
|
|
if ! git diff --quiet 2>/dev/null; then
|
|
warn "로컬 변경사항이 있습니다. 백업하거나 커밋하세요."
|
|
info "강제 업데이트: git checkout -- . && git pull origin main"
|
|
return 1
|
|
fi
|
|
|
|
log "Git pull 중..."
|
|
git pull origin main
|
|
|
|
# 포트 재탐색 (환경이 바뀌었을 수 있음)
|
|
find_available_ports || exit 1
|
|
|
|
# 환경변수 업데이트
|
|
check_env_file || exit 1
|
|
|
|
# 의존성 및 빌드
|
|
install_dependencies force
|
|
build_frontend
|
|
|
|
# ecosystem 재생성 (포트 설정 반영)
|
|
create_ecosystem
|
|
|
|
# 서비스 재시작
|
|
start_services
|
|
|
|
log "업데이트 완료!"
|
|
show_status
|
|
}
|
|
|
|
# ============================================
|
|
# 도움말
|
|
# ============================================
|
|
show_help() {
|
|
show_banner
|
|
|
|
echo "사용법: $0 <명령어>"
|
|
echo ""
|
|
echo "명령어:"
|
|
echo -e " ${GREEN}start${NC} 서버 전체 설치 및 시작 (첫 배포 시 사용)"
|
|
echo -e " ${GREEN}setup${NC} start + nginx + SSL 전체 설치 (sudo 필요)"
|
|
echo -e " ${GREEN}stop${NC} 서버 중지"
|
|
echo -e " ${GREEN}restart${NC} 서버 재시작"
|
|
echo -e " ${GREEN}status${NC} 서버 상태 확인"
|
|
echo -e " ${GREEN}logs${NC} 실시간 로그 보기"
|
|
echo -e " ${GREEN}update${NC} Git pull + 재빌드 + 재시작"
|
|
echo -e " ${GREEN}build${NC} 프론트엔드만 빌드"
|
|
echo -e " ${GREEN}nginx${NC} nginx 설정 설치 (sudo 필요)"
|
|
echo -e " ${GREEN}ssl${NC} SSL 인증서 발급 + 자동 설정 (sudo 필요)"
|
|
echo -e " ${GREEN}dns${NC} DNS 레코드 확인"
|
|
echo -e " ${GREEN}check${NC} 환경 및 포트 검사 (자동 탐색)"
|
|
echo ""
|
|
echo "예시:"
|
|
echo " sudo ./startserver.sh setup # 전체 설치 (권장)"
|
|
echo " ./startserver.sh start # 서버만 시작"
|
|
echo " ./startserver.sh update # 업데이트"
|
|
echo ""
|
|
echo "포트 설정 (기본값 - 충돌 시 자동 탐색):"
|
|
echo " Backend: ${BACKEND_PORT_DEFAULT} (현재: ${BACKEND_PORT})"
|
|
echo " Instagram: ${INSTAGRAM_PORT_DEFAULT} (현재: ${INSTAGRAM_PORT})"
|
|
echo ""
|
|
echo "※ 포트 충돌 시 자동으로 다음 사용 가능한 포트를 찾습니다"
|
|
echo ""
|
|
}
|
|
|
|
# ============================================
|
|
# 메인
|
|
# ============================================
|
|
case "$ACTION" in
|
|
start)
|
|
full_install
|
|
;;
|
|
setup)
|
|
# 전체 설치 (sudo로 실행 권장)
|
|
if [ "$EUID" -ne 0 ]; then
|
|
warn "setup 명령어는 sudo로 실행하는 것이 좋습니다"
|
|
info "sudo ./startserver.sh setup"
|
|
echo ""
|
|
read -p "계속하시겠습니까? (y/N) " -n 1 -r
|
|
echo ""
|
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
|
exit 0
|
|
fi
|
|
fi
|
|
full_install
|
|
install_ssl
|
|
show_status
|
|
echo ""
|
|
log "🍮 CaStAD 전체 설치 완료!"
|
|
info " https://${DOMAIN_PRIMARY}"
|
|
info " https://${DOMAIN_SECONDARY}"
|
|
;;
|
|
dns)
|
|
show_banner
|
|
check_dns
|
|
;;
|
|
stop)
|
|
show_banner
|
|
stop_services
|
|
;;
|
|
restart)
|
|
show_banner
|
|
find_available_ports || exit 1
|
|
check_env_file || exit 1
|
|
create_ecosystem
|
|
start_services
|
|
show_status
|
|
;;
|
|
status)
|
|
show_banner
|
|
show_status
|
|
;;
|
|
logs)
|
|
pm2 logs --lines 100
|
|
;;
|
|
update)
|
|
show_banner
|
|
update_server
|
|
;;
|
|
build)
|
|
show_banner
|
|
build_frontend
|
|
;;
|
|
nginx)
|
|
show_banner
|
|
create_nginx_config
|
|
install_nginx_config
|
|
;;
|
|
ssl)
|
|
show_banner
|
|
install_ssl
|
|
;;
|
|
check)
|
|
show_banner
|
|
check_environment
|
|
find_available_ports
|
|
;;
|
|
help|--help|-h|"")
|
|
show_help
|
|
;;
|
|
*)
|
|
error "알 수 없는 명령어: $ACTION"
|
|
show_help
|
|
exit 1
|
|
;;
|
|
esac
|