init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,724 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<EOF
用法:
npm run deploy:rust:remote
./scripts/deploy-rust-remote.sh --name 20260422-153000
说明:
1. 在仓库根目录创建 build/<当前时间>/ 作为 Ubuntu 发布包目录。
2. 使用 Vite 构建前端 release 到目标目录的 web/。
3. 构建 api-server 的 x86_64-unknown-linux-gnu release并复制到目标目录。
4. 构建 spacetime-module 的 wasm32-unknown-unknown release并复制 wasm 到目标目录。
5. 在目标目录生成 start.sh / stop.sh用于目标服务器启动静态网站、SpacetimeDB、发布 wasm、启动 api-server。
常用参数:
--name <folder-name> 指定 build 子目录名,默认使用当前时间 YYYYmmdd-HHMMSS
--database <database> SpacetimeDB database默认 ${DATABASE}
--api-port <port> api-server 端口,默认 ${API_PORT}
--web-port <port> 静态网站端口,默认 ${WEB_PORT}
--spacetime-port <port> SpacetimeDB 端口,默认 ${SPACETIME_PORT}
--ssh-key <path> 上传使用的 SSH 私钥,默认 ${SSH_KEY}
--remote <user@host> 上传目标 SSH 主机,默认 ${REMOTE_TARGET}
--remote-dir <path> 上传目标目录,默认 ${REMOTE_DIR}
--skip-upload 只生成本地发布包,不上传服务器
--skip-web-build 跳过 Vite 构建,仅用于调试
--skip-api-build 跳过 api-server 构建,仅用于调试
--skip-spacetime-build 跳过 wasm 构建,仅用于调试
目标服务器要求:
Ubuntu x86_64已安装 node、spacetime CLI并允许执行目标目录内的 start.sh / stop.sh。
如果在非 Linux 主机执行本脚本,需要本机 Rust 已配置 x86_64-unknown-linux-gnu 交叉编译工具链。
EOF
}
require_command() {
local command_name="$1"
if ! command -v "${command_name}" >/dev/null 2>&1; then
echo "[deploy:rust] 缺少命令: ${command_name}" >&2
exit 1
fi
}
copy_required_file() {
local source_path="$1"
local target_path="$2"
local label="$3"
if [[ ! -f "${source_path}" ]]; then
echo "[deploy:rust] 缺少 ${label}: ${source_path}" >&2
exit 1
fi
cp "${source_path}" "${target_path}"
}
normalize_env_file() {
local env_file="$1"
local temp_file="${env_file}.tmp.$$"
if [[ ! -f "${env_file}" ]]; then
return
fi
# 发布环境文件可能由 Windows 编辑器保存,启动脚本只接受无 BOM、无 CRLF 的 KEY=value 文本。
LC_ALL=C sed $'1s/^\xef\xbb\xbf//;s/\r$//' "${env_file}" >"${temp_file}"
mv "${temp_file}" "${env_file}"
}
copy_optional_file() {
local source_path="$1"
local target_path_a="$2"
local target_path_b="$3"
local label="$4"
if [[ ! -f "${source_path}" ]]; then
echo "[deploy:rust] 跳过未找到的可选文件 ${label}: ${source_path}"
return
fi
cp "${source_path}" "${target_path_a}"
cp "${source_path}" "${target_path_b}"
normalize_env_file "${target_path_a}"
normalize_env_file "${target_path_b}"
echo "[deploy:rust] 已复制 ${label} -> ${target_path_a}${target_path_b}"
}
normalize_local_path_for_bash() {
local value="$1"
if [[ "${value}" == "~"* ]]; then
local rest="${value:1}"
rest="${rest#\\}"
rest="${rest#/}"
rest="${rest//\\//}"
printf "%s/%s" "${HOME}" "${rest}"
return
fi
if [[ "${value}" =~ ^([A-Za-z]):\\(.*)$ ]]; then
local drive
drive="$(printf "%s" "${BASH_REMATCH[1]}" | tr '[:upper:]' '[:lower:]')"
local rest="${BASH_REMATCH[2]//\\//}"
printf "/%s/%s" "${drive}" "${rest}"
return
fi
printf "%s" "${value}"
}
remote_shell_quote() {
local value="$1"
printf "'%s'" "$(printf "%s" "${value}" | sed "s/'/'\\\\''/g")"
}
replace_placeholder_in_file() {
local file_path="$1"
local placeholder="$2"
local value="$3"
local escaped_value="${value//\\/\\\\}"
escaped_value="${escaped_value//&/\\&}"
escaped_value="${escaped_value//|/\\|}"
sed -i "s|${placeholder}|${escaped_value}|g" "${file_path}"
}
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
SERVER_RS_DIR="${REPO_ROOT}/server-rs"
BUILD_ROOT="${REPO_ROOT}/build"
BUILD_NAME="$(date +%Y%m%d-%H%M%S)"
DATABASE="xushi-p4wfr"
API_HOST="127.0.0.1"
API_PORT="8082"
WEB_HOST="0.0.0.0"
WEB_PORT="25001"
SPACETIME_HOST="127.0.0.1"
SPACETIME_PORT="3101"
SSH_KEY='~\.ssh\dsk.pem'
REMOTE_TARGET="ubuntu@82.157.175.59"
REMOTE_DIR="/home/ubuntu/genarrative"
UPLOAD_ENABLED=1
SKIP_WEB_BUILD=0
SKIP_API_BUILD=0
SKIP_SPACETIME_BUILD=0
BUILD_COMPLETED=0
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--name)
BUILD_NAME="${2:?缺少 --name 的值}"
shift 2
;;
--database)
DATABASE="${2:?缺少 --database 的值}"
shift 2
;;
--api-host)
API_HOST="${2:?缺少 --api-host 的值}"
shift 2
;;
--api-port)
API_PORT="${2:?缺少 --api-port 的值}"
shift 2
;;
--web-host)
WEB_HOST="${2:?缺少 --web-host 的值}"
shift 2
;;
--web-port)
WEB_PORT="${2:?缺少 --web-port 的值}"
shift 2
;;
--spacetime-host)
SPACETIME_HOST="${2:?缺少 --spacetime-host 的值}"
shift 2
;;
--spacetime-port)
SPACETIME_PORT="${2:?缺少 --spacetime-port 的值}"
shift 2
;;
--ssh-key)
SSH_KEY="${2:?缺少 --ssh-key 的值}"
shift 2
;;
--remote)
REMOTE_TARGET="${2:?缺少 --remote 的值}"
shift 2
;;
--remote-dir)
REMOTE_DIR="${2:?缺少 --remote-dir 的值}"
shift 2
;;
--skip-upload)
UPLOAD_ENABLED=0
shift
;;
--skip-web-build)
SKIP_WEB_BUILD=1
shift
;;
--skip-api-build)
SKIP_API_BUILD=1
shift
;;
--skip-spacetime-build)
SKIP_SPACETIME_BUILD=1
shift
;;
*)
echo "[deploy:rust] 未知参数: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ ! "${BUILD_NAME}" =~ ^[0-9A-Za-z._-]+$ ]]; then
echo "[deploy:rust] --name 只能包含数字、字母、点、下划线和短横线。" >&2
exit 1
fi
TARGET_DIR="${BUILD_ROOT}/${BUILD_NAME}"
WEB_DIR="${TARGET_DIR}/web"
API_BINARY_SOURCE="${SERVER_RS_DIR}/target/x86_64-unknown-linux-gnu/release/api-server"
WASM_SOURCE="${SERVER_RS_DIR}/target/wasm32-unknown-unknown/release/spacetime_module.wasm"
cleanup_partial_build() {
if [[ "${BUILD_COMPLETED}" -ne 1 && -n "${TARGET_DIR:-}" && -d "${TARGET_DIR}" ]]; then
echo "[deploy:rust] 清理未完成发布包: ${TARGET_DIR}" >&2
rm -rf "${TARGET_DIR}"
fi
}
trap cleanup_partial_build EXIT
if [[ -e "${TARGET_DIR}" ]]; then
echo "[deploy:rust] 目标目录已存在: ${TARGET_DIR}" >&2
exit 1
fi
require_command node
require_command cargo
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
require_command npm
fi
NORMALIZED_SSH_KEY="$(normalize_local_path_for_bash "${SSH_KEY}")"
if [[ "${UPLOAD_ENABLED}" -eq 1 ]]; then
require_command ssh
require_command scp
if [[ ! -f "${NORMALIZED_SSH_KEY}" ]]; then
echo "[deploy:rust] SSH 私钥不存在: ${SSH_KEY}" >&2
echo "[deploy:rust] Git Bash 解析路径: ${NORMALIZED_SSH_KEY}" >&2
exit 1
fi
fi
mkdir -p "${WEB_DIR}"
echo "[deploy:rust] 发布包目录: ${TARGET_DIR}"
copy_optional_file "${REPO_ROOT}/.env" "${TARGET_DIR}/.env" "${WEB_DIR}/.env" ".env"
copy_optional_file "${REPO_ROOT}/.env.local" "${TARGET_DIR}/.env.local" "${WEB_DIR}/.env.local" ".env.local"
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
echo "[deploy:rust] 构建 Vite release -> ${WEB_DIR}"
(
cd "${REPO_ROOT}"
node scripts/vite-cli.mjs build --outDir "${WEB_DIR}" --emptyOutDir
)
fi
if [[ "${SKIP_API_BUILD}" -ne 1 ]]; then
echo "[deploy:rust] 构建 api-server -> x86_64-unknown-linux-gnu"
(
cd "${SERVER_RS_DIR}"
cargo build \
-p api-server \
--release \
--target x86_64-unknown-linux-gnu \
--manifest-path "${SERVER_RS_DIR}/Cargo.toml"
)
fi
copy_required_file "${API_BINARY_SOURCE}" "${TARGET_DIR}/api-server" "api-server release binary"
chmod +x "${TARGET_DIR}/api-server"
if [[ "${SKIP_SPACETIME_BUILD}" -ne 1 ]]; then
echo "[deploy:rust] 构建 spacetime-module -> wasm32-unknown-unknown"
(
cd "${SERVER_RS_DIR}"
cargo build \
-p spacetime-module \
--release \
--target wasm32-unknown-unknown \
--manifest-path "${SERVER_RS_DIR}/Cargo.toml"
)
fi
copy_required_file "${WASM_SOURCE}" "${TARGET_DIR}/spacetime_module.wasm" "spacetime-module wasm"
cat >"${TARGET_DIR}/web-server.mjs" <<'WEB_SERVER'
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const releaseDir = path.dirname(fileURLToPath(import.meta.url));
const webRoot = path.join(releaseDir, 'web');
const webHost = process.env.GENARRATIVE_WEB_HOST || '0.0.0.0';
const webPort = Number(process.env.GENARRATIVE_WEB_PORT || '3000');
const apiTarget = new URL(process.env.GENARRATIVE_API_TARGET || 'http://127.0.0.1:8082');
const indexPath = path.join(webRoot, 'index.html');
const proxyPrefixes = [
'/api/',
'/api',
'/generated-character-drafts',
'/generated-characters',
'/generated-animations',
'/generated-custom-world-scenes',
'/generated-custom-world-covers',
'/generated-qwen-sprites',
'/healthz',
];
function isProxyPath(pathname) {
return proxyPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
}
function contentTypeFor(filePath) {
const ext = path.extname(filePath).toLowerCase();
const typeMap = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.txt': 'text/plain; charset=utf-8',
'.webp': 'image/webp',
};
return typeMap[ext] || 'application/octet-stream';
}
function sendFile(response, filePath) {
fs.createReadStream(filePath)
.on('error', () => {
response.writeHead(500, {'content-type': 'text/plain; charset=utf-8'});
response.end('failed to read static file');
})
.pipe(response);
}
function serveStatic(request, response, pathname) {
const decodedPath = decodeURIComponent(pathname);
const relativePath = decodedPath === '/' ? '/index.html' : decodedPath;
const filePath = path.normalize(path.join(webRoot, relativePath));
const safeRelativePath = path.relative(webRoot, filePath);
if (safeRelativePath.startsWith('..') || path.isAbsolute(safeRelativePath)) {
response.writeHead(403, {'content-type': 'text/plain; charset=utf-8'});
response.end('forbidden');
return;
}
const resolvedFilePath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
? filePath
: indexPath;
response.writeHead(200, {'content-type': contentTypeFor(resolvedFilePath)});
sendFile(response, resolvedFilePath);
}
function proxyToApi(request, response) {
const targetUrl = new URL(request.url || '/', apiTarget);
const proxyRequest = http.request(
{
hostname: targetUrl.hostname,
method: request.method,
path: `${targetUrl.pathname}${targetUrl.search}`,
port: targetUrl.port || 80,
protocol: targetUrl.protocol,
headers: {
...request.headers,
host: apiTarget.host,
},
},
(proxyResponse) => {
response.writeHead(proxyResponse.statusCode || 502, proxyResponse.headers);
proxyResponse.pipe(response);
},
);
proxyRequest.on('error', (error) => {
response.writeHead(502, {'content-type': 'application/json; charset=utf-8'});
response.end(JSON.stringify({ok: false, error: {code: 'API_PROXY_FAILED', message: error.message}}));
});
request.pipe(proxyRequest);
}
const server = http.createServer((request, response) => {
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
if (isProxyPath(url.pathname)) {
proxyToApi(request, response);
return;
}
serveStatic(request, response, url.pathname);
});
server.listen(webPort, webHost, () => {
console.log(`[web] listening on http://${webHost}:${webPort}, api target ${apiTarget.href}`);
});
WEB_SERVER
cat >"${TARGET_DIR}/start.sh" <<'START_SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PID_DIR="${SCRIPT_DIR}/run"
LOG_DIR="${SCRIPT_DIR}/logs"
CLEAR_DATABASE=0
cd "${SCRIPT_DIR}"
load_env_file() {
local env_file="$1"
local line=""
local line_number=0
local key=""
local value=""
local utf8_bom=$'\xef\xbb\xbf'
if [[ ! -f "${env_file}" ]]; then
return
fi
echo "[start] 加载环境文件: ${env_file}"
# 环境文件按 dotenv 的 KEY=value 子集解析,避免 BOM 被 shell 当成命令名执行。
while IFS= read -r line || [[ -n "${line}" ]]; do
line_number=$((line_number + 1))
if [[ "${line_number}" -eq 1 ]]; then
line="${line#"${utf8_bom}"}"
fi
line="${line%$'\r'}"
if [[ "${line}" =~ ^[[:space:]]*$ || "${line}" =~ ^[[:space:]]*# ]]; then
continue
fi
if [[ ! "${line}" =~ ^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
echo "[start] 跳过不符合 KEY=value 的环境行: ${env_file}:${line_number}" >&2
continue
fi
key="${BASH_REMATCH[2]}"
value="${BASH_REMATCH[3]}"
if [[ "${#value}" -ge 2 && "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then
value="${value:1:${#value}-2}"
value="${value//\\\"/\"}"
elif [[ "${#value}" -ge 2 && "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then
value="${value:1:${#value}-2}"
fi
printf -v "${key}" '%s' "${value}"
export "${key}"
done <"${env_file}"
}
usage() {
cat <<'EOF'
用法:
./start.sh
./start.sh --clear-database
说明:
1. 启动当前发布包内的静态网站、SpacetimeDB 与 api-server。
2. 默认发布 spacetime_module.wasm 到 GENARRATIVE_SPACETIME_DATABASE但不清库。
3. 只有显式传入 --clear-database 时才会在 schema 冲突时清理旧模块数据后重发。
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--clear-database)
CLEAR_DATABASE=1
shift
;;
*)
echo "[start] 未知参数: $1" >&2
usage >&2
exit 1
;;
esac
done
load_env_file "${SCRIPT_DIR}/.env"
load_env_file "${SCRIPT_DIR}/.env.local"
SPACETIME_DATA_DIR="${GENARRATIVE_SPACETIME_DATA_DIR:-${SCRIPT_DIR}/spacetimedb-data}"
SPACETIME_HOST="${GENARRATIVE_SPACETIME_HOST:-__GENARRATIVE_DEFAULT_SPACETIME_HOST__}"
SPACETIME_PORT="${GENARRATIVE_SPACETIME_PORT:-__GENARRATIVE_DEFAULT_SPACETIME_PORT__}"
SPACETIME_SERVER_URL="${GENARRATIVE_SPACETIME_SERVER_URL:-http://${SPACETIME_HOST}:${SPACETIME_PORT}}"
SPACETIME_DATABASE="${GENARRATIVE_SPACETIME_DATABASE:-__GENARRATIVE_DEFAULT_SPACETIME_DATABASE__}"
API_HOST="${GENARRATIVE_API_HOST:-__GENARRATIVE_DEFAULT_API_HOST__}"
API_PORT="${GENARRATIVE_API_PORT:-__GENARRATIVE_DEFAULT_API_PORT__}"
API_LOG="${GENARRATIVE_API_LOG:-info,tower_http=info}"
WEB_HOST="${GENARRATIVE_WEB_HOST:-__GENARRATIVE_DEFAULT_WEB_HOST__}"
WEB_PORT="${GENARRATIVE_WEB_PORT:-__GENARRATIVE_DEFAULT_WEB_PORT__}"
# 日志默认落文件,显式关闭 ANSI 颜色码,避免控制字符写入 *.log。
export NO_COLOR="${NO_COLOR:-1}"
export CARGO_TERM_COLOR="${CARGO_TERM_COLOR:-never}"
require_command() {
local command_name="$1"
if ! command -v "${command_name}" >/dev/null 2>&1; then
echo "[start] 缺少命令: ${command_name}" >&2
exit 1
fi
}
wait_for_spacetime() {
local deadline=$((SECONDS + 60))
while ((SECONDS < deadline)); do
if spacetime server ping "${SPACETIME_SERVER_URL}" >/dev/null 2>&1; then
return
fi
sleep 0.5
done
echo "[start] 等待 SpacetimeDB 就绪超时: ${SPACETIME_SERVER_URL}" >&2
exit 1
}
start_process() {
local name="$1"
shift
local pid_file="${PID_DIR}/${name}.pid"
local log_file="${LOG_DIR}/${name}.log"
if [[ -f "${pid_file}" ]] && kill -0 "$(cat "${pid_file}")" 2>/dev/null; then
echo "[start] ${name} 已在运行: $(cat "${pid_file}")"
return
fi
echo "[start] 启动 ${name}"
nohup "$@" >"${log_file}" 2>&1 &
echo "$!" >"${pid_file}"
}
require_command node
require_command spacetime
mkdir -p "${PID_DIR}" "${LOG_DIR}" "${SPACETIME_DATA_DIR}"
start_process spacetimedb \
spacetime \
start \
--data-dir "${SPACETIME_DATA_DIR}" \
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \
--non-interactive
wait_for_spacetime
PUBLISH_ARGS=(
publish
"${SPACETIME_DATABASE}"
--server "${SPACETIME_SERVER_URL}"
--bin-path "${SCRIPT_DIR}/spacetime_module.wasm"
--yes
)
if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then
# 发布包清库模式只在 schema 冲突时删除旧模块数据,避免无冲突升级误清数据。
PUBLISH_ARGS+=(-c=on-conflict)
fi
echo "[start] 发布 SpacetimeDB wasm: ${SPACETIME_DATABASE}"
spacetime "${PUBLISH_ARGS[@]}"
export GENARRATIVE_API_HOST="${API_HOST}"
export GENARRATIVE_API_PORT="${API_PORT}"
export GENARRATIVE_API_LOG="${API_LOG}"
export GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER_URL}"
export GENARRATIVE_SPACETIME_DATABASE="${SPACETIME_DATABASE}"
start_process api-server "${SCRIPT_DIR}/api-server"
export GENARRATIVE_WEB_HOST="${WEB_HOST}"
export GENARRATIVE_WEB_PORT="${WEB_PORT}"
export GENARRATIVE_API_TARGET="http://${API_HOST}:${API_PORT}"
start_process web node "${SCRIPT_DIR}/web-server.mjs"
echo "[start] 完成"
echo "[start] Web: http://${WEB_HOST}:${WEB_PORT}"
echo "[start] API: http://${API_HOST}:${API_PORT}"
echo "[start] SpacetimeDB: ${SPACETIME_SERVER_URL}"
START_SCRIPT
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_SPACETIME_HOST__" "${SPACETIME_HOST}"
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_SPACETIME_PORT__" "${SPACETIME_PORT}"
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_SPACETIME_DATABASE__" "${DATABASE}"
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_API_HOST__" "${API_HOST}"
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_API_PORT__" "${API_PORT}"
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_WEB_HOST__" "${WEB_HOST}"
replace_placeholder_in_file "${TARGET_DIR}/start.sh" "__GENARRATIVE_DEFAULT_WEB_PORT__" "${WEB_PORT}"
cat >"${TARGET_DIR}/stop.sh" <<'STOP_SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PID_DIR="${SCRIPT_DIR}/run"
stop_process() {
local name="$1"
local pid_file="${PID_DIR}/${name}.pid"
if [[ ! -f "${pid_file}" ]]; then
echo "[stop] ${name} 未记录 pid"
return
fi
local pid
pid="$(cat "${pid_file}")"
if kill -0 "${pid}" 2>/dev/null; then
echo "[stop] 停止 ${name} (pid=${pid})"
kill "${pid}" 2>/dev/null || true
sleep 0.5
if kill -0 "${pid}" 2>/dev/null; then
kill -9 "${pid}" 2>/dev/null || true
fi
else
echo "[stop] ${name} 未运行"
fi
rm -f "${pid_file}"
}
stop_process web
stop_process api-server
stop_process spacetimedb
echo "[stop] 完成"
STOP_SCRIPT
chmod +x "${TARGET_DIR}/start.sh" "${TARGET_DIR}/stop.sh"
cat >"${TARGET_DIR}/README.md" <<EOF
# Genarrative Ubuntu Release
构建时间:\`${BUILD_NAME}\`
## 内容
- \`.env\` / \`.env.local\`:从仓库根目录复制的环境文件,同时各保留一份到 \`web/\`
- \`web/\`Vite release 静态资源
- \`api-server\`x86_64-unknown-linux-gnu release 可执行文件
- \`spacetime_module.wasm\`wasm32-unknown-unknown release 模块
- \`web-server.mjs\`:静态网站与 API 反代入口
- \`start.sh\` / \`stop.sh\`:目标服务器启动与停止脚本
## 启动
\`\`\`bash
./start.sh
\`\`\`
默认不清空 SpacetimeDB。如需开发库清库重发
\`\`\`bash
./start.sh --clear-database
\`\`\`
## 环境变量
- 启动时会先加载发布目录根部的 \`.env\` 与 \`.env.local\`,再回退到脚本内默认值。
- 环境文件复制进发布包时会移除 UTF-8 BOM 与 CRLF启动时也会按 \`KEY=value\` 子集解析,跳过不合法行。
- 脚本内默认值来自构建时的 `--database`、`--api-port`、`--web-port`、`--spacetime-host`、`--spacetime-port` 参数。
- 默认导出 \`NO_COLOR=1\` 与 \`CARGO_TERM_COLOR=never\`,避免 ANSI 颜色控制码写入日志文件;如确有需要可在启动前显式覆盖。
- \`GENARRATIVE_WEB_HOST\` / \`GENARRATIVE_WEB_PORT\`
- \`GENARRATIVE_API_HOST\` / \`GENARRATIVE_API_PORT\` / \`GENARRATIVE_API_LOG\`
- \`GENARRATIVE_SPACETIME_HOST\` / \`GENARRATIVE_SPACETIME_PORT\`
- \`GENARRATIVE_SPACETIME_SERVER_URL\` / \`GENARRATIVE_SPACETIME_DATABASE\`
- \`GENARRATIVE_SPACETIME_DATA_DIR\`
- OSS、LLM、短信、微信等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理。
EOF
BUILD_COMPLETED=1
if [[ "${UPLOAD_ENABLED}" -eq 1 ]]; then
echo "[deploy:rust] 创建远端目录: ${REMOTE_TARGET}:${REMOTE_DIR}"
ssh -i "${NORMALIZED_SSH_KEY}" "${REMOTE_TARGET}" "mkdir -p $(remote_shell_quote "${REMOTE_DIR}")"
echo "[deploy:rust] 上传发布包: ${TARGET_DIR} -> ${REMOTE_TARGET}:${REMOTE_DIR}/"
scp -r -i "${NORMALIZED_SSH_KEY}" "${TARGET_DIR}" "${REMOTE_TARGET}:${REMOTE_DIR}/"
fi
echo "[deploy:rust] 完成: ${TARGET_DIR}"