#!/bin/sh
# ============================================================
# ZQAgent 一键安装（CLI + 本地节点服务）
#
#   curl -fsSL https://agent.cd-zq.com/install.sh | sh
#
# Linux 直接下载单文件可执行程序，零依赖、无需 Node。
# glibc < 2.28 的系统（CentOS 7 / 麒麟 V10 等）自动改用 glibc 2.17 兼容构建。
# 其它平台（或加 --npm）走 npm 安装，需要 Node.js >= 18.17。
# 安装完成后默认注册为开机自启服务（Linux systemd / macOS launchd），
# 并立即启动本地控制台。安装完成时可选择用 Hub 账号（邮箱/手机号 + 密码）
# 直接接入，或安装后执行 `zqagent link` 随时接入。
#
# 用法：
#   curl -fsSL https://agent.cd-zq.com/install.sh | sh                 # 安装/升级，并报告状态
#   curl -fsSL https://agent.cd-zq.com/install.sh | sudo sh            # 同上，装成开机自启服务
#   curl -fsSL https://agent.cd-zq.com/install.sh | sudo sh -s -- uninstall   # 卸载服务
#
# 子命令：
#   install（默认） 安装或升级到清单中的版本，并（重）装服务
#   status          查看版本、服务、本地地址、远程接入与自动更新状态
#   logs            跟踪节点日志
#   restart         重启服务
#   rollback        回滚到上一次自动更新前的版本并重启
#   uninstall       停止并移除开机自启服务（数据目录与二进制保留）
#   print-config    只打印将使用的配置，不执行任何动作
#
# 参数：
#   --version X      安装指定版本（默认取清单里的 stable）
#   --channel NAME   清单位置，默认 stable（beta 用于预发布）
#   --bin-dir DIR    安装目录（默认：可写则 /usr/local/bin，否则 ~/.local/bin）
#   --host ADDR      监听地址，默认 127.0.0.1（局域网访问用 0.0.0.0）
#   --port N         本地控制台端口，默认 8787
#   --data-dir DIR   数据目录（默认 ~/.zqagent_data；macOS ~/Library/Application Support/zqagent）
#   --hub URL        Hub 地址，默认 https://hub.cd-zq.com（也可用 HUB_URL）
#   --username USER  用 Hub 已注册的邮箱或手机号接入（安装完成后自动执行）
#   --password PASS  Hub 账号密码（建议改用 ZQAGENT_HUB_PASSWORD，避免留在命令历史）
#   --code CODE      一次性接入码（与 --username 二选一）
#   --node-label N   节点显示名称（默认取主机名 / Hub 现有名称）
#   --no-enroll      安装后不提示接入 Hub（之后仍可手动 zqagent link）
#   --pubkey KEY     自动更新的验签公钥（也可用 ZQAGENT_UPDATE_PUBKEY）；
#                    不给时自动从 $BASE_URL/update-public.b64 获取
#   --service-name N Linux systemd unit 名，默认 zqagent
#   --label NAME     macOS launchd label，默认 com.zqagent.node
#   --no-service     只装 CLI，不装开机自启服务
#   --force          已安装时也重新下载并覆盖（默认：已是最新则只报告状态）
#   --npm            强制使用 npm 安装
#   --base-url URL   清单与产物根地址（内网/自建镜像用）
#   --no-telemetry   不上报匿名安装统计
#   -h, --help       显示帮助
#
# 示例：
#   curl -fsSL https://agent.cd-zq.com/install.sh | sh
#   curl -fsSL https://agent.cd-zq.com/install.sh | sudo sh
#   curl -fsSL https://agent.cd-zq.com/install.sh | sudo sh -s -- \
#     --username you@example.com --password '你的密码'
#   curl -fsSL https://agent.cd-zq.com/install.sh | sh -s -- --bin-dir ~/bin --port 9000
#   curl -fsSL https://agent.cd-zq.com/install.sh | sudo sh -s -- status
#   curl -fsSL https://agent.cd-zq.com/install.sh | sudo sh -s -- rollback
#   curl -fsSL https://agent.cd-zq.com/install.sh | sudo sh -s -- uninstall
#
# 安装与卸载都可以重复执行：已装则只报告状态，未装时卸载也不会报错。
#
# 隐私：安装成功后会上报一条匿名统计（版本/平台/架构/安装方式），
#       不含用户名、路径、IP 或任何可识别身份的信息，用于判断各平台的实际使用情况。
#       不需要的话加 --no-telemetry 或设置 ZQAGENT_NO_TELEMETRY=1。
#
# 卸载（uninstall）只移除服务，数据目录与二进制保留（会打印实际路径）；
# 彻底清理请按卸载输出里的「彻底清理」一行执行。
# ============================================================
set -eu

BASE_URL="${BASE_URL:-https://agent.cd-zq.com/downloads/zqagent}"
CHANNEL="${CHANNEL:-stable}"
VERSION="${VERSION:-}"
BIN_DIR="${BIN_DIR:-}"
HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-8787}"
DATA_DIR="${DATA_DIR:-}"
LOG="${LOG:-}"
SERVICE_NAME="${SERVICE_NAME:-zqagent}"
LABEL="${LABEL:-com.zqagent.node}"
HUB_URL="${HUB_URL:-https://hub.cd-zq.com}"
HUB_USER="${ZQAGENT_HUB_USERNAME:-}"
HUB_PASSWORD="${ZQAGENT_HUB_PASSWORD:-}"
HUB_CODE="${ZQAGENT_HUB_CODE:-}"
NODE_LABEL="${ZQAGENT_NODE_LABEL:-}"
NO_SERVICE="${ZQAGENT_NO_SERVICE:-0}"
NO_ENROLL="${ZQAGENT_NO_ENROLL:-0}"
PUBKEY_ARG="${ZQAGENT_UPDATE_PUBKEY:-}"
ACTION="install"
FORCE_NPM=0
FORCE=0
NO_TELEMETRY="${ZQAGENT_NO_TELEMETRY:-0}"
PORT_SET=0
DATA_DIR_SET=0
DATA_DIR_CONFIRMED=""
BIN_DIR_SET=0
LOG_EXPLICIT=0
if [ -n "$LOG" ]; then LOG_EXPLICIT=1; fi

# Umami 站点标识：上报到自建实例，不涉及第三方
TELEMETRY_URL="https://agent.cd-zq.com/stats/api/send"
TELEMETRY_SITE="0edd5794-ff2e-40cf-b39c-d4f2e4c08fd7"

# 匿名上报：仅版本/平台/架构/安装方式。失败静默，绝不阻断安装。
report_install() {
  [ "$NO_TELEMETRY" = "1" ] && return 0
  command -v curl >/dev/null 2>&1 || return 0
  payload="{\"type\":\"event\",\"payload\":{\"website\":\"$TELEMETRY_SITE\",\"hostname\":\"install.sh\",\"url\":\"/install-ok/$1\",\"title\":\"install-ok\",\"language\":\"\",\"screen\":\"\"}}"
  # Umami 会静默丢弃非浏览器 UA（仍返回 200），因此必须带上浏览器 UA，
  # 否则统计永远为空且无法从返回值发现。
  curl -s -m 3 -o /dev/null -X POST "$TELEMETRY_URL" \
    -H "Content-Type: application/json" \
    -H "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36" \
    -d "$payload" 2>/dev/null || true
}

usage() {
  # 从脚本自身的注释里抽用法段（两条 ===== 分隔线之间）；
  # `curl | sh` 时 $0 是解释器本身，读不到源码，退回在线文档地址。
  if [ -r "$0" ] && [ "$(head -c 2 "$0" 2>/dev/null || true)" = "#!" ]; then
    awk '/^# =====/{if (++n == 2) exit; next} n == 1 {sub(/^# ?/, ""); print}' "$0"
  else
    printf '%s\n' "用法：curl -fsSL https://agent.cd-zq.com/install.sh | sh"
    printf '%s\n' "参数详见 https://agent.cd-zq.com/guide/install"
  fi
}

while [ $# -gt 0 ]; do
  case "$1" in
    --version) VERSION="${2:-}"; shift 2 ;;
    --channel) CHANNEL="${2:-}"; shift 2 ;;
    --bin-dir) BIN_DIR="${2:-}"; BIN_DIR_SET=1; shift 2 ;;
    --base-url) BASE_URL="${2:-}"; shift 2 ;;
    --host) HOST="${2:-}"; shift 2 ;;
    --port) PORT="${2:-}"; PORT_SET=1; shift 2 ;;
    --data-dir) DATA_DIR="${2:-}"; DATA_DIR_SET=1; shift 2 ;;
    --hub) HUB_URL="${2:-}"; shift 2 ;;
    --username|--user|--email|--phone) HUB_USER="${2:-}"; shift 2 ;;
    --password) HUB_PASSWORD="${2:-}"; shift 2 ;;
    --code) HUB_CODE="${2:-}"; shift 2 ;;
    --node-label|--node-name) NODE_LABEL="${2:-}"; shift 2 ;;
    --no-enroll) NO_ENROLL=1; shift ;;
    --no-service) NO_SERVICE=1; shift ;;
    --pubkey) PUBKEY_ARG="${2:-}"; shift 2 ;;
    --service-name) SERVICE_NAME="${2:-}"; shift 2 ;;
    --label) LABEL="${2:-}"; shift 2 ;;
    install|status|logs|restart|rollback|print-config) ACTION="$1"; shift ;;
    uninstall|--uninstall) ACTION="uninstall"; shift ;;
    --force) FORCE=1; shift ;;
    --npm) FORCE_NPM=1; shift ;;
    --no-telemetry) NO_TELEMETRY=1; shift ;;
    -h|--help) usage; exit 0 ;;
    *) echo "未知参数：$1（用 --help 查看用法）" >&2; exit 2 ;;
  esac
done

say() { printf '%s\n' "$*"; }
die() { printf '错误：%s\n' "$*" >&2; exit 1; }

# 品牌字标：与 CLI 启动横幅（packages/core/src/cli/banner.ts）一致，仅实心块、无阴影
print_logo() {
  cat <<'LOGO'
███████   ██████   ██████   ██████  ███████  ██    ██ ████████
   ███   ██    ██ ██    ██ ██       ██       ███   ██    ██
  ███    ██    ██ ████████ ██   ███ ███████  ████  ██    ██
 ███     ██ ▄▄ ██ ██    ██ ██    ██ ██       ██ ██ ██    ██
███████   ██████  ██    ██  ██████  ███████  ██  ████    ██
              ▀▀
LOGO
  say ""
}

# 从 JSON 文本中提取字段（避免依赖 jq）
json_get() {
  printf '%s' "$1" | tr -d '\n' | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p"
}

case "$PORT" in
  ''|*[!0-9]*) die "--port 需要数字（当前：${PORT}）" ;;
esac

if [ -n "$HUB_CODE" ] && [ -n "$HUB_USER" ]; then
  die "--code 与 --username 只能二选一（接入码方式与账号方式）"
fi

print_logo

# ── 平台识别 ────────────────────────────────────────────────
OS="$(uname -s)"
case "$OS" in
  Linux) OS="linux" ;;
  Darwin) OS="darwin" ;;
  *) OS="unsupported" ;;
esac

ARCH="$(uname -m)"
case "$ARCH" in
  x86_64|amd64) ARCH="x64" ;;
  aarch64|arm64) ARCH="arm64" ;;
  *) ARCH="unsupported" ;;
esac

# 清单里的平台键沿用构建产物命名（macos-*/linux-*），与 uname 的 darwin 不同。
# 节点自动更新器（packages/core/src/update/manifest.ts）也用同一套键，
# 两边必须一致，否则 macOS 明明发了产物也匹配不到。
case "$OS" in
  darwin) MANIFEST_PLATFORM="macos" ;;
  linux) MANIFEST_PLATFORM="linux" ;;
  *) MANIFEST_PLATFORM="$OS" ;;
esac
PLATFORM_KEY="$MANIFEST_PLATFORM-$ARCH"

