feat: add repeatable Pascal production deployment

This commit is contained in:
134 2026-07-11 22:43:32 +08:00
parent 12ae15ed3a
commit af3d17f4a2
6 changed files with 615 additions and 5 deletions

View File

@ -3,7 +3,7 @@
location = /_pascal_auth {
internal;
proxy_pass http://127.0.0.1:3125/api/internal/pascal/access;
proxy_pass http://__MAIN_NGINX_UPSTREAM__/api/internal/pascal/access;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Host $host;
@ -13,15 +13,15 @@ location = /_pascal_auth {
proxy_set_header X-Pascal-Proxy-Secret "__PASCAL_PROXY_SECRET__";
}
location = /pascal-service {
return 302 /pascal-service/scenes;
location = __PASCAL_BASE_PATH__ {
return 302 __PASCAL_BASE_PATH__/scenes;
}
location ^~ /pascal-service/ {
location ^~ __PASCAL_BASE_PATH__/ {
auth_request /_pascal_auth;
auth_request_set $pascal_user_id $upstream_http_x_pascal_user_id;
proxy_pass http://127.0.0.1:3002;
proxy_pass http://__PASCAL_NGINX_UPSTREAM__;
proxy_http_version 1.1;
proxy_hide_header X-Powered-By;
proxy_set_header Host $host;

View File

@ -115,6 +115,11 @@ and provider from system settings on every request. OpenAI-compatible
`openai`, `custom`, `alibaba`, and `zhipu` providers work without copying their
API keys into the Pascal service.
For repeatable deployment and upgrades on additional servers, use
`scripts/deploy-pascal-production.sh`. The complete defaults, override
variables, health check, real-model smoke test, and rollback behavior are
documented in `docs/pascal-production-deployment.md`.
## Next Step
After the iframe POC is stable, the native integration path is:

View File

@ -0,0 +1,111 @@
# Pascal Editor 可复用生产部署
本项目把 Pascal Editor 作为独立 Bun/Next.js 服务运行,并通过主站 Nginx
同域挂载。部署脚本会复用主站登录态、系统设置中的 Agent 文本模型和主站
Git 仓库里的 `pascal-editor-ai-agent` 独立分支。
## 默认结构
| 项目 | 默认值 |
| --- | --- |
| 主站目录 | 当前仓库目录 |
| 主站 PM2 服务 | `ai-chatbot` |
| 主站内部地址 | `http://127.0.0.1:3125` |
| Pascal 源码 | `/opt/pascal-editor` |
| Pascal 数据 | `/var/lib/pascal-editor` |
| Pascal PM2 服务 | `pascal-editor` |
| Pascal 监听 | `127.0.0.1:3002` |
| 同域路径 | `/pascal-service` |
| Pascal Git 分支 | `pascal-editor-ai-agent` |
Pascal 端口不对公网开放。Nginx 会先通过主站
`/api/internal/pascal/access` 校验登录 Cookie,再把可信用户 ID 注入 Pascal。
场景的创建、列表、读取、保存、删除、事件流和 AI Agent 都检查同一个
`ownerId`。
## 首次部署
在目标服务器先部署好主站并确认主站 Git remote 能读取
`pascal-editor-ai-agent` 分支,然后执行:
```bash
cd /var/www/ai-chatbot
git pull --ff-only origin main
sudo env \
PASCAL_PUBLIC_ORIGIN=https://example.com \
NGINX_SITE_CONFIG=/etc/nginx/sites-enabled/example.com.conf \
pnpm pascal:deploy
```
脚本会按顺序:
1. 生成或复用 `PASCAL_PROXY_SECRET`,写入主站 `.env.local`。
2. 构建并重启主站。
3. 安装固定版本 Bun(默认 `1.3.0`)。
4. 克隆或快进 Pascal 独立分支。
5. 写入仅 root 可读的 Pascal 生产环境文件。
6. 安装依赖、构建 Pascal,并注册 PM2 服务。
7. 渲染 Nginx 片段、执行 `nginx -t`,成功后热重载。
8. 检查主站、Pascal、Agent 状态和主站模型网关。
主站环境文件和 Nginx 配置会备份到 `/var/backups/pascal-deploy`。Nginx
校验失败时,脚本会立即恢复执行前的配置。
## 日常更新
主站和 Pascal 分支有新提交后,重复执行同一个命令即可。脚本只允许 Git
快进更新;如果 `/opt/pascal-editor` 有本地修改,会停止部署,不会覆盖现场
文件。
只做无模型费用的健康检查:
```bash
sudo env \
PASCAL_PUBLIC_ORIGIN=https://example.com \
pnpm pascal:check
```
执行一次真实自然语言建模、验证节点已落库并自动删除临时场景:
```bash
sudo env PASCAL_SMOKE_CONFIRM=1 pnpm pascal:smoke
```
真实冒烟会调用当前系统设置中的 Agent 模型,因此默认不会在一键部署时
执行。需要每次部署自动执行时设置 `RUN_AGENT_SMOKE_TEST=true`。
## 常用覆盖项
| 环境变量 | 用途 |
| --- | --- |
| `MAIN_APP_DIR` | 主站源码目录 |
| `MAIN_ENV_FILE` | 主站环境文件 |
| `MAIN_PM2_NAME` | 主站 PM2 服务名 |
| `MAIN_INTERNAL_URL` | Pascal 调用主站网关的地址 |
| `MAIN_NGINX_UPSTREAM` | Nginx 主站 upstream,例如 `127.0.0.1:3125` |
| `BUILD_MAIN_APP=false` | 跳过主站安装、构建和重启 |
| `PASCAL_INSTALL_DIR` | Pascal 源码目录 |
| `PASCAL_DATA_DIR` | SQLite 数据目录 |
| `PASCAL_GIT_REPO` | Pascal 分支所在 Git 仓库;默认使用主站 origin |
| `PASCAL_GIT_REF` | Pascal 分支或标签 |
| `PASCAL_PORT` | Pascal 本地监听端口 |
| `PASCAL_BASE_PATH` | 同域挂载路径 |
| `PASCAL_PUBLIC_ORIGIN` | 公网主站 Origin,不带末尾斜杠 |
| `NGINX_SITE_CONFIG` | 需要加入 Pascal include 的 HTTPS server 配置 |
| `INSTALL_NGINX=false` | 仅部署服务,不修改 Nginx |
| `PASCAL_BUN_VERSION` | 固定 Bun 版本 |
| `PASCAL_BACKUP_DIR` | 环境与 Nginx 配置备份目录 |
## 运行维护
```bash
pm2 status
pm2 logs pascal-editor --lines 100
curl http://127.0.0.1:3002/pascal-service/api/health
nginx -t
```
升级时不要把主站源码复制到 Pascal 目录,也不要把本地测试数据库上传到
服务器。主站始终更新 `main`,Pascal 始终更新独立分支,用户场景只保存在
`PASCAL_DATA_DIR`。

View File

@ -11,6 +11,9 @@
"scripts": {
"dev": "next dev --turbo",
"pascal:dev": "node scripts/pascal-editor-dev.mjs",
"pascal:deploy": "bash scripts/deploy-pascal-production.sh deploy",
"pascal:check": "bash scripts/deploy-pascal-production.sh check",
"pascal:smoke": "bash scripts/smoke-pascal-agent.sh",
"postinstall": "node scripts/security/auto-enable-perf-guard.mjs",
"prebuild": "node scripts/security/auto-enable-perf-guard.mjs",
"build": "next build",

View File

@ -0,0 +1,391 @@
#!/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_DATA_DIR="${PASCAL_DATA_DIR:-/var/lib/pascal-editor}"
PASCAL_GIT_REF="${PASCAL_GIT_REF:-pascal-editor-ai-agent}"
PASCAL_GIT_REPO="${PASCAL_GIT_REPO:-}"
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"
}
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
pnpm install --frozen-lockfile
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
)
}
resolve_pascal_repo() {
if [ -n "$PASCAL_GIT_REPO" ]; then
printf '%s' "$PASCAL_GIT_REPO"
return
fi
git -C "$MAIN_APP_DIR" remote get-url origin
}
checkout_pascal() {
local repo="$1"
if [ -d "$PASCAL_INSTALL_DIR/.git" ]; then
[ -z "$(git -C "$PASCAL_INSTALL_DIR" status --porcelain)" ] ||
die "$PASCAL_INSTALL_DIR has local Git changes; refusing to overwrite them"
git -C "$PASCAL_INSTALL_DIR" fetch origin "$PASCAL_GIT_REF"
git -C "$PASCAL_INSTALL_DIR" checkout "$PASCAL_GIT_REF"
git -C "$PASCAL_INSTALL_DIR" pull --ff-only origin "$PASCAL_GIT_REF"
return
fi
[ ! -e "$PASCAL_INSTALL_DIR" ] ||
die "$PASCAL_INSTALL_DIR exists but is not a Git checkout"
mkdir -p "$(dirname "$PASCAL_INSTALL_DIR")"
git clone --single-branch --branch "$PASCAL_GIT_REF" "$repo" "$PASCAL_INSTALL_DIR"
}
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 Pascal from $PASCAL_GIT_REF"
(
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
checkout_pascal "$(resolve_pascal_repo)"
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:-<public-origin>}/pascal-editor"
}
main "$@"

100
scripts/smoke-pascal-agent.sh Executable file
View File

@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -Eeuo pipefail
if [ "${PASCAL_SMOKE_CONFIRM:-}" != "1" ]; then
printf '%s\n' \
'This check makes one real model request and then deletes its temporary scene.' \
'Run with PASCAL_SMOKE_CONFIRM=1 after confirming provider usage is acceptable.' >&2
exit 2
fi
command -v curl >/dev/null 2>&1 || { printf 'curl is required.\n' >&2; exit 1; }
command -v node >/dev/null 2>&1 || { printf 'node is required.\n' >&2; exit 1; }
PASCAL_ENV_FILE="${PASCAL_ENV_FILE:-/opt/pascal-editor/.env.production}"
PASCAL_PORT="${PASCAL_PORT:-3002}"
PASCAL_BASE_PATH="/${PASCAL_BASE_PATH#/}"
PASCAL_BASE_PATH="${PASCAL_BASE_PATH%/}"
BASE_URL="${PASCAL_INTERNAL_URL:-http://127.0.0.1:${PASCAL_PORT}}${PASCAL_BASE_PATH}"
USER_ID="deployment-smoke-test"
SCENE_ID="deployment-smoke-scene"
read_env_value() {
sed -n "s/^${1}=//p" "$PASCAL_ENV_FILE" | tail -n 1
}
SECRET="${PASCAL_PROXY_SECRET:-$(read_env_value PASCAL_AGENT_PROXY_SECRET)}"
[ -n "$SECRET" ] || {
printf 'PASCAL proxy secret is not configured.\n' >&2
exit 1
}
stream_file="$(mktemp)"
scene_file="$(mktemp)"
prompt="${PASCAL_SMOKE_PROMPT:-创建一个4米乘4米的简单房间,添加一扇门和一扇窗,检查结构后保存。}"
cleanup() {
curl -sS -o /dev/null -X DELETE \
-H "X-Pascal-Scene-Token: $SECRET" \
-H "X-Pascal-User-Id: $USER_ID" \
"$BASE_URL/api/scenes/$SCENE_ID" || true
rm -f "$stream_file" "$scene_file"
}
trap cleanup EXIT
cleanup
payload="$(node -e '
const sceneId = process.argv[1];
const prompt = process.argv[2];
process.stdout.write(JSON.stringify({
sceneId,
messages: [{
id: "deployment-smoke-message",
role: "user",
parts: [{ type: "text", text: prompt }],
}],
}));
' "$SCENE_ID" "$prompt")"
curl -fsS -X POST \
-H 'Content-Type: application/json' \
-H "X-Pascal-Scene-Token: $SECRET" \
-H "X-Pascal-User-Id: $USER_ID" \
--data "{\"id\":\"$SCENE_ID\",\"name\":\"Deployment smoke test\",\"graph\":{\"nodes\":{},\"rootNodeIds\":[]}}" \
"$BASE_URL/api/scenes" >/dev/null
http_code="$(curl -sS --max-time "${PASCAL_SMOKE_TIMEOUT_SECONDS:-360}" \
-o "$stream_file" \
-w '%{http_code}' \
-X POST \
-H 'Content-Type: application/json' \
-H "X-Pascal-Agent-Proxy-Secret: $SECRET" \
-H "X-Pascal-User-Id: $USER_ID" \
--data "$payload" \
"$BASE_URL/api/agent/chat")"
[ "$http_code" = "200" ] || {
printf 'Agent smoke request returned HTTP %s.\n' "$http_code" >&2
exit 1
}
if grep -q '"type":"error"' "$stream_file"; then
printf 'Agent smoke stream reported an error.\n' >&2
exit 1
fi
curl -fsS \
-H "X-Pascal-Scene-Token: $SECRET" \
-H "X-Pascal-User-Id: $USER_ID" \
"$BASE_URL/api/scenes/$SCENE_ID" >"$scene_file"
node -e '
const fs = require("node:fs");
const scene = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const expectedOwner = process.argv[2];
if (!(scene.nodeCount > 0)) throw new Error("Agent did not persist scene nodes");
if (scene.ownerId !== expectedOwner) throw new Error("Scene owner mismatch");
console.log(JSON.stringify({ nodeCount: scene.nodeCount, ownerId: scene.ownerId, version: scene.version }));
' "$scene_file" "$USER_ID"
printf '[pascal-smoke] Real model and MCP persistence check passed.\n'