Ctrl K
ComunidadGuías…

update.sh

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
# update.sh — one-click update: pull latest + update LS binary + restart PM2
set -e

cd "$(dirname "$0")"

PORT="${PORT:-3003}"
NAME="${PM2_NAME:-windsurf-api}"

# Cross-process serialization for update.sh. mkdir is atomic on every platform
# supported by this script (including macOS hosts without flock). A second
# updater must fail before fetch/stash/reset rather than relying on git's index
# lock after rollback metadata and service lifecycle have already raced.
GIT_DIR_PATH="$(git rev-parse --git-dir)"
UPDATE_LOCK_DIR="$GIT_DIR_PATH/windsurfapi-update.lock"
UPDATE_LOCK_TOKEN="$$-$(date +%s)-${RANDOM}-${RANDOM}"
UPDATE_TARGET_TREE_TMP=""
UPDATE_TAG_LIST_TMP=""
UPDATE_IGNORED_ROOTS_TMP=""
UPDATE_IGNORED_PATHS_TMP=""
OWNED_STASH_OID=""
SERVICE_STOP_FAILED=0

lock_process_is_alive() {
  local candidate_pid="$1"
  if kill -0 "$candidate_pid" 2>/dev/null; then
    return 0
  fi
  # kill -0 also fails with EPERM when another user owns a live process. Only
  # recover when a second read-only process-table check proves the PID absent;
  # if ps is unavailable or inconclusive, stay fail-closed.
  if command -v ps >/dev/null 2>&1; then
    [ -n "$(ps -p "$candidate_pid" -o pid= 2>/dev/null || true)" ] && return 0
    return 1
  fi
  return 0
}

acquire_update_lock() {
  if mkdir "$UPDATE_LOCK_DIR" 2>/dev/null; then
    return 0
  fi
  # Recover only a lock whose numeric owner PID is provably gone. Missing,
  # malformed, inaccessible, or still-live ownership stays fail-closed.
  local lock_pid="" lock_token="" legacy="0"
  if [ -r "$UPDATE_LOCK_DIR/owner" ]; then
    read -r lock_pid lock_token < "$UPDATE_LOCK_DIR/owner" || true
    [[ "$lock_pid" =~ ^[0-9]+$ ]] || return 1
    [[ "$lock_token" =~ ^[A-Za-z0-9._-]+$ ]] || return 1
  elif [ -r "$UPDATE_LOCK_DIR/pid" ]; then
    IFS= read -r lock_pid < "$UPDATE_LOCK_DIR/pid" || true
    legacy="1"
    lock_token="legacy"
  else
    return 1
  fi
  [[ "$lock_pid" =~ ^[0-9]+$ ]] || return 1
  lock_process_is_alive "$lock_pid" && return 1

  # O_EXCL recovery claim closes the stale-lock ABA: exactly one contender may
  # rename the still-existing stale directory. A crash in this tiny recovery
  # window intentionally leaves a fail-closed claim for manual inspection.
  local claim_path="$UPDATE_LOCK_DIR/recovery"
  if ! ( set -o noclobber; printf '%s %s\n' "$$" "$UPDATE_LOCK_TOKEN" > "$claim_path" ) 2>/dev/null; then
    return 1
  fi
  local verify_pid="" verify_token="" verify_legacy="0"
  if [ -r "$UPDATE_LOCK_DIR/owner" ]; then
    read -r verify_pid verify_token < "$UPDATE_LOCK_DIR/owner" || true
  elif [ -r "$UPDATE_LOCK_DIR/pid" ]; then
    IFS= read -r verify_pid < "$UPDATE_LOCK_DIR/pid" || true
    verify_token="legacy"
    verify_legacy="1"
  fi
  if [ "$verify_pid" != "$lock_pid" ] || [ "$verify_token" != "$lock_token" ] || [ "$verify_legacy" != "$legacy" ]; then
    rm -f "$claim_path"
    return 1
  fi
  local quarantine="$UPDATE_LOCK_DIR.stale-$UPDATE_LOCK_TOKEN"
  if ! mv "$UPDATE_LOCK_DIR" "$quarantine" 2>/dev/null; then
    rm -f "$claim_path"
    return 1
  fi
  rm -f "$quarantine/recovery" "$quarantine/owner" "$quarantine/pid"
  rmdir "$quarantine" 2>/dev/null || true
  if mkdir "$UPDATE_LOCK_DIR" 2>/dev/null; then
    return 0
  fi
  return 1
}
if ! acquire_update_lock; then
  echo "✗ Another WindsurfAPI update is already in progress ($UPDATE_LOCK_DIR)"
  exit 1
fi
if ! ( set -o noclobber; printf '%s %s\n' "$$" "$UPDATE_LOCK_TOKEN" > "$UPDATE_LOCK_DIR/owner" ) 2>/dev/null; then
  rmdir "$UPDATE_LOCK_DIR" 2>/dev/null || true
  echo "✗ Could not initialize WindsurfAPI update lock ownership"
  exit 1
fi
if ! ( set -o noclobber; printf '%s\n' "$$" > "$UPDATE_LOCK_DIR/pid" ) 2>/dev/null; then
  rm -f "$UPDATE_LOCK_DIR/owner"
  rmdir "$UPDATE_LOCK_DIR" 2>/dev/null || true
  echo "✗ Could not initialize WindsurfAPI update lock compatibility PID"
  exit 1
