#!/usr/bin/env bash
# sema-up — Layer 0 薄 bootstrap(DEPLOY-OVERSEAS-PILOT §3/§3b/§3d,REV-3;2026-07-12 一键补完批次)。
# 三入口同一引擎:
#   sema cloud deploy        → 本脚本 --interactive=json(每决策点一行 JSON,stdin 读答案)
#   curl | sh(TTY)         → 本脚本无参(TTY prompt)
#   无人值守                 → 本脚本 -f site.env --yes
# 纪律:幂等 check-then-apply;凭据只生成一次(重入不轮换);密码不入日志;不长状态管理
# (状态归 Layer 1 compose/helm 清单)。
# 形态:
#   full-single  单机 docker 全栈(引擎+数据面)[+registry][+git]
#   data-center  数据面(PG+MinIO)[+registry][+git],agent 在别处连
#   local-plus   本机只跑 agent(docker),数据面在远端(data-center 档给的 endpoint)
#   full-cluster k8s 路径:machines.yaml 多机 ssh 装配(cluster-up.sh)/ 已有 kubeconfig /
#                本机装 k3s(后两态=kube-up.sh,helm chart 驱动)
set -euo pipefail

HERE="$(cd "$(dirname "$0")" && pwd)"
MODE_INTERACTIVE="tty"       # tty | json | none(--yes)
PROFILE="" USERS="" REGION="auto" CONF="" PROJECT="sema" TEARDOWN=false SKIP_PREFLIGHT=false
DATA_ROOT="" SERVER_PORT=8090 PG_PORT=5432 MINIO_PORT=9000 MINIO_CONSOLE_PORT=9001
GATEWAY="" MODEL="" MODEL_KEY="" SANDBOX_LANE="" REGISTRY_ENABLED="" REGISTRY_PORT="${REGISTRY_PORT:-3000}"
GIT_MODE="" GIT_URL="" GIT_TOKEN="" GIT_HTTP_PORT="${GIT_HTTP_PORT:-3300}" GIT_SSH_PORT="${GIT_SSH_PORT:-2222}"
KUBE_MODE="" KUBECONFIG_PATH="" DRY_RUN=false
AGENT_PG_HOST="" AGENT_PG_PASSWORD="" AGENT_MINIO_ENDPOINT="" AGENT_MINIO_PASSWORD=""
PG_URL="${PG_URL:-}" S3_ENDPOINT="${S3_ENDPOINT:-}" S3_ACCESS_KEY="${S3_ACCESS_KEY:-}" S3_SECRET_KEY="${S3_SECRET_KEY:-}"

usage() {
  cat <<'EOF'
用法: sema-up.sh [选项]
  --profile=P            full-single|data-center|local-plus|full-cluster
  --users=U              single|multi
  --region=R             auto|cn|global
  --interactive=json     机器可读交互协议(sema cloud deploy 渲染用)
  --yes                  全默认无人值守(与 -f 组合)
  -f FILE                site 配置(env 形式;machines.yaml=固定形状 mini-parser)
  --project=NAME         compose project 名(默认 sema;并行试验用)
  --dry-run              只打印将执行计划(不写 .env、不起任何容器)
  --port=N               服务端口(默认 8090);--pg-port/--minio-port/--minio-console-port 同理
  --gateway=URL --model=ID --model-key=K   模型面(带 agent 的档必需)
  --sandbox=LANE         kata|e2b-cloud|runc(full-single)
  --registry=BOOL        配置中心网站(默认 true;local-plus 默认 false)
  --registry-image=REF   sema-registry 镜像(registry=true 必需,可 -f 配置 SEMA_REGISTRY_IMAGE)
  --git=MODE             none(默认)|provision(一键自建 Gitea)|connect(接已有 git)
  --git-url=URL --git-token=T   git=connect 的已有服务(Gitea/GitHub/任意三方 git;自动识别,证书不校验)
  --pg-url=URL           外接第三方 PG(postgres://user:pass@host:port/db;连不上会先检,不部署本地 PG)
  --s3-endpoint=URL --s3-access-key=K --s3-secret-key=S   外接第三方 S3(兼容协议即可;不部署本地 MinIO)
  --git-http-port=N --git-ssh-port=N   自建 Gitea 端口(默认 3300/2222)
  --gitea-image=REF      自建 Gitea 镜像(默认 gitea/gitea:1.24)
  --pg-host=H --pg-password=P --minio-endpoint=URL --minio-password=P   local-plus 远端数据面
  --kube=MODE            full-cluster 单机两态:existing(已有 kubeconfig)|k3s(本机装 k3s)
  --kubeconfig=PATH      --kube=existing 的 kubeconfig(缺省 $KUBECONFIG 或 ~/.kube/config)
  --teardown             卸载本 project(数据卷保留;--teardown-volumes 连卷删)
  --skip-preflight       跳过预检(不建议)
EOF
}

TEARDOWN_VOLUMES=false
for a in "$@"; do case "$a" in
  --profile=*) PROFILE="${a#*=}" ;;
  --users=*) USERS="${a#*=}" ;;
  --region=*) REGION="${a#*=}" ;;
  --interactive=json) MODE_INTERACTIVE="json" ;;
  --yes) MODE_INTERACTIVE="none" ;;
  -f) ;; # 下一个参数处理
  --project=*) PROJECT="${a#*=}" ;;
  --dry-run) DRY_RUN=true ;;
  --port=*) SERVER_PORT="${a#*=}" ;;
  --pg-port=*) PG_PORT="${a#*=}" ;;
  --minio-port=*) MINIO_PORT="${a#*=}" ;;
  --minio-console-port=*) MINIO_CONSOLE_PORT="${a#*=}" ;;
  --gateway=*) GATEWAY="${a#*=}" ;;
  --model=*) MODEL="${a#*=}" ;;
  --model-key=*) MODEL_KEY="${a#*=}" ;;
  --sandbox=*) SANDBOX_LANE="${a#*=}" ;;
  --registry=*) REGISTRY_ENABLED="${a#*=}" ;;
  --registry-image=*) SEMA_REGISTRY_IMAGE="${a#*=}" ;;
  --git=*) GIT_MODE="${a#*=}" ;;
  --git-url=*) GIT_URL="${a#*=}" ;;
  --git-token=*) GIT_TOKEN="${a#*=}" ;;
  --git-http-port=*) GIT_HTTP_PORT="${a#*=}" ;;
  --git-ssh-port=*) GIT_SSH_PORT="${a#*=}" ;;
  --gitea-image=*) GITEA_IMAGE="${a#*=}" ;;
  --pg-host=*) AGENT_PG_HOST="${a#*=}" ;;
  --pg-password=*) AGENT_PG_PASSWORD="${a#*=}" ;;
  --minio-endpoint=*) AGENT_MINIO_ENDPOINT="${a#*=}" ;;
  --minio-password=*) AGENT_MINIO_PASSWORD="${a#*=}" ;;
  --pg-url=*) PG_URL="${a#*=}" ;;
  --s3-endpoint=*) S3_ENDPOINT="${a#*=}" ;;
  --s3-access-key=*) S3_ACCESS_KEY="${a#*=}" ;;
  --s3-secret-key=*) S3_SECRET_KEY="${a#*=}" ;;
  --kube=*) KUBE_MODE="${a#*=}" ;;
  --kubeconfig=*) KUBECONFIG_PATH="${a#*=}" ;;
  --data-root=*) DATA_ROOT="${a#*=}" ;;
  --teardown) TEARDOWN=true ;;
  --teardown-volumes) TEARDOWN=true; TEARDOWN_VOLUMES=true ;;
  --skip-preflight) SKIP_PREFLIGHT=true ;;
  -h|--help) usage; exit 0 ;;
esac; done
# -f FILE:*.yaml/*.yml 走 mini-parser(固定形状,fail-loud);其余按 env 形式 source
prev=""; for a in "$@"; do [ "$prev" = "-f" ] && CONF="$a"; prev="$a"; done
if [ -n "$CONF" ]; then
  [ -r "$CONF" ] || { echo "config 不可读: $CONF" >&2; exit 1; }
  case "$CONF" in
    *.yaml|*.yml)
      PARSED="$(python3 "$HERE/lib/parse-machines.py" "$CONF")" || exit 1
      _P="$PROFILE" _U="$USERS" _R="$REGION"   # 命令行显式参数赢过 yaml
      set -a; eval "$PARSED"; set +a
      [ -n "$_P" ] && PROFILE="$_P"; [ -n "$_U" ] && USERS="$_U"; [ "$_R" != "auto" ] && REGION="$_R"
      if [ "${MACHINE_COUNT:-0}" -ge 3 ] && [ -z "$PROFILE" ]; then PROFILE="full-cluster"; fi
      ;;
    *) set -a; . "$CONF"; set +a ;;
  esac