# ── glibc 基线 ──────────────────────────────────────────────
# 官方 Node 22 二进制要求 glibc >= 2.28；CentOS 7 / 麒麟 V10 等仍是 2.17，
# 直接装官方单文件包会报 GLIBC_2.27/2.28 not found。这类系统改用
# glibc 2.17 兼容构建（清单键带 -glibc217）；清单里暂时还没有该变体时，
# 回退到「自带 glibc-217 Node + npm 安装」，见 install_legacy_runtime。
GLIBC_VERSION=""
LIBC_VARIANT=""
if [ "$OS" = "linux" ] && command -v ldd >/dev/null 2>&1; then
  GLIBC_VERSION="$(ldd --version 2>/dev/null \
    | sed -n '1s/.*[^0-9]\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p')"
fi

# 本机 glibc 低于 2.28 时返回 0（版本号缺失时按「未知」处理，不切换方案）
glibc_older_than_228() {
  [ -n "$GLIBC_VERSION" ] || return 1
  g_major="${GLIBC_VERSION%%.*}"
  g_minor="${GLIBC_VERSION#*.}"
  case "$g_major$g_minor" in *[!0-9]*) return 1 ;; esac
  if [ "$g_major" -lt 2 ]; then return 0; fi
  [ "$g_major" -eq 2 ] && [ "$g_minor" -lt 28 ]
}

if glibc_older_than_228; then
  if [ "$ARCH" = "x64" ]; then
    LIBC_VARIANT="glibc217"
    PLATFORM_KEY="linux-$ARCH-$LIBC_VARIANT"
  fi
fi

# ── 数据目录 / 日志 ─────────────────────────────────────────

# 取用户名对应的家目录（macOS 没有 getent）。用户名先做白名单校验，
# 避免把 SUDO_USER 之类的环境变量直接拼进 eval。
home_of() {
  case "${1:-}" in
    ""|*[!A-Za-z0-9._-]*) return 0 ;;
  esac
  h="$(eval "printf '%s' ~$1" 2>/dev/null || true)"
  # 用户不存在时 shell 会原样返回 "~user"，不能当成路径用
  case "$h" in
    ""|"~"*) return 0 ;;
  esac
  printf '%s' "$h"
}

# 数据统一放在「运行服务的那个用户」的家目录下（~/.zqagent_data）：
# 权限天然归该用户所有，日常读写不必 sudo，也符合「数据在自己家目录」的直觉。
#
# 为什么不用 $HOME 直接拼：sudo 安装时 HOME 是 /root（macOS 是 /var/root），
# 而服务以 SUDO_USER 运行（见 RUN_USER）。两者不一致时，root 建出来的目录
# 服务自己反而进不去。
#
# 历史位置 /var/lib/zqagent 不再作为 Linux 默认值；已安装的实例由
# adopt_existing_config 从 systemd unit / launchd plist 读回原路径，保持不变。
RUN_HOME="$(home_of "${SUDO_USER:-}")"
[ -n "$RUN_HOME" ] || RUN_HOME="$HOME"

case "$OS" in
  darwin)
    # macOS 沿用 Application Support / Logs：系统惯例，备份与迁移工具都认
    DEFAULT_DATA="$RUN_HOME/Library/Application Support/zqagent"
    DEFAULT_LOG="$RUN_HOME/Library/Logs/zqagent/node.log"
    ;;
  *)
    DEFAULT_DATA="$RUN_HOME/.zqagent_data"
    DEFAULT_LOG="$RUN_HOME/.zqagent_data/node.log"
    ;;
esac

