#!/usr/bin/env bash

# Bash 5.3+ can deadlock writing heredoc pipes on macOS before the reader starts.
if [[ ${OSTYPE:-} == darwin* && $BASH != /bin/bash ]] && ((BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3))); then
  if (return 0 2>/dev/null); then
    printf '%s\n' 'Run this installer with /bin/bash on macOS instead of sourcing it.' >&2
    return 1
  fi
  case "${BASH_SOURCE[0]:-}" in
    ""|bash|-bash|/dev/stdin)
      # Bash reads piped scripts unbuffered; stdin now starts after this guard.
      OPENCLAW_INSTALLER_REEXEC_FILE="$(mktemp "${TMPDIR:-/tmp}/openclaw-installer.XXXXXX")" || exit 1
      export OPENCLAW_INSTALLER_REEXEC_FILE
      trap 'rm -f -- "$OPENCLAW_INSTALLER_REEXEC_FILE"' EXIT
      { printf '#!/bin/bash\n'; cat; } > "$OPENCLAW_INSTALLER_REEXEC_FILE" || exit 1
      exec /bin/bash "$OPENCLAW_INSTALLER_REEXEC_FILE" "$@"
      ;;
    *) exec /bin/bash "$0" "$@" ;;
  esac
fi

set -euo pipefail

# BEGIN GENERATED UPDATE NETWORK BUDGET
# Source: src/infra/update-network-budget.ts; regenerate: node scripts/generate-update-network-budget.mjs
UPDATE_NETWORK_TIMEOUT_SECONDS=300
# END GENERATED UPDATE NETWORK BUDGET

# The re-executed shell has the script open, so unlink its private copy now.
if [[ -n "${OPENCLAW_INSTALLER_REEXEC_FILE:-}" && "${BASH_SOURCE[0]:-}" == "$OPENCLAW_INSTALLER_REEXEC_FILE" ]]; then
  rm -f -- "$OPENCLAW_INSTALLER_REEXEC_FILE"
fi
unset OPENCLAW_INSTALLER_REEXEC_FILE

# OpenClaw CLI installer (non-interactive, no onboarding)
# Usage: curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install-cli.sh | bash -s -- [--json] [--prefix <path>] [--version <ver>] [--node-version <ver>] [--onboard]

ensure_home_env() {
  if [[ -n "${HOME:-}" && "${HOME}" != "/" && -d "${HOME}" ]]; then
    return 0
  fi

  local user_name=""
  local home_dir=""
  user_name="$(id -un 2>/dev/null || true)"

  if [[ -n "$user_name" ]]; then
    if command -v getent >/dev/null 2>&1; then
      home_dir="$(getent passwd "$user_name" 2>/dev/null | awk -F: '{print $6; exit}' || true)"
    fi
    if [[ -z "$home_dir" && "$(uname -s 2>/dev/null || true)" == "Darwin" ]] && command -v dscl >/dev/null 2>&1; then
      home_dir="$(dscl . -read "/Users/${user_name}" NFSHomeDirectory 2>/dev/null | awk '{print $2; exit}' || true)"
    fi
  fi

  if [[ -n "$home_dir" && "$home_dir" != "/" && -d "$home_dir" ]]; then
    export HOME="$home_dir"
  fi
}

ensure_home_env

# Track temp paths so fail/exit paths do not leak mktemp dirs/files.
# Register paths in the caller: command substitutions run in a subshell, so
# array mutations inside a helper would not reach this shell.
TMPFILES=()
WRAPPER_BACKUP_TARGET=""
WRAPPER_BACKUP_PATH=""
cleanup_tmpfiles() {
  if [[ -n "$WRAPPER_BACKUP_PATH" && ( -e "$WRAPPER_BACKUP_PATH" || -L "$WRAPPER_BACKUP_PATH" ) ]]; then
    rm -f "$WRAPPER_BACKUP_TARGET" 2>/dev/null || true
    mv "$WRAPPER_BACKUP_PATH" "$WRAPPER_BACKUP_TARGET" 2>/dev/null || true
  fi
  local f
  for f in "${TMPFILES[@]:-}"; do
    rm -rf "$f" 2>/dev/null || true
  done
}
trap cleanup_tmpfiles EXIT