fi

WORK="$HOME/.sema-up/$PROJECT"; mkdir -p "$WORK"; chmod 700 "$HOME/.sema-up" "$WORK"
# 协议流(--interactive=json 的 question/summary 行)固定走 fd3=原始 stdout——
# $(ask …) 的命令替换只捕获答案,协议行不被吞(真机验出的坑)
exec 3>&1
log() { printf '[sema-up] %s\n' "$*" >&2; }

# compose 入口解析:优先 docker compose(插件),缺了退 docker-compose(独立版)——
# homebrew 裸 docker CLI 不带插件是真场景;都没有=报错带下一步
dc() {
  if docker compose version >/dev/null 2>&1; then docker compose "$@"
  elif command -v docker-compose >/dev/null 2>&1; then docker-compose "$@"
  else log "⛔ 没有可用的 compose(docker compose 插件/docker-compose 都缺)。下一步:装 Docker Desktop/OrbStack,或 brew install docker-compose"; exit 1; fi
}

# ── 跨平台探针(Linux 目标机为主;macOS 开发机跑 docker 档也是真场景——npx 分发)────────────
ram_mb() { if [ -r /proc/meminfo ]; then awk '/MemTotal/{printf "%d", $2/1024}' /proc/meminfo
           else sysctl -n hw.memsize 2>/dev/null | awk '{printf "%d", $1/1048576}'; fi; }
ncpus() { nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 2; }
host_ip() { hostname -I 2>/dev/null | awk '{print $1}' | grep . \
            || ipconfig getifaddr en0 2>/dev/null || echo 127.0.0.1; }

# ── 交互引擎:ask <id> <question> <default> <why> <opt1>:<label1>[:infeasible=原因|建议] … ──────
# 答案回显到 stdout 由调用方捕获;json 模式=stdout 一行协议,stdin 读答案;none=取默认。
ask() {
  local id="$1" q="$2" def="$3" why="$4"; shift 4
  local ans=""
  case "$MODE_INTERACTIVE" in
    none) ans="$def" ;;
    json)
      local opts="" v l inf reason fix
      for o in "$@"; do
        v="${o%%:*}"; l="${o#*:}"; inf="null"
        if [[ "$l" == *":infeasible="* ]]; then
          local infpart="${l#*:infeasible=}"; l="${l%%:infeasible=*}"
          reason="${infpart%%|*}"; fix="${infpart#*|}"
          inf="{\"reason\":\"$reason\",\"suggestion\":\"$fix\"}"
        fi
        opts="$opts{\"value\":\"$v\",\"label\":\"$l\",\"infeasible\":$inf},"
      done
      printf '{"type":"question","id":"%s","question":"%s","default":"%s","why":"%s","options":[%s]}\n' \
        "$id" "$q" "$def" "$why" "${opts%,}" >&3
      IFS= read -r ans || ans="$def"; [ -z "$ans" ] && ans="$def"
      ;;
    tty)
      {
        printf '\n%s\n' "$q"
        local i=1 v l
        for o in "$@"; do v="${o%%:*}"; l="${o#*:}"; l="${l%%:infeasible=*}"
          if [[ "$o" == *":infeasible="* ]]; then local infpart="${o#*:infeasible=}"
            printf '  %d) %s — %s ⛔ %s(建议:%s)\n' "$i" "$v" "$l" "${infpart%%|*}" "${infpart#*|}"
          else printf '  %d) %s — %s\n' "$i" "$v" "$l"; fi
          i=$((i+1)); done
        printf '默认 [%s](为什么问:%s): ' "$def" "$why"
      } >&2
      IFS= read -r ans </dev/tty || ans=""; [ -z "$ans" ] && ans="$def"
      # 数字选择转值
      if [[ "$ans" =~ ^[0-9]+$ ]] && [ "$#" -gt 0 ]; then local i=1; for o in "$@"; do [ "$i" = "$ans" ] && ans="${o%%:*}"; i=$((i+1)); done; fi
      ;;
  esac
  # infeasible 选项拒绝(fail-loud 非静默降级)
  for o in "$@"; do
    if [ "${o%%:*}" = "$ans" ] && [[ "$o" == *":infeasible="* ]]; then
      local infpart="${o#*:infeasible=}"
      log "⛔ 选项 $ans 在本机不可行:${infpart%%|*};建议:${infpart#*|}"; exit 1
    fi
  done
  printf '%s' "$ans"
}

emit() { [ "$MODE_INTERACTIVE" = "json" ] && printf '%s\n' "$1" >&3 || log "$2"; }

# ── teardown ─────────────────────────────────────────────────────────────────────────────────
if $TEARDOWN; then
  cd "$HERE/stack"
  if [ -f "$WORK/.env" ]; then
    OV=""; grep -q '^DATA_ROOT_DIR=..*' "$WORK/.env" && OV="-f compose.dataroot.yaml"
    # 全 profile down 会对未启用服务也做 config 校验——.env 缺 SEMA_*_IMAGE(该面没开过)时
    # 整个 down 失败(真踩:假「已卸载」)。补占位符只为过校验;shell env 优先级高于 --env-file
    grep -q '^SEMA_SERVER_IMAGE=..*' "$WORK/.env" || export SEMA_SERVER_IMAGE="teardown-placeholder"
    grep -q '^SEMA_REGISTRY_IMAGE=..*' "$WORK/.env" || export SEMA_REGISTRY_IMAGE="teardown-placeholder"
    if ! dc --project-name "$PROJECT" --env-file "$WORK/.env" -f compose.yaml $OV \
      --profile data --profile pg --profile s3 --profile agent --profile registry --profile git \
      down $($TEARDOWN_VOLUMES && echo -v); then
      log "⛔ 卸载未完全成功。下一步:docker ps -a --filter label=com.docker.compose.project=$PROJECT 手查残留"
      exit 1
    fi
    log "已卸载 project=$PROJECT $($TEARDOWN_VOLUMES && echo '(含数据卷)' || echo '(数据卷保留:pgdata/miniodata/registrydata/gitdata)')"
    if $TEARDOWN_VOLUMES && [ -n "$OV" ]; then
      DR="$(grep '^DATA_ROOT_DIR=' "$WORK/.env" | head -1 | cut -d= -f2-)"
      # ${DR} 必须带花括号:后面紧跟中文破折号,bash 3.2(macOS 系统壳)会把多字节并进变量名(真踩)
      log "⚠ 数据根在大盘 bind 目录 ${DR}——卷对象已删,盘上文件还在;确认不要了再手动 rm -rf"
    fi
  else log "没有找到 $WORK/.env,无可卸载"; fi
  exit 0
fi

KVM_OK=$([ -e /dev/kvm ] && echo true || echo false)

# ── 决策点(三轴+registry+git,§3b;问句都带「为什么问」)─────────────────────────────────────
[ -z "$PROFILE" ] && PROFILE=$(ask profile "要装成哪种形态?" "full-single" "决定这台机器上起哪些服务" \
  "full-single:单机全栈——引擎+数据库+对象存储都在这台机(最快起步)" \
  "data-center:只装数据面(PG+MinIO+备份)——agent 在你自己机器上,数据放这里" \
  "full-cluster:k8s 准生产——3 机 ssh 装配 / 已有集群 kubeconfig / 本机装 k3s" \
  "local-plus:本机只跑 agent,连一个已经部署好的 data-center 数据面")
[ -z "$USERS" ] && USERS=$(ask users "几个人用?" "single" "单人可跳过全部权限配置,多人要开身份隔离" \
  "single:就我一个(超级管理员 turnkey,零权限配置)" \
  "multi:一个团队(身份隔离 ON,能力默认全开,之后可用 policy 收紧)")

# registry:各档默认带上(clay 2026-07-12 拍;显式 --registry=false 可关)。
# local-plus 例外默认 false——它的数据面在远端,配置中心应随数据面部署,本机再起一个=两个配置中心。
if [ -z "$REGISTRY_ENABLED" ]; then
  if [ "$PROFILE" = "local-plus" ]; then REGISTRY_ENABLED=false; else REGISTRY_ENABLED=true; fi
fi

