#!/usr/bin/env bash set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" HOST="${STUDIO_HOST:-58.38.22.103}" REMOTE_USER_ROOT="${STUDIO_USER_ROOT:-/Users/john}" SHARED_MEMIND_ROOT="${STUDIO_SHARED_MEMIND_ROOT:-/Users/john/Project/Memind}" APP_DIR="${REMOTE_USER_ROOT}/MindSpace" INCOMING_DIR="/Users/john/Project/incoming/mindspace-service-runtime" BACKUP_DIR="/Users/john/Project/backups/mindspace-service" ARCHIVE_DIR="/Users/john/Project/archives/mindspace-service" HEALTH_URL="${MINDSPACE_SERVICE_HEALTH_URL:-http://127.0.0.1:8082/health}" CONTRACT_URL="${MINDSPACE_SERVICE_CONTRACT_URL:-http://127.0.0.1:8082/mindspace/v1/contract}" PORTAL_HEALTH_URL="${STUDIO_HEALTH_URL:-http://127.0.0.1:8081/api/status}" SERVICE_LABEL="cn.tkmind.mindspace-service" PORTAL_LABEL="cn.tkmind.memind-portal" LAUNCHD_GUI="gui/$(id -u)" RELEASE_TS="$(date +%Y%m%d-%H%M%S)" SHORT_SHA="$(git -C "${ROOT}" rev-parse --short HEAD 2>/dev/null || echo no-git)" RELEASE_ID="${RELEASE_TS}-${SHORT_SHA}" TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mindspace-service-release.XXXXXX")" RUNTIME_ROOT="${ROOT}/.runtime/mindspace-service" BUNDLE_PATH="${TMP_DIR}/mindspace-service-${RELEASE_ID}.tar.gz" MANIFEST_PATH="${TMP_DIR}/mindspace-service-${RELEASE_ID}.manifest.txt" SHA_PATH="${TMP_DIR}/mindspace-service-${RELEASE_ID}.sha256" DRY_RUN=0 SKIP_TESTS=0 SKIP_BUILD=0 AUTO_YES=0 cleanup() { rm -rf "${TMP_DIR}" } trap cleanup EXIT usage() { cat <<'EOF' 用法: bash scripts/release-mindspace-service-prod.sh [--dry-run] [--skip-tests] [--skip-build] [--yes] 说明: 1. 本地构建 MindSpace service runtime artifact 2. 上传到 103 3. 103 备份当前 /Users/john/MindSpace(如存在) 4. 103 备份 /Users/john/Project/Memind/.env 5. 启动 /Users/john/MindSpace 独立服务(127.0.0.1:8082) 6. 将 Memind 切到 remote adapter 并重启 Portal 7. 失败时恢复旧 MindSpace 服务目录与 Memind .env EOF } while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=1 ;; --skip-tests) SKIP_TESTS=1 ;; --skip-build) SKIP_BUILD=1 ;; --yes|-y) AUTO_YES=1 ;; -h|--help) usage exit 0 ;; *) echo "未知参数: $1" >&2 usage >&2 exit 1 ;; esac shift done say() { printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*" } need_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "缺少命令: $1" >&2 exit 1 } } need_cmd ssh need_cmd scp need_cmd tar need_cmd shasum if [[ -x "${ROOT}/scripts/check-release-ready.sh" ]]; then bash "${ROOT}/scripts/check-release-ready.sh" else echo "缺少发布前闸门: ${ROOT}/scripts/check-release-ready.sh" >&2 exit 1 fi say "本地预检查" ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo mindspace-service-ssh-ok" >/dev/null ssh -o BatchMode=yes "${HOST}" "test -d '${SHARED_MEMIND_ROOT}' && test -f '${SHARED_MEMIND_ROOT}/.env'" >/dev/null if [[ "${SKIP_TESTS}" -ne 1 ]]; then say "运行最小验证" ( cd "${ROOT}" node --test mindspace-service/mindspace-rpc-server.test.mjs >/dev/null ) fi if [[ "${SKIP_BUILD}" -ne 1 ]]; then say "构建 MindSpace service runtime" ( cd "${ROOT}" node scripts/build-mindspace-service-runtime.mjs ) fi [[ -d "${RUNTIME_ROOT}" ]] || { echo "缺少 runtime 目录: ${RUNTIME_ROOT}" >&2 exit 1 } verify_runtime_artifact() { local missing=0 for required in README.md package.json server.mjs mindspace-rpc-server.mjs mindspace-service-bootstrap.mjs scripts/run-mindspace-prod.sh memind-source RUNBOOK.txt; do if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2 missing=1 fi done if [[ "${missing}" -ne 0 ]]; then exit 1 fi } verify_runtime_artifact say "生成发布清单" cat > "${MANIFEST_PATH}" < "${SHA_PATH}" if [[ "${DRY_RUN}" -eq 1 ]]; then say "dry-run 完成" ls -lh "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}" exit 0 fi if [[ "${AUTO_YES}" -ne 1 ]]; then echo "" echo "即将部署 MindSpace service 到 ${HOST}:${APP_DIR}" echo "并把 Memind 切换到 remote adapter -> http://127.0.0.1:8082" read -r -p "确认继续?[y/N] " reply if [[ ! "${reply}" =~ ^[Yy]$ ]]; then echo "已取消" exit 1 fi fi say "上传 MindSpace service runtime 到 103" ssh "${HOST}" "mkdir -p '${INCOMING_DIR}' '${BACKUP_DIR}' '${ARCHIVE_DIR}'" scp -q "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${HOST}:${INCOMING_DIR}/" say "在 103 切换 MindSpace service 并更新 Memind remote adapter" ssh -o BatchMode=yes "${HOST}" 'bash -s' -- "${RELEASE_ID}" <<'REMOTE' set -euo pipefail RELEASE_ID="$1" USER_ROOT="/Users/john" APP_DIR="${USER_ROOT}/MindSpace" NEW_DIR="${USER_ROOT}/MindSpace.release-${RELEASE_ID}" INCOMING_DIR="/Users/john/Project/incoming/mindspace-service-runtime" BACKUP_DIR="/Users/john/Project/backups/mindspace-service" ARCHIVE_DIR="/Users/john/Project/archives/mindspace-service" SHARED_MEMIND_ROOT="/Users/john/Project/Memind" BUNDLE="${INCOMING_DIR}/mindspace-service-${RELEASE_ID}.tar.gz" MANIFEST="${INCOMING_DIR}/mindspace-service-${RELEASE_ID}.manifest.txt" ENV_FILE="${SHARED_MEMIND_ROOT}/.env" ENV_BACKUP="${BACKUP_DIR}/memind-env-${RELEASE_ID}.before" APP_BACKUP="${BACKUP_DIR}/mindspace-service-${RELEASE_ID}.before.tar.gz" OLD_APP_ARCHIVE="${ARCHIVE_DIR}/MindSpace-before-${RELEASE_ID}" SERVICE_LABEL="cn.tkmind.mindspace-service" PORTAL_LABEL="cn.tkmind.memind-portal" LAUNCHD_GUI="gui/$(id -u)" SERVICE_PLIST="${HOME}/Library/LaunchAgents/${SERVICE_LABEL}.plist" SERVICE_PLIST_BAK="${BACKUP_DIR}/${SERVICE_LABEL}.${RELEASE_ID}.plist.bak" SERVICE_STARTED=0 PORTAL_TOUCHED=0 APP_MOVED=0 HAD_OLD_APP=0 PERSISTED_DATA_MOVED=0 PERSISTED_USERS_MOVED=0 say() { printf '\n[remote %s] %s\n' "$(date +%H:%M:%S)" "$*" } service_is_running() { launchctl print "${LAUNCHD_GUI}/${SERVICE_LABEL}" 2>/dev/null | grep -q 'state = running' } rollback() { set +e say "rollback: restoring previous state" if [[ "${PORTAL_TOUCHED}" == "1" && -f "${ENV_BACKUP}" ]]; then cp "${ENV_BACKUP}" "${ENV_FILE}" launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true fi if [[ "${SERVICE_STARTED}" == "1" ]]; then launchctl bootout "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true fi if [[ -f "${SERVICE_PLIST_BAK}" ]]; then cp "${SERVICE_PLIST_BAK}" "${SERVICE_PLIST}" else rm -f "${SERVICE_PLIST}" fi if [[ "${APP_MOVED}" == "1" ]]; then if [[ "${HAD_OLD_APP}" == "1" ]]; then if [[ "${PERSISTED_DATA_MOVED}" == "1" && ( -e "${APP_DIR}/data" || -L "${APP_DIR}/data" ) ]]; then rm -rf "${OLD_APP_ARCHIVE}/data" mv "${APP_DIR}/data" "${OLD_APP_ARCHIVE}/data" fi if [[ "${PERSISTED_USERS_MOVED}" == "1" && ( -e "${APP_DIR}/users" || -L "${APP_DIR}/users" ) ]]; then rm -rf "${OLD_APP_ARCHIVE}/users" mv "${APP_DIR}/users" "${OLD_APP_ARCHIVE}/users" fi fi rm -rf "${APP_DIR}" if [[ "${HAD_OLD_APP}" == "1" && -d "${OLD_APP_ARCHIVE}" ]]; then mv "${OLD_APP_ARCHIVE}" "${APP_DIR}" fi fi if [[ "${HAD_OLD_APP}" == "1" && -f "${SERVICE_PLIST}" ]]; then launchctl bootstrap "${LAUNCHD_GUI}" "${SERVICE_PLIST}" >/dev/null 2>&1 || true launchctl kickstart -k "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true fi exit 1 } trap rollback ERR [[ -f "${BUNDLE}" && -f "${MANIFEST}" ]] say "校验产物完整性" cd "${INCOMING_DIR}" tar -tzf "${BUNDLE}" >/dev/null say "备份 Memind .env" cp "${ENV_FILE}" "${ENV_BACKUP}" if [[ -d "${APP_DIR}" ]]; then HAD_OLD_APP=1 say "备份旧 MindSpace service 目录" COPYFILE_DISABLE=1 tar -czf "${APP_BACKUP}" -C "$(dirname "${APP_DIR}")" "$(basename "${APP_DIR}")" fi say "准备新的 runtime 目录" rm -rf "${NEW_DIR}" mkdir -p "${NEW_DIR}" tar -xzf "${BUNDLE}" -C "${NEW_DIR}" cp "${MANIFEST}" "${NEW_DIR}/.release-manifest.txt" say "更新 MindSpace service LaunchAgent" if [[ -f "${SERVICE_PLIST}" ]]; then cp "${SERVICE_PLIST}" "${SERVICE_PLIST_BAK}" fi cat > "${SERVICE_PLIST}" < Label ${SERVICE_LABEL} ProgramArguments ${APP_DIR}/scripts/run-mindspace-prod.sh WorkingDirectory ${APP_DIR} EnvironmentVariables PATH /opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin MEMIND_SHARED_ROOT ${SHARED_MEMIND_ROOT} MINDSPACE_SERVICE_PORT 8082 MINDSPACE_SERVICE_HOST 127.0.0.1 MINDSPACE_STORAGE_ROOT ${APP_DIR}/data/mindspace H5_USERS_ROOT ${APP_DIR}/users MINDSPACE_SERVICE_AGENT_WORKER_ENABLED true MINDSPACE_SERVICE_WORKSPACE_MAINTENANCE true RunAtLoad KeepAlive StandardOutPath ${HOME}/Library/Logs/mindspace-service.log StandardErrorPath ${HOME}/Library/Logs/mindspace-service.log EOF say "切换 MindSpace service live 目录" launchctl bootout "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true for _ in $(seq 1 60); do if ! lsof -nP -iTCP:8082 -sTCP:LISTEN >/dev/null 2>&1; then break fi sleep 1 done if lsof -nP -iTCP:8082 -sTCP:LISTEN >/dev/null 2>&1; then echo "旧 MindSpace service 未在超时内退出,拒绝切换 runtime" >&2 exit 1 fi rm -rf "${OLD_APP_ARCHIVE}" if [[ -d "${APP_DIR}" ]]; then mv "${APP_DIR}" "${OLD_APP_ARCHIVE}" fi mv "${NEW_DIR}" "${APP_DIR}" APP_MOVED=1 say "继承 MindSpace 持久目录" if [[ "${HAD_OLD_APP}" == "1" && ( -e "${OLD_APP_ARCHIVE}/data" || -L "${OLD_APP_ARCHIVE}/data" ) ]]; then rm -rf "${APP_DIR}/data" mv "${OLD_APP_ARCHIVE}/data" "${APP_DIR}/data" PERSISTED_DATA_MOVED=1 fi if [[ "${HAD_OLD_APP}" == "1" && ( -e "${OLD_APP_ARCHIVE}/users" || -L "${OLD_APP_ARCHIVE}/users" ) ]]; then rm -rf "${APP_DIR}/users" mv "${OLD_APP_ARCHIVE}/users" "${APP_DIR}/users" PERSISTED_USERS_MOVED=1 fi say "启动 MindSpace service" mkdir -p "${APP_DIR}/data/mindspace" "${APP_DIR}/users" launchctl bootstrap "${LAUNCHD_GUI}" "${SERVICE_PLIST}" >/dev/null SERVICE_STARTED=1 launchctl enable "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null launchctl kickstart -k "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null for _ in $(seq 1 30); do code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/health || true)" if [[ "${code}" == "200" ]] && service_is_running; then break fi sleep 1 done [[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/health || true)" == "200" ]] service_is_running [[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/mindspace/v1/contract || true)" == "200" ]] set_env() { local key="$1" local value="$2" local file="$3" local tmp tmp="$(mktemp "${TMPDIR:-/tmp}/env.XXXXXX")" awk -v k="${key}" -v v="${value}" ' BEGIN { done = 0 } index($0, k "=") == 1 { if (!done) { print k "=" v done = 1 } next } { print } END { if (!done) print k "=" v } ' "${file}" > "${tmp}" mv "${tmp}" "${file}" } TOKEN="$(grep -E '^TKMIND_SERVER__SECRET_KEY=' "${ENV_FILE}" | tail -1 | cut -d= -f2- || true)" if [[ -z "${TOKEN}" ]]; then echo "Memind .env 缺少 TKMIND_SERVER__SECRET_KEY,无法写入 remote adapter token" >&2 exit 1 fi say "切换 Memind 到 remote adapter" set_env MINDSPACE_SERVER_ADAPTER remote "${ENV_FILE}" set_env MINDSPACE_REMOTE_BASE_URL http://127.0.0.1:8082 "${ENV_FILE}" set_env MINDSPACE_REMOTE_AUTH_TOKEN "${TOKEN}" "${ENV_FILE}" set_env MEMIND_WORKSPACE_MAINTENANCE 0 "${ENV_FILE}" PORTAL_TOUCHED=1 launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true for _ in $(seq 1 30); do code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/api/status || true)" if [[ "${code}" == "200" ]]; then break fi sleep 1 done [[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/api/status || true)" == "200" ]] sleep 3 [[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/health || true)" == "200" ]] service_is_running trap - ERR say "切换完成" printf 'release_id=%s\n' "${RELEASE_ID}" printf 'service_root=%s\n' "${APP_DIR}" printf 'service_backup=%s\n' "${APP_BACKUP}" printf 'env_backup=%s\n' "${ENV_BACKUP}" printf 'service_health=%s\n' "$(curl -fsS http://127.0.0.1:8082/health)" REMOTE say "发布完成" printf 'release_id=%s\n' "${RELEASE_ID}" printf 'bundle=%s\n' "${BUNDLE_PATH}" printf 'manifest=%s\n' "${MANIFEST_PATH}" printf 'sha=%s\n' "${SHA_PATH}"