resolve_home_path() {
  local input="$1"
  case "$input" in
    \~) echo "$HOME" ;;
    \~/*) echo "${HOME}${input:1}" ;;
    *) echo "$input" ;;
  esac
}

INSTALLER_CWD="$(pwd -P)"
resolve_installer_path() {
  local input
  input="$(resolve_home_path "$1")"
  case "$input" in
    "") echo "" ;;
    /*) echo "$input" ;;
    *) echo "${INSTALLER_CWD}/${input}" ;;
  esac
}

OPENCLAW_EFFECTIVE_HOME="$(resolve_home_path "${OPENCLAW_HOME:-$HOME}")"
PREFIX="${OPENCLAW_PREFIX:-${HOME}/.openclaw}"
OPENCLAW_VERSION="${OPENCLAW_VERSION:-latest}"
REQUIRED_COMPATIBLE_VERSION=""
DEFAULT_NODE_VERSION="24.21.0"
NODE_VERSION="${OPENCLAW_NODE_VERSION:-${DEFAULT_NODE_VERSION}}"
NODE_VERSION_REQUESTED=0
if [[ -n "${OPENCLAW_NODE_VERSION:-}" ]]; then
  NODE_VERSION_REQUESTED=1
fi
MIN_NODE_24_VERSION="24.16.0"
MIN_NODE_26_VERSION="26.1.0"
SUPPORTED_NODE_VERSION_LABEL="Node 24.16.0+ or Node 26.1.0+"
NODE_RELEASE_VERSION_CORE=""
APK_NODE_BIN_DIR="/usr/bin"
NPM_LOGLEVEL="${OPENCLAW_NPM_LOGLEVEL:-error}"
INSTALL_METHOD="${OPENCLAW_INSTALL_METHOD:-npm}"
GIT_DIR="${OPENCLAW_GIT_DIR:-${OPENCLAW_EFFECTIVE_HOME}/openclaw}"
GIT_UPDATE="${OPENCLAW_GIT_UPDATE:-1}"
JSON=0
RUN_ONBOARD=0
NODE_ONLY=0
RUNTIME_ONLY=0
SET_NPM_PREFIX=0
PNPM_CMD=()
GIT_REF_KIND=""
FRESH_GIT_MIN_FREE_KIB=$((6 * 1024 * 1024))

print_usage() {
  cat <<EOF
Usage: install-cli.sh [options]
  --json                              Emit NDJSON events (no human output)
  --prefix <path>                     Install prefix (default: ~/.openclaw; use \$OPENCLAW_PREFIX to override)
  --install-method, --method npm|git  Install via npm (default) or from a git checkout
  --npm                               Shortcut for --install-method npm
  --git, --github                     Shortcut for --install-method git
  --git-dir, --dir <path>             Checkout directory (default: ~/openclaw, or \$OPENCLAW_HOME/openclaw)
  --version <ver>                     OpenClaw version (default: latest)
  --compatible-with <ver>             Refuse a CLI that cannot modify config written by <ver>
  --node-version <ver>                Node version (default: 24.21.0)
  --node-only                         Install only a private Node runtime (no system package changes)
  --runtime-only                      Install CLI runtime without Gateway probes, service changes, or onboarding
  --onboard                           Run "openclaw onboard" after install
  --no-onboard                        Skip onboarding (default)
  --set-npm-prefix                    Force npm prefix to ~/.npm-global if current prefix is not writable (Linux)

Environment variables:
  OPENCLAW_NPM_LOGLEVEL=error|warn|notice  Default: error (hide npm deprecation noise)
  OPENCLAW_INSTALL_METHOD=git|npm
  OPENCLAW_HOME=...
  OPENCLAW_PREFIX=...
  OPENCLAW_VERSION=latest|next|<semver>
  OPENCLAW_GIT_DIR=...
  OPENCLAW_GIT_UPDATE=0|1
EOF
}

log() {
  if [[ "$JSON" -eq 0 ]]; then
    echo "$@"
  fi
}

DOWNLOADER=""
detect_downloader() {
  if command -v curl >/dev/null 2>&1; then
    DOWNLOADER="curl"
    return 0
  fi
  if command -v wget >/dev/null 2>&1; then
    DOWNLOADER="wget"
    return 0
  fi
  fail "Missing downloader (curl or wget required)"
}

download_file() {
  local url="$1"
  local output="$2"
  if [[ -z "$DOWNLOADER" ]]; then
    detect_downloader
  fi
  if [[ "$DOWNLOADER" == "curl" ]]; then
    # Bound connection and transfer stalls without a total download duration.
    curl -fsSL --proto '=https' --tlsv1.2 \
      --connect-timeout "$UPDATE_NETWORK_TIMEOUT_SECONDS" \
      --speed-limit 1 --speed-time "$UPDATE_NETWORK_TIMEOUT_SECONDS" \
      --retry 3 --retry-delay 1 --retry-connrefused \
      -o "$output" "$url"
    return
  fi
  wget -q --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout="$UPDATE_NETWORK_TIMEOUT_SECONDS" -O "$output" "$url"
}

cleanup_legacy_submodules() {
  local repo_dir="${1:-${OPENCLAW_GIT_DIR:-${OPENCLAW_EFFECTIVE_HOME}/openclaw}}"
  local legacy_dir="${repo_dir}/Peekaboo"
  if [[ -d "$legacy_dir" ]]; then
    emit_json step name legacy-submodule status start path "$legacy_dir"
    log "Removing legacy submodule checkout: ${legacy_dir}"
    rm -rf "$legacy_dir"
    emit_json step name legacy-submodule status ok path "$legacy_dir"
  fi
}

sha256_file() {
  local file="$1"
  if command -v sha256sum >/dev/null 2>&1; then
    sha256sum "$file" | awk '{print $1}'
    return 0
  fi
  if command -v shasum >/dev/null 2>&1; then
    shasum -a 256 "$file" | awk '{print $1}'
    return 0
  fi
  if command -v openssl >/dev/null 2>&1; then
    openssl dgst -sha256 "$file" | awk '{print $NF}'
    return 0
  fi
  fail "Missing sha256 tool (need sha256sum, shasum, or openssl)"
}

JSON_STRING=""
quote_json_string() {
  local value="${1:-}"
  local char code escaped index
  # Byte iteration escapes every C0 control without rewriting valid UTF-8 bytes.
  # The final emitter can therefore guarantee one physical line per event.
  local LC_ALL=C

  JSON_STRING='"'
  for ((index = 0; index < ${#value}; index++)); do
    char="${value:index:1}"
    case "$char" in
      '"') JSON_STRING+='\"' ;;
      \\) JSON_STRING+="\\\\" ;;
      *)
        printf -v code '%d' "'$char"
        # Bash 3.2 reports high UTF-8 bytes as negative integers, not C0 controls.
        if ((code >= 0 && code < 32)); then
          printf -v escaped '\\u%04x' "$code"
          JSON_STRING+="$escaped"
        else
          JSON_STRING+="$char"
        fi
        ;;
    esac
  done
  JSON_STRING+='"'
}

emit_json() {
  if [[ "$JSON" -ne 1 ]]; then
    return 0
  fi

  local event="$1"
  local key value output
  shift
  quote_json_string "$event"
  output="{\"event\":${JSON_STRING}"
  # `ok` is the installer's only non-string event field and belongs to done.
  if [[ "$event" == "done" ]]; then
    output+=',"ok":true'
  fi
  while [[ $# -gt 0 ]]; do
    key="$1"
    value="$2"
    shift 2
    quote_json_string "$key"
    output+=",${JSON_STRING}:"
    quote_json_string "$value"
    output+="$JSON_STRING"
  done
  printf '%s}\n' "$output"
}

fail() {
  local msg="$1"
  emit_json error message "$msg"
  log "ERROR: $msg"
  exit 1
}

fail_freebsd_source_install() {
  fail "Source/git installation is unsupported on FreeBSD. Use --install-method npm with a published version or compatible built .tgz package and the same --prefix. Keep pkg/Ports-managed installations with pkg or Ports."
}

prepare_tmpdir() {
  local base tmp fallback=0
  base="$(resolve_installer_path "${TMPDIR:-/tmp}")"
  if ! tmp="$(mktemp -d "${base%/}/openclaw-install.XXXXXX" 2>/dev/null)"; then
    if ! tmp="$(mktemp -d /tmp/openclaw-install.XXXXXX 2>/dev/null)"; then
      fail "Cannot create a temporary directory. Check permissions for TMPDIR and /tmp, then retry setup."
    fi
    fallback=1
  fi
  TMPFILES+=("$tmp")
  export TMPDIR="$tmp"
  if [[ "$fallback" -eq 1 ]]; then
    emit_json step name temporary-directory status warn reason using-fallback
    log "Using a private directory under /tmp because TMPDIR is unavailable."
  fi
}

require_bin() {
  local name="$1"
  if ! command -v "$name" >/dev/null 2>&1; then
    fail "Missing required binary: $name"
  fi
}

available_disk_kib() {
  local target="$1"
  df -Pk "$target" 2>/dev/null | awk 'NR == 2 { print $4; exit }' || true
}

preflight_fresh_git_disk_space() {
  local repo_dir="$1"
  local ancestor
  local available_kib
  local available_gib

  if [[ -d "$repo_dir/.git" ]]; then
    return 0
  fi

  emit_json step name disk-space status start
  ancestor="$repo_dir"
  while [[ ! -e "$ancestor" ]]; do
    local parent
    parent="$(dirname "$ancestor")"
    if [[ "$parent" == "$ancestor" ]]; then
      break
    fi
    ancestor="$parent"
  done
  if [[ ! -d "$ancestor" ]]; then
    ancestor="$(dirname "$ancestor")"
  fi

  available_kib="$(available_disk_kib "$ancestor")"
  if [[ ! "$available_kib" =~ ^[0-9]+$ ]]; then
    emit_json step name disk-space status warn reason unreadable
    return 0
  fi
  if ((available_kib < FRESH_GIT_MIN_FREE_KIB)); then
    available_gib="$(awk -v kib="$available_kib" 'BEGIN { printf "%.1f", kib / 1048576 }')"
    fail "Fresh Git installs require at least 6 GiB of free disk space; only ${available_gib} GiB is available. Free disk space and retry."
  fi
  emit_json step name disk-space status ok
}

has_sudo() {
  command -v sudo >/dev/null 2>&1
}

is_root() {
  [[ "$(id -u)" -eq 0 ]]
}

ensure_git() {
  if command -v git >/dev/null 2>&1; then
    emit_json step name git status ok
    return
  fi

  emit_json step name git status start
  log "Installing Git (required for npm installs)..."

  case "$(os_detect)" in
    linux)
      local -a git_cmd=()
      if command -v apt-get >/dev/null 2>&1; then
        git_cmd=(apt-get install -y git)
        if is_root; then
          apt-get update -y
        elif has_sudo; then
          sudo apt-get update -y
          git_cmd=(sudo "${git_cmd[@]}")
        else
          fail "Git missing and sudo unavailable. Install git and retry."
        fi
      elif command -v dnf >/dev/null 2>&1; then
        git_cmd=(dnf install -y git)
      elif command -v yum >/dev/null 2>&1; then
        git_cmd=(yum install -y git)
      elif command -v apk >/dev/null 2>&1; then
        git_cmd=(apk add --no-cache git)
      else
        fail "Git missing and package manager not found. Install git and retry."
      fi
      if [[ "${git_cmd[0]}" != "apt-get" && "${git_cmd[0]}" != "sudo" ]] && ! is_root; then
        has_sudo || fail "Git missing and sudo unavailable. Install git and retry."
        git_cmd=(sudo "${git_cmd[@]}")
      fi
      "${git_cmd[@]}"
      ;;
    freebsd)
      fail "Git missing. Ask the system administrator to install it with pkg install git, then retry."
      ;;
    darwin)
      if command -v brew >/dev/null 2>&1; then
        brew install git
      else
        fail "Git missing. Install Xcode Command Line Tools or Homebrew Git, then retry."
      fi
      ;;
  esac

  if ! command -v git >/dev/null 2>&1; then
    fail "Git install failed. Install git manually and retry."
  fi

  emit_json step name git status ok
}

parse_args() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --json)
        JSON=1
        shift
        ;;
      --prefix|--version|--compatible-with|--node-version|--install-method|--method|--git-dir|--dir)
        if [[ $# -lt 2 || "${2:-}" == --* ]]; then
          fail "Missing value for $1"
        fi
        case "$1" in
          --prefix) PREFIX="$2" ;;
          --version) OPENCLAW_VERSION="$2" ;;
          --compatible-with) REQUIRED_COMPATIBLE_VERSION="$2" ;;
          --node-version)
            NODE_VERSION="$2"
            NODE_VERSION_REQUESTED=1
            ;;
          --install-method|--method) INSTALL_METHOD="$2" ;;
          --git-dir|--dir) GIT_DIR="$2" ;;
        esac
        shift 2
        ;;
      --runtime-only)
        RUNTIME_ONLY=1
        shift
        ;;
      --node-only)
        NODE_ONLY=1
        shift
        ;;
      --npm)
        INSTALL_METHOD="npm"
        shift
        ;;
      --git|--github)
        INSTALL_METHOD="git"
        shift
        ;;
      --no-git-update)
        GIT_UPDATE=0
        shift
        ;;
      --onboard)
        RUN_ONBOARD=1
        shift
        ;;
      --no-onboard)
        RUN_ONBOARD=0
        shift
        ;;
      --help|-h)
        print_usage
        exit 0
        ;;
      --set-npm-prefix)
        SET_NPM_PREFIX=1
        shift
        ;;
      *)
        fail "Unknown option: $1"
        ;;
    esac
  done
}

os_detect() {
  local os
  os="$(uname -s)"
  case "$os" in
    Darwin) echo "darwin" ;;
    Linux) echo "linux" ;;
    FreeBSD) echo "freebsd" ;;
    *) fail "Unsupported OS: $os" ;;
  esac
}

arch_detect() {
  local arch
  arch="$(uname -m)"
  case "$arch" in
    arm64|aarch64) echo "arm64" ;;
    armv7|armv7l) echo "armv7l" ;;
    x86_64|amd64) echo "x64" ;;
    *) fail "Unsupported architecture: $arch" ;;
  esac
}

select_node_version_for_platform() {
  local os="$1"
  local arch="$2"
  if [[ "$os" == "linux" && "$arch" == "armv7l" ]]; then
    fail "Linux ARMv7 is unsupported: official Node 24+ binaries are unavailable. Use a 64-bit OS on compatible hardware or another supported host."
  fi
}

node_dir() {
  echo "${PREFIX}/tools/node-v${NODE_VERSION}"
}

node_bin() {
  echo "$(node_dir)/bin/node"
}

npm_bin() {
  echo "$(node_dir)/bin/npm"
}

is_installer_node_bin() {
  [[ "$1" -ef "$(node_dir)/bin" || "$1" -ef "${PREFIX}/tools/node/bin" ]]
}

command_path_without_node_prefix() {
  local name="$1"
  local exclude_active_runtime="${2:-0}"
  local path_entry
  local prefix_bin
  local filtered_path=""
  local separator=""
  local -a path_entries=()

  prefix_bin="$(node_dir)/bin"
  # The extra delimiter preserves a trailing (or sole) empty cwd entry.
  IFS=: read -r -a path_entries <<<"${PATH}:"
  for path_entry in "${path_entries[@]}"; do
    if [[ "$path_entry" == "$prefix_bin" ]] ||
      { [[ "$exclude_active_runtime" == "1" ]] && is_installer_node_bin "${path_entry:-.}"; }; then
      continue
    fi
    filtered_path="${filtered_path}${separator}${path_entry}"
    separator=":"
  done

  [[ -n "$separator" ]] || return 1
  PATH="$filtered_path" command -v "$name" 2>/dev/null
}

is_musl_linux() {
  if [[ "$(os_detect)" != "linux" ]]; then
    return 1
  fi
  if [[ -f /etc/alpine-release ]]; then
    return 0
  fi
  ldd --version 2>&1 | grep -qi musl
}

link_node_runtime_paths() {
  local node_path="$1"
  local npm_path="$2"
  local dir
  local runtime_bin
  local resolved
  # PATH entries resolve from this cwd; published links must work from any cwd.
  [[ "$node_path" == /* ]] || node_path="$PWD/$node_path"
  [[ "$npm_path" == /* ]] || npm_path="$PWD/$npm_path"
  dir="$(node_dir)"
  runtime_bin="${node_path%/*}"

  mkdir -p "${dir}/bin" "${PREFIX}/tools"
  ln -sfn "$node_path" "${dir}/bin/node"
  ln -sfn "$npm_path" "${dir}/bin/npm"
  for name in npx corepack; do
    if [[ -x "${runtime_bin}/${name}" ]]; then
      ln -sfn "${runtime_bin}/${name}" "${dir}/bin/${name}"
      continue
    fi
    # These optional tools cannot point through the alias we republish below.
    resolved="$(command_path_without_node_prefix "$name" 1 || true)"
    if [[ -n "$resolved" && "$resolved" != "${dir}/bin/${name}" ]]; then
      [[ "$resolved" == /* ]] || resolved="$PWD/$resolved"
      ln -sfn "$resolved" "${dir}/bin/${name}"
    fi
  done
  ln -sfn "$dir" "${PREFIX}/tools/node"
}

linked_node_is_usable() {
  local candidate_node="${1-$(node_bin)}"
  local candidate_npm="${2-$(npm_bin)}"
  local candidate_bin
  local current_version
  local required_version

  if [[ ! -x "$candidate_node" || ! -x "$candidate_npm" ]]; then
    return 1
  fi

  current_version="$("$candidate_node" -v 2>/dev/null || echo "")"
  required_version="$(required_node_version)"
  if ! node_release_version_is_supported "$current_version"; then
    return 1
  fi
  if ! semver_at_least "$NODE_RELEASE_VERSION_CORE" "$required_version"; then
    return 1
  fi
  candidate_bin="${candidate_node%/*}"
  if ! PATH="${candidate_bin}:${PATH}" "$candidate_npm" --version >/dev/null 2>&1; then
    return 1
  fi

  "$candidate_node" -e '
    const { DatabaseSync } = require("node:sqlite");
    const db = new DatabaseSync(":memory:");
    try {
      const value = db.prepare("SELECT sqlite_version() AS version").get()?.version;
      const match = typeof value === "string" ? /^(\d+)\.(\d+)\.(\d+)$/.exec(value) : null;
      const major = Number(match?.[1]);
      const minor = Number(match?.[2]);
      const patch = Number(match?.[3]);
      const safe =
        major > 3 ||
        (major === 3 &&
          (minor > 51 ||
            (minor === 51 && patch >= 3) ||
            (minor === 50 && patch >= 7) ||
            (minor === 44 && patch >= 6)));
      const text = "a\u0000b\u0000";
      const bytes = Buffer.from(text, "utf8");
      const json = JSON.stringify({ value: text });
      db.exec("CREATE TABLE probe (text_value TEXT, blob_value BLOB, json_value TEXT)");
      db.prepare("INSERT INTO probe VALUES (?, ?, ?)").run(text, bytes, json);
      const row = db.prepare("SELECT text_value, blob_value, json_value FROM probe").get();
      const textSafe = typeof row?.text_value === "string" && row.text_value.length === text.length && Buffer.from(row.text_value, "utf8").equals(bytes);
      const blobSafe = row?.blob_value instanceof Uint8Array && Buffer.from(row.blob_value).equals(bytes);
      const jsonSafe = row?.json_value === json && JSON.parse(row.json_value).value === text;
      if (!textSafe) {
        console.error("Node " + process.versions.node + ": node:sqlite truncates TEXT at embedded NUL (nodejs/node#61954); use 24.16+/26.1+ or a build with the fix");
      } else if (!blobSafe || !jsonSafe) {
        console.error("Node " + process.versions.node + ": node:sqlite NUL round-trip capability probe failed; use 24.16+/26.1+ or a build with the fix");
      } else if (!safe) {
        console.error("Node " + process.versions.node + ": SQLite " + value + " is not WAL-reset-safe");
      }
      if (!safe || !textSafe || !blobSafe || !jsonSafe) process.exitCode = 1;
    } finally {
      db.close();
    }
  ' --no-warnings >/dev/null
}

linked_node_sqlite_version() {
  local candidate_node="${1-$(node_bin)}"
  if [[ ! -x "$candidate_node" ]]; then
    printf 'unavailable\n'
    return
  fi
  local version
  version="$("$candidate_node" -e '
    const { DatabaseSync } = require("node:sqlite");
    const db = new DatabaseSync(":memory:");
    try {
      process.stdout.write(String(db.prepare("SELECT sqlite_version() AS version").get()?.version ?? "unknown"));
    } finally {
      db.close();
    }
  ' 2>/dev/null || true)"
  printf '%s\n' "${version:-unavailable}"
}

semver_at_least() {
  local version="${1#v}"
  local required="${2#v}"
  local version_major version_minor version_patch
  local required_major required_minor required_patch

  IFS=. read -r version_major version_minor version_patch <<<"$version"
  IFS=. read -r required_major required_minor required_patch <<<"$required"
  version_minor="${version_minor:-0}"
  version_patch="${version_patch:-0}"
  required_minor="${required_minor:-0}"
  required_patch="${required_patch:-0}"

  for part in "$version_major" "$version_minor" "$version_patch" "$required_major" "$required_minor" "$required_patch"; do
    if [[ ! "$part" =~ ^[0-9]+$ ]]; then
      return 1
    fi
  done

  if ((version_major != required_major)); then
    ((version_major > required_major))
    return
  fi
  if ((version_minor != required_minor)); then
    ((version_minor > required_minor))
    return
  fi
  ((version_patch >= required_patch))
}

parse_node_release_version() {
  local version="$1"
  local major minor patch

  NODE_RELEASE_VERSION_CORE=""
  while [[ "$version" == [[:space:]]* ]]; do version="${version#?}"; done
  while [[ "$version" == *[[:space:]] ]]; do version="${version%?}"; done
  if [[ ! "$version" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then
    return 1
  fi
  major="${BASH_REMATCH[1]}"
  minor="${BASH_REMATCH[2]}"
  patch="${BASH_REMATCH[3]}"
  for part in "$major" "$minor" "$patch"; do
    if ((${#part} > 16)) || ((${#part} == 16 && 10#$part > 9007199254740991)); then
      return 1
    fi
  done

  NODE_RELEASE_VERSION_CORE="${major}.${minor}.${patch}"
}

node_release_version_is_supported() {
  parse_node_release_version "$1" || return 1
  node_version_is_supported "$NODE_RELEASE_VERSION_CORE"
}

# Download labels are plain numeric Node distribution versions. Installed
# runtimes use node_release_version_is_supported, which accepts canonical
# release labels with a leading v or build metadata.
node_version_is_supported() {
  local version="${1#v}"
  local major minor patch

  IFS=. read -r major minor patch <<<"$version"
  minor="${minor:-0}"
  patch="${patch:-0}"
  for part in "$major" "$minor" "$patch"; do
    if [[ ! "$part" =~ ^[0-9]+$ ]]; then
      return 1
    fi
  done

  if ((major == 24)); then
    semver_at_least "$version" "$MIN_NODE_24_VERSION"
    return
  fi
  if ((major == 26)); then
    semver_at_least "$version" "$MIN_NODE_26_VERSION"
    return
  fi
  ((major > 26))
}

required_node_version() {
  if [[ "$NODE_VERSION_REQUESTED" == "1" ]] && node_version_is_supported "$NODE_VERSION"; then
    printf '%s\n' "$NODE_VERSION"
    return
  fi
  printf '%s\n' "$MIN_NODE_24_VERSION"
}

try_link_usable_node_runtime_from_path() {
  local path_entry
  local -a path_entries=()

  # The extra delimiter preserves a trailing (or sole) empty cwd entry.
  IFS=: read -r -a path_entries <<<"${PATH}:"
  for path_entry in "${path_entries[@]}"; do
    if [[ -z "$path_entry" ]]; then
      path_entry="."
    fi
    # Never publish links back into the runtime prefix being replaced.
    if is_installer_node_bin "$path_entry"; then
      continue
    fi
    if linked_node_is_usable "${path_entry}/node" "${path_entry}/npm"; then
      link_node_runtime_paths "${path_entry}/node" "${path_entry}/npm"
      return 0
    fi
  done
  return 1
}

install_alpine_node() {
  local installed_version
  local required_version
  local sqlite_version

  emit_json step name node status start method apk
  if try_link_usable_node_runtime_from_path; then
    installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
    emit_json step name node status ok method system version "$installed_version"
    return
  fi

  log "Installing Node via apk (Alpine Linux detected)..."
  if is_root; then
    apk add --no-cache nodejs npm
  elif has_sudo; then
    sudo apk add --no-cache nodejs npm
  else
    fail "Alpine Linux detected, but Node musl tarballs are unavailable and sudo is unavailable. Install nodejs and npm with apk, then retry."
  fi

  if [[ -x "${APK_NODE_BIN_DIR}/node" && -x "${APK_NODE_BIN_DIR}/npm" ]]; then
    # Failed package prerequisites must leave the prefix's existing runtime intact.
    if ! linked_node_is_usable "${APK_NODE_BIN_DIR}/node" "${APK_NODE_BIN_DIR}/npm"; then
      installed_version="$("${APK_NODE_BIN_DIR}/node" -v 2>/dev/null || echo unknown)"
      required_version="$(required_node_version)"
      sqlite_version="$(linked_node_sqlite_version "${APK_NODE_BIN_DIR}/node")"
      fail "Alpine Node package must provide Node >= ${required_version} with WAL-reset-safe SQLite 3.51.3+, 3.50.7+ within 3.50.x, or 3.44.6+ within 3.44.x; found Node ${installed_version}, SQLite ${sqlite_version}."
    fi
    link_node_runtime_paths "${APK_NODE_BIN_DIR}/node" "${APK_NODE_BIN_DIR}/npm"
  elif ! try_link_usable_node_runtime_from_path; then
    fail "apk Node install failed. Install nodejs and npm manually, then retry."
  fi

  installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
  emit_json step name node status ok method apk version "$installed_version"
}

set_pnpm_cmd() {
  PNPM_CMD=("$@")
}

run_pnpm() (
  local repo_dir="$PWD"
  if [[ "${1:-}" == "-C" ]]; then
    repo_dir="$2"
    shift 2
  fi
  cd "$repo_dir" || return 1
  # Pin nested commands and inherited roots only for this child. Corepack's
  # cold-cache prompt would otherwise wait invisibly in the version probe.
  env COREPACK_ENABLE_DOWNLOAD_PROMPT=0 PATH="${PNPM_CMD[0]%/*}:$PATH" \
    NPM_CONFIG_WORKSPACE_DIR="$PWD" npm_config_workspace_dir="$PWD" \
    PNPM_CONFIG_LOCKFILE_DIR="$PWD" pnpm_config_lockfile_dir="$PWD" \
    "${PNPM_CMD[@]}" "$@"
)

should_prefer_offline_pnpm_install() {
  local project_dir="${1:-$PWD}"
  [[ -z "${PNPM_CONFIG_PREFER_OFFLINE+x}" && -z "${pnpm_config_prefer_offline+x}" ]] || return 1
  local configured=""
  configured="$(run_pnpm -C "$project_dir" config get prefer-offline 2>/dev/null)" || return 1
  [[ -z "$configured" || "$configured" == "undefined" || "$configured" == "null" ]]
}

to_lowercase_ascii() {
  printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]'
}

is_openclaw_source_package_install_spec() {
  local value="${1:-}"
  local normalized_value=""
  normalized_value="$(to_lowercase_ascii "$value")"
  normalized_value="${normalized_value#openclaw@}"

  [[ "$normalized_value" == "main" ]] && return 0
  [[ "$normalized_value" =~ ^github:openclaw/openclaw($|[#/]) ]] && return 0

  normalized_value="${normalized_value#git+}"
  [[ "$normalized_value" =~ ^https?://github\.com/openclaw/openclaw(\.git)?($|[?#]) ]] && return 0
  [[ "$normalized_value" =~ ^ssh://git@github\.com[:/]openclaw/openclaw(\.git)?($|[?#]) ]] && return 0
  [[ "$normalized_value" =~ ^git://github\.com/openclaw/openclaw(\.git)?($|[?#]) ]] && return 0
  [[ "$normalized_value" =~ ^git@github\.com:openclaw/openclaw(\.git)?($|[?#]) ]] && return 0
  return 1
}

openclaw_version_is_compatible_with() {
  local candidate="$1"
  local config_writer="$2"

  "$(node_bin)" - "$candidate" "$config_writer" <<'NODE'
const candidateRaw = process.argv[2];
const writerRaw = process.argv[3];

function parse(raw) {
  let value = String(raw ?? "").trim();
  const legacyBeta = /^([vV]?\d+\.\d+\.\d+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(value);
  if (legacyBeta) {
    value = `${legacyBeta[1]}-beta${legacyBeta[2] ? `.${legacyBeta[2]}` : ""}`;
  }
  const match = /^[vV]?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/.exec(value);
  if (!match) return null;
  const parseIdentifiers = (rawIdentifiers) => {
    if (!rawIdentifiers) return [];
    const identifiers = rawIdentifiers.split(".");
    if (identifiers.some((identifier) => identifier.length === 0)) return null;
    return identifiers.map((identifier) => {
      if (!/^\d+$/.test(identifier)) return identifier;
      if (identifier.length > 1 && identifier.startsWith("0")) return null;
      return Number(identifier);
    });
  };
  const prerelease = parseIdentifiers(match[4]);
  const build = parseIdentifiers(match[5]);
  if (!prerelease || !build || prerelease.includes(null) || build.includes(null)) return null;
  return {
    core: [Number(match[1]), Number(match[2]), Number(match[3])],
    prerelease,
    build,
  };
}

function compareIdentifiers(left, right) {
  for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
    const a = left[index];
    const b = right[index];
    if (a === undefined) return -1;
    if (b === undefined) return 1;
    if (a === b) continue;
    if (typeof a === "number" && typeof b === "number") return a < b ? -1 : 1;
    if (typeof a === "number") return -1;
    if (typeof b === "number") return 1;
    return a < b ? -1 : 1;
  }
  return 0;
}

function isCorrection(version) {
  return version.prerelease.length === 1 && typeof version.prerelease[0] === "number";
}

function comparable(version) {
  if (!isCorrection(version)) return version;
  return { ...version, prerelease: [], build: [version.prerelease[0]] };
}

function compare(leftRaw, rightRaw) {
  const left = comparable(leftRaw);
  const right = comparable(rightRaw);
  for (let index = 0; index < left.core.length; index += 1) {
    if (left.core[index] !== right.core[index]) {
      return left.core[index] < right.core[index] ? -1 : 1;
    }
  }
  if (left.prerelease.length === 0 && right.prerelease.length > 0) return 1;
  if (right.prerelease.length === 0 && left.prerelease.length > 0) return -1;
  const prereleaseOrder = compareIdentifiers(left.prerelease, right.prerelease);
  return prereleaseOrder === 0 ? compareIdentifiers(left.build, right.build) : prereleaseOrder;
}

const candidate = parse(candidateRaw);
const writer = parse(writerRaw);
if (!candidate || !writer) process.exit(2);
const sameCore = candidate.core.every((part, index) => part === writer.core[index]);
if (sameCore && (writer.prerelease.length === 0 || isCorrection(writer))) process.exit(0);
process.exit(compare(candidate, writer) < 0 ? 1 : 0);
NODE
}

require_openclaw_version_compatible() {
  local candidate="$1"
  local config_writer="${REQUIRED_COMPATIBLE_VERSION:-}"
  if [[ -z "$config_writer" ]]; then
    return 0
  fi

  local status=0
  openclaw_version_is_compatible_with "$candidate" "$config_writer" || status=$?
  if [[ "$status" -eq 0 ]]; then
    return 0
  fi
  if [[ "$status" -eq 2 ]]; then
    fail "Cannot compare resolved OpenClaw version '${candidate}' with config writer '${config_writer}'."
  fi
  fail "OpenClaw ${candidate} is older than config writer ${config_writer}. Choose a newer CLI channel or retry after the channel is updated."
}

resolve_npm_openclaw_version() {
  local requested="$1"
  "$(npm_bin)" view "openclaw@${requested}" version 2>/dev/null | awk 'NF { value = $0 } END { print value }'
}

resolve_git_checkout_openclaw_version() {
  local repo_dir="$1"
  "$(node_bin)" -e '
    const fs = require("node:fs");
    const path = require("node:path");
    const value = JSON.parse(fs.readFileSync(path.join(process.argv[1], "package.json"), "utf8")).version;
    if (typeof value !== "string" || value.trim() === "") process.exit(1);
    process.stdout.write(value.trim());
  ' "$repo_dir"
}

resolve_git_openclaw_ref() {
  local requested="${OPENCLAW_VERSION:-latest}"
  local resolved_version=""

  case "$requested" in
    ""|latest|next|beta)
      resolved_version="$("$(npm_bin)" view "openclaw" "dist-tags.${requested:-latest}" 2>/dev/null || true)"
      if [[ -n "$resolved_version" ]]; then
        echo "v${resolved_version}"
      elif [[ -z "$requested" || "$requested" == "latest" ]]; then
        echo "main"
      else
        echo "$requested"
      fi
      return 0
      ;;
    [0-9]*.[0-9]*.[0-9]*)
      echo "v${requested}"
      return 0
      ;;
    *)
      echo "$requested"
      return 0
      ;;
  esac
}

verify_git_rebase_recovery() {
  local repo_dir="$1"
  local expected_head="$2"
  local expected_status="$3"
  local git_dir

  git_dir="$(git -C "$repo_dir" rev-parse --absolute-git-dir)" || return 1
  if [[ -d "$git_dir/rebase-merge" || -d "$git_dir/rebase-apply" ]]; then
    git -C "$repo_dir" rebase --abort >/dev/null 2>&1 || return 1
  fi

  [[ "$(git -C "$repo_dir" rev-parse --verify HEAD 2>/dev/null)" == "$expected_head" ]] &&
    [[ "$(git -C "$repo_dir" status --porcelain=v1 --untracked-files=all 2>/dev/null)" == "$expected_status" ]] &&
    [[ ! -d "$git_dir/rebase-merge" && ! -d "$git_dir/rebase-apply" ]]
}

checkout_git_openclaw_ref() {
  local repo_dir="$1"
  local ref="$2"
  local original_head=""
  local original_status=""
  local namespaces=(heads tags)

  GIT_REF_KIND=""

  if [[ -z "$ref" ]]; then
    return 0
  fi

  # Full commit IDs pin source bytes, even when a remote ref has the same name.
  # Bundled/existing checkouts already have the object and need no remote lookup.
  if [[ "$ref" =~ ^[[:xdigit:]]{40}$ ]]; then
    if ! git -C "$repo_dir" cat-file -e "$ref" 2>/dev/null; then
      git -C "$repo_dir" fetch --no-tags origin "$ref" ||
        fail "Could not fetch requested git commit: ${ref}"
    fi
    git -C "$repo_dir" rev-parse --verify --quiet "${ref}^{commit}" >/dev/null ||
      fail "Requested git version is not a commit: ${ref}"
    git -C "$repo_dir" checkout --detach "$ref"
    GIT_REF_KIND="immutable"
    return 0
  fi

  if [[ "$ref" == "main" ]]; then
    git -C "$repo_dir" fetch --no-tags origin "refs/heads/main:refs/remotes/origin/main"
    git -C "$repo_dir" checkout main
    if [[ "$GIT_UPDATE" == "1" ]]; then
      if ! original_head="$(git -C "$repo_dir" rev-parse --verify HEAD 2>/dev/null)"; then
        fail "Could not record repository state before updating from origin/main"
      fi
      if ! original_status="$(git -C "$repo_dir" status --porcelain=v1 --untracked-files=all 2>/dev/null)"; then
        fail "Could not record repository state before updating from origin/main"
      fi
      if ! git -C "$repo_dir" rebase origin/main; then
        if verify_git_rebase_recovery "$repo_dir" "$original_head" "$original_status"; then
          fail "Could not update repository from origin/main; the checkout was restored to its pre-update state"
        fi
        fail "Could not update repository from origin/main; checkout recovery was not verified. Run git -C \"$repo_dir\" rebase --abort and inspect the checkout before retrying"
      fi
    fi
    GIT_REF_KIND="moving"
    return 0
  fi

  # Normalized release selectors prefer immutable tags. A same-name branch
  # remains a fallback for operator-supplied v-prefixed branch names.
  if [[ "$ref" == v[0-9]* ]]; then
    namespaces=(tags heads)
  fi

  local namespace=""
  local probe_status=0
  for namespace in "${namespaces[@]}"; do
    if git -C "$repo_dir" ls-remote --exit-code origin "refs/${namespace}/${ref}" >/dev/null 2>&1; then
      if [[ "$namespace" == "heads" ]]; then
        git -C "$repo_dir" fetch --no-tags origin "refs/heads/${ref}:refs/remotes/origin/${ref}"
        git -C "$repo_dir" checkout -B "$ref" "origin/$ref"
        GIT_REF_KIND="moving"
      else
        git -C "$repo_dir" fetch --no-tags origin "refs/tags/${ref}:refs/tags/${ref}"
        git -C "$repo_dir" rev-parse --verify --quiet "refs/tags/${ref}^{commit}" >/dev/null ||
          fail "Requested git version is not a commit: ${ref}"
        git -C "$repo_dir" checkout --detach "refs/tags/${ref}"
        GIT_REF_KIND="immutable"
      fi
      return 0
    else
      probe_status=$?
    fi
    (( probe_status == 2 )) || fail "Could not resolve requested git ref: ${ref}"
  done

  fail "Requested git version not found: ${ref}"
}

git_install_lockfile_flag() {
  if [[ "$1" == "moving" ]]; then
    echo "--no-frozen-lockfile"
  else
    echo "--frozen-lockfile"
  fi
}

repo_pnpm_spec() {
  local repo_dir="$1"
  local package_json="${repo_dir}/package.json"

  if [[ ! -f "$package_json" ]]; then
    return 1
  fi

  "$(node_bin)" -e 'const fs = require("node:fs"); const pkg = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); if (typeof pkg.packageManager === "string") process.stdout.write(pkg.packageManager);' "$package_json"
}


install_node() {
  # Packaging provisions each requested architecture in a fresh private prefix.
  # It must execute that Node (Rosetta for x64 on ARM), never link the host runtime.
  local os="$1"
  local arch="$2"
  local url
  local tmp
  local dir
  local base_url
  local tarball
  local expected_sha
  local actual_sha

  select_node_version_for_platform "$os" "$arch"
  if ! node_version_is_supported "$NODE_VERSION"; then
    fail "Node ${NODE_VERSION} is unsupported; use ${SUPPORTED_NODE_VERSION_LABEL}."
  fi
  dir="$(node_dir)"

  if [[ "$os" == "freebsd" ]]; then
    if [[ "$NODE_ONLY" -eq 1 ]]; then
      fail "Private Node.js recovery is unavailable on FreeBSD. Update Node.js and npm with pkg, then retry."
    fi
    local system_node system_npm installed_version
    system_node="$(command_path_without_node_prefix node || true)"
    system_npm="$(command_path_without_node_prefix npm || true)"
    emit_json step name node status start method system
    # FreeBSD has no official Node binary archive. Validate the package-owned
    # runtime before publishing links so a failed prerequisite leaves the CLI intact.
    if ! linked_node_is_usable "$system_node" "$system_npm"; then
      fail "FreeBSD requires ${SUPPORTED_NODE_VERSION_LABEL}, working npm, and WAL-reset-safe SQLite. Ask the system administrator to install or update node24 and npm-node24 with pkg, then retry with node and npm on PATH."
    fi
    system_node="$("$system_node" -p 'process.execPath')"
    system_npm="$("$system_node" -p 'require("node:fs").realpathSync(process.argv[1])' "$system_npm")"
    link_node_runtime_paths "$system_node" "$system_npm"
    installed_version="$("$(node_bin)" -v)"
    emit_json step name node status ok method system version "$installed_version"
    return
  fi

  if [[ "$os" == "linux" ]] && command -v apk >/dev/null 2>&1 && is_musl_linux; then
    install_alpine_node
    return
  fi

  if linked_node_is_usable; then
    ln -sfn "$dir" "${PREFIX}/tools/node"
    emit_json step name node status skip path "$dir"
    return
  fi

  emit_json step name node status start version "$NODE_VERSION"
  log "Installing Node ${NODE_VERSION} (user-space)..."

  mkdir -p "${PREFIX}/tools"
  # Darwin's default mktemp location can ignore TMPDIR; use the prepared path explicitly.
  tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-node.XXXXXX")"
  TMPFILES+=("$tmp")
  base_url="https://nodejs.org/dist/v${NODE_VERSION}"
  tarball="node-v${NODE_VERSION}-${os}-${arch}.tar.gz"
  url="${base_url}/${tarball}"

  detect_downloader
  require_bin tar

  download_file "${base_url}/SHASUMS256.txt" "$tmp/SHASUMS256.txt"
  expected_sha="$(grep "  ${tarball}$" "$tmp/SHASUMS256.txt" | awk '{print $1}' | head -n 1 || true)"
  if [[ -z "${expected_sha}" ]]; then
    fail "Failed to resolve Node shasum for ${tarball}"
  fi

  download_file "$url" "$tmp/node.tgz"
  actual_sha="$(sha256_file "$tmp/node.tgz")"
  if [[ "$actual_sha" != "$expected_sha" ]]; then
    fail "Node tarball sha256 mismatch for ${tarball} (expected ${expected_sha}, got ${actual_sha})"
  fi

  rm -rf "$dir"
  mkdir -p "$dir"
  tar -xzf "$tmp/node.tgz" -C "$dir" --strip-components=1
  rm -rf "$tmp"

  if ! linked_node_is_usable; then
    local installed_version
    local required_version
    local sqlite_version
    installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
    required_version="$(required_node_version)"
    sqlite_version="$(linked_node_sqlite_version)"
    fail "Installed Node ${NODE_VERSION} must provide Node >= ${required_version} with WAL-reset-safe SQLite; found Node ${installed_version}, SQLite ${sqlite_version}. Re-run with --node-version 24.21.0 (or newer)"
  fi
  # Existing CLI wrappers use this alias; activate only a runtime that can start.
  ln -sfn "$dir" "${PREFIX}/tools/node"
  emit_json step name node status ok version "$NODE_VERSION"
}

ensure_pnpm() {
  local repo_dir="${1:-$PWD}"
  local spec version pnpm_dir corepack_cmd="" npm_cmd lifecycle_arg selected_version
  spec="$(repo_pnpm_spec "$repo_dir" || true)"
  [[ "$spec" == pnpm@* ]] || spec="pnpm@12.5.1"
  version="${spec#pnpm@}"
  version="${version%%+*}"
  pnpm_dir="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-pnpm.XXXXXX")" || return 1
  TMPFILES+=("$pnpm_dir")
  if [[ -x "$(node_dir)/bin/corepack" ]]; then
    corepack_cmd="$(node_dir)/bin/corepack"
  else
    corepack_cmd="$(command -v corepack || true)"
  fi
  if [[ -n "$corepack_cmd" ]]; then
    emit_json step name pnpm status start method corepack
    log "Selecting repo pnpm ${version} via Corepack..."
    set_pnpm_cmd "$pnpm_dir/pnpm"
    if "$corepack_cmd" enable --install-directory "$pnpm_dir" pnpm &&
      selected_version="$(run_pnpm -C "$repo_dir" --version 2>/dev/null)" &&
      [[ "$selected_version" == "$version" ]]; then
      emit_json step name pnpm status ok
      return 0
    fi
    log "Corepack could not provision pnpm; falling back to npm."
  fi

  emit_json step name pnpm status start method npm
  log "Installing pnpm ${version} via npm..."
  npm_cmd="$(npm_bin)"
  lifecycle_arg="$(npm_lifecycle_allow_arg "$npm_cmd" "pnpm@${version}" "$repo_dir" "pnpm@${version}")" || return 1
  # The explicit npm prefix owns this executable; never rediscover ambient pnpm.
  "$npm_cmd" install -g --prefix "$pnpm_dir/npm" "pnpm@${version}" ${lifecycle_arg:+"$lifecycle_arg"} || return 1
  set_pnpm_cmd "$pnpm_dir/npm/bin/pnpm"
  if [[ ! -x "${PNPM_CMD[0]}" ]] || ! selected_version="$(run_pnpm -C "$repo_dir" --version 2>/dev/null)" || [[ "$selected_version" != "$version" ]]; then
    fail "Could not provision pnpm ${version} for ${repo_dir}"
  fi
  emit_json step name pnpm status ok
}

fix_npm_prefix_if_needed() {
  # only meaningful on Linux, non-root installs
  if [[ "$(os_detect)" != "linux" ]]; then
    return
  fi

  local prefix
  prefix="$("$(npm_bin)" config get prefix 2>/dev/null || true)"
  if [[ -z "$prefix" ]]; then
    return
  fi

  if [[ -w "$prefix" || -w "${prefix}/lib" ]]; then
    return
  fi

  local target="${HOME}/.npm-global"
  mkdir -p "$target"
  "$(npm_bin)" config set prefix "$target"

  local path_line="export PATH=\"${target}/bin:\$PATH\""
  for rc in "${HOME}/.bashrc" "${HOME}/.zshrc"; do
    if [[ -f "$rc" ]] && ! grep -q ".npm-global" "$rc"; then
      echo "$path_line" >> "$rc"
    fi
  done

  export PATH="${target}/bin:${PATH}"
  emit_json step name npm-prefix status ok prefix "$target"
  log "Configured npm prefix to ${target}"
}

resolve_npm_config_path() {
  local raw="$1"
  if [[ -z "$raw" || "$raw" == "null" || "$raw" == "undefined" ]]; then
    return 1
  fi
  if [[ "$raw" == \~/* && -n "${HOME:-}" ]]; then
    printf '%s\n' "${HOME}/${raw#"~/"}"
    return 0
  fi
  if [[ "$raw" == "\${HOME}/"* && -n "${HOME:-}" ]]; then
    printf '%s\n' "${HOME}/${raw#"\${HOME}/"}"
    return 0
  fi
  printf '%s\n' "$raw"
}

npm_config_file_has_key() {
  local file="$1"
  local key="$2"
  [[ -f "$file" ]] || return 1
  grep -Eiq "^[[:space:]]*${key}[[:space:]]*=" "$file"
}

npm_command_path() {
  local npm_cmd="$1"
  local npm_path="$npm_cmd"
  if [[ "$npm_path" != */* ]]; then
    npm_path="$(command -v "$npm_cmd" 2>/dev/null)" || return 1
  fi
  if command -v node >/dev/null 2>&1; then
    node -e 'const fs = require("node:fs"); console.log(fs.realpathSync(process.argv[1]));' "$npm_path" 2>/dev/null && return 0
  fi
  printf '%s\n' "$npm_path"
}