# git 面三态(none 默认;provision=一键自建 Gitea;connect=接已有)
[ -z "$GIT_MODE" ] && GIT_MODE=$(ask git "要带 git 仓库服务吗?" "none" "agent 干活要有代码仓;没有的话可以现装一个" \
  "none:不带(已有别的地方管代码,或暂时用不上)" \
  "provision:一键自建 Gitea(本机起一个 git 服务,含管理员账号)" \
  "connect:接已有的 git 服务(自建 Gitea 或 GitHub,填地址+token)")
case "$GIT_MODE" in none|provision|connect) : ;; *) log "⛔ --git 只认 none|provision|connect(收到:$GIT_MODE)"; exit 1 ;; esac
if [ "$GIT_MODE" = "connect" ]; then
  [ -z "$GIT_URL" ] && { [ "$MODE_INTERACTIVE" = "none" ] && { log "⛔ --git=connect 需要 --git-url(如 https://gitea.example.com 或 https://github.com/<org>)"; exit 1; } || GIT_URL=$(ask git_url "git 服务地址?(如 https://gitea.example.com)" "" "要验证连通并登记进配置中心"); }
  [ -z "$GIT_TOKEN" ] && { [ "$MODE_INTERACTIVE" = "none" ] && { log "⛔ --git=connect 需要 --git-token(该服务的 access token)"; exit 1; } || GIT_TOKEN=$(ask git_token "该服务的 access token?" "" "验证连通用;Gitea 还会用它自动建 OAuth 登录应用"); }
fi

if [ "$PROFILE" = "full-single" ] && [ -z "$SANDBOX_LANE" ]; then
  if [ "$KVM_OK" = "true" ]; then
    SANDBOX_LANE=$(ask sandbox "agent 跑代码用哪种沙箱?" "kata" "本机有 KVM,VM 级隔离可用" \
      "kata:Kata VM(默认建议,VM 级隔离)" "e2b-cloud:E2B 云沙箱(填 key 即用)" "runc:容器隔离(弱于 VM,不建议生产)")
  else
    SANDBOX_LANE=$(ask sandbox "agent 跑代码用哪种沙箱?" "e2b-cloud" "本机无 KVM,kata 不可行" \
      "kata:Kata VM:infeasible=本机无虚拟化支持(/dev/kvm 缺失)|云上开嵌套虚拟化/换 bare metal,或选 e2b" \
      "e2b-cloud:E2B 云沙箱(填 key,海外快)" "runc:容器隔离(弱于 VM,不建议生产)")
  fi
fi
if [ "$PROFILE" = "full-single" ] || [ "$PROFILE" = "local-plus" ]; then
  [ -z "$GATEWAY" ] && { [ "$MODE_INTERACTIVE" = "none" ] && { log "⛔ $PROFILE 档要跑 agent,需要 --gateway/--model(模型面)。例:--gateway=https://api.deepseek.com/v1 --model=deepseek-v4-flash --model-key=sk-…"; exit 1; } || GATEWAY=$(ask gateway "模型网关 baseURL?(OpenAI 兼容,如 https://api.deepseek.com/v1)" "" "引擎的大脑入口,没有它 agent 无法思考"); }
  [ -z "$MODEL" ] && MODEL=$(ask model "模型 id?" "deepseek-v4-flash" "按成本推荐 flash;要更强推理换 pro")
fi
if [ "$PROFILE" = "local-plus" ]; then
  [ -z "$AGENT_PG_HOST" ] && { [ "$MODE_INTERACTIVE" = "none" ] && { log "⛔ local-plus 需要 --pg-host(data-center 档 summary 里的 PG 地址)"; exit 1; } || AGENT_PG_HOST=$(ask pg_host "远端 PG 地址?(data-center 部署完 summary 里有)" "" "agent 的会话/任务都持久化到这里"); }
  [ -z "$AGENT_PG_PASSWORD" ] && { [ "$MODE_INTERACTIVE" = "none" ] && { log "⛔ local-plus 需要 --pg-password(data-center 机器 ~/.sema-up/<project>/.env 里的 PG_PASSWORD)"; exit 1; } || AGENT_PG_PASSWORD=$(ask pg_password "远端 PG 密码?(data-center 机器 .env 里的 PG_PASSWORD)" "" "连接数据面的凭据"); }
  [ -z "$AGENT_MINIO_ENDPOINT" ] && { [ "$MODE_INTERACTIVE" = "none" ] && { log "⛔ local-plus 需要 --minio-endpoint(如 http://<data-center-ip>:9000)"; exit 1; } || AGENT_MINIO_ENDPOINT=$(ask minio_endpoint "远端对象存储地址?(如 http://<data-center-ip>:9000)" "" "工作区/大对象放这里"); }
  [ -z "$AGENT_MINIO_PASSWORD" ] && { [ "$MODE_INTERACTIVE" = "none" ] && { log "⛔ local-plus 需要 --minio-password(data-center .env 里的 MINIO_PASSWORD)"; exit 1; } || AGENT_MINIO_PASSWORD=$(ask minio_password "远端对象存储密码?(data-center .env 里的 MINIO_PASSWORD)" "" "连接对象存储的凭据"); }
fi

# ── full-cluster 路由(k8s 路径;本地不做 docker 预检)───────────────────────────────────────
if [ "$PROFILE" = "full-cluster" ]; then
  [ -z "$GATEWAY" ] && { [ "$MODE_INTERACTIVE" = "none" ] && { log "⛔ full-cluster 需要 --gateway/--model(模型面)"; exit 1; } || GATEWAY=$(ask gateway "模型网关 baseURL?(OpenAI 兼容)" "" "引擎的大脑入口"); }
  [ -z "$MODEL" ] && MODEL=$(ask model "模型 id?" "deepseek-v4-flash" "按成本推荐 flash")
  if [ "${MACHINE_COUNT:-0}" -ge 1 ]; then
    KUBE_MODE="machines"   # machines.yaml 在场=多机 ssh 装配(as-built 原路径)
  fi
  [ -z "$KUBE_MODE" ] && KUBE_MODE=$(ask kube "k8s 从哪来?" "existing" "决定是连现有集群还是现装一个" \
    "existing:已有 k8s(kubeconfig 指向;kind/k3d/云商托管都行)" \
    "k3s:本机现装 k3s 单机集群(Linux;再 helm 装栈)" \
    "machines:多机 ssh 装配 k3s HA(要 -f machines.yaml)")
  case "$KUBE_MODE" in
    machines)
      [ "${MACHINE_COUNT:-0}" -ge 1 ] || { log "⛔ --kube=machines 需要 -f machines.yaml(机器清单);模板见 deploy/sema-up/machines.example.yaml"; exit 1; }
      $DRY_RUN && { log "dry-run:将经 cluster-up.sh 对 ${MACHINE_COUNT} 台机器做 k3s HA 装配(ssh)+ helm/render 装栈;不执行"; exit 0; }
      WORK="$WORK" REGION="$REGION" PROJECT="$PROJECT" \
        MODEL_GATEWAY_BASEURL="$GATEWAY" MODEL_ID="$MODEL" MODEL_API_KEY="${MODEL_KEY:-${MODEL_API_KEY:-}}" \
        exec "$HERE/cluster-up.sh"
      ;;
    existing|k3s)
      # kube-up.sh:同一 .env 词表(SEMA_REGISTRY_IMAGE/REGISTRY_*/GIT_* 原样传);它自己做 k8s 侧预检
      WORK="$WORK" REGION="$REGION" PROJECT="$PROJECT" KUBE_MODE="$KUBE_MODE" \
        KUBECONFIG_PATH="${KUBECONFIG_PATH:-${KUBECONFIG:-}}" DRY_RUN="$DRY_RUN" \
        USERS="$USERS" REGISTRY_ENABLED="$REGISTRY_ENABLED" GIT_MODE="$GIT_MODE" \
        GIT_URL="$GIT_URL" GIT_TOKEN="$GIT_TOKEN" \
        SEMA_SERVER_IMAGE="${SEMA_SERVER_IMAGE:-}" SEMA_REGISTRY_IMAGE="${SEMA_REGISTRY_IMAGE:-}" \
        GITEA_IMAGE="${GITEA_IMAGE:-}" \
        MODEL_GATEWAY_BASEURL="$GATEWAY" MODEL_ID="$MODEL" MODEL_API_KEY="${MODEL_KEY:-${MODEL_API_KEY:-}}" \
        exec "$HERE/kube-up.sh"
      ;;
    *) log "⛔ --kube 只认 existing|k3s|machines(收到:$KUBE_MODE)"; exit 1 ;;
  esac
fi

