cf04e73f51
确认 goosed-prod-1..9 映射 18006-18014,更新 topology、发包必看、 发布 skill 与 release 脚本的动态 pool 校验。 Co-authored-by: Cursor <cursoragent@cursor.com>
659 lines
24 KiB
Bash
Executable File
659 lines
24 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||
HOST="${STUDIO_HOST:-58.38.22.103}"
|
||
REMOTE_ROOT="${STUDIO_REMOTE_ROOT:-/Users/john/Project}"
|
||
APP_DIR="${REMOTE_ROOT}/Memind"
|
||
INCOMING_DIR="${REMOTE_ROOT}/incoming/memind-portal-runtime"
|
||
BACKUP_DIR="${REMOTE_ROOT}/backups/memind"
|
||
ARCHIVE_DIR="${REMOTE_ROOT}/archives"
|
||
HEALTH_URL="${STUDIO_HEALTH_URL:-http://127.0.0.1:8081/api/status}"
|
||
PORTAL_LABEL="cn.tkmind.memind-portal"
|
||
PORTAL_TUNNEL_LABEL="cn.tkmind.memind-portal-tunnel"
|
||
MEMIND_PORTAL_TUNNEL_HOST="${MEMIND_PORTAL_TUNNEL_HOST:-ssh105-public}"
|
||
MEMIND_PORTAL_TUNNEL_REMOTE_PORT="${MEMIND_PORTAL_TUNNEL_REMOTE_PORT:-19081}"
|
||
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}/memind-portal-runtime-release.XXXXXX")"
|
||
RUNTIME_ROOT="${ROOT}/.runtime/portal"
|
||
BUNDLE_PATH="${TMP_DIR}/memind-portal-runtime-${RELEASE_ID}.tar.gz"
|
||
MANIFEST_PATH="${TMP_DIR}/memind-portal-runtime-${RELEASE_ID}.manifest.txt"
|
||
SHA_PATH="${TMP_DIR}/memind-portal-runtime-${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-portal-runtime-prod.sh [--dry-run] [--skip-tests] [--skip-build] [--yes]
|
||
|
||
说明:
|
||
1. 本地构建 Portal 无源码 runtime artifact
|
||
2. 上传到 103(固定公网地址)
|
||
3. 103 备份当前 Memind 全目录 + 持久目录
|
||
4. 停止旧 Portal 服务
|
||
5. 用 runtime artifact 替换 live 目录,只继承持久目录
|
||
6. 启动 Portal,保持旧端口 8081
|
||
7. 健康检查通过后重启 m.tkmind.cn 反向隧道 (105:19081 -> 103:8081)
|
||
8. 旧源码目录移入 archive,不再保留可运行 live 源码
|
||
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
|
||
|
||
check_release_scope() {
|
||
local bypass="${ALLOW_PORTAL_RELEASE_SCOPE_BYPASS:-0}"
|
||
if [[ "${bypass}" == "1" ]]; then
|
||
say "跳过发版范围检查(ALLOW_PORTAL_RELEASE_SCOPE_BYPASS=1)"
|
||
return 0
|
||
fi
|
||
git -C "${ROOT}" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
|
||
|
||
local path
|
||
local blocked=()
|
||
while IFS= read -r path; do
|
||
[[ -n "${path}" ]] || continue
|
||
case "${path}" in
|
||
docker/*|scripts/start-goosed-local.sh|scripts/verify-goosed-docker.sh|docs/goosed-*.md|server.mjs.bak*|*.bak)
|
||
blocked+=("${path}")
|
||
;;
|
||
docs/local-dev.md|README.md)
|
||
blocked+=("${path}")
|
||
;;
|
||
esac
|
||
done < <(git -C "${ROOT}" status --short --untracked-files=all | sed -E 's/^.. //')
|
||
|
||
if [[ "${#blocked[@]}" -gt 0 ]]; then
|
||
echo "检测到默认不应并入 103 Portal 发版的本地改动:" >&2
|
||
printf ' - %s\n' "${blocked[@]}" >&2
|
||
echo "请先清理这些改动,或确认本次需求明确包含这些模块后再重试。" >&2
|
||
echo "如确需绕过,可临时设置 ALLOW_PORTAL_RELEASE_SCOPE_BYPASS=1。" >&2
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
say "本地预检查"
|
||
check_release_scope
|
||
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo portal-runtime-ssh-ok" >/dev/null
|
||
ssh -o BatchMode=yes "${HOST}" "test -d '${APP_DIR}' && test -f '${APP_DIR}/.env'" >/dev/null
|
||
|
||
if [[ "${SKIP_TESTS}" -ne 1 ]]; then
|
||
say "运行最小验证"
|
||
(
|
||
cd "${ROOT}"
|
||
npm test -- --test-name-pattern='publish|space|billing' >/dev/null
|
||
npm run verify:mindspace-publish-guards >/dev/null
|
||
)
|
||
fi
|
||
|
||
if [[ "${SKIP_BUILD}" -ne 1 ]]; then
|
||
say "构建 Portal runtime"
|
||
(
|
||
cd "${ROOT}"
|
||
node scripts/build-portal-runtime.mjs
|
||
)
|
||
fi
|
||
|
||
[[ -d "${RUNTIME_ROOT}" ]] || {
|
||
echo "缺少 runtime 目录: ${RUNTIME_ROOT}" >&2
|
||
exit 1
|
||
}
|
||
|
||
verify_runtime_artifact() {
|
||
local missing=0
|
||
for required in server.mjs wechat-mp.bundle.mjs mindspace-sandbox-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/check-mindspace-public-links.mjs scripts/load-env.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do
|
||
if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then
|
||
echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2
|
||
missing=1
|
||
fi
|
||
done
|
||
if [[ "${missing}" -ne 0 ]]; then
|
||
echo "请先执行: node scripts/build-portal-runtime.mjs" >&2
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
verify_runtime_artifact
|
||
|
||
say "验证 MindSpace 发布与聊天 Finish 回归守卫"
|
||
(
|
||
cd "${ROOT}"
|
||
npm run verify:mindspace-publish-guards:full
|
||
)
|
||
|
||
verify_mindspace_public_links() {
|
||
local target_root="${1:-${ROOT}/MindSpace}"
|
||
local label="${2:-本地 MindSpace}"
|
||
if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" == "1" ]]; then
|
||
say "跳过 ${label} 公开页链接检查(ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES=1)"
|
||
return 0
|
||
fi
|
||
if [[ ! -d "${target_root}" ]]; then
|
||
say "跳过 ${label} 公开页链接检查(目录不存在: ${target_root})"
|
||
return 0
|
||
fi
|
||
say "检查 ${label} 公开页相对链接"
|
||
if ! node "${ROOT}/scripts/check-mindspace-public-links.mjs" --root "${target_root}" --downloads-only; then
|
||
echo "MindSpace 公开页存在缺失的相对下载/资源链接。" >&2
|
||
echo "修复 public/*.html 中的 href/src/cover 路径,或临时设置 ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES=1 跳过。" >&2
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
if [[ "${DRY_RUN}" -ne 1 ]]; then
|
||
verify_mindspace_public_links "${ROOT}/MindSpace" "本地"
|
||
fi
|
||
|
||
verify_remote_goosed_dependency() {
|
||
say "检查 103 goosed 依赖"
|
||
ssh -o BatchMode=yes "${HOST}" 'bash -s' <<'REMOTE'
|
||
set -euo pipefail
|
||
|
||
missing=0
|
||
|
||
pg_isready_bin="/opt/homebrew/opt/postgresql@17/bin/pg_isready"
|
||
if [[ -x "${pg_isready_bin}" ]]; then
|
||
if ! "${pg_isready_bin}" -h 127.0.0.1 -p 5432 -q 2>/dev/null; then
|
||
echo "goosed dependency check failed: host PostgreSQL (127.0.0.1:5432) is not accepting connections" >&2
|
||
echo "Run: bash /Users/john/Project/goosed-prod/scripts/ensure-goose-session-postgres.sh" >&2
|
||
missing=1
|
||
fi
|
||
else
|
||
echo "goosed dependency check warning: pg_isready not found; skipping PostgreSQL readiness check" >&2
|
||
fi
|
||
|
||
docker_bin="/opt/homebrew/bin/docker"
|
||
if [[ -x "${docker_bin}" ]] && "${docker_bin}" ps --format '{{.Names}}' 2>/dev/null | grep -q '^goosed-prod-1$'; then
|
||
goosed_indexes=()
|
||
while IFS= read -r name; do
|
||
if [[ "${name}" =~ ^goosed-prod-([0-9]+)$ ]]; then
|
||
goosed_indexes+=("${BASH_REMATCH[1]}")
|
||
fi
|
||
done < <("${docker_bin}" ps --format '{{.Names}}' | sort -V)
|
||
if ((${#goosed_indexes[@]} == 0)); then
|
||
echo "goosed dependency check failed: no goosed-prod-* containers running" >&2
|
||
missing=1
|
||
fi
|
||
for index in "${goosed_indexes[@]}"; do
|
||
container="goosed-prod-${index}"
|
||
host_port=$((18005 + index))
|
||
if ! "${docker_bin}" ps --format '{{.Names}} {{.Ports}} {{.Status}}' | grep -q "^${container} .*0.0.0.0:${host_port}->18006/tcp.*healthy"; then
|
||
echo "goosed dependency check failed: ${container} is not healthy on host port ${host_port}" >&2
|
||
missing=1
|
||
fi
|
||
if ! "${docker_bin}" exec "${container}" sh -lc 'test -x /usr/local/bin/node && test -f /opt/portal/mindspace-sandbox-mcp.mjs' >/dev/null 2>&1; then
|
||
echo "goosed dependency check failed: ${container} missing /usr/local/bin/node or /opt/portal/mindspace-sandbox-mcp.mjs" >&2
|
||
missing=1
|
||
fi
|
||
done
|
||
if [[ -f /Users/john/Project/Memind/.env ]]; then
|
||
portal_mcp_node="$(grep -E '^GOOSED_MCP_NODE_PATH=' /Users/john/Project/Memind/.env | tail -1 | cut -d= -f2- || true)"
|
||
portal_mcp_server="$(grep -E '^GOOSED_MCP_SERVER_PATH=' /Users/john/Project/Memind/.env | tail -1 | cut -d= -f2- || true)"
|
||
if [[ -z "${portal_mcp_node}" || -z "${portal_mcp_server}" ]]; then
|
||
echo "goosed dependency check failed: Portal .env must set GOOSED_MCP_NODE_PATH and GOOSED_MCP_SERVER_PATH for Docker goosed" >&2
|
||
missing=1
|
||
else
|
||
for index in "${goosed_indexes[@]}"; do
|
||
container="goosed-prod-${index}"
|
||
if ! "${docker_bin}" exec "${container}" sh -lc "test -x '${portal_mcp_node}' && test -f '${portal_mcp_server}'" >/dev/null 2>&1; then
|
||
echo "goosed dependency check failed: ${container} cannot resolve Portal .env MCP paths '${portal_mcp_node}' and '${portal_mcp_server}'" >&2
|
||
missing=1
|
||
fi
|
||
done
|
||
fi
|
||
targets_line="$(grep -E '^TKMIND_API_TARGETS=' /Users/john/Project/Memind/.env | tail -1 | cut -d= -f2- || true)"
|
||
if [[ -z "${targets_line}" ]]; then
|
||
echo "goosed dependency check failed: Portal .env must set TKMIND_API_TARGETS for Docker goosed pool" >&2
|
||
missing=1
|
||
else
|
||
IFS=',' read -ra target_urls <<< "${targets_line}"
|
||
for target_url in "${target_urls[@]}"; do
|
||
trimmed="$(echo "${target_url}" | tr -d ' \"')"
|
||
if [[ -z "${trimmed}" ]]; then
|
||
continue
|
||
fi
|
||
status="$(curl -skS -m 5 "${trimmed}/status" 2>/dev/null || true)"
|
||
if [[ "${status}" != "ok" ]]; then
|
||
echo "goosed dependency check failed: ${trimmed}/status != ok (${status:-empty})" >&2
|
||
missing=1
|
||
fi
|
||
done
|
||
if ((${#target_urls[@]} != ${#goosed_indexes[@]})); then
|
||
echo "goosed dependency check warning: TKMIND_API_TARGETS count (${#target_urls[@]}) != running containers (${#goosed_indexes[@]})" >&2
|
||
fi
|
||
fi
|
||
fi
|
||
else
|
||
api_secret="$(grep -E '^TKMIND_SERVER__SECRET_KEY=' /Users/john/Project/Memind/.env 2>/dev/null | tail -1 | cut -d= -f2- || true)"
|
||
for port in 18006 18007; do
|
||
status="$(curl -skS -m 5 -H "X-Secret-Key: ${api_secret}" "https://127.0.0.1:${port}/status" 2>/dev/null || true)"
|
||
if [[ "${status}" != "ok" ]]; then
|
||
line="$(lsof -nP -iTCP:${port} -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print $1, $2, $9}')"
|
||
echo "goosed dependency check failed: port ${port} status check failed (${line:-no listener})" >&2
|
||
missing=1
|
||
fi
|
||
done
|
||
|
||
portal_mcp_node="$(grep -E '^GOOSED_MCP_NODE_PATH=' /Users/john/Project/Memind/.env 2>/dev/null | tail -1 | cut -d= -f2- || true)"
|
||
portal_mcp_server="$(grep -E '^GOOSED_MCP_SERVER_PATH=' /Users/john/Project/Memind/.env 2>/dev/null | tail -1 | cut -d= -f2- || true)"
|
||
portal_mcp_node="${portal_mcp_node:-/opt/homebrew/opt/node@24/bin/node}"
|
||
portal_mcp_server="${portal_mcp_server:-/Users/john/Project/Memind/mindspace-sandbox-mcp.mjs}"
|
||
if [[ ! -x "${portal_mcp_node}" || ! -f "${portal_mcp_server}" ]]; then
|
||
echo "goosed dependency check failed: Portal MCP paths are not valid: ${portal_mcp_node}, ${portal_mcp_server}" >&2
|
||
missing=1
|
||
fi
|
||
fi
|
||
|
||
exit "${missing}"
|
||
REMOTE
|
||
}
|
||
|
||
if [[ "${DRY_RUN}" -ne 1 ]]; then
|
||
verify_remote_goosed_dependency
|
||
fi
|
||
|
||
if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then
|
||
say "发布确认"
|
||
echo "目标主机: ${HOST}"
|
||
echo "目标目录: ${APP_DIR}"
|
||
echo "发布编号: ${RELEASE_ID}"
|
||
echo "本地 HEAD: $(git -C "${ROOT}" rev-parse HEAD 2>/dev/null || echo unknown)"
|
||
echo "说明: 此次只切换 test-memind Portal 到无源码 runtime,不包含 memindadm / memindplaza"
|
||
read -r -p "确认继续发布到 103? [y/N] " confirm </dev/tty
|
||
[[ "${confirm}" =~ ^[Yy]$ ]] || exit 0
|
||
fi
|
||
|
||
say "生成发布清单"
|
||
{
|
||
echo "release_id=${RELEASE_ID}"
|
||
echo "created_at=$(date '+%Y-%m-%d %H:%M:%S %z')"
|
||
echo "host=$(hostname)"
|
||
echo "git_head=$(git -C "${ROOT}" rev-parse HEAD 2>/dev/null || echo unknown)"
|
||
echo "git_branch=$(git -C "${ROOT}" branch --show-current 2>/dev/null || echo detached)"
|
||
echo "artifact=.runtime/portal"
|
||
echo "persisted_items=.env, MindSpace, data, users, .tailscale, public/plaza-covers, logs"
|
||
} > "${MANIFEST_PATH}"
|
||
|
||
say "打包 Portal runtime artifact"
|
||
tar -czf "${BUNDLE_PATH}" -C "${RUNTIME_ROOT}" .
|
||
checksum="$(shasum -a 256 "${BUNDLE_PATH}" | awk '{print $1}')"
|
||
printf '%s %s\n' "${checksum}" "$(basename "${BUNDLE_PATH}")" > "${SHA_PATH}"
|
||
|
||
if [[ "${DRY_RUN}" -eq 1 ]]; then
|
||
say "dry-run 完成"
|
||
ls -lh "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}"
|
||
exit 0
|
||
fi
|
||
|
||
say "上传 Portal runtime 到 103"
|
||
ssh -o BatchMode=yes "${HOST}" "mkdir -p '${INCOMING_DIR}' '${BACKUP_DIR}' '${ARCHIVE_DIR}'"
|
||
scp -q "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}" "${HOST}:${INCOMING_DIR}/"
|
||
|
||
say "在 103 停旧服务并切换到无源码 runtime"
|
||
ssh -o BatchMode=yes "${HOST}" \
|
||
"RELEASE_ID='${RELEASE_ID}' APP_DIR='${APP_DIR}' INCOMING_DIR='${INCOMING_DIR}' BACKUP_DIR='${BACKUP_DIR}' ARCHIVE_DIR='${ARCHIVE_DIR}' HEALTH_URL='${HEALTH_URL}' PORTAL_LABEL='${PORTAL_LABEL}' PORTAL_TUNNEL_LABEL='${PORTAL_TUNNEL_LABEL}' MEMIND_PORTAL_TUNNEL_HOST='${MEMIND_PORTAL_TUNNEL_HOST}' MEMIND_PORTAL_TUNNEL_REMOTE_PORT='${MEMIND_PORTAL_TUNNEL_REMOTE_PORT}' LAUNCHD_GUI='${LAUNCHD_GUI}' ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES='${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}' /bin/bash" <<'REMOTE_SCRIPT'
|
||
set -euo pipefail
|
||
|
||
RUNTIME_DIR="${APP_DIR}.runtime-${RELEASE_ID}"
|
||
OLD_LIVE_DIR="${ARCHIVE_DIR}/Memind-source-before-${RELEASE_ID}"
|
||
BUNDLE="${INCOMING_DIR}/memind-portal-runtime-${RELEASE_ID}.tar.gz"
|
||
MANIFEST="${INCOMING_DIR}/memind-portal-runtime-${RELEASE_ID}.manifest.txt"
|
||
SHA_FILE="${INCOMING_DIR}/memind-portal-runtime-${RELEASE_ID}.sha256"
|
||
FULL_BACKUP_TAR="${BACKUP_DIR}/memind-full-${RELEASE_ID}-before.tar.gz"
|
||
PERSIST_BACKUP_TAR="${BACKUP_DIR}/memind-persisted-${RELEASE_ID}-before.tar.gz"
|
||
PERSISTED_ITEMS=(
|
||
".env"
|
||
"MindSpace"
|
||
"data"
|
||
"users"
|
||
".tailscale"
|
||
"public/plaza-covers"
|
||
"logs"
|
||
)
|
||
|
||
say() {
|
||
printf '\n[remote %s] %s\n' "$(date +%H:%M:%S)" "$*"
|
||
}
|
||
|
||
ensure_portal_tunnel() {
|
||
local tunnel_script="${APP_DIR}/scripts/memind-portal-tunnel.sh"
|
||
local tunnel_label="${PORTAL_TUNNEL_LABEL:-cn.tkmind.memind-portal-tunnel}"
|
||
local tunnel_host="${MEMIND_PORTAL_TUNNEL_HOST:-ssh105-public}"
|
||
local remote_port="${MEMIND_PORTAL_TUNNEL_REMOTE_PORT:-19081}"
|
||
|
||
[[ -x "${tunnel_script}" ]] || {
|
||
echo "missing tunnel script: ${tunnel_script}" >&2
|
||
return 1
|
||
}
|
||
|
||
cat > "${HOME}/Library/LaunchAgents/${tunnel_label}.plist" <<EOF
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||
<plist version="1.0">
|
||
<dict>
|
||
<key>Label</key>
|
||
<string>${tunnel_label}</string>
|
||
<key>ProgramArguments</key>
|
||
<array>
|
||
<string>${tunnel_script}</string>
|
||
</array>
|
||
<key>EnvironmentVariables</key>
|
||
<dict>
|
||
<key>MEMIND_PORTAL_TUNNEL_HOST</key>
|
||
<string>${tunnel_host}</string>
|
||
</dict>
|
||
<key>RunAtLoad</key>
|
||
<true/>
|
||
<key>KeepAlive</key>
|
||
<true/>
|
||
<key>StandardOutPath</key>
|
||
<string>${HOME}/Library/Logs/memind-portal-tunnel.log</string>
|
||
<key>StandardErrorPath</key>
|
||
<string>${HOME}/Library/Logs/memind-portal-tunnel.log</string>
|
||
</dict>
|
||
</plist>
|
||
EOF
|
||
|
||
launchctl bootout "${LAUNCHD_GUI}/${tunnel_label}" >/dev/null 2>&1 || true
|
||
launchctl bootstrap "${LAUNCHD_GUI}" "${HOME}/Library/LaunchAgents/${tunnel_label}.plist" >/dev/null 2>&1 || true
|
||
launchctl enable "${LAUNCHD_GUI}/${tunnel_label}" >/dev/null 2>&1 || true
|
||
launchctl kickstart -k "${LAUNCHD_GUI}/${tunnel_label}" >/dev/null 2>&1 || true
|
||
sleep 2
|
||
|
||
if ! launchctl print "${LAUNCHD_GUI}/${tunnel_label}" 2>/dev/null | grep -q 'state = running'; then
|
||
echo "portal tunnel failed to start (${tunnel_label})" >&2
|
||
return 1
|
||
fi
|
||
|
||
if command -v ssh >/dev/null 2>&1; then
|
||
tunnel_code="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "${tunnel_host}" \
|
||
"curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${remote_port}/api/status" 2>/dev/null || true)"
|
||
if [[ "${tunnel_code}" == "200" ]]; then
|
||
say "105 隧道端口 ${remote_port} 健康"
|
||
else
|
||
echo "warn: 105:${remote_port} 健康检查未通过 (code=${tunnel_code:-000});请确认 105 nginx upstream 指向 127.0.0.1:${remote_port}" >&2
|
||
fi
|
||
fi
|
||
}
|
||
|
||
rollback() {
|
||
if [[ -d "${OLD_LIVE_DIR}" && ! -d "${APP_DIR}" ]]; then
|
||
mv "${OLD_LIVE_DIR}" "${APP_DIR}"
|
||
fi
|
||
launchctl bootstrap "${LAUNCHD_GUI}" "${HOME}/Library/LaunchAgents/${PORTAL_LABEL}.plist" >/dev/null 2>&1 || true
|
||
launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true
|
||
if ! curl -sf "${HEALTH_URL}" >/dev/null 2>&1; then
|
||
nohup "${APP_DIR}/scripts/run-memind-portal-prod.sh" >> "${HOME}/Library/Logs/memind-portal.log" 2>&1 &
|
||
fi
|
||
ensure_portal_tunnel >/dev/null 2>&1 || true
|
||
}
|
||
|
||
trap 'rollback' ERR
|
||
|
||
[[ -f "${BUNDLE}" ]] || { echo "missing bundle: ${BUNDLE}" >&2; exit 1; }
|
||
[[ -f "${MANIFEST}" ]] || { echo "missing manifest: ${MANIFEST}" >&2; exit 1; }
|
||
[[ -f "${SHA_FILE}" ]] || { echo "missing sha file: ${SHA_FILE}" >&2; exit 1; }
|
||
|
||
say "校验产物完整性"
|
||
cd "${INCOMING_DIR}"
|
||
shasum -a 256 -c "$(basename "${SHA_FILE}")"
|
||
|
||
copy_persisted_item() {
|
||
local src="$1"
|
||
local dest_root="$2"
|
||
local item="$3"
|
||
mkdir -p "${dest_root}"
|
||
if [[ -d "${src}" ]]; then
|
||
local pipe_status tar_status extract_status
|
||
set +e
|
||
(
|
||
cd "${APP_DIR}"
|
||
COPYFILE_DISABLE=1 tar --exclude='*.sock' -cf - "${item}"
|
||
) | (
|
||
cd "${dest_root}"
|
||
COPYFILE_DISABLE=1 tar -xf -
|
||
)
|
||
pipe_status=("${PIPESTATUS[@]}")
|
||
tar_status=${pipe_status[0]:-0}
|
||
extract_status=${pipe_status[1]:-0}
|
||
set -e
|
||
if [[ "${extract_status}" -ne 0 ]]; then
|
||
echo "failed to extract persisted item ${item}" >&2
|
||
exit "${extract_status}"
|
||
fi
|
||
if [[ "${tar_status}" -ne 0 ]]; then
|
||
echo "warning: persisted item ${item} changed during copy; continuing with partial snapshot" >&2
|
||
fi
|
||
else
|
||
cp -a "${src}" "${dest_root}/"
|
||
fi
|
||
}
|
||
|
||
say "备份当前 live 全目录"
|
||
COPYFILE_DISABLE=1 tar --exclude='Memind/.tailscale/*.sock' \
|
||
-czf "${FULL_BACKUP_TAR}" -C "$(dirname "${APP_DIR}")" "$(basename "${APP_DIR}")"
|
||
|
||
say "单独备份持久目录"
|
||
tmp_persist_dir="$(mktemp -d "${TMPDIR:-/tmp}/memind-persisted.XXXXXX")"
|
||
for item in "${PERSISTED_ITEMS[@]}"; do
|
||
if [[ -e "${APP_DIR}/${item}" ]]; then
|
||
mkdir -p "${tmp_persist_dir}/$(dirname "${item}")"
|
||
if [[ "${item}" == ".tailscale" && -d "${APP_DIR}/${item}" ]]; then
|
||
mkdir -p "${tmp_persist_dir}/${item}"
|
||
(
|
||
cd "${APP_DIR}/${item}"
|
||
COPYFILE_DISABLE=1 tar --exclude='*.sock' -cf - .
|
||
) | (
|
||
cd "${tmp_persist_dir}/${item}"
|
||
tar -xf -
|
||
)
|
||
else
|
||
copy_persisted_item "${APP_DIR}/${item}" "${tmp_persist_dir}" "${item}"
|
||
fi
|
||
fi
|
||
done
|
||
COPYFILE_DISABLE=1 tar -czf "${PERSIST_BACKUP_TAR}" -C "${tmp_persist_dir}" .
|
||
rm -rf "${tmp_persist_dir}"
|
||
|
||
say "停止旧 Portal 服务"
|
||
launchctl bootout "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true
|
||
lsof -tiTCP:8081 -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true
|
||
sleep 2
|
||
lsof -tiTCP:8081 -sTCP:LISTEN 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||
|
||
say "准备新的 runtime live 目录"
|
||
rm -rf "${RUNTIME_DIR}"
|
||
mkdir -p "${RUNTIME_DIR}"
|
||
tar -xzf "${BUNDLE}" -C "${RUNTIME_DIR}"
|
||
cp "${MANIFEST}" "${RUNTIME_DIR}/.release-manifest.txt"
|
||
|
||
for item in "${PERSISTED_ITEMS[@]}"; do
|
||
if [[ -e "${APP_DIR}/${item}" ]]; then
|
||
rm -rf "${RUNTIME_DIR:?}/${item}"
|
||
mkdir -p "$(dirname "${RUNTIME_DIR}/${item}")"
|
||
if [[ "${item}" == ".tailscale" && -d "${APP_DIR}/${item}" ]]; then
|
||
mkdir -p "${RUNTIME_DIR}/${item}"
|
||
(
|
||
cd "${APP_DIR}/${item}"
|
||
COPYFILE_DISABLE=1 tar --exclude='*.sock' -cf - .
|
||
) | (
|
||
cd "${RUNTIME_DIR}/${item}"
|
||
tar -xf -
|
||
)
|
||
else
|
||
copy_persisted_item "${APP_DIR}/${item}" "${RUNTIME_DIR}" "${item}"
|
||
fi
|
||
fi
|
||
done
|
||
|
||
say "将旧源码 live 目录移入 archive"
|
||
rm -rf "${OLD_LIVE_DIR}"
|
||
mv "${APP_DIR}" "${OLD_LIVE_DIR}"
|
||
mv "${RUNTIME_DIR}" "${APP_DIR}"
|
||
|
||
say "更新 LaunchAgent 指向 runtime 启动脚本"
|
||
cat > "${HOME}/Library/LaunchAgents/${PORTAL_LABEL}.plist" <<EOF
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||
<plist version="1.0">
|
||
<dict>
|
||
<key>Label</key>
|
||
<string>${PORTAL_LABEL}</string>
|
||
<key>ProgramArguments</key>
|
||
<array>
|
||
<string>${APP_DIR}/scripts/run-memind-portal-prod.sh</string>
|
||
</array>
|
||
<key>WorkingDirectory</key>
|
||
<string>${APP_DIR}</string>
|
||
<key>EnvironmentVariables</key>
|
||
<dict>
|
||
<key>PATH</key>
|
||
<string>/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||
</dict>
|
||
<key>RunAtLoad</key>
|
||
<true/>
|
||
<key>KeepAlive</key>
|
||
<true/>
|
||
<key>ThrottleInterval</key>
|
||
<integer>10</integer>
|
||
<key>StandardOutPath</key>
|
||
<string>${HOME}/Library/Logs/memind-portal.log</string>
|
||
<key>StandardErrorPath</key>
|
||
<string>${HOME}/Library/Logs/memind-portal.log</string>
|
||
</dict>
|
||
</plist>
|
||
EOF
|
||
|
||
say "启动新的 Portal runtime"
|
||
launchctl bootstrap "${LAUNCHD_GUI}" "${HOME}/Library/LaunchAgents/${PORTAL_LABEL}.plist" >/dev/null 2>&1 || true
|
||
launchctl enable "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true
|
||
if ! launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1; then
|
||
nohup "${APP_DIR}/scripts/run-memind-portal-prod.sh" >> "${HOME}/Library/Logs/memind-portal.log" 2>&1 &
|
||
fi
|
||
|
||
AGENT_RUN_WORKER_LABEL="${MEMIND_AGENT_RUN_WORKER_LABEL:-cn.tkmind.memind-agent-run-worker}"
|
||
if launchctl print "${LAUNCHD_GUI}/${AGENT_RUN_WORKER_LABEL}" >/dev/null 2>&1; then
|
||
say "重启 agent run worker"
|
||
launchctl kickstart -k "${LAUNCHD_GUI}/${AGENT_RUN_WORKER_LABEL}" >/dev/null 2>&1 || true
|
||
fi
|
||
|
||
say "健康检查"
|
||
for _ in $(seq 1 60); do
|
||
portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)"
|
||
if [[ "${portal_code}" == "200" ]]; then
|
||
break
|
||
fi
|
||
sleep 2
|
||
done
|
||
|
||
portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)"
|
||
if [[ "${portal_code}" != "200" ]]; then
|
||
echo "health check failed: portal=${portal_code}" >&2
|
||
exit 1
|
||
fi
|
||
|
||
if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" != "1" && -d "${APP_DIR}/MindSpace" && -f "${APP_DIR}/scripts/check-mindspace-public-links.mjs" ]]; then
|
||
say "修复 MindSpace 公开页缺失 docx 下载"
|
||
node_bin="/opt/homebrew/opt/node@24/bin/node"
|
||
if [[ ! -x "${node_bin}" ]]; then
|
||
node_bin="$(command -v node)"
|
||
fi
|
||
if [[ -f "${APP_DIR}/scripts/repair-mindspace-public-downloads.mjs" ]]; then
|
||
"${node_bin}" "${APP_DIR}/scripts/repair-mindspace-public-downloads.mjs" --root "${APP_DIR}/MindSpace" || true
|
||
fi
|
||
say "检查 MindSpace 公开页相对链接"
|
||
if ! "${node_bin}" "${APP_DIR}/scripts/check-mindspace-public-links.mjs" --root "${APP_DIR}/MindSpace" --downloads-only; then
|
||
echo "MindSpace public link check failed: broken relative download/asset links under public/*.html" >&2
|
||
echo "Set ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES=1 only if you accept shipping with known broken links." >&2
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
say "重启 m.tkmind.cn 反向隧道"
|
||
ensure_portal_tunnel
|
||
|
||
say "检查 live 目录中不再保留源码树"
|
||
allowed_live_mjs=(
|
||
"${APP_DIR}/server.mjs"
|
||
"${APP_DIR}/mindspace-sandbox-mcp.mjs"
|
||
"${APP_DIR}/mindspace-public-links.mjs"
|
||
)
|
||
extra_files=""
|
||
while IFS= read -r file; do
|
||
[[ -z "${file}" ]] && continue
|
||
allowed=0
|
||
for allowed_file in "${allowed_live_mjs[@]}"; do
|
||
if [[ "${file}" == "${allowed_file}" ]]; then
|
||
allowed=1
|
||
break
|
||
fi
|
||
done
|
||
if [[ "${allowed}" -eq 0 ]]; then
|
||
extra_files+="${file} "
|
||
fi
|
||
done < <(find "${APP_DIR}" -maxdepth 1 \( -name '*.mjs' -o -name 'docs' -o -name 'src' -o -name 'ops' \))
|
||
if [[ -n "${extra_files// /}" ]]; then
|
||
echo "unexpected source-like files remain in live dir: ${extra_files}" >&2
|
||
exit 1
|
||
fi
|
||
|
||
say "Portal runtime 发布完成"
|
||
printf 'live_dir=%s\n' "${APP_DIR}"
|
||
printf 'archived_source=%s\n' "${OLD_LIVE_DIR}"
|
||
printf 'full_backup=%s\n' "${FULL_BACKUP_TAR}"
|
||
printf 'persist_backup=%s\n' "${PERSIST_BACKUP_TAR}"
|
||
REMOTE_SCRIPT
|
||
|
||
say "读取 103 最终状态"
|
||
ssh -o BatchMode=yes "${HOST}" "curl -s '${HEALTH_URL}' && echo && ls -la '${APP_DIR}' | sed -n '1,80p' && echo '---' && ls -la '${APP_DIR}/scripts' | sed -n '1,40p'"
|