DATA_DIR="${DATA_DIR:-$DEFAULT_DATA}"
# systemd 的 WorkingDirectory 需要绝对路径
case "$DATA_DIR" in
  /*) ;;
  *) DATA_DIR="$(pwd)/$DATA_DIR" ;;
esac
# 显式指定了数据目录却没指定日志：让日志跟随数据目录。
# 两者默认都在 ~/.zqagent_data 下，用户换目录时若日志还留在原处会很别扭。
LOG_FOLLOWS_DATA=0
if [ "$DATA_DIR_SET" = "1" ] && [ "$LOG_EXPLICIT" = "0" ]; then
  DEFAULT_LOG="$DATA_DIR/node.log"
  # 也别让 adopt_existing_config 再用旧 unit/plist 里的日志路径覆盖它
  LOG_FOLLOWS_DATA=1
fi
LOG="${LOG:-$DEFAULT_LOG}"

# 更新源为 http（内网自测）时放行节点的下载校验
UPDATE_INSECURE=""
case "$BASE_URL" in
  http://*) UPDATE_INSECURE="1" ;;
esac

# ── 安装目录 ────────────────────────────────────────────────
if [ -z "$BIN_DIR" ]; then
  if [ -w /usr/local/bin ] 2>/dev/null; then
    BIN_DIR="/usr/local/bin"
  elif [ "$(id -u)" -eq 0 ]; then
    BIN_DIR="/usr/local/bin"
  else
    BIN_DIR="$HOME/.local/bin"
  fi
fi
# 只有 install 需要创建安装目录；状态/日志/重启等子命令只读，不应因权限失败
if [ "$ACTION" = "install" ]; then
  mkdir -p "$BIN_DIR" 2>/dev/null || die "无法创建安装目录：${BIN_DIR}（可加 sudo 或指定 --bin-dir）"
fi

# ── 开机自启服务（systemd / launchd） ───────────────────────
SERVICE_KIND="none"   # systemd | launchd | none
SERVICE_NOTE=""       # 未装服务的原因
SERVICE_HINT=""       # 补救命令
SERVICE_RESTART_FAILED=0
RUN_USER="${SUDO_USER:-root}"
ZQAGENT_BIN="$BIN_DIR/zqagent"
AUTO_UPDATE=0
# 显式 --pubkey / ZQAGENT_UPDATE_PUBKEY 优先；没给时安装阶段自动从清单源获取
UPDATE_PUBKEY=""
if [ -n "$PUBKEY_ARG" ]; then
  case "$PUBKEY_ARG" in
    *[!A-Za-z0-9+/=]*)
      die "--pubkey 不是合法的 base64 公钥（只允许 A-Z a-z 0-9 + / =）" ;;
  esac
  UPDATE_PUBKEY="$PUBKEY_ARG"
  AUTO_UPDATE=1
fi
detect_service() {
  if [ "$NO_SERVICE" = "1" ]; then
    SERVICE_NOTE="按 --no-service 跳过"
    return 0
  fi
  if [ "$OS" = "linux" ]; then
    if [ "$(id -u)" -ne 0 ]; then
      SERVICE_NOTE="当前不是 root，未安装系统服务"
      SERVICE_HINT="以 root 重新执行即可装成开机自启服务：
     curl -fsSL https://agent.cd-zq.com/install.sh | sudo sh"
    elif ! command -v systemctl >/dev/null 2>&1 || [ ! -d /run/systemd/system ]; then
      SERVICE_NOTE="当前环境没有运行中的 systemd（容器内常见）"
      SERVICE_HINT="容器里请把节点作为主进程运行：
     zqagent serve --data-dir \"$DATA_DIR\" --port $PORT"
    else
      SERVICE_KIND="systemd"
    fi
  elif [ "$OS" = "darwin" ]; then
    # root 装 LaunchDaemon（开机即启动）；普通用户装 LaunchAgent（登录后启动）
    SERVICE_KIND="launchd"
  else
    SERVICE_NOTE="当前平台不支持服务化"
  fi
}

# 自动更新需要验签公钥；取不到就关掉，避免服务反复下载失败
fetch_update_pubkey() {
  # 显式提供了公钥（--pubkey / ZQAGENT_UPDATE_PUBKEY）就不再覆盖，也不联网获取
  [ -n "$UPDATE_PUBKEY" ] && return 0
  command -v curl >/dev/null 2>&1 || return 0
  key="$(curl -fsSL --max-time 10 "$BASE_URL/update-public.b64" 2>/dev/null | tr -d ' \t\r\n' || true)"
  case "$key" in
    ""|*[!A-Za-z0-9+/=]*) return 0 ;;
  esac
  UPDATE_PUBKEY="$key"
  AUTO_UPDATE=1
}

launchd_domain() {
  if [ "$(id -u)" -eq 0 ]; then printf 'system'; else printf 'gui/%s' "$(id -u)"; fi
}

launchd_plist_path() {
  if [ "$(id -u)" -eq 0 ]; then
    printf '/Library/LaunchDaemons/%s.plist' "$LABEL"
  else
    printf '%s/Library/LaunchAgents/%s.plist' "$HOME" "$LABEL"
  fi
}

install_service_systemd() {
  unit="/etc/systemd/system/$SERVICE_NAME.service"
  # 已有服务（例如重复执行本脚本、或之前用旧版安装脚本装过）：
  # 没显式改端口/目录时不覆盖配置，只在必要时重启，避免把正在工作的节点改坏。
  if [ -f "$unit" ] && [ "$PORT_SET" = "0" ] && [ "$DATA_DIR_SET" = "0" ]; then
    systemctl daemon-reload || true
    systemctl enable "$SERVICE_NAME" >/dev/null 2>&1 || true
    if [ "$SERVICE_NEEDS_RESTART" = "1" ] || ! systemctl is-active --quiet "$SERVICE_NAME"; then
      systemctl restart "$SERVICE_NAME" || SERVICE_RESTART_FAILED=1
    fi
    return 0
  fi

  mkdir -p "$DATA_DIR" "$(dirname "$LOG")" 2>/dev/null || true
  if [ "$RUN_USER" != "root" ] && id "$RUN_USER" >/dev/null 2>&1; then
    chown -R "$RUN_USER" "$DATA_DIR" "$(dirname "$LOG")" 2>/dev/null || true
  fi

  cat > "$unit" <<EOF
[Unit]
Description=zqagent node (agent server)
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=10

[Service]
Type=simple
User=$RUN_USER
WorkingDirectory=$DATA_DIR
ExecStart=$ZQAGENT_BIN serve --host $HOST --port $PORT --data-dir $DATA_DIR
Restart=always
RestartSec=3
KillSignal=SIGTERM
TimeoutStopSec=15
Environment=NODE_ENV=production
Environment=PATH=$BIN_DIR:/usr/local/bin:/usr/bin:/bin
Environment=ZQAGENT_SERVICE_MANAGER=systemd
Environment=ZQAGENT_SERVICE_NAME=$SERVICE_NAME
Environment=ZQAGENT_AUTO_UPDATE=$AUTO_UPDATE
Environment=ZQAGENT_UPDATE_CHANNEL=$CHANNEL
Environment=ZQAGENT_UPDATE_PUBKEY=$UPDATE_PUBKEY
Environment=ZQAGENT_UPDATE_URL=$BASE_URL/$CHANNEL.json
Environment=ZQAGENT_UPDATE_INSECURE=$UPDATE_INSECURE
# 可选：LLM key、HUB_URL、SECRET_KEY 等放在 /etc/zqagent.env
EnvironmentFile=-/etc/zqagent.env
NoNewPrivileges=true

StandardOutput=append:$LOG
StandardError=append:$LOG

[Install]
WantedBy=multi-user.target
EOF

  systemctl daemon-reload || true
  systemctl enable "$SERVICE_NAME" >/dev/null 2>&1 || true
  systemctl restart "$SERVICE_NAME" || SERVICE_RESTART_FAILED=1
}

install_service_launchd() {
  plist="$(launchd_plist_path)"

  # 同一台机器上 root 装 Daemon、普通用户装 Agent 会各自拉起一个节点，默认端口相同
  # 必然互相抢占。安装是「替换/升级」语义，这里先清掉另一身份留下的服务。
  if [ "$(id -u)" -eq 0 ]; then
    sudo_home="$(home_of "${SUDO_USER:-}")"
    stray=""
    if [ -n "$sudo_home" ]; then stray="$sudo_home/Library/LaunchAgents/$LABEL.plist"; fi
    if [ -n "$stray" ] && [ -f "$stray" ]; then
      stray_uid="$(id -u "${SUDO_USER:-}" 2>/dev/null || id -u)"
      say "→ 发现用户级服务 ${stray}，先移除（否则会与系统级服务抢同一端口）"
      launchctl bootout "gui/$stray_uid/$LABEL" >/dev/null 2>&1 || true
      rm -f "$stray" 2>/dev/null || say "! 无法删除 ${stray}（权限不足）"
    fi
  elif [ -f "/Library/LaunchDaemons/$LABEL.plist" ]; then
    say "! 已存在系统级服务 /Library/LaunchDaemons/$LABEL.plist（由 root 安装）"
    say "  现在装用户级服务会再起一个实例；建议先 sudo 卸载，或直接改用 sudo 安装"
  fi
  # 已装载且无需重启时只补 enable，不动正在跑的服务
  if [ -f "$plist" ] && [ "$PORT_SET" = "0" ] && [ "$DATA_DIR_SET" = "0" ] \
    && [ "$SERVICE_NEEDS_RESTART" = "0" ] \
    && launchctl print "$(launchd_domain)/$LABEL" >/dev/null 2>&1; then
    # 已装载：补一次 enable，避免上次被 disable 后一直起不来
    launchctl enable "$(launchd_domain)/$LABEL" >/dev/null 2>&1 || true
    return 0
  fi
  mkdir -p "$(dirname "$plist")" "$DATA_DIR" "$(dirname "$LOG")" 2>/dev/null || true

  # npm 形态的 zqagent 是 `#!/usr/bin/env node` 脚本：launchd 不继承登录 shell 的
  # PATH，nvm/volta/fnm 装的 node 常在 $HOME 下，不显式加进来服务会起不来。
  SERVICE_PATH="$BIN_DIR:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
  if [ "$(head -c 2 "$ZQAGENT_BIN" 2>/dev/null || true)" = "#!" ]; then
    node_bin="$(command -v node 2>/dev/null || true)"
    if [ -n "$node_bin" ]; then
      node_dir="$(dirname "$node_bin")"
      case ":$SERVICE_PATH:" in
        *":$node_dir:"*) ;;
        *) SERVICE_PATH="$node_dir:$SERVICE_PATH" ;;
      esac
    else
      say "! 未找到 node，launchd 启动 $ZQAGENT_BIN 可能失败；请装 Node >= 18.17 或改用单文件安装包"
    fi
  fi

  cat > "$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>$LABEL</string>
  <key>ProgramArguments</key>
  <array>
    <string>$ZQAGENT_BIN</string>
    <string>serve</string>
    <string>--host</string><string>$HOST</string>
    <string>--port</string><string>$PORT</string>
    <string>--data-dir</string><string>$DATA_DIR</string>
  </array>
  <key>WorkingDirectory</key><string>$DATA_DIR</string>
  <key>RunAtLoad</key><true/>
  <!-- 无条件保活：自动更新替换文件后进程会主动退出，需要 launchd 拉起新版本 -->
  <key>KeepAlive</key><true/>
  <key>ThrottleInterval</key><integer>5</integer>
  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key><string>$SERVICE_PATH</string>
    <key>NODE_ENV</key><string>production</string>
    <key>ZQAGENT_SERVICE_MANAGER</key><string>launchd</string>
    <key>ZQAGENT_SERVICE_LABEL</key><string>$LABEL</string>
    <key>ZQAGENT_AUTO_UPDATE</key><string>$AUTO_UPDATE</string>
    <key>ZQAGENT_UPDATE_CHANNEL</key><string>$CHANNEL</string>
    <key>ZQAGENT_UPDATE_PUBKEY</key><string>$UPDATE_PUBKEY</string>
    <key>ZQAGENT_UPDATE_URL</key><string>$BASE_URL/$CHANNEL.json</string>
    <key>ZQAGENT_UPDATE_INSECURE</key><string>$UPDATE_INSECURE</string>
  </dict>
  <key>StandardOutPath</key><string>$LOG</string>
  <key>StandardErrorPath</key><string>$LOG</string>
</dict>
</plist>
EOF

  launchctl bootout "$(launchd_domain)/$LABEL" >/dev/null 2>&1 || true
  # bootout 是异步的：立刻 bootstrap 偶尔返回 "Input/output error 5"，重试几次
  i=0
  ok=0
  while [ "$i" -lt 5 ]; do
    if launchctl bootstrap "$(launchd_domain)" "$plist" 2>/dev/null; then
      ok=1
      break
    fi
    i=$((i + 1))
    sleep 1
    launchctl bootout "$(launchd_domain)/$LABEL" >/dev/null 2>&1 || true
  done
  # 若该 Label 曾被 `launchctl disable`（手动操作、或上次卸载留下 disabled 记录），
  # bootstrap 仍会成功，但作业永远不会运行，表现就是「服务 · 未运行」。
  # 旧脚本一直有这一步，重构时漏掉了，这里补回来。
  if [ "$ok" = "1" ]; then
    launchctl enable "$(launchd_domain)/$LABEL" >/dev/null 2>&1 || true
  fi
  [ "$ok" = "1" ] || SERVICE_RESTART_FAILED=1
}

install_service() {
  [ "$SERVICE_KIND" = "none" ] && return 0
  if [ ! -x "$ZQAGENT_BIN" ]; then
    SERVICE_KIND="none"
    SERVICE_NOTE="未找到可执行文件 $ZQAGENT_BIN"
    SERVICE_HINT="请重开终端后重试，或手动指定：--bin-dir <目录>"
    return 0
  fi
  fetch_update_pubkey
  if [ "$SERVICE_KIND" = "systemd" ]; then
    install_service_systemd
  else
    install_service_launchd
  fi
}

# 停止并移除开机自启服务。幂等：没装过也正常返回。
# 只动服务，数据目录与二进制保留（会话、凭据、模型配置都在数据目录里）。
do_uninstall() {
  removed=0
  note=""
  # 普通用户遇到 root 安装的系统级服务：没有权限卸载，最终提示要说明「仍在」
  NEED_ROOT=0

  case "$OS" in
    linux)
      if [ "$(id -u)" -ne 0 ]; then
        die "卸载系统服务需要 root：
  curl -fsSL https://agent.cd-zq.com/install.sh | sudo sh -s -- uninstall"
      fi
      if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
        systemctl disable --now "$SERVICE_NAME" >/dev/null 2>&1 || true
      fi
      if [ -f "/etc/systemd/system/$SERVICE_NAME.service" ]; then
        rm -f "/etc/systemd/system/$SERVICE_NAME.service"
        removed=1
      fi
      if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
        systemctl daemon-reload >/dev/null 2>&1 || true
        # 已 disable 时 reset-failed 只是为了清掉 failed 记录，失败无妨
        systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
      fi
      note="systemd: $SERVICE_NAME"
      ;;
    darwin)
      # root 装的是 LaunchDaemon，普通用户装的是 LaunchAgent，两者位置和域都不同。
      # sudo 卸载时若只查 Daemon，会漏掉当初以普通用户安装的 Agent（它仍在登录时自启）。
      daemon_plist="/Library/LaunchDaemons/$LABEL.plist"
      if [ "$(id -u)" -eq 0 ]; then
        candidates="$daemon_plist"
        # 当前身份与 sudo 发起者的 LaunchAgent 都一并处理
        for h in "$HOME" "$(home_of "${SUDO_USER:-}")"; do
          [ -n "$h" ] || continue
          candidate="$h/Library/LaunchAgents/$LABEL.plist"
          case " $candidates " in
            *" $candidate "*) ;;
            *) candidates="$candidates $candidate" ;;
          esac
        done
      else
        candidates="$HOME/Library/LaunchAgents/$LABEL.plist"
        if [ -f "$daemon_plist" ]; then
          say "! 检测到系统级服务：$daemon_plist"
          say "  卸载需要 root：curl -fsSL https://agent.cd-zq.com/install.sh | sudo sh -s -- uninstall"
          NEED_ROOT=1
        fi
      fi

      notes=""
      for plist in $candidates; do
        [ -f "$plist" ] || continue
        case "$plist" in
          /Library/LaunchDaemons/*) domain="system" ;;
          *)
            # 从 plist 所在的家目录反查 uid：LaunchAgent 的域是 gui/<uid>
            owner=""
            case "$plist" in
              "$HOME"/Library/LaunchAgents/*) owner="$(id -u)" ;;
              *)
                if [ -n "${SUDO_USER:-}" ] && [ "$plist" = "$(home_of "$SUDO_USER")/Library/LaunchAgents/$LABEL.plist" ]; then
                  owner="$(id -u "$SUDO_USER" 2>/dev/null || true)"
                fi
                ;;
            esac
            [ -n "$owner" ] || owner="$(id -u)"
            domain="gui/$owner"
            ;;
        esac
        launchctl bootout "$domain/$LABEL" >/dev/null 2>&1 || true
        # rm 失败不能中断脚本（set -e），要给出可执行的补救命令
        if rm -f "$plist" 2>/dev/null; then
          removed=1
          notes="$notes $domain:$LABEL"
        else
          NEED_ROOT=1
          say "! 无法删除 ${plist}（权限不足）"
          say "  请用 root 重新执行：curl -fsSL https://agent.cd-zq.com/install.sh | sudo sh -s -- uninstall"
        fi
      done
      note="launchd:${notes:- $LABEL}"
      ;;
    *)
      say "当前平台（${OS}）不支持服务化，无需卸载服务。"
      return 0
      ;;
  esac

  if [ "$removed" = "1" ]; then
    say "✅ 已停止并移除开机自启服务（${note}）"
  elif [ "$NEED_ROOT" = "1" ]; then
    say "! 服务仍在，需要 root 才能卸载（见上方命令）"
  else
    say "✓ 没有找到已安装的服务（${note}），无需卸载"
  fi

  say ""
  say "已保留（重装可直接复用）："
  say "  二进制    ${ZQAGENT_BIN}"
  case "$DATA_DIR_CONFIRMED" in
    explicit|unit)
      say "  数据目录  ${DATA_DIR}"
      say "            （会话、Hub 接入凭据、模型配置都在这里，删除后需重新接入）"
      ;;
    guess)
      say "  数据目录  ${DATA_DIR}（按默认位置推断）"
      say "            安装时若用过 --data-dir，请带上相同参数再执行卸载"
      ;;
    *)
      say "  数据目录  未找到安装记录（默认位置 ${DATA_DIR}）"
      say "            安装时若用过 --data-dir，请带上相同参数再执行卸载"
      ;;
  esac
  say ""
  say "重新安装：curl -fsSL https://agent.cd-zq.com/install.sh | sudo sh"
  case "$DATA_DIR_CONFIRMED" in
    explicit|unit) say "彻底清理：sudo rm -rf \"$DATA_DIR\" \"$ZQAGENT_BIN\"" ;;
    guess) say "彻底清理（确认路径无误后）：sudo rm -rf \"$DATA_DIR\" \"$ZQAGENT_BIN\"" ;;
    *) say "彻底清理：sudo rm -f \"$ZQAGENT_BIN\"   并删除你的数据目录" ;;
  esac
}

# ── 控制类子命令（status / logs / restart / rollback / print-config） ──
# 都不下载、不安装，只读取或操作已经在运行的服务。

# 重启系统服务；没有托管服务时给出可执行的前台运行提示
restart_service() {
  case "$SERVICE_KIND" in
    systemd)
      systemctl restart "$SERVICE_NAME" \
        || die "重启失败：systemctl restart $SERVICE_NAME"
      say "✅ 已重启 systemd 服务 $SERVICE_NAME"
      ;;
    launchd)
      launchctl kickstart -k "$(launchd_domain)/$LABEL" \
        || die "重启失败：launchctl kickstart $(launchd_domain)/$LABEL"
      say "✅ 已重启 launchd 作业 $LABEL"
      ;;
    *)
      say "! 当前没有可管理的服务${SERVICE_NOTE:+（${SERVICE_NOTE}）}"
      [ -n "$SERVICE_HINT" ] && say "$SERVICE_HINT"
      say "  前台运行：$ZQAGENT_BIN serve --data-dir \"$DATA_DIR\" --port $PORT"
      return 1
      ;;
  esac
}

do_control() {
  case "$1" in
    status)
      # 显式查状态时忽略 --no-service：那是安装选项，不代表服务没装
      NO_SERVICE=0
      detect_service
      if [ ! -x "$ZQAGENT_BIN" ]; then
        say "未安装 ZQAgent：$ZQAGENT_BIN 不存在"
        say "安装：curl -fsSL https://agent.cd-zq.com/install.sh | sudo sh"
        return 1
      fi
      resolve_cmd
      print_status
      ;;
    logs)
      [ -f "$LOG" ] || die "日志文件不存在：${LOG}（节点可能还没启动过）"
      say "→ 跟踪日志（Ctrl+C 退出）：$LOG"
      tail -n 100 -f "$LOG" || die "无法读取 ${LOG}（需要 root？用 sudo 重新执行）"
      ;;
    restart)
      restart_service
      ;;
    rollback)
      [ -x "$ZQAGENT_BIN" ] || die "未找到可执行文件：${ZQAGENT_BIN}（用 --bin-dir 指定）"
      say "→ 回滚到上一次自动更新前的版本"
      ZQAGENT_NO_RESTART_HINT=1 \
        "$ZQAGENT_BIN" update --rollback --data-dir "$DATA_DIR" \
        || die "回滚失败（可能尚未通过自动更新安装过新版本）"
      restart_service
      ;;
    print-config)
      say "ACTION=$ACTION"
      say "PLATFORM=$PLATFORM_KEY"
      say "MANIFEST=$BASE_URL/$CHANNEL.txt"
      say "VERSION=${VERSION:-<最新>}"
      say "BIN=$ZQAGENT_BIN"
      say "DATA_DIR=$DATA_DIR"
      say "LOG=$LOG"
      say "HOST=$HOST PORT=$PORT"
      say "PUBKEY=${UPDATE_PUBKEY:+已配置}"
      if [ "$SERVICE_KIND" = "none" ]; then
        say "SERVICE=no"
      else
        say "SERVICE=yes（$SERVICE_KIND · $SERVICE_NAME / ${LABEL}）"
      fi
      ;;
    uninstall)
      do_uninstall
      ;;
    *)
      die "未知子命令：$1"
      ;;
  esac
}

# ── 运行状态查询（供重复执行时报告） ────────────────────────

# 从 launchd plist 取回端口 / 数据目录 / 日志 / 二进制位置。
#
# plist 里 ProgramArguments 与 EnvironmentVariables 是 XML，先去掉换行再按
# 「<string>--port</string><string>8787</string>」这类相邻形式匹配。
launchd_adopt_from_plist() {
  [ -f "$1" ] || return 0
  flat="$(tr -d '\n' < "$1" 2>/dev/null || true)"
  [ -n "$flat" ] || return 0

  if [ "$PORT_SET" = "0" ]; then
    p="$(printf '%s' "$flat" | sed -n 's#.*<string>--port</string>[[:space:]]*<string>\([0-9][0-9]*\)</string>.*#\1#p' | head -1)"
    if [ -n "$p" ]; then PORT="$p"; fi
  fi
  if [ "$DATA_DIR_SET" = "0" ]; then
    d="$(printf '%s' "$flat" | sed -n 's#.*<string>--data-dir</string>[[:space:]]*<string>\([^<]*\)</string>.*#\1#p' | head -1)"
    if [ -n "$d" ]; then
      DATA_DIR="$d"
      DATA_DIR_CONFIRMED="unit"
    fi
  fi
  # 用户显式换了数据目录时，日志已跟随新目录，不再用旧配置覆盖
  if [ "$LOG_EXPLICIT" = "0" ] && [ "$LOG_FOLLOWS_DATA" = "0" ]; then
    l="$(printf '%s' "$flat" | sed -n 's#.*<key>StandardOutPath</key>[[:space:]]*<string>\([^<]*\)</string>.*#\1#p' | head -1)"
    if [ -n "$l" ]; then LOG="$l"; fi
  fi
  if [ "$BIN_DIR_SET" = "0" ]; then
    b="$(printf '%s' "$flat" | sed -n 's#.*<key>ProgramArguments</key>[[:space:]]*<array>[[:space:]]*<string>\([^<]*\)</string>.*#\1#p' | head -1)"
    if [ -n "$b" ] && [ -x "$b" ]; then
      ZQAGENT_BIN="$b"
      BIN_DIR="$(dirname "$b")"
    fi
  fi
}

# 从已安装的服务描述里取回实际端口 / 数据目录 / 日志 / 二进制位置。
# 重复执行本脚本时通常不带参数，若沿用默认值会把状态报错（端口不同还会误判「打不开」）。
adopt_existing_config() {
  desc="/etc/systemd/system/$SERVICE_NAME.service"
  if [ "$SERVICE_KIND" = "systemd" ] && [ -f "$desc" ]; then
    if [ "$PORT_SET" = "0" ]; then
      p="$(sed -n 's/^ExecStart=.*--port[ =]\([0-9][0-9]*\).*/\1/p' "$desc" | head -1)"
      if [ -n "$p" ]; then PORT="$p"; fi
    fi
    if [ "$DATA_DIR_SET" = "0" ]; then
      d="$(sed -n 's/^ExecStart=.*--data-dir[ =]\([^ ]*\).*/\1/p' "$desc" | head -1)"
      if [ -n "$d" ]; then
        DATA_DIR="$d"
        DATA_DIR_CONFIRMED="unit"
      fi
    fi
    if [ "$LOG_EXPLICIT" = "0" ] && [ "$LOG_FOLLOWS_DATA" = "0" ]; then
      l="$(sed -n 's/^StandardOutput=append:\(.*\)$/\1/p' "$desc" | head -1)"
      if [ -n "$l" ]; then LOG="$l"; fi
    fi
    # 服务实际用的是 unit 里的那个二进制（可能换过 --bin-dir），优先采用
    if [ "$BIN_DIR_SET" = "0" ]; then
      b="$(sed -n 's/^ExecStart=\([^ ]*\) .*/\1/p' "$desc" | head -1)"
      if [ -n "$b" ] && [ -x "$b" ]; then
        ZQAGENT_BIN="$b"
        BIN_DIR="$(dirname "$b")"
      fi
    fi
  fi

  if [ "$SERVICE_KIND" = "launchd" ]; then
    # root 装的 Daemon 与用户装的 Agent 位置不同；sudo 执行时必须两个都看，
    # 否则会把用户级服务的配置报错（端口/数据目录退回默认值）。
    launchd_adopt_from_plist "$(launchd_plist_path)"
    if [ "$DATA_DIR_CONFIRMED" = "" ] && [ -n "${SUDO_USER:-}" ]; then
      sudo_home="$(home_of "$SUDO_USER")"
      if [ -n "$sudo_home" ]; then
        launchd_adopt_from_plist "$sudo_home/Library/LaunchAgents/$LABEL.plist"
      fi
    fi
  fi

  # 服务描述已经不在时（刚卸载、容器里从未装过服务），按默认位置确认数据目录。
  # 猜不到就不硬猜——卸载提示里会退化成「未确定」，避免误导用户删错目录。
  if [ "$DATA_DIR_SET" = "1" ]; then
    DATA_DIR_CONFIRMED="explicit"
  elif [ "$DATA_DIR_CONFIRMED" = "" ] && [ -d "$DATA_DIR" ]; then
    DATA_DIR_CONFIRMED="guess"
  elif [ "$DATA_DIR_CONFIRMED" = "" ]; then
    # 只在与当前平台匹配的位置里找：macOS 上用 Linux 路径会误报，
    # 反过来容器/WSL 里也可能躺着 /var/lib/zqagent。
    found=0
    case "$OS" in
      darwin)
        # sudo 卸载时 HOME 是 /var/root，用户真正的数据目录在 SUDO_USER 的家目录
        for cand in "$HOME/Library/Application Support/zqagent" \
                    "$(home_of "${SUDO_USER:-}")/Library/Application Support/zqagent"; do
          case "$cand" in
            "/Library/Application Support/zqagent") continue ;;  # home_of 解析失败
          esac
          if [ -d "$cand" ]; then
            DATA_DIR="$cand"
            found=1
            break
          fi
        done
        ;;
      *)
        # 旧版 Linux 安装在 /var/lib/zqagent，新版在运行用户的家目录。
        # sudo 执行时 HOME 是 /root，必须同时看 SUDO_USER 的家目录，
        # 否则会漏掉用户当初建的数据目录（卸载时尤其要命）。
        for cand in /var/lib/zqagent \
                    "$HOME/.zqagent_data" \
                    "$(home_of "${SUDO_USER:-}")/.zqagent_data"; do
          case "$cand" in
            "/.zqagent_data") continue ;;  # home_of 解析失败
          esac
          if [ -d "$cand" ]; then
            DATA_DIR="$cand"
            found=1
            break
          fi
        done
        ;;
    esac
    if [ "$found" = "1" ]; then DATA_DIR_CONFIRMED="guess"; fi
  fi
  if [ "$BIN_DIR_SET" = "0" ] && [ ! -x "$ZQAGENT_BIN" ] && [ -x /usr/local/bin/zqagent ]; then
    ZQAGENT_BIN="/usr/local/bin/zqagent"
    BIN_DIR="/usr/local/bin"
  fi
}

