#!/bin/sh set -eu umask 077 REPO_URL=${MULTI_SIMADMIN_REPO_URL:-https://gitea.chickliu.fun/Hermes/multi-simadmin.git} PNPM_VERSION="11.13.0" APP_ROOT=${MULTI_SIMADMIN_HOME:-"$HOME/Library/Application Support/multi-simadmin"} SOURCE_DIR="$APP_ROOT/source" DATA_ROOT="$APP_ROOT/data" RUNTIME_DIR="$APP_ROOT/run" LOG_DIR="$APP_ROOT/logs" TOKEN_FILE="$APP_ROOT/gateway-token" API_PID_FILE="$RUNTIME_DIR/api.pid" GATEWAY_PID_FILE="$RUNTIME_DIR/gateway.pid" API_LOG="$LOG_DIR/api.log" GATEWAY_LOG="$LOG_DIR/gateway.log" say() { printf '%s\n' "$*"; } die() { printf '错误:%s\n' "$*" >&2; exit 1; } usage() { cat <<'EOF' Multi SimAdmin 一键安装与服务管理 用法:install.sh [install|start|stop|restart|status|uninstall|help] install 下载源码、安装依赖、构建并启动(默认) start 启动 API(127.0.0.1:8790) 与 LAN Gateway(*:8788) stop 安全停止服务 restart 停止后重新启动 status 显示进程、端口与健康状态 uninstall 卸载程序,默认保留数据、令牌和日志 环境变量: MULTI_SIMADMIN_HOME 安装根目录 MULTI_SIMADMIN_ALLOWED_HOSTS 额外允许的 LAN 主机名/IP,逗号分隔 MULTI_SIMADMIN_REPO_URL 源码仓库地址 EOF } require_macos() { [ "$(uname -s)" = Darwin ] || die "当前生产版仅支持 macOS(秘密存储依赖 macOS Keychain)。" } require_commands() { for command in git node corepack curl lsof install; do command -v "$command" >/dev/null 2>&1 || die "缺少命令:$command" done } pid_alive() { [ -f "$1" ] && pid=$(cat "$1" 2>/dev/null) && [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; } pid_owned() { file=$1 kind=$2 pid_alive "$file" || return 1 pid=$(cat "$file") command_line=$(ps -p "$pid" -o command= 2>/dev/null || true) case "$kind:$command_line" in api:*"$SOURCE_DIR/apps/api/src/production-cli.ts"*) return 0 ;; gateway:*"$SOURCE_DIR/apps/api/src/production-gateway-cli.ts"*) return 0 ;; *) return 1 ;; esac } port_free() { ! lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1 } lan_ip() { for interface in en0 en1; do value=$(ipconfig getifaddr "$interface" 2>/dev/null || true) if [ -n "$value" ]; then printf '%s' "$value"; return; fi done printf '127.0.0.1' } allowed_hosts() { host=$(hostname 2>/dev/null || true) ip=$(lan_ip) extra=${MULTI_SIMADMIN_ALLOWED_HOSTS:-} value="127.0.0.1,localhost,$ip" [ -n "$host" ] && value="$value,$host" [ -n "$extra" ] && value="$value,$extra" printf '%s' "$value" } create_token() { if [ -e "$TOKEN_FILE" ] || [ -L "$TOKEN_FILE" ]; then [ -f "$TOKEN_FILE" ] && [ ! -L "$TOKEN_FILE" ] || die "Gateway token 必须是普通文件且不能是符号链接" chmod 600 "$TOKEN_FILE" return fi tmp="$TOKEN_FILE.tmp.$$" trap 'rm -f "$tmp"' EXIT HUP INT TERM node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('hex'))" >"$tmp" install -m 600 "$tmp" "$TOKEN_FILE" rm -f "$tmp" trap - EXIT HUP INT TERM } build_source() { if [ -e "$SOURCE_DIR" ]; then [ -d "$SOURCE_DIR/.git" ] || die "安装目录已存在但不是有效源码仓库:$SOURCE_DIR" say "检测到已有源码;为避免覆盖本地修改,不自动 pull。" else mkdir -p "$APP_ROOT" git clone --depth 1 "$REPO_URL" "$SOURCE_DIR" fi cd "$SOURCE_DIR" corepack pnpm --version | grep -qx "$PNPM_VERSION" || corepack prepare "pnpm@$PNPM_VERSION" --activate corepack pnpm install --frozen-lockfile corepack pnpm --filter @multi-simadmin/web build [ -f "$SOURCE_DIR/apps/web/dist/index.html" ] || die "Web 构建产物缺失" node -e "require('better-sqlite3');" || die "better-sqlite3 无法加载" } api_probe() { TOKEN_FILE="$TOKEN_FILE" node -e ' const fs=require("node:fs"); const token=fs.readFileSync(process.env.TOKEN_FILE,"utf8").trim(); fetch("http://127.0.0.1:8790/healthz",{headers:{"x-multi-simadmin-gateway-token":token},signal:AbortSignal.timeout(2000)}) .then(r=>{if(!r.ok)process.exit(1);return r.json()}).then(v=>process.exit(v.status==="ok"?0:1)).catch(()=>process.exit(1)); ' >/dev/null 2>&1 } gateway_probe() { curl -fsS --max-time 2 http://127.0.0.1:8788/healthz >/dev/null 2>&1; } wait_for() { label=$1 shift count=0 until "$@"; do count=$((count + 1)) [ "$count" -lt 40 ] || die "$label 启动后健康检查失败,请查看 $LOG_DIR" sleep 1 done } start_service() { require_macos require_commands [ -f "$SOURCE_DIR/apps/web/dist/index.html" ] || die "尚未安装,请先运行 install" [ -f "$TOKEN_FILE" ] && [ ! -L "$TOKEN_FILE" ] || die "Gateway token 缺失或不是安全的普通文件,请重新运行 install" chmod 600 "$TOKEN_FILE" mkdir -p "$DATA_ROOT" "$RUNTIME_DIR" "$LOG_DIR" chmod 700 "$APP_ROOT" "$DATA_ROOT" "$RUNTIME_DIR" "$LOG_DIR" api_running=false gateway_running=false pid_owned "$API_PID_FILE" api && api_running=true pid_owned "$GATEWAY_PID_FILE" gateway && gateway_running=true if [ "$api_running" = true ] && [ "$gateway_running" = true ]; then say "Multi SimAdmin 已在运行。" return fi if [ "$api_running" = true ] || [ "$gateway_running" = true ]; then die "检测到部分服务仍在运行;为避免丢失进程所有权,请先运行 stop 后再 start。" fi rm -f "$API_PID_FILE" "$GATEWAY_PID_FILE" port_free 8790 || die "端口 8790 已被其他进程占用,不会覆盖。" port_free 8788 || die "端口 8788 已被其他进程占用,不会覆盖。" token=$(cat "$TOKEN_FILE") [ "${#token}" -ge 32 ] || die "Gateway token 无效" cd "$SOURCE_DIR" started_api=false started_gateway=false rollback_failed=false rollback_process() { file=$1 kind=$2 started=$3 [ "$started" = true ] || return pid_owned "$file" "$kind" || return pid=$(cat "$file") kill -TERM "$pid" 2>/dev/null || true count=0 while kill -0 "$pid" 2>/dev/null; do count=$((count + 1)) if [ "$count" -ge 15 ]; then rollback_failed=true say "警告:$kind 启动回滚未完成,保留 PID 文件 $file 以便人工停止。" return fi sleep 1 done rm -f "$file" } rollback_start() { rollback_process "$GATEWAY_PID_FILE" gateway "$started_gateway" rollback_process "$API_PID_FILE" api "$started_api" [ "$rollback_failed" = false ] || say "启动失败后仍有进程可能存活;请运行 status 并检查日志。" } trap 'rollback_start' EXIT HUP INT TERM MULTI_SIMADMIN_DATA_ROOT="$DATA_ROOT" \ MULTI_SIMADMIN_DATABASE_PATH="$DATA_ROOT/control-plane.sqlite3" \ MULTI_SIMADMIN_GATEWAY_TOKEN="$token" \ MULTI_SIMADMIN_WEB_DIST="$SOURCE_DIR/apps/web/dist" \ API_HOST=127.0.0.1 API_PORT=8790 \ nohup node --import tsx "$SOURCE_DIR/apps/api/src/production-cli.ts" >>"$API_LOG" 2>&1 & printf '%s\n' "$!" >"$API_PID_FILE" started_api=true wait_for API api_probe MULTI_SIMADMIN_GATEWAY_TOKEN="$token" \ MULTI_SIMADMIN_CUTOVER_ACK='I ACKNOWLEDGE MULTI-SIMADMIN OWNS PORT 8788' \ MULTI_SIMADMIN_GATEWAY_HOST=0.0.0.0 \ MULTI_SIMADMIN_GATEWAY_ALLOWED_HOSTS="$(allowed_hosts)" \ CANARY_DIST_DIR="$SOURCE_DIR/apps/web/dist" CANARY_UPSTREAM_PORT=8790 \ nohup node --import tsx "$SOURCE_DIR/apps/api/src/production-gateway-cli.ts" >>"$GATEWAY_LOG" 2>&1 & printf '%s\n' "$!" >"$GATEWAY_PID_FILE" started_gateway=true wait_for Gateway gateway_probe trap - EXIT HUP INT TERM say "已启动:http://$(lan_ip):8788/fleet" } stop_one() { file=$1 kind=$2 if [ ! -f "$file" ]; then return; fi if ! pid_owned "$file" "$kind"; then rm -f "$file" die "$kind PID 记录与进程身份不符,已拒绝发送信号。" fi pid=$(cat "$file") kill -TERM "$pid" count=0 while kill -0 "$pid" 2>/dev/null; do count=$((count + 1)) [ "$count" -lt 15 ] || die "$kind 未在 15 秒内停止,请人工检查 PID $pid。" sleep 1 done rm -f "$file" } stop_service() { stop_one "$GATEWAY_PID_FILE" gateway stop_one "$API_PID_FILE" api say "服务已停止。" } status_service() { if pid_owned "$API_PID_FILE" api; then say "API: running"; else say "API: stopped"; fi if pid_owned "$GATEWAY_PID_FILE" gateway; then say "Gateway: running" if gateway_probe; then say "Health: ok"; else say "Health: unavailable"; fi else say "Gateway: stopped" say "Health: unavailable" fi if command -v lsof >/dev/null 2>&1; then if port_free 8789; then say "Canary 8789: closed"; else say "Canary 8789: occupied (非本安装器启动)"; fi else say "Canary 8789: unknown (缺少 lsof)" fi [ -f "$DATA_ROOT/control-plane.sqlite3" ] && say "Database: preserved at $DATA_ROOT/control-plane.sqlite3" || say "Database: not created" } install_app() { require_macos require_commands mkdir -p "$APP_ROOT" "$DATA_ROOT" "$RUNTIME_DIR" "$LOG_DIR" chmod 700 "$APP_ROOT" "$DATA_ROOT" "$RUNTIME_DIR" "$LOG_DIR" build_source create_token start_service } uninstall_app() { stop_service if [ -d "$SOURCE_DIR" ]; then rm -rf "$SOURCE_DIR"; fi rm -f "$API_PID_FILE" "$GATEWAY_PID_FILE" say "程序已卸载;默认保留数据、Gateway token 和日志:$APP_ROOT" } command=${1:-install} case "$command" in install) install_app ;; start) start_service ;; stop) stop_service ;; restart) stop_service; start_service ;; status) status_service ;; uninstall) uninstall_app ;; help|-h|--help) usage ;; *) usage >&2; exit 2 ;; esac