feat: deploy Pascal from vendored source

This commit is contained in:
134 2026-07-12 00:55:11 +08:00
parent 40ff3ca851
commit 5260502507
9 changed files with 136 additions and 178 deletions

2
.gitignore vendored
View File

@ -87,7 +87,7 @@ checkpoints/
# 第三方参考源码(不提交到git)
reference-sources/
# Local self-hosted Pascal Editor checkout.
# Legacy external Pascal Editor checkout (the managed source is under services/).
.pascal-editor/
# Drizzle migrations must be versioned (override the global *.sql ignore above)

View File

@ -3,6 +3,7 @@
/* include utility classes in streamdown */
@source '../node_modules/streamdown/dist/index.js';
@source not '../services/pascal-editor';
/* custom variant for setting dark mode programmatically */
@custom-variant dark (&:is(.dark, .dark *));

View File

@ -13,6 +13,7 @@
"!lib/db/migrations/meta",
"!public/vendor",
"!public/three",
"!services/pascal-editor",
"!vercel.json"
]
},

View File

@ -2,10 +2,11 @@
## Current Shape
The app now exposes a first-phase Pascal Editor workspace at `/pascal-editor`.
It is intentionally iframe-based so Pascal can be self-hosted as a standalone
Next app without forcing its unpublished `@pascal-app/nodes` workspace package
into the main application dependency graph.
The app exposes a Pascal Editor workspace at `/pascal-editor`. Its customized
source is versioned with the main repository under `services/pascal-editor`,
while it remains an independently built Next app embedded through an iframe.
This keeps Pascal's Bun workspace and `@pascal-app/nodes` dependency graph out
of the host application's pnpm dependency graph.
The wrapper route is protected by the host application's existing login system.
Unauthenticated users are redirected to `/login?callbackUrl=/pascal-editor`.
@ -26,11 +27,10 @@ loading a broken iframe.
## Why Iframe First
`@pascal-app/core`, `@pascal-app/viewer`, and `@pascal-app/editor` are published
on npm, but the official standalone app still depends on `@pascal-app/nodes`
from its monorepo workspace. That package registers the built-in node
definitions used by walls, doors, windows, levels, roofs, stairs, and other
editable building elements. Until it is published or vendored, a pure npm-based
native React integration would be incomplete.
on npm, but the standalone app also depends on its monorepo workspace packages.
Keeping the complete Pascal source tree below `services/pascal-editor` preserves
the built-in walls, doors, windows, levels, roofs, stairs, MCP tools, and our AI
Agent customization without coupling the host build to those packages.
## Deployment Options
@ -42,9 +42,9 @@ Run Pascal Editor as a local standalone service:
pnpm pascal:dev
```
The helper clones `pascalorg/editor` into the ignored `.pascal-editor/`
directory on first run, installs its dependencies, and starts `apps/editor` on
`http://localhost:3002`.
The helper uses `services/pascal-editor`, installs its dependencies when needed,
and starts `apps/editor` on `http://localhost:3002`. Set `PASCAL_EDITOR_DIR` only
when deliberately testing a different Pascal checkout.
Then open:
@ -121,13 +121,20 @@ For repeatable deployment and upgrades on additional servers, use
variables, health check, real-model smoke test, and rollback behavior are
documented in `docs/pascal-production-deployment.md`.
## Next Step
## Source Maintenance
After the iframe POC is stable, the native integration path is:
`services/pascal-editor` was imported with Git subtree so its upstream history
can still be refreshed deliberately. Apply and test upstream updates in a
dedicated branch before merging them with our localization, host authentication,
scene storage, and AI Agent changes:
1. Pin a Pascal upstream commit.
2. Vendor or workspace-link `packages/nodes` as `@pascal-app/nodes`.
3. Add Pascal packages to `transpilePackages`.
4. Upgrade `three` to the Pascal-compatible `^0.184` line and retest existing
3D/CAD surfaces.
5. Wire `Editor` `onLoad` / `onSave` to our project storage APIs.
```bash
git subtree pull \
--prefix=services/pascal-editor \
https://github.com/pascalorg/editor.git main \
--squash
```
Production servers do not run this command. They only pull the reviewed main
repository and let `pnpm pascal:deploy` sync the vendored source into the
isolated runtime directory.

View File

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

View File