# ── 预检(决策已定,端口面按真实激活服务算;§3d-2:BLOCK 项带原因+建议)────────────────────
PF_PORTS=""
if ! $SKIP_PREFLIGHT; then
  # 端口面按真实要起的服务算(外接 PG/S3 的口不占本机)
  case "$PROFILE" in
    data-center) PF_PORTS="" ;;
    local-plus)  PF_PORTS="$SERVER_PORT" ;;
    *)           PF_PORTS="$SERVER_PORT" ;;
  esac
  if [ "$PROFILE" != "local-plus" ]; then
    [ -z "$PG_URL" ] && PF_PORTS="$PF_PORTS,$PG_PORT"
    [ -z "$S3_ENDPOINT" ] && PF_PORTS="$PF_PORTS,$MINIO_PORT,$MINIO_CONSOLE_PORT"
    PF_PORTS="${PF_PORTS#,}"
  fi
  [ "$REGISTRY_ENABLED" = "true" ] && PF_PORTS="$PF_PORTS,$REGISTRY_PORT"
  [ "$GIT_MODE" = "provision" ] && PF_PORTS="$PF_PORTS,$GIT_HTTP_PORT,$GIT_SSH_PORT"
  # 重入幂等:本 project 自己已发布的端口不算占用(否则第二遍必被自己 BLOCK——真踩);
  # 别的 project/进程占着照样 BLOCK
  if command -v docker >/dev/null 2>&1; then
    # 注意 {{.Ports}} 的范围语法(0.0.0.0:9000-9001->…)——按范围展开,别只匹配单口(真踩)
    # 尾部 || true:project 还没起(零匹配)时 grep 退 1,pipefail+set -e 会静默杀脚本(真踩)
    SELF_PORTS="$(docker ps --filter "label=com.docker.compose.project=$PROJECT" --format '{{.Ports}}' 2>/dev/null \
      | { grep -oE ':[0-9]+(-[0-9]+)?->' || true; } | sed 's/[:>-]*$//;s/^://' \
      | while IFS=- read -r a b; do seq "$a" "${b:-$a}"; done | sort -u | tr '\n' ' ')"
    if [ -n "${SELF_PORTS// /}" ]; then
      NEW_PORTS=""
      for p in $(printf '%s' "$PF_PORTS" | tr ',' ' '); do
        case " $SELF_PORTS " in *" $p "*) : ;; *) NEW_PORTS="$NEW_PORTS,$p" ;; esac
      done
      PF_PORTS="${NEW_PORTS#,}"
    fi
  fi
  PF="$("$HERE/preflight.sh" --json --profile="${PROFILE:-full}" --ports="$PF_PORTS")" || {
    emit "{\"type\":\"preflight\",\"deployable\":false,\"findings\":$(printf '%s' "$PF" | sed 's/.*"findings"://;s/,"deployable".*//')}" "预检未过:"
    printf '%s\n' "$PF" | python3 -c 'import json,sys
d=json.load(sys.stdin)
for f in d["findings"]:
    # 不用 f-string:表达式里的 \" 转义在 python3.12 前非法(真踩,吞掉了 BLOCK 原因)
    if f["level"]=="BLOCK": print("⛔ %s: %s — %s\n   ↳ 建议:%s" % (f["check"], f["result"], f["reason"], f["suggestion"]))' >&2
    exit 1
  }
  emit "{\"type\":\"preflight\",\"deployable\":true}" "预检通过"
  # 建议不强求:预检过了也把 WARN 的建议念出来(--yes 无人值守同样看得见,不然建议白算)
  printf '%s\n' "$PF" | python3 -c 'import json,sys
d=json.load(sys.stdin)
for f in d["findings"]:
    if f["level"]=="WARN" and f.get("suggestion"):
        print("⚠ %s: %s — %s\n   ↳ 建议:%s" % (f["check"], f["result"], f["reason"], f["suggestion"]))' >&2 || true
fi

# ── region(落三处:OS 源/registry mirror/SANDBOX_PKG_SOURCE) ────────────────────────────────
if [ "$REGION" = "auto" ]; then
  t_cn=$(curl -m3 -so /dev/null -w '%{time_total}' https://mirrors.aliyun.com 2>/dev/null || echo 99)
  t_gl=$(curl -m3 -so /dev/null -w '%{time_total}' https://registry.npmjs.org 2>/dev/null || echo 99)
  REGION=$(awk -v a="$t_cn" -v b="$t_gl" 'BEGIN{print (a<b)?"cn":"global"}')
fi
HOSTIP=$(host_ip)
log "region=$REGION profile=$PROFILE users=$USERS sandbox=${SANDBOX_LANE:-n/a} registry=$REGISTRY_ENABLED git=$GIT_MODE"

# ── 数据盘选择(多盘机自动选大容量盘;--data-root 显式优先;macOS 不自动——bind 要经 VM 挂载,
#    显式给了才用)。首装落 .env 后不漂移:换盘=先 --teardown-volumes 迁数据再重装。──────────────
if [ -z "$DATA_ROOT" ] && [ "$(uname -s)" = "Linux" ] && [ "$PROFILE" != "local-plus" ]; then
  ROOT_AVAIL=$(df -BG --output=avail / 2>/dev/null | tail -1 | tr -dc '0-9'); ROOT_AVAIL=${ROOT_AVAIL:-0}
  BEST_LINE=$(df -BG --output=source,avail,target 2>/dev/null | awk '$1 ~ /^\/dev\// && $3 != "/" && $3 !~ /^\/(boot|efi)/ {gsub(/G/,"",$2); print $2"\t"$3}' | sort -rn | head -1)
  BEST_GB="${BEST_LINE%%$'\t'*}"; BEST_MNT="${BEST_LINE#*$'\t'}"
  # 逻辑闸:候选盘要「明显更大」(>2× 根盘可用)才自动切,免得在两块差不多的盘之间抖
  if [ -n "$BEST_LINE" ] && [ "${BEST_GB:-0}" -gt $(( ROOT_AVAIL * 2 )) ]; then
    DATA_ROOT="$BEST_MNT/sema-data"
    log "多盘机:自动选大容量盘 $BEST_MNT(${BEST_GB}G avail,根盘 ${ROOT_AVAIL}G)→ 数据根 $DATA_ROOT(--data-root 可覆盖)"
  fi
fi

# ── 必需镜像的显式校验(在 dry-run/写 .env 之前,报错带下一步动作)────────────────────────────
# 数据面细分:外接第三方 PG(--pg-url)/S3(--s3-endpoint)时对应服务不本地部署
DATA_FACE=""
if [ "$PROFILE" = "data-center" ] || [ "$PROFILE" = "full-single" ]; then
  [ -z "$PG_URL" ] && DATA_FACE="pg"
  [ -z "$S3_ENDPOINT" ] && DATA_FACE="$DATA_FACE,s3"
  DATA_FACE="${DATA_FACE#,}"
fi
if [ -n "$S3_ENDPOINT" ] && { [ -z "$S3_ACCESS_KEY" ] || [ -z "$S3_SECRET_KEY" ]; }; then
  log "⛔ --s3-endpoint 需要配套 --s3-access-key/--s3-secret-key(S3 兼容协议的一对密钥)"; exit 1
fi
COMPOSE_PROFILES="$DATA_FACE"
case "$PROFILE" in full-single|local-plus) COMPOSE_PROFILES="$COMPOSE_PROFILES,agent" ;; esac
[ "$REGISTRY_ENABLED" = "true" ] && COMPOSE_PROFILES="$COMPOSE_PROFILES,registry"
[ "$GIT_MODE" = "provision" ] && COMPOSE_PROFILES="$COMPOSE_PROFILES,git"
COMPOSE_PROFILES="${COMPOSE_PROFILES#,}"; COMPOSE_PROFILES="${COMPOSE_PROFILES%,}"
ENVF="$WORK/.env"
has_env() { [ -f "$ENVF" ] && grep -q "^$1=..*" "$ENVF"; }
case ",$COMPOSE_PROFILES," in *,agent,*)
  if [ -z "${SEMA_SERVER_IMAGE:-}" ] && ! has_env SEMA_SERVER_IMAGE; then
    log "⛔ $PROFILE 档需要 sema-server 镜像。下一步:-f 配置里加 SEMA_SERVER_IMAGE=<registry>/sema-server:<tag>(默认=ghcr.io/clayboby/sema-server:<tag> 公开仓匿名可拉;CN 兜底=内网 Gitea 仓;SWR 已弃用);镜像分发=D4"
    exit 1
  fi ;;