fi
cleanup_update_lock() {
  local owner_pid="" owner_token=""
  [ -r "$UPDATE_LOCK_DIR/owner" ] || return 0
  read -r owner_pid owner_token < "$UPDATE_LOCK_DIR/owner" || true
  [ "$owner_pid" = "$$" ] && [ "$owner_token" = "$UPDATE_LOCK_TOKEN" ] || return 0
  rm -f "$UPDATE_LOCK_DIR/owner" "$UPDATE_LOCK_DIR/pid"
  rmdir "$UPDATE_LOCK_DIR" 2>/dev/null || true
}
cleanup_ignored_target_probe_files() {
  [ -n "$UPDATE_TARGET_TREE_TMP" ] && rm -f -- "$UPDATE_TARGET_TREE_TMP"
  [ -n "$UPDATE_IGNORED_ROOTS_TMP" ] && rm -f -- "$UPDATE_IGNORED_ROOTS_TMP"
  [ -n "$UPDATE_IGNORED_PATHS_TMP" ] && rm -f -- "$UPDATE_IGNORED_PATHS_TMP"
  UPDATE_TARGET_TREE_TMP=""
  UPDATE_IGNORED_ROOTS_TMP=""
  UPDATE_IGNORED_PATHS_TMP=""
  return 0
}
cleanup_update_probe_files() {
  cleanup_ignored_target_probe_files
  [ -n "$UPDATE_TAG_LIST_TMP" ] && rm -f -- "$UPDATE_TAG_LIST_TMP"
  UPDATE_TAG_LIST_TMP=""
  # An empty final path is a normal no-op. Keep the EXIT trap successful so a
  # completed update does not return 1 (and skip lock cleanup) under `set -e`.
  return 0
}
cleanup_update_state() {
  cleanup_update_probe_files
  cleanup_update_lock
}
trap cleanup_update_state EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM

check_ignored_target_conflicts() {
  local target_commit="$1"
  local -a target_paths=()
  local -a target_paths_folded=()
  local -a roots_folded=()
  local -a local_roots=()
  local -a local_pathspecs=()
  local -a conflicts=()
  local path="" folded="" root="" root_folded="" existing="" ignored="" ignored_root="" left="" right="" duplicate="0"
  local i=0

  cleanup_ignored_target_probe_files
  UPDATE_TARGET_TREE_TMP="$(mktemp "$GIT_DIR_PATH/windsurfapi-target-tree.XXXXXX")" || return 2
  UPDATE_IGNORED_ROOTS_TMP="$(mktemp "$GIT_DIR_PATH/windsurfapi-ignored-roots.XXXXXX")" || return 2
  UPDATE_IGNORED_PATHS_TMP="$(mktemp "$GIT_DIR_PATH/windsurfapi-ignored-paths.XXXXXX")" || return 2
  if ! git ls-tree -r -z --name-only "$target_commit" > "$UPDATE_TARGET_TREE_TMP"; then
    echo "    ! could not inspect the update target tree; refusing a destructive checkout"
    return 2
  fi
  while IFS= read -r -d '' path; do
    target_paths+=("$path")
    folded="$(printf '%s' "$path" | LC_ALL=C tr '[:upper:]' '[:lower:]')"
    target_paths_folded+=("$folded")
    root="${path%%/*}"
    root_folded="$(printf '%s' "$root" | LC_ALL=C tr '[:upper:]' '[:lower:]')"
    duplicate="0"
    for existing in "${roots_folded[@]}"; do
      [ "$existing" = "$root_folded" ] && duplicate="1" && break
    done
    if [ "$duplicate" = "0" ]; then
      roots_folded+=("$root_folded")
    fi
  done < "$UPDATE_TARGET_TREE_TMP"
  [ "${#target_paths[@]}" -gt 0 ] || return 0

  # Inventory ignored roots in collapsed form first. This avoids descending
  # into wholly ignored trees such as node_modules while discovering the real
  # on-disk spelling of roots whose case differs from the target. Do not trust
  # core.ignorecase here: it can be absent or stale on a case-insensitive FS.
  if ! git ls-files --others --ignored --exclude-standard --directory -z > "$UPDATE_IGNORED_ROOTS_TMP"; then
    echo "    ! could not inventory ignored runtime roots; refusing a destructive checkout"
    return 2
  fi
  while IFS= read -r -d '' ignored; do
    ignored="${ignored%/}"
    ignored_root="${ignored%%/*}"
    left="$(printf '%s' "$ignored_root" | LC_ALL=C tr '[:upper:]' '[:lower:]')"
    duplicate="0"
    for right in "${roots_folded[@]}"; do
      [ "$left" = "$right" ] || continue
      for existing in "${local_roots[@]}"; do
        [ "$existing" = "$ignored_root" ] && duplicate="1" && break
      done
      if [ "$duplicate" = "0" ]; then
        local_roots+=("$ignored_root")
        local_pathspecs+=(":(top,literal)$ignored_root")
      fi
      break
    done
  done < "$UPDATE_IGNORED_ROOTS_TMP"
  [ "${#local_roots[@]}" -gt 0 ] || return 0

  if ! git ls-files --others --ignored --exclude-standard -z -- "${local_pathspecs[@]}" > "$UPDATE_IGNORED_PATHS_TMP"; then
    echo "    ! could not inspect ignored runtime paths; refusing a destructive checkout"
    return 2
  fi
  while IFS= read -r -d '' ignored; do
    left="$(printf '%s' "$ignored" | LC_ALL=C tr '[:upper:]' '[:lower:]')"
    for ((i = 0; i < ${#target_paths_folded[@]}; i++)); do
      right="${target_paths_folded[$i]}"
      if [ "$left" = "$right" ] || [[ "$left" == "$right/"* ]] || [[ "$right" == "$left/"* ]]; then
        conflicts+=("$ignored")
        break
      fi
    done
  done < "$UPDATE_IGNORED_PATHS_TMP"

  if [ "${#conflicts[@]}" -gt 0 ]; then
    echo "    ! update target would overwrite ignored runtime data; move or back up these paths first:"
    local shown="0"
    for path in "${conflicts[@]}"; do
      echo "      $path"
      shown=$((shown + 1))
      [ "$shown" -ge 20 ] && break
    done
    return 1
  fi
  return 0
}

stable_tag_is_newer() {
  local left="${1#v}" right="${2#v}"
  local left_major="" left_minor="" left_patch=""
  local right_major="" right_minor="" right_patch=""
  IFS=. read -r left_major left_minor left_patch <<< "$left"
  IFS=. read -r right_major right_minor right_patch <<< "$right"
  local -a left_parts=("$left_major" "$left_minor" "$left_patch")
  local -a right_parts=("$right_major" "$right_minor" "$right_patch")
  local index="0" position="0" left_part="" right_part="" left_digit="" right_digit=""
  while [ "$index" -lt 3 ]; do
    left_part="${left_parts[$index]}"
    right_part="${right_parts[$index]}"
    if [ "${#left_part}" -ne "${#right_part}" ]; then
      [ "${#left_part}" -gt "${#right_part}" ]
      return
    fi
    if [ "$left_part" != "$right_part" ]; then
      position="0"
      while [ "$position" -lt "${#left_part}" ]; do
        left_digit="${left_part:$position:1}"
        right_digit="${right_part:$position:1}"
        if [ "$left_digit" != "$right_digit" ]; then
          [ "$left_digit" -gt "$right_digit" ]
          return
        fi
        position=$((position + 1))
      done
    fi
    index=$((index + 1))
  done
  return 1
}

is_git_object_id() {
  local oid="$1"
  [[ "$oid" =~ ^[0-9a-f]+$ ]] || return 1
  [ "${#oid}" -eq 40 ] || [ "${#oid}" -eq 64 ]
}

is_nonnegative_git_count() {
  local value="$1"
  [[ "$value" =~ ^(0|[1-9][0-9]*)$ ]] || return 1
  # Bash's `[` accepts decimal strings syntactically but emits `integer
  # expected` and returns false for values outside its signed integer range.
  # Every caller branches on the result, so accepting an oversized count here
  # would turn an unknown count into zero and could allow a destructive reset.
  # Keep the bound below both Bash's range and JavaScript's safe-integer limit,
  # and reject before any arithmetic comparison is attempted.
  [ "${#value}" -le 16 ] || return 1
  if [ "${#value}" -eq 16 ] && [ "$value" -gt 9007199254740991 ]; then
    return 1
  fi
  return 0
}

port_listener_probe() {
  local output="" rc=0
  if command -v ss >/dev/null 2>&1; then
    if ! output="$(ss -ltn 2>/dev/null)"; then
      return 2
    fi
    printf '%s\n' "$output" | grep -q ":$PORT "
    return $?
  fi
  if command -v lsof >/dev/null 2>&1; then
    lsof -nP -iTCP:"$PORT" -sTCP:LISTEN -t >/dev/null 2>&1
    rc=$?
    [ "$rc" -eq 0 ] && return 0
    [ "$rc" -eq 1 ] && return 1
    return 2
  fi
  return 2
}

pm2_process_is_live() {
  local pids="" pid="" found=""
  PM2_LIVE_PID=""
  if ! pids="$(pm2 pid "$NAME" 2>/dev/null)"; then
    return 1
  fi
  while IFS= read -r pid; do
    [[ "$pid" =~ ^[1-9][0-9]*$ ]] || continue
    if kill -0 "$pid" 2>/dev/null; then
      [ -z "$found" ] || return 1
      found="$pid"
    fi
  done <<< "$pids"
  [ -n "$found" ] || return 1
  PM2_LIVE_PID="$found"
  return 0
}

# A rollback must prove that the failed target is no longer represented by the
# named PM2 app before Git moves the checkout back. Real PM2 exits successfully
# and prints an empty result for a deleted name, while a stopped entry reports
# `0`. Any positive PID is still a PM2 record even when that OS process has just
# died: accepting it based on `kill -0` would race PM2's restart/accounting and
# could move Git underneath a process that comes back from the failed target.
pm2_named_process_is_absent() {
  local pids="" pid=""
  if ! pids="$(pm2 pid "$NAME" 2>/dev/null)"; then
    return 1
  fi
  while IFS= read -r pid; do
    [ -z "$pid" ] && continue
    [ "$pid" = "0" ] && continue
    return 1
  done <<< "$pids"
  return 0
}

health_response_matches_commit() {
  local body="$1" expected="$2" expected_pid="$3"
  [[ "$expected_pid" =~ ^[1-9][0-9]*$ ]] || return 1
  [[ "$expected" =~ ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ ]] || return 1
  if command -v node >/dev/null 2>&1; then
    printf '%s' "$body" | node -e '
      const fs = require("node:fs");
      let body;
      try { body = JSON.parse(fs.readFileSync(0, "utf8")); } catch { process.exit(1); }
      const expectedCommit = process.argv[1].toLowerCase();
      const expectedPid = Number(process.argv[2]);
      const actualCommit = typeof body?.commit === "string"
        ? body.commit.trim().toLowerCase()
        : "";
      const commitMatches = /^[0-9a-f]{7,64}$/.test(actualCommit)
        && (actualCommit === expectedCommit || expectedCommit.startsWith(actualCommit));
      if (!body || Array.isArray(body) || body.status !== "ok" || !commitMatches
          || !Number.isSafeInteger(body.pid) || body.pid !== expectedPid) process.exit(1);
    ' "$expected" "$expected_pid" >/dev/null 2>&1
    return $?
  fi
  if command -v jq >/dev/null 2>&1; then
    printf '%s' "$body" | jq -e --arg expected "$expected" --argjson expectedPid "$expected_pid" \
      'type == "object" and .status == "ok" and (.commit | type == "string") and
       ((.commit | ascii_downcase) as $actual |
         ($expected | ascii_downcase) as $wanted |
         ($actual | test("^[0-9a-f]{7,64}$")) and
         ($actual == $wanted or ($wanted | startswith($actual)))) and
       (.pid | type == "number") and .pid == $expectedPid' \
      >/dev/null 2>&1
    return $?
  fi
  return 1
}

if ! command -v pm2 >/dev/null 2>&1; then
  echo "✗ pm2 is required for a safe update; refusing to mutate Git or the LS installation"
  exit 1
fi

echo "=== [1/5] Pull latest ==="
# Fetch only the release branch here. Published-tag authority comes from
# `ls-remote` below; importing/pruning the remote tag namespace would either keep
# revoked local refs alive or destroy operator-owned local tags.
git fetch --quiet --no-tags origin master
BEFORE=$(git rev-parse HEAD)
REMOTE_HEAD=$(git rev-parse origin/master)
DIRTY=$(git status --porcelain)
FORCE_RESET="${WINDSURFAPI_UPDATE_FORCE_RESET:-0}"
FORCE_UPDATE="${WINDSURFAPI_UPDATE_FORCE:-0}"

# 版本门禁(tag gate): normal OTA installs the newest published tag, not
# origin/master HEAD. Post-tag release notes, generated assets, and work for
# the next release must not make the published release impossible to install.
# WINDSURFAPI_UPDATE_FORCE=1 explicitly opts into the untagged branch HEAD.
# Filter exact stable semver before choosing. The remote ref namespace is the
# publication authority: `git fetch --tags` never removes a revoked tag, so
# selecting from `git tag --list` can reinstall a release deleted upstream.
# Each candidate is fetched only into FETCH_HEAD, preserving all local tags.
LATEST_TAG=""
RELEASE_COMMIT=""
UPDATE_TAG_LIST_TMP="$(mktemp "$GIT_DIR_PATH/windsurfapi-release-tags.XXXXXX")"
if ! git ls-remote --tags origin 'refs/tags/v[0-9]*' > "$UPDATE_TAG_LIST_TMP"; then
  echo "    ! could not verify published release tags; refusing update"
  exit 1
fi

REMOTE_TAG_NAMES=()
REMOTE_TAG_OBJECTS=()
REMOTE_TAG_PEELED=()
while IFS= read -r advertised_line || [ -n "$advertised_line" ]; do
  if [[ "$advertised_line" != *$'\t'* ]] || [[ "${advertised_line#*$'\t'}" == *$'\t'* ]]; then
    echo "    ! remote tag enumeration returned a malformed record; refusing update"
    exit 1
  fi
  advertised_oid="${advertised_line%%$'\t'*}"
  advertised_ref="${advertised_line#*$'\t'}"
  if [ -z "$advertised_oid" ] || [ -z "$advertised_ref" ] \
      || ! is_git_object_id "$advertised_oid" || [[ "$advertised_ref" != refs/tags/* ]]; then
    echo "    ! remote tag enumeration returned a malformed record; refusing update"
    exit 1
  fi
  advertised_name="${advertised_ref#refs/tags/}"
  advertised_peeled="0"
  if [[ "$advertised_name" == *'^{}' ]]; then
    advertised_name="${advertised_name:0:${#advertised_name}-3}"
    advertised_peeled="1"
  fi
  if [ -z "$advertised_name" ] || [[ "$advertised_name" == .* ]] \
      || [[ "$advertised_name" == *. ]] || [[ "$advertised_name" == */ ]] \
      || [[ "$advertised_name" == *'..'* ]] || [[ "$advertised_name" == *'//'* ]] \
      || [[ "$advertised_name" == *'@{'* ]] || [[ "$advertised_name" =~ [[:space:]~^:?*\[\\] ]]; then
    echo "    ! remote tag enumeration returned an invalid ref name; refusing update"
    exit 1
  fi
  [[ "$advertised_name" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || continue

  advertised_index="-1"
  for index in "${!REMOTE_TAG_NAMES[@]}"; do
    if [ "${REMOTE_TAG_NAMES[$index]}" = "$advertised_name" ]; then
      advertised_index="$index"
      break
    fi
  done
  if [ "$advertised_peeled" = "1" ]; then
    if [ "$advertised_index" -lt 0 ] || [ -n "${REMOTE_TAG_PEELED[$advertised_index]:-}" ]; then
      echo "    ! remote tag enumeration returned an orphan/duplicate peeled ref; refusing update"
      exit 1
    fi
    REMOTE_TAG_PEELED[$advertised_index]="$advertised_oid"
  else
    if [ "$advertised_index" -ge 0 ]; then
      echo "    ! remote tag enumeration returned a duplicate tag ref; refusing update"
      exit 1
    fi
    REMOTE_TAG_NAMES+=("$advertised_name")
    REMOTE_TAG_OBJECTS+=("$advertised_oid")
    REMOTE_TAG_PEELED+=("")
  fi
done < "$UPDATE_TAG_LIST_TMP"

# Consider stable versions from newest to oldest, selecting the first whose
# exact remote object peels correctly and is reachable from origin/master.
REMOTE_TAG_USED=()
while true; do
  newest_index="-1"
  for index in "${!REMOTE_TAG_NAMES[@]}"; do
    [ "${REMOTE_TAG_USED[$index]:-0}" = "1" ] && continue
    if [ "$newest_index" -lt 0 ] || stable_tag_is_newer "${REMOTE_TAG_NAMES[$index]}" "${REMOTE_TAG_NAMES[$newest_index]}"; then
      newest_index="$index"
    fi
  done
  [ "$newest_index" -ge 0 ] || break
  REMOTE_TAG_USED[$newest_index]="1"

  candidate="${REMOTE_TAG_NAMES[$newest_index]}"
  expected_object="${REMOTE_TAG_OBJECTS[$newest_index]}"
  expected_peeled="${REMOTE_TAG_PEELED[$newest_index]:-}"
  if ! git fetch --quiet --no-tags origin "refs/tags/$candidate"; then
    echo "    ! could not fetch remote release tag $candidate; refusing update"
    exit 1
  fi
  if ! fetched_object=$(git rev-parse FETCH_HEAD 2>/dev/null) \
      || ! is_git_object_id "$fetched_object" || [ "$fetched_object" != "$expected_object" ]; then
    echo "    ! remote release tag $candidate changed during verification; refusing update"
    exit 1
  fi
  if ! fetched_commit=$(git rev-parse 'FETCH_HEAD^{commit}' 2>/dev/null) \
      || ! is_git_object_id "$fetched_commit" \
      || ! fetched_type=$(git cat-file -t "$fetched_object" 2>/dev/null); then
    echo "    ! remote release tag $candidate did not resolve to a verified commit; refusing update"
    exit 1
  fi
  if [ -n "$expected_peeled" ]; then
    if [ "$fetched_type" != "tag" ] || [ "$fetched_commit" != "$expected_peeled" ]; then
      echo "    ! annotated remote tag $candidate failed peeled-commit correspondence; refusing update"
      exit 1
    fi
  elif [ "$fetched_type" != "commit" ] || [ "$fetched_commit" != "$fetched_object" ]; then
    echo "    ! lightweight remote tag $candidate did not name one commit; refusing update"
    exit 1
  fi

  if git merge-base --is-ancestor "$fetched_commit" origin/master; then
    LATEST_TAG="$candidate"
    RELEASE_COMMIT="$fetched_commit"
    break
  else
    ancestor_status=$?
    if [ "$ancestor_status" -ne 1 ]; then
      echo "    ! could not verify whether $candidate is reachable from origin/master; refusing update"
      exit 1
    fi
  fi
done

if [ "$FORCE_UPDATE" = "1" ]; then
  TARGET="$REMOTE_HEAD"
elif [ -z "$LATEST_TAG" ]; then
  echo "    ! no stable release tag is reachable from origin/master; refusing to follow an unverified branch HEAD"
  echo "      set WINDSURFAPI_UPDATE_FORCE=1 only if you intentionally want origin/master"
  exit 1
else
  TARGET="$RELEASE_COMMIT"
fi
if [ -n "$LATEST_TAG" ]; then
  if ! UNRELEASED=$(git rev-list --count "${RELEASE_COMMIT}..${REMOTE_HEAD}" 2>/dev/null); then
    echo "    ! could not verify commits after the latest release; refusing update"
    exit 1
  fi
  if ! is_nonnegative_git_count "$UNRELEASED"; then
    echo "    ! Git returned an invalid release-distance count; refusing update"
    exit 1
  fi
  if [ "${UNRELEASED:-0}" -gt 0 ]; then
    echo "    i 远端有 ${UNRELEASED} 个未发布提交;本次只安装 ${LATEST_TAG}"
    if [ "$FORCE_UPDATE" = "1" ]; then
      echo "      WINDSURFAPI_UPDATE_FORCE=1:改为跟随 origin/master"
    fi
  fi
fi

if ! TO_TARGET=$(git rev-list --count "HEAD..${TARGET}" 2>/dev/null); then
  echo "    ! could not verify whether the target is ahead; refusing update"
  exit 1
fi
if ! is_nonnegative_git_count "$TO_TARGET"; then
  echo "    ! Git returned an invalid target-ahead count; refusing update"
  exit 1
fi
if ! PAST_TARGET=$(git rev-list --count "${TARGET}..HEAD" 2>/dev/null); then
  echo "    ! could not verify whether this checkout is past the target; refusing update"
  exit 1
fi
if ! is_nonnegative_git_count "$PAST_TARGET"; then
  echo "    ! Git returned an invalid target-past count; refusing update"
  exit 1
fi
if ! UNPUSHED=$(git rev-list --count "${REMOTE_HEAD}..HEAD" 2>/dev/null); then
  echo "    ! could not verify local commits against origin/master; refusing update"
  exit 1
fi
if ! is_nonnegative_git_count "$UNPUSHED"; then
  echo "    ! Git returned an invalid local-commit count; refusing update"
  exit 1
fi

if [ "${TO_TARGET:-0}" -gt 0 ] && [ "${PAST_TARGET:-0}" -gt 0 ]; then
  echo "    ! 当前 HEAD 与更新目标 ${TARGET:0:7} 已分叉;拒绝非 fast-forward 更新"
  exit 1
fi

RESET_TARGET="$TARGET"
if [ "$FORCE_RESET" = "1" ] && [ "${TO_TARGET:-0}" -eq 0 ] && [ "${PAST_TARGET:-0}" -gt 0 ] && [ "${UNPUSHED:-0}" -eq 0 ]; then
  RESET_TARGET="$BEFORE"
fi
MUTATION_TARGET=""
MUTATION_EXIT=0
if [ "$FORCE_RESET" = "1" ]; then
  MUTATION_TARGET="$RESET_TARGET"
elif [ "${TO_TARGET:-0}" -gt 0 ]; then
  MUTATION_TARGET="$TARGET"
fi
if [ -n "$MUTATION_TARGET" ]; then
  if ! check_ignored_target_conflicts "$MUTATION_TARGET"; then
    exit 1
  fi
fi

# ── D9 source-OTA attestation ─────────────────────────────────────────
# The selected release tag must carry a release-attestation.json whose commit
# equals the peeled tag commit, signed by the release workflow identity. This
# runs before any stash/reset/merge and before the service restart.
#   warn (default): missing attestation/bundle/cosign logs a warning and the
#                   update proceeds, so existing installs keep working;
#   required:       missing material refuses the update.
# A present but mismatched or invalid attestation is always fatal.
ATTESTATION_MODE="${WINDSURFAPI_UPDATE_ATTESTATION:-warn}"
case "$ATTESTATION_MODE" in
  warn|required) ;;
  *)
    echo "    ! invalid WINDSURFAPI_UPDATE_ATTESTATION='$ATTESTATION_MODE' (expected warn or required); refusing update"
    exit 1
    ;;
esac
if [ -n "$LATEST_TAG" ] && [ "$FORCE_UPDATE" != "1" ] \
    && { [ -z "$MUTATION_TARGET" ] || [ "$MUTATION_TARGET" = "$TARGET" ]; }; then
  ATTESTATION_REPOSITORY=""
  ORIGIN_URL="$(git remote get-url origin 2>/dev/null || true)"
  case "$ORIGIN_URL" in
    https://github.com/dwgx/WindsurfAPI|https://github.com/dwgx/WindsurfAPI.git|git@github.com:dwgx/WindsurfAPI.git|ssh://git@github.com/dwgx/WindsurfAPI.git)
      ATTESTATION_REPOSITORY="dwgx/WindsurfAPI"
      ;;
  esac
  ATTESTATION_ARGS=(--tag "$LATEST_TAG" --commit "$RELEASE_COMMIT" --mode "$ATTESTATION_MODE")
  [ -n "$ATTESTATION_REPOSITORY" ] && ATTESTATION_ARGS+=(--repository "$ATTESTATION_REPOSITORY")
  [ -n "${WINDSURFAPI_ATTESTATION_DIR:-}" ] && ATTESTATION_ARGS+=(--dir "$WINDSURFAPI_ATTESTATION_DIR")
  [ -n "${COSIGN:-}" ] && ATTESTATION_ARGS+=(--cosign "$COSIGN")
  if command -v node >/dev/null 2>&1 && [ -f scripts/verify-release-attestation.mjs ]; then
    if ! node scripts/verify-release-attestation.mjs "${ATTESTATION_ARGS[@]}"; then
      echo "    ! release attestation verification failed for $LATEST_TAG; refusing update before any Git mutation"
      exit 1
    fi
  elif [ "$ATTESTATION_MODE" = "required" ]; then
    echo "    ! node or scripts/verify-release-attestation.mjs is missing; WINDSURFAPI_UPDATE_ATTESTATION=required refuses update"
    exit 1
  else
    echo "    ! node or the release attestation verifier is missing; continuing because WINDSURFAPI_UPDATE_ATTESTATION=warn"
  fi
fi

if [ "$FORCE_RESET" = "1" ]; then
  if [ -n "$DIRTY" ]; then
    echo "    ! preserving local changes in a stash before forced reset"
    STASH_BEFORE=$(git for-each-ref --format='%(objectname)' refs/stash)
    STASH_MARKER="windsurfapi-pre-update-${UPDATE_LOCK_TOKEN}"
    git stash push --include-untracked -m "$STASH_MARKER"
    STASH_LISTING=$(git stash list --format='%H%x09%gs')
    OWNED_STASH_OID=$(printf '%s\n' "$STASH_LISTING" | awk -v marker="$STASH_MARKER" '
      index($0, marker) { count++; oid=$1 }
      END { if (count > 1) exit 2; if (count == 1) print oid }
    ')
    if [ -n "$OWNED_STASH_OID" ] && [[ ! "$OWNED_STASH_OID" =~ ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ ]]; then
      echo "    ! protective stash resolved to an invalid object ID; refusing reset"
      exit 1
    fi
    if [ -z "$OWNED_STASH_OID" ]; then
      STASH_AFTER=$(git for-each-ref --format='%(objectname)' refs/stash)
      if [ "$STASH_AFTER" != "$STASH_BEFORE" ]; then
        echo "    ! refs/stash changed but this updater could not identify its own stash; refusing reset"
        exit 1
      fi
      echo "    i no protective stash was created; the working tree was cleaned before stash"
    fi
  fi
  # force-reset normally means "clean my working tree and continue updating".
  # If this checkout already contains the release target and is itself on the
  # remote branch, resetting to the tag would be an accidental downgrade.
  echo "    ! forced reset to ${RESET_TARGET:0:7}"
  if git reset --hard "$RESET_TARGET"; then
    :
  else
    MUTATION_EXIT=$?
  fi
else
  if [ -n "$DIRTY" ] || [ "${UNPUSHED:-0}" -gt 0 ]; then
    echo "    ! local changes or commits detected; refusing destructive update"
    echo "      review them first, or set WINDSURFAPI_UPDATE_FORCE_RESET=1"
    exit 1
  fi
  if [ "${TO_TARGET:-0}" -gt 0 ]; then
    if git merge --ff-only --quiet "$TARGET"; then
      :
    else
      MUTATION_EXIT=$?
    fi
  fi
fi

if ! AFTER=$(git rev-parse HEAD); then
  echo "    ! could not verify HEAD after the Git mutation; refusing to continue"
  exit 1
fi
if [ -n "$MUTATION_TARGET" ]; then
  if ! TRACKED_AFTER=$(git status --porcelain --untracked-files=no --ignore-submodules=none); then
    echo "    ! could not verify tracked state after the Git mutation; refusing to continue"
    exit 1
  fi
  if [ "$AFTER" != "$MUTATION_TARGET" ] || [ -n "$TRACKED_AFTER" ]; then
    echo "    ! Git did not reach the expected clean target; refusing to report update success"
    echo "      expected: $MUTATION_TARGET"
    echo "      actual:   $AFTER"
    if [ -n "$TRACKED_AFTER" ]; then
      echo "      tracked changes remain:"
      printf '%s\n' "$TRACKED_AFTER" | head -20
    fi
    if [ -n "$OWNED_STASH_OID" ]; then
      echo "      protective stash remains at $OWNED_STASH_OID"
    fi
    exit 1
  fi
  if [ "$MUTATION_EXIT" -ne 0 ]; then
    echo "    ! Git returned exit $MUTATION_EXIT after reaching the verified clean target; continuing"
  fi
elif [ "$MUTATION_EXIT" -ne 0 ]; then
  echo "    ! Git mutation failed with exit $MUTATION_EXIT"
  exit "$MUTATION_EXIT"
fi
if [ "$BEFORE" = "$AFTER" ]; then
  echo "    已是最新 / Already up to date"
else
  echo "    $BEFORE → $AFTER"
  git log --oneline "$BEFORE..$AFTER" 2>/dev/null | head -10 || true
fi

echo ""
echo "=== [2/5] Update LS binary ==="
LS_PATH="${LS_BINARY_PATH:-/opt/windsurf/language_server_linux_x64}"
if [ -f .env ]; then
  _lp="$(awk '
    /^[[:space:]]*(export[[:space:]]+)?LS_BINARY_PATH[[:space:]]*=/ {
      sub(/^[[:space:]]*(export[[:space:]]+)?LS_BINARY_PATH[[:space:]]*=[[:space:]]*/, "")
      if (substr($0, 1, 1) != "\"" && substr($0, 1, 1) != "'\''") {
        sub(/[[:space:]]+#.*/, "")
      }
      sub(/[[:space:]]*$/, "")
      if ((substr($0, 1, 1) == "\"" && substr($0, length($0), 1) == "\"") ||
          (substr($0, 1, 1) == "'\''" && substr($0, length($0), 1) == "'\''")) {
        $0 = substr($0, 2, length($0) - 2)
      }
      print $0
      exit
    }
  ' .env 2>/dev/null || true)"
  [ -n "$_lp" ] && LS_PATH="$_lp"
fi
if [ ! -f install-ls.sh ]; then
  echo "    ! install-ls.sh not found; cannot update LS binary"
  exit 1
fi
echo "    Updating via install-ls.sh -> $LS_PATH"
if LS_INSTALL_PATH="$LS_PATH" bash install-ls.sh; then
  echo "    LS binary update finished"
else
  _ls_rc=$?
  if [ -s "$LS_PATH" ]; then
    echo "    ! LS binary update failed (exit $_ls_rc); keeping existing binary at $LS_PATH"
  else
    echo "    ! LS binary update failed and no existing binary exists at $LS_PATH"
    exit "$_ls_rc"
  fi
fi

echo ""
echo "=== [3/5] Stop service ==="
pm2 stop "$NAME" >/dev/null 2>&1 || true
pm2 delete "$NAME" >/dev/null 2>&1 || true
# Never fall back to a command-line substring kill here: another checkout or
# an unrelated service can also contain `src/index.js`. PM2 owns the named app;
# the remaining fallback is bounded to this deployment's configured port.
fuser -k "$PORT"/tcp >/dev/null 2>&1 || true
PORT_RELEASED=0
for i in $(seq 1 30); do
  # `set -e` must not turn the probe's meaningful 1 (free) or 2 (unknown)
  # result into an unverified process exit before the state machine handles it.
  if port_listener_probe; then
    probe_rc=0
  else
    probe_rc=$?
  fi
  if [ "$probe_rc" -eq 1 ]; then
    PORT_RELEASED=1
    break
  fi
  if [ "$probe_rc" -ne 0 ]; then
    echo "    ! could not verify whether service port $PORT is free; entering verified rollback"
    SERVICE_STOP_FAILED=1
    break
  fi
  sleep 1
done
if [ "$PORT_RELEASED" -ne 1 ]; then
  if [ "$SERVICE_STOP_FAILED" -eq 0 ]; then
    echo "    ! service port $PORT remains occupied after stop; entering verified rollback"
  fi
  SERVICE_STOP_FAILED=1
fi

echo ""
echo "=== [4/5] Start service ==="
PM2_START_EXIT=0
if [ "$SERVICE_STOP_FAILED" -eq 1 ]; then
  PM2_START_EXIT=125
else
  if pm2 start src/index.js --name "$NAME" --cwd "$(pwd)"; then
    pm2 save >/dev/null 2>&1 || true
    if ! pm2_process_is_live; then
      PM2_START_EXIT=126
      echo "    ! pm2 reported success but the named app has no live process; entering verified rollback"
    fi
  else
    PM2_START_EXIT=$?
    echo "    ! pm2 start failed with exit $PM2_START_EXIT; entering verified rollback"
  fi
fi

echo ""
echo "=== [5/5] Health check + rollback on failure ==="
# audit #5: `curl -sf ... | head` returned head's exit code (always 0), so a 500
# /health or an unbound port passed the check. Capture first — curl -sf's own
# exit code (nonzero on HTTP >=400 / connection failure) is now authoritative.
HEALTH_OUT=""
HEALTH_OK=0
if [ "$PM2_START_EXIT" -eq 0 ]; then
  sleep 3
  if HEALTH_OUT="$(curl -sf --connect-timeout 5 --max-time 15 "http://localhost:$PORT/health")" \
      && health_response_matches_commit "$HEALTH_OUT" "$AFTER" "$PM2_LIVE_PID"; then
    HEALTH_OK=1
  else
    HEALTH_OK=0
    echo "    ! /health did not identify the expected running commit ${AFTER:0:12}"
  fi
fi
if [ "$HEALTH_OK" = 1 ]; then
  echo "$HEALTH_OUT" | head -200
  echo ""
  echo ""
  echo "✓ Update complete. Dashboard: http://\$YOUR_IP:$PORT/dashboard"
else
  echo ""
  echo "✗ Health check failed after update. Auto-rolling back to $BEFORE ..."
  # Stop/delete the failed target before changing the checkout.  A bare
  # `pm2 start --name` retry is not a rollback: real PM2 can reject a duplicate
  # name, reuse the old process identity, or leave the target code resident in
  # memory after Git has moved back.  Keep the proof separate from the initial
  # stop gate so a health failure is audited again on its own path.
  ROLLBACK_SERVICE_STOP_FAILED=0
  pm2 stop "$NAME" >/dev/null 2>&1 || true
  pm2 delete "$NAME" >/dev/null 2>&1 || true
  if ! pm2_named_process_is_absent; then
    echo "    ! could not prove that the failed PM2 app $NAME is absent"
    ROLLBACK_SERVICE_STOP_FAILED=1
  fi
  fuser -k "$PORT"/tcp >/dev/null 2>&1 || true
  ROLLBACK_PORT_RELEASED=0
  for i in $(seq 1 30); do
    if port_listener_probe; then
      rollback_probe_rc=0
    else
      rollback_probe_rc=$?
    fi
    if [ "$rollback_probe_rc" -eq 1 ]; then
      ROLLBACK_PORT_RELEASED=1
      break
    fi
    if [ "$rollback_probe_rc" -ne 0 ]; then
      echo "    ! could not verify whether service port $PORT is free during rollback"
      ROLLBACK_SERVICE_STOP_FAILED=1
      break
    fi
    sleep 1
  done
  if [ "$ROLLBACK_PORT_RELEASED" -ne 1 ]; then
    if [ "$ROLLBACK_SERVICE_STOP_FAILED" -eq 0 ]; then
      echo "    ! service port $PORT remains occupied during rollback"
    fi
    ROLLBACK_SERVICE_STOP_FAILED=1
  fi
  if [ "$ROLLBACK_SERVICE_STOP_FAILED" -eq 0 ] && ! pm2_named_process_is_absent; then
    echo "    ! the failed PM2 app $NAME reappeared while waiting for its listener to stop"
    ROLLBACK_SERVICE_STOP_FAILED=1
  fi
  if [ "$ROLLBACK_SERVICE_STOP_FAILED" -eq 1 ]; then
    echo "✗ Automatic rollback refused before changing Git because the failed service could not be proven stopped."
    echo "  The failed target remains checked out at $AFTER; no stash was restored."
    if [ -n "$OWNED_STASH_OID" ]; then
      echo "  Protective stash remains at $OWNED_STASH_OID."
    fi
    exit 1
  fi
  if ! check_ignored_target_conflicts "$BEFORE"; then
    echo "✗ Automatic rollback refused because checking out $BEFORE could overwrite ignored owner data."
    echo "  The failed target remains checked out; preserve the reported paths and recover manually."
    if [ -n "$OWNED_STASH_OID" ]; then
      echo "  Protective stash remains at $OWNED_STASH_OID."
    fi
    exit 1
  fi

  ROLLBACK_MUTATION_EXIT=0
  if git reset --hard "$BEFORE"; then
    :
  else
    ROLLBACK_MUTATION_EXIT=$?
  fi
  if ! ROLLBACK_HEAD=$(git rev-parse HEAD); then
    echo "✗ Could not verify HEAD after automatic rollback; manual recovery required."
    [ -n "$OWNED_STASH_OID" ] && echo "  Protective stash remains at $OWNED_STASH_OID."
    exit 1
  fi
  if ! ROLLBACK_TRACKED=$(git status --porcelain --untracked-files=no --ignore-submodules=none); then
    echo "✗ Could not verify tracked state after automatic rollback; manual recovery required."
    [ -n "$OWNED_STASH_OID" ] && echo "  Protective stash remains at $OWNED_STASH_OID."
    exit 1
  fi
  if [ "$ROLLBACK_HEAD" != "$BEFORE" ] || [ -n "$ROLLBACK_TRACKED" ]; then
    echo "✗ Automatic rollback did not reach the expected clean target; manual recovery required."
    echo "  expected: $BEFORE"
    echo "  actual:   $ROLLBACK_HEAD"
    if [ -n "$ROLLBACK_TRACKED" ]; then
      echo "  tracked changes remain:"
      printf '%s\n' "$ROLLBACK_TRACKED" | head -20
    fi
    [ -n "$OWNED_STASH_OID" ] && echo "  Protective stash remains at $OWNED_STASH_OID."
    exit 1
  fi
  if [ "$ROLLBACK_MUTATION_EXIT" -ne 0 ]; then
    echo "    ! Git returned exit $ROLLBACK_MUTATION_EXIT after reaching the verified clean rollback target; continuing"
  fi

  STASH_RESTORE_OK=1
  if [ -n "$OWNED_STASH_OID" ]; then
    echo "    restoring exact protective stash ${OWNED_STASH_OID:0:12} (backup remains in git stash)"
    if ! git stash apply "$OWNED_STASH_OID" 2>/dev/null; then
      STASH_RESTORE_OK=0
      echo "    ! local changes were NOT restored; backup remains at $OWNED_STASH_OID"
    fi
  fi
  if pm2 start src/index.js --name "$NAME" --cwd "$(pwd)" >/dev/null 2>&1; then
    pm2 save >/dev/null 2>&1 || true
    if ! pm2_process_is_live; then
      ROLLBACK_PM2_START_EXIT=126
      echo "✗ Code rolled back to $BEFORE, but the named PM2 app has no live process; manual recovery required."
      exit 1
    fi
  else
    ROLLBACK_PM2_START_EXIT=$?
    echo "✗ Code rolled back to $BEFORE, but PM2 restart failed with exit $ROLLBACK_PM2_START_EXIT; manual recovery required."
    exit 1
  fi
  sleep 3
  if ROLLBACK_OUT="$(curl -sf --connect-timeout 5 --max-time 15 "http://localhost:$PORT/health")" \
      && health_response_matches_commit "$ROLLBACK_OUT" "$BEFORE" "$PM2_LIVE_PID"; then
    ROLLBACK_OK=1
  else
    ROLLBACK_OK=0
    echo "    ! rollback /health did not identify the expected commit ${BEFORE:0:12}"
  fi
  if [ "$ROLLBACK_OK" = 1 ]; then
    echo "$ROLLBACK_OUT" | head -200
    echo ""
    if [ "$STASH_RESTORE_OK" != 1 ]; then
      echo "✗ Code rolled back to $BEFORE and the service is healthy, but local changes still require manual recovery from $OWNED_STASH_OID."
      exit 1
    fi
    echo "✓ Rolled back to $BEFORE — service healthy. Check 'pm2 logs $NAME' for the original failure."
    exit 0
  fi
  echo "✗ Rollback also failed. Manual intervention required. Check 'pm2 logs $NAME'."
  exit 1
fi