npm_builtin_config_path() {
  local npm_cmd="$1"
  local npm_path
  npm_path="$(npm_command_path "$npm_cmd")" || return 1
  local npm_root
  npm_root="$(cd "$(dirname "$npm_path")/.." >/dev/null 2>&1 && pwd -P)" || return 1
  printf '%s\n' "${npm_root}/npmrc"
}

npm_config_has_raw_key() {
  local npm_cmd="$1"
  local key="$2"
  local project_dir="${3:-}"
  local raw=""
  local file=""
  local -a files=()

  if [[ -n "$project_dir" ]]; then
    files+=("${project_dir}/.npmrc")
  fi

  raw="${NPM_CONFIG_USERCONFIG:-${npm_config_userconfig:-}}"
  if [[ -n "$raw" ]]; then
    file="$(resolve_npm_config_path "$raw" 2>/dev/null || true)"
    [[ -n "$file" ]] && files+=("$file")
  elif [[ -n "${HOME:-}" ]]; then
    files+=("${HOME}/.npmrc")
  fi

  raw="${NPM_CONFIG_GLOBALCONFIG:-${npm_config_globalconfig:-}}"
  if [[ -n "$raw" ]]; then
    file="$(resolve_npm_config_path "$raw" 2>/dev/null || true)"
    [[ -n "$file" ]] && files+=("$file")
  fi

  raw="$(env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$npm_cmd" config get globalconfig --global 2>/dev/null || true)"
  file="$(resolve_npm_config_path "$raw" 2>/dev/null || true)"
  [[ -n "$file" ]] && files+=("$file")

  file="$(npm_builtin_config_path "$npm_cmd" 2>/dev/null || true)"
  [[ -n "$file" ]] && files+=("$file")

  for file in "${files[@]}"; do
    if npm_config_file_has_key "$file" "$key"; then
      return 0
    fi
  done
  return 1
}