esac
if [ "$REGISTRY_ENABLED" = "true" ] && [ -z "${SEMA_REGISTRY_IMAGE:-}" ] && ! has_env SEMA_REGISTRY_IMAGE; then
  log "⛔ registry=true(现在是各档默认)需要 sema-registry 镜像。下一步(三选一):"
  log "   ① --registry-image=ghcr.io/clayboby/sema-registry:<tag>(GHCR/Hub 公开仓,匿名可拉;SWR 已弃用)"
  log "   ② -f 配置里加 SEMA_REGISTRY_IMAGE=<你的仓>/sema-registry:<tag>"
  log "   ③ 不要配置中心:--registry=false"
  exit 1
fi

# ── dry-run:打印将执行计划,零改动退出 ───────────────────────────────────────────────────────
if $DRY_RUN; then
  {
    echo "══════ sema-up dry-run(不写文件、不起容器)══════"
    echo " profile=$PROFILE users=$USERS region=$REGION registry=$REGISTRY_ENABLED git=$GIT_MODE"
    echo " compose profiles: $COMPOSE_PROFILES(project=$PROJECT)"
    echo " 凭据文件: $ENVF $([ -f "$ENVF" ] && echo '(已存在:只补缺,已有凭据不轮换)' || echo '(将新建,0600)')"
    echo " 将执行:"
    echo "   1. 生成/补齐 .env(PG/MinIO/API token$([ "$REGISTRY_ENABLED" = "true" ] && printf ';registry 管理员/SESSION_SECRET/PULL_TOKEN')$([ "$GIT_MODE" = "provision" ] && printf ';Gitea 管理员'))"
    [ "$GIT_MODE" = "provision" ] && echo "   2. 先起 Gitea → 引导管理员+access token$([ "$REGISTRY_ENABLED" = "true" ] && printf '+OAuth app(接 registry 登录)')"
    [ -n "$PG_URL$S3_ENDPOINT" ] && echo "   2b. 外接端点连接检测(PG=真 select 1;S3=兼容协议 ls+建桶;证书不校验)"
    echo "   3. docker compose up -d --wait(profiles=$COMPOSE_PROFILES)$([ -n "$DATA_ROOT" ] && printf ';数据卷 bind → %s' "$DATA_ROOT")"
    case ",$COMPOSE_PROFILES," in *,s3,*) echo "   4. MinIO 建桶 workspaces/blobs/backups(幂等)" ;; esac
    [ "$GIT_MODE" = "connect" ] && echo "   +. 验证 $GIT_URL 连通(Gitea→GitHub→通用 git 探测链,证书不校验;Gitea=自动建 OAuth app,其余=打印待人工项)"
    echo "   5. 装后即验(PG/S3$([ "$PROFILE" != "data-center" ] && printf '/引擎 health')$([ "$REGISTRY_ENABLED" = "true" ] && printf '/registry healthz')$([ "$GIT_MODE" = "provision" ] && printf '/gitea healthz'))+ 一屏 summary"
    echo " 端口: $PF_PORTS(bind:数据面 $([ "$PROFILE" = "data-center" ] && echo 0.0.0.0 || echo 127.0.0.1);服务/registry/git 0.0.0.0)"
    echo " 卸载: $0 --project=$PROJECT --teardown(留数据)/--teardown-volumes(连卷删)"
    echo "═══════════════════════════════════════════════"
  } >&2
  exit 0
fi

# ── PG 调优(§3c pgtune 公式;SSD 假设)────────────────────────────────────────────────────────
# 基数=容器真正跑的那台机:docker 后端内存(macOS 上 VM 常远小于宿主——32G Mac 配 2G colima VM,
# 按宿主算出的 shared_buffers=8G 会让 PG 直接起不来);拿不到再退宿主探测
RAM_MB="$(docker info --format '{{.MemTotal}}' 2>/dev/null | awk '{printf "%d", $1/1048576}')"
[ -n "$RAM_MB" ] && [ "$RAM_MB" -gt 0 ] || RAM_MB="$(ram_mb)"
[ -n "$RAM_MB" ] && [ "$RAM_MB" -gt 0 ] || RAM_MB=4096
CPUS="$(docker info --format '{{.NCPU}}' 2>/dev/null)"
[ -n "$CPUS" ] && [ "$CPUS" -gt 0 ] || CPUS="$(ncpus)"
SB=$(( RAM_MB/4 )); [ $SB -gt 8192 ] && SB=8192           # shared_buffers=RAM/4 cap 8G
ECS=$(( RAM_MB*7/10 ))                                     # effective_cache_size=RAM×0.7
MWM=$(( RAM_MB/16 )); [ $MWM -gt 2048 ] && MWM=2048        # maintenance_work_mem cap 2G
WM=$(( (RAM_MB/4)*1024/100/4 )); [ $WM -lt 4096 ] && WM=4096  # work_mem(KB), max_conn=100
PGW=$(( CPUS/2 )); [ $PGW -gt 8 ] && PGW=8
PG_TUNE_FLAGS="-c max_connections=100 -c shared_buffers=${SB}MB -c effective_cache_size=${ECS}MB -c maintenance_work_mem=${MWM}MB -c work_mem=${WM}kB -c random_page_cost=1.1 -c effective_io_concurrency=200 -c wal_buffers=16MB -c checkpoint_completion_target=0.9 -c max_worker_processes=${CPUS} -c max_parallel_workers=${PGW} -c max_parallel_workers_per_gather=$(( PGW/2 > 0 ? PGW/2 : 1 ))"

# ── .env 生成(幂等:已存在则只补缺,凭据绝不轮换;0600) ───────────────────────────────────
genpw() { openssl rand -hex 24; }
touch "$ENVF"; chmod 600 "$ENVF"
setk() { grep -q "^$1=" "$ENVF" || printf '%s=%s\n' "$1" "$2" >> "$ENVF"; }
# setk_new:同 setk,但新写入时返回 0(summary 用来判断「密码只显示一次」)
setk_new() { if grep -q "^$1=" "$ENVF"; then return 1; else printf '%s=%s\n' "$1" "$2" >> "$ENVF"; return 0; fi; }
# resetk:非凭据键每次重写(翻转要落 .env,不然 teardown/汇总口径漂)
resetk() { grep -v "^$1=" "$ENVF" > "$ENVF.tmp" || true; mv "$ENVF.tmp" "$ENVF"; chmod 600 "$ENVF"; printf '%s=%s\n' "$1" "$2" >> "$ENVF"; }
setk SEMA_PROJECT "$PROJECT"
setk PG_PASSWORD "$(genpw)"
setk MINIO_PASSWORD "$(genpw)"
setk SERVICE_AUTH_TOKEN "$(genpw)"
setk PG_PORT "$PG_PORT"
setk MINIO_PORT "$MINIO_PORT"
setk MINIO_CONSOLE_PORT "$MINIO_CONSOLE_PORT"
setk SERVER_PORT "$SERVER_PORT"
# data-center 数据面对外服务(agent 在别处连);full-single 数据面仅回环、只有 server 对外
if [ "$PROFILE" = "data-center" ]; then setk PG_BIND "0.0.0.0"; setk MINIO_BIND "0.0.0.0"; else setk PG_BIND "127.0.0.1"; setk MINIO_BIND "127.0.0.1"; fi
# PG_TUNE_FLAGS 每次重算(规格可能变;非凭据,轮换无害);引号:source 与 compose env-file 双方兼容
grep -v '^PG_TUNE_FLAGS=' "$ENVF" > "$ENVF.tmp" && mv "$ENVF.tmp" "$ENVF"; chmod 600 "$ENVF"
printf 'PG_TUNE_FLAGS="%s"\n' "$PG_TUNE_FLAGS" >> "$ENVF"
if [ "$PROFILE" = "full-single" ] || [ "$PROFILE" = "local-plus" ]; then
  setk MODEL_GATEWAY_BASEURL "$GATEWAY"
  setk MODEL_ID "$MODEL"
  [ -n "$MODEL_KEY" ] && setk MODEL_API_KEY "$MODEL_KEY"
  setk REQUIRE_PRINCIPAL "$([ "$USERS" = "multi" ] && echo true || echo false)"
  setk SANDBOX_PKG_SOURCE "$([ "$REGION" = "global" ] && echo global || echo '')"
  [ -n "${SEMA_SERVER_IMAGE:-}" ] && setk SEMA_SERVER_IMAGE "$SEMA_SERVER_IMAGE"
  [ "${SANDBOX_LANE:-}" = "e2b-cloud" ] && setk REMOTE_EXEC e2b