service_active() {
  case "$SERVICE_KIND" in
    systemd) systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null ;;
    launchd) launchctl print "$(launchd_domain)/$LABEL" 2>/dev/null | grep -q 'state = running' ;;
    *) return 1 ;;
  esac
}

service_enabled() {
  case "$SERVICE_KIND" in
    systemd) systemctl is-enabled --quiet "$SERVICE_NAME" 2>/dev/null ;;
    # LaunchDaemon/LaunchAgent 都带 RunAtLoad，装好即开机自启
    launchd) return 0 ;;
    *) return 1 ;;
  esac
}

service_installed() {
  case "$SERVICE_KIND" in
    systemd) [ -f "/etc/systemd/system/$SERVICE_NAME.service" ] ;;
    launchd) [ -f "$(launchd_plist_path)" ] ;;
    *) return 1 ;;
  esac
}

local_probe_host() {
  case "$HOST" in
    0.0.0.0|::|"") printf '127.0.0.1' ;;
    *) printf '%s' "$HOST" ;;
  esac
}

# 端口是否已有进程在监听。
# macOS 自带 lsof；Linux 精简镜像可能只有 ss/netstat，逐级回退。
port_listening() {
  _p="$1"
  if command -v lsof >/dev/null 2>&1; then
    lsof -nP -iTCP:"$_p" -sTCP:LISTEN >/dev/null 2>&1 && return 0
    return 1
  fi
  if command -v ss >/dev/null 2>&1; then
    ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${_p}$" && return 0
    return 1
  fi
  if command -v netstat >/dev/null 2>&1; then
    netstat -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${_p}$" && return 0
    return 1
  fi
  # 最后手段：bash 的 /dev/tcp（macOS 的 sh 支持；dash 走上面几个分支）
  (exec 3<>"/dev/tcp/127.0.0.1/$_p") >/dev/null 2>&1 && {
    exec 3<&- 2>/dev/null || true
    return 0
  }
  return 1
}

# 该端口上跑的是不是本数据目录的 zqagent。
# 用 admin key 请求 fleet/status：能认证通过才是同一个节点，避免把别的
# zqagent（不同 --data-dir）误当自己而共用一个端口。
port_is_our_node() {
  _p="$1"
  command -v curl >/dev/null 2>&1 || return 1
  [ -f "$DATA_DIR/admin.json" ] || return 1
  _h="$(curl -fsS -m 1 "http://$(local_probe_host):$_p/healthz" 2>/dev/null || true)"
  [ -n "$_h" ] || return 1
  _key="$(sed -n 's/.*"apiKey"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$DATA_DIR/admin.json" | head -1)"
  [ -n "$_key" ] || return 1
  curl -fsS -m 2 -H "Authorization: Bearer $_key" \
    "http://$(local_probe_host):$_p/api/v1/fleet/status" >/dev/null 2>&1
}

