#!/usr/bin/env bash set -euo pipefail usage() { cat </ 作为 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 指定 build 子目录名,默认使用当前时间 YYYYmmdd-HHMMSS --database SpacetimeDB database,默认 ${DATABASE} --api-port api-server 端口,默认 ${API_PORT} --web-port 静态网站端口,默认 ${WEB_PORT} --spacetime-port SpacetimeDB 端口,默认 ${SPACETIME_PORT} --ssh-key 上传使用的 SSH 私钥,默认 ${SSH_KEY} --remote 上传目标 SSH 主机,默认 ${REMOTE_TARGET} --remote-dir 上传目标目录,默认 ${REMOTE_DIR} --skip-upload 只生成本地发布包,不上传服务器 --skip-web-build 跳过 Vite 构建,仅用于调试 --skip-api-build 跳过 api-server 构建,仅用于调试 --skip-spacetime-build 跳过 wasm 构建,仅用于调试;此时必须同时传 --no-migration-bootstrap-secret --no-migration-bootstrap-secret 构建不带迁移引导密钥的 spacetime-module 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}" cp "${temp_file}" "${env_file}" } write_env_override() { local env_file="$1" local key="$2" local value="$3" local temp_file="${env_file}.tmp.$$" mkdir -p "$(dirname "${env_file}")" if [[ -f "${env_file}" ]]; then # 发布包参数是本次构建的权威值,必须覆盖从 Jenkins 工作区复制进来的旧 .env.local。 awk -v target_key="${key}" ' BEGIN { pattern = "^[[:space:]]*(export[[:space:]]+)?" target_key "=" } $0 !~ pattern { print } ' "${env_file}" >"${temp_file}" else : >"${temp_file}" fi printf "%s=%s\n" "${key}" "${value}" >>"${temp_file}" cp "${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}" } generate_migration_bootstrap_secret() { node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));' } prepare_migration_bootstrap_secret() { case "${MIGRATION_BOOTSTRAP_SECRET_MODE}" in auto) MIGRATION_BOOTSTRAP_SECRET="$(generate_migration_bootstrap_secret)" ;; manual) if [[ "${#MIGRATION_BOOTSTRAP_SECRET}" -lt 16 ]]; then echo "[deploy:rust] 迁移引导密钥至少需要 16 个字符。" >&2 exit 1 fi ;; disabled) unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET echo "[deploy:rust] 未启用迁移引导密钥。" return ;; *) echo "[deploy:rust] 未知迁移引导密钥模式: ${MIGRATION_BOOTSTRAP_SECRET_MODE}" >&2 exit 1 ;; esac export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET}" echo "[deploy:rust] 迁移引导密钥: ${MIGRATION_BOOTSTRAP_SECRET}" } 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 MIGRATION_BOOTSTRAP_SECRET="" MIGRATION_BOOTSTRAP_SECRET_MODE="auto" 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 ;; --migration-bootstrap-secret) MIGRATION_BOOTSTRAP_SECRET="${2:?缺少 --migration-bootstrap-secret 的值}" MIGRATION_BOOTSTRAP_SECRET_MODE="manual" shift 2 ;; --no-migration-bootstrap-secret) MIGRATION_BOOTSTRAP_SECRET="" MIGRATION_BOOTSTRAP_SECRET_MODE="disabled" 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 if [[ ! "${DATABASE}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then echo "[deploy:rust] --database 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔: ${DATABASE}" >&2 exit 1 fi echo "[deploy:rust] SpacetimeDB 发布数据库: ${DATABASE}" if [[ "${SKIP_SPACETIME_BUILD}" -eq 1 && "${MIGRATION_BOOTSTRAP_SECRET_MODE}" != "disabled" ]]; then echo "[deploy:rust] --skip-spacetime-build 无法把迁移引导密钥注入 wasm。" >&2 echo "[deploy:rust] 请移除 --skip-spacetime-build,或同时传 --no-migration-bootstrap-secret。" >&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 prepare_migration_bootstrap_secret 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" write_env_override "${TARGET_DIR}/.env.local" "GENARRATIVE_SPACETIME_DATABASE" "${DATABASE}" write_env_override "${WEB_DIR}/.env.local" "GENARRATIVE_SPACETIME_DATABASE" "${DATABASE}" 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" if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" != "disabled" ]]; then printf "%s\n" "${MIGRATION_BOOTSTRAP_SECRET}" >"${TARGET_DIR}/migration-bootstrap-secret.txt" chmod 600 "${TARGET_DIR}/migration-bootstrap-secret.txt" fi mkdir -p "${TARGET_DIR}/scripts" for migration_script in \ spacetime-migration-common.mjs \ spacetime-export-migration-json.mjs \ spacetime-import-migration-json.mjs \ spacetime-authorize-migration-operator.mjs \ spacetime-revoke-migration-operator.mjs; do copy_required_file \ "${SCRIPT_DIR}/${migration_script}" \ "${TARGET_DIR}/scripts/${migration_script}" \ "SpacetimeDB 迁移脚本 ${migration_script}" done 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 touch "${TARGET_DIR}/.env" for env_file in "${TARGET_DIR}/.env" "${TARGET_DIR}/.env.local"; do if [[ -f "${env_file}" ]]; then grep -v '^GENARRATIVE_SPACETIME_ROOT_DIR=' "${env_file}" >"${env_file}.tmp" || true mv "${env_file}.tmp" "${env_file}" printf '\nGENARRATIVE_SPACETIME_ROOT_DIR=__GENARRATIVE_RUNTIME_SPACETIME_ROOT_DIR__\n' >>"${env_file}" fi done 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. 默认遇到 schema 冲突时自动导出旧库、清库发布新模块并导入回灌。 4. 显式传入 --clear-database 时代表人工确认清库,不执行自动回灌。 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_ROOT_DIR="${GENARRATIVE_SPACETIME_ROOT_DIR:-${SCRIPT_DIR}/.spacetimedb}" if [[ "${SPACETIME_ROOT_DIR}" == "__GENARRATIVE_RUNTIME_SPACETIME_ROOT_DIR__" ]]; then SPACETIME_ROOT_DIR="${SCRIPT_DIR}/.spacetimedb" fi 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__}" SPACETIME_TIMEOUT_SECONDS="${GENARRATIVE_SPACETIME_TIMEOUT_SECONDS:-60}" SPACETIME_MIGRATE_ON_CONFLICT="${GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT:-true}" SPACETIME_MIGRATION_DIR="${GENARRATIVE_SPACETIME_MIGRATION_DIR:-}" 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__}" MIGRATION_BOOTSTRAP_SECRET_FILE="${SCRIPT_DIR}/migration-bootstrap-secret.txt" PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE="${SCRIPT_DIR}/deploy-state/migration-bootstrap-secret.previous.txt" MIGRATION_SCRIPT_DIR="${SCRIPT_DIR}/scripts" MIGRATION_EXPORT_SCRIPT="${MIGRATION_SCRIPT_DIR}/spacetime-export-migration-json.mjs" MIGRATION_IMPORT_SCRIPT="${MIGRATION_SCRIPT_DIR}/spacetime-import-migration-json.mjs" # 日志默认落文件,显式关闭 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 } is_truthy() { local normalized normalized="$(printf "%s" "${1:-}" | tr '[:upper:]' '[:lower:]')" case "${normalized}" in 1|true|yes|y|on) return 0 ;; *) return 1 ;; esac } timestamp_slug() { date -u +%Y-%m-%dT%H-%M-%SZ } sanitize_path_segment() { printf "%s" "$1" | tr -c 'A-Za-z0-9._-' '_' } validate_spacetime_database_name() { local database="$1" if [[ ! "${database}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then echo "[start] GENARRATIVE_SPACETIME_DATABASE 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔: ${database}" >&2 exit 1 fi } is_publish_conflict_output() { local output="$1" local normalized normalized="$(printf "%s" "${output}" | tr '[:upper:]' '[:lower:]')" [[ "${normalized}" == *"requires a manual migration"* ]] \ || [[ "${normalized}" == *"manual migration"* ]] \ || [[ "${normalized}" == *"schema"* && "${normalized}" == *"conflict"* ]] \ || [[ "${normalized}" == *"clear-database"* ]] \ || [[ "${normalized}" == *"clear database"* && "${normalized}" == *"publish"* ]] } read_migration_bootstrap_secret() { local secret_file="$1" local label="$2" local secret="" if [[ ! -f "${secret_file}" ]]; then echo "[start] schema 冲突自动迁移需要${label}: ${secret_file}" >&2 echo "[start] 请使用默认带迁移引导密钥的发布包,或设置 GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT=false 后人工处理。" >&2 return 1 fi secret="$(tr -d '\r\n' <"${secret_file}")" if [[ -z "${secret}" ]]; then echo "[start] 迁移引导密钥为空${label}: ${secret_file}" >&2 return 1 fi printf "%s" "${secret}" } read_export_migration_bootstrap_secret() { if [[ -f "${PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE}" ]]; then read_migration_bootstrap_secret "${PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE}" "(旧模块导出)" return fi read_migration_bootstrap_secret "${MIGRATION_BOOTSTRAP_SECRET_FILE}" "(当前模块导出兜底)" } read_import_migration_bootstrap_secret() { read_migration_bootstrap_secret "${MIGRATION_BOOTSTRAP_SECRET_FILE}" "(新模块导入)" } require_migration_script() { local script_path="$1" if [[ ! -f "${script_path}" ]]; then echo "[start] 发布包缺少 SpacetimeDB 迁移脚本: ${script_path}" >&2 exit 1 fi } run_publish() { local output_file="$1" shift set +e spacetime --root-dir="${SPACETIME_ROOT_DIR}" "$@" >"${output_file}" 2>&1 local status=$? set -e cat "${output_file}" return "${status}" } run_conflict_migration_publish() { local export_bootstrap_secret="" local import_bootstrap_secret="" local migration_database_slug="" local migration_root="" local migration_file="" local publish_log="" export_bootstrap_secret="$(read_export_migration_bootstrap_secret)" import_bootstrap_secret="$(read_import_migration_bootstrap_secret)" require_migration_script "${MIGRATION_EXPORT_SCRIPT}" require_migration_script "${MIGRATION_IMPORT_SCRIPT}" migration_database_slug="$(sanitize_path_segment "${SPACETIME_DATABASE}")" migration_root="${SPACETIME_MIGRATION_DIR:-${SCRIPT_DIR}/database-migrations/${migration_database_slug}}" mkdir -p "${migration_root}" migration_file="${migration_root}/$(timestamp_slug).json" echo "[start] 检测到 SpacetimeDB schema 冲突,开始导出旧库迁移 JSON: ${migration_file}" node "${MIGRATION_EXPORT_SCRIPT}" \ --server "${SPACETIME_SERVER_URL}" \ --server-url "${SPACETIME_SERVER_URL}" \ --root-dir "${SPACETIME_ROOT_DIR}" \ --database "${SPACETIME_DATABASE}" \ --bootstrap-secret "${export_bootstrap_secret}" \ --out "${migration_file}" \ --note "deploy conflict export $(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "[start] 清库发布新 SpacetimeDB wasm" publish_log="$(mktemp)" if ! run_publish "${publish_log}" \ publish \ "${SPACETIME_DATABASE}" \ --server "${SPACETIME_SERVER_URL}" \ --bin-path "${SCRIPT_DIR}/spacetime_module.wasm" \ --clear-database \ --yes; then echo "[start] 清库发布失败,迁移 JSON 已保留: ${migration_file}" >&2 rm -f "${publish_log}" exit 1 fi rm -f "${publish_log}" echo "[start] 导入迁移 JSON 回灌数据" if ! node "${MIGRATION_IMPORT_SCRIPT}" \ --server "${SPACETIME_SERVER_URL}" \ --server-url "${SPACETIME_SERVER_URL}" \ --root-dir "${SPACETIME_ROOT_DIR}" \ --database "${SPACETIME_DATABASE}" \ --bootstrap-secret "${import_bootstrap_secret}" \ --in "${migration_file}" \ --replace-existing \ --note "deploy conflict import $(date -u +%Y-%m-%dT%H:%M:%SZ)"; then echo "[start] 导入失败,迁移 JSON 已保留: ${migration_file}" >&2 exit 1 fi echo "[start] schema 冲突自动迁移完成,迁移 JSON: ${migration_file}" } wait_for_spacetime() { local process_pid="${1:-}" local deadline=$((SECONDS + SPACETIME_TIMEOUT_SECONDS)) while ((SECONDS < deadline)); do if is_spacetime_ready; then return fi if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then echo "[start] SpacetimeDB 进程在就绪前退出。" >&2 print_spacetime_start_diagnostics exit 1 fi sleep 0.5 done if is_spacetime_ready; then return fi echo "[start] 等待 SpacetimeDB 就绪超时: ${SPACETIME_SERVER_URL}" >&2 print_spacetime_start_diagnostics exit 1 } is_spacetime_ready() { local output if ! output="$(spacetime --root-dir="${SPACETIME_ROOT_DIR}" server ping "${SPACETIME_SERVER_URL}" 2>&1)"; then return 1 fi # SpacetimeDB CLI 2.1.0 在 502 Bad Gateway 时仍可能返回 0,不能只依赖退出码。 [[ "${output}" == *"Server is online:"* ]] } print_spacetime_start_diagnostics() { local log_file="${LOG_DIR}/spacetimedb.log" local root_owner="" # SpacetimeDB 启动日志默认重定向到文件;失败时主动回显关键现场,避免只看到“就绪前退出”。 echo "[start] SpacetimeDB 启动诊断:" >&2 echo "[start] - server: ${SPACETIME_SERVER_URL}" >&2 echo "[start] - listen: ${SPACETIME_HOST}:${SPACETIME_PORT}" >&2 echo "[start] - root-dir: ${SPACETIME_ROOT_DIR}" >&2 echo "[start] - log: ${log_file}" >&2 if [[ -f "${log_file}" ]]; then echo "[start] ${log_file} 最近 120 行:" >&2 tail -n 120 "${log_file}" >&2 || true else echo "[start] 尚未生成 ${log_file}" >&2 fi echo "[start] server ping 结果:" >&2 spacetime --root-dir="${SPACETIME_ROOT_DIR}" server ping "${SPACETIME_SERVER_URL}" >&2 || true if command -v ss >/dev/null 2>&1; then echo "[start] ${SPACETIME_PORT} 端口监听检查:" >&2 ss -ltnp 2>/dev/null | awk -v listen=":${SPACETIME_PORT}" 'NR == 1 || index($0, listen) > 0 { print }' >&2 || true elif command -v netstat >/dev/null 2>&1; then echo "[start] ${SPACETIME_PORT} 端口监听检查:" >&2 netstat -ltnp 2>/dev/null | awk -v listen=":${SPACETIME_PORT}" 'NR == 1 || index($0, listen) > 0 { print }' >&2 || true fi root_owner="$(describe_spacetime_root_owner || true)" if [[ -n "${root_owner}" ]]; then echo "[start] root-dir 相关 SpacetimeDB 进程:" >&2 echo "${root_owner}" >&2 fi } describe_spacetime_root_owner() { if command -v ps >/dev/null 2>&1; then ps -eo user=,pid=,ppid=,stat=,comm=,args= 2>/dev/null | awk -v root_dir="${SPACETIME_ROOT_DIR}" ' { user = $1 pid = $2 ppid = $3 stat = $4 command = $5 args = $0 sub(/^[[:space:]]*[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]*/, "", args) name = command sub(/^.*\//, "", name) # 只认真实的 SpacetimeDB 启动进程,避免 .spacetimedb 路径让 grep/awk 自身误命中。 if ((name == "spacetime" || name == "spacetimedb-cli") && index(args, root_dir) > 0) { print user " " pid " " ppid " " stat " " name " " args } } ' || true fi } sync_ubuntu_spacetime_install() { local root_dir="$1" local target_bin_dir="${root_dir}/bin/current" local target_cli="${target_bin_dir}/spacetimedb-cli" local target_standalone="${target_bin_dir}/spacetimedb-standalone" local spacetime_command="" local resolved_command="" local install_dir="" local root_bin="${root_dir}/bin" local parent_dir="" local share_bin_dir="" local version_dir="" if [[ -x "${target_cli}" && -x "${target_standalone}" ]]; then return fi spacetime_command="$(command -v spacetime || true)" if [[ -z "${spacetime_command}" ]]; then echo "[start] 缺少 spacetime 命令,无法同步 SpacetimeDB 安装。" >&2 exit 1 fi resolved_command="${spacetime_command}" if command -v readlink >/dev/null 2>&1; then resolved_command="$(readlink -f "${spacetime_command}" 2>/dev/null || echo "${spacetime_command}")" fi install_dir="$(cd -- "$(dirname -- "${resolved_command}")" && pwd)" mkdir -p "${root_bin}" for share_bin_dir in \ "/usr/.local/share/spacetime/bin" \ "${HOME:-}/.local/share/spacetime/bin"; do if [[ -d "${share_bin_dir}" ]]; then version_dir="$(find "${share_bin_dir}" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)" if [[ -n "${version_dir}" && -x "${version_dir}/spacetimedb-cli" && -x "${version_dir}/spacetimedb-standalone" ]]; then echo "[start] 同步 Ubuntu SpacetimeDB 安装: ${version_dir} -> ${target_bin_dir}" rm -rf "${target_bin_dir}" mkdir -p "${target_bin_dir}" cp -a "${version_dir}/." "${target_bin_dir}/" chmod +x "${target_cli}" "${target_standalone}" return fi fi done if [[ -d "${install_dir}/bin" ]]; then echo "[start] 同步 Ubuntu SpacetimeDB 安装: ${install_dir}/bin -> ${root_bin}" cp -a "${install_dir}/bin/." "${root_bin}/" elif [[ -x "${install_dir}/current/spacetimedb-cli" ]]; then echo "[start] 同步 Ubuntu SpacetimeDB 安装: ${install_dir} -> ${root_bin}" cp -a "${install_dir}/." "${root_bin}/" elif [[ -x "${install_dir}/spacetimedb-cli" && -x "${install_dir}/spacetimedb-standalone" ]]; then echo "[start] 同步 Ubuntu SpacetimeDB 安装: ${install_dir} -> ${target_bin_dir}" rm -rf "${target_bin_dir}" mkdir -p "${target_bin_dir}" cp -f "${install_dir}/spacetimedb-cli" "${target_cli}" cp -f "${install_dir}/spacetimedb-standalone" "${target_standalone}" chmod +x "${target_cli}" "${target_standalone}" elif [[ -f "${resolved_command}" ]]; then parent_dir="$(cd -- "${install_dir}/.." && pwd)" if [[ -d "${parent_dir}/bin" && -x "${parent_dir}/bin/current/spacetimedb-cli" && -x "${parent_dir}/bin/current/spacetimedb-standalone" ]]; then echo "[start] 同步 Ubuntu SpacetimeDB 安装: ${parent_dir}/bin -> ${root_bin}" cp -a "${parent_dir}/bin/." "${root_bin}/" else echo "[start] 未能从 spacetime 命令路径推断完整 SpacetimeDB 安装目录: ${resolved_command}" >&2 fi fi if [[ ! -x "${target_cli}" || ! -x "${target_standalone}" ]]; then echo "[start] 同步 SpacetimeDB 安装后仍未找到完整 current 目录。" >&2 echo "[start] 需要同时存在: ${target_cli} 与 ${target_standalone}" >&2 echo "[start] 请确认 Ubuntu 上的 spacetime 安装目录包含 spacetimedb-cli 和 spacetimedb-standalone。" >&2 exit 1 fi } 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}" JENKINS_NODE_COOKIE=dontKillMe BUILD_ID=dontKillMe nohup "$@" >"${log_file}" 2>&1 & echo "$!" >"${pid_file}" } validate_spacetime_database_name "${SPACETIME_DATABASE}" echo "[start] SpacetimeDB 发布配置:" echo "[start] - database: ${SPACETIME_DATABASE}" echo "[start] - server: ${SPACETIME_SERVER_URL}" echo "[start] - root-dir: ${SPACETIME_ROOT_DIR}" require_command node require_command spacetime mkdir -p "${PID_DIR}" "${LOG_DIR}" "${SPACETIME_ROOT_DIR}" sync_ubuntu_spacetime_install "${SPACETIME_ROOT_DIR}" SPACETIME_PID="" if is_spacetime_ready; then echo "[start] 复用已运行的 SpacetimeDB: ${SPACETIME_SERVER_URL}" else SPACETIME_ROOT_OWNER="$(describe_spacetime_root_owner)" if [[ -n "${SPACETIME_ROOT_OWNER}" ]]; then echo "[start] 当前 root-dir 已被其他 SpacetimeDB 实例占用,无法再次启动。" >&2 echo "[start] 目标地址未就绪: ${SPACETIME_SERVER_URL}" >&2 echo "[start] 如需复用,请把 GENARRATIVE_SPACETIME_PORT 改为占用实例实际端口;如需重启,请先停止下列进程。" >&2 echo "${SPACETIME_ROOT_OWNER}" >&2 exit 1 fi start_process spacetimedb \ spacetime \ --root-dir="${SPACETIME_ROOT_DIR}" \ start \ --edition standalone \ --listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" SPACETIME_PID="$(cat "${PID_DIR}/spacetimedb.pid")" fi wait_for_spacetime "${SPACETIME_PID}" 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}" if [[ -f "${MIGRATION_BOOTSTRAP_SECRET_FILE}" ]]; then echo "[start] 迁移引导密钥: $(cat "${MIGRATION_BOOTSTRAP_SECRET_FILE}")" else echo "[start] 未启用迁移引导密钥。" fi PUBLISH_LOG="$(mktemp)" if ! run_publish "${PUBLISH_LOG}" "${PUBLISH_ARGS[@]}"; then PUBLISH_OUTPUT="$(cat "${PUBLISH_LOG}")" rm -f "${PUBLISH_LOG}" if [[ "${CLEAR_DATABASE}" -eq 0 ]] \ && is_truthy "${SPACETIME_MIGRATE_ON_CONFLICT}" \ && is_publish_conflict_output "${PUBLISH_OUTPUT}"; then run_conflict_migration_publish else if [[ "${CLEAR_DATABASE}" -eq 0 ]] && ! is_truthy "${SPACETIME_MIGRATE_ON_CONFLICT}"; then echo "[start] 已禁用 schema 冲突自动迁移: GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT=${SPACETIME_MIGRATE_ON_CONFLICT}" >&2 fi echo "[start] SpacetimeDB 发布失败。" >&2 echo "[start] 如果错误包含 403 Forbidden 或 is not authorized,通常是当前 CLI 身份无权更新目标数据库。" >&2 echo "[start] 当前 start.sh 使用的 CLI root: ${SPACETIME_ROOT_DIR}" >&2 spacetime --root-dir="${SPACETIME_ROOT_DIR}" login show >&2 || true echo "[start] 如果目标是本地库且可以清空数据:先执行 ./stop.sh,备份或删除 ${SPACETIME_ROOT_DIR},再重新执行 ./start.sh --clear-database。" >&2 echo "[start] 如果目标是 Maincloud 或必须保留数据:请切换到创建该数据库的 SpacetimeDB 身份,或把 GENARRATIVE_SPACETIME_DATABASE 改为当前身份有权限的库。" >&2 exit 1 fi else rm -f "${PUBLISH_LOG}" fi 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}" echo "[start] SpacetimeDB database: ${SPACETIME_DATABASE}" 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 构建时间:`__GENARRATIVE_BUILD_NAME__` ## 内容 - \`.env\` / \`.env.local\`:从仓库根目录复制的环境文件,同时各保留一份到 \`web/\` - \`web/\`:Vite release 静态资源 - \`api-server\`:x86_64-unknown-linux-gnu release 可执行文件 - \`spacetime_module.wasm\`:wasm32-unknown-unknown release 模块 - \`migration-bootstrap-secret.txt\`:本发布包 wasm 编译时注入的迁移引导密钥;服务器 \`start.sh\` 发布时会显示,迁移授权完成后可删除 - \`scripts/spacetime-*.mjs\`:部署时 schema 冲突自动导出、导入回灌使用的 SpacetimeDB 迁移脚本 - \`web-server.mjs\`:静态网站与 API 反代入口 - \`start.sh\` / \`stop.sh\`:目标服务器启动与停止脚本 ## 启动 \`\`\`bash ./start.sh \`\`\` 默认不清空 SpacetimeDB。如需开发库清库重发: \`\`\`bash ./start.sh --clear-database \`\`\` 默认启动会先尝试无清库发布;如果 SpacetimeDB 返回 schema 冲突,\`start.sh\` 会把旧库导出到 \`database-migrations//\`,随后清库发布新 wasm,并用 \`--replace-existing\` 导入回灌。 ## 环境变量 - 启动时会先加载发布目录根部的 \`.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_ROOT_DIR\`:默认使用发布目录下的 \`.spacetimedb/\`,同时承载本地 SpacetimeDB 运行数据与 CLI 身份。 - \`GENARRATIVE_SPACETIME_TIMEOUT_SECONDS\`:等待 SpacetimeDB 就绪的秒数,默认 \`60\`。 - \`GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT\`:默认 \`true\`,普通发布遇到 schema 冲突时自动导出、清库发布、导入回灌;设为 \`false\` 时保留原始发布失败。 - \`GENARRATIVE_SPACETIME_MIGRATION_DIR\`:自动迁移 JSON 输出目录,默认 \`database-migrations//\`。 - OSS、LLM、短信、微信等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理。 - 迁移引导密钥由构建发布包时随机生成,构建日志和服务器 \`start.sh\` 发布日志都会显示同一份密钥。 EOF replace_placeholder_in_file "${TARGET_DIR}/README.md" "__GENARRATIVE_BUILD_NAME__" "${BUILD_NAME}" 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}"