fi
if [ "$PROFILE" = "local-plus" ]; then
  setk AGENT_PG_HOST "$AGENT_PG_HOST"
  setk AGENT_PG_PORT "${AGENT_PG_PORT:-5432}"
  setk AGENT_PG_USER "${AGENT_PG_USER:-sema}"
  setk AGENT_PG_DATABASE "${AGENT_PG_DATABASE:-sema}"
  setk AGENT_MINIO_ENDPOINT "$AGENT_MINIO_ENDPOINT"
  setk AGENT_MINIO_USER "${AGENT_MINIO_USER:-sema}"
  # local-plus 的 PG_PASSWORD/MINIO_PASSWORD=远端数据面凭据(覆盖上面生成的本地值——
  # 本档没有本地 pg/minio,生成值无用;显式传入才是真凭据)
  resetk PG_PASSWORD "$AGENT_PG_PASSWORD"
  resetk MINIO_PASSWORD "$AGENT_MINIO_PASSWORD"
fi

# 外接第三方数据面(--pg-url/--s3-endpoint;凭据属外部系统=用户可换,resetk 非 setk)
if [ -n "$PG_URL" ]; then
  PGP="$(PG_URL="$PG_URL" python3 -c 'import os,urllib.parse as u
p=u.urlsplit(os.environ["PG_URL"])
if p.scheme not in ("postgres","postgresql") or not p.hostname: raise SystemExit("bad")
print(p.hostname, p.port or 5432, p.username or "postgres", p.password or "", (p.path or "/postgres").lstrip("/"))' 2>/dev/null)" \
    || { log "⛔ --pg-url 解析失败(期望 postgres://user:pass@host:port/db):已按 *** 隐去凭据"; exit 1; }
  read -r EXT_PG_HOST EXT_PG_PORT EXT_PG_USER EXT_PG_PW EXT_PG_DB <<<"$PGP"
  resetk AGENT_PG_HOST "$EXT_PG_HOST"; resetk AGENT_PG_PORT "$EXT_PG_PORT"
  resetk AGENT_PG_USER "$EXT_PG_USER"; resetk AGENT_PG_DATABASE "$EXT_PG_DB"
  resetk PG_PASSWORD "$EXT_PG_PW"
fi
if [ -n "$S3_ENDPOINT" ]; then
  resetk AGENT_MINIO_ENDPOINT "$S3_ENDPOINT"
  resetk AGENT_MINIO_USER "$S3_ACCESS_KEY"
  resetk MINIO_PASSWORD "$S3_SECRET_KEY"
fi

# 档位落 .env(smoke/teardown 的口径来源;非凭据,重写)
resetk DEPLOY_PROFILE "$PROFILE"

# registry(sema-registry 配置中心)
resetk REGISTRY_ENABLED "$REGISTRY_ENABLED"
NEW_REG_PW=false
if [ "$REGISTRY_ENABLED" = "true" ]; then
  [ -n "${SEMA_REGISTRY_IMAGE:-}" ] && setk SEMA_REGISTRY_IMAGE "$SEMA_REGISTRY_IMAGE"
  setk_new REGISTRY_ADMIN_PASSWORD "$(genpw)" && NEW_REG_PW=true
  setk REGISTRY_SESSION_SECRET "$(genpw)"
  setk REGISTRY_PULL_TOKEN "$(genpw)"
  setk REGISTRY_PORT "$REGISTRY_PORT"
  setk REGISTRY_ADMIN_USER "${REGISTRY_ADMIN_USER:-zhaoheran}"
fi

# git 面
resetk GIT_MODE "$GIT_MODE"
NEW_GIT_PW=false
if [ "$GIT_MODE" = "provision" ]; then
  [ -n "${GITEA_IMAGE:-}" ] && setk GITEA_IMAGE "$GITEA_IMAGE"
  setk GIT_HTTP_PORT "$GIT_HTTP_PORT"
  setk GIT_SSH_PORT "$GIT_SSH_PORT"
  setk GIT_DOMAIN "$HOSTIP"
  setk GIT_ADMIN_USER "${GIT_ADMIN_USER:-sema}"
  setk_new GIT_ADMIN_PASSWORD "$(genpw)" && NEW_GIT_PW=true
elif [ "$GIT_MODE" = "connect" ]; then
  # resetk:connect 目标是外部系统,用户随时可换——setk 会让第一次填错的 URL 永久粘住,
  # 且检测段 source .env 时旧值会盖掉命令行新值(真踩)
  resetk GIT_URL "$GIT_URL"
  resetk GIT_TOKEN "$GIT_TOKEN"
fi

# DATA_ROOT 落定(首装写 .env 后为准——重跑换值会造出一套空卷,数据看似丢失)
if has_env DATA_ROOT_DIR; then
  _stored="$(grep '^DATA_ROOT_DIR=' "$ENVF" | head -1 | cut -d= -f2-)"
  if [ -n "$DATA_ROOT" ] && [ "$DATA_ROOT" != "$_stored" ]; then
    log "⚠ --data-root=$DATA_ROOT 与首装选定的 $_stored 不同——沿用首装值(换盘流程:--teardown-volumes 后迁数据重装)"
  fi
  DATA_ROOT="$_stored"
elif [ -n "$DATA_ROOT" ]; then
  setk DATA_ROOT_DIR "$DATA_ROOT"
fi
if [ -n "$DATA_ROOT" ]; then
  for d in pgdata miniodata registrydata gitdata; do mkdir -p "$DATA_ROOT/$d"; done
fi

command -v docker >/dev/null || { log "⛔ 未装 docker。下一步:curl -fsSL https://get.docker.com | sh(cn region 用镜像站,如 https://mirrors.aliyun.com/docker-ce)"; exit 1; }
compose() { ( cd "$HERE/stack" && dc --project-name "$PROJECT" --env-file "$ENVF" -f compose.yaml ${DATA_ROOT:+-f compose.dataroot.yaml} "$@" ); }

# ── 外接端点连接检测(装配前 fail-loud;只验兼容可用,不校验证书——内网自签是常态)────────────
if [ -n "$PG_URL" ]; then
  log "外接 PG 连接检测(host=$EXT_PG_HOST:$EXT_PG_PORT db=$EXT_PG_DB)"
  PGOK=$(docker run --rm "${PG_IMAGE:-postgres:17}" psql "$PG_URL" -tAc 'select 1' 2>&1 | tail -1)
  [ "$PGOK" = "1" ] || { log "⛔ 外接 PG 连不上:${PGOK:0:160}。下一步:核对 --pg-url(host 从容器可达?防火墙?);TLS 端点带 sslmode=require 试试"; exit 1; }
fi
if [ -n "$S3_ENDPOINT" ]; then
  log "外接 S3 连接检测($S3_ENDPOINT)"
  S3OK=$(docker run --rm --entrypoint sh -e MC_HOST_x="$(printf '%s' "$S3_ENDPOINT" | sed "s#://#://$S3_ACCESS_KEY:$S3_SECRET_KEY@#")" \
    "${MC_IMAGE:-minio/mc:latest}" -c 'mc --insecure ls x/ >/dev/null 2>&1 && mc --insecure mb --ignore-existing x/workspaces x/blobs x/backups >/dev/null 2>&1 && echo S3_OK' 2>/dev/null | tail -1)
  [ "$S3OK" = "S3_OK" ] || { log "⛔ 外接 S3 连不上或建桶失败。下一步:核对 endpoint/密钥;需要 S3 兼容协议(MinIO/OSS/COS/R2 等);证书问题已用 --insecure 忽略,若还不通多半是网络/密钥"; exit 1; }
  log "外接 S3 就绪(workspaces/blobs/backups 已确保)"
fi