# server 启动后会把实际监听端口写进这个文件（端口被占时它会自动递增）。
# plist/unit 里写的是安装时选定的端口，两者可能不同，探测时两个都试。
service_port_candidates() {
  _f="$DATA_DIR/server-port"
  if [ -f "$_f" ]; then
    _sp="$(tr -dc '0-9' < "$_f" 2>/dev/null | head -c 5)"
    if [ -n "$_sp" ]; then printf '%s\n' "$_sp"; fi
  fi
  printf '%s\n' "$PORT"
}

# 探测本地 server 健康状态。
#
# 结果放进全局 HEALTH_JSON / HEALTH_PORT（不能用命令替换：子 shell 里的变量
# 传不出来，状态报告就拿不到真实端口）。命中返回 0。
probe_health() {
  HEALTH_JSON=""
  HEALTH_PORT=""
  command -v curl >/dev/null 2>&1 || return 1
  for _cand in $(service_port_candidates); do
    _h="$(curl -fsS -m 1 "http://$(local_probe_host):$_cand/healthz" 2>/dev/null || true)"
    if [ -n "$_h" ]; then
      HEALTH_JSON="$_h"
      HEALTH_PORT="$_cand"
      return 0
    fi
  done
  return 1
}

# 本地 server 的云端接入状态；需要 admin key（{dataDir}/admin.json）
fleet_status_json() {
  [ -f "$DATA_DIR/admin.json" ] || return 0
  command -v curl >/dev/null 2>&1 || return 0
  key="$(sed -n 's/.*"apiKey"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$DATA_DIR/admin.json" | head -1)"
  [ -n "$key" ] || return 0
  curl -fsS -m 3 -H "Authorization: Bearer $key" \
    "http://$(local_probe_host):${HEALTH_PORT:-$PORT}/api/v1/fleet/status" 2>/dev/null || true
}

# fleet.json 里的展示字段（绝不读取/输出 fleetSecret）
fleet_field() {
  [ -f "$DATA_DIR/fleet.json" ] || return 0
  sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" "$DATA_DIR/fleet.json" | head -1
}

# 等本地服务可用再报「已启动」，避免用户看到提示却打不开页面。
# 升级时旧进程可能还占着端口，给了期望版本就等它切换过来。
wait_healthy() {
  want="${1:-}"
  command -v curl >/dev/null 2>&1 || return 2
  i=0
  while [ "$i" -lt 15 ]; do
    if probe_health; then
      h="$HEALTH_JSON"
      if [ -z "$want" ] || [ "$(json_get "$h" version)" = "$want" ]; then
        return 0
      fi
    fi
    i=$((i + 1))
    sleep 1
  done
  return 1
}

# 选定最终监听端口。
#
# 8787 是常见默认值，开发机上很容易被别的服务占着；此前安装脚本不做检查，
# 服务启动即 EADDRINUSE，用户只看到「服务 · 未运行」。
# 这里安装阶段就把冲突解决掉：占用则递增，并把每次尝试打印出来。
resolve_port() {
  start="$PORT"
  n=0
  limit=20
  say "→ 检查端口 ${PORT} 是否可用 ..."
  while :; do
    if ! port_listening "$PORT"; then
      if [ "$PORT" != "$start" ]; then
        say "→ 端口 ${PORT} 可用"
      fi
      break
    fi
    # 本节点自己的服务在跑：沿用，不要另起一个实例
    if port_is_our_node "$PORT"; then
      say "✓ 端口 ${PORT} 已由本节点占用（服务在运行），沿用该端口"
      break
    fi
    say "! 端口 ${PORT} 已被其它进程占用"
    if [ "$n" -ge "$limit" ]; then
      die "从 ${start} 起连续 ${limit} 个端口都被占用，无法自动选择。
  请释放端口，或手动指定：--port <端口>"
    fi
    PORT=$((PORT + 1))
    n=$((n + 1))
    say "→ 尝试端口 ${PORT} ..."
  done

  if [ "$PORT" != "$start" ]; then
    say ""
    say "! 已自动改用端口 ${PORT}（原定 ${start} 被占用）"
    say "  本地控制台：http://$(local_probe_host):$PORT"
  fi
}

# 把当前版本 / 服务 / 本地地址 / 远程接入 / 自动更新一次性说清楚
print_status() {
  ver="$("$ZQAGENT_BIN" --version 2>/dev/null | head -1 || true)"
  [ -n "$ver" ] || ver="未知"
  probe_health || true
  health="$HEALTH_JSON"
  live="$(json_get "$health" version)"

  # 优先问运行中的 server（最准），否则退回 fleet.json 的静态信息
  st="$(fleet_status_json)"
  node_id="$(json_get "$st" nodeId)"
  [ -n "$node_id" ] || node_id="$(fleet_field nodeId)"
  hub="$(json_get "$st" hubUrl)"
  [ -n "$hub" ] || hub="$(fleet_field hubUrl)"
  gw="$(json_get "$st" gatewayUrl)"
  [ -n "$gw" ] || gw="$(fleet_field gatewayUrl)"
  label="$(json_get "$st" label)"
  [ -n "$label" ] || label="$(fleet_field label)"
  err="$(json_get "$st" lastError)"

  printf '  版本       %s\n' "$ver"

  case "$SERVICE_KIND" in
    systemd|launchd)
      if [ "$SERVICE_KIND" = "systemd" ]; then svc="systemd · $SERVICE_NAME"; else svc="launchd · $LABEL"; fi
      if service_active; then
        if service_enabled; then state="运行中（已设开机自启）"; else state="运行中（未设开机自启）"; fi
      elif service_installed; then
        state="未运行"
      else
        state="未安装"
      fi
      printf '  服务       %s · %s\n' "$svc" "$state"
      if [ "$state" = "未运行" ]; then
        if [ "$SERVICE_KIND" = "systemd" ]; then
          printf '             启动：systemctl start %s\n' "$SERVICE_NAME"
          printf '             排查：journalctl -u %s -n 50 --no-pager\n' "$SERVICE_NAME"
        else
          printf '             启动：launchctl kickstart -k %s/%s\n' "$(launchd_domain)" "$LABEL"
          printf '             排查：tail -n 50 "%s"\n' "$LOG"
          # 直接把 launchd 记录的退出原因摆出来：进程反复崩溃时这是最快的线索
          reason="$(launchctl print "$(launchd_domain)/$LABEL" 2>/dev/null \
            | sed -n 's/^[[:space:]]*last exit \(reason\|code\) = /last exit \1 = /p' | tr '\n' ' ' | sed 's/ $//')"
          if [ -n "$reason" ]; then
            printf '             退出：%s\n' "$reason"
          fi
        fi
      fi
      ;;
    *)
      printf '  服务       未安装（%s）\n' "$SERVICE_NOTE"
      ;;
  esac

  # 端口上可能跑着别的节点实例：有 admin key 却拿不到状态，说明不是本数据目录的 server
  port_owner_ok=1
  if [ -n "$live" ] && [ -f "$DATA_DIR/admin.json" ] && [ -z "$st" ]; then
    port_owner_ok=0
  fi

  # 实际端口：服务在跑时以探测到的为准（server 可能在启动时又递增过）
  shown_port="${HEALTH_PORT:-$PORT}"
  if [ -n "$live" ] && [ "$port_owner_ok" = "1" ]; then
    printf '  本地控制台 http://%s:%s  ✓ 可访问（v%s）\n' "$(local_probe_host)" "$shown_port" "$live"
  elif [ -n "$live" ]; then
    printf '  本地控制台 http://%s:%s  ! 端口已被其它进程占用（不是本数据目录的节点）\n' \
      "$(local_probe_host)" "$shown_port"
  elif service_active; then
    printf '  本地控制台 http://%s:%s  ✗ 无响应（服务在运行，可能以其它端口启动）\n' \
      "$(local_probe_host)" "$shown_port"
  else
    printf '  本地控制台 http://%s:%s  （服务未运行）\n' "$(local_probe_host)" "$shown_port"
  fi

  printf '  数据目录   %s\n' "$DATA_DIR"
  printf '  日志       %s\n' "$LOG"

  # 「已接入」以凭据或运行中 server 的 linked 为准；
  # 未接入时 server 也会返回 nodeId（本机标识），不能据此判定
  has_cred=0
  if [ -f "$DATA_DIR/fleet.json" ]; then has_cred=1; fi
  linked=0
  case "$st" in *'"linked": true'*|*'"linked":true'*) linked=1 ;; esac

  if [ "$linked" = "1" ] || [ "$has_cred" = "1" ]; then
    printf '  远程管理   已接入 %s\n' "${hub:-（Hub 地址未记录）}"
    if [ -n "$node_id" ]; then
      printf '             节点 %s%s\n' "$node_id" "${label:+（${label}）}"
    fi
    case "$st" in
      *'"online": true'*|*'"online":true'*)
        printf '             状态 在线%s\n' "${gw:+　网关 $gw}" ;;
      *)
        if [ -z "$st" ]; then
          printf '             状态 未连接（本地 server 未运行，启动后自动接入）\n'
        else
          printf '             状态 离线%s\n' "${err:+（${err}）}"
        fi
        ;;
    esac
  else
    printf '  远程管理   未接入（可选）\n'
    if link_supports_account; then
      printf '             用 Hub 账号直接接入：\n'
      printf '               %s link --hub %s --username <邮箱/手机号> --password <密码>\n' "$CMD" "$HUB_URL"
      printf '             或在 %s 生成接入码后：\n' "$HUB_URL"
    else
      printf '             在 %s 生成接入码后：\n' "$HUB_URL"
    fi
    printf '               %s link --hub %s --code <接入码>\n' "$CMD" "$HUB_URL"
  fi

  if [ "$AUTO_UPDATE" = "1" ] || [ -n "$UPDATE_PUBKEY" ]; then
    printf '  自动更新   已启用（%s 通道）\n' "$CHANNEL"
  fi
}

# CLI 不在 PATH 时用绝对路径，复制粘贴即可用
resolve_cmd() {
  case ":$PATH:" in
    *":$BIN_DIR:"*) CMD="zqagent" ;;
    *) CMD="$ZQAGENT_BIN" ;;
  esac
}
resolve_cmd

print_usage_hints() {
  # 用实际端口（服务运行中可能已自动递增）
  hint_port="${HEALTH_PORT:-$PORT}"
  say ""
  say "① 浏览器打开：http://$(local_probe_host):$hint_port"
  say "   在图形界面里配置模型、新建会话、查看运行状态。"
  case "$HOST" in
    0.0.0.0|::)
      say "   注意：已监听所有网卡，请用防火墙限制端口 $hint_port 的访问来源。"
      ;;
  esac
  if [ "$SERVICE_KIND" = "none" ]; then
    say "   服务未安装，先手动启动（Ctrl+C 后停止）："
    say "     $CMD serve --data-dir \"$DATA_DIR\" --port $hint_port"
  fi
  say ""
  say "② 命令行："
  say "     $CMD                 # 交互式 CLI（输入 /connect 配置模型）"
  say "     $CMD --help          # 查看全部子命令"
  case ":$PATH:" in
    *":$BIN_DIR:"*) ;;
    *)
      say ""
      say "! $BIN_DIR 不在 PATH 中，请把下面一行加进 ~/.profile（或 ~/.zshrc）后重开终端："
      say "    export PATH=\"$BIN_DIR:\$PATH\""
      ;;
  esac
  say ""
  say "③ 重新运行本安装脚本可随时查看上述状态（不会重复安装）。"
  say "文档：https://agent.cd-zq.com/guide/getting-started"
}

# ── Hub 接入（可选） ────────────────────────────────────────
# 目标：把「新节点加入 Hub」压缩成一次安装。有终端时安装末尾直接进入
# `zqagent link` 的账号向导（密码隐藏输入、可选短信验证码）；自动化场景
# 用 --username/--password 或 --code。凭据始终由 CLI 写入服务的数据目录。
can_prompt() {
  [ "$NO_ENROLL" = "1" ] && return 1
  # curl|sh 时 stdin 是脚本管道，交互必须走 /dev/tty；容器里设备存在但常打不开
  ( : < /dev/tty ) 2>/dev/null || return 1
  ( : > /dev/tty ) 2>/dev/null || return 1
  return 0
}

ask_tty() {
  printf '%s' "$1" > /dev/tty
  IFS= read -r _answer < /dev/tty || _answer=""
  printf '%s' "$_answer"
}

# 已安装的 CLI 是否支持「Hub 账号接入」（0.2.12+ 的 link 才有 --username）。
# install.sh 与节点二进制独立发布，可能出现「脚本已更新、CLI 还是旧版」：
# 旧版只认 --code，直接进账号向导只会报「缺少 --hub 或 --code」。
link_supports_account() {
  [ -x "$ZQAGENT_BIN" ] || return 1
  "$ZQAGENT_BIN" link --help 2>/dev/null | grep -q -- '--username'
}

# 调用已安装的 CLI：自动发现本机服务并热接入；服务没在跑时写入它的数据目录。
run_link() {
  set -- link --hub "$HUB_URL" --data-dir "$DATA_DIR"
  [ -n "$NODE_LABEL" ] && set -- "$@" --label "$NODE_LABEL"
  [ -n "$HUB_CODE" ] && set -- "$@" --code "$HUB_CODE"
  if [ -n "$HUB_USER" ]; then
    set -- "$@" --username "$HUB_USER"
  fi

  # 安装期屏蔽 Node 弃用警告。
  # 依赖链 node-fetch → whatwg-url → tr46 会 require 内置 punycode，Node 21+
  # 每次启动都往 stderr 打 DEP0040，混进登录向导的提示里（「请选择 [1]：」后面
  # 跟着两行警告）。新版 CLI 自己会静音，但安装脚本拿到的可能是发布滞后的旧版，
  # 这里兜底。用 --no-deprecation 而不是 --disable-warning=DEP0040：后者要
  # Node 20.11+，而本脚本支持 Node 18.17 起。
  LINK_NODE_OPTIONS="--no-deprecation${NODE_OPTIONS:+ $NODE_OPTIONS}"

  if [ -n "$HUB_PASSWORD" ]; then
    # 密码只经环境变量传递，不放进 argv（ps / shell 历史都看不到）
    NODE_OPTIONS="$LINK_NODE_OPTIONS" ZQAGENT_HUB_PASSWORD="$HUB_PASSWORD" \
      "$ZQAGENT_BIN" "$@"
  elif [ -z "$HUB_CODE" ] && [ ! -t 0 ] && [ -r /dev/tty ]; then
    # 没给密码：交给 link 的隐藏输入向导补问，stdin 接到终端
    NODE_OPTIONS="$LINK_NODE_OPTIONS" "$ZQAGENT_BIN" "$@" < /dev/tty
  else
    NODE_OPTIONS="$LINK_NODE_OPTIONS" "$ZQAGENT_BIN" "$@"
  fi
}

enroll_hub() {
  [ "$NO_ENROLL" = "1" ] && return 0
  resolve_cmd
  # 记录服务实际端口，供接入后的 linked 状态探测使用
  probe_health || true

  # 重复执行安装脚本时不重新询问，避免把正在使用的凭据换掉
  if [ -f "$DATA_DIR/fleet.json" ]; then
    say ""
    say "→ 已检测到 Hub 接入凭据，跳过接入步骤（换绑请手动执行 $CMD link）"
    return 0
  fi

  # 脚本与二进制独立发布：旧版 CLI 没有账号接入，不能把用户送进账号向导
  account_link_ok=0
  if link_supports_account; then
    account_link_ok=1
  fi
  if [ -n "$HUB_USER" ] && [ "$account_link_ok" = "0" ]; then
    say ""
    say "! 当前 CLI 版本不支持 Hub 账号接入，已跳过。请改用一次性接入码："
    say "    $CMD link --hub $HUB_URL --code <接入码>"
    return 0
  fi

  if [ -z "$HUB_CODE" ] && [ -z "$HUB_USER" ]; then
    if ! can_prompt; then
      say ""
      say "ℹ️  未接入 Hub（非交互环境）。安装完成后可随时执行："
      if [ "$account_link_ok" = "1" ]; then
        say "     $CMD link --hub $HUB_URL --username <邮箱/手机号> --password '<密码>'"
        say "   或在 Hub 控制台生成接入码：$CMD link --hub $HUB_URL --code <接入码>"
      else
        say "     $CMD link --hub $HUB_URL --code <接入码>"
        say "   （接入码在 Hub 控制台「节点管理」生成）"
      fi
      return 0
    fi
    say ""
    say "── 接入 Hub（可选）────────────────────────────────────"
    if [ "$account_link_ok" = "1" ]; then
      say "   用 Hub 注册的邮箱/手机号接入，便于在用户中心远程管理这台节点。"
    else
      say "   当前 CLI 版本不支持账号接入，请用 Hub「节点管理」生成的一次性接入码。"
    fi
    answer="$(ask_tty '现在接入 Hub？[Y/n]：')"
    case "$answer" in
      n|N|no|NO)
        say "已跳过 Hub 接入。之后可随时执行：$CMD link --hub $HUB_URL"
        return 0
        ;;
    esac
    if [ "$account_link_ok" = "0" ]; then
      HUB_CODE="$(ask_tty '接入码：')"
      # 粘贴时常带换行/空白，去掉再校验
      HUB_CODE="$(printf '%s' "$HUB_CODE" | tr -d '[:space:]')"
      if [ -z "$HUB_CODE" ]; then
        say "! 未输入接入码，已跳过 Hub 接入。之后可执行：$CMD link --hub $HUB_URL --code <接入码>"
        return 0
      fi
    fi
  elif [ -n "$HUB_USER" ] && [ -z "$HUB_PASSWORD" ] && ! can_prompt; then
    say "! 已指定 --username 但没有密码（--password / ZQAGENT_HUB_PASSWORD），跳过接入"
    return 0
  fi

  say ""
  if [ -n "$HUB_CODE" ]; then
    say "→ 使用一次性接入码接入 $HUB_URL"
  else
    say "→ 使用 Hub 账号接入 $HUB_URL"
  fi

  if run_link; then
    link_ok=1
  else
    link_ok=0
    say ""
    say "! Hub 接入未完成（不影响节点本地运行），可稍后重试："
    if [ "$account_link_ok" = "1" ]; then
      say "    $CMD link --hub $HUB_URL --username <邮箱/手机号> --password '<密码>'"
    fi
    say "    $CMD link --hub $HUB_URL --code <接入码>"
  fi

  # root 安装、服务以 SUDO_USER 运行时，root 写入的 fleet.json（0600）服务读不到
  if [ "$link_ok" = "1" ] && [ "$OS" = "linux" ] && [ "$(id -u)" -eq 0 ] \
    && [ "$RUN_USER" != "root" ] && id "$RUN_USER" >/dev/null 2>&1; then
    chown -R "$RUN_USER" "$DATA_DIR" 2>/dev/null || true
  fi

  # 服务没在跑时凭据只落在磁盘上，启动一次让它读取；
  # 服务在跑但没能热接入（admin key 不一致等）时也重启一次兜底。
  if [ "$link_ok" = "1" ] && [ "$SERVICE_KIND" != "none" ]; then
    need_restart=0
    if ! service_active; then
      need_restart=1
    else
      case "$(fleet_status_json)" in
        *'"linked": true'*|*'"linked":true'*) ;;
        *) need_restart=1 ;;
      esac
    fi
    if [ "$need_restart" = "1" ]; then
      say "→ 重启服务以应用接入凭据"
      restart_service >/dev/null 2>&1 || true
      wait_healthy "$(version_number "$ZQAGENT_BIN")" || true
    fi
  fi
  return 0
}

# ── glibc 2.17 兼容运行时（CentOS 7 / 麒麟 V10 等） ────────
# 官方 Node 22 在这些系统上起不来（要求 glibc >= 2.28）。unofficial-builds
# 提供同版本、glibc 2.17 基线的构建：装到数据目录的独立前缀，用它的 npm
# 安装 @zqagent/core，并在 BIN_DIR 放一个包装脚本。
#   - 服务与用户只认 BIN_DIR/zqagent，不必把 node 加进 PATH；
#   - 不覆盖系统自带的 node（可能还有其它程序依赖它）。
LEGACY_NODE_VERSION="${ZQAGENT_LEGACY_NODE_VERSION:-22.23.2}"
# 默认走自建镜像：unofficial-builds 在部分网络下只有几十 KB/s，60MB 的包要下很久。
# 镜像目录结构、SHASUMS256.txt 与上游完全一致，失败时自动回退到官方源。
LEGACY_NODE_MIRROR="https://agent.cd-zq.com/downloads/node"
LEGACY_NODE_UPSTREAM="https://unofficial-builds.nodejs.org/download/release"
LEGACY_NODE_BASE="${ZQAGENT_LEGACY_NODE_BASE:-$LEGACY_NODE_MIRROR}"

## 运行时下载源：显式指定时只用它，否则「自建镜像 → 官方源」依次尝试
legacy_node_sources() {
  if [ -n "${ZQAGENT_LEGACY_NODE_BASE:-}" ]; then
    printf '%s\n' "$ZQAGENT_LEGACY_NODE_BASE"
  else
    printf '%s\n' "$LEGACY_NODE_MIRROR" "$LEGACY_NODE_UPSTREAM"
  fi
}

## 校验和来源：与下载源相反，优先上游（更可信），取不到再用镜像自带的
legacy_checksum_sources() {
  if [ -n "${ZQAGENT_LEGACY_NODE_BASE:-}" ]; then
    printf '%s\n' "$ZQAGENT_LEGACY_NODE_BASE"
  else
    printf '%s\n' "$LEGACY_NODE_UPSTREAM" "$LEGACY_NODE_MIRROR"
  fi
}

install_legacy_runtime() {
  if [ "$OS" != "linux" ]; then
    die "glibc 兼容运行时只支持 Linux（当前：${OS}）"
  fi
  if [ "$ARCH" != "x64" ]; then
    die "当前架构（${ARCH}）没有 glibc 2.17 兼容的 Node 构建，无法在老 glibc（${GLIBC_VERSION:-未知}）上安装。
  可换一台 x64 机器，或升级系统 glibc 后重试。"
  fi
  command -v curl >/dev/null 2>&1 || die "需要 curl 下载 Node 运行时"
  command -v tar >/dev/null 2>&1 || die "需要 tar 解压 Node 运行时"

  RUNTIME_DIR="$DATA_DIR/runtime"
  NODE_DIR="$RUNTIME_DIR/node"
  CACHE_DIR="$RUNTIME_DIR/cache"
  ARCHIVE="node-v$LEGACY_NODE_VERSION-linux-x64-glibc-217.tar.gz"
  ARCHIVE_PATH="$CACHE_DIR/$ARCHIVE"
  NODE_URL="$LEGACY_NODE_BASE/v$LEGACY_NODE_VERSION/$ARCHIVE"
  PKG_ENTRY="$RUNTIME_DIR/lib/node_modules/@zqagent/core/bin/zqagent"
  PKG_PKGJSON="$RUNTIME_DIR/lib/node_modules/@zqagent/core/package.json"

  say ""
  say "→ 本机 glibc 为 ${GLIBC_VERSION}（低于 2.28），改用 glibc 2.17 兼容运行时"
  say "  Node v${LEGACY_NODE_VERSION}：$NODE_URL"

  mkdir -p "$RUNTIME_DIR" || die "无法创建运行时目录：$RUNTIME_DIR"

  # ── Node 运行时：已装好就直接复用，不重复下载解压 ──────────
  NODE_READY=0
  if [ -x "$NODE_DIR/bin/node" ] && \
     [ "$("$NODE_DIR/bin/node" -v 2>/dev/null || true)" = "v$LEGACY_NODE_VERSION" ]; then
    NODE_READY=1
    say "→ 复用已安装的 Node v${LEGACY_NODE_VERSION}：$NODE_DIR"
  fi

  if [ "$NODE_READY" = "0" ]; then
    mkdir -p "$CACHE_DIR" || die "无法创建缓存目录：$CACHE_DIR"

    # 上游提供 SHASUMS256.txt，能取到就用于校验。
    # 顺序是「上游 → 镜像」：镜像与安装包同源，被同时篡改时校验就失效了。
    EXPECTED=""
    for SRC in $(legacy_checksum_sources); do
      EXPECTED="$(curl -fsSL --max-time 30 "$SRC/v$LEGACY_NODE_VERSION/SHASUMS256.txt" 2>/dev/null \
        | awk -v f="$ARCHIVE" '$2 == f { print $1 }' || true)"
      [ -n "$EXPECTED" ] && break
    done

    sha256_of_file() {
      if command -v sha256sum >/dev/null 2>&1; then
        sha256sum "$1" | awk '{print $1}'
      elif command -v shasum >/dev/null 2>&1; then
        shasum -a 256 "$1" | awk '{print $1}'
      else
        printf ''
      fi
    }

    # 下载过的安装包留在 cache/ 里复用（重装/升级不必再下一遍 60MB+）
    if [ -f "$ARCHIVE_PATH" ]; then
      if [ -z "$EXPECTED" ]; then
        say "→ 复用已下载的运行时包：$ARCHIVE_PATH"
      else
        CACHED_SHA="$(sha256_of_file "$ARCHIVE_PATH")"
        if [ -n "$CACHED_SHA" ] && [ "$CACHED_SHA" = "$EXPECTED" ]; then
          say "→ 复用已下载的运行时包：$ARCHIVE_PATH"
        else
          say "! 已缓存的运行时包校验不通过，重新下载"
          rm -f "$ARCHIVE_PATH"
        fi
      fi
    fi

    if [ ! -f "$ARCHIVE_PATH" ]; then
      # 先下到 .part 再 rename：中断不会留下被下次误当成完整包的半截文件。
      # 允许续传与重试，超时放宽到 30 分钟；一个源失败就换下一个。
      DL_OK=0
      for SRC in $(legacy_node_sources); do
        NODE_URL="$SRC/v$LEGACY_NODE_VERSION/$ARCHIVE"
        say "→ 下载 Node 运行时：$NODE_URL"
        if curl -fL --progress-bar --max-time 1800 --retry 2 --retry-delay 2 -C - \
             -o "$ARCHIVE_PATH.part" "$NODE_URL"; then
          DL_OK=1
          break
        fi
        # 换源时不能复用上一源的半截文件（可能是一段错误页）
        rm -f "$ARCHIVE_PATH.part"
        say "! 该来源下载失败，尝试下一个"
      done
      [ "$DL_OK" = "1" ] || die "下载 Node 运行时失败
  可换用镜像后重试：ZQAGENT_LEGACY_NODE_BASE=<镜像根地址>（需含 v$LEGACY_NODE_VERSION/ 与 SHASUMS256.txt）"
      if [ -n "$EXPECTED" ]; then
        ACTUAL="$(sha256_of_file "$ARCHIVE_PATH.part")"
        if [ -n "$ACTUAL" ] && [ "$ACTUAL" != "$EXPECTED" ]; then
          rm -f "$ARCHIVE_PATH.part"
          die "Node 运行时 SHA-256 校验失败（期望 ${EXPECTED}，实际 ${ACTUAL}）"
        fi
      fi
      mv -f "$ARCHIVE_PATH.part" "$ARCHIVE_PATH" || die "无法写入缓存：$ARCHIVE_PATH"
    fi

    EXTRACT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/zqagent-node-x.XXXXXX")" || die "无法创建临时目录"
    tar -xzf "$ARCHIVE_PATH" -C "$EXTRACT_DIR" || { rm -rf "$EXTRACT_DIR"; die "解压 Node 运行时失败"; }
    rm -rf "$NODE_DIR"
    mv "$EXTRACT_DIR/node-v$LEGACY_NODE_VERSION-linux-x64-glibc-217" "$NODE_DIR" \
      || { rm -rf "$EXTRACT_DIR"; die "安装 Node 运行时失败（$RUNTIME_DIR 不可写？）"; }
    rm -rf "$EXTRACT_DIR"
  fi

  # 版本跟清单走：指定了 --version 就装对应版本，否则装 registry 上的 latest
  PKG_SPEC="@zqagent/core"
  if [ -n "$VERSION" ]; then PKG_SPEC="@zqagent/core@$VERSION"; fi
  INSTALLED_VER="$("$NODE_DIR/bin/node" -e \
    'try{process.stdout.write(require(process.argv[1]).version)}catch(e){}' \
    "$PKG_PKGJSON" 2>/dev/null || true)"

  # 同一版本已装好就跳过 npm：重复执行安装脚本不该再跑一遍网络安装
  if [ -n "$VERSION" ] && [ "$INSTALLED_VER" = "$VERSION" ] && [ -f "$PKG_ENTRY" ]; then
    say "→ 已安装 ${PKG_SPEC}，跳过下载与 npm 安装"
  else
    resolve_npm_registry
    # 探测只做启发式：网络抖动时它可能误判，所以安装失败后再按镜像顺序重试一次。
    npm_install_pkg() {
      PATH="$NODE_DIR/bin:$PATH" "$NODE_DIR/bin/npm" i -g "$PKG_SPEC" \
        --registry="$1" --prefix "$RUNTIME_DIR" --no-audit --no-fund
    }
    say "→ 安装 ${PKG_SPEC}（Node v${LEGACY_NODE_VERSION}，registry: ${NPM_REGISTRY}）..."
    NPM_OK=0
    if npm_install_pkg "$NPM_REGISTRY"; then
      NPM_OK=1
    elif [ "$NPM_REGISTRY_SET" = "0" ]; then
      for cand in $NPM_REGISTRY_FALLBACKS; do
        [ "$cand" = "$NPM_REGISTRY" ] && continue
        say "! 用 $NPM_REGISTRY 安装失败，改用镜像：$cand"
        if npm_install_pkg "$cand"; then
          NPM_REGISTRY="$cand"
          NPM_OK=1
          break
        fi
      done
    fi
    if [ "$NPM_OK" = "0" ]; then
      die "npm 安装失败（registry: ${NPM_REGISTRY}）
  可指定其它镜像后重试，例如：
    curl -fsSL https://agent.cd-zq.com/install.sh | sudo env NPM_REGISTRY=https://registry.npmmirror.com/ sh"
    fi
  fi

  ENTRY="$PKG_ENTRY"
  [ -f "$ENTRY" ] || die "安装后找不到 CLI 入口：$ENTRY"

  mkdir -p "$BIN_DIR" || die "无法创建安装目录：$BIN_DIR"
  cat > "$BIN_DIR/zqagent" <<EOF
#!/bin/sh
# 由 ZQAgent 安装脚本生成：glibc 2.17 兼容运行时包装脚本
exec "$NODE_DIR/bin/node" "$ENTRY" "\$@"
EOF
  chmod 755 "$BIN_DIR/zqagent" || die "无法设置可执行权限"

  ZQAGENT_BIN="$BIN_DIR/zqagent"
  GOT="$("$ZQAGENT_BIN" --version 2>&1 | head -1 || true)"
  case "$GOT" in
    *zqagent*) ;;
    *)
      die "兼容运行时安装后仍无法执行 ${ZQAGENT_BIN}：
$GOT"
      ;;
  esac

  say ""
  say "✓ 安装完成：$ZQAGENT_BIN"
  say "  $GOT"
  say "  （glibc 2.17 兼容运行时，Node v${LEGACY_NODE_VERSION}）"

  # 安装成功才上报（失败路径不会走到这里）
  report_install "binary-linux-x64-glibc217-runtime"
}

# ── npm 回退 ────────────────────────────────────────────────
# 全局 npm 目录不可写时（macOS 用官方 pkg 装的 Node、Linux 发行版的 node 常见），
# 改装到用户目录。`curl | sh` 的 stdin 是脚本管道，sudo 读不到密码，因此不做提权重试。
npm_install_user_prefix() {
  prefix="$(npm prefix -g 2>/dev/null || true)"
  # 目录可写说明失败与权限无关（网络/磁盘等），不要把真实错误掩盖成权限问题
  if [ -n "$prefix" ] && { [ -w "$prefix/lib/node_modules" ] || [ -w "$prefix" ]; }; then
    return 1
  fi
  NPM_USER_PREFIX="$HOME/.local"
  say ""
  say "! npm 全局目录没有写权限：${prefix:-未知}，改用用户目录：$NPM_USER_PREFIX"
  say "  如需装到全局：sudo npm i -g @zqagent/core --registry=$NPM_REGISTRY"
  npm i -g @zqagent/core --registry="$NPM_REGISTRY" --prefix "$NPM_USER_PREFIX"
}

# 系统 Node 的主版本号（没有 node 或输出异常时返回空）
node_major_version() {
  command -v node >/dev/null 2>&1 || return 1
  v="$(node -v 2>/dev/null || true)"
  case "$v" in
    v[0-9]*) printf '%s' "${v#v}" | sed 's/\..*//' ; return 0 ;;
    *) return 1 ;;
  esac
}

install_npm() {
  if ! command -v npm >/dev/null 2>&1; then
    # 老 glibc 系统（CentOS 7 等）常常连 Node 都没装：走自带的兼容运行时
    if [ -n "$LIBC_VARIANT" ]; then
      say "未找到 npm（系统没有可用的 Node），改用自带的 glibc 2.17 兼容运行时安装。"
      install_legacy_runtime
      return 0
    fi
    say ""
    say "未找到 npm。请先安装 Node.js >= 18.17：https://nodejs.org/"
    say "或下载单文件可执行版本：https://agent.cd-zq.com/downloads/zqagent/"
    exit 1
  fi
  _node_major="$(node_major_version || true)"
  if [ -z "$_node_major" ] || [ "$_node_major" -lt 18 ]; then
    if [ -n "$LIBC_VARIANT" ]; then
      say "系统 Node 版本过低（${_node_major:+v$_node_major.}x，需要 >= 18.17），"
      say "改用自带的 glibc 2.17 兼容运行时安装。"
      install_legacy_runtime
      return 0
    fi
    die "npm 安装需要 Node.js >= 18.17（当前：$(node -v 2>/dev/null || printf '未安装')）"
  fi
  # npm 版本过低（< 7）不支持 --registry 之外的现代行为，仅提示
  resolve_npm_registry
  say "→ 使用 npm 安装 @zqagent/core ..."
  NPM_USER_PREFIX=""
  if ! npm i -g @zqagent/core --registry="$NPM_REGISTRY"; then
    npm_install_user_prefix || die "npm 安装失败"
  fi
  if [ -n "$NPM_USER_PREFIX" ]; then
    # --prefix 装的 bin 不一定在 PATH 里，直接按安装位置取
    ZQAGENT_BIN="$NPM_USER_PREFIX/bin/zqagent"
    BIN_DIR="$NPM_USER_PREFIX/bin"
  else
    ZQAGENT_BIN="$(command -v zqagent 2>/dev/null || true)"
    [ -n "$ZQAGENT_BIN" ] || ZQAGENT_BIN="$BIN_DIR/zqagent"
    BIN_DIR="$(dirname "$ZQAGENT_BIN")"
  fi
  report_install "npm-$OS-$ARCH"
}

# npm registry：显式传入（参数/环境变量）时完全尊重用户选择；否则直连 npmjs
# 不可达时自动切国内镜像——CentOS 7 这类老系统往往也在国内网络，
# registry.npmjs.org 会直接 ETIMEDOUT，用户看到的只是「npm 安装失败」。
NPM_REGISTRY="${NPM_REGISTRY:-https://registry.npmjs.org/}"
NPM_REGISTRY_SET=0
if [ "${NPM_REGISTRY}" != "https://registry.npmjs.org/" ]; then
  NPM_REGISTRY_SET=1
fi
NPM_REGISTRY_FALLBACKS="https://registry.npmmirror.com/ https://mirrors.cloud.tencent.com/npm/"

npm_registry_reachable() {
  command -v curl >/dev/null 2>&1 || return 1
  curl -fsS -m 8 -o /dev/null "$1@zqagent%2fcore" 2>/dev/null
}

# 只有在用默认 npmjs、且它确实不可达时才切换，避免给可用的网络添乱
resolve_npm_registry() {
  [ "$NPM_REGISTRY_SET" = "1" ] && return 0
  case "$NPM_REGISTRY" in
    *registry.npmjs.org*) ;;
    *) return 0 ;;
  esac
  npm_registry_reachable "$NPM_REGISTRY" && return 0
  for cand in $NPM_REGISTRY_FALLBACKS; do
    if npm_registry_reachable "$cand"; then
      say "! 无法连接 ${NPM_REGISTRY}，改用镜像：$cand"
      NPM_REGISTRY="$cand"
      return 0
    fi
  done
  say "! 无法连接 npm registry（${NPM_REGISTRY}），也未找到可用镜像"
  return 0
}

# 取版本号（`zqagent --version` 输出形如 `zqagent v0.2.4`）
version_number() {
  "$1" --version 2>/dev/null | head -1 \
    | sed -n 's/.*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p'
}

# 清单里的最新版本；网络不可用时返回空（不因此阻断已有安装）
manifest_version() {
  command -v curl >/dev/null 2>&1 || return 0
  m="$(curl -fsSL --max-time 8 "$BASE_URL/$CHANNEL.json" 2>/dev/null || true)"
  [ -n "$m" ] || return 0
  json_get "$m" version
}

detect_service
adopt_existing_config

# 非安装子命令：不下载、不安装，只读或操作已有服务（status/logs/restart/rollback/print-config/uninstall）
if [ "$ACTION" != "install" ]; then
  do_control "$ACTION" || exit $?
  exit 0
fi

# 先确定可用端口（在下载之前）：端口冲突要尽早反馈，不该等 100MB 下载完才报错
resolve_port

# ── 重复执行：已装且已是最新就只补服务、报告状态，不重新下载 ──
SKIP_DOWNLOAD=0
SERVICE_NEEDS_RESTART=1
if [ -x "$ZQAGENT_BIN" ] && [ "$FORCE" = "0" ]; then
  CUR_VER="$(version_number "$ZQAGENT_BIN")"
  say "→ 已检测到 ZQAgent${CUR_VER:+ v$CUR_VER}：$ZQAGENT_BIN"
  TARGET="${VERSION:-$(manifest_version)}"
  if [ -z "$TARGET" ]; then
    say "→ 暂时无法获取版本清单，保留现有安装"
    SKIP_DOWNLOAD=1
  elif [ "$CUR_VER" = "$TARGET" ]; then
    say "→ 已是最新版本，跳过下载安装"
    SKIP_DOWNLOAD=1
  else
    say "→ 发现新版本 v${TARGET}（当前 v${CUR_VER:-未知}），开始升级"
  fi
fi

if [ "$SKIP_DOWNLOAD" = "1" ]; then
  SERVICE_NEEDS_RESTART=0
elif [ "$FORCE_NPM" -eq 1 ] || [ "$OS" = "unsupported" ] || [ "$ARCH" = "unsupported" ]; then
  say "该平台（$OS/${ARCH}）使用 npm 安装。"
  install_npm
else
  # ── 读取清单 ──────────────────────────────────────────────
  command -v curl >/dev/null 2>&1 || die "需要 curl（或用 --npm 走 npm 安装）"

  MANIFEST_URL="$BASE_URL/$CHANNEL.json"
  # 清单只描述当前通道的最新版本；历史版本目录中没有清单，无法校验 sha256。
  # 因此指定 --version 时仍读主清单，版本不符则明确拒绝，不做无校验下载。

  say "→ 读取版本清单：$MANIFEST_URL"
  MANIFEST="$(curl -fsSL --max-time 20 "$MANIFEST_URL")" || {
    say ""
    say "无法获取版本清单，改用 npm 安装。"
    install_npm
  }

  if [ -n "${MANIFEST:-}" ]; then
    LATEST="$(json_get "$MANIFEST" version)"
    [ -n "$LATEST" ] || die "清单中缺少 version 字段"

    if [ -n "$VERSION" ] && [ "$VERSION" != "$LATEST" ]; then
      say ""
      say "清单中当前 $CHANNEL 通道为 ${LATEST}，没有 $VERSION 的校验信息。"
      say "安装指定历史版本请用 npm："
      say "    npm i -g @zqagent/core@$VERSION --registry=$NPM_REGISTRY"
      exit 1
    fi
    VERSION="$LATEST"

    # 取出该平台对应的二进制块
    BLOCK="$(printf '%s' "$MANIFEST" | tr -d '\n' | sed -n "s/.*\"$PLATFORM_KEY\"[[:space:]]*:[[:space:]]*{\([^}]*\)}.*/\1/p")"
    if [ -z "$BLOCK" ]; then
      if [ -n "$LIBC_VARIANT" ]; then
        say "版本 $VERSION 还没有 $PLATFORM_KEY 的兼容包（本机 glibc ${GLIBC_VERSION}）。"
        say "改用自带的 glibc 2.17 兼容运行时安装。"
        install_legacy_runtime
      else
        say "版本 $VERSION 没有 $PLATFORM_KEY 的单文件安装包，改用 npm。"
        install_npm
      fi
    fi
  fi

  if [ -n "${BLOCK:-}" ]; then
    BIN_URL="$(json_get "$BLOCK" url)"
    BIN_SHA="$(json_get "$BLOCK" sha256)"
    [ -n "$BIN_URL" ] || die "清单缺少 $PLATFORM_KEY 的下载地址"

    # ── 下载并校验 ────────────────────────────────────────────
    TMP="$(mktemp "${TMPDIR:-/tmp}/zqagent-install.XXXXXX")" || die "无法创建临时文件"
    trap 'rm -f "$TMP"' EXIT INT TERM

    say "→ 下载 ZQAgent ${VERSION}（${PLATFORM_KEY}）"
    say "  $BIN_URL"
    curl -fL --progress-bar --max-time 600 -o "$TMP" "$BIN_URL" || die "下载失败"

    if [ -n "$BIN_SHA" ]; then
      if command -v sha256sum >/dev/null 2>&1; then
        ACTUAL="$(sha256sum "$TMP" | awk '{print $1}')"
      elif command -v shasum >/dev/null 2>&1; then
        ACTUAL="$(shasum -a 256 "$TMP" | awk '{print $1}')"
      else
        ACTUAL=""
        say "! 未找到 sha256sum/shasum，跳过完整性校验"
      fi
      if [ -n "$ACTUAL" ] && [ "$ACTUAL" != "$BIN_SHA" ]; then
        die "SHA-256 校验失败（期望 ${BIN_SHA}，实际 ${ACTUAL}），已中止安装"
      fi
    fi

    # ── 落盘 ──────────────────────────────────────────────────
    # 必须「先复制到目标目录，再同目录 rename」，不能直接从 /tmp rename 过来：
    # 同文件系统下跨目录 rename 会把 inode 连同 /tmp 的 SELinux 上下文（tmp_t）
    # 一起搬到 ${BIN_DIR}。systemd 以 init_t 执行 tmp_t 文件会被拒绝，表现为
    # status=203/EXEC（openEuler/麒麟/统信等默认 Enforcing 的系统必现）。
    # 同一目录内 rename 则是原子的，且新文件继承目标目录的默认标签（bin_t）。
    NEW_BIN="$(mktemp "$BIN_DIR/.zqagent.new.XXXXXX")" \
      || die "无法在 $BIN_DIR 创建临时文件（权限不足？）"
    cp "$TMP" "$NEW_BIN" 2>/dev/null || die "无法写入 ${BIN_DIR}（权限不足？）"
    chmod 755 "$NEW_BIN" 2>/dev/null || { rm -f "$NEW_BIN"; die "无法设置可执行权限"; }
    mv -f "$NEW_BIN" "$BIN_DIR/zqagent" 2>/dev/null \
      || { rm -f "$NEW_BIN"; die "无法写入 $BIN_DIR/zqagent（权限不足？）"; }
    rm -f "$TMP"
    trap - EXIT INT TERM

    # 目标文件曾被历史版本（跨目录 mv）污染成 tmp_t 时，这里连带纠正标签；
    # 非 SELinux 系统没有 restorecon，忽略即可。
    restorecon -v "$BIN_DIR/zqagent" >/dev/null 2>&1 || true

    # ── 自检 ──────────────────────────────────────────────────
    INSTALLED="$BIN_DIR/zqagent"
    ZQAGENT_BIN="$INSTALLED"
    GOT="$("$INSTALLED" --version 2>/dev/null | head -1 || true)"
    if [ -z "$GOT" ]; then
      # 把 stderr 留下来：glibc/架构不匹配的线索全在这里，吞掉只剩「无法执行」
      ERR="$("$INSTALLED" --version 2>&1 | head -5 || true)"
      printf '\n错误：安装后无法执行 %s\n' "$INSTALLED" >&2
      if [ -n "$ERR" ]; then printf '%s\n' "$ERR" >&2; fi
      case "$ERR" in
        *GLIBCXX_*|*GLIBC_*)
          printf '\n该二进制要求的 glibc/libstdc++ 高于本机（glibc %s）。\n' "${GLIBC_VERSION:-未知}" >&2
          if [ -z "$LIBC_VARIANT" ]; then
            printf '  请重新执行安装脚本，它会自动选用 glibc 2.17 兼容构建：\n' >&2
            printf '    curl -fsSL https://agent.cd-zq.com/install.sh | sudo sh\n' >&2
          else
            printf '  兼容构建仍无法启动，请把上面的报错反馈给技术支持。\n' >&2
          fi
          ;;
        *)
          printf '  可尝试强制重新下载：curl -fsSL https://agent.cd-zq.com/install.sh | sudo sh -s -- --force\n' >&2
          ;;
      esac
      exit 1
    fi

    say ""
    say "✓ 安装完成：$INSTALLED"
    say "  $GOT"

    # 安装成功才上报（失败路径不会走到这里）
    report_install "binary-$PLATFORM_KEY"
  fi
fi

# ── 服务化 ──────────────────────────────────────────────────
install_service

# 刚装/刚重启时给它一点启动时间，报告里的「可访问」才属实
if [ "$SERVICE_KIND" != "none" ] && [ "$SERVICE_RESTART_FAILED" = "0" ]; then
  wait_healthy "$(version_number "$ZQAGENT_BIN")" || true
fi

# 接入 Hub（可选）：终端下直接进入账号向导；参数化场景自动执行
enroll_hub

# 服务起不来时给出可直接执行的排查命令。最常见的两种：
#   203/EXEC → SELinux 标签（openEuler/麒麟/统信等默认 Enforcing）；
#              或二进制与系统架构/glibc 不匹配。
#   EADDRINUSE → 端口被其它进程（多为升级前的旧实例）占用。
if [ "$SERVICE_KIND" != "none" ] && [ "$SERVICE_RESTART_FAILED" != "0" ]; then
  say ""
  say "! 服务未能启动，请按下面顺序排查："
  if [ "$SERVICE_KIND" = "systemd" ]; then
    say "  1) journalctl -u $SERVICE_NAME -n 50 --no-pager"
  else
    say "  1) tail -n 50 \"$LOG\""
  fi
  say "  2) 若日志显示 status=203/EXEC，多为 SELinux 标签问题："
  say "     ls -Z $ZQAGENT_BIN        （看到 tmp_t 即命中）"
  say "     restorecon -v $ZQAGENT_BIN && systemctl reset-failed $SERVICE_NAME && systemctl start $SERVICE_NAME"
fi

# ── 状态报告 + 使用引导 ────────────────────────────────────
resolve_cmd
say ""
if [ "$SKIP_DOWNLOAD" = "1" ]; then
  say "✓ ZQAgent 已安装，未重复下载。当前状态："
else
  say "✅ ZQAgent 安装完成，当前状态："
fi
print_status
print_usage_hints