npm_lifecycle_allow_arg() {
  local npm_cmd="$1" spec="$2" npm_cwd="${3:-$PWD}" exact_identity="${4:-}" version="" output=""
  if ! version="$("$npm_cmd" --version 2>/dev/null)"; then
    log "ERROR: unable to determine npm version; no package changes were made"
    return 1
  fi
  output="$("$(node_bin)" - "$version" "$spec" "$npm_cwd" "$exact_identity" <<'NODE'
const path = require("node:path");
const [versionOutput, spec, cwd, exactIdentity] = process.argv.slice(2);
const version = versionOutput.trim().split(/\r?\n/).at(-1) ?? "";
const parsed = version.match(/^[vV]?(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$/);
const fail = (message) => { process.stderr.write(`${message}\n`); process.exit(1); };
if (!parsed) fail("Unable to determine npm version; no package changes were made.");
if (+parsed[1] < 12 && (+parsed[1] !== 11 || +parsed[2] < 16)) process.exit(0);
const normalized = spec.trim();
const unaliased = normalized.toLowerCase().startsWith("openclaw@") ? normalized.slice(9).trim() : normalized;
const explicit = (value) => /\.(?:tgz|tar\.gz)$/i.test(value) || value.includes("://") || value.includes("#") || /^(?:file|github|git\+(?:ssh|https|http|file)|npm):/i.test(value);
let identity = !normalized || explicit(normalized) || explicit(unaliased) || /^\.{1,2}(?:[\\/]|$)/.test(unaliased) || path.isAbsolute(normalized) || path.isAbsolute(unaliased) ? unaliased : "openclaw";
const alias = /^npm:/i.test(identity);
if (alias) identity = /^npm:(@[^/]+\/[^@]+|[^@]+?)(?:@.*)?$/i.exec(identity)?.[1] ?? "";
const filePrefix = /^file:/i.test(identity) ? "file:" : "";
const archivePath = identity.slice(filePrefix.length);
const gitShorthand = !/^~[\\/]/.test(identity) && /^[^./@\s:#][^/\s:@#]*\/[^/\s:@#]+(?:#[\s\S]*)?$/.test(identity);
const localArchive = !alias && !gitShorthand && /\.(?:tgz|tar\.gz|tar)$/i.test(archivePath) && (filePrefix || path.isAbsolute(archivePath) || !/^[a-z][a-z0-9+.-]*:/i.test(archivePath));
let absoluteArchive = "";
if (localArchive) {
  const npmPath = process.platform === "win32" ? archivePath.replaceAll("\\", "/") : archivePath;
  // Escape raw paths before URL normalization so literal %, #, and ? retain their identity.
  let fileUrl = `file:${encodeURI(npmPath).replace(/[?#]/g, encodeURIComponent)}`;
  fileUrl = fileUrl.replace(/^file:\/\/(?=[^/])/, "file:/").replace(/^file:\/{1,3}(?=\.\.?(?:\/|$))/, "file:");
  const specPath = decodeURIComponent(new URL(fileUrl).pathname);
  let resolvedPath = decodeURIComponent(new URL(fileUrl, `${require("node:url").pathToFileURL(path.resolve(cwd || process.cwd())).href}/`).pathname);
  if (process.platform === "win32") resolvedPath = resolvedPath.replace(/^\/+([a-z]:\/)/i, "$1");
  absoluteArchive = /^\/~(?:\/|$)/.test(specPath) ? path.resolve(require("node:os").homedir(), specPath.slice(3)) : path.resolve(cwd || process.cwd(), resolvedPath);
}
// Tarballs match the absolute npm resolved identity; directory links accept relative paths.
// Keep the npm 11 comma-path identity: its advisory/strict decision stays npm-owned.
if (absoluteArchive && (+parsed[1] >= 12 || !absoluteArchive.includes(","))) identity = `${filePrefix}${absoluteArchive}`;
else {
  const relative = cwd && path.isAbsolute(identity) ? path.relative(cwd, identity) || "." : "";
  if (relative) identity = path.isAbsolute(relative) || relative === "." || relative === ".." || relative.startsWith(`..${path.sep}`) ? relative : `.${path.sep}${relative}`;
}
if (exactIdentity) identity = exactIdentity;
if (!identity || identity.includes(",")) fail(`npm cannot allow lifecycle scripts for install target '${spec}'; use a package URL or local path without commas.`);
process.stdout.write(`--allow-scripts=${identity}\n`);
NODE
)" || return 1
  printf '%s' "$output"
}

publish_executable_wrapper() {
  local target="$1" target_dir="" temp="" backup=""
  target_dir="${target%/*}"
  mkdir -p "$target_dir"
  temp="$(mktemp "${target_dir}/.openclaw-wrapper.XXXXXX")" || return 1
  TMPFILES+=("$temp")
  cat > "$temp"
  chmod +x "$temp"
  if [[ -z "$WRAPPER_BACKUP_PATH" && ( -e "$target" || -L "$target" ) ]]; then
    backup="$(mktemp "${target}.backup.XXXXXX")" || return 1
    rm -f "$backup" || return 1
    mv "$target" "$backup" || return 1
    WRAPPER_BACKUP_TARGET="$target"
    WRAPPER_BACKUP_PATH="$backup"
  fi
  mv -f "$temp" "$target"
}

commit_wrapper_backup() {
  [[ -z "$WRAPPER_BACKUP_PATH" ]] || rm -f "$WRAPPER_BACKUP_PATH" || return 1
  WRAPPER_BACKUP_TARGET=""
  WRAPPER_BACKUP_PATH=""
}

install_openclaw() {
  local requested="${OPENCLAW_VERSION:-latest}"
  if is_openclaw_source_package_install_spec "$requested"; then
    if [[ "$(os_detect)" == "freebsd" ]]; then
      fail_freebsd_source_install
    fi
    fail "npm installs do not support OpenClaw GitHub source targets like '${requested}'. Use --install-method git --version main, latest, beta, an exact version, or a built .tgz package."
  fi
  local freshness_flag="--min-release-age=0"
  local min_release_age=""
  min_release_age="$(env -u NPM_CONFIG_BEFORE -u npm_config_before "$(npm_bin)" config get min-release-age --global 2>/dev/null || true)"
  if npm_config_has_raw_key "$(npm_bin)" "min-release-age"; then
    freshness_flag="--min-release-age=0"
  elif [[ -z "$min_release_age" || "$min_release_age" == "null" || "$min_release_age" == "undefined" ]]; then
    local before_value=""
    before_value="$(env -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" config get before --global 2>/dev/null || true)"
    if [[ -n "$before_value" && "$before_value" != "null" && "$before_value" != "undefined" ]]; then
      freshness_flag="--before=$(date -u '+%Y-%m-%dT%H:%M:%S.000Z')"
    fi
  fi
  local npm_args=(
    --loglevel "$NPM_LOGLEVEL"
    --no-fund
    --no-audit
    "$freshness_flag"
  )
  local resolved_requested="$requested"
  if [[ -n "${REQUIRED_COMPATIBLE_VERSION:-}" ]]; then
    # || true: a failed npm view must reach the explicit fail below instead
    # of dying silently through set -e with no error event.
    resolved_requested="$(resolve_npm_openclaw_version "$requested" || true)"
    if [[ -z "$resolved_requested" ]]; then
      fail "Could not resolve OpenClaw ${requested} before compatibility checking."
    fi
    require_openclaw_version_compatible "$resolved_requested"
  fi
  local install_spec="openclaw@${resolved_requested}"
  if [[ "$resolved_requested" == *"://"* || "$resolved_requested" == /* || "$resolved_requested" == ./* || "$resolved_requested" == ../* || "$resolved_requested" =~ ^(file|github|git\+|npm): || "$resolved_requested" =~ \.(tgz|tar\.gz)$ ]]; then
    install_spec="$resolved_requested"
  fi
  local npm_cmd="" lifecycle_arg=""
  npm_cmd="$(npm_bin)"
  local npm_cwd="$PWD"
  lifecycle_arg="$(npm_lifecycle_allow_arg "$npm_cmd" "$install_spec" "$npm_cwd")" || return 1
  emit_json step name openclaw status start version "$requested"
  log "Installing OpenClaw (${requested})..."
  if [[ "$SET_NPM_PREFIX" -eq 1 ]]; then
    fix_npm_prefix_if_needed
  fi

  local installed_entry lifecycle_pending legacy_install_guard
  installed_entry="$(node_dir)/lib/node_modules/openclaw/dist/entry.js"
  lifecycle_pending="$(node_dir)/lib/node_modules/openclaw/.openclaw-lifecycle-pending"
  legacy_install_guard="$(node_dir)/lib/node_modules/openclaw/dist/openclaw-install-guard"
  local npm_install_args=(install -g --prefix "$(node_dir)" "${npm_args[@]}")
  [[ -z "$lifecycle_arg" ]] || npm_install_args+=("$lifecycle_arg")
  npm_install_args+=("$install_spec")
  if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$npm_cmd" "${npm_install_args[@]}" || [[ ! -f "$installed_entry" || -e "$lifecycle_pending" || -e "$legacy_install_guard" ]]; then
    log "npm install openclaw@${resolved_requested} did not produce a usable package; retrying once"
    if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$npm_cmd" "${npm_install_args[@]}" || [[ ! -f "$installed_entry" || -e "$lifecycle_pending" || -e "$legacy_install_guard" ]]; then
      emit_json error message "npm install did not produce a usable OpenClaw package"
      log "ERROR: npm install did not produce a usable OpenClaw package"
      return 1
    fi
  fi

  mkdir -p "${PREFIX}/bin"
  publish_executable_wrapper "${PREFIX}/bin/openclaw" <<EOF
#!/usr/bin/env bash
set -euo pipefail
exec "${PREFIX}/tools/node/bin/node" "$(node_dir)/lib/node_modules/openclaw/dist/entry.js" "\$@"
EOF
  emit_json step name openclaw status ok version "$requested"
}

ensure_pnpm_git_prepare_allowlist() {
  local repo_dir="$1"
  local workspace_file="${repo_dir}/pnpm-workspace.yaml"
  local dep="@tloncorp/api"
  local tmp

  if [[ -f "$workspace_file" ]] && ! grep -Fq "\"${dep}\"" "$workspace_file" && ! grep -Fq "${dep}:" "$workspace_file" && ! grep -Fq -- "- ${dep}" "$workspace_file"; then
    tmp="$(mktemp "${TMPDIR:-/tmp}/openclaw-workspace.XXXXXX")"
    TMPFILES+=("$tmp")
    if grep -q '^allowBuilds:[[:space:]]*$' "$workspace_file"; then
      awk -v dep="$dep" '
        BEGIN { inserted = 0 }
        {
          print
          if (!inserted && $0 ~ /^allowBuilds:[[:space:]]*$/) {
            print "  \"" dep "\": true"
            inserted = 1
          }
        }
      ' "$workspace_file" >"$tmp"
    else
      cat "$workspace_file" >"$tmp"
      printf '\nallowBuilds:\n  "%s": true\n' "$dep" >>"$tmp"
    fi
    mv "$tmp" "$workspace_file"
  elif [[ ! -f "$workspace_file" ]]; then
    printf 'allowBuilds:\n  "%s": true\n' "$dep" >"$workspace_file"
  fi

  log "Updated pnpm allowlist for git-hosted build dependency: ${dep}"
}

clone_git_checkout_transactionally() {
  local repo_url="$1"
  local repo_dir="$2"

  local parent_dir staging_dir clone_status=0 preserve_repo_dir=0
  parent_dir="$(dirname "$repo_dir")"
  mkdir -p "$parent_dir"
  parent_dir="$(cd "$parent_dir" && pwd -P)"
  if [[ -d "$repo_dir" && -z "$(ls -A "$repo_dir" 2>/dev/null || true)" ]]; then
    preserve_repo_dir=1
    repo_dir="$(cd "$repo_dir" && pwd -P)"
    staging_dir="$(mktemp -d "${repo_dir}/.openclaw-clone.XXXXXX")"
  else
    repo_dir="${parent_dir}/$(basename "$repo_dir")"
    staging_dir="$(mktemp -d "${parent_dir}/.openclaw-clone.XXXXXX")"
  fi
  TMPFILES+=("$staging_dir")

  # Blobless partial clone: the dev checkout only needs current files plus pullable
  # history refs; full multi-gigabyte blob history would dominate install time.
  git clone --filter=blob:none "$repo_url" "$staging_dir" || clone_status=$?
  if [[ "$clone_status" -ne 0 ]]; then
    return "$clone_status"
  fi

  if ! node - "$staging_dir" "$repo_dir" "$preserve_repo_dir" <<'NODE'
const fs = require("node:fs");
const [source, target, preserveTarget] = process.argv.slice(2);
if (preserveTarget === "0") {
  try {
    fs.lstatSync(target);
  } catch (error) {
    if (error?.code !== "ENOENT") throw error;
    fs.renameSync(source, target);
    process.exit(0);
  }
  throw new Error(`Git install dir appeared while cloning: ${target}`);
}
const expected = preserveTarget === "1" ? [source.slice(source.lastIndexOf("/") + 1)] : [];
if (!fs.statSync(target).isDirectory() || fs.readdirSync(target).sort().join("\0") !== expected.sort().join("\0")) {
  throw new Error(`Git install dir appeared while cloning: ${target}`);
}
const entries = fs.readdirSync(source).sort((a, b) => (a === ".git" ? 1 : b === ".git" ? -1 : 0));
const moved = [];
try {
  for (const entry of entries) {
    fs.renameSync(`${source}/${entry}`, `${target}/${entry}`);
    moved.push(entry);
  }
  fs.rmdirSync(source);
} catch (error) {
  const rollbackErrors = [];
  for (const entry of moved.reverse()) {
    try {
      fs.renameSync(`${target}/${entry}`, `${source}/${entry}`);
    } catch (rollbackError) {
      rollbackErrors.push(rollbackError);
    }
  }
  if (rollbackErrors.length > 0) {
    let recovery = source;
    try {
      recovery = `${source}.recovery`;
      fs.renameSync(source, recovery);
    } catch (recoveryError) {
      rollbackErrors.push(recoveryError);
      recovery = source;
    }
    throw new AggregateError(
      [error, ...rollbackErrors],
      `Could not publish or fully roll back the cloned checkout at ${target}; recovery files remain at ${recovery}`,
    );
  }
  throw error;
}
NODE
  then
    fail "Could not publish the cloned checkout: ${repo_dir}. Inspect the destination for partial files, move it or choose another --git-dir, then retry."
  fi
}

install_openclaw_from_git() {
  local repo_dir="$1"
  local repo_url="https://github.com/openclaw/openclaw.git"
  local fresh_checkout=0

  if [[ -z "$repo_dir" ]]; then
    fail "Git install dir cannot be empty"
  fi
  mkdir -p "$(dirname "$repo_dir")"
  if [[ -d "$repo_dir" ]]; then
    repo_dir="$(cd "$repo_dir" && pwd -P)"
  else
    repo_dir="$(cd "$(dirname "$repo_dir")" && pwd -P)/$(basename "$repo_dir")"
  fi

  emit_json step name openclaw status start method git repo "$repo_url"
  if [[ -d "$repo_dir/.git" ]]; then
    log "Installing Openclaw from git checkout: ${repo_dir}"
  else
    log "Installing Openclaw from GitHub (${repo_url})..."
  fi

  emit_json step name git-tools status start
  ensure_git
  emit_json step name git-tools status ok

  if [[ -d "$repo_dir/.git" ]] &&
    ! git --git-dir="$repo_dir/.git" --work-tree="$repo_dir" rev-parse --verify --quiet 'HEAD^{commit}' >/dev/null 2>&1; then
    fail "Git checkout has no commit: ${repo_dir}. Move or remove this incomplete checkout, then retry."
  fi

  if [[ ! -d "$repo_dir/.git" ]]; then
    if [[ -d "$repo_dir" && -n "$(ls -A "$repo_dir" 2>/dev/null || true)" ]]; then
      fail "Git install dir exists but is not a git repo: ${repo_dir}"
    fi
    emit_json step name git-clone status start
    clone_git_checkout_transactionally "$repo_url" "$repo_dir"
    emit_json step name git-clone status ok
    fresh_checkout=1
  fi

  local git_ref
  git_ref="$(resolve_git_openclaw_ref)"
  if [[ -z "$(git -C "$repo_dir" status --porcelain 2>/dev/null || true)" ]]; then
    log "Using git ref: ${git_ref}"
    if [[ "$fresh_checkout" -eq 0 ]]; then
      emit_json step name git-update status start
    fi
    checkout_git_openclaw_ref "$repo_dir" "$git_ref"
    if [[ "$fresh_checkout" -eq 0 ]]; then
      emit_json step name git-update status ok
    fi
  else
    log "Repo is dirty; skipping git checkout/update"
    emit_json step name git-update status warn reason dirty
    if git -C "$repo_dir" symbolic-ref --quiet HEAD >/dev/null; then
      GIT_REF_KIND="moving"
    else
      GIT_REF_KIND="immutable"
    fi
  fi

  if [[ -n "${REQUIRED_COMPATIBLE_VERSION:-}" ]]; then
    local resolved_version
    resolved_version="$(resolve_git_checkout_openclaw_version "$repo_dir" 2>/dev/null || true)"
    if [[ -z "$resolved_version" ]]; then
      fail "Could not resolve the Git checkout version before compatibility checking."
    fi
    require_openclaw_version_compatible "$resolved_version"
  fi

  cleanup_legacy_submodules "$repo_dir"
  ensure_pnpm_git_prepare_allowlist "$repo_dir"
  ensure_pnpm "$repo_dir"

  local install_lockfile_flag
  install_lockfile_flag="$(git_install_lockfile_flag "$GIT_REF_KIND")"
  local -a pnpm_prefer_offline_args=()
  if should_prefer_offline_pnpm_install "$repo_dir"; then
    pnpm_prefer_offline_args=(--prefer-offline)
  fi
  emit_json step name dependencies status start
  CI="${CI:-true}" run_pnpm -C "$repo_dir" install ${pnpm_prefer_offline_args[@]+"${pnpm_prefer_offline_args[@]}"} "$install_lockfile_flag"
  emit_json step name dependencies status ok

  emit_json step name control-ui status start
  if ! run_pnpm -C "$repo_dir" ui:build; then
    log "UI build failed; continuing (CLI may still work)"
    emit_json step name control-ui status warn
  else
    emit_json step name control-ui status ok
  fi
  emit_json step name cli-build status start
  run_pnpm -C "$repo_dir" build
  emit_json step name cli-build status ok

  mkdir -p "${PREFIX}/bin"
  publish_executable_wrapper "${PREFIX}/bin/openclaw" <<EOF
#!/usr/bin/env bash
set -euo pipefail
exec "${PREFIX}/tools/node/bin/node" "${repo_dir}/dist/entry.js" "\$@"
EOF
  emit_json step name openclaw status ok method git
}

is_gateway_daemon_loaded() {
  local claw="$1"
  if [[ -z "$claw" || ! -x "$claw" ]]; then
    return 1
  fi

  local status_json=""
  # Unlike daemon status, gateway status reports service.loaded during pending migrations.
  status_json="$("$claw" gateway status --json 2>/dev/null || true)"
  if [[ -z "$status_json" ]]; then
    return 1
  fi

  # Managed installs must parse with their provisioned Node even when the system has none.
  local node_bin="${PREFIX}/tools/node/bin/node"
  if [[ ! -x "$node_bin" ]]; then
    if command -v node >/dev/null 2>&1; then
      node_bin="$(command -v node)"
    else
      # Approximate POSIX-safe fallback when neither managed nor system Node is available.
      printf '%s\n' "$status_json" | grep -Eq '"loaded"[[:space:]]*:[[:space:]]*true'
      return
    fi
  fi

  printf '%s' "$status_json" | "$node_bin" -e '
const fs = require("fs");
const raw = fs.readFileSync(0, "utf8").trim();
if (!raw) process.exit(1);
try {
  const data = JSON.parse(raw);
  process.exit(data?.service?.loaded ? 0 : 1);
} catch {
  process.exit(1);
}
' >/dev/null 2>&1
}

refresh_gateway_service_if_loaded() {
  local claw="${PREFIX}/bin/openclaw" refresh_output
  if [[ ! -x "$claw" ]]; then
    return 0
  fi

  if ! is_gateway_daemon_loaded "$claw"; then
    emit_json step name gateway-service status skip reason not-loaded
    return 0
  fi

  emit_json step name gateway-service status start
  log "Refreshing loaded gateway service..."

  if ! refresh_output="$({ set +x; "$claw" gateway install --force; } 2>&1 | sed -n -e 's/^Replacing unsupported Gateway service Node .*; refreshing the install\.$/node-runtime-replaced/p' -e 's/^Replacing missing Gateway service Node .*; refreshing the install\.$/node-runtime-replaced/p' -e 's/.*SERVICE_DEFINITION_SEALED:.*/ask the privileged deployment owner to manually repair it/p' -e 's/.*SERVICE_DEFINITION_UNKNOWN:.*/inspect service-definition access and manually repair it/p')"; then
    refresh_output="$(printf '%s\n' "$refresh_output" | sed '/^node-runtime-replaced$/d')"
    if [[ -n "$refresh_output" ]]; then
      emit_json step name gateway-service status warn reason definition-mutation-denied
      printf '%s\n' "Code installed; gateway service definition left unchanged; ${refresh_output}." >&2
      printf '%s\n' "Run openclaw gateway status --deep, verify the installation owner, and restart it manually if needed." >&2
      return 0
    fi
    emit_json step name gateway-service status warn reason install-failed
    log "Warning: gateway service refresh failed; continuing."
    return 0
  fi
  if [[ "$refresh_output" == *node-runtime-replaced* ]]; then
    printf '%s\n' "Gateway service Node runtime replaced." >&2
  fi

  # `gateway install --force` activates the replacement service. A second
  # restart can kill startup migrations and strand their lock until expiry.
  "$claw" gateway status --probe --json >/dev/null 2>&1 || true
  emit_json step name gateway-service status ok
}

main() {
  parse_args "$@"
  # Reject unsupported source installs before changing runtime links or checkouts.
  # Node-only recovery owns its separate platform refusal and ignores the method.
  if [[ "$NODE_ONLY" -eq 0 && "$INSTALL_METHOD" == "git" && "$(os_detect)" == "freebsd" ]]; then
    fail_freebsd_source_install
  fi
  PREFIX="$(resolve_installer_path "$PREFIX")"
  local original_tmpdir="${TMPDIR-}" original_tmpdir_set="${TMPDIR+x}"
  local TMPDIR="$original_tmpdir"
  prepare_tmpdir
  if [[ "$NODE_ONLY" -eq 1 ]]; then
    if is_musl_linux; then
      fail "Private Node.js recovery is unavailable on musl Linux; update Node.js with your system package manager."
    fi
    install_node "$(os_detect)" "$(arch_detect)"
    return
  fi
  GIT_DIR="$(resolve_installer_path "$GIT_DIR")"

  if [[ "${OPENCLAW_NO_ONBOARD:-0}" == "1" ]]; then
    RUN_ONBOARD=0
  fi

  if [[ "$INSTALL_METHOD" == "git" ]]; then
    preflight_fresh_git_disk_space "$GIT_DIR"
  fi

  select_node_version_for_platform "$(os_detect)" "$(arch_detect)"
  PATH="$(node_dir)/bin:${PREFIX}/bin:${PATH}"
  export PATH

  install_node "$(os_detect)" "$(arch_detect)"
  if [[ "$INSTALL_METHOD" == "git" ]]; then
    install_openclaw_from_git "$GIT_DIR"
  elif [[ "$INSTALL_METHOD" == "npm" ]]; then
    if [[ "$RUNTIME_ONLY" -eq 0 ]]; then
      ensure_git
    fi
    if [[ "$SET_NPM_PREFIX" -eq 1 ]]; then
      fix_npm_prefix_if_needed
    fi
    install_openclaw
  else
    fail "Unknown install method: ${INSTALL_METHOD} (use npm or git)"
  fi

  local installed_version
  if ! installed_version="$("${PREFIX}/bin/openclaw" --version 2>/dev/null | head -n 1 | tr -d '\r')" ||
    [[ -z "$installed_version" ]]; then
    fail "Installed OpenClaw CLI did not return a version successfully from ${PREFIX}/bin/openclaw."
  fi
  commit_wrapper_backup

  # Services and onboarding outlive staging; never persist its temporary path.
  if [[ "$original_tmpdir_set" == x ]]; then
    export TMPDIR="$original_tmpdir"
  else
    unset TMPDIR
  fi
  if [[ "$RUNTIME_ONLY" -eq 1 ]]; then
    emit_json step name gateway-service status skip reason runtime-only
  else
    refresh_gateway_service_if_loaded
  fi
  emit_json "done" version "$installed_version"
  log "OpenClaw installed (${installed_version})."

  if [[ "$RUN_ONBOARD" -eq 1 && "$RUNTIME_ONLY" -eq 0 ]]; then
    "${PREFIX}/bin/openclaw" onboard
  fi
}

if [[ "${OPENCLAW_INSTALL_CLI_SH_NO_RUN:-0}" != "1" ]]; then
  main "$@"
fi