# ── git=provision:先起 Gitea 并引导(OAuth app 的 client id/secret 要在 registry 起来之前
#    进 .env——compose 对 registry 的 env 插值发生在 up 时)────────────────────────────────────
GIT_MANUAL_NOTES=()
if [ "$GIT_MODE" = "provision" ]; then
  log "git 面:先起 Gitea(引导管理员/token/OAuth 要在 registry 之前)"
  COMPOSE_PROFILES=git compose up -d --wait git || { log "⛔ Gitea 起不来。下一步:docker compose -p $PROJECT logs git;镜像拉不动时 --gitea-image=<可达镜像仓>/gitea:1.24"; exit 1; }
  . "$ENVF"
  GIT_CTR="${PROJECT}-git-1"
  GIT_API="http://127.0.0.1:${GIT_HTTP_PORT}/api/v1"
  # 引导管理员(幂等:已存在即跳过;user 1000=镜像内 git 用户)
  if ! docker exec -u 1000 "$GIT_CTR" gitea admin user list 2>/dev/null | awk '{print $2}' | grep -qx "$GIT_ADMIN_USER"; then
    docker exec -u 1000 "$GIT_CTR" gitea admin user create --admin \
      --username "$GIT_ADMIN_USER" --password "$GIT_ADMIN_PASSWORD" \
      --email "${GIT_ADMIN_USER}@sema.local" --must-change-password=false >/dev/null \
      || { log "⛔ Gitea 管理员创建失败。下一步:docker compose -p $PROJECT logs git"; exit 1; }
    log "Gitea 管理员 $GIT_ADMIN_USER 已建"
  fi
  # 管理员 access token(只生成一次;丢了 .env 就换个名再生成,旧 token 在 Gitea 里可手动清)
  if ! has_env GIT_ADMIN_TOKEN; then
    TOK_NAME="sema-up-$(openssl rand -hex 3)"
    TOK=$(curl -m10 -s -u "$GIT_ADMIN_USER:$GIT_ADMIN_PASSWORD" -X POST "$GIT_API/users/$GIT_ADMIN_USER/tokens" \
      -H 'Content-Type: application/json' -d "{\"name\":\"$TOK_NAME\",\"scopes\":[\"all\"]}" \
      | python3 -c 'import json,sys
try: print(json.load(sys.stdin).get("sha1",""))
except Exception: print("")')
    [ -n "$TOK" ] || { log "⛔ Gitea access token 生成失败。下一步:curl -u <admin> $GIT_API/users/$GIT_ADMIN_USER/tokens 手查;或看 docker compose -p $PROJECT logs git"; exit 1; }
    setk GIT_ADMIN_TOKEN "$TOK"
  fi
  . "$ENVF"
  # registry 登录集成:自动建 OAuth app(真自动化,不是纸面;client secret 只在创建响应里给一次)
  if [ "$REGISTRY_ENABLED" = "true" ] && ! has_env REGISTRY_GITEA_OAUTH_ID; then
    REDIRECT="http://$HOSTIP:$REGISTRY_PORT/api/auth/oauth/callback"
    OAUTH=$(curl -m10 -s -H "Authorization: token $GIT_ADMIN_TOKEN" -X POST "$GIT_API/user/applications/oauth2" \
      -H 'Content-Type: application/json' \
      -d "{\"name\":\"sema-registry\",\"redirect_uris\":[\"$REDIRECT\"],\"confidential_client\":true}")
    OA_ID=$(printf '%s' "$OAUTH" | python3 -c 'import json,sys
try: print(json.load(sys.stdin).get("client_id",""))
except Exception: print("")')
    OA_SECRET=$(printf '%s' "$OAUTH" | python3 -c 'import json,sys
try: print(json.load(sys.stdin).get("client_secret",""))
except Exception: print("")')
    if [ -n "$OA_ID" ] && [ -n "$OA_SECRET" ]; then
      setk REGISTRY_GITEA_OAUTH_ID "$OA_ID"
      setk REGISTRY_GITEA_OAUTH_SECRET "$OA_SECRET"
      setk REGISTRY_GITEA_BASE_URL "http://$HOSTIP:$GIT_HTTP_PORT"
      log "Gitea→registry OAuth 登录已接(app=sema-registry)"
    else
      GIT_MANUAL_NOTES+=("Gitea OAuth app 自动创建失败——registry 仍可表单登录;手动接法:Gitea 设置→应用→新建 OAuth2(回调 $REDIRECT),把 client id/secret 写进 $ENVF 的 REGISTRY_GITEA_OAUTH_ID/SECRET+REGISTRY_GITEA_BASE_URL 后重跑本命令")
    fi
  fi
elif [ "$GIT_MODE" = "connect" ]; then
  . "$ENVF"
  # 连通验证(fail-loud:接不上就别继续,别部署完才发现 git 面是死的)。
  # 任意三方 git 都支持:探测链 Gitea API → GitHub API → 通用 git(ls-remote/HTTP 可达);
  # 证书一律不校验(-k / http.sslVerify=false)——内网自签是常态,验的是兼容可用性。
  GIT_KIND=""
  GT_VER=$(curl -k -m10 -s -H "Authorization: token $GIT_TOKEN" "$GIT_URL/api/v1/version" | python3 -c 'import json,sys
try: print(json.load(sys.stdin).get("version",""))
except Exception: print("")')
  if [ -n "$GT_VER" ]; then
    GIT_KIND="gitea"
    log "git=connect:Gitea $GT_VER 验证通过"
    if [ "$REGISTRY_ENABLED" = "true" ] && ! has_env REGISTRY_GITEA_OAUTH_ID; then
      REDIRECT="http://$HOSTIP:$REGISTRY_PORT/api/auth/oauth/callback"
      OAUTH=$(curl -k -m10 -s -H "Authorization: token $GIT_TOKEN" -X POST "$GIT_URL/api/v1/user/applications/oauth2" \
        -H 'Content-Type: application/json' \
        -d "{\"name\":\"sema-registry\",\"redirect_uris\":[\"$REDIRECT\"],\"confidential_client\":true}")
      OA_ID=$(printf '%s' "$OAUTH" | python3 -c 'import json,sys
try: print(json.load(sys.stdin).get("client_id",""))
except Exception: print("")')
      OA_SECRET=$(printf '%s' "$OAUTH" | python3 -c 'import json,sys
try: print(json.load(sys.stdin).get("client_secret",""))
except Exception: print("")')
      if [ -n "$OA_ID" ] && [ -n "$OA_SECRET" ]; then
        setk REGISTRY_GITEA_OAUTH_ID "$OA_ID"
        setk REGISTRY_GITEA_OAUTH_SECRET "$OA_SECRET"
        setk REGISTRY_GITEA_BASE_URL "$GIT_URL"
        log "已有 Gitea→registry OAuth 登录已接(app=sema-registry)"
      else
        GIT_MANUAL_NOTES+=("OAuth app 自动创建失败(token 可能缺 write:user scope)——手动接法:该 Gitea 设置→应用→新建 OAuth2(回调 $REDIRECT),把 client id/secret 写进 $ENVF 的 REGISTRY_GITEA_OAUTH_ID/SECRET+REGISTRY_GITEA_BASE_URL 后重跑本命令")
      fi
    fi
  elif printf '%s' "$GIT_URL" | grep -q 'github\.com'; then
    GH_CODE=$(curl -k -m10 -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $GIT_TOKEN" https://api.github.com/user)
    [ "$GH_CODE" = "200" ] || { log "⛔ GitHub token 验证失败(HTTP $GH_CODE)。下一步:检查 token 是否有效/未过期(需要 repo 读写 scope)"; exit 1; }
    GIT_KIND="github"
    log "git=connect:GitHub token 验证通过"
    GIT_MANUAL_NOTES+=("配置中心的 OAuth 登录只支持 Gitea——GitHub 仓可用但登录集成无法自动配;registry 用表单登录即可")
  else
    # 通用 git(GitLab/Bitbucket/裸 git-http 等):URL 是仓库→ls-remote 真验(token 两种常见姿势
    # 都试);URL 是服务根→HTTP 可达检(2xx/3xx/401/403 都算服务在,鉴权细节归 worker 侧)
    GIT_KIND="generic"
    LSOK=""
    for auth in "oauth2:$GIT_TOKEN" "$GIT_TOKEN"; do
      AUTH_URL=$(printf '%s' "$GIT_URL" | sed "s#://#://$auth@#")
      if GIT_TERMINAL_PROMPT=0 git -c http.sslVerify=false ls-remote "$AUTH_URL" >/dev/null 2>&1; then LSOK=1; break; fi
    done
    if [ -n "$LSOK" ]; then
      log "git=connect:通用 git 仓库 ls-remote 验证通过"
    else
      GH_CODE=$(curl -k -m10 -s -o /dev/null -w '%{http_code}' "$GIT_URL")
      case "$GH_CODE" in
        2*|3*|401|403) log "git=connect:通用 git 服务可达(HTTP $GH_CODE;URL 非单仓,未做 ls-remote 深验)" ;;
        *) log "⛔ $GIT_URL 连不上(HTTP ${GH_CODE:-000},Gitea/GitHub/通用 git 三种探测都失败)。下一步:核对地址与网络;若是仓库地址请带完整路径(…/owner/repo.git)"; exit 1 ;;
      esac
    fi
    GIT_MANUAL_NOTES+=("通用 git 已连(可达性/ls-remote 级验证);registry OAuth 登录只支持 Gitea,不自动配——token 已存 .env GIT_TOKEN 供 worker 侧使用")
  fi
  resetk GIT_KIND "$GIT_KIND"
fi

# ── 装配(Layer 1 compose;profiles 映射形态) ────────────────────────────────────────────────
log "装配:docker compose profiles=$COMPOSE_PROFILES project=$PROJECT"
COMPOSE_PROFILES="$COMPOSE_PROFILES" compose up -d --wait --remove-orphans \
  || { log "⛔ compose up 失败。下一步:docker compose -p $PROJECT logs(常见:镜像拉取失败→检查镜像引用/docker login;端口占用→--port 换口)"; exit 1; }
# 建桶(幂等,一次性动作归 Layer 0;只对本地 MinIO——外接 S3 已在连接检测时确保)
. "$ENVF"
case ",$COMPOSE_PROFILES," in *,s3,*)
  docker run --rm --network "${PROJECT}_default" -e MC_HOST_x="http://sema:${MINIO_PASSWORD}@minio:9000" "${MC_IMAGE:-minio/mc:latest}" mb --ignore-existing x/workspaces x/blobs x/backups >/dev/null || { log "⛔ MinIO 建桶失败。下一步:docker compose -p $PROJECT logs minio"; exit 1; } ;;
esac

# ── 验证(装后即验,§3d smoke 的子集;全量随时可跑 smoke.sh --project=$PROJECT)──────────────
. "$ENVF"
V_PG="n/a" V_BK="n/a"
case ",$COMPOSE_PROFILES," in *,pg,*)
  V_PG=$(docker exec "${PROJECT}-pg-1" psql -U sema -d sema -tAc "SHOW shared_buffers" 2>/dev/null || echo FAIL) ;;
esac
case ",$COMPOSE_PROFILES," in *,s3,*)
  V_BK=$(docker run --rm --network "${PROJECT}_default" -e MC_HOST_x="http://sema:${MINIO_PASSWORD}@minio:9000" "${MC_IMAGE:-minio/mc:latest}" ls x/ 2>/dev/null | wc -l | tr -d ' ') ;;
esac
V_HEALTH="n/a"
case ",$COMPOSE_PROFILES," in *,agent,*)
  V_HEALTH=$(curl -m5 -s "http://127.0.0.1:${SERVER_PORT}/health" | head -c 60 || echo FAIL) ;;
esac
V_REG="n/a"
if [ "$REGISTRY_ENABLED" = "true" ]; then
  V_REG=$(curl -m5 -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${REGISTRY_PORT}/api/healthz" || echo FAIL)
  case "$V_REG" in 2*) : ;; *) log "⛔ registry 未就绪(healthz HTTP $V_REG)。下一步:docker compose -p $PROJECT logs registry"; exit 1 ;; esac
fi
V_GIT="n/a"
if [ "$GIT_MODE" = "provision" ]; then
  V_GIT=$(curl -m5 -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${GIT_HTTP_PORT}/api/healthz" || echo FAIL)
  case "$V_GIT" in 2*) : ;; *) log "⛔ Gitea 未就绪(healthz HTTP $V_GIT)。下一步:docker compose -p $PROJECT logs git"; exit 1 ;; esac
fi

# ── 汇总(凭据打印一次;文件 0600 常驻;一屏读完)────────────────────────────────────────────
if [ "$MODE_INTERACTIVE" = "json" ]; then
  printf '{"type":"summary","profile":"%s","region":"%s","pg":"postgres://sema:***@%s:%s/sema","s3":"http://%s:%s","buckets":%s,"pgSharedBuffers":"%s","health":"%s","registry":"%s","git":"%s","credentialsFile":"%s"}\n' \
    "$PROFILE" "$REGION" "$HOSTIP" "$PG_PORT" "$HOSTIP" "$MINIO_PORT" "${V_BK:-0}" "$V_PG" "$V_HEALTH" \
    "$([ "$REGISTRY_ENABLED" = "true" ] && echo "http://$HOSTIP:$REGISTRY_PORT" || echo n/a)" \
    "$(case "$GIT_MODE" in provision) echo "http://$HOSTIP:$GIT_HTTP_PORT";; connect) echo "$GIT_URL";; *) echo n/a;; esac)" \
    "$ENVF" >&3
else
  {
    echo
    echo "══════════════ sema-up 完成(profile=$PROFILE)══════════════"
    case ",$COMPOSE_PROFILES," in *,pg,*)
      echo " PG        postgres://sema:<PG_PASSWORD>@$HOSTIP:$PG_PORT/sema(shared_buffers=$V_PG)" ;;
    esac
    [ -n "$PG_URL" ] && echo " PG(外接) $EXT_PG_HOST:$EXT_PG_PORT/$EXT_PG_DB(连接检测已过;凭据在 .env)"
    case ",$COMPOSE_PROFILES," in *,s3,*)
      echo " S3/MinIO  http://$HOSTIP:$MINIO_PORT(console :$MINIO_CONSOLE_PORT,桶×$V_BK:workspaces/blobs/backups)" ;;
    esac
    [ -n "$S3_ENDPOINT" ] && echo " S3(外接) $S3_ENDPOINT(连接检测+三桶已确保;密钥在 .env)"
    case ",$COMPOSE_PROFILES," in *,agent,*)
      echo " 引擎      http://$HOSTIP:$SERVER_PORT(/health: $V_HEALTH;API 凭据=SERVICE_AUTH_TOKEN)" ;;
    esac
    if [ "$REGISTRY_ENABLED" = "true" ]; then
      echo " 配置中心  http://$HOSTIP:$REGISTRY_PORT(healthz $V_REG)"
      if $NEW_REG_PW; then
        echo "           首登:${REGISTRY_ADMIN_USER:-zhaoheran} / ${REGISTRY_ADMIN_PASSWORD}(⚠ 密码只显示这一次,之后看 .env)"
      else
        echo "           首登:${REGISTRY_ADMIN_USER:-zhaoheran}(密码沿用上次生成,在 .env REGISTRY_ADMIN_PASSWORD)"
      fi
      [ -n "${REGISTRY_GITEA_BASE_URL:-}" ] && echo "           OAuth 登录:已接 ${REGISTRY_GITEA_BASE_URL}(也可表单登录)"
      echo "           service 对接=REGISTRY_PULL_TOKEN(在 .env)"
    fi
    if [ "$GIT_MODE" = "provision" ]; then
      echo " Git/Gitea http://$HOSTIP:$GIT_HTTP_PORT(healthz $V_GIT;ssh 端口 $GIT_SSH_PORT)"
      if $NEW_GIT_PW; then
        echo "           管理员:${GIT_ADMIN_USER} / ${GIT_ADMIN_PASSWORD}(⚠ 密码只显示这一次,之后看 .env)"
      else
        echo "           管理员:${GIT_ADMIN_USER}(密码沿用上次生成,在 .env GIT_ADMIN_PASSWORD)"
      fi
      echo "           API token 在 .env GIT_ADMIN_TOKEN;clone:git clone http://$HOSTIP:$GIT_HTTP_PORT/<owner>/<repo>.git"
    elif [ "$GIT_MODE" = "connect" ]; then
      echo " Git       已连接 $GIT_URL(token 在 .env GIT_TOKEN)"
    fi
    for n in ${GIT_MANUAL_NOTES[@]+"${GIT_MANUAL_NOTES[@]}"}; do
      echo " ⚠ 待人工  $n"
    done
    echo " 凭据      $ENVF(0600;全部密码/token 常驻此文件,不进日志)"
    echo " 数据卷    $(printf ',%s,' "$COMPOSE_PROFILES" | grep -q ',pg,' && printf '%s_pgdata ' "$PROJECT")$(printf ',%s,' "$COMPOSE_PROFILES" | grep -q ',s3,' && printf '%s_miniodata ' "$PROJECT")$([ "$REGISTRY_ENABLED" = "true" ] && printf '%s_registrydata ' "$PROJECT")$([ "$GIT_MODE" = "provision" ] && printf '%s_gitdata' "$PROJECT")$([ -n "$DATA_ROOT" ] && printf '(bind 落 %s)' "$DATA_ROOT")"
    echo " 重入      再跑同命令=幂等升级(凭据不轮换);验证=smoke.sh --project=$PROJECT"
    echo " 卸载      $0 --project=$PROJECT --teardown(留数据)/--teardown-volumes(连卷删)"
    [ "$PROFILE" = "data-center" ] && echo " 接入      别处 agent 连这个数据面 = sema-up.sh --profile=local-plus --pg-host=$HOSTIP --minio-endpoint=http://$HOSTIP:$MINIO_PORT + 两个密码(本机 .env 里)"
    echo "═══════════════════════════════════════════════════════════"
  } >&2
fi