@ -12,9 +12,8 @@ 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_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}}"
@ -127,6 +126,17 @@ install_bun() {
[ -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=""
@ -176,29 +186,40 @@ configure_main_app() {
)
}
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
}
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"
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
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
[ ! -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"
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() {
@ -226,7 +247,7 @@ 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"
log "Installing and building vendored Pascal service"
(
cd "$PASCAL_INSTALL_DIR"
"$PASCAL_BUN_BIN" install --frozen-lockfile
@ -369,7 +390,7 @@ main() {
[ "$(id -u)" -eq 0 ] || die "Production deployment must run as root"
configure_main_app "$secret"
install_bun
checkout_pascal "$(resolve_pascal_repo)"
sync_pascal_source
write_pascal_env "$secret"
build_pascal
start_pascal

View File

@ -1,22 +1,13 @@
#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { existsSync, writeFileSync } from "node:fs";
import path from "node:path";
const cwd = process.cwd();
const pascalDir = path.resolve(
cwd,
process.env.PASCAL_EDITOR_DIR || ".pascal-editor"
process.env.PASCAL_EDITOR_DIR || "services/pascal-editor"
);
const repoUrl =
process.env.PASCAL_EDITOR_REPO || "https://github.com/pascalorg/editor.git";
const repoRef = process.env.PASCAL_EDITOR_REF || "main";
const port = process.env.PASCAL_EDITOR_PORT || "3002";
function run(command, args, options = {}) {
@ -36,72 +27,6 @@ function commandExists(command) {
return result.status === 0;
}
function patchNestedNextRoot() {
const configPath = path.join(pascalDir, "apps/editor/next.config.ts");
if (!existsSync(configPath)) {
return;
}
let config = readFileSync(configPath, "utf8");
if (!config.includes("import path from 'node:path'")) {
config = config.replace(
"import type { NextConfig } from 'next'\n",
"import path from 'node:path'\nimport type { NextConfig } from 'next'\n"
);
}
if (!config.includes("root: path.resolve(process.cwd(), '../..')")) {
config = config.replace(
" turbopack: {\n",
" turbopack: {\n root: path.resolve(process.cwd(), '../..'),\n"
);
}
writeFileSync(configPath, config, "utf8");
}
function ensureSparseCheckout() {
run(
"git",
[
"-C",
pascalDir,
"sparse-checkout",
"set",
"apps/editor",
"packages",
"tooling",
"styles",
],
{ cwd }
);
}
function patchCssImports() {
const source = path.join(pascalDir, "styles/elevation.css");
const targetDir = path.join(pascalDir, "apps/editor/styles");
const target = path.join(targetDir, "elevation.css");
const globalsPath = path.join(pascalDir, "apps/editor/app/globals.css");
if (existsSync(source)) {
mkdirSync(targetDir, { recursive: true });
copyFileSync(source, target);
}
if (!existsSync(globalsPath)) {
return;
}
const globals = readFileSync(globalsPath, "utf8");
const patched = globals.replace(
'../../../styles/elevation.css',
'../styles/elevation.css'
);
if (patched !== globals) {
writeFileSync(globalsPath, patched, "utf8");
}
}
function runPackageScript(packageManager, packageDir, scriptName) {
if (packageManager === "bun") {
run("bun", ["--cwd", packageDir, "run", scriptName], { cwd: pascalDir });
@ -127,36 +52,18 @@ function ensureBuiltWorkspacePackages(packageManager) {
}
}
if (!existsSync(pascalDir)) {
mkdirSync(path.dirname(pascalDir), { recursive: true });
run("git", [
"clone",
"--depth",
"1",
"--filter=blob:none",
"--sparse",
"--branch",
repoRef,
repoUrl,
pascalDir,
]);
} else if (!existsSync(path.join(pascalDir, ".git"))) {
if (!existsSync(path.join(pascalDir, "apps/editor/package.json"))) {
console.error(
`${pascalDir} already exists but is not a git checkout. Move it away or set PASCAL_EDITOR_DIR.`
`Vendored Pascal Editor source was not found at ${pascalDir}. Set PASCAL_EDITOR_DIR to override it.`
);
process.exit(1);
}
ensureSparseCheckout();
patchCssImports();
const envLocalPath = path.join(pascalDir, ".env.local");
if (!existsSync(envLocalPath)) {
writeFileSync(envLocalPath, `PORT=${port}\n`, "utf8");
}
patchNestedNextRoot();
const hasBun = commandExists("bun");
const hasNpm = commandExists("npm");
const hasNodeModules = existsSync(path.join(pascalDir, "node_modules"));

View File

@ -0,0 +1,19 @@
# Host Application Integration
This directory is the source of truth for the Pascal Editor service shipped
with the host application. It originated from `pascalorg/editor` and includes
the host authentication, user-scoped scene storage, Chinese localization, and
AI Agent integration maintained by this project.
## Boundaries
- The host application uses pnpm and does not install this workspace as part of
its own dependency graph.
- Pascal uses its own Bun lockfile and is built as an independent Next service.
- Production deploys copy this directory to `/opt/pascal-editor`.
- Runtime secrets remain in `/opt/pascal-editor/.env.production`.
- User scene data remains outside both source trees in `/var/lib/pascal-editor`.
Run `pnpm pascal:dev` from the host repository for local development. Run
`pnpm pascal:deploy` as root on a production server after pulling the host main
branch. Never commit runtime environment files or SQLite scene databases here.

View File

@ -31,5 +31,5 @@
"next.config.js",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "services/pascal-editor"]
}