Files
replypier/e2e/scripts/stack.sh
T
WangShen007 20fef2b9f3 chore: settle product naming across app, widget and deployments
One word everywhere: ReplyPier in UI copy, package and image names, the
replypier.site domains (app/api/docs/help), REPLYPIER_* settings and the
--rp-* design tokens.

- public widget API is window.ReplyPier with replypierId / replypierBaseUrl /
  replypierConfig / replypierToken, loader at webclient/replypier.min.js;
  both bundles rebuilt from source
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and the demo runtime
  config rewritten; the demo config ships no third-party keys
- migration adds the ReplyPier managed model value to the AI model enum
- local compose project, database, role, volume and Docker network carry the
  same name
2026-10-26 00:57:22 +08:00

366 lines
16 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# Runs the ReplyPier e2e stack natively (no Docker): a throwaway Postgres DB,
# the backend, a production build of the dashboard and the fake LLM — all on
# 127.0.0.1 ports that never collide with a dev stack on localhost.
#
# stack.sh up start everything in the background, wait until healthy
# stack.sh down stop what `up` started
# stack.sh reset fresh database (restarts the backend)
# stack.sh logs [name] tail backend | frontend | fake-llm logs
# stack.sh tick <worker> one pass of a background worker against the stack's DB:
# crm-sync | knowledge | ticket-lifecycle (tests call this
# instead of waiting on always-on workers' poll intervals)
# stack.sh backend|frontend|fake-llm|fake-externals|fake-mcp|customer-site|mail run one service in the foreground
# (what playwright.config.ts webServer calls)
# stack.sh python ARGS... run python with the stack's backend settings (one-off jobs a test needs)
#
# Knobs (all optional):
# E2E_EDITION=oss|enterprise oss (default) blocks the enterprise submodule even if present;
# enterprise also applies the private suite's .env.e2e overlay
# and seed.sql (frontend/src/modules/enterprise/e2e)
# E2E_PG_URL admin URL of a Postgres with pgvector, no db name
# (default postgresql://$USER@127.0.0.1:5432)
# E2E_PYTHON python with backend deps (default backend/venv/bin/python, else python3)
# E2E_FRONTEND_NODE_MODULES frontend node_modules to build with (default frontend/node_modules)
# E2E_REDIS_URL run with Redis on (webhook dedupe, rate limits, socket.io fan-out),
# as production does. Use a database of its own, e.g. redis://127.0.0.1:6379/5:
# the stack shares its cors:origins cache with anything else on it
# E2E_BACKEND_WORKERS run the backend under gunicorn (--preload, as scripts/start.sh does)
# with this many workers, instead of a single uvicorn process
# FASTEMBED_CACHE_PATH where the embedding model is cached (default e2e/.cache/fastembed;
# fastembed's own default is the OS temp dir, which macOS purges)
set -euo pipefail
E2E_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO_DIR="$(cd "$E2E_DIR/.." && pwd)"
STATE_DIR="$E2E_DIR/.stack"
ENTERPRISE_E2E_DIR="$REPO_DIR/frontend/src/modules/enterprise/e2e"
CACHE_DIR="$E2E_DIR/.cache"
export FASTEMBED_CACHE_PATH="${FASTEMBED_CACHE_PATH:-$CACHE_DIR/fastembed}"
EDITION="${E2E_EDITION:-oss}"
DB_NAME="replypier_e2e"
TPL_NAME="${DB_NAME}_tpl"
PG_URL="${E2E_PG_URL:-postgresql://${USER}@127.0.0.1:5432}"
PG_URL="${PG_URL%/}"
BACKEND_PORT=18000
FRONTEND_PORT=15173
FAKE_LLM_PORT=14010
FAKE_EXTERNALS_PORT=14020
FAKE_MCP_PORT=14030
CUSTOMER_SITE_PORT=13000
MAIL_SMTP_PORT=11025
MAIL_WEB_PORT=18025
if [[ -z "${E2E_PYTHON:-}" ]]; then
if [[ -x "$REPO_DIR/backend/venv/bin/python" ]]; then E2E_PYTHON="$REPO_DIR/backend/venv/bin/python"; else E2E_PYTHON="python3"; fi
fi
NODE_MODULES="${E2E_FRONTEND_NODE_MODULES:-$REPO_DIR/frontend/node_modules}"
log() { printf '\033[36m[e2e]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m[e2e]\033[0m %s\n' "$*" >&2; exit 1; }
case "$EDITION" in oss|enterprise) ;; *) die "E2E_EDITION must be oss or enterprise, got '$EDITION'";; esac
if [[ "$EDITION" == enterprise && ! -d "$ENTERPRISE_E2E_DIR" ]]; then
die "E2E_EDITION=enterprise needs the enterprise_frontend submodule (with its e2e/ folder) checked out"
fi
# The suite drops and recreates this database on every run. Refuse anything that
# is not obviously a throwaway, so a typo can never point it at a dev DB.
[[ "$DB_NAME" == *_e2e ]] || die "refusing to manage database '$DB_NAME': name must end in _e2e"
psql_admin() { psql "$PG_URL/postgres" -v ON_ERROR_STOP=1 -qtA "$@"; }
# SQLAlchemy needs the driver in the scheme; psql accepts both spellings.
db_url() { local base="${PG_URL#postgres://}"; base="${base#postgresql://}"; echo "postgresql+psycopg://$base/$1"; }
backend_env() {
export ENV_FILE="$E2E_DIR/.env.e2e"
export DATABASE_URL; DATABASE_URL="$(db_url "$1")"
if [[ "$EDITION" == oss ]]; then
export NO_ENTERPRISE=1
else
unset NO_ENTERPRISE
# Enterprise-only settings (SMTP to Mailpit, default AI key, …) live with the
# private suite; exported values override the shared .env.e2e.
if [[ -f "$ENTERPRISE_E2E_DIR/.env.e2e" ]]; then set -a; source "$ENTERPRISE_E2E_DIR/.env.e2e"; set +a; fi
fi
if [[ -n "${E2E_REDIS_URL:-}" ]]; then
export REDIS_ENABLED=true REDIS_URL="$E2E_REDIS_URL"
fi
}
# Fingerprint of everything that shapes the schema: migration contents + edition.
# Contents, not names — amending an unmerged migration must rebuild the template.
schema_fingerprint() {
{
echo "$EDITION"
cat "$REPO_DIR"/backend/alembic/versions/*.py
if [[ "$EDITION" == enterprise ]]; then
cat "$REPO_DIR"/backend/app/enterprise/migrations/versions/*.py "$ENTERPRISE_E2E_DIR/seed.sql" 2>/dev/null || true
fi
} | shasum | cut -c1-16
}
# The template DB holds a freshly migrated schema; every run clones it, which takes
# a second instead of re-running every migration.
ensure_template() {
local fp current
fp="$(schema_fingerprint)"
current="$(psql_admin -c "SELECT shobj_description(oid, 'pg_database') FROM pg_database WHERE datname = '$TPL_NAME'")"
if [[ "$current" == "$fp" ]]; then return; fi
log "building template database $TPL_NAME (schema changed or first run)"
psql_admin -c "DROP DATABASE IF EXISTS $TPL_NAME"
psql_admin -c "CREATE DATABASE $TPL_NAME"
psql "$PG_URL/$TPL_NAME" -v ON_ERROR_STOP=1 -q -c "CREATE EXTENSION IF NOT EXISTS vector"
(
backend_env "$TPL_NAME"
cd "$REPO_DIR/backend"
"$E2E_PYTHON" -m alembic upgrade head
if [[ "$EDITION" == enterprise ]]; then
"$E2E_PYTHON" -m alembic -c app/enterprise/migrations/alembic.ini upgrade head
fi
) >&2
if [[ "$EDITION" == enterprise && -f "$ENTERPRISE_E2E_DIR/seed.sql" ]]; then
psql "$PG_URL/$TPL_NAME" -v ON_ERROR_STOP=1 -q -f "$ENTERPRISE_E2E_DIR/seed.sql"
fi
psql_admin -c "COMMENT ON DATABASE $TPL_NAME IS '$fp'"
}
fresh_database() {
ensure_template
psql_admin -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB_NAME' AND pid <> pg_backend_pid()" >/dev/null
psql_admin -c "DROP DATABASE IF EXISTS $DB_NAME"
psql_admin -c "CREATE DATABASE $DB_NAME TEMPLATE $TPL_NAME"
log "fresh database $DB_NAME"
}
run_backend() {
fresh_database
backend_env "$DB_NAME"
# The frontend step builds the widget into this dir; the backend only needs it to exist.
export WIDGET_ASSETS_DIR; WIDGET_ASSETS_DIR="$(widget_assets_dir)"
mkdir -p "$WIDGET_ASSETS_DIR"
cd "$REPO_DIR/backend"
if [[ -n "${E2E_BACKEND_WORKERS:-}" ]]; then
# The same server and flags as production (scripts/start.sh). On macOS, a
# preloaded app that forks trips the Objective-C fork-safety abort.
if [[ "$(uname)" == Darwin ]]; then export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES; fi
log "backend ($EDITION, gunicorn × $E2E_BACKEND_WORKERS${E2E_REDIS_URL:+, redis}) on http://127.0.0.1:$BACKEND_PORT"
exec "$E2E_PYTHON" -m gunicorn app.main:app --workers "$E2E_BACKEND_WORKERS" \
--worker-class uvicorn.workers.UvicornWorker --bind "127.0.0.1:$BACKEND_PORT" \
--timeout 120 --keep-alive 5 --access-logfile - --error-logfile - --preload
fi
log "backend ($EDITION${E2E_REDIS_URL:+, redis}) on http://127.0.0.1:$BACKEND_PORT"
exec "$E2E_PYTHON" -m uvicorn app.main:app --host 127.0.0.1 --port "$BACKEND_PORT"
}
# Python with the running stack's backend settings (database, ENV_FILE, edition),
# for one-off jobs a test needs - e.g. one run of the setup-email sender.
run_python() {
backend_env "$DB_NAME"
cd "$REPO_DIR/backend"
exec "$E2E_PYTHON" "$@"
}
# Copies frontend/ into the cache so e2e builds never touch the developer's dist/,
# .env or the committed widget/loader bundles, and so the OSS edition can leave out
# the enterprise module. Prints rsync's change list (empty = nothing changed).
sync_frontend_copy() {
local dest="$1"
[[ -d "$NODE_MODULES" ]] || die "no frontend node_modules at $NODE_MODULES (run npm ci in frontend/, or set E2E_FRONTEND_NODE_MODULES)"
mkdir -p "$dest"
# The loader bundle is generated inside the copy (build_loader), so keep rsync off it.
local excludes=(--exclude node_modules --exclude 'dist*' --exclude '.env*' --exclude e2e --exclude coverage
--exclude public/webclient/replypier.min.js)
if [[ "$EDITION" == oss ]]; then excludes+=(--exclude src/modules/enterprise); fi
rsync -a --delete -i "${excludes[@]}" "$REPO_DIR/frontend/" "$dest/"
ln -sfn "$NODE_MODULES" "$dest/node_modules"
}
widget_assets_dir() { echo "$CACHE_DIR/widget-assets-$EDITION"; }
# The widget bundle the backend serves at /assets, built from the current source
# (backend/assets holds the committed production build, which may lag the source).
build_widget() {
local src="$1" out
out="$(widget_assets_dir)"
(cd "$src" && node_modules/.bin/vite build --config vite.widget.config.ts --outDir "$out" --emptyOutDir --logLevel warn) >&2
# Same post-steps as scripts/build-widget.ts: the stylesheet can land in unused/,
# and only widget.js + widget.css belong in the assets dir (vite also copies public/).
if [[ ! -f "$out/widget.css" ]]; then cp "$(ls "$out"/unused/*.css | head -n 1)" "$out/widget.css"; fi
find "$out" -mindepth 1 -maxdepth 1 ! -name widget.js ! -name widget.css -exec rm -rf {} +
}
# The embed loader (replypier.min.js), built from source into the dashboard copy's
# public/ so vite ships it in the build — what build-webclient-*.ts does.
build_loader() {
local src="$1"
(cd "$src" && node_modules/.bin/esbuild src/webclient/replypier.js --bundle --minify --format=iife \
--target=es2015 --loader:.svg=text --log-level=warning \
--define:process.env.NODE_ENV='"production"' \
--define:__REPLYPIER_API_URL__="\"http://127.0.0.1:$BACKEND_PORT/api/v1\"" \
--outfile=public/webclient/replypier.min.js) >&2
}
# One copy of frontend/ feeds all three builds: loader, widget, dashboard.
build_frontend() {
local src="$CACHE_DIR/frontend-$EDITION" out="$CACHE_DIR/dist-$EDITION" changes
changes="$(sync_frontend_copy "$src")"
if [[ -n "$changes" || ! -f "$out/index.html" || ! -f "$(widget_assets_dir)/widget.js" || -n "${E2E_REBUILD_FRONTEND:-}" ]]; then
log "building loader, widget and dashboard ($EDITION)"
build_loader "$src"
build_widget "$src"
(cd "$src" && node_modules/.bin/vite build --outDir "$out" --emptyOutDir --logLevel warn) >&2
fi
# Runtime config: what docker-entrypoint.sh writes in a real deployment.
cat > "$out/config.js" <<EOF
self.APP_CONFIG = {
API_URL: "http://127.0.0.1:$BACKEND_PORT/api/v1",
WS_URL: "ws://127.0.0.1:$BACKEND_PORT",
WIDGET_URL: "http://127.0.0.1:$BACKEND_PORT",
NODE_ENV: "production"
};
EOF
}
run_frontend() {
build_frontend
cd "$CACHE_DIR/frontend-$EDITION"
log "dashboard on http://127.0.0.1:$FRONTEND_PORT"
exec node_modules/.bin/vite preview --outDir "$CACHE_DIR/dist-$EDITION" --host 127.0.0.1 --port "$FRONTEND_PORT" --strictPort
}
run_fake_llm() {
cd "$E2E_DIR"
exec node services/fake-llm/server.ts --port "$FAKE_LLM_PORT"
}
run_fake_externals() {
cd "$E2E_DIR"
exec node services/fake-externals/server.ts --port "$FAKE_EXTERNALS_PORT"
}
run_fake_mcp() {
cd "$E2E_DIR"
exec node services/fake-mcp/server.ts --port "$FAKE_MCP_PORT"
}
run_customer_site() {
cd "$E2E_DIR"
exec node services/customer-site/server.ts --port "$CUSTOMER_SITE_PORT" \
--api "http://127.0.0.1:$BACKEND_PORT/api/v1" \
--loader "http://127.0.0.1:$FRONTEND_PORT/webclient/replypier.min.js"
}
# SMTP capture (Mailpit): everything the backend emails — email-channel replies,
# account emails, notifications — lands here, readable through its API
# (src/fakes/mailbox.ts).
run_mail() {
command -v mailpit >/dev/null || die "mailpit not found (brew install mailpit, or see https://mailpit.axllent.org)"
exec mailpit --smtp "127.0.0.1:$MAIL_SMTP_PORT" --listen "127.0.0.1:$MAIL_WEB_PORT" --disable-version-check --quiet
}
# One pass of a worker production runs on a loop (docker-compose's knowledge_processor,
# ticket_investigator, crm_sync), with the backend's exact environment.
run_tick() {
local import
case "${1:-}" in
crm-sync) import="from app.workers.crm_sync import run_sync_pass as tick" ;;
knowledge) import="from app.workers.knowledge_processor import run_processor as tick" ;;
# Its main() routes socket emits through Redis and loads extensions first; so must a tick.
ticket-lifecycle) import="from app.core.socketio import configure_socketio; configure_socketio(); from app.channels.app_hooks import load_extensions; load_extensions(); from app.workers.ticket_investigator import run_ticket_lifecycle as tick" ;;
*) die "unknown worker '${1:-}' (crm-sync | knowledge | ticket-lifecycle)" ;;
esac
backend_env "$DB_NAME"
export WIDGET_ASSETS_DIR; WIDGET_ASSETS_DIR="$(widget_assets_dir)"
cd "$REPO_DIR/backend"
"$E2E_PYTHON" -c "import asyncio; $import; asyncio.run(tick())"
}
wait_for() {
local name="$1" url="$2" timeout="$3" i
for ((i = 0; i < timeout; i++)); do
if curl -fsS -o /dev/null "$url" 2>/dev/null; then log "$name is up"; return; fi
if ! kill -0 "$(cat "$STATE_DIR/$name.pid")" 2>/dev/null; then
tail -n 40 "$STATE_DIR/$name.log" >&2; die "$name exited during startup (log: $STATE_DIR/$name.log)"
fi
sleep 1
done
die "$name not healthy after ${timeout}s (log: $STATE_DIR/$name.log)"
}
start_bg() {
local name="$1"
if [[ -f "$STATE_DIR/$name.pid" ]] && kill -0 "$(cat "$STATE_DIR/$name.pid")" 2>/dev/null; then
log "$name already running"; return
fi
"$0" "$name" >"$STATE_DIR/$name.log" 2>&1 &
echo $! >"$STATE_DIR/$name.pid"
}
stop_bg() {
local name="$1" pid
[[ -f "$STATE_DIR/$name.pid" ]] || return 0
pid="$(cat "$STATE_DIR/$name.pid")"
# The service is exec'd, so this pid is the server itself; its children go with it.
pkill -TERM -P "$pid" 2>/dev/null || true
kill -TERM "$pid" 2>/dev/null || true
rm -f "$STATE_DIR/$name.pid"
}
cmd_up() {
mkdir -p "$STATE_DIR"
start_bg fake-llm
wait_for fake-llm "http://127.0.0.1:$FAKE_LLM_PORT/health" 30
start_bg fake-externals
wait_for fake-externals "http://127.0.0.1:$FAKE_EXTERNALS_PORT/health" 30
start_bg fake-mcp
wait_for fake-mcp "http://127.0.0.1:$FAKE_MCP_PORT/health" 30
start_bg customer-site
wait_for customer-site "http://127.0.0.1:$CUSTOMER_SITE_PORT/health" 30
start_bg mail
wait_for mail "http://127.0.0.1:$MAIL_WEB_PORT/livez" 30
start_bg backend
wait_for backend "http://127.0.0.1:$BACKEND_PORT/health" 180
start_bg frontend
wait_for frontend "http://127.0.0.1:$FRONTEND_PORT/config.js" 300
log "stack ready — dashboard http://127.0.0.1:$FRONTEND_PORT"
}
cmd_down() { for s in frontend backend customer-site fake-mcp fake-externals fake-llm mail; do stop_bg "$s"; done; log "stack stopped"; }
wait_port_free() {
local port="$1" i
for ((i = 0; i < 30; i++)); do
lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 || return 0
sleep 1
done
die "port $port still in use after 30s"
}
cmd_reset() {
mkdir -p "$STATE_DIR"
stop_bg backend
wait_port_free "$BACKEND_PORT"
start_bg backend
wait_for backend "http://127.0.0.1:$BACKEND_PORT/health" 180
}
case "${1:-}" in
up) cmd_up ;;
down) cmd_down ;;
reset) cmd_reset ;;
logs) tail -n 100 -f "$STATE_DIR/${2:-backend}.log" ;;
tick) run_tick "${2:-}" ;;
backend) run_backend ;;
frontend) run_frontend ;;
fake-llm) run_fake_llm ;;
fake-externals) run_fake_externals ;;
fake-mcp) run_fake_mcp ;;
customer-site) run_customer_site ;;
mail) run_mail ;;
python) shift; run_python "$@" ;;
*) sed -n '2,21p' "$0"; exit 1 ;;
esac