#!/usr/bin/env bash set -Eeuo pipefail MODE="${1:-deploy}" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" MAIN_APP_DIR="${MAIN_APP_DIR:-$ROOT_DIR}" MAIN_ENV_FILE="${MAIN_ENV_FILE:-$MAIN_APP_DIR/.env.local}" MAIN_PM2_NAME="${MAIN_PM2_NAME:-ai-chatbot}" MAIN_INTERNAL_URL="${MAIN_INTERNAL_URL:-http://127.0.0.1:3125}" MAIN_NGINX_UPSTREAM="${MAIN_NGINX_UPSTREAM:-127.0.0.1:3125}" BUILD_MAIN_APP="${BUILD_MAIN_APP:-true}" PASCAL_INSTALL_DIR="${PASCAL_INSTALL_DIR:-/opt/pascal-editor}" PASCAL_SOURCE_DIR="${PASCAL_SOURCE_DIR:-$MAIN_APP_DIR/services/pascal-editor}" PASCAL_DATA_DIR="${PASCAL_DATA_DIR:-/var/lib/pascal-editor}" PASCAL_PM2_NAME="${PASCAL_PM2_NAME:-pascal-editor}" PASCAL_PORT="${PASCAL_PORT:-3002}" PASCAL_NGINX_UPSTREAM="${PASCAL_NGINX_UPSTREAM:-127.0.0.1:${PASCAL_PORT}}" PASCAL_BASE_PATH="${PASCAL_BASE_PATH:-/pascal-service}" PASCAL_PUBLIC_ORIGIN="${PASCAL_PUBLIC_ORIGIN:-}" PASCAL_BUN_VERSION="${PASCAL_BUN_VERSION:-1.3.0}" PASCAL_BUN_BIN="${PASCAL_BUN_BIN:-$HOME/.bun/bin/bun}" NGINX_SITE_CONFIG="${NGINX_SITE_CONFIG:-}" NGINX_SNIPPET_PATH="${NGINX_SNIPPET_PATH:-/etc/nginx/snippets/pascal-service.conf}" INSTALL_NGINX="${INSTALL_NGINX:-true}" RUN_AGENT_SMOKE_TEST="${RUN_AGENT_SMOKE_TEST:-false}" PASCAL_BACKUP_DIR="${PASCAL_BACKUP_DIR:-/var/backups/pascal-deploy}" log() { printf '[pascal-deploy] %s\n' "$*" } die() { printf '[pascal-deploy] ERROR: %s\n' "$*" >&2 exit 1 } is_true() { case "$1" in 1 | true | TRUE | True | yes | YES | Yes | on | ON | On) return 0 ;; *) return 1 ;; esac } require_command() { command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1" } read_env_value() { local key="$1" local file="$2" [ -f "$file" ] || return 0 sed -n "s/^${key}=//p" "$file" | tail -n 1 } upsert_env_value() { local key="$1" local value="$2" local file="$3" local temp mkdir -p "$(dirname "$file")" touch "$file" temp="$(mktemp)" awk -v prefix="${key}=" 'index($0, prefix) != 1 { print }' "$file" >"$temp" printf '%s=%s\n' "$key" "$value" >>"$temp" mv "$temp" "$file" chmod 600 "$file" } generate_secret() { if command -v openssl >/dev/null 2>&1; then openssl rand -hex 32 return fi od -An -N32 -tx1 /dev/urandom | tr -d ' \n' } resolve_secret() { local configured="${PASCAL_PROXY_SECRET:-}" if [ -z "$configured" ]; then configured="$(read_env_value PASCAL_PROXY_SECRET "$MAIN_ENV_FILE")" fi if [ -z "$configured" ] && [ "$MODE" = "deploy" ]; then configured="$(generate_secret)" fi [[ "$configured" =~ ^[A-Za-z0-9_-]{32,128}$ ]] || die "PASCAL_PROXY_SECRET must contain 32-128 letters, numbers, underscores, or hyphens" printf '%s' "$configured" } normalize_base_path() { local value="${1#/}" value="${value%/}" [ -n "$value" ] || die "PASCAL_BASE_PATH cannot be empty" printf '/%s' "$value" } resolve_public_host() { local value="${PASCAL_PUBLIC_ORIGIN#*://}" value="${value%%/*}" value="${value%%:*}" printf '%s' "$value" } install_bun() { if [ -x "$PASCAL_BUN_BIN" ] && [ "$($PASCAL_BUN_BIN --version 2>/dev/null || true)" = "$PASCAL_BUN_VERSION" ]; then return fi [ "$(id -u)" -eq 0 ] || die "Installing Bun requires root; install Bun $PASCAL_BUN_VERSION manually" require_command curl if ! command -v unzip >/dev/null 2>&1; then require_command apt-get apt-get update -qq DEBIAN_FRONTEND=noninteractive apt-get install -y -qq unzip fi local installer installer="$(mktemp)" curl -fsSL https://bun.sh/install -o "$installer" bash "$installer" "bun-v${PASCAL_BUN_VERSION}" rm -f "$installer" [ -x "$PASCAL_BUN_BIN" ] || die "Bun installation did not create $PASCAL_BUN_BIN" } install_rsync() { if command -v rsync >/dev/null 2>&1; then return fi [ "$(id -u)" -eq 0 ] || die "Installing rsync requires root; install rsync manually" require_command apt-get apt-get update -qq DEBIAN_FRONTEND=noninteractive apt-get install -y -qq rsync } configure_main_app() { local secret="$1" local next_env_backup="" mkdir -p "$PASCAL_BACKUP_DIR" if [ -f "$MAIN_ENV_FILE" ]; then cp "$MAIN_ENV_FILE" "$PASCAL_BACKUP_DIR/main-env-$(date +%Y%m%d%H%M%S).bak" chmod 600 "$PASCAL_BACKUP_DIR"/main-env-*.bak fi upsert_env_value PASCAL_EDITOR_URL "${PASCAL_BASE_PATH}/scenes" "$MAIN_ENV_FILE" upsert_env_value PASCAL_PROXY_SECRET "$secret" "$MAIN_ENV_FILE" if ! is_true "$BUILD_MAIN_APP"; then log "Skipping main application build (BUILD_MAIN_APP=$BUILD_MAIN_APP)" return fi require_command pnpm require_command pm2 log "Building main application in $MAIN_APP_DIR" if [ -f "$MAIN_APP_DIR/next-env.d.ts" ]; then next_env_backup="$(mktemp)" cp "$MAIN_APP_DIR/next-env.d.ts" "$next_env_backup" fi local build_status=0 set +e ( cd "$MAIN_APP_DIR" set -a # shellcheck disable=SC1090 source "$MAIN_ENV_FILE" set +a DISABLE_PERF_GUARD_AUTO_INSTALL=1 pnpm install --frozen-lockfile --prod=false DISABLE_PERF_GUARD_AUTO_INSTALL=1 pnpm build ) || build_status=$? set -e if [ -n "$next_env_backup" ]; then cp "$next_env_backup" "$MAIN_APP_DIR/next-env.d.ts" rm -f "$next_env_backup" fi [ "$build_status" -eq 0 ] || die "Main application build failed" ( set -a # shellcheck disable=SC1090 source "$MAIN_ENV_FILE" set +a pm2 restart "$MAIN_PM2_NAME" --update-env ) } sync_pascal_source() { [ -f "$PASCAL_SOURCE_DIR/package.json" ] || die "Vendored Pascal source not found: $PASCAL_SOURCE_DIR" [ -f "$PASCAL_SOURCE_DIR/apps/editor/package.json" ] || die "Pascal editor package not found below $PASCAL_SOURCE_DIR" [ "$PASCAL_INSTALL_DIR" != "/" ] || die "PASCAL_INSTALL_DIR cannot be /" [ "$PASCAL_INSTALL_DIR" != "$MAIN_APP_DIR" ] || die "PASCAL_INSTALL_DIR cannot overwrite MAIN_APP_DIR" [ "$PASCAL_INSTALL_DIR" != "$PASCAL_SOURCE_DIR" ] || die "PASCAL_INSTALL_DIR must be separate from PASCAL_SOURCE_DIR" install_rsync mkdir -p "$PASCAL_INSTALL_DIR" "$PASCAL_BACKUP_DIR" if [ -d "$PASCAL_INSTALL_DIR/.git" ]; then [ -z "$(git -C "$PASCAL_INSTALL_DIR" status --porcelain)" ] || die "$PASCAL_INSTALL_DIR has legacy local Git changes; refusing to overwrite them" local legacy_git_backup="$PASCAL_BACKUP_DIR/legacy-pascal-git-$(date +%Y%m%d%H%M%S)" log "Archiving legacy Pascal Git metadata to $legacy_git_backup" mv "$PASCAL_INSTALL_DIR/.git" "$legacy_git_backup" fi log "Syncing vendored Pascal source from $PASCAL_SOURCE_DIR" rsync -a --delete \ --exclude '.env.production' \ --exclude '.git/' \ --exclude '.next/' \ --exclude '.turbo/' \ --exclude 'node_modules/' \ "$PASCAL_SOURCE_DIR/" "$PASCAL_INSTALL_DIR/" if git -C "$MAIN_APP_DIR" rev-parse --verify HEAD >/dev/null 2>&1; then git -C "$MAIN_APP_DIR" rev-parse HEAD >"$PASCAL_INSTALL_DIR/.pascal-source-version" fi } write_pascal_env() { local secret="$1" local env_file="$PASCAL_INSTALL_DIR/.env.production" umask 077 { printf 'NODE_ENV=production\n' printf 'NEXT_PUBLIC_PASCAL_BASE_PATH=%s\n' "$PASCAL_BASE_PATH" printf 'PASCAL_REQUIRE_HOST_USER=true\n' printf 'PASCAL_DATA_DIR=%s\n' "$PASCAL_DATA_DIR" printf 'PASCAL_SCENE_API_TOKEN=%s\n' "$secret" printf 'PASCAL_AGENT_PROXY_SECRET=%s\n' "$secret" printf 'PASCAL_AGENT_API_KEY=%s\n' "$secret" printf 'PASCAL_AGENT_BASE_URL=%s/api/internal/pascal-agent/v1\n' "$MAIN_INTERNAL_URL" printf 'PASCAL_AGENT_MODEL=main-agent\n' printf 'PASCAL_AGENT_MAX_STEPS=%s\n' "${PASCAL_AGENT_MAX_STEPS:-18}" printf 'PASCAL_AGENT_STEP_TIMEOUT_MS=%s\n' "${PASCAL_AGENT_STEP_TIMEOUT_MS:-90000}" printf 'PASCAL_AGENT_TOTAL_TIMEOUT_MS=%s\n' "${PASCAL_AGENT_TOTAL_TIMEOUT_MS:-300000}" } >"$env_file" chmod 600 "$env_file" } build_pascal() { mkdir -p "$PASCAL_DATA_DIR" chmod 700 "$PASCAL_DATA_DIR" export PATH="$(dirname "$PASCAL_BUN_BIN"):$PATH" log "Installing and building vendored Pascal service" ( cd "$PASCAL_INSTALL_DIR" "$PASCAL_BUN_BIN" install --frozen-lockfile set -a # shellcheck disable=SC1091 source .env.production set +a "$PASCAL_BUN_BIN" x turbo run build --filter=editor --output-logs=errors-only ) } start_pascal() { require_command pm2 if pm2 describe "$PASCAL_PM2_NAME" >/dev/null 2>&1; then pm2 restart "$PASCAL_PM2_NAME" else pm2 start /usr/bin/bash \ --name "$PASCAL_PM2_NAME" \ --cwd "$PASCAL_INSTALL_DIR" \ -- -lc "set -a; source '$PASCAL_INSTALL_DIR/.env.production'; set +a; export PATH='$(dirname "$PASCAL_BUN_BIN")':\$PATH; exec '$PASCAL_BUN_BIN' run --cwd apps/editor start -- -p '$PASCAL_PORT' -H 127.0.0.1" fi pm2 save } render_nginx() { local secret="$1" local public_host public_host="$(resolve_public_host)" if ! is_true "$INSTALL_NGINX"; then log "Skipping Nginx setup (INSTALL_NGINX=$INSTALL_NGINX)" return fi [ -n "$public_host" ] || die "PASCAL_PUBLIC_ORIGIN is required when INSTALL_NGINX=true" if [ -z "$NGINX_SITE_CONFIG" ]; then NGINX_SITE_CONFIG="/etc/nginx/sites-enabled/${public_host}.conf" fi [ -f "$NGINX_SITE_CONFIG" ] || die "Nginx site config not found: $NGINX_SITE_CONFIG" require_command nginx local template="$MAIN_APP_DIR/deploy/nginx/pascal-service.conf.template" local site_backup snippet_backup rendered updated_site [ -f "$template" ] || die "Nginx template not found: $template" site_backup="$(mktemp)" snippet_backup="$(mktemp)" rendered="$(mktemp)" updated_site="$(mktemp)" cp "$NGINX_SITE_CONFIG" "$site_backup" mkdir -p "$PASCAL_BACKUP_DIR" cp "$NGINX_SITE_CONFIG" "$PASCAL_BACKUP_DIR/nginx-site-$(date +%Y%m%d%H%M%S).bak" if [ -f "$NGINX_SNIPPET_PATH" ]; then cp "$NGINX_SNIPPET_PATH" "$snippet_backup" cp "$NGINX_SNIPPET_PATH" "$PASCAL_BACKUP_DIR/nginx-snippet-$(date +%Y%m%d%H%M%S).bak" else : >"$snippet_backup" fi sed \ -e "s/__PASCAL_PROXY_SECRET__/${secret}/g" \ -e "s#__MAIN_NGINX_UPSTREAM__#${MAIN_NGINX_UPSTREAM}#g" \ -e "s#__PASCAL_NGINX_UPSTREAM__#${PASCAL_NGINX_UPSTREAM}#g" \ -e "s#__PASCAL_BASE_PATH__#${PASCAL_BASE_PATH}#g" \ "$template" >"$rendered" install -m 600 "$rendered" "$NGINX_SNIPPET_PATH" if ! grep -qF "include $NGINX_SNIPPET_PATH;" "$NGINX_SITE_CONFIG"; then awk -v target="server_name ${public_host};" -v include_line=" include ${NGINX_SNIPPET_PATH};" ' !inserted && index($0, target) > 0 { print; print include_line; inserted = 1; next } { print } END { if (!inserted) exit 42 } ' "$NGINX_SITE_CONFIG" >"$updated_site" || { cp "$site_backup" "$NGINX_SITE_CONFIG" die "Could not find server_name ${public_host}; in $NGINX_SITE_CONFIG" } install -m 644 "$updated_site" "$NGINX_SITE_CONFIG" fi if ! nginx -t; then cp "$site_backup" "$NGINX_SITE_CONFIG" if [ -s "$snippet_backup" ]; then cp "$snippet_backup" "$NGINX_SNIPPET_PATH" else rm -f "$NGINX_SNIPPET_PATH" fi nginx -t || true die "Nginx validation failed; previous configuration restored" fi systemctl reload nginx rm -f "$site_backup" "$snippet_backup" "$rendered" "$updated_site" } check_deployment() { local secret="$1" local local_pascal="http://127.0.0.1:${PASCAL_PORT}${PASCAL_BASE_PATH}" log "Checking main application" curl -fsS "$MAIN_INTERNAL_URL/ping" >/dev/null log "Checking Pascal health and Agent configuration" curl -fsS "$local_pascal/api/health" >/dev/null curl -fsS \ -H "X-Pascal-Agent-Proxy-Secret: $secret" \ -H "X-Pascal-User-Id: deployment-health-check" \ "$local_pascal/api/agent/chat" >/dev/null log "Checking the main model gateway" curl -fsS \ -H "Authorization: Bearer $secret" \ "$MAIN_INTERNAL_URL/api/internal/pascal-agent/v1/chat/completions" printf '\n' if [ -n "$PASCAL_PUBLIC_ORIGIN" ]; then local public_code public_code="$(curl -sS -o /dev/null -w '%{http_code}' "${PASCAL_PUBLIC_ORIGIN}${PASCAL_BASE_PATH}/scenes")" case "$public_code" in 200 | 302 | 307 | 401) ;; *) die "Unexpected public Pascal status: $public_code" ;; esac fi log "Deployment checks passed" } main() { case "$MODE" in deploy | check) ;; *) die "Usage: $0 [deploy|check]" ;; esac require_command curl require_command git require_command sed PASCAL_BASE_PATH="$(normalize_base_path "$PASCAL_BASE_PATH")" local secret secret="$(resolve_secret)" if [ "$MODE" = "check" ]; then check_deployment "$secret" return fi [ "$(id -u)" -eq 0 ] || die "Production deployment must run as root" configure_main_app "$secret" install_bun sync_pascal_source write_pascal_env "$secret" build_pascal start_pascal render_nginx "$secret" sleep 3 check_deployment "$secret" if is_true "$RUN_AGENT_SMOKE_TEST"; then PASCAL_SMOKE_CONFIRM=1 \ PASCAL_ENV_FILE="$PASCAL_INSTALL_DIR/.env.production" \ PASCAL_PORT="$PASCAL_PORT" \ PASCAL_BASE_PATH="$PASCAL_BASE_PATH" \ "$MAIN_APP_DIR/scripts/smoke-pascal-agent.sh" fi log "Pascal is ready at ${PASCAL_PUBLIC_ORIGIN:-}/pascal-editor" } main "$@"