#!/usr/bin/env bash
export OX_CLOUD_ORIGIN='https://portal.oxrecorder.cn'
export OX_INSTALL_LANGUAGE='zh'
# Unified self-host installer. Downloads and installs verified releases.
set -Eeuo pipefail
umask 077
export PATH="$HOME/.local/share/ox-self-host/node/bin:$HOME/.local/bin:$PATH"
# Keep environment overrides for automation; interactive users answer the wizard.
OX_MODE=production
OX_INSTALL_INSTANCE=""
OX_NEW_INSTANCE=0
OX_LIST_INSTANCES=0
OX_MENU_HOME_CUSTOM=0
[[ -n "${OX_SELF_HOST_HOME:-}" ]] && OX_MENU_HOME_CUSTOM=1
OX_INSTALL_ROOT="${OX_SELF_HOST_HOME:-}"
OX_RELEASE_DIRECTORY=''
OX_CLOUD_OVERRIDE="${OX_CLOUD_ORIGIN:-}"
OX_CLOUD_ORIGIN="${OX_CLOUD_ORIGIN:-}"
OX_SETUP_PORT="${OX_SETUP_PORT:-3081}"
OX_DEPENDENCIES_ONLY=0
OX_INSTALL_INTERACTIVE=0
OX_SELECT_INTERACTIVE=0
[[ -t 0 ]] && OX_SELECT_INTERACTIVE=1
OX_ACTION=""
OX_TEARDOWN=0
OX_CONFIRMED=0
OX_SHOW_HELP=0
OX_STATUS=0
OX_ACCEPT_SERVER_MODE=0
while [[ $# -gt 0 ]]; do
  case "$1" in
    --instance|--cloud-url|--home|--lan-host|--lan-port|--action|--language)
      [[ $# -ge 2 && -n "$2" ]] || { printf 'Missing value for %s\n' "$1" >&2; exit 1; }
      case "$1" in --instance) OX_INSTALL_INSTANCE="$2" ;; --action) OX_ACTION="$2" ;; --language) OX_INSTALL_LANGUAGE="$2" ;; --cloud-url) OX_CLOUD_ORIGIN="$2"; OX_CLOUD_OVERRIDE="$2" ;; --home) OX_INSTALL_ROOT="$2"; OX_MENU_HOME_CUSTOM=1 ;; --lan-host) export OX_LAN_HOST="$2" ;; --lan-port) export OX_LAN_PORT="$2" ;; esac
      shift 2 ;;
    --mode)
      # Maintainer-only: development orchestration is not offered to customers.
      [[ "${OX_MAINTAINER_DEV:-0}" == 1 && $# -ge 2 && -n "$2" ]] || { printf 'Unknown argument: %s\n' "$1" >&2; exit 1; }
      OX_MODE="$2"; shift 2 ;;
    --new) OX_NEW_INSTANCE=1; shift ;;
    --list) OX_LIST_INSTANCES=1; shift ;;
    --status) OX_STATUS=1; shift ;;
    --teardown) OX_TEARDOWN=1; shift ;;
    --yes) OX_CONFIRMED=1; shift ;;
    --accept-server-mode) OX_ACCEPT_SERVER_MODE=1; shift ;;
    --dependencies-only) OX_DEPENDENCIES_ONLY=1; shift ;;
    --interactive) OX_INSTALL_INTERACTIVE=1; OX_SELECT_INTERACTIVE=1; shift ;;
    --non-interactive) OX_INSTALL_INTERACTIVE=0; OX_SELECT_INTERACTIVE=0; shift ;;
    --help|-h) OX_SHOW_HELP=1; shift ;;
    *) printf 'Unknown argument: %s\n' "$1" >&2; exit 1 ;;
  esac
done
# A per-user preference is available before Node or dependencies are installed.
OX_LANGUAGE_FILE="$HOME/.config/ox-self-host/installer-language"
OX_LANGUAGE_KNOWN=0
if [[ -n "${OX_INSTALL_LANGUAGE:-}" ]]; then OX_LANGUAGE_KNOWN=1
elif [[ -f "$OX_LANGUAGE_FILE" && ! -L "$OX_LANGUAGE_FILE" ]]; then
  IFS= read -r saved_language < "$OX_LANGUAGE_FILE" || true
  case "${saved_language:-}" in zh|en) OX_INSTALL_LANGUAGE="$saved_language"; OX_LANGUAGE_KNOWN=1 ;; esac
fi
# Help must exit before prompts, dependency checks or installation changes.
if [[ "$OX_SHOW_HELP" == 1 ]]; then
  case "${OX_INSTALL_LANGUAGE:-zh}" in
    en)
      cat <<'HELP_EN'
OX Recorder self-host installer

Usage
  bash install_self_host.sh [options]

Getting started
  Run without options to open the menu on macOS or Ubuntu.
  Choose a language once (Enter selects Chinese); it is saved locally.
  Use arrow keys and Enter; Esc goes back and q exits.
  Simple terminals use numbered choices, b for Back and q for Exit.
  Select an instance to view status, logs, service controls and recovery.
  Create or select named instances.
  --new               Create an instance with a random name.
  --instance NAME     Select an instance.
  --list              List registered instances (read-only).

Status (read-only)
  --status            Inspect the instance without starting or changing it.
                      Selected mode only; defaults to production. No prompts.

Live logs (read-only)
  Choose View live logs in the menu, then all logs or one component.
  Arrows/Page Up/Page Down pause and scroll; F follows; q or Ctrl+C returns.
  Live / Paused status stays visible. Services keep running.

Ubuntu host protection
  --accept-server-mode
                      Accept mandatory host protection for new installations.
  --action host-check   Read the latest host audit without changing anything.
  --action host-configure
                      Adopt server mode in place; keep existing data.
  --action host-restore
                      Restore reviewed host settings (interactive only).

Installation actions
  --action cleanup    Finish local cleanup after cloud-confirmed data deletion.
  --action forget-registration
                      Forget a missing-folder registration after safety checks.
                      Requires interactive confirmation; removes no resources.
  --action discard    Discard an incomplete installation after confirmation.
  --action fresh      Create a new instance when no existing data remains.
                      Remove existing data through the cloud portal first.
  --action resume     Start the existing service and keep its data.
  --action start      Start application services; keep existing data.
  --action stop       Stop application services; keep cloud control online.
  --action restart    Restart idle application services; keep existing data.
  --action restore    Reauthorize retained data using a cloud restoration key.
                      Requires retained self-hosted server data.

Language and prompts
  --language zh|en    Select Chinese (default) or English.
                      Also supported: OX_INSTALL_LANGUAGE=zh|en.
  --interactive       Open menus, including with redirected input.
                      With --action, open that action’s setup wizard.
  --non-interactive   Run without prompts; supply the required options.
  -h, --help          Show help without making changes or asking questions.

Installation settings
  --cloud-url ORIGIN  Set the cloud address. Production default:
                      https://portal.oxrecorder.cn
  --home DIRECTORY    Set the directory for a new installation.
                      An existing installation keeps its registered directory.
  --lan-host HOST     Override the detected LAN hostname or IP address.
  --lan-port PORT     Override the LAN HTTPS port.
  --dependencies-only
                      Prepare dependencies without installing an instance.

Data removal
  Delete instances and self-hosted server data from the cloud portal.
  Completed instance data requires cloud authorization.
  Incomplete attempts use Discard with explicit yes/no confirmation.

Examples
  # Start the guided installer
  bash install_self_host.sh

  # Show English help (Chinese: --language zh)
  bash install_self_host.sh --help --language en

  # Check the existing instance without starting it
  bash install_self_host.sh --status --language en

  # Resume after a reboot, without prompts
  bash install_self_host.sh --action resume --non-interactive --language en

  # Restore retained data with renewed cloud authorization
  bash install_self_host.sh --action restore

HELP_EN
      ;;
    zh)
      cat <<'HELP_ZH'
OX Recorder 自托管安装程序

用法
  bash install_self_host.sh [选项]

快速开始
  不带选项运行即可打开菜单，支持 macOS 和 Ubuntu。
  首次选择语言（回车默认中文），之后使用保存的语言。
  使用方向键和回车；Esc 返回，q 退出。
  简单终端使用序号选择，b 返回，q 退出。
  选择实例后可查看状态、日志、服务控制和恢复操作。
  支持创建多个具名实例。
  --new               创建随机命名的新实例。
  --instance NAME     选择当前模式下的实例。
  --list              列出两个模式的已登记实例（只读）。

查看状态（只读）
  --status            查看已有实例，不启动服务，也不修改安装。
                      查看所选模式（默认生产模式），不询问问题。

实时日志（只读）
  在菜单中选择“查看实时日志”，查看所有日志或单个组件。
  方向键 / 翻页键自动暂停并滚动，F 继续跟踪，q 或 Ctrl+C 返回。
  始终显示“实时 / 已暂停”状态，不会停止服务。

Ubuntu 主机保护
  --accept-server-mode  确认新生产安装必需的主机级保护。
  --action host-check   只读查看最近的主机检查结果。
  --action host-configure  为已有安装启用服务器模式，保留数据。
  --action host-restore  审查并恢复原主机设置（需要交互确认）。

安装操作
  --action cleanup    完成云端已确认的数据删除后的安装清理。
  --action forget-registration
                      检查残留资源后，交互确认忘记目录已丢失的登记。
                      不会删除容器、服务或数据。
  --action discard    确认后清理未完成的安装。
  --action fresh      本机没有已有数据时创建全新实例。
                      已有数据必须先通过云端控制台删除并完成确认。
  --action resume     启动已有服务，保留原有数据。
  --action start      启动应用服务，保留已有数据。
  --action stop       停止应用服务，保持云端控制连接。
  --action restart    重启空闲的应用服务，保留已有数据。
  --action restore    使用云端恢复授权密钥，重新授权并保留已有数据。
                      仅在本机存在可恢复数据时可用。

语言与交互
  --language zh|en    选择中文（默认）或英文。
                      也可设置环境变量 OX_INSTALL_LANGUAGE=zh|en。
  --interactive       打开菜单，也适用于重定向输入。
                      与 --action 一起使用时，直接进入该操作的向导。
  --non-interactive   不显示交互提示；请通过选项提供所需参数。
  -h, --help          显示帮助，不询问问题，也不修改安装。

安装设置
  --cloud-url ORIGIN  指定云端地址，生产模式默认：
                      https://portal.oxrecorder.cn
  --home DIRECTORY    指定新安装目录。
                      已有安装仍使用原来登记的目录。
  --lan-host HOST     指定局域网主机名或 IP，覆盖自动检测结果。
  --lan-port PORT     指定局域网 HTTPS 端口。
  --dependencies-only
                      仅准备依赖，不安装实例。

数据删除
  请在云端控制台删除实例及自托管服务器数据。
  已完成实例的数据删除必须通过云端授权。
  未完成的安装可选择清理，并输入 yes/no 确认。

示例
  # 启动安装向导
  bash install_self_host.sh

  # 显示英文帮助（中文使用 --language zh）
  bash install_self_host.sh --help --language en

  # 查看已有实例状态，不启动服务
  bash install_self_host.sh --status

  # 重启后继续运行，不询问问题
  bash install_self_host.sh --action resume --non-interactive --language zh

  # 通过云端重新授权，恢复保留的数据
  bash install_self_host.sh --action restore

HELP_ZH
      ;;
    *) printf '语言无效 / Invalid language: use zh or en.\n' >&2; exit 1 ;;
  esac
  exit 0
fi
if [[ "$OX_STATUS" == 1 ]]; then
  if [[ -n "$OX_ACTION" || "$OX_TEARDOWN" == 1 || "$OX_CONFIRMED" == 1 || "$OX_DEPENDENCIES_ONLY" == 1 ]]; then
    printf '%s\n' '--status 不能与安装或卸载操作一起使用 / --status cannot be combined with installation or teardown actions.' >&2
    exit 1
  fi
  OX_SELECT_INTERACTIVE=0
  OX_INSTALL_INTERACTIVE=0
fi
case "$OX_ACTION" in ""|fresh|resume|restore|start|stop|restart|forget-registration|discard|cleanup|host-check|host-configure|host-restore) ;; *) printf '操作无效 / Invalid action: fresh, resume, restore, start, stop, restart, discard, cleanup\n' >&2; exit 1 ;; esac
if [[ -n "$OX_INSTALL_INSTANCE" && ! "$OX_INSTALL_INSTANCE" =~ ^[a-z0-9][a-z0-9-]{0,62}$ ]]; then printf 'Invalid instance name / 实例名称无效\n' >&2; exit 1; fi
if [[ "$OX_NEW_INSTANCE" == 1 && ( -n "$OX_INSTALL_INSTANCE" || "$OX_STATUS" == 1 || "$OX_LIST_INSTANCES" == 1 || "$OX_TEARDOWN" == 1 || "$OX_DEPENDENCIES_ONLY" == 1 || ( -n "$OX_ACTION" && "$OX_ACTION" != fresh ) ) ]]; then printf 'Invalid --new combination / --new 参数组合无效\n' >&2; exit 1; fi
if [[ "$OX_LIST_INSTANCES" == 1 && ( -n "$OX_ACTION" || "$OX_STATUS" == 1 || "$OX_TEARDOWN" == 1 || "$OX_DEPENDENCIES_ONLY" == 1 ) ]]; then printf 'Invalid --list combination / --list 参数组合无效\n' >&2; exit 1; fi
OX_INSTALL_ALLOW_CREATE=0
[[ "$OX_NEW_INSTANCE" != 1 && "$OX_ACTION" != fresh ]] || OX_INSTALL_ALLOW_CREATE=1
export OX_INSTALL_INSTANCE OX_INSTALL_ALLOW_CREATE
case "$OX_MODE" in dev|production) ;; *) printf 'Invalid mode: use dev or production\n' >&2; exit 1 ;; esac
if [[ -n "${OX_LAN_PORT:-}" ]] && [[ ! "$OX_LAN_PORT" =~ ^[0-9]+$ || ${#OX_LAN_PORT} -gt 5 ]] ; then printf 'Invalid --lan-port; use 1–65535.\n' >&2; exit 1; fi
if [[ -n "${OX_LAN_PORT:-}" ]] && (( 10#$OX_LAN_PORT < 1 || 10#$OX_LAN_PORT > 65535 )); then printf 'Invalid --lan-port; use 1–65535.\n' >&2; exit 1; fi
if [[ "$OX_CONFIRMED" == 1 && "$OX_TEARDOWN" != 1 ]]; then printf '%s\n' '--yes requires --teardown' >&2; exit 1; fi
OX_DEFAULT_PRODUCTION_ROOT="$PWD/OX-SelfHost"
if [[ "$(uname -s)" == Darwin ]]; then OX_DEFAULT_PRODUCTION_ROOT="$HOME/.local/share/ox-self-host/instance"; fi
if [[ "$OX_MODE" == dev ]]; then
  OX_INSTALL_ROOT="${OX_INSTALL_ROOT:-$HOME/Documents/OX-SelfHost-Dev}"
  OX_CLOUD_ORIGIN="${OX_CLOUD_ORIGIN:-${OX_DEV_CLOUD_ORIGIN:-http://localhost:3000}}"
else
  if [[ -z "$OX_INSTALL_ROOT" && -f "$PWD/source-path" && -f "$PWD/instance.json" ]]; then OX_INSTALL_ROOT="$PWD"; fi
  OX_INSTALL_ROOT="${OX_INSTALL_ROOT:-$OX_DEFAULT_PRODUCTION_ROOT}"
  OX_CLOUD_ORIGIN="${OX_CLOUD_ORIGIN:-https://portal.oxrecorder.cn}"
fi
export OX_INSTALL_MODE="$OX_MODE" OX_CLOUD_OVERRIDE
# Select language before platform checks so early failures are localized too.
OX_INSTALL_LANGUAGE="${OX_INSTALL_LANGUAGE:-zh}"
case "$OX_INSTALL_LANGUAGE" in
  zh|en) ;;
  *) printf '语言无效 / Invalid language: use zh or en.\n' >&2; exit 1 ;;
esac
translate() {
  if [[ "$OX_INSTALL_LANGUAGE" == en ]]; then printf '%s' "$2"; else printf '%s' "$1"; fi
}
say() {
  local chinese="$1" english="$2"; shift 2
  if [[ "$OX_INSTALL_LANGUAGE" == en ]]; then printf "$english" "$@"; else printf "$chinese" "$@"; fi
}
# Color is presentation only: preserve labels, raw logs and plain redirected output.
use_color() { [[ -t 1 && "${TERM:-dumb}" != dumb && -z "${NO_COLOR:-}" ]]; }
tone() {
  use_color || return 0
  case "$1" in
    success) printf '\033[32m' ;; warning) printf '\033[33m' ;;
    error) printf '\033[31m' ;; info) printf '\033[36m' ;;
    reset) printf '\033[0m' ;;
  esac
}
say_tone() { local level="$1"; shift; tone "$level"; say "$@"; tone reset; }
# Keep wizard output out of shell scrollback; redirected logs retain full history.
OX_MENU_SCREEN=0
menu_leave() {
  if [[ "$OX_MENU_SCREEN" == 1 ]]; then printf '\033[0m\033[?25h\033[?1049l'; OX_MENU_SCREEN=0; fi
}
trap menu_leave EXIT
menu_screen() {
  if [[ -t 0 && -t 1 && "${TERM:-dumb}" != dumb ]]; then
    if [[ "$OX_MENU_SCREEN" == 0 && "${OX_INSTALL_MENU_CHILD:-0}" != 1 && -z "$OX_ACTION" && "$OX_STATUS" == 0 && "$OX_DEPENDENCIES_ONLY" == 0 ]]; then printf '\033[?1049h'; OX_MENU_SCREEN=1; fi
    printf '\033[0m\033[2J\033[H'
  fi
}
menu_pause() {
  if [[ -t 0 && -t 1 && "${TERM:-dumb}" != dumb ]]; then
    local ignored
    say '\n按回车返回菜单。' '\nPress Enter to return to the menu.'
    IFS= read -r ignored || return 1
  fi
}
# Legacy flags must fail before discovery or any installation changes.
if [[ "$OX_TEARDOWN" == 1 ]]; then
  say '请在云端控制台删除实例及自托管服务器数据；安装程序不再支持 --teardown。已有数据未修改。\n' 'Delete the instance and self-hosted server data from the cloud portal; --teardown is no longer supported. Existing data was preserved.\n' >&2
  exit 1
fi
# Bash-only menus also work before Node.js or other dependencies are installed.
menu_select() {
  local destination="$1" title="$2" selected="$3"; shift 3
  local -a choices=("$@")
  local count=${#choices[@]} index key suffix answer painted=0 hint number_hint invalid_hint read_status read_started
  hint="$(translate '↑↓ 选择 · 回车确认 · Esc 返回 · q 退出' '↑↓ Select · Enter Confirm · Esc Back · q Exit')"
  number_hint="$(translate '输入序号，b 返回，q 退出' 'Number, b Back, q Exit')"
  invalid_hint="$(translate '请选择列表中的选项。' 'Please select a listed option.')"
  if [[ "$title" == '选择语言 / Select language:' ]]; then
    hint='↑↓ · Enter / 回车 · Esc / 返回 · q / 退出'
    number_hint='序号 / Number, b 返回 / Back, q 退出 / Exit'
    invalid_hint='请选择列表中的选项 / Please select a listed option.'
  fi
  local arrows=0
  if [[ -t 0 && -t 1 && "${TERM:-dumb}" != dumb ]]; then arrows=1; fi
  while true; do
    if [[ "$arrows" == 1 && "$painted" == 1 ]]; then printf '\033[%sA\r\033[J' "$((count + 3))"; fi
    tone info; printf '\n%s\n' "$title"; tone reset
    for ((index=0; index<count; index++)); do
      if [[ "$arrows" == 1 && "$index" == "$selected" ]]; then printf '  \033[1;7m %s) %s \033[0m\n' "$((index + 1))" "${choices[$index]}"
      else printf '  %s) %s\n' "$((index + 1))" "${choices[$index]}"; fi
    done
    if [[ "$arrows" == 1 ]]; then
      printf '%s\n' "$hint"
      painted=1
      # A bounded read keeps signal handling responsive on macOS Bash 3.2.
      while true; do
        read_status=0; read_started=$SECONDS
        IFS= read -r -s -n 1 -t 1 key || read_status=$?
        [[ "$read_status" != 0 ]] || break
        # Bash 3.2 returns 1 for both EOF and timeout; elapsed time distinguishes them.
        (( read_status > 128 || SECONDS > read_started )) || return 1
      done
      case "$key" in
        '') printf -v "$destination" '%s' "$selected"; return 0 ;;
        q|Q) return 1 ;;
        $'\033')
          suffix=''
          if IFS= read -r -s -n 1 -t 1 suffix && [[ "$suffix" == '[' || "$suffix" == O ]]; then
            if IFS= read -r -s -n 1 -t 1 key; then
              case "$key" in
                A) selected=$(((selected + count - 1) % count)) ;;
                B) selected=$(((selected + 1) % count)) ;;
                H) selected=0 ;;
                F) selected=$((count - 1)) ;;
              esac
            fi
          else printf -v "$destination" '%s' -1; return 0; fi ;;
        [1-9]) if (( key <= count )); then selected=$((key - 1)); fi ;;
      esac
    else
      printf '%s [%s]: ' "$number_hint" "$((selected + 1))"
      if ! IFS= read -r answer; then return 1; fi
      case "$answer" in
        b|B) printf -v "$destination" '%s' -1; return 0 ;;
        q|Q) return 1 ;;
        zh|zh-CN) answer=1 ;;
        en|English|english) answer=2 ;;
      esac
      answer="${answer:-$((selected + 1))}"
      if [[ "$answer" =~ ^[1-9][0-9]{0,5}$ ]] && (( answer <= count )); then printf -v "$destination" '%s' "$((answer - 1))"; return 0; fi
      printf '%s\n' "$invalid_hint"
    fi
  done
}
select_language() {
  local choice
  menu_screen
  if ! menu_select choice '选择语言 / Select language:' 0 '简体中文' 'English'; then return 1; fi
  [[ "$choice" != -1 ]] || return 0
  if [[ "$choice" == 1 ]]; then OX_INSTALL_LANGUAGE=en; else OX_INSTALL_LANGUAGE=zh; fi
  export OX_INSTALL_LANGUAGE
  mkdir -p "$(dirname "$OX_LANGUAGE_FILE")"
  local preference_tmp
  preference_tmp="$(mktemp "$(dirname "$OX_LANGUAGE_FILE")/.language.XXXXXX")"
  printf '%s\n' "$OX_INSTALL_LANGUAGE" > "$preference_tmp"
  mv -f "$preference_tmp" "$OX_LANGUAGE_FILE"
  OX_LANGUAGE_KNOWN=1
}
if [[ "${OX_INSTALL_MENU_CHILD:-0}" == 1 ]]; then
  OX_INSTALL_INTERACTIVE=0
elif [[ "$OX_SELECT_INTERACTIVE" == 1 && "$OX_LANGUAGE_KNOWN" == 0 && "$OX_LIST_INSTANCES" == 0 ]]; then
  select_language || exit 0
fi
export OX_INSTALL_LANGUAGE
progress_pid=''
stop_progress() { if [[ -n "$progress_pid" ]]; then kill "$progress_pid" 2>/dev/null || true; wait "$progress_pid" 2>/dev/null || true; progress_pid=''; fi; }
live_log_pid=''
stop_live_logs() {
  if [[ -n "$live_log_pid" ]]; then kill "$live_log_pid" 2>/dev/null || true; wait "$live_log_pid" 2>/dev/null || true; live_log_pid=''; fi
}
# Tell the browser setup page that the terminal is waiting for the administrator password.
awaiting_admin_marker=''
clear_awaiting_admin() { if [[ -n "$awaiting_admin_marker" ]]; then rm -f "$awaiting_admin_marker" 2>/dev/null || true; awaiting_admin_marker=''; fi; }
mark_awaiting_admin() {
  local home="${OX_SELF_HOST_HOME:-${OX_INSTALL_ROOT:-}}"
  # Never create or claim the installation directory just to publish a notice.
  [[ -n "$home" && -d "$home" && ! -L "$home" ]] || return 0
  awaiting_admin_marker="$home/setup-awaiting-admin.json"
  printf '{"status":"awaiting-admin","at":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$awaiting_admin_marker" 2>/dev/null || awaiting_admin_marker=''
}
trap 'stop_live_logs; stop_progress; clear_awaiting_admin; menu_leave' EXIT
trap 'stop_progress; clear_awaiting_admin; exit 130' INT
trap 'stop_progress; clear_awaiting_admin; exit 143' TERM
authorize_admin() {
  [[ "$EUID" != 0 ]] || return 0
  if ! command -v sudo >/dev/null; then
    say '需要 sudo 和管理员权限。请联系服务器管理员后重试。\n' 'sudo and administrator access are required. Contact the server administrator and retry.\n' >&2
    return 1
  fi
  # Never draw over password input, including after a long download expires sudo's cache.
  stop_progress
  local status=0
  if [[ "${OX_INSTALL_INTERACTIVE:-0}" == 1 || "${OX_SELECT_INTERACTIVE:-0}" == 1 ]]; then
    say '此步骤需要系统管理员授权。如有提示，请输入系统密码（输入时不显示）。\n' 'This step requires administrator authorization. If prompted, enter your system password (characters are hidden).\n'
    mark_awaiting_admin
    # sudo abandons a prompt after about five minutes. Keep the installation alive and let the user
    # ask again with Enter, so coming back after a long absence needs no cleanup and no rerun.
    while true; do
      status=0
      sudo -v || status=$?
      [[ "$status" != 0 && "$status" != 130 ]] || break
      mark_awaiting_admin
      say '未获得管理员授权（可能已超时）。按回车键重新输入密码，或按 Control-C 取消安装。\n' 'Administrator authorization was not obtained (it may have timed out). Press Enter to enter the password again, or Control-C to cancel the installation.\n'
      # Keep the browser notice fresh while waiting, however long the person is away. A background
      # heartbeat is used because bash 3.2 (macOS) cannot tell a read timeout from end of input.
      ( while true; do sleep 300; mark_awaiting_admin; done ) &
      local heartbeat=$!
      local answered=0
      IFS= read -r _ && answered=1
      kill "$heartbeat" 2>/dev/null || true; wait "$heartbeat" 2>/dev/null || true
      # End of input (no terminal to answer) gives up.
      [[ "$answered" == 1 ]] || break
    done
    clear_awaiting_admin
  else
    sudo -n -v || status=$?
  fi
  if [[ "$status" != 0 ]]; then
    say '未获得管理员授权，安装已停止。请在终端重新运行安装程序并选择同一实例；已有文件已保留。\n' 'Administrator authorization was not obtained; installation stopped. Rerun the installer in a terminal and select the same instance; existing files were preserved.\n' >&2
  fi
  return "$status"
}
progress() {
  local label="$1"; shift
  if [[ "$1" == sudo ]]; then
    authorize_admin || return
    shift; set -- sudo -n "$@"
  fi
  tone info; printf '%s\n' "$label"; tone reset
  # A command that draws its own terminal progress bar (for example limactl's image download)
  # must own the line; a spinner redrawing the same line would flicker against it.
  # OX_PROGRESS_NATIVE=2 means the command reports its own status on every output type.
  if [[ "${OX_PROGRESS_NATIVE:-0}" == 2 || ( "${OX_PROGRESS_NATIVE:-0}" == 1 && -t 1 ) ]]; then
    local native_status=0; "$@" || native_status=$?
    return "$native_status"
  fi
  (
    sleeper=''
    trap 'if [[ -n "$sleeper" ]]; then kill "$sleeper" 2>/dev/null || true; wait "$sleeper" 2>/dev/null || true; fi; exit 0' TERM INT
    frame=0
    while true; do
      if [[ -t 1 ]]; then
        symbols=('|' '/' '-' '\\'); printf '\r%s %s' "$label" "${symbols[$((frame % 4))]}"
        frame=$((frame + 1)); sleep 0.2 &
      else
        printf '%s …\n' "$label"; sleep 5 &
      fi
      sleeper=$!; wait "$sleeper"; sleeper=''
    done
  ) & progress_pid=$!
  local status=0; "$@" || status=$?
  stop_progress; printf '\n'; return "$status"
}
# Download with a "downloaded MB / total MB" line. The total comes from the Content-Length
# header; servers that stream without one show only the downloaded size.
download() {
  local label="$1" destination="$2" max_time="$3" url="$4" total status=0
  total="$(curl --silent --fail --location --head --proto '=https' --proto-redir '=https' --connect-timeout 20 --max-time 20 "$url" 2>/dev/null \
    | awk '/^HTTP\// {code=$2; value=""} tolower($1)=="content-length:" {value=$2} END {gsub(/\r/,"",value); if (code == 200 && value ~ /^[0-9]+$/) print value}')" || total=''
  rm -f "$destination"
  tone info; printf '%s\n' "$label"; tone reset
  (
    trap 'exit 0' TERM INT
    while true; do
      size=0; if [[ -f "$destination" ]]; then size="$(wc -c < "$destination")"; size="${size//[[:space:]]/}"; fi
      line="$(awk -v done="${size:-0}" -v total="${total:-0}" 'BEGIN { if (total > 0) { pct = done*100/total; if (pct > 100) pct = 100; fill = int(pct*30/100); bar = ""; for (i = 0; i < 30; i++) bar = bar (i < fill ? "=" : (i == fill ? ">" : " ")); printf "[%s] %3d%%  %.1f MB / %.1f MB", bar, pct, done/1048576, total/1048576 } else printf "%.1f MB", done/1048576 }')"
      if [[ -t 1 ]]; then printf '\r\033[K%s' "$line"; sleep 0.5 & else printf '%s\n' "$line"; sleep 5 & fi
      wait "$!"
    done
  ) & progress_pid=$!
  curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --retry 3 --connect-timeout 20 --max-time "$max_time" -o "$destination" "$url" || status=$?
  stop_progress
  if [[ "$status" == 0 ]]; then
    local final; final="$(wc -c < "$destination" | awk '{printf "%.1f MB", $1/1048576}')"
    if [[ -t 1 ]]; then printf '\r\033[K%s\n' "$final"; else printf '%s\n' "$final"; fi
  else printf '\n'; fi
  return "$status"
}
OX_INSTALL_PLATFORM="$(uname -s)"
export OX_INSTALL_PLATFORM
if [[ "$OX_INSTALL_PLATFORM" != 'Linux' && "$OX_INSTALL_PLATFORM" != 'Darwin' ]]; then
  say '此安装程序仅支持 Linux 和 macOS。请在支持的系统上运行。\n' 'This installer supports Linux and macOS only. Run it on a supported system.\n' >&2
  exit 1
fi
if [[ "$OX_INSTALL_PLATFORM" == Darwin && "$EUID" == 0 ]]; then
  say 'macOS 请使用登录账户运行安装脚本，不要使用 sudo。\n' 'On macOS, run the installer as your logged-in user without sudo.\n' >&2
  exit 1
fi
ask() {
  local destination="$1" question="$2" fallback="$3" answer=''
  if [[ "$OX_INSTALL_INTERACTIVE" == 1 ]]; then
    printf '%s' "$question"
    if [[ -n "$fallback" ]]; then printf ' [%s]' "$fallback"; fi
    printf ': '
    if ! IFS= read -r answer; then say '\n安装已取消。\n' '\nInstallation cancelled.\n' >&2; exit 1; fi
  fi
  printf -v "$destination" '%s' "${answer:-$fallback}"
}
absolute_path() {
  case "$1" in
    '~') printf '%s\n' "$HOME" ;;
    '~/'*) printf '%s/%s\n' "$HOME" "${1#\~/}" ;;
    /*) printf '%s\n' "$1" ;;
    *) printf '%s/%s\n' "$PWD" "$1" ;;
  esac
}
invalid_choice() {
  printf '%s\n' "$1" >&2
  if [[ "$OX_INSTALL_INTERACTIVE" != 1 ]]; then
    say '请在终端直接运行安装脚本，按问题填写选项。\n' 'Run the installer in a terminal and answer the setup questions.\n' >&2
    exit 1
  fi
}
# This embedded source keeps the downloadable installer independent of a checkout.
write_host_helper() {
  cat <<'OX_HOST_PY'
#!/usr/bin/python3
"""Installer-owned Ubuntu host policy. Persistent execution uses system Python only."""
import base64
import datetime
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import signal
import stat
import subprocess
import sys
import tempfile

STATE = Path('/var/lib/ox-self-host-host-reliability')
HELPER = Path('/usr/local/libexec/ox-self-host-host-reliability.py')
SLEEP = ('sleep', 'suspend', 'hibernate', 'hybrid-sleep', 'suspend-then-hibernate')
PACKAGES = ('docker.io', 'docker-ce', 'docker-ce-cli', 'docker-ce-rootless-extras', 'containerd', 'containerd.io', 'runc', 'docker-compose-v2', 'docker-compose-plugin', 'docker-buildx-plugin')
TIMER = 'ox-self-host-host-audit.timer'
DOCKER = Path('/etc/docker/daemon.json')
CAPABILITY = 'host-reliability-v1'


def command(*args):
    try:
        result = subprocess.run(args, capture_output=True, text=True, timeout=20,
                                env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LC_ALL': 'C'})
        return result.returncode, result.stdout[:131072]
    except (OSError, subprocess.TimeoutExpired):
        return 127, ''


def required(*args):
    code, output = command(*args)
    if code:
        raise RuntimeError('Host command failed: ' + args[0] + ' ' + args[1])
    return output


def trusted(path, missing=True):
    """Refuse symlinks and writable/non-root ancestors at the privilege boundary."""
    for item in reversed((path, *path.parents)):
        try:
            info = item.lstat()
        except FileNotFoundError:
            if missing:
                continue
            raise
        if stat.S_ISLNK(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
            raise RuntimeError('Unsafe host path: ' + str(item))


def sync_directory(directory):
    descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
    try:
        os.fsync(descriptor)
    finally:
        os.close(descriptor)


def atomic(path, data, mode=0o600):
    trusted(path)
    path.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
    fd, temporary = tempfile.mkstemp(prefix='.ox-', dir=path.parent)
    try:
        os.fchmod(fd, mode)
        with os.fdopen(fd, 'wb') as stream:
            stream.write(data)
            stream.flush()
            os.fsync(stream.fileno())
        os.replace(temporary, path)
        sync_directory(path.parent)
    finally:
        if os.path.exists(temporary):
            os.unlink(temporary)


def json_write(path, value, mode=0o600):
    atomic(path, (json.dumps(value, sort_keys=True) + '\n').encode(), mode)


def read_journal():
    trusted(STATE / 'journal.json')
    try:
        value = json.loads((STATE / 'journal.json').read_text())
    except FileNotFoundError:
        return {'version': 1, 'phase': 'new', 'files': {}, 'participants': []}
    if value.get('version') != 1 or not isinstance(value.get('files'), dict):
        raise RuntimeError('Unrecognized host journal; preserved without changes')
    return value


def snapshot(path):
    trusted(path.parent)
    if path.is_symlink():
        # Only preserve known unit masks; never follow an arbitrary symlink.
        if path.parent == Path('/etc/systemd/system') and os.readlink(path) == '/dev/null':
            return {'kind': 'mask'}
        raise RuntimeError('Unsafe host symlink: ' + str(path))
    trusted(path)
    if not path.exists():
        return {'kind': 'absent'}
    if not path.is_file() or path.stat().st_size > 262144:
        raise RuntimeError('Invalid host configuration: ' + str(path))
    return {'kind': 'file', 'data': base64.b64encode(path.read_bytes()).decode(), 'mode': stat.S_IMODE(path.stat().st_mode)}


def put(path, value):
    if value['kind'] == 'file':
        atomic(path, base64.b64decode(value['data']), value['mode'])
    elif value['kind'] == 'mask':
        trusted(path.parent)
        if not path.is_symlink():
            path.parent.mkdir(parents=True, exist_ok=True)
            os.symlink('/dev/null', path)
            sync_directory(path.parent)
    else:
        path.unlink(missing_ok=True)
        sync_directory(path.parent)


def text_value(text, mode=0o644):
    return {'kind': 'file', 'data': base64.b64encode(text.encode()).decode(), 'mode': mode}


def install_file(journal, path, after):
    key = str(path)
    before = snapshot(path)
    saved = journal['files'].get(key)
    if saved:
        if before != saved['after'] and before != saved['before']:
            raise RuntimeError('Administrator configuration changed: ' + key)
        if saved['after'] != after:
            saved['pendingBefore'] = before
        saved['after'] = after
    else:
        journal['files'][key] = {'before': before, 'after': after}
    # Persist intent before mutation, including when interrupted between writes.
    json_write(STATE / 'journal.json', journal)
    if before != after:
        put(path, after)
    journal['files'][key].pop('pendingBefore', None)
    json_write(STATE / 'journal.json', journal)


def power():
    batteries, online = [], []
    for supply in Path('/sys/class/power_supply').glob('*'):
        try:
            kind = (supply / 'type').read_text().strip()
            if kind == 'Battery' and not ((supply / 'scope').exists() and (supply / 'scope').read_text().strip() == 'Device'):
                batteries.append(supply)
            if kind in ('Mains', 'USB', 'USB_C', 'USB_PD') and (supply / 'online').exists():
                online.append((supply / 'online').read_text().strip() == '1')
        except OSError:
            continue
    return bool(batteries), any(online) if batteries else None


def critical_power():
    code, output = command('busctl', 'call', 'org.freedesktop.UPower', '/org/freedesktop/UPower', 'org.freedesktop.UPower', 'GetCriticalAction')
    return code == 0 and '"PowerOff"' in output


def audit(journal=None):
    now = datetime.datetime.now(datetime.timezone.utc).isoformat()
    if journal is None:
        journal = read_journal()
    configured = journal.get('phase') == 'applied'
    battery, ac = power()
    checks = {}
    checks['sleep'] = all(command('systemctl', 'is-enabled', name + '.target')[1].strip() == 'masked' for name in SLEEP)
    code, apt = command('apt-config', 'dump')
    checks['automatic-reboot'] = code == 0 and bool(re.search(r'^Unattended-Upgrade::Automatic-Reboot "false";', apt, re.M))
    checks['security-updates'] = code == 0 and bool(re.search(r'^APT::Periodic::Unattended-Upgrade "1";', apt, re.M))
    checks['security-updates'] = checks['security-updates'] and command('systemctl', 'is-enabled', 'apt-daily-upgrade.timer')[1].strip() == 'enabled' and command('systemctl', 'is-active', 'apt-daily-upgrade.timer')[1].strip() == 'active'
    checks['runtime-updates'] = code == 0 and all(('"' + name.replace('.', '[.]') + '$"') in apt for name in PACKAGES)
    nr = Path('/etc/needrestart/conf.d/99-ox-self-host.conf')
    checks['service-restarts'] = command('dpkg-query', '-W', '-f=${Status}', 'needrestart')[1].strip() == 'install ok installed' and nr.exists() and str(nr) in journal['files'] and snapshot(nr) == journal['files'][str(nr)]['after']
    # needrestart chooses the first matching regex in sorted order. An earlier
    # administrator rule can otherwise defeat an intact OX drop-in.
    try:
        trusted(Path('/etc/needrestart/needrestart.conf'), missing=False)
        for other in Path('/etc/needrestart/conf.d').glob('*.conf'):
            trusted(other, missing=False)
        units = ['docker.service', 'containerd.service', 'NetworkManager.service', 'systemd-networkd.service', 'systemd-resolved.service',
                 'ox-self-host.service', 'ox-self-host-agent.service', 'ox-self-host-' + 'a' * 24 + '.service', 'ox-self-host-agent-' + 'a' * 24 + '.service']
        units += ['user@' + str(row['uid']) + '.service' for row in journal.get('participants', []) if row['uid'] != 0]
        probe = "our %nrconf = (override_rc => {}); my $loaded = do '/etc/needrestart/needrestart.conf'; die ($@ || $!) unless defined $loaded; for my $unit (@ARGV) { my $allowed=1; for my $re (sort keys %{$nrconf{override_rc}}) { if ($unit =~ /$re/) { $allowed=$nrconf{override_rc}->{$re}; last; } } exit 1 if $allowed; } print qq(protected\\n);"
        rc, effective = command('perl', '-e', probe, *units)
        checks['service-restarts'] = checks['service-restarts'] and rc == 0 and effective.strip() == 'protected'
    except (OSError, RuntimeError):
        checks['service-restarts'] = False
    code, info = command('docker', '--host', 'unix:///var/run/docker.sock', 'info', '--format', '{{json .LiveRestoreEnabled}}')
    checks['docker-live-restore'] = code == 0 and info.strip() == 'true'
    checks['docker-boot'] = command('systemctl', 'is-enabled', 'docker.service')[1].strip() == 'enabled'
    checks['critical-battery'] = not battery or critical_power()
    checks['audit-timer'] = command('systemctl', 'is-active', TIMER)[1].strip() == 'active'
    drift = []
    for filename, saved in journal['files'].items():
        try:
            if snapshot(Path(filename)) != saved['after']:
                drift.append('configuration-changed')
        except (OSError, RuntimeError):
            drift.append('configuration-unreadable')
    maintenance = ['service-restarts-pending'] if journal.get('pendingRestarts') else []
    if Path('/var/run/reboot-required').exists():
        maintenance.append('reboot-required')
    code, upgrades = command('apt', 'list', '--upgradable')
    if code == 0 and any(re.match(r'(' + '|'.join(re.escape(p) for p in PACKAGES) + r')/', line) for line in upgrades.splitlines()):
        maintenance.append('runtime-updates-pending')
    nr_code, nr_output = command('needrestart', '-b', '-r', 'l')
    if nr_code == 0 and 'NEEDRESTART-SVC:' in nr_output:
        maintenance.append('service-restarts-pending')
    warnings = ['boot-recovery-unverified', 'firmware-ups-cooling-check', 'package-restarts-not-fully-controlled']
    if Path('/run/systemd/shutdown/scheduled').exists():
        warnings.append('shutdown-scheduled')
    if code != 0 or nr_code != 0:
        warnings.append('maintenance-check-unavailable')
    if battery and ac is not True:
        warnings.append('battery-power')
    code, sync = command('timedatectl', 'show', '--property=NTPSynchronized', '--value')
    if code or sync.strip() != 'yes':
        warnings.append('clock-not-synchronized')
    code, connections = command('nmcli', '-t', '-f', 'UUID,TYPE', 'connection', 'show', '--active')
    if code == 0:
        for row in connections.splitlines():
            uuid = row.split(':')[0]
            if not re.fullmatch(r'[a-fA-F0-9-]{36}', uuid) or not any(t in row for t in ('ethernet', 'wireless')):
                continue
            rc, access = command('nmcli', '-g', 'connection.autoconnect,connection.permissions', 'connection', 'show', uuid)
            lines = access.splitlines()
            if rc or not lines or lines[0] != 'yes' or (len(lines) > 1 and lines[1]):
                warnings.append('network-login-dependency')
            if 'wireless' in row:
                warnings.append('wired-network-recommended')
    else:
        warnings.append('network-boot-unverified')
    if Path('/etc/crypttab').exists() and any(line.strip() and not line.lstrip().startswith('#') for line in Path('/etc/crypttab').read_text().splitlines()):
        warnings.append('encrypted-boot-check')
    try:
        disk = os.statvfs('/var/lib')
        if disk.f_bavail * disk.f_frsize < 1073741824:
            warnings.append('host-disk-low')
    except OSError:
        warnings.append('host-disk-unavailable')
    return {'capability': CAPABILITY, 'profileVersion': 1, 'observedAt': now,
            'status': 'protected' if configured and all(checks.values()) and not drift else 'attention' if configured else 'not-assessed',
            'checks': checks, 'drift': sorted(set(drift)), 'maintenance': maintenance,
            'warnings': sorted(set(warnings)), 'battery': battery, 'acOnline': ac}


def rollback(journal):
    conflicts = []
    # Stop only our audit job. No application or Docker restart.
    command('systemctl', 'disable', '--now', TIMER)
    for filename, saved in reversed(list(journal['files'].items())):
        path = Path(filename)
        try:
            current = snapshot(path)
            if current == saved['before']:
                continue
            if current != saved['after'] and current != saved.get('pendingBefore'):
                conflicts.append(filename)
                continue
            if current['kind'] == 'mask':
                path.unlink()
            put(path, saved['before'])
        except (OSError, RuntimeError):
            conflicts.append(filename)
    command('systemctl', 'daemon-reload')
    command('systemctl', 'reload', 'docker.service')
    if str(Path('/etc/UPower/UPower.conf')) in journal['files']:
        # Releasing the OX override must not unexpectedly change active battery handling.
        journal['pendingRestarts'] = ['upower']
    journal['phase'] = 'restore-conflict' if conflicts else 'restored'
    journal['conflicts'] = conflicts
    json_write(STATE / 'journal.json', journal)
    json_write(STATE / 'status.json', audit(journal), 0o644)
    if conflicts:
        raise RuntimeError('Restoration needs review: ' + '; '.join(conflicts))


def configure(journal, participant):
    if journal['phase'] not in ('new', 'applied', 'restored'):
        rollback(journal)
    if journal['phase'] == 'restored':
        journal = {'version': 1, 'phase': 'new', 'files': {}, 'participants': []}
    if not Path('/etc/os-release').exists() or not re.search(r'^ID=["\']?ubuntu["\']?$', Path('/etc/os-release').read_text(), re.M):
        raise RuntimeError('Server mode requires Ubuntu')
    required('systemctl', 'show-environment')
    if Path('/run/systemd/shutdown/scheduled').exists():
        raise RuntimeError('A shutdown is already scheduled; review it before installation')
    if command('systemctl', 'is-enabled', 'apt-daily-upgrade.timer')[1].strip() != 'enabled':
        raise RuntimeError('Enable Ubuntu apt-daily-upgrade.timer before configuring server mode')
    for package in ('unattended-upgrades', 'needrestart'):
        if command('dpkg-query', '-W', '-f=${Status}', package)[1].strip() != 'install ok installed':
            raise RuntimeError('Install Ubuntu package before configuring server mode: ' + package)
    battery, ac = power()
    if battery and ac is not True:
        raise RuntimeError('Connect AC power before configuring server mode')
    # Reject all conflicts before any writes on an already protected host.
    if journal['phase'] == 'applied':
        for filename, saved in journal['files'].items():
            current = snapshot(Path(filename))
            if current != saved['after'] and current != saved.get('pendingBefore'):
                raise RuntimeError('Administrator configuration changed: ' + filename)
        # Finish an interrupted addition of another participating account. The
        # recorded prior applied bytes distinguish our interrupted write from drift.
        for filename, saved in journal['files'].items():
            if 'pendingBefore' in saved:
                put(Path(filename), saved['after'])
                saved.pop('pendingBefore')
        json_write(STATE / 'journal.json', journal)
        report = audit(journal)
        if report['status'] != 'protected':
            raise RuntimeError('Existing server mode needs operator review; run host-check')
        if participant not in journal['participants']:
            nr = Path('/etc/needrestart/conf.d/99-ox-self-host.conf')
            if participant['uid'] != 0:
                data = nr.read_text() + r'$nrconf{override_rc}->{qr(^user\@' + str(participant['uid']) + r'\.service$)} = 0;' + '\n'
                install_file(journal, nr, text_value(data))
            journal['participants'].append(participant)
            json_write(STATE / 'journal.json', journal)
        json_write(STATE / 'status.json', report, 0o644)
        return report
    journal['phase'] = 'applying'
    if participant not in journal['participants']:
        journal['participants'].append(participant)
    json_write(STATE / 'journal.json', journal)
    try:
        install_file(journal, HELPER, text_value(Path(__file__).read_text(), 0o755))
        install_file(journal, Path('/etc/systemd/sleep.conf.d/90-ox-self-host.conf'), text_value('[Sleep]\nAllowSuspend=no\nAllowHibernation=no\nAllowHybridSleep=no\nAllowSuspendThenHibernate=no\n'))
        for name in SLEEP:
            target = Path('/etc/systemd/system/' + name + '.target')
            before = snapshot(target)
            if before['kind'] not in ('absent', 'mask'):
                raise RuntimeError('Existing sleep target override must be reviewed: ' + str(target))
            install_file(journal, target, {'kind': 'mask'})
        apt = 'APT::Periodic::Update-Package-Lists "1";\nAPT::Periodic::Unattended-Upgrade "1";\nUnattended-Upgrade::Automatic-Reboot "false";\nUnattended-Upgrade::Automatic-Reboot-WithUsers "false";\nUnattended-Upgrade::Package-Blacklist {\n'
        apt += ''.join('  "' + name.replace('.', '[.]') + '$";\n' for name in PACKAGES) + '};\n'
        install_file(journal, Path('/etc/apt/apt.conf.d/99-ox-self-host'), text_value(apt))
        # Merge Perl hash entries: never replace other administrator exclusions.
        units = ['ox-self-host(?:-agent)?(?:-[a-f0-9]{24})?', 'docker', 'containerd', 'NetworkManager', 'systemd-networkd', 'systemd-resolved']
        uid = participant['uid']
        if uid != 0:
            units.append('user@' + str(uid))
        nr = '# Keep critical processes running until reviewed maintenance.\n' + ''.join('$nrconf{override_rc}->{qr(^' + unit.replace('@', r'\@') + r'\.service$)} = 0;' + '\n' for unit in units)
        install_file(journal, Path('/etc/needrestart/conf.d/99-ox-self-host.conf'), text_value(nr))
        before = snapshot(DOCKER)
        daemon = json.loads(base64.b64decode(before['data'])) if before['kind'] == 'file' else {}
        if not isinstance(daemon, dict):
            raise RuntimeError('Docker daemon configuration must be an object')
        daemon['live-restore'] = True
        candidate = STATE / 'docker-candidate.json'
        json_write(candidate, daemon)
        required('dockerd', '--validate', '--config-file=' + str(candidate))
        candidate.unlink()
        install_file(journal, DOCKER, text_value(json.dumps(daemon, indent=2) + '\n', before.get('mode', 0o644)))
        required('systemctl', 'daemon-reload')
        required('systemctl', 'reload', 'docker.service')
        if battery and not critical_power():
            up = Path('/etc/UPower/UPower.conf')
            data = up.read_text() if up.exists() else '[UPower]\n'
            if '[UPower]' not in data:
                raise RuntimeError('UPower configuration needs review')
            data = re.sub(r'^CriticalPowerAction=.*\n?', '', data, flags=re.M)
            data = data.replace('[UPower]', '[UPower]\nCriticalPowerAction=PowerOff', 1)
            install_file(journal, up, text_value(data))
            required('systemctl', 'restart', 'upower.service')
            if not critical_power():
                raise RuntimeError('Critical battery shutdown could not be verified')
        service = '[Unit]\nDescription=OX host reliability observation (read only)\n[Service]\nType=oneshot\nExecStart=/usr/bin/python3 -I ' + str(HELPER) + ' audit\nTimeoutStartSec=240\nUMask=0022\nNoNewPrivileges=yes\nProtectSystem=strict\nReadWritePaths=' + str(STATE) + '\nProtectHome=read-only\n'
        install_file(journal, Path('/etc/systemd/system/ox-self-host-host-audit.service'), text_value(service))
        install_file(journal, Path('/etc/systemd/system/' + TIMER), text_value('[Unit]\nDescription=Observe OX host reliability\n[Timer]\nOnBootSec=60\nOnUnitActiveSec=300\n[Install]\nWantedBy=timers.target\n'))
        required('systemctl', 'daemon-reload')
        required('systemctl', 'enable', '--now', TIMER)
        journal['phase'] = 'applied'
        report = audit(journal)
        if report['status'] != 'protected':
            raise RuntimeError('Protection verification failed: ' + ', '.join(k for k, v in report['checks'].items() if not v))
        json_write(STATE / 'journal.json', journal)
        json_write(STATE / 'status.json', report, 0o644)
        return report
    except BaseException:
        rollback(journal)
        raise


def main():
    action = sys.argv[1]
    if action == 'check':
        # No lock creation, sudo, writes, or privileged probes in read-only mode.
        try:
            filename = STATE / 'status.json'
            trusted(filename, missing=False)
            if filename.stat().st_size > 16384:
                raise RuntimeError('Host observation is too large')
            report = json.loads(filename.read_text())
            observed = datetime.datetime.fromisoformat(report.get('observedAt', ''))
            age = (datetime.datetime.now(datetime.timezone.utc) - observed).total_seconds()
            if age > 600 or age < -60:
                report['status'] = 'unknown'
            print(json.dumps(report))
        except FileNotFoundError:
            print(json.dumps({'capability': CAPABILITY, 'status': 'not-assessed'}))
        return
    if os.geteuid() != 0:
        raise RuntimeError('System authorization is required')
    os.umask(0o022)
    if action == 'review' and not STATE.exists():
        raise RuntimeError('No OX host policy has been applied')
    trusted(STATE)
    STATE.mkdir(parents=True, exist_ok=True, mode=0o755)
    sync_directory(STATE.parent)
    trusted(STATE / 'lock')
    fd = os.open(STATE / 'lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
    with os.fdopen(fd, 'w') as lock:
        fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
        journal = read_journal()
        if action == 'audit':
            json_write(STATE / 'status.json', audit(journal), 0o644)
        elif action == 'review':
            # Private local identities are never included in the public audit snapshot.
            print(json.dumps({'participants': journal['participants'], 'revision': hashlib.sha256(json.dumps(journal, sort_keys=True).encode()).hexdigest()}))
        elif action == 'restore':
            expected = sys.argv[2]
            actual = hashlib.sha256(json.dumps(journal, sort_keys=True).encode()).hexdigest()
            if expected != actual:
                raise RuntimeError('Host policy changed since review; inspect again')
            rollback(journal)
            print(json.dumps(audit(journal)))
        elif action == 'configure':
            directory = os.path.abspath(sys.argv[2])
            uid = int(sys.argv[3])
            if uid < 0 or len(directory) > 4096 or any(ord(c) < 32 for c in directory):
                raise RuntimeError('Invalid participant identity')
            print(json.dumps(configure(journal, {'directory': directory, 'uid': uid})))
        else:
            raise RuntimeError('Unknown host action')


if __name__ == '__main__':
    for sig in (signal.SIGTERM, signal.SIGINT):
        signal.signal(sig, lambda *_: sys.exit(130))
    try:
        main()
    except Exception as error:
        print('Host reliability: ' + str(error), file=sys.stderr)
        sys.exit(1)
OX_HOST_PY
}
run_host_helper() {
  local operation="$1" temporary result=0
  shift
  [[ "$OX_INSTALL_PLATFORM" == Linux && "$OX_MODE" == production ]] || { say '主机保护仅适用于 Ubuntu 生产安装。\n' 'Host protection is only available for Ubuntu production installations.\n' >&2; return 1; }
  [[ -x /usr/bin/python3 ]] || { say '需要系统 Python 3。请安装 python3 后重试。\n' 'System Python 3 is required. Install python3 and retry.\n' >&2; return 1; }
  temporary="$(mktemp)" || return
  write_host_helper > "$temporary"
  if [[ "$operation" == check ]]; then
    /usr/bin/python3 -I "$temporary" "$operation" "$@" || result=$?
  elif [[ "$EUID" == 0 ]]; then
    /usr/bin/python3 -I "$temporary" "$operation" "$@" || result=$?
  else
    sudo -n /usr/bin/python3 -I "$temporary" "$operation" "$@" || result=$?
  fi
  rm -f "$temporary"
  return "$result"
}
confirm_host_mode() {
  local choice=0
  say 'Ubuntu 服务器模式将影响此电脑的所有账户和实例：禁止睡眠与休眠，启用常规安全更新但禁止自动重启，推迟容器运行环境更新及关键服务重启，启用 Docker 持续运行和每五分钟只读检查。屏幕锁定保留；电池严重不足时正常关机。需要维护的更新由管理员处理。\n将重新加载 Docker 配置；必要时重启电源管理服务，不重启电脑或 OX 服务。数据保留；可通过 host-restore 撤销，但不会撤回已安装的安全更新。\n' 'Ubuntu server mode affects every account and instance on this computer: disable sleep/hibernation, enable routine security updates without automatic reboots, defer container-runtime updates and critical service restarts, enable Docker live restore and read-only checks every five minutes. Screen locking remains; critically low batteries shut down normally. An operator must perform deferred maintenance.\nDocker configuration will be reloaded; the power-management daemon may restart. The computer and OX services will not be restarted. Data is retained. Use host-restore to reverse configuration; installed security updates cannot be undone.\n'
  say '主机：%s\n实例目录：%s\n' 'Host: %s\nInstance directory: %s\n' "$(hostname)" "$OX_INSTALL_ROOT"
  [[ "$OX_ACCEPT_SERVER_MODE" != 1 ]] || return 0
  if [[ "$OX_SELECT_INTERACTIVE" != 1 ]]; then
    say '新的 Ubuntu 生产安装需要 --accept-server-mode。尚未进行云端登记。\n' 'New Ubuntu production installations require --accept-server-mode. Cloud enrollment has not started.\n' >&2
    return 1
  fi
  menu_select choice "$(translate '确认此电脑用于持续运行的自托管服务' 'Acknowledge continuous self-hosted operation on this computer')" 0 \
    "$(translate '我确认此电脑用于自托管服务，了解上述影响并同意启用服务器模式' 'I confirm this computer is for self-hosted services, understand these consequences and accept server mode')" \
    "$(translate '取消，不修改主机' 'Cancel without changing the host')" || return 1
  [[ "$choice" == 0 ]] || return 1
  OX_ACCEPT_SERVER_MODE=1
}
configure_host_mode() {
  [[ "$OX_INSTALL_PLATFORM" == Linux && "$OX_MODE" == production ]] || { say '主机保护仅适用于 Ubuntu 生产安装。\n' 'Host protection is only available for Ubuntu production installations.\n' >&2; return 1; }
  confirm_host_mode || return
  authorize_admin || return
  local -a privileged=()
  [[ "$EUID" == 0 ]] || privileged=(sudo)
  local package missing=0
  for package in python3 unattended-upgrades needrestart; do
    [[ "$(dpkg-query -W -f='${Status}' "$package" 2>/dev/null || true)" == 'install ok installed' ]] || missing=1
  done
  if [[ "$missing" == 1 ]]; then
    say '缺少 Ubuntu 更新控制工具，需要先安装。安装仅缺少的工具；现有服务不应自动重启。\n' 'Ubuntu update-control tools are missing and must be installed first. Only missing tools are installed; automatic service restarts are suppressed.\n'
    progress "$(translate '正在更新软件包列表' 'Updating package lists')" "${privileged[@]}" apt-get -q update || return
    progress "$(translate '正在准备更新控制工具' 'Preparing update-control tools')" "${privileged[@]}" env NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get -q install -y --no-upgrade python3 unattended-upgrades needrestart || return
  fi
  progress "$(translate '正在应用并检查主机保护' 'Applying and verifying host protection')" run_host_helper configure "$OX_INSTALL_ROOT" "$(id -u)"
}
restore_host_mode() {
  [[ "$OX_INSTALL_PLATFORM" == Linux && "$OX_MODE" == production ]] || { say '主机保护仅适用于 Ubuntu 生产安装。\n' 'Host protection is only available for Ubuntu production installations.\n' >&2; return 1; }
  local review revision choice=0
  authorize_admin || return
  review="$(run_host_helper review)" || return
  say '将恢复此主机原先的电源、更新和 Docker 设置，所有列出的实例都会失去 OX 主机保护。服务和数据保留；此操作立即执行，开始后无法取消。管理员之后修改的设置会保留并报告冲突。\n受影响的安装及审查版本：\n%s\n' 'Restore this host’s previous power, update and Docker settings. All listed installations lose OX host protection. Services and data are retained. This runs immediately and cannot be cancelled after it starts. Later administrator changes are preserved and reported as conflicts.\nAffected installations and reviewed revision:\n%s\n' "$review"
  [[ "$OX_SELECT_INTERACTIVE" == 1 ]] || { say '恢复主机设置需要交互确认。\n' 'Restoring host settings requires interactive acknowledgement.\n' >&2; return 1; }
  menu_select choice "$(translate '确认恢复主机设置' 'Acknowledge host configuration restoration')" 0 \
    "$(translate '取消' 'Cancel')" "$(translate '我了解所有实例将失去保护，确认恢复' 'I acknowledge all instances lose protection; restore settings')" || return 1
  [[ "$choice" == 1 ]] || return 1
  revision="$(printf '%s' "$review" | /usr/bin/python3 -c 'import json,sys; print(json.load(sys.stdin)["revision"])')" || return
  progress "$(translate '正在恢复主机设置' 'Restoring host settings')" run_host_helper restore "$revision"
}

# Named installer-managed instances, scoped by environment. The override is for isolated tests.
if [[ "$OX_INSTALL_PLATFORM" == Darwin ]]; then
  OX_HOST_REGISTRY="${OX_SELF_HOST_REGISTRY_DIRECTORY:-/Users/Shared/OX-SelfHost}"
else
  OX_HOST_REGISTRY="${OX_SELF_HOST_REGISTRY_DIRECTORY:-/var/lib/ox-self-host}"
fi
export OX_HOST_REGISTRY
host_installation() {
  node - "$1" "$OX_INSTALL_ROOT" "$OX_MODE" <<'HOST_NODE'
const fs = require('fs'), path = require('path'), os = require('os'), https = require('https');
let [action, requested, mode] = process.argv.slice(2);
const registry = process.env.OX_HOST_REGISTRY;
let instanceName = process.env.OX_INSTALL_INSTANCE || '';
if (instanceName && !/^[a-z0-9][a-z0-9-]{0,62}$/.test(instanceName)) throw Error('Invalid instance name');
const recordFile = (recordMode, name) => path.join(registry, recordMode + (name === 'default' ? '' : '--' + name) + '.json');
const legacyFile = path.join(registry, 'installation.json');
const message = (en, zh) => process.env.OX_INSTALL_LANGUAGE === 'en' ? en : zh;
function portalAddresses(config) {
  if (!config?.lanOrigin) return;
  let lan;
  try { lan = new URL(config.lanOrigin); } catch { return; }
  if (lan.protocol !== 'https:') return;
  const local = new URL('https://127.0.0.1');
  local.port = String(config.port || lan.port || 443);
  console.log(message('Portal on this computer: ', '本机控制台：') + local.origin);
  console.log(message('Configured LAN endpoint (not verified): ', '已配置的局域网端点（未经验证）：') + lan.origin);
  const observed = Object.entries(os.networkInterfaces()).flatMap(([name, rows]) => (rows || []).filter(row => row.family === 'IPv4' && !row.internal && !row.address.startsWith('169.254.')).map(row => name + ': ' + row.address));
  console.log(message('Current interfaces: ', '当前网卡地址：') + observed.join(', '));
  console.log(message('Automatic address policy is managed in the cloud instance settings.', '自动地址策略可在云端实例设置中管理。'));
  if (require('net').isIPv4(lan.hostname) && !Object.values(os.networkInterfaces()).flatMap(rows => rows || []).some(row => row.address === lan.hostname)) {
    console.log(message('The saved LAN IP is no longer assigned to this computer. Use the local portal above; restore the saved IP through your router or use endpoint migration to update the registered address and certificate. The saved endpoint has not been changed.', '保存的局域网 IP 已不属于本机。请使用上方本机控制台；通过路由器恢复原 IP，或使用端点迁移更新登记地址和证书。保存的端点未被修改。'));
  }
}
const read = file => { try { if (fs.lstatSync(file).isSymbolicLink()) throw Error('Installation state must not use symbolic links'); return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return null; if (['status', 'discover'].includes(action)) throw Error(message('Cannot read installation state: ', '无法读取安装状态：') + path.basename(file)); throw error; } };
const missingPath = file => { try { fs.lstatSync(file); return false; } catch (error) { if (error.code === 'ENOENT') return true; throw error; } };
const alive = pid => { if (!Number.isSafeInteger(pid) || pid < 1) throw Error('Invalid process lock'); try { process.kill(pid, 0); return true; } catch (error) { if (error.code === 'ESRCH') return false; throw error; } };
(async () => {
  if (fs.existsSync(registry) && (fs.lstatSync(registry).isSymbolicLink() || !fs.statSync(registry).isDirectory())) throw Error('Invalid host registry');
  const validateRecord = record => {
    if (record && (record.version !== 1 || typeof record.directory !== 'string' || !path.isAbsolute(record.directory) || !['dev', 'production'].includes(record.mode) || (record.name !== undefined && !/^[a-z0-9][a-z0-9-]{0,62}$/.test(record.name)))) throw Error('Invalid host installation record');
    return record;
  };
  function loadRecords() {
    const names = fs.existsSync(registry) ? fs.readdirSync(registry).filter(name => /^(dev|production)(--[a-z0-9][a-z0-9-]{0,62})?\.json$/.test(name)) : [];
    const records = names.map(file => {
      const record = validateRecord(read(path.join(registry, file)));
      const expected = path.basename(recordFile(record.mode, record.name || 'default'));
      if (file !== expected) throw Error(message('Host registration identity mismatch', '安装登记身份不匹配'));
      return { ...record, name: record.name || 'default' };
    });
    const legacy = validateRecord(read(legacyFile));
    if (legacy) records.push({ ...legacy, name: 'default' });
    const unique = new Map();
    for (const record of records) {
      const key = record.mode + '/' + record.name;
      if (unique.has(key) && (unique.get(key).directory !== record.directory || unique.get(key).uid !== record.uid)) throw Error(message('Conflicting registrations; reconcile before continuing', '安装登记冲突，请先核对'));
      unique.set(key, record);
    }
    return [...unique.values()];
  }
  const legacy = validateRecord(read(legacyFile));
  const records = loadRecords();
  if (action === 'list') {
    const clean = value => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
    for (const record of records.filter(record => record.uid === process.getuid()).sort((a, b) => (a.mode + a.name).localeCompare(b.mode + b.name))) {
      const lifecycle = missingPath(record.directory) ? null : read(path.join(record.directory, 'lifecycle.json'));
      let config = null; try { config = missingPath(record.directory) ? null : read(path.join(record.directory, 'instance.json')); } catch {}
      const state = missingPath(record.directory) ? message('folder missing', '目录丢失')
        : lifecycle?.authorization ? lifecycle.status === 'erased' && lifecycle.acknowledgedAt
          ? message('deleted — cleanup pending', '已删除 — 待清理安装') : message('deletion pending', '正在删除')
        : lifecycle?.status === 'retained' ? message('removed — data retained', '已移除 — 数据已保留')
        : !config ? message('setup incomplete — discard or resume', '安装未完成 — 请清理或继续安装')
        : message('registered; inspect status', '已登记；请查看状态');
      // The cloud origin identifies staging vs production; the name is the one chosen in the portal.
      let origin = ''; try { origin = new URL(config?.cloudOrigin || read(path.join(record.directory, 'setup-authorization.json'))?.cloudOrigin).hostname; } catch {}
      const environment = record.mode === 'production' && /staging/i.test(origin) ? 'staging' : record.mode;
      console.log([record.mode, record.name, clean(record.directory), state, environment, clean(config?.name || '')].join('\t'));
    }
    return;
  }
  const candidates = records.filter(record => record.mode === mode && record.uid === process.getuid());
  if (!instanceName) {
    if (candidates.length > 1) throw Error(message('Multiple instances exist. Use --list and --instance NAME.', '存在多个实例，请使用 --list 和 --instance 名称。'));
    instanceName = candidates[0]?.name || 'default';
  }
  if (action === 'selected-name') { process.stdout.write(instanceName); return; }
  const filename = recordFile(mode, instanceName);
  const sameInstance = record => record.mode === mode && record.name === instanceName;
  let saved = records.find(sameInstance);
  if (action === 'resolve' && instanceName !== 'default' && !saved && process.env.OX_INSTALL_ALLOW_CREATE !== '1') throw Error(message('Instance not found. Use --list to select an existing instance or --new to create one.', '找不到实例。请使用 --list 选择已有实例，或使用 --new 创建。'));
  if (saved && saved.uid !== process.getuid()) throw Error(message('Run the installer as the operating-system account that owns this server installation.', '请使用拥有此服务器安装的操作系统账户运行安装程序。'));
  const canonical = value => {
    let parent = path.resolve(value), rest = [];
    while (missingPath(parent)) { rest.unshift(path.basename(parent)); parent = path.dirname(parent); }
    return path.join(fs.realpathSync(parent), ...rest);
  };
  const overlaps = (a, b) => { a = canonical(a); b = canonical(b); return a === b || a.startsWith(b + path.sep) || b.startsWith(a + path.sep); };
  function assertSeparate(target) {
    if (records.some(record => !sameInstance(record) && overlaps(path.resolve(record.directory), path.resolve(target)))) throw Error(message('Instances must use separate, non-nested directories.', '实例必须使用不同且互不包含的目录。'));
    try { if (fs.lstatSync(target).isSymbolicLink()) throw Error('Installation directory must not use symbolic links'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
    const own = read(path.join(target, 'installation.json'));
    if (!saved && instanceName !== 'default' && (own || read(path.join(target, 'development-owner.json')) || read(path.join(target, 'instance.json')))) throw Error(message('This folder already contains an installation; select its existing registration.', '此目录已有安装，请选择原有登记。'));
    if ((mode === 'dev' && own?.mode === 'production') || (mode === 'production' && read(path.join(target, 'development-owner.json')))) throw Error(message('This directory belongs to the other installation mode.', '此目录属于另一种安装模式。'));
  }
  if (!['status', 'discover', 'ports'].includes(action)) assertSeparate(saved?.directory || requested);
  if (['resolve', 'status', 'discover'].includes(action)) {
    // Adopt known legacy locations before allowing a second installation.
    if (!saved && instanceName === 'default') {
      const candidates = [...new Set([requested, process.cwd(), path.join(process.cwd(), 'OX-SelfHost'), path.join(os.homedir(), 'OX-SelfHost'), path.join(os.homedir(), 'Documents/OX-SelfHost-Dev')].map(p => path.resolve(p)))];
      // Older production installs may live outside the default folders.
      const serviceDirectories = process.env.OX_INSTALL_PLATFORM === 'Darwin'
        ? [path.join(os.homedir(), 'Library/LaunchAgents'), '/Library/LaunchDaemons']
        : [path.join(os.homedir(), '.config/systemd/user'), '/etc/systemd/system'];
      for (const serviceDirectory of serviceDirectories) {
        let entries;
        try { entries = fs.readdirSync(serviceDirectory); } catch (error) { if (['ENOENT', 'EACCES'].includes(error.code)) continue; throw error; }
        for (const entry of entries.filter(name => /^(cn\.oxrecorder\.self-host.*\.plist|ox-self-host.*\.service)$/.test(name))) {
          const content = fs.readFileSync(path.join(serviceDirectory, entry), 'utf8');
          const plist = content.match(/<key>WorkingDirectory<\/key>\s*<string>([^<]+)<\/string>/);
          const unit = content.match(/^Environment=("OX_SELF_HOST_CONFIG=.*")$/m);
          if (plist) candidates.push(plist[1].replace(/&(lt|gt|amp|quot|apos);/g, (_, key) => ({ lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" })[key]));
          if (unit) candidates.push(path.dirname(JSON.parse(unit[1]).slice('OX_SELF_HOST_CONFIG='.length).replaceAll('%%', '%')));
        }
      }
      const installations = [...new Set(candidates)].map(directory => {
        const dev = read(path.join(directory, 'development-owner.json'));
        const release = read(path.join(directory, 'installation.json'));
        return dev?.directory === directory ? { directory, mode: 'dev' } : release?.mode === 'production' && release.directory === directory ? { directory, mode: 'production' } : null;
      }).filter(record => record?.mode === mode && !records.some(saved => path.resolve(saved.directory) === record.directory));
      if (installations.length > 1) throw Error(message('Multiple legacy installations were found. Reconcile them before installing; all data was preserved.', '发现多个旧安装。请先确认保留的安装；所有数据均已保留。'));
      if (installations.length) {
        saved = installations[0];
      }
    }
    if (action === 'resolve') {
      process.stdout.write(saved?.directory || path.resolve(requested));
      return;
    }
    requested = saved?.directory || path.resolve(requested);
    mode = saved?.mode || mode;
  }
  if (action === 'ports') {
    const ports = [];
    for (const record of records.filter(record => !sameInstance(record))) {
      const dependencies = read(path.join(record.directory, 'dependencies.json')) || {};
      for (const [key, value] of Object.entries(dependencies)) if (key.endsWith('Port')) ports.push(value);
      for (const name of ['instance.json', 'development.json', 'setup-address.json']) {
        const config = read(path.join(record.directory, name)) || {};
        if (config.port) ports.push(config.port);
        if (config.setupPort) ports.push(config.setupPort);
        for (const value of [config.services?.pssp?.port, config.services?.pssp?.publicPort, config.services?.pssp?.provisioning?.port]) if (value) ports.push(value);
        for (const uri of [config.mongoUri, config.services?.audio?.hot?.redisUrl]) if (uri) {
          const endpoint = new URL(uri);
          if (['localhost', '127.0.0.1', '[::1]'].includes(endpoint.hostname)) ports.push(Number(endpoint.port || (endpoint.protocol === 'redis:' ? 6379 : 27017)));
        }
        for (const field of ['lanOrigin', ...(name === 'development.json' ? ['cloudOrigin'] : [])]) if (config[field]) ports.push(Number(new URL(config[field]).port || 443));
      }
    }
    if (ports.some(port => !Number.isInteger(port) || port < 1 || port > 65535)) throw Error(message('Invalid sibling instance ports', '另一模式的实例端口配置无效'));
    process.stdout.write(JSON.stringify([...new Set(ports)])); return;
  }
  if (['forget-preflight', 'forget'].includes(action)) {
    if (!saved || saved.directory !== requested || !missingPath(requested)) throw Error(message('Only a registration whose original directory is missing can be forgotten.', '只能忘记原安装目录已丢失的登记。'));
    const { execFileSync } = require('child_process');
    const command = (binary, args) => execFileSync(binary, args, { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'pipe'] });
    try {
      // Inspect process environments too: Node children often have only source
      // paths in argv, but retain OX_SELF_HOST_CONFIG/OX_SELF_HOST_HOME.
      const rows = command('ps', ['ewwax', '-o', 'pid=,ppid=,command=']).trim().split('\n').map(line => line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/)).filter(Boolean);
      const parents = new Set([process.pid]);
      if (!rows.some(row => Number(row[1]) === process.pid)) throw Error('Process inventory unavailable');
      let pid = process.pid;
      while (pid > 1) { const row = rows.find(row => Number(row[1]) === pid); if (!row) break; pid = Number(row[2]); parents.add(pid); }
      if (rows.some(row => !parents.has(Number(row[1])) && Number(row[2]) !== process.pid && !row[3].includes('install_self_host.sh') && row[3].includes(requested))) throw Error('process');
      const dirs = process.env.OX_INSTALL_PLATFORM === 'Darwin'
        ? [path.join(os.homedir(), 'Library/LaunchAgents'), '/Library/LaunchDaemons']
        : [path.join(os.homedir(), '.config/systemd/user'), '/etc/systemd/system'];
      const escaped = requested.replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' })[c]);
      for (const dir of dirs) {
        let names; try { names = fs.readdirSync(dir); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
        for (const name of names.filter(name => /^(cn\.oxrecorder\.self-host.*\.plist|ox-self-host.*\.service)$/.test(name))) {
          const text = fs.readFileSync(path.join(dir, name), 'utf8');
          if ([requested, escaped, requested.replaceAll('%', '%%')].some(value => text.includes(value))) throw Error('startup');
        }
      }
      // A missing folder can leave a dedicated Lima VM outside that folder.
      if (process.env.OX_INSTALL_PLATFORM === 'Darwin') {
        const vm = 'ox-self-host-' + require('crypto').createHash('sha256').update(requested).digest('hex').slice(0, 12);
        try { if (command('limactl', ['list', '--format', '{{.Name}}']).split(/\s+/).includes(vm)) throw Error('runtime'); }
        catch (error) { if (error.code !== 'ENOENT') throw error; }
      }
      const contexts = command('docker', ['context', 'ls', '--format', '{{json .}}']).trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
      const targets = [...contexts.map(context => ['--context', context.Name]), ...(process.env.DOCKER_HOST ? [['--host', process.env.DOCKER_HOST]] : [])];
      if (!targets.length) throw Error('Docker inventory unavailable');
      for (const target of targets) {
        const ids = command('docker', [...target, 'ps', '-aq']).trim().split(/\s+/).filter(Boolean);
        if (!ids.length) continue;
        const containers = JSON.parse(command('docker', [...target, 'inspect', ...ids]));
        if (containers.some(container => (container.Mounts || []).some(mount => mount.Source && overlaps(path.resolve(mount.Source), requested)) || container.Config?.Labels?.['com.docker.compose.project.working_dir'] === requested)) throw Error('container');
      }
    } catch {
      throw Error(message('Cannot forget registration: surviving resources were found or inspection was unavailable. Restore the original folder, or reconcile its processes, startup services and Docker resources first. Nothing was removed.', '无法忘记登记：发现残留资源或无法完成检查。请恢复原目录，或先检查并处理其进程、启动服务和 Docker 资源。未删除任何内容。'));
    }
    if (action === 'forget-preflight') return;
  }
  if (action === 'release' && !saved) return;
  if (action === 'discard-bootstrap') {
    // A failed release download has no source-path and therefore no shipped
    // cleanup helper. Only installer bootstrap artifacts may use this path.
    const refuse = () => { throw Error(message('Cannot safely discard this installation without its release helper: ownership, running processes, or unexpected files require review. Nothing was removed.', '无法在缺少发布包清理工具时安全清理此安装：请检查目录归属、运行进程或额外文件。未删除任何内容。')); };
    if (mode !== 'production' || requested === path.parse(requested).root || requested === os.homedir() || os.homedir().startsWith(requested + path.sep)) refuse();
    if (fs.realpathSync(requested) !== requested || fs.lstatSync(requested).uid !== process.getuid()) refuse();
    const markerFile = path.join(requested, 'installation.json');
    const marker = read(markerFile);
    if (!marker || marker.version !== 1 || marker.mode !== 'production' || marker.directory !== requested || marker.bootMode !== undefined) refuse();
    const validateTree = file => {
      const stat = fs.lstatSync(file);
      if (stat.uid !== process.getuid() || stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory())) refuse();
      if (stat.isDirectory()) for (const name of fs.readdirSync(file)) validateTree(path.join(file, name));
    };
    const artifacts = fs.readdirSync(requested).sort();
    for (const name of artifacts) {
      if (name !== 'installation.json' && !/^\.download-[A-Za-z0-9]+$/.test(name) && !/^source-[a-f0-9]{64}$/.test(name)) refuse();
      const file = path.join(requested, name);
      if (name !== 'installation.json' && !fs.lstatSync(file).isDirectory()) refuse();
      validateTree(file);
    }
    // No Docker access is needed: this state precedes setup/enrollment and
    // dependency containers. Do not modify the shared engine or host policy.
    const { execFileSync } = require('child_process');
    const rows = execFileSync('ps', ['ewwax', '-o', 'pid=,ppid=,command='], { encoding: 'utf8', timeout: 15000, maxBuffer: 20000000 }).trim().split('\n').map(line => line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/)).filter(Boolean);
    const parents = new Set([process.pid]);
    if (!rows.some(row => Number(row[1]) === process.pid)) refuse();
    let pid = process.pid;
    while (pid > 1) { const row = rows.find(row => Number(row[1]) === pid); if (!row) break; pid = Number(row[2]); parents.add(pid); }
    // The progress indicator is a sibling shell with the installer's argv and
    // environment, including --home. It is not a running instance process.
    const parentCommand = rows.find(row => Number(row[1]) === process.ppid)?.[3];
    if (rows.some(row => !parents.has(Number(row[1])) && Number(row[2]) !== process.pid
      && !(Number(row[2]) === process.ppid && row[3] === parentCommand) && row[3].includes(requested))) refuse();
    const serviceDirectories = process.env.OX_INSTALL_PLATFORM === 'Darwin'
      ? [path.join(os.homedir(), 'Library/LaunchAgents'), '/Library/LaunchDaemons']
      : [path.join(os.homedir(), '.config/systemd/user'), '/etc/systemd/system'];
    for (const directory of serviceDirectories) {
      let names; try { names = fs.readdirSync(directory); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
      const escaped = requested.replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' })[c]);
      for (const name of names.filter(name => /^(cn\.oxrecorder\.self-host.*\.plist|ox-self-host.*\.service)$/.test(name))) {
        const text = fs.readFileSync(path.join(directory, name), 'utf8');
        if ([requested, escaped, requested.replaceAll('%', '%%')].some(value => text.includes(value))) refuse();
      }
    }
    const lock = path.join(registry, '.registration-lock');
    fs.mkdirSync(lock);
    try {
      const fresh = loadRecords();
      if (JSON.stringify(fresh.find(sameInstance)) !== JSON.stringify(saved) || fresh.some(record => !sameInstance(record) && overlaps(record.directory, requested))) refuse();
      if (JSON.stringify(read(markerFile)) !== JSON.stringify(marker) || JSON.stringify(fs.readdirSync(requested).sort()) !== JSON.stringify(artifacts)) refuse();
      for (const name of artifacts.filter(name => name !== 'installation.json')) {
        validateTree(path.join(requested, name));
        fs.rmSync(path.join(requested, name), { recursive: true });
      }
      // Keep the ownership marker until all download artifacts are removed so
      // an interrupted cleanup can be retried. Never recursively remove home.
      fs.unlinkSync(markerFile);
      try { fs.rmdirSync(requested); }
      catch (error) { fs.writeFileSync(markerFile, JSON.stringify(marker), { flag: 'wx', mode: 0o600 }); throw error; }
      if (saved && fs.existsSync(filename)) fs.unlinkSync(filename);
      if (legacy?.mode === mode && instanceName === 'default') fs.unlinkSync(legacyFile);
    } finally { fs.rmdirSync(lock); }
    return;
  }
  if (['release', 'forget', 'claim'].includes(action)) {
    // Serialize claims across modes so two installers cannot claim the same root.
    const lock = path.join(registry, '.registration-lock');
    fs.mkdirSync(lock);
    try {
      const freshLegacy = validateRecord(read(legacyFile));
      const fresh = loadRecords();
      if (legacy?.mode === mode && instanceName === 'default' && JSON.stringify(freshLegacy) !== JSON.stringify(legacy)) throw Error(message('Registration changed; inspect it again.', '登记已更改，请重新检查。'));
      if (action === 'claim') {
        if (fresh.some(record => sameInstance(record) && (record.directory !== requested || record.uid !== process.getuid()))) throw Error(message('Another installer selected a different installation; rerun to resume it', '另一个安装程序已选择不同的安装目录，请重新运行'));
        if (fresh.some(record => !sameInstance(record) && overlaps(path.resolve(record.directory), requested))) throw Error(message('Instances must use separate, non-nested directories', '实例必须使用不同且互不包含的目录'));
        if (!fs.existsSync(filename)) fs.writeFileSync(filename, JSON.stringify({ version: 1, directory: requested, mode, name: instanceName, uid: process.getuid() }), { flag: 'wx', mode: 0o644 });
        if (legacy?.mode === mode && instanceName === 'default') fs.unlinkSync(legacyFile);
      } else if (saved?.directory === requested && missingPath(requested)) {
        // Recheck the exact registration after taking the lock.
        if (fresh.some(record => sameInstance(record) && (record.directory !== requested || record.uid !== process.getuid()))) throw Error(message('Registration changed; inspect it again', '登记已更改，请重新检查'));
        if (fs.existsSync(filename)) fs.unlinkSync(filename);
        if (legacy?.mode === mode && instanceName === 'default') fs.unlinkSync(legacyFile);
      }
    } finally { fs.rmdirSync(lock); }
    return;
  }
  const missing = saved && missingPath(requested);
  if (missing && !['status', 'discover'].includes(action)) throw Error(message('The registered installation folder is missing. Restore it or use --action forget-registration after checking surviving resources.', '已登记的安装目录已丢失。请恢复原目录，或检查残留资源后使用 --action forget-registration。'));
  const config = read(path.join(requested, 'instance.json'));
  const development = mode === 'dev' ? read(path.join(requested, 'development.json')) : null;
  if (!['status', 'discover'].includes(action) && process.env.OX_CLOUD_OVERRIDE && (development?.upstream || config?.cloudOrigin) && new URL(process.env.OX_CLOUD_OVERRIDE).origin !== (development?.upstream || config.cloudOrigin)) throw Error(message('Cloud origin cannot change on resume.', '继续已有安装时不能更改云端地址。'));
  const lifecycle = read(path.join(requested, 'lifecycle.json'));
  const incomplete = !read(path.join(requested, 'setup-complete.json')) && read(path.join(requested, 'setup-attempt.json'));
  const hasData = config && ['mongo', 'audio', 'redis'].some(name => fs.existsSync(path.join(requested, name)));
  if (action === 'discover') {
    const state = missing ? 'missing' : lifecycle?.authorization ? lifecycle.status === 'erased' && lifecycle.acknowledgedAt ? 'erased' : 'erasing' : lifecycle?.status === 'retained' ? 'retained' : incomplete ? 'partial' : hasData ? 'data' : config ? 'configured' : saved || read(path.join(requested, 'enrollment.json')) ? 'partial' : 'none';
    process.stdout.write([mode, requested, development?.upstream || config?.cloudOrigin || '', state].join('\n'));
    return;
  }
  if (action === 'status') {
    // Read files, a pinned self-hosted health endpoint and database documents only.
    // Never acquire locks, repair state, contact cloud or start dependencies.
    const clean = value => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
    const paint = (value, color) => process.env.OX_STATUS_COLOR === '1' && !process.env.NO_COLOR ? `\x1b[${color}m${value}\x1b[0m` : value;
    const field = (en, zh, value, color = 36) => console.log(message(en, zh) + ': ' + paint(clean(value), color));
    const unknown = message('Unknown', '未知');
    console.log(paint(message('OX Self-Host Status', 'OX 自托管实例状态'), 36));
    if (!saved && !config && !read(path.join(requested, 'enrollment.json'))) {
      field('Installation', '安装', message('Not installed', '未安装'), 33);
      return;
    }
    field('Name', '名称', instanceName);
    field('Directory', '安装目录', requested);
    if (missing) { field('Installation', '安装', message('Registered folder is missing; restore it or use --action forget-registration.', '已登记的目录已丢失；请恢复原目录或使用 --action forget-registration。'), 33); return; }
    field('Mode', '模式', mode);
    const boot = read(path.join(requested, 'installation.json'))?.bootMode;
    field('Boot recovery', '开机恢复', ['system', 'systemd'].includes(boot) ? message('Before-login startup configured; reboot verification required', '已配置登录前启动；需重启验证') : message('Before-login startup unverified', '未确认登录前启动'), 33);
    if (process.platform === 'linux') {
      let host;
      try {
        const filename = '/var/lib/ox-self-host-host-reliability/status.json', stat = fs.lstatSync(filename);
        if (stat.isFile() && stat.uid === 0 && !(stat.mode & 0o022) && stat.size <= 16384) host = JSON.parse(fs.readFileSync(filename, 'utf8'));
      } catch {}
      const current = host?.capability === 'host-reliability-v1' && Number.isFinite(Date.parse(host.observedAt)) && Date.now() - Date.parse(host.observedAt) <= 600000 && Date.parse(host.observedAt) <= Date.now() + 60000;
      field('Host protection', '主机保护', !host ? message('Not assessed; use --action host-configure', '尚未评估；请使用 --action host-configure') : !current ? message('Unknown; host observation expired', '未知；主机检查已过期') : host.status === 'protected' ? message('Protections applied; reboot recovery unverified', '保护已生效；开机恢复未验证') : message('Needs review; use --action host-check', '需要检查；请使用 --action host-check'), current && host.status === 'protected' ? 32 : 33);
    }
    field('Instance', '实例', config?.instanceId || message('Setup incomplete', '安装尚未完成'));
    const supervisor = read(path.join(requested, 'supervisor.pid'));
    const running = supervisor && alive(supervisor.pid);
    const agent = read(path.join(requested, 'agent/supervisor.pid'));
    field('Management agent', '管理代理', agent && alive(agent.pid) ? message('Running', '运行中') : message('Unavailable / update required', '不可用 / 需要更新'), agent && alive(agent.pid) ? 32 : 33);
    const observation = read(path.join(requested, 'agent/snapshot.json'));
    if (observation) field('Last observation', '最近观测', observation.observedAt);
    field('Supervisor', '监督进程', running ? message('Running', '运行中') : message('Stopped', '已停止'), running ? 32 : 33);
    if (running) field('PID', '进程号', supervisor.pid);
    if (mode === 'dev') {
      const launcher = read(path.join(requested, '.development-launcher/supervisor.pid'));
      field('Development launcher', '开发启动器', launcher && alive(launcher.pid) ? message('Running', '运行中') : message('Stopped', '已停止'), launcher && alive(launcher.pid) ? 32 : 33);
    }
    // Only origins are printed: config URLs may contain credentials or query keys.
    const safeOrigin = value => { try { return new URL(value).origin; } catch { return unknown; } };
    portalAddresses(config);
    field('Cloud URL', '云端地址', safeOrigin(development?.upstream || config?.cloudOrigin));
    const removed = lifecycle?.status === 'erased';
    if (lifecycle?.authorization) field('Self-hosted server data', '自托管服务器数据', removed
      ? lifecycle.acknowledgedAt ? message('Removed; cloud acknowledged', '已删除；云端已确认') : message('Removed; cloud acknowledgement pending', '已删除；等待云端确认')
      : message('Removal pending', '等待删除'), 33);
    else field('Self-hosted server data', '自托管服务器数据', hasData ? message('Present', '存在') : message('Not found', '未找到'), hasData ? 32 : 33);
    let source = config?.sourceRoot;
    try { source = fs.realpathSync(path.join(requested, 'current')); } catch {}
    let version = config?.releaseVersion || (mode === 'dev' ? 'development' : unknown);
    if (source && config?.signingPublicKey) {
      try {
        const envelope = read(path.join(source, 'release-manifest.json'));
        if (envelope && require('crypto').verify(null, Buffer.from(envelope.data), config.signingPublicKey, Buffer.from(envelope.signature, 'base64url'))) {
          const manifest = JSON.parse(Buffer.from(envelope.data, 'base64url'));
          if (manifest.purpose === 'self-host-release') version = manifest.releaseVersion || version;
        }
      } catch {}
    }
    let portal = null;
    if (config?.tls?.cert && !removed) {
      try {
        portal = await new Promise(resolve => {
          const req = https.get({ hostname: '127.0.0.1', port: config.port || 3080, path: '/health', ca: fs.readFileSync(config.tls.cert), timeout: 2000 }, res => {
            let body = '';
            res.on('data', chunk => { body += chunk; if (body.length > 16384) req.destroy(); });
            res.on('error', () => resolve(null));
            res.on('end', () => { try { const data = JSON.parse(body); resolve(res.statusCode === 200 && data.application === 'self-host' && data.status === 'ok' ? data : null); } catch { resolve(null); } });
          });
          const deadline = setTimeout(() => req.destroy(), 3000);
          req.on('close', () => clearTimeout(deadline));
          req.on('error', () => resolve(null)); req.on('timeout', () => req.destroy());
        });
      } catch {}
    }
    field('Portal health', '控制台健康状态', portal ? message('Healthy', '正常') : removed ? message('Data removed', '数据已删除') : message('Unavailable', '不可用'), portal ? 32 : removed || !running ? 33 : 31);
    field('Version', '版本', portal?.build || version);
    let connection;
    try {
      if (!config?.mongoUri || !source || removed) throw Error('No self-hosted server database');
      const localRequire = require('module').createRequire(path.join(source, 'package.json'));
      const mongoose = localRequire('mongoose');
      connection = mongoose.createConnection(config.mongoUri, { dbName: config.mongoDb || 'ox_self_host', autoIndex: false, autoCreate: false, serverSelectionTimeoutMS: 2000, connectTimeoutMS: 2000, socketTimeoutMS: 2000 });
      await connection.asPromise();
      const states = await connection.db.collection('localstates').find({ _id: { $in: ['sync', 'instance-revocation', 'service-control'] } }, { maxTimeMS: 2000 }).toArray();
      const service = states.find(row => row._id === 'service-control')?.value;
      const serviceStates = { running: ['Running', '运行中'], stopped: ['Stopped (cloud control online)', '已停止（云端控制在线）'], starting: ['Starting', '正在启动'], stopping: ['Stopping', '正在停止'], failed: ['Startup failed', '启动失败'] };
      field('Application service', '应用服务', !running ? message('Stopped', '已停止') : message(...(serviceStates[service?.state] || ['Running or starting', '运行中或启动中'])), !running ? 33 : service?.state === 'failed' ? 31 : service?.state === 'running' ? 32 : 33);
      const sync = states.find(row => row._id === 'sync')?.value?.lastSyncAt;
      const timestamp = Date.parse(sync);
      const recent = Number.isFinite(timestamp) && Date.now() - timestamp >= -60000 && Date.now() - timestamp < 60000;
      const revoked = states.some(row => row._id === 'instance-revocation');
      field('Cloud sync', '云端同步', revoked ? message('Revoked', '已撤销') : recent && running ? message('Recent successful sync', '最近同步成功') : Number.isFinite(timestamp) ? message('Not current; connection unconfirmed', '非实时；连接状态未确认') : message('No successful sync recorded', '尚无成功同步记录'), revoked ? 33 : recent && running ? 32 : 33);
      field('Last successful sync', '上次成功同步', Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : unknown);
    } catch {
      field('Cloud sync', '云端同步', message('Unknown (self-hosted server database unavailable)', '未知（自托管服务器数据库不可用）'), 33);
    } finally { if (connection) await connection.close().catch(() => {}); }
    console.log(message('Read-only snapshot. Portal health does not verify every worker.', '以上为只读状态快照。控制台健康状态不代表所有后台进程均正常。'));
    return;
  }
  if (action === 'inspect') {
    process.stdout.write(lifecycle?.authorization ? lifecycle.status === 'erased' && lifecycle.acknowledgedAt ? 'erased' : 'erasing' : lifecycle?.status === 'retained' ? 'retained' : incomplete ? 'partial' : hasData ? 'data' : config ? 'configured' : fs.existsSync(path.join(requested, 'installation.json')) || fs.existsSync(path.join(requested, 'development-owner.json')) || fs.existsSync(path.join(requested, 'enrollment.json')) ? 'partial' : 'none');
    return;
  }
  if (action.startsWith('service-')) {
    const operation = action.slice(8);
    if (!['start', 'stop', 'restart'].includes(operation)) throw Error('Invalid service action');
    if (lifecycle?.authorization && process.env.OX_SERVICE_RESUME === '1') { process.exitCode = 3; return; }
    if (incomplete) throw Error(message('Incomplete setup cannot be resumed. Use --action discard --interactive, then create a new instance.', '未完成的安装不能继续。请使用 --action discard --interactive 清理，然后创建新实例。'));
    if (!config || lifecycle?.authorization || lifecycle?.status === 'retained') throw Error(message('No active installation is available for service control.', '没有可控制服务的有效安装。'));
    const directSocket = path.join(requested, 'service-control.sock');
    const socket = Buffer.byteLength(directSocket) < 100 ? directSocket : `/tmp/ox-self-host-control-${process.getuid()}/${require('crypto').createHash('sha256').update(requested).digest('hex').slice(0, 24)}.sock`;
    if (socket !== directSocket && fs.existsSync(path.dirname(socket))) {
      const entry = fs.lstatSync(path.dirname(socket));
      if (!entry.isDirectory() || entry.isSymbolicLink() || entry.uid !== process.getuid() || (entry.mode & 0o077)) throw Error(message('Unsafe service control directory.', '服务控制目录不安全。'));
    }
    const request = (method, endpoint) => new Promise((resolve, reject) => {
      const req = require('http').request({ socketPath: socket, path: endpoint, method, timeout: 3000 }, res => {
        let body = ''; res.on('data', chunk => { body += chunk; if (body.length > 8192) req.destroy(); });
        res.on('end', () => { try { if (res.statusCode >= 400) throw Error('Service control unavailable'); resolve(JSON.parse(body)); } catch (error) { reject(error); } });
        res.on('error', reject);
      });
      req.on('error', reject); req.on('timeout', () => req.destroy(Error('Service control timeout'))); req.end();
    });
    try { if (!fs.existsSync(socket)) throw Error('Service socket absent'); await request('GET', '/status'); }
    catch {
      const supervisor = read(path.join(requested, 'supervisor.pid'));
      if (supervisor && alive(supervisor.pid)) {
        if (process.env.OX_SERVICE_RESUME === '1') { process.exitCode = 3; return; }
        throw Error(message('This supervisor does not support service control yet. Update and relaunch it first.', '当前 supervisor 尚不支持服务控制，请先更新并重新启动 supervisor。'));
      }
      if (operation === 'stop') { console.log(message('The service is already stopped.', '服务已停止。')); return; }
      process.exitCode = 3; return;
    }
    if (process.env.OX_SERVICE_RESUME === '1') {
      let source = config.sourceRoot;
      if (mode === 'production') { try { source = fs.realpathSync(path.join(requested, 'current')); } catch {} }
      const bootstrap = source && path.join(source, 'apps/self-host/setup/agentBootstrap.js');
      if (bootstrap && fs.existsSync(bootstrap)) {
        console.log(message('Checking the independent management agent', '正在检查独立管理代理'));
        await (await import(require('url').pathToFileURL(bootstrap))).ensureManagementAgent(requested, mode, source);
      }
    }
    const submitted = await request('POST', '/' + operation);
    const deadline = Date.now() + 180000;
    while (Date.now() < deadline) {
      await new Promise(resolve => setTimeout(resolve, 1000));
      const status = await request('GET', '/status');
      if (status.requestId !== submitted.requestId) throw Error(message('Service status changed; check Status before retrying.', '服务状态已变化，请先查看状态再重试。'));
      if (status.status === 'applied') { console.log(message('Service action completed: ', '服务操作完成：') + message(status.state, status.state === 'stopped' ? '已停止（保留云端控制）' : '运行中')); return; }
      if (['failed', 'conflict', 'expired'].includes(status.status)) throw Error(message('Service action did not complete. Check recording schedules, active recording, maintenance and supervisor logs.', '服务操作未完成，请检查录音计划、正在进行的录音、维护操作和 supervisor 日志。'));
    }
    throw Error(message('Service action is still pending. Check Status before retrying.', '服务操作仍在等待处理，请先查看状态再重试。'));
  }
  if (action === 'fresh-preflight' || action === 'cleanup-preflight') {
    if (!config || !lifecycle?.authorization || lifecycle.status !== 'erased' || !lifecycle.acknowledgedAt) {
      throw Error(message('Delete self-hosted server data through the cloud portal and wait for acknowledgement first. Self-hosted server data was preserved.', '请先通过云端控制台删除自托管服务器数据，并等待确认。自托管服务器数据已保留。'));
    }
    return;
  }
  if (action === 'restore') {
    if (!hasData || lifecycle?.authorization) throw Error(message('No retained data is available for restoration.', '没有可恢复的保留数据。'));
    const crypto = require('crypto');
    const key = fs.readFileSync(3, 'utf8').trim();
    if (!/^[A-Za-z0-9_-]{43}$/.test(key)) throw Error(message('Invalid restoration authorization key.', '恢复授权密钥无效。'));
    const nonce = crypto.randomUUID();
    const certificate = new crypto.X509Certificate(fs.readFileSync(config.tls.cert));
    const fingerprint = certificate.fingerprint256.replaceAll(':', '').toLowerCase();
    const data = Buffer.from(JSON.stringify({ version: 1, purpose: 'instance-restore', instanceId: config.instanceId, nonce, timestamp: Date.now(), keyHash: crypto.createHash('sha256').update(key).digest('hex'), lanOrigin: config.lanOrigin, tlsCertificateFingerprint: fingerprint })).toString('base64url');
    const origin = new URL(development?.upstream || config.cloudOrigin);
    if (origin.protocol !== 'https:' && !(origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname))) throw Error('Invalid cloud origin');
    const response = await fetch(`${origin.origin}/api/self-host/${config.instanceId}/restore`, { method: 'POST', headers: { 'content-type': 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(20000), body: JSON.stringify({ key, instancePublicKey: crypto.createPublicKey(config.privateKey).export({ type: 'spki', format: 'pem' }).toString(), tlsCertificate: certificate.toString(), tlsSignature: crypto.sign('sha256', Buffer.from(data), fs.readFileSync(config.tls.key)).toString('base64url'), proof: { data, signature: crypto.sign(null, Buffer.from(data), config.privateKey).toString('base64url') } }) });
    if (!response.ok) throw Error(message('Cloud restoration was not authorized. Check the key, original account, and retained-data status; self-hosted server data is unchanged.', '云端未授权恢复。请检查密钥、原云端账户和数据保留状态；自托管服务器数据未修改。'));
    const raw = await response.text();
    if (raw.length > 16384) throw Error('Restoration response exceeds limit');
    const result = JSON.parse(raw);
    if (typeof result.data !== 'string' || typeof result.signature !== 'string' || !crypto.verify(null, Buffer.from(result.data), config.signingPublicKey, Buffer.from(result.signature, 'base64url'))) throw Error(message('Cloud restoration signature is invalid.', '云端恢复签名无效。'));
    const proof = JSON.parse(Buffer.from(result.data, 'base64url'));
    if (proof.version !== 1 || proof.purpose !== 'instance-restored' || proof.instanceId !== config.instanceId || proof.nonce !== nonce || proof.lanOrigin !== config.lanOrigin) throw Error(message('Cloud restoration identity does not match.', '云端恢复身份不匹配。'));
    if (lifecycle?.status === 'retained') fs.unlinkSync(path.join(requested, 'lifecycle.json'));
    console.log(message('Cloud authorization restored. Existing database and recordings were preserved.', '云端授权已恢复，现有数据库和录音已保留。'));
    return;
  }
  if (action !== 'running') throw Error('Unknown host installation action');
  if (lifecycle?.status === 'erased' && lifecycle.acknowledgedAt) throw Error(message('Self-hosted server data was removed. Finish deletion cleanup, then create a new instance.', '自托管服务器数据已删除，请先完成删除清理，再创建新实例。'));
  const locks = [path.join(requested, 'supervisor.pid'), ...(mode === 'dev' ? [path.join(requested, '.development-launcher/supervisor.pid')] : [])];
  if (!locks.some(file => { const lock = read(file); return lock && alive(lock.pid); })) { process.exitCode = 3; return; }
  let healthy = false;
  if (config?.tls?.cert) {
    healthy = await new Promise(resolve => {
      const req = https.get({ hostname: '127.0.0.1', port: config.port, path: '/health', ca: fs.readFileSync(config.tls.cert), timeout: 2000 }, res => { res.resume(); resolve(res.statusCode === 200); });
      req.on('error', () => resolve(false)); req.on('timeout', () => req.destroy());
    });
  }
  const bootstrap = config.sourceRoot && path.join(config.sourceRoot, 'apps/self-host/setup/agentBootstrap.js');
  if (bootstrap && fs.existsSync(bootstrap)) await (await import(require('url').pathToFileURL(bootstrap))).ensureManagementAgent(requested, mode, config.sourceRoot);
  console.log(healthy ? message('Reusing the running self-host instance.', '正在使用已运行的自托管实例。') : message('The existing self-host process is running; its portal is not ready yet. No second process was started. Check its startup output or supervisor logs.', '已有自托管进程正在运行，控制台尚未就绪。未启动第二个进程，请查看启动输出或 supervisor 日志。'));
  console.log(message('Installation: ', '安装目录：') + requested);
  portalAddresses(config);
  if (!healthy) process.exitCode = 4;
})().catch(error => { console.error(error.message); process.exitCode = 1; });
HOST_NODE
}
claim_host_installation() {
  if [[ ! -d "$OX_HOST_REGISTRY" ]]; then
    if [[ "$OX_INSTALL_PLATFORM" == Linux && -z "${OX_SELF_HOST_REGISTRY_DIRECTORY:-}" && "$EUID" != 0 ]]; then
      say '正在创建服务器安装记录（需要系统授权）。\n' 'Creating the host installation record (system authorization required).\n'
      authorize_admin || return
      sudo -n install -d -m 755 -o "$EUID" "$OX_HOST_REGISTRY"
    else
      mkdir -p "$OX_HOST_REGISTRY"
      chmod 755 "$OX_HOST_REGISTRY"
    fi
  fi
  host_installation claim
}
reuse_running_installation() {
  local status=0
  host_installation running || status=$?
  if [[ "$status" == 3 ]]; then return; fi
  if [[ "$status" == 0 || "$status" == 4 ]]; then claim_host_installation; fi
  exit "$status"
}
capture_instance_status() {
  local color=0
  use_color && color=1
  OX_STATUS_TEXT="$(OX_STATUS_COLOR="$color" host_installation status 2>&1)"
}
show_instance_status() {
  local OX_STATUS_TEXT='' result=0
  progress "$(translate '正在读取实例状态' 'Reading instance status')" capture_instance_status || result=$?
  printf '%s\n' "$OX_STATUS_TEXT"
  return "$result"
}
menu_edit() {
  local variable="$1" heading="$2" kind="$3" current="$4" value
  menu_screen
  printf '\n%s\n' "$heading"
  say '回车保留当前值；输入 :back 返回。\n' 'Enter keeps the current value; type :back to return.\n'
  while true; do
    printf '[%s]: ' "${current:-$(translate '自动' 'Automatic')}"
    IFS= read -r value || return 1
    [[ "$value" != :back ]] || return 0
    value="${value:-$current}"
    if [[ "$kind" == port && -n "$value" ]] && { [[ ! "$value" =~ ^[0-9]{1,5}$ ]] || (( 10#$value < 1 || 10#$value > 65535 )); }; then
      say_tone warning '请输入 1 到 65535 之间的端口。\n' 'Enter a port between 1 and 65535.\n'; continue
    fi
    if [[ "$kind" == cloud ]]; then
      if [[ "$OX_MODE" == production && ! "$value" =~ ^https://[^/@?#]+/?$ ]] || [[ "$OX_MODE" == dev && ! "$value" =~ ^https?://[^/@?#]+/?$ ]]; then
        say_tone warning '请输入云端来源地址（不含路径或登录凭据）。生产模式需要 HTTPS。\n' 'Enter a cloud origin without a path or credentials. Production requires HTTPS.\n'; continue
      fi
    fi
    printf -v "$variable" '%s' "$value"
    return 0
  done
}
menu_settings() {
  local choice outcome
  local -a host_options
  while true; do
    menu_screen
    host_options=()
    if [[ "$OX_INSTALL_PLATFORM" == Linux && "$OX_MODE" == production ]]; then
      host_options=("$(translate '主机保护检查（只读）' 'Host protection check (read-only)')" "$(translate '配置主机保护' 'Configure host protection')" "$(translate '恢复原主机设置' 'Restore previous host settings')")
    fi
    menu_select choice "$(translate '安装设置' 'Installation settings')" 0 \
      "$(translate '返回主菜单' 'Back to main menu')" \
      "$(translate '安装目录' 'Installation directory')" \
      "$(translate '云端地址' 'Cloud address')" \
      "$(translate '局域网主机名或 IP' 'LAN hostname or IP')" \
      "$(translate '局域网 HTTPS 端口' 'LAN HTTPS port')" \
      "$(translate '设置页面端口' 'Setup page port')" \
      "$(translate '仅准备依赖' 'Prepare dependencies only')" ${host_options[@]+"${host_options[@]}"} || return 1
    case "$choice" in
      -1|0) return 0 ;;
      1|2)
        if [[ "$OX_EXISTING" != none ]]; then
          say_tone warning '已有安装使用原目录和云端地址。如需更换，请先在云端控制台删除旧实例及数据。\n' 'An existing installation keeps its directory and cloud address. Delete it and its data from the cloud portal before changing these settings.\n'
          menu_pause || return 1
          continue
        fi
        case "$choice" in
          1) menu_edit OX_INSTALL_ROOT "$(translate '安装目录' 'Installation directory')" path "$OX_INSTALL_ROOT" || return 1; OX_INSTALL_ROOT="$(absolute_path "$OX_INSTALL_ROOT")"; OX_MENU_HOME_CUSTOM=1 ;;
          2) menu_edit OX_CLOUD_ORIGIN "$(translate '云端地址' 'Cloud address')" cloud "$OX_CLOUD_ORIGIN" || return 1 ;;
        esac ;;
      3|4|5)
        if [[ "$OX_EXISTING" != none ]]; then
          say_tone warning '已有实例保留网络配置。请在控制台中使用端点维护功能。\n' 'Existing network settings are preserved. Use endpoint maintenance in the portal.\n'; menu_pause || return 1; continue
        fi
        case "$choice" in
          3) menu_edit OX_LAN_HOST "$(translate '局域网主机名或 IP' 'LAN hostname or IP')" host "${OX_LAN_HOST:-}" || return 1 ;;
          4) menu_edit OX_LAN_PORT "$(translate '局域网 HTTPS 端口' 'LAN HTTPS port')" port "${OX_LAN_PORT:-}" || return 1 ;;
          5) menu_edit OX_SETUP_PORT "$(translate '设置页面端口' 'Setup page port')" port "$OX_SETUP_PORT" || return 1 ;;
        esac ;;
      6) outcome=0; menu_execute dependencies || outcome=$?; [[ "$outcome" != 2 ]] || return 1 ;;
      7) run_host_helper check || true; menu_pause || return 1 ;;
      8) configure_host_mode || true; menu_pause || return 1 ;;
      9) restore_host_mode || true; menu_pause || return 1 ;;
    esac
  done
}
menu_execute() {
  local operation="$1" review=0 result=0
  menu_screen
  local -a arguments=(--instance "${OX_INSTALL_INSTANCE:-default}" --home "$OX_INSTALL_ROOT" --cloud-url "$OX_CLOUD_ORIGIN" --language "$OX_INSTALL_LANGUAGE" --interactive)
  # --mode is maintainer-only; customer re-executions rely on the production default.
  [[ "${OX_MAINTAINER_DEV:-0}" != 1 ]] || arguments+=(--mode "$OX_MODE")
  [[ "${OX_ACCEPT_SERVER_MODE:-0}" != 1 ]] || arguments+=(--accept-server-mode)
  [[ -z "${OX_LAN_HOST:-}" ]] || arguments+=(--lan-host "$OX_LAN_HOST")
  [[ -z "${OX_LAN_PORT:-}" ]] || arguments+=(--lan-port "$OX_LAN_PORT")
  printf '\n%s\n' "$(translate '请确认以下设置' 'Review these settings')"
  say '模式：%s\n目录：%s\n云端：%s\n局域网主机：%s\nHTTPS 端口：%s\n设置页面端口：%s\n' 'Mode: %s\nDirectory: %s\nCloud: %s\nLAN host: %s\nHTTPS port: %s\nSetup page port: %s\n' \
    "$OX_MODE" "$OX_INSTALL_ROOT" "$OX_CLOUD_ORIGIN" "${OX_LAN_HOST:-$(translate '自动' 'Automatic')}" "${OX_LAN_PORT:-$(translate '自动' 'Automatic')}" "$OX_SETUP_PORT"
  say '实例名称：%s\n' 'Instance name: %s\n' "${OX_INSTALL_INSTANCE:-default}"
  case "$operation" in
    fresh)
      arguments+=(--action fresh)
      if [[ "$OX_EXISTING" != none && "$OX_EXISTING" != erased ]]; then
        say_tone warning '已有安装或数据。请在云端控制台选择删除实例及自托管服务器数据，并等待删除完成后再全新安装。\n如需保留数据，请选择继续运行或恢复云端关联；未完成的安装请选择清理后创建新实例。\n' 'An installation or data already exists. Delete the instance and self-hosted server data from the cloud portal and wait for completion before a fresh install.\nTo keep data, choose Resume or Restore; discard an incomplete installation before creating a new instance.\n'
        menu_pause || return 2
        return 0
      fi
      say '将创建一个新实例，完成后在浏览器中进行云端授权。\n' 'A new instance will be created, followed by cloud authorization in your browser.\n' ;;
    retry-erasure)
      arguments+=(--action resume)
      say_tone warning '将继续此实例已获云端授权的数据删除；不会恢复服务或创建新实例。删除的数据无法恢复。\n' 'Continue this instance’s cloud-authorized data deletion. This does not restore application service or create a new instance. Deleted data cannot be recovered.\n' ;;
    cleanup)
      arguments+=(--action cleanup)
      say_tone warning '将清理已删除实例 %s 的启动服务、专用容器环境、安装目录和登记。不会创建新实例；其他实例不受影响。清理开始后无法撤销。\n' 'Finish removing deleted instance %s: startup services, dedicated container runtime, installation directory and registration. No replacement is created; other instances are unaffected. Cleanup cannot be undone once started.\n' "$OX_INSTALL_INSTANCE" ;;
    discard)
      arguments+=(--action discard)
      say_tone warning '将停止此未完成安装的进程，撤销云端登记，并永久删除其专用容器和安装目录。其他实例不受影响。清理开始后不能撤销。\n' 'This stops the incomplete attempt, cancels cloud enrollment, and permanently removes its dedicated containers and installation directory. Other instances are unaffected. Cleanup cannot be cancelled once removal begins.\n' ;;
    start|stop|restart)
      arguments+=(--action "$operation")
      say_tone warning '启动或重启应用服务不会删除数据。停止服务后，自托管控制台和设备服务不可用，但保留云端控制连接，可远程启动。录音或维护期间不能停止或重启。\n' 'Starting or restarting services preserves data. Stop makes the self-hosted portal and device services unavailable but keeps cloud control online for remote Start. Stop and Restart require idle devices and no maintenance.\n' ;;
    resume) arguments+=(--action resume); say '将继续运行已有服务，保留数据。\n' 'The existing service will resume with its data preserved.\n' ;;
    restore) arguments+=(--action restore); say '保留数据，并要求原云端账户提供恢复授权密钥。\n' 'Data is retained. A restoration key from the original cloud account is required.\n' ;;
    dependencies) arguments+=(--dependencies-only); say '仅准备运行依赖，不创建实例。\n' 'Prepare runtime dependencies without creating an instance.\n' ;;
  esac
  menu_select review "$(translate '是否继续？' 'Continue?')" "$review" "$(translate '继续' 'Continue')" "$(translate '返回主菜单' 'Back to main menu')" "$(translate '退出' 'Exit')" || return 2
  [[ "$review" != 2 ]] || return 2
  [[ "$review" == 0 ]] || return 0
  menu_screen
  # Execute in a child so failures and completed actions return to the menu.
  OX_INSTALL_MENU_CHILD=1 OX_SETUP_PORT="$OX_SETUP_PORT" bash "${BASH_SOURCE[0]}" "${arguments[@]}" || result=$?
  if [[ "$result" == 0 ]]; then say_tone success '\n操作已完成。\n' '\nAction completed.\n'
  else say_tone error '\n操作未完成；请查看上方提示。数据不会因返回菜单而被删除。\n' '\nAction did not complete; review the message above. Returning to the menu does not delete data.\n'; fi
  # Successful setup/resume returns to the shell, including the outer instance picker.
  if [[ "$result" == 0 && ( "$operation" == fresh || "$operation" == resume ) ]]; then exit 0; fi
  menu_pause || return 2
  if [[ ( "$operation" == discard || "$operation" == cleanup ) && "$result" == 0 ]]; then return 3; fi
  return 0
}
# Read only known service logs, following rotation without creating log files.
menu_live_logs() {
  local choice name
  local -a labels files available
  while true; do
    menu_screen
    labels=("$(translate '所有可用日志' 'All available logs')"); available=()
    for name in launcher supervisor agent web broker pssp sync archive cloud; do
      if [[ -f "$OX_INSTALL_ROOT/$name.log" && ! -L "$OX_INSTALL_ROOT/$name.log" && -r "$OX_INSTALL_ROOT/$name.log" ]]; then
        available+=("$OX_INSTALL_ROOT/$name.log")
        case "$name" in
          supervisor) labels+=("$(translate '监督进程' 'Supervisor')") ;;
          agent) labels+=("$(translate '管理代理' 'Management agent')") ;;
          web) labels+=("$(translate '自托管控制台' 'Self-hosted portal')") ;;
          broker) labels+=('PSSP Broker') ;;
          pssp) labels+=('PSSP Worker') ;;
          sync) labels+=("$(translate '设备同步工作进程' 'Device Sync Worker')") ;;
          archive) labels+=("$(translate '归档工作进程' 'Archive Worker')") ;;
          cloud) labels+=("$(translate '云端同步' 'Cloud sync')") ;;
        esac
      fi
    done
    if [[ "${#available[@]}" == 0 ]]; then
      say_tone warning '暂无可读取的服务日志。服务首次运行后才会生成日志。\n' 'No readable service logs yet. Logs are created when services first run.\n'
      menu_pause || return 1
      return 0
    fi
    labels+=("$(translate '返回主菜单' 'Back to main menu')")
    menu_select choice "$(translate '查看实时日志（只读）' 'View live logs (read-only)')" 0 "${labels[@]}" || return 1
    [[ "$choice" != -1 && "$choice" != "$((${#labels[@]} - 1))" ]] || return 0
    files=()
    if [[ "$choice" == 0 ]]; then files=("${available[@]}"); else files=("${available[$((choice - 1))]}"); fi
    menu_screen
    if [[ -t 0 && -t 1 && "${TERM:-dumb}" != dumb ]] && command -v node >/dev/null; then
      trap ':' INT
      # Embedded so the downloaded standalone installer needs no extra assets.
      node -e "$(cat <<'LOG_VIEWER_NODE'
const { spawn } = require('node:child_process');
const readline = require('node:readline');
const zh = process.argv[1] !== 'en';
const tr = (cn, en) => zh ? cn : en;
const reader = spawn('tail', ['-n', '200', '-F', ...process.argv.slice(2)], { stdio: ['ignore', 'pipe', 'pipe'] });
const input = reader.stdout;
input.setEncoding('utf8');
reader.stderr.setEncoding('utf8');
const lines = [];
let partial = '';
let following = true;
let top = 0;
let dirty = true;
let closed = false;
let dropped = 0;
const height = () => Math.max(1, (process.stdout.rows || 24) - 3);
// Logs are untrusted terminal content. Strip controls and cap retained history.
const clean = value => value.replace(/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|$))/g, '').replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/g, '');
const clip = value => {
  let width = 0;
  let result = '';
  for (const character of value) {
    const code = character.codePointAt(0);
    width += code >= 0x1100 ? 2 : 1;
    if (width > Math.max(1, (process.stdout.columns || 80) - 1)) break;
    result += character;
  }
  return result;
};
function render() {
  if (!dirty || closed) return;
  dirty = false;
  const count = height();
  const visible = partial ? [...lines, partial] : lines;
  if (following) top = Math.max(0, visible.length - count);
  top = Math.max(0, Math.min(top, Math.max(0, visible.length - count)));
  const status = following ? tr('实时', 'Live') : tr('已暂停', 'Paused');
  const color = process.env.NO_COLOR ? '' : following ? '\x1b[32m' : '\x1b[33m';
  const reset = process.env.NO_COLOR ? '' : '\x1b[0m';
  const header = clip(`${status} · ${tr('只读日志', 'Read-only logs')} · ${top + (visible.length ? 1 : 0)}–${Math.min(top + count, visible.length)} / ${visible.length}${dropped ? tr('（较早记录已释放）', ' (older history released)') : ''}`);
  const body = Array.from({ length: count }, (_, index) => clip(visible[top + index] || ''));
  process.stdout.write('\x1b[H\x1b[2J' + color + header + reset + '\r\n' + body.join('\r\n') + '\r\n' + clip(tr('↑↓ / PgUp / PgDn 暂停并滚动 · F 实时 · q 返回', 'Arrows / PgUp / PgDn Pause & scroll · F Live · q Back')));
}
input.on('data', chunk => {
  const parts = (partial + clean(chunk)).split('\n');
  partial = parts.pop().slice(-8192);
  for (const line of parts) lines.push(line.slice(0, 8192));
  if (lines.length > 4000) {
    const excess = lines.length - 4000;
    lines.splice(0, excess);
    top = Math.max(0, top - excess);
    dropped += excess;
  }
  if (following) dirty = true;
});
function close(code = 0) {
  if (closed) return;
  closed = true;
  clearInterval(timer);
  reader.kill();
  input.destroy();
  process.stdin.setRawMode(false);
  process.stdin.pause();
  process.stdout.write('\x1b[0m\x1b[?25h\x1b[2J\x1b[H');
  process.exit(code);
}
readline.emitKeypressEvents(process.stdin);
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.on('keypress', (_, key = {}) => {
  if (key.name === 'q' || (key.ctrl && key.name === 'c')) return close();
  if (key.name === 'f' || key.name === 'end') following = true;
  else if (['up', 'down', 'pageup', 'pagedown', 'home', 'space'].includes(key.name)) {
    following = false;
    if (key.name === 'home') top = 0;
    else top += ({ up: -1, down: 1, pageup: -height(), pagedown: height(), space: 0 })[key.name];
  } else return;
  dirty = true;
  render();
});
process.stdout.on('resize', () => { dirty = true; render(); });
process.on('SIGINT', () => close());
process.on('SIGTERM', () => close(143));
input.on('error', () => close(1));
reader.on('error', () => close(1));
reader.stderr.on('data', chunk => input.emit('data', chunk));
reader.on('close', code => { if (!closed) close(code || 0); });
const timer = setInterval(render, 100);
process.stdout.write('\x1b[?25l');
render();
LOG_VIEWER_NODE
)" "$OX_INSTALL_LANGUAGE" "${files[@]}" || true
    else
      say_tone warning '当前终端不支持滚动查看器，或未安装 Node.js。使用实时输出；按 Ctrl+C 返回，不会停止服务。\n' 'A scrollable terminal or Node.js is unavailable. Streaming logs instead; Ctrl+C returns without stopping services.\n'
      trap 'stop_live_logs' INT
      tail -n 200 -F "${files[@]}" &
      live_log_pid=$!
      wait "$live_log_pid" || true
    fi
    stop_live_logs
    trap 'stop_progress; exit 130' INT
  done
}
forget_registration() {
  if [[ "$OX_SELECT_INTERACTIVE" != 1 ]]; then say '忘记登记需要交互确认，请使用 --interactive。\n' 'Forgetting a registration requires confirmation; use --interactive.\n' >&2; return 1; fi
  progress "$(translate '正在检查残留进程、启动服务和容器' 'Checking surviving processes, startup services and containers')" host_installation forget-preflight || return
  say '仅忘记 %s 实例的登记：%s\n不会停止服务、删除容器或更改云端实例。请输入完整原目录以确认；其他输入取消：\n' 'Forget only the %s registration: %s\nNo services, containers or cloud instances will be changed. Type the complete original directory to confirm; anything else cancels:\n' "${OX_INSTALL_INSTANCE:-default}" "$OX_INSTALL_ROOT"
  local confirmation
  IFS= read -r confirmation || return 1
  [[ "$confirmation" == "$OX_INSTALL_ROOT" ]] || return 1
  progress "$(translate '正在重新检查并忘记登记' 'Rechecking and forgetting registration')" host_installation forget
}
interactive_menu() {
  local discovery remainder found_cloud choice operation selected_default outcome
  local -a labels keys
  while true; do
    menu_screen
    OX_EXISTING=none
    if command -v node >/dev/null; then
      if discovery="$(host_installation discover)"; then
        OX_INSTALL_INSTANCE="$(host_installation selected-name)"
        export OX_INSTALL_INSTANCE
        OX_MODE="${discovery%%$'\n'*}"; remainder="${discovery#*$'\n'}"
        OX_INSTALL_ROOT="${remainder%%$'\n'*}"; remainder="${remainder#*$'\n'}"
        found_cloud="${remainder%%$'\n'*}"; OX_EXISTING="${remainder#*$'\n'}"
        if [[ -n "$found_cloud" ]]; then OX_CLOUD_ORIGIN="$found_cloud"
        elif [[ "$OX_MODE" == dev && "$OX_CLOUD_ORIGIN" == https://portal.oxrecorder.cn ]]; then OX_CLOUD_ORIGIN=http://localhost:3000; fi
      else
        menu_select choice "$(translate '无法读取已有安装' 'Cannot inspect the existing installation')" 0 "$(translate '重试' 'Retry')" "$(translate '退出（保留数据）' 'Exit (keep data)')" || return 0
        [[ "$choice" == 0 ]] && continue
        return 0
      fi
    fi
    export OX_INSTALL_MODE="$OX_MODE"
    if [[ "$OX_EXISTING" == missing ]]; then
      say_tone warning '此前的 %s 安装目录已丢失：%s\n请恢复原目录以修复安装，或检查残留资源后忘记登记。\n' 'Previous %s installation folder is missing: %s\nRestore the original folder to repair it, or check surviving resources before forgetting its registration.\n' "$OX_MODE" "$OX_INSTALL_ROOT"
      menu_select choice "$(translate '缺失的安装目录' 'Missing installation folder')" 0 "$(translate '返回 / 退出（保留登记）' 'Exit (keep registration)')" "$(translate '检查并忘记此实例的登记' 'Check and forget this instance’s registration')" || return 0
      [[ "$choice" == 1 ]] || return 0
      forget_registration || return 0
      return 0
    fi
    if [[ "$OX_EXISTING" != none ]]; then say_tone info '\n发现已有安装（%s）：%s\n' '\nExisting installation (%s): %s\n' "$OX_MODE" "$OX_INSTALL_ROOT"; fi
    keys=(); labels=(); selected_default=0
    if [[ "$OX_EXISTING" != none && "$OX_EXISTING" != erased && "$OX_EXISTING" != erasing && "$OX_EXISTING" != retained && "$OX_EXISTING" != partial ]]; then keys+=(resume); labels+=("$(translate '继续运行（保留数据）' 'Resume service (keep data)')"); fi
    if [[ "$OX_EXISTING" == partial ]]; then keys+=(discard); labels+=("$(translate '清理未完成的安装' 'Discard incomplete installation')"); fi
    if [[ "$OX_EXISTING" == erasing ]]; then keys+=(retry-erasure); labels+=("$(translate '重试已授权的数据删除' 'Retry authorized data deletion')"); fi
    if [[ "$OX_EXISTING" == erased ]]; then keys+=(cleanup); labels+=("$(translate '完成删除清理' 'Finish deletion')")
    elif [[ "$OX_EXISTING" != erasing && "$OX_EXISTING" != retained ]]; then keys+=(fresh); labels+=("$(translate '全新安装' 'Fresh installation')"); fi
    keys+=(status); labels+=("$(translate '查看状态（只读）' 'View status (read-only)')")
    if [[ "$OX_EXISTING" == data || "$OX_EXISTING" == retained ]]; then keys+=(restore); labels+=("$(translate '恢复云端关联（保留数据）' 'Restore cloud registration (keep data)')"); fi
    if [[ "$OX_EXISTING" == retained ]]; then keys+=(delete-data); labels+=("$(translate '删除保留的数据（云端授权）' 'Delete retained data (cloud authorization)')"); fi
    if [[ "$OX_EXISTING" != none && "$OX_EXISTING" != erased && "$OX_EXISTING" != erasing && "$OX_EXISTING" != retained && "$OX_EXISTING" != partial ]]; then
      keys+=(service); labels+=("$(translate '服务控制（启动 / 停止 / 重启）' 'Service controls (Start / Stop / Restart)')")
    fi
    if [[ "$OX_EXISTING" != none && "$OX_EXISTING" != erased ]]; then keys+=(logs); labels+=("$(translate '查看实时日志（只读）' 'View live logs (read-only)')"); fi
    keys+=(settings language help exit); labels+=("$(translate '安装设置与依赖' 'Settings and dependencies')" "$(translate '切换语言' 'Change language')" "$(translate '帮助' 'Help')" "$(translate '退出' 'Exit')")
    menu_select choice "$(translate 'OX 自托管服务 · 主菜单' 'OX Self-Host · Main menu')" "$selected_default" "${labels[@]}" || return 0
    if [[ "$choice" == -1 ]]; then return 0; fi
    operation="${keys[$choice]}"
    case "$operation" in
      exit) return 0 ;;
      language) select_language || return 0 ;;
      settings) menu_settings || return 0 ;;
      logs) menu_live_logs || return 0 ;;
      service)
        menu_screen
        menu_select choice "$(translate '服务控制' 'Service controls')" 0 "$(translate '启动服务' 'Start service')" "$(translate '停止服务（保留云端控制）' 'Stop service (keep cloud control)')" "$(translate '重启服务' 'Restart service')" "$(translate '返回主菜单' 'Back to main menu')" || return 0
        case "$choice" in
          0) operation=start ;; 1) operation=stop ;; 2) operation=restart ;; *) continue ;;
        esac
        outcome=0; menu_execute "$operation" || outcome=$?; [[ "$outcome" != 2 ]] || return 0 ;;

      delete-data)
        say '请在云端控制台的“已删除的实例”中选择此实例并授权删除数据。收到授权后服务器会执行删除；完成后在此选择“完成删除清理”。\n' 'Open Deleted instances in the cloud portal, select this instance and authorize data deletion. The server will erase data after receiving authorization; then choose Finish deletion here.\n'
        menu_pause || return 0 ;;
      status|help)
        menu_screen
        if [[ "$operation" == help ]]; then bash "${BASH_SOURCE[0]}" --help --language "$OX_INSTALL_LANGUAGE"
        elif command -v node >/dev/null; then show_instance_status || true
        else say '查看状态需要 Node.js。可在“安装设置与依赖”中准备依赖。\n' 'Status requires Node.js. Use Settings and dependencies to prepare it.\n'; fi
        menu_select choice "$(translate '返回' 'Return')" 0 "$(translate '返回主菜单' 'Back to main menu')" "$(translate '退出' 'Exit')" || return 0
        [[ "$choice" != 1 ]] || return 0 ;;
      *) outcome=0; menu_execute "$operation" || outcome=$?; [[ "$outcome" != 2 && "$outcome" != 3 ]] || return 0 ;;
    esac
  done
}
new_instance() {
  local suffix prefix
  prefix=prod; [[ "$OX_MODE" != dev ]] || prefix=dev
  while true; do
    suffix="$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
    OX_INSTALL_INSTANCE="ox-$prefix-$suffix"
    [[ -e "$OX_HOST_REGISTRY/$OX_MODE--$OX_INSTALL_INSTANCE.json" || -L "$OX_HOST_REGISTRY/$OX_MODE--$OX_INSTALL_INSTANCE.json" ]] || break
  done
  OX_INSTALL_ALLOW_CREATE=1
  export OX_INSTALL_INSTANCE OX_INSTALL_ALLOW_CREATE
  if [[ "$OX_MENU_HOME_CUSTOM" != 1 ]]; then
    if [[ "$OX_MODE" == dev ]]; then OX_INSTALL_ROOT="$HOME/Documents/OX-SelfHost-Instances/$OX_INSTALL_INSTANCE"
    elif [[ "$OX_INSTALL_PLATFORM" == Darwin ]]; then OX_INSTALL_ROOT="$HOME/.local/share/ox-self-host/instances/$OX_INSTALL_INSTANCE"
    else OX_INSTALL_ROOT="$PWD/OX-SelfHost-Instances/$OX_INSTALL_INSTANCE"; fi
  fi
  OX_EXISTING=none
}
instance_menu() {
  local choice rows row record_mode record_name record_directory record_state record_environment record_title
  local -a names modes directories labels
  while true; do
    menu_screen
    menu_select choice "$(translate 'OX 自托管 · 实例' 'OX Self-Host · Instances')" 0 \
      "$(translate '创建新实例' 'Create a new instance')" \
      "$(translate '管理已有实例' 'Manage an existing instance')" \
      "$(translate '切换语言' 'Change language')" \
      "$(translate '帮助' 'Help')" "$(translate '退出' 'Exit')" || return 0
    case "$choice" in
      0)
        # Customers always create production instances; maintainers keep the mode they selected.
        [[ "$OX_MODE" == dev ]] || { OX_MODE=production; OX_CLOUD_ORIGIN="${OX_CLOUD_OVERRIDE:-https://portal.oxrecorder.cn}"; }
        OX_MENU_HOME_CUSTOM=0
        new_instance
        say '新实例名称：%s\n' 'New instance name: %s\n' "$OX_INSTALL_INSTANCE"
        interactive_menu ;;
      1)
        if ! command -v node >/dev/null; then
          say '列出已有实例需要 Node.js。请先准备运行依赖。\n' 'Listing instances requires Node.js. Prepare runtime dependencies first.\n'
          menu_pause || return 0; continue
        fi
        if ! rows="$(host_installation list)"; then menu_pause || return 0; continue; fi
        names=(); modes=(); directories=(); labels=()
        while IFS=$'\t' read -r record_mode record_name record_directory record_state record_environment record_title; do
          [[ -n "$record_name" ]] || continue
          names+=("$record_name"); modes+=("$record_mode"); directories+=("$record_directory")
          labels+=("$record_name${record_title:+ \"$record_title\"} [${record_environment:-$record_mode}] — $record_directory ($record_state)")
        done <<< "$rows"
        if [[ "${#names[@]}" == 0 ]]; then
          say '没有已登记的实例。\n' 'No registered instances.\n'; menu_pause || return 0; continue
        fi
        labels+=("$(translate '返回' 'Back')")
        menu_select choice "$(translate '选择实例' 'Select an instance')" 0 "${labels[@]}" || return 0
        [[ "$choice" -ge 0 && "$choice" -lt "${#names[@]}" ]] || continue
        OX_INSTALL_INSTANCE="${names[$choice]}"; OX_MODE="${modes[$choice]}"; OX_INSTALL_ROOT="${directories[$choice]}"
        export OX_INSTALL_INSTANCE
        interactive_menu ;;
      2) select_language || return 0 ;;
      3) bash "${BASH_SOURCE[0]}" --help --language "$OX_INSTALL_LANGUAGE"; menu_pause || return 0 ;;
      *) return 0 ;;
    esac
    OX_INSTALL_INSTANCE=""; export OX_INSTALL_INSTANCE
  done
}
if [[ -n "$OX_INSTALL_INSTANCE" && "$OX_INSTALL_INSTANCE" != default && "$OX_MENU_HOME_CUSTOM" != 1 ]]; then
  if [[ "$OX_MODE" == dev ]]; then OX_INSTALL_ROOT="$HOME/Documents/OX-SelfHost-Instances/$OX_INSTALL_INSTANCE"
  elif [[ "$OX_INSTALL_PLATFORM" == Darwin ]]; then OX_INSTALL_ROOT="$HOME/.local/share/ox-self-host/instances/$OX_INSTALL_INSTANCE"
  else OX_INSTALL_ROOT="$PWD/OX-SelfHost-Instances/$OX_INSTALL_INSTANCE"; fi
fi
OX_INSTALL_ROOT="$(absolute_path "$OX_INSTALL_ROOT")"
case "$OX_ACTION" in
  host-check) run_host_helper check; exit $? ;;
  host-configure)
    if command -v node >/dev/null; then OX_INSTALL_ROOT="$(host_installation resolve)" || exit; fi
    configure_host_mode; exit $? ;;
  host-restore) restore_host_mode; exit $? ;;
esac
if [[ "$OX_LIST_INSTANCES" == 1 ]]; then
  host_installation list
  exit $?
fi
if [[ "$OX_NEW_INSTANCE" == 1 ]]; then new_instance; fi
if [[ "$OX_STATUS" == 1 ]]; then
  if ! command -v node >/dev/null; then
    say '无法查看状态：找不到 Node.js。此命令不会安装依赖。\n' 'Cannot inspect status: Node.js was not found. This command does not install dependencies.\n' >&2
    exit 1
  fi
  show_instance_status
  exit $?
fi
if [[ "$OX_SELECT_INTERACTIVE" == 1 && -z "$OX_ACTION" && "$OX_TEARDOWN" == 0 && "$OX_DEPENDENCIES_ONLY" == 0 && "${OX_INSTALL_MENU_CHILD:-0}" != 1 ]]; then
  if [[ -n "$OX_INSTALL_INSTANCE" || "$OX_MENU_HOME_CUSTOM" == 1 ]]; then interactive_menu
  else instance_menu; fi
  exit 0
fi
if [[ "$OX_ACTION" == forget-registration ]]; then
  OX_INSTALL_INSTANCE="$(host_installation selected-name)"
  export OX_INSTALL_INSTANCE
  OX_INSTALL_ROOT="$(host_installation resolve)"
  forget_registration
  exit $?
fi
if [[ "$OX_ACTION" == start || "$OX_ACTION" == stop || "$OX_ACTION" == restart ]]; then
  if ! command -v node >/dev/null; then say '服务控制需要 Node.js。\n' 'Service control requires Node.js.\n' >&2; exit 1; fi
  discovery="$(host_installation discover)"
  OX_MODE="${discovery%%$'\n'*}"; remainder="${discovery#*$'\n'}"
  OX_INSTALL_ROOT="${remainder%%$'\n'*}"; remainder="${remainder#*$'\n'}"
  found_cloud="${remainder%%$'\n'*}"
  if [[ -z "$OX_CLOUD_OVERRIDE" ]]; then
    if [[ -n "$found_cloud" ]]; then OX_CLOUD_ORIGIN="$found_cloud"
    elif [[ "$OX_MODE" == dev ]]; then OX_CLOUD_ORIGIN=http://localhost:3000; fi
  fi
  export OX_INSTALL_MODE="$OX_MODE"
fi
if [[ "$OX_DEPENDENCIES_ONLY" != 1 ]] && command -v node >/dev/null; then
  OX_INSTALL_ROOT="$(host_installation resolve)"
fi
# Never route unfinished setup into runtime recovery, even after instance.json exists.
if [[ "$OX_DEPENDENCIES_ONLY" != 1 ]] && command -v node >/dev/null; then
  OX_EXISTING="$(host_installation inspect)"
  if [[ "$OX_ACTION" == discard ]]; then
    [[ "$OX_EXISTING" == partial ]] || { say '只能清理未完成的安装。\n' 'Only an incomplete installation can be discarded.\n' >&2; exit 1; }
    [[ "$OX_SELECT_INTERACTIVE" == 1 ]] || { say '清理需要交互确认，请使用 --interactive。\n' 'Discard requires confirmation; use --interactive.\n' >&2; exit 1; }
    say '将永久删除未完成实例 %s 的专用容器和目录：%s\n停止其进程并撤销云端登记；其他实例和共享镜像不受影响。开始删除后无法撤销。\n确认清理？输入 yes 确认，输入 no 或直接回车取消 [no]：\n' 'Permanently discard incomplete instance %s and its dedicated containers and directory: %s\nIts processes will stop and cloud enrollment will be cancelled. Other instances and shared images are preserved. Removal cannot be undone.\nConfirm cleanup? Type yes to proceed, or no / Enter to cancel [no]:\n' "${OX_INSTALL_INSTANCE:-default}" "$OX_INSTALL_ROOT"
    IFS= read -r discard_confirmation || exit 1
    case "$discard_confirmation" in [yY][eE][sS]) ;; *) exit 1 ;; esac
    if [[ "$OX_MODE" == production && ! -e "$OX_INSTALL_ROOT/source-path" && ! -L "$OX_INSTALL_ROOT/source-path" ]]; then
      progress "$(translate '正在清理未完成的安装下载' 'Discarding the incomplete installation download')" host_installation discard-bootstrap
      say '清理完成。请重新运行安装程序，选择创建新实例。\n' 'Cleanup complete. Run the installer again and choose Create new instance.\n'
      exit 0
    fi
    if [[ "$OX_MODE" == dev ]]; then
      discard_source="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
    else
      discard_source="$(cat "$OX_INSTALL_ROOT/source-path")"
    fi
    [[ -f "$discard_source/apps/self-host/setup/discard.js" ]] || { say '安装包缺少安全清理功能，请使用更新的安装包。未删除数据。\n' 'The installation package lacks safe cleanup support. Use an updated package; data was preserved.\n' >&2; exit 1; }
    export OX_SELF_HOST_HOME="$OX_INSTALL_ROOT"
    export PATH="$PATH:/Applications/Docker.app/Contents/Resources/bin:$HOME/.docker/bin"
    if [[ -f "$OX_INSTALL_ROOT/headless-runtime.json" ]]; then
      DOCKER_HOST="$(node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(process.argv[1])).dockerHost)' "$OX_INSTALL_ROOT/headless-runtime.json")"; export DOCKER_HOST; unset DOCKER_CONTEXT
    fi
    # Prompt visibly here; the helper only uses sudo -n so it never waits on a hidden password prompt.
    if [[ "$OX_INSTALL_PLATFORM" == Darwin ]]; then authorize_admin || exit 1; fi
    progress "$(translate '正在清理未完成的安装' 'Discarding the incomplete installation')" node "$discard_source/apps/self-host/setup/discard.js" "$OX_MODE" "$discard_source"
    host_installation release
    say '清理完成。请重新运行安装程序，选择创建新实例。\n' 'Cleanup complete. Run the installer again and choose Create new instance.\n'
    exit 0
  fi
  if [[ "$OX_EXISTING" == partial ]]; then
    say '未完成的安装不能继续。请运行 --action discard --interactive 清理此实例，然后创建新实例。\n' 'Incomplete setup cannot be resumed. Run --action discard --interactive for this instance, then create a new instance.\n' >&2
    exit 1
  fi
fi
if [[ "$OX_ACTION" == start || "$OX_ACTION" == stop || "$OX_ACTION" == restart || ( "$OX_ACTION" == resume && -f "$OX_INSTALL_ROOT/instance.json" ) ]]; then
  service_action="$OX_ACTION"; OX_SERVICE_RESUME=0
  if [[ "$service_action" == resume ]]; then service_action=start; OX_SERVICE_RESUME=1; fi
  export OX_SERVICE_RESUME
  service_result=0
  progress "$(translate '正在处理服务操作' 'Processing service action')" host_installation "service-$service_action" || service_result=$?
  if [[ "$service_result" != 3 ]]; then exit "$service_result"; fi
  OX_ACTION=resume
fi
if [[ "$OX_DEPENDENCIES_ONLY" != 1 && "$OX_TEARDOWN" != 1 ]]; then
  OX_EXISTING=none
  if command -v node >/dev/null; then OX_EXISTING="$(host_installation inspect)"; fi
  if [[ -z "$OX_ACTION" ]]; then
    if [[ "$OX_EXISTING" == none ]]; then OX_ACTION=fresh
    elif [[ "$OX_EXISTING" == erased ]]; then OX_ACTION=cleanup
    elif [[ "$OX_EXISTING" == retained ]]; then OX_ACTION=restore
    else OX_ACTION=resume; fi
    if [[ "$OX_SELECT_INTERACTIVE" == 1 && "$OX_EXISTING" != none && "$OX_EXISTING" != erased && "$OX_EXISTING" != erasing && "$OX_EXISTING" != retained ]]; then
      say '发现已有安装：%s\n  1) 继续运行已有服务（保留数据）\n  2) 全新安装（请先通过云端删除已有数据）\n' 'Existing installation: %s\n  1) Resume the existing service (keep data)\n  2) Fresh installation (remove existing data through cloud first)\n' "$OX_INSTALL_ROOT"
      if [[ "$OX_EXISTING" == data ]]; then say '  3) 恢复云端关联（保留数据，需要云端重新授权）\n' '  3) Restore cloud registration (keep data; new cloud authorization required)\n'; fi
      while true; do
        action_default=1; [[ "$OX_EXISTING" == erased ]] && action_default=2
        printf '[%s]: ' "$action_default"
        IFS= read -r action_choice || exit 1
        case "${action_choice:-$action_default}" in
          1) OX_ACTION=resume; break ;;
          2) OX_ACTION=fresh; break ;;
          3) if [[ "$OX_EXISTING" == data ]]; then OX_ACTION=restore; break; fi ;;
        esac
        say '请选择可用的操作。\n' 'Choose an available action.\n'
      done
    fi
  fi
  if [[ "$OX_ACTION" == restore ]]; then
    if [[ "$OX_EXISTING" != data && "$OX_EXISTING" != retained ]]; then say '没有可恢复的保留数据，请清理未完成的安装后选择全新安装。\n' 'No retained data is available. Discard the incomplete installation, then create a new instance.\n' >&2; exit 1; fi
    say '请在原云端账户中打开“已删除的实例”，选择此实例并授权恢复。然后粘贴一小时有效的恢复密钥。\n恢复密钥（输入不显示）：' 'In the original cloud account, open Deleted instances, choose this instance and authorize restoration. Paste the one-hour restoration key.\nRestoration key (hidden input): '
    IFS= read -r -s restore_key || exit 1
    printf '\n'
    progress "$(translate '正在请求云端恢复授权' 'Requesting cloud restoration authorization')" host_installation restore 3<<<"$restore_key"
    unset restore_key
    OX_ACTION=resume
  fi
  if [[ "$OX_ACTION" == cleanup ]]; then
    progress "$(translate '正在确认云端删除已完成' 'Confirming completed cloud data removal')" host_installation cleanup-preflight
    OX_TEARDOWN=1
  elif [[ "$OX_ACTION" == fresh && "$OX_EXISTING" != none ]]; then
    say '已有安装。已删除的实例请先选择“完成删除清理”，再从实例列表创建新实例。保留数据的实例请选择恢复。\n' 'An installation already exists. Delete the instance and self-hosted server data from the cloud portal if needed, then finish deletion cleanup and create a new instance from the instance list. Restore instances whose data was retained.\n' >&2
    exit 1
  elif [[ "$OX_ACTION" == resume && "$OX_EXISTING" == none ]]; then
    say '没有可继续运行的安装，请先完成删除清理，再创建新实例。\n' 'No installation exists to resume. Choose Fresh.\n' >&2; exit 1
  elif [[ "$OX_EXISTING" != erasing ]] && command -v node >/dev/null; then
    reuse_running_installation
  fi
fi
if [[ "$OX_TEARDOWN" == 1 ]]; then
  if [[ ! -d "$OX_INSTALL_ROOT" ]]; then host_installation release; say '没有找到安装目录。\n' 'No installation directory exists.\n'; exit 0; fi
  command -v node >/dev/null || { say '卸载需要 Node.js。\n' 'Node.js is required for teardown.\n' >&2; exit 1; }
  if [[ "$OX_MODE" == dev ]]; then
    OX_SOURCE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
  else
    OX_SOURCE_ROOT="$(node - "$OX_INSTALL_ROOT" <<'NODE'
const fs = require('fs'), path = require('path');
const directory = fs.realpathSync(process.argv[2]);
const source = fs.realpathSync(fs.readFileSync(path.join(directory, 'source-path'), 'utf8').trim());
if (path.dirname(source) !== directory || !/^source-[a-f0-9]{64}$/.test(path.basename(source))) throw Error('Invalid release ownership');
process.stdout.write(source);
NODE
)"
  fi
  cleanup_tool="$OX_SOURCE_ROOT/apps/self-host/setup/cleanup.js"
  checkout_cleanup="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/apps/self-host/setup/cleanup.js"
  if [[ -f "$checkout_cleanup" && -f "$(dirname "$checkout_cleanup")/../../../scripts/install_self_host.sh" ]]; then cleanup_tool="$checkout_cleanup"; fi
  if [[ ! -f "$cleanup_tool" && "$OX_MODE" == production ]]; then
    fetch_cleanup_tools() {
      node --input-type=module - "$OX_SOURCE_ROOT" "$OX_INSTALL_ROOT" <<'CLEANUP_TOOLS'
import fs from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const [source, directory] = process.argv.slice(2);
let temporary;
try {
  const config = JSON.parse(await fs.readFile(path.join(directory, 'instance.json'), 'utf8'));
  const { validateRelease, downloadVerifiedRelease } = await import(pathToFileURL(path.join(source, 'apps/self-host/supervisor/updates.js')));
  const { extractRelease } = await import(pathToFileURL(path.join(source, 'apps/self-host/supervisor/install.js')));
  const origin = new URL(config.cloudOrigin);
  if (origin.protocol !== 'https:' || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash) throw Error('Invalid cleanup distribution origin');
  const response = await fetch(`${origin.origin}/api/self-host/releases/current?platform=${process.platform}&architecture=${process.arch}`, { redirect: 'error', signal: AbortSignal.timeout(20000) });
  if (!response.ok) throw Error('Cleanup release unavailable');
  const { manifest } = await response.json();
  // Pin the existing installation's signing identity, never a key from this response.
  const release = validateRelease(manifest, config.signingPublicKey);
  if (new URL(release.url).origin !== origin.origin) throw Error('Invalid cleanup release origin');
  temporary = await fs.mkdtemp(path.join(directory, '.cleanup-tools-'));
  const filename = path.join(temporary, 'bundle.tgz');
  await downloadVerifiedRelease(release, filename);
  const extracted = await extractRelease({ manifest, filename }, config, temporary);
  const tool = path.join(extracted, 'apps/self-host/setup/cleanup.js');
  await fs.access(tool);
  await fs.writeFile(path.join(directory, 'cleanup-tool-path'), tool, { mode: 0o600 });
  // This cache is removed with the installation; no current/source link changes.
} catch (error) {
  if (temporary) await fs.rm(temporary, { recursive: true, force: true });
  console.error(process.env.OX_INSTALL_LANGUAGE === 'en' ? `Cleanup tools unavailable: ${error.message}` : `无法准备清理工具：${error.message}`);
  process.exitCode = 1;
}
CLEANUP_TOOLS
    }
    progress "$(translate '正在下载并验证删除清理工具' 'Downloading and verifying deletion cleanup tools')" fetch_cleanup_tools
    cleanup_tool="$(cat "$OX_INSTALL_ROOT/cleanup-tool-path")"
  fi
  [[ -f "$cleanup_tool" ]] || { say '清理工具不可用，登记已保留。请使用更新的安装程序重试。\n' 'Cleanup tools are unavailable; registration was preserved. Retry with an updated installer.\n' >&2; exit 1; }
  if [[ "$OX_INSTALL_PLATFORM" == Darwin ]]; then authorize_admin || exit 1; fi
  export OX_SELF_HOST_HOME="$OX_INSTALL_ROOT"
  progress "$(translate '正在清理已由云端删除的安装' 'Cleaning the installation already erased through cloud')" node "$cleanup_tool" "$OX_MODE" "$OX_SOURCE_ROOT"
  host_installation release
  say '删除清理已完成，实例已从列表移除。\n' 'Deletion cleanup completed; the instance was removed from discovery.\n'
  exit 0
fi
if [[ "$OX_MODE" == dev ]]; then
  OX_SOURCE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
  [[ -f "$OX_SOURCE_ROOT/scripts/self-host-dev.js" ]] || { printf 'Development mode must run from the webservice checkout.\n' >&2; exit 1; }
fi
if [[ "$OX_DEPENDENCIES_ONLY" != 1 && "$OX_MODE" == production ]]; then
say 'OX 自托管服务安装向导\n按回车使用方括号中的默认值，按 Ctrl+C 取消。\n' 'OX Self-Host Setup Wizard\nPress Enter to accept the default in brackets, or Ctrl+C to cancel.\n'
if [[ "${OX_INSTALL_DOCKER_REEXEC:-0}" != 1 && ( "$OX_INSTALL_INSTANCE" == "" || "$OX_INSTALL_INSTANCE" == default ) ]] && [[ ! -f "$OX_HOST_REGISTRY/$OX_MODE.json" && ! -f "$OX_INSTALL_ROOT/installation.json" ]]; then
ask OX_INSTALL_ROOT "$(translate '1. 安装到哪个文件夹？' '1. Which folder should be used for installation?')" "$OX_INSTALL_ROOT"
OX_INSTALL_ROOT="$(absolute_path "$OX_INSTALL_ROOT")"
fi
# Resume only a previously installed release; never launch a local checkout.
if [[ -f "$OX_INSTALL_ROOT/instance.json" ]]; then
  if [[ ! -f "$OX_INSTALL_ROOT/source-path" ]]; then
    say '此目录不是发布版本安装目录。请检查原安装模式和安装目录；已有配置未修改。\n' 'This is not a release installation directory. Check the original installation mode and directory; existing configuration was not changed.\n' >&2
    exit 1
  fi
  if ! command -v node >/dev/null; then say '恢复已有实例需要先安装 Node.js 18+。\n' 'Install Node.js 18+ before resuming an existing instance.\n' >&2; exit 1; fi
  OX_RELEASE_DIRECTORY="$(node - "$OX_INSTALL_ROOT" <<'NODE'
const fs = require('fs');
const path = require('path');
try {
  const root = fs.realpathSync(process.argv[2]);
  const directory = fs.realpathSync(fs.readFileSync(path.join(root, 'source-path'), 'utf8').trim());
  const config = JSON.parse(fs.readFileSync(path.join(root, 'instance.json'), 'utf8'));
  if (process.env.OX_CLOUD_OVERRIDE && new URL(process.env.OX_CLOUD_OVERRIDE).origin !== config.cloudOrigin) throw Error('Cloud origin cannot change on resume');
  if (path.dirname(directory) !== root || !/^source-[a-f0-9]{64}$/.test(path.basename(directory)) ||
      fs.realpathSync(config.sourceRoot) !== directory ||
      fs.realpathSync(config.brokerBinary) !== path.join(directory, 'bin/pssp-broker')) throw Error();
  process.stdout.write(directory);
} catch {
  console.error((process.env.OX_INSTALL_LANGUAGE === 'en' ? 'The existing instance does not match a release directory. Check the original installation mode and directory; existing configuration was not changed.' : '已有实例不匹配发布安装目录。请检查原安装模式和安装目录；已有配置未修改。'));
  process.exit(1);
}
NODE
)"
  export OX_PSSP_BINARY="$OX_RELEASE_DIRECTORY/bin/pssp-broker"
  say '发现已有发布实例，将保留配置并使用已安装版本启动。\n' 'Found an existing release instance. Its configuration will be preserved and the installed version will be started.\n'
elif [[ "${OX_INSTALL_DOCKER_REEXEC:-0}" != 1 ]]; then
  ask OX_CLOUD_ORIGIN "$(translate '2. 您的云端控制台 HTTPS 地址是什么？' '2. What is your cloud portal HTTPS URL?')" "$OX_CLOUD_ORIGIN"
  while true; do
    ask OX_SETUP_PORT "$(translate '3. 自托管设置页面使用哪个端口？' '3. Which port should the self-hosted setup page use?')" "$OX_SETUP_PORT"
    if [[ "$OX_SETUP_PORT" =~ ^[0-9]{1,5}$ ]] && (( 10#$OX_SETUP_PORT >= 1 && 10#$OX_SETUP_PORT <= 65535 )); then break; fi
    invalid_choice "$(translate '请输入 1 到 65535 之间的端口。' 'Enter a port between 1 and 65535.')"
    OX_SETUP_PORT=3081
  done

fi
fi
check_host_hardware() {
  # Fresh-install requirements only: never block management of retained releases.
  # MongoDB 7 requires AVX on x86. Check OS-visible flags, not CPU model names:
  # a hypervisor may hide instructions supported by the physical processor.
  local architecture features result
  architecture="$(uname -m)" || return 1
  if [[ "$OX_INSTALL_PLATFORM" == Darwin && "$(sysctl -n hw.optional.arm64 2>/dev/null || true)" == 1 ]]; then architecture=arm64; fi
  say '正在检查 CPU 兼容性…\n' 'Checking CPU compatibility…\n'
  case "$architecture" in
    arm64|aarch64) return 0 ;; # AVX is x86-only; do not infer ARM ISA support from x86 flags.
    x86_64|amd64) ;;
    *) say '不支持 CPU 架构 %s；自托管安装包仅支持 x86_64 和 ARM64。尚未下载依赖或进行云端登记。\n' 'Unsupported CPU architecture %s; self-hosted packages support x86_64 and ARM64 only. No dependencies have been downloaded and cloud enrollment has not started.\n' "$architecture" >&2; return 1 ;;
  esac
  result=unknown
  if [[ "$OX_INSTALL_PLATFORM" == Linux ]]; then
    # Require AVX on every reported logical processor. Empty/unreadable cpuinfo
    # is an inspection failure, not evidence that the hardware lacks AVX.
    result="$(awk '
      /^[[:space:]]*processor[[:space:]]*:/ { processors++ }
      /^[[:space:]]*flags[[:space:]]*:/ {
        flags++; avx=0
        for (i=3; i<=NF; i++) if ($i == "avx") avx=1
        if (!avx) missing=1
      }
      END { if (!flags || flags != processors) print "unknown"; else if (missing) print "missing"; else print "supported" }
    ' /proc/cpuinfo 2>/dev/null)" || result=unknown
  elif features="$(sysctl -n machdep.cpu.features 2>/dev/null)" && [[ -n "$features" ]]; then
    case " $features " in *' AVX1.0 '*) result=supported ;; *) result=missing ;; esac
  fi
  if [[ "$result" == supported ]]; then return 0; fi
  if [[ "$result" == missing ]]; then
    say '无法安装：当前系统未提供 AVX CPU 指令，而随附的 MongoDB 7 必须使用 AVX。请使用支持 AVX 的主机；如果是虚拟机，请检查 CPU 指令透传设置。重装或创建新实例无法解决此问题。尚未下载依赖或进行云端登记。\n' 'Cannot install: this system does not expose AVX CPU instructions required by the bundled MongoDB 7. Use an AVX-capable host; for a VM, check CPU instruction passthrough. Reinstalling or creating another instance will not resolve this. No dependencies have been downloaded and cloud enrollment has not started.\n' >&2
  else
    say '无法确认 CPU 是否支持 MongoDB 7 所需的 AVX 指令。请检查系统 CPU 信息的读取权限（Linux：/proc/cpuinfo；Intel macOS：sysctl machdep.cpu.features），然后重新运行安装程序。尚未下载依赖或进行云端登记。\n' 'Cannot verify AVX support required by MongoDB 7. Check access to system CPU information (Linux: /proc/cpuinfo; Intel macOS: sysctl machdep.cpu.features), then rerun the installer. No dependencies have been downloaded and cloud enrollment has not started.\n' >&2
  fi
  return 1
}
if [[ "$OX_ACTION" == fresh || "$OX_DEPENDENCIES_ONLY" == 1 ]]; then check_host_hardware || exit 1; fi
export OX_CLOUD_ORIGIN OX_SETUP_PORT OX_PSSP_BINARY
# Docker preparation is kept separate from the release installation.
docker_temporary=''
docker_mount=''
linux_docker_launcher=''
cleanup_docker() {
  local exit_code=$?
  stop_progress
  if [[ "$exit_code" != 0 && -n "${OX_SETUP_PID:-}" && -n "${OX_SELF_HOST_HOME:-}" ]]; then
    printf '{}' > "$OX_SELF_HOST_HOME/setup-startup-error.json"
    progress "$(translate '正在向设置页面报告启动失败' 'Showing startup failure on the setup page')" sleep 3
  fi
  if [[ -n "${OX_SETUP_PID:-}" ]]; then kill "$OX_SETUP_PID" 2>/dev/null || true; wait "$OX_SETUP_PID" 2>/dev/null || true; fi
  if [[ -n "$docker_mount" ]]; then hdiutil detach "$docker_mount" >/dev/null 2>&1 || true; fi
  if [[ -n "$docker_temporary" ]]; then rm -rf "$docker_temporary"; fi
  if [[ -n "$linux_docker_launcher" ]]; then rm -f "$linux_docker_launcher"; fi
}
trap cleanup_docker EXIT
trap 'cleanup_docker; exit 130' INT
trap 'cleanup_docker; exit 143' TERM
runner=(env); if [[ "$EUID" != 0 ]]; then runner=(sudo); fi
# Reuse the private runtime on subsequent invocations and in installed services.
export PATH="$HOME/.local/share/ox-self-host/node/bin:$HOME/.local/bin:$PATH"
missing=0
for tool in openssl curl tar; do command -v "$tool" >/dev/null || missing=1; done
if [[ "$missing" == 1 && "$OX_INSTALL_PLATFORM" == Linux ]] && command -v apt-get >/dev/null; then
  progress "$(translate '正在更新安装依赖列表' 'Updating dependency package lists')" "${runner[@]}" apt-get -q update
  progress "$(translate '正在安装下载与证书工具' 'Installing download and certificate tools')" "${runner[@]}" env DEBIAN_FRONTEND=noninteractive apt-get -q install -y openssl curl ca-certificates tar
fi
for tool in openssl curl tar; do command -v "$tool" >/dev/null || { say '缺少工具：%s\n' 'Missing prerequisite: %s\n' "$tool" >&2; exit 1; }; done
install_node() {
  local platform architecture manifest archive expected actual temporary destination
  case "$OX_INSTALL_PLATFORM" in Darwin) platform=darwin ;; Linux) platform=linux ;; esac
  case "$(uname -m)" in arm64|aarch64) architecture=arm64 ;; x86_64|amd64) architecture=x64 ;; *) printf 'Unsupported Node.js architecture\n' >&2; return 1 ;; esac
  temporary="$(mktemp -d)"
  destination="$HOME/.local/share/ox-self-host/node"
  if ! curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --retry 3 --connect-timeout 20 --max-time 120 -o "$temporary/SHASUMS256.txt" https://nodejs.org/dist/latest-v22.x/SHASUMS256.txt; then rm -rf "$temporary"; return 1; fi
  manifest="$(awk -v suffix="-$platform-$architecture.tar.gz" 'index($2,suffix) && substr($2,length($2)-length(suffix)+1)==suffix {print $0}' "$temporary/SHASUMS256.txt")"
  expected="${manifest%% *}"; archive="${manifest##* }"
  if [[ ! "$expected" =~ ^[a-f0-9]{64}$ || ! "$archive" =~ ^node-v22\.[0-9]+\.[0-9]+-(linux|darwin)-(arm64|x64)\.tar\.gz$ ]]; then rm -rf "$temporary"; return 1; fi
  if ! download "$(translate '正在下载 Node.js 运行环境' 'Downloading Node.js runtime')" "$temporary/node.tgz" 1800 "https://nodejs.org/dist/latest-v22.x/$archive"; then rm -rf "$temporary"; return 1; fi
  actual="$(openssl dgst -sha256 "$temporary/node.tgz")"; actual="${actual##* }"
  if [[ "$actual" != "$expected" ]]; then rm -rf "$temporary"; return 1; fi
  mkdir -p "$temporary/runtime" "$(dirname "$destination")"
  tar -xzf "$temporary/node.tgz" --strip-components=1 -C "$temporary/runtime" || { rm -rf "$temporary"; return 1; }
  "$temporary/runtime/bin/node" -e 'if(Number(process.versions.node.split(".")[0])<18)process.exit(1)' || { rm -rf "$temporary"; return 1; }
  if [[ -e "$destination" ]]; then mv "$destination" "$temporary/previous"; fi
  mv "$temporary/runtime" "$destination"
  rm -rf "$temporary"
}
if ! command -v node >/dev/null || ! command -v npm >/dev/null || ! node -e 'if(Number(process.versions.node.split(".")[0])<18)process.exit(1)'; then
  OX_PROGRESS_NATIVE=2 progress "$(translate '正在安装 Node.js 运行环境' 'Installing Node.js runtime')" install_node
  hash -r
fi
wait_for_docker() {
  local attempt
  for ((attempt=0; attempt<120; attempt++)); do
    if docker info >/dev/null 2>&1; then return 0; fi
    sleep 5
  done
  return 1
}
docker_login_help() {
  say 'Docker 登录说明：\n  1. 公共镜像通常无需账户。只有 Docker 要求登录或镜像下载受到限制时才需要登录。\n  2. macOS：在 Docker Desktop 右上角点击 Sign in，在浏览器登录或创建 Docker 账户，完成后返回 Docker Desktop。\n  3. Linux：在另一个终端运行 docker login，打开它显示的网址并输入一次性代码；无浏览器时在另一台设备上打开网址。\n  4. 使用运行此安装脚本的同一系统账户登录；不要把 Docker 密码或令牌输入此安装脚本。登录完成后重新尝试安装。\n' 'Docker sign-in instructions:\n  1. Public images normally need no account. Sign in only if Docker requires it or image downloads are limited.\n  2. macOS: click Sign in at the top right of Docker Desktop, sign in or create a Docker account in the browser, then return to Docker Desktop.\n  3. Linux: run docker login in another terminal, open the displayed URL and enter its one-time code; use another device if this server has no browser.\n  4. Sign in as the same operating-system user running this installer. Never enter a Docker password or token into this installer. Retry installation after signing in.\n'
}
prepare_headless_docker() {
  # A private Lima VM leaves the user's Docker context and other projects alone.
  if ! command -v limactl >/dev/null || ! command -v docker >/dev/null || ! docker compose version >/dev/null 2>&1; then
    if ! command -v brew >/dev/null; then
      say '无人值守 macOS 安装需要 Homebrew。请先从 https://brew.sh 安装，再重新运行。\n' 'Unattended macOS installation requires Homebrew. Install it from https://brew.sh, then rerun.\n' >&2
      return 1
    fi
    OX_PROGRESS_NATIVE=1 progress "$(translate '正在安装无人值守容器运行环境' 'Installing the headless container runtime')" brew install lima docker docker-compose || return
    if ! docker compose version >/dev/null 2>&1; then
      mkdir -p "$HOME/.docker/cli-plugins"
      if [[ ! -e "$HOME/.docker/cli-plugins/docker-compose" ]]; then ln -s "$(brew --prefix)/opt/docker-compose/bin/docker-compose" "$HOME/.docker/cli-plugins/docker-compose"; fi
      docker compose version >/dev/null || return
    fi
  fi
  if ! limactl autostart enable --help 2>&1 | grep -q -- '--condition'; then
    say '需要 Lima 2.2 或更高版本以支持登录前启动。请运行 brew upgrade lima 后重试。\n' 'Lima 2.2 or newer is required for startup before login. Run brew upgrade lima and retry.\n' >&2
    return 1
  fi
  # A configured instance must keep its recorded container runtime.
  if [[ -f "$OX_INSTALL_ROOT/instance.json" && ! -f "$OX_INSTALL_ROOT/headless-runtime.json" ]]; then
    say '缺少此实例的容器运行环境记录，无法安全继续。现有数据未更改。\n' 'The instance container-runtime record is missing; cannot safely resume. Existing data was preserved.\n' >&2
    return 1
  fi
  local vm_name
  mkdir -p "$OX_INSTALL_ROOT" || return
  vm_name="ox-self-host-$(printf '%s' "$OX_INSTALL_ROOT" | shasum -a 256 | cut -c1-12)"
  if ! limactl list "$vm_name" --format '{{.Name}}' 2>/dev/null | grep -qx "$vm_name"; then
    # Replace inherited home mounts: overlapping read-only parents hide writes.
    OX_PROGRESS_NATIVE=1 progress "$(translate '正在创建独立的自托管容器环境' 'Creating the dedicated self-host container environment')" limactl create --tty=false --name="$vm_name" --mount-only="$OX_INSTALL_ROOT:w" template:docker-rootful || return
  fi
  OX_PROGRESS_NATIVE=1 progress "$(translate '正在启动自托管容器环境' 'Starting the self-host container environment')" limactl start --tty=false "$vm_name" || return
  authorize_admin || return
  progress "$(translate '正在启用登录前自动启动（需要系统授权）' 'Enabling startup before login (system authorization required)')" limactl autostart enable --condition=boot --user "$(id -un)" "$vm_name" || return
  DOCKER_HOST="$(limactl list "$vm_name" --format 'unix://{{.Dir}}/sock/docker.sock')"
  export DOCKER_HOST
  unset DOCKER_CONTEXT
  [[ "$DOCKER_HOST" == unix:///* ]] || return 1
  OX_SELF_HOST_HOME="$OX_INSTALL_ROOT" node -e 'require("fs").writeFileSync(require("path").join(process.env.OX_SELF_HOST_HOME,"headless-runtime.json"),JSON.stringify({version:1,dockerHost:process.env.DOCKER_HOST}),{mode:0o600})' || return
  if ! progress "$(translate '正在检查实例目录写入权限' 'Checking instance directory write access')" limactl shell --tty=false --workdir=/ "$vm_name" sudo -n -- sh -c 'probe=$(mktemp -d "$1/.ox-mount-check.XXXXXX") && rmdir -- "$probe"' sh "$OX_INSTALL_ROOT"; then
    say '容器环境无法写入此实例目录。请清理未完成的安装，然后使用最新安装脚本创建新实例。\n' 'The container environment cannot write to this instance directory. Discard the incomplete installation and create a new instance with the latest installer.\n' >&2
    return 1
  fi
  progress "$(translate '正在等待容器服务就绪' 'Waiting for the container service')" wait_for_docker
}
check_linux_services() {
  if ! command -v systemctl >/dev/null || ! systemctl show-environment >/dev/null 2>&1; then
    say '需要正在运行的 systemd。请在使用 systemd 启动的 Ubuntu 主机上安装；普通容器或未启用 systemd 的 WSL 不受支持。\n' 'A running systemd manager is required. Install on an Ubuntu host booted with systemd; ordinary containers and WSL without systemd are unsupported.\n' >&2
    return 1
  fi
}
prepare_linux_group_tool() {
  [[ "$EUID" != 0 ]] || return 0
  command -v sg >/dev/null 2>&1 && return 0
  local sg_package
  # Ubuntu 26.04 moved sg out of login. Keep older installations on their
  # existing provider; do not assume Docker's installer supplies this tool.
  sg_package="$(
    ID='' VERSION_ID=''
    [[ ! -r /etc/os-release ]] || . /etc/os-release
    if [[ "$ID" == ubuntu ]]; then
      if dpkg --compare-versions "$VERSION_ID" ge 26.04; then printf util-linux-extra; else printf login; fi
    fi
  )" || return
  if [[ -z "$sg_package" ]] || ! command -v apt-get >/dev/null 2>&1; then
    say '缺少 Docker 权限工具 sg。请通过系统软件仓库安装提供 sg 的软件包，然后使用同一账户重新运行安装程序。\n' 'The Docker group tool sg is missing. Install the package providing sg from your system repository, then rerun as the same account.\n' >&2
    return 1
  fi
  progress "$(translate '正在更新 Docker 权限工具安装列表' 'Updating Docker group tool package lists')" "${runner[@]}" apt-get -q update || return
  progress "$(translate '正在安装 Docker 权限工具' 'Installing the Docker group tool')" "${runner[@]}" env DEBIAN_FRONTEND=noninteractive apt-get -q install -y "$sg_package" || return
  if ! command -v sg >/dev/null 2>&1; then
    say '安装后仍找不到 Docker 权限工具 sg。请检查上方软件包安装结果和 PATH，然后使用同一账户重试。\n' 'The Docker group tool sg is still unavailable after installation. Check the package installation output and PATH, then retry as the same account.\n' >&2
    return 1
  fi
}
linux_docker_exec() {
  # Existing user managers retain their old supplementary groups. Activate only
  # the already-authorized group in this child, never restart the user manager.
  if [[ "$(id -u)" == 0 ]] || [[ " $(id -Gn) " == *' docker '* ]]; then exec "$@"; fi
  # Preserve installations whose socket access comes from an ACL or another
  # authorized group, including while the engine is temporarily unresponsive.
  if [[ -S /var/run/docker.sock && -r /var/run/docker.sock && -w /var/run/docker.sock ]]; then exec "$@"; fi
  local command='exec' argument single_quote="'" escaped_quote="'\\''"
  # sg uses /bin/sh: POSIX single quoting preserves every argument literally.
  for argument in "$@"; do command+=" '${argument//$single_quote/$escaped_quote}'"; done
  exec sg docker -c "$command"
}
prepare_linux_docker_launcher() {
  [[ -z "${linux_docker_launcher:-}" ]] || return 0
  linux_docker_launcher="$(mktemp)" || return
  { printf '#!/bin/bash\nset -euo pipefail\n'; declare -f linux_docker_exec; printf '\nlinux_docker_exec "$@"\n'; } > "$linux_docker_launcher"
  chmod 600 "$linux_docker_launcher"
}
continue_with_docker_group() {
  # This is process handoff state, not a configurable setting. Bound retries and
  # preserve resolved choices (especially --new), not the original CLI arguments.
  if [[ "${OX_INSTALL_DOCKER_REEXEC:-0}" == 1 ]]; then
    say '激活 Docker 权限后仍无法访问 Docker。请检查上方错误；尚未继续安装。\n' 'Docker is still inaccessible after activating group permissions. Check the error above; installation has not continued.\n' >&2
    return 1
  fi
  prepare_linux_docker_launcher || return
  progress "$(translate '正在激活 Docker 访问权限' 'Activating Docker access')" /bin/bash "$linux_docker_launcher" "$(command -v docker)" info || return
  local -a arguments=(--instance "${OX_INSTALL_INSTANCE:-default}" --home "$OX_INSTALL_ROOT" --cloud-url "$OX_CLOUD_ORIGIN" --language "$OX_INSTALL_LANGUAGE")
  # --mode is maintainer-only; customer re-executions rely on the production default.
  [[ "${OX_MAINTAINER_DEV:-0}" != 1 ]] || arguments+=(--mode "$OX_MODE")
  [[ -z "${OX_ACTION:-}" ]] || arguments+=(--action "$OX_ACTION")
  [[ "${OX_ACCEPT_SERVER_MODE:-0}" != 1 ]] || arguments+=(--accept-server-mode)
  [[ -z "${OX_LAN_HOST:-}" ]] || arguments+=(--lan-host "$OX_LAN_HOST")
  [[ -z "${OX_LAN_PORT:-}" ]] || arguments+=(--lan-port "$OX_LAN_PORT")
  [[ "$OX_DEPENDENCIES_ONLY" != 1 ]] || arguments+=(--dependencies-only)
  [[ "$OX_CONFIRMED" != 1 ]] || arguments+=(--yes)
  if [[ "$OX_SELECT_INTERACTIVE" == 1 ]]; then arguments+=(--interactive); else arguments+=(--non-interactive); fi
  say 'Docker 权限已就绪，正在继续安装，无需退出登录。\n' 'Docker access is ready. Continuing installation without logging out.\n'
  local result=0
  OX_INSTALL_DOCKER_REEXEC=1 OX_INSTALL_MENU_CHILD=1 OX_SETUP_PORT="$OX_SETUP_PORT" /bin/bash "$linux_docker_launcher" /bin/bash "${BASH_SOURCE[0]}" "${arguments[@]}" || result=$?
  exit "$result"
}
check_linux_background_docker() {
  prepare_linux_docker_launcher || return
  systemd-run --user --quiet --wait --pipe --collect --property=RuntimeMaxSec=30s --property=UnsetEnvironment=DOCKER_CONTEXT -- /bin/bash "$linux_docker_launcher" "$(command -v docker)" --host unix:///var/run/docker.sock info >/dev/null
}
prepare_linux_user_services() {
  [[ "$EUID" != 0 ]] || return 0
  prepare_linux_group_tool || return
  local account runtime
  account="$(id -un)" || return
  runtime="/run/user/$EUID"
  progress "$(translate '正在配置退出登录后的持续运行' 'Enabling the service to keep running after logout')" sudo loginctl enable-linger "$account" || return
  # SSH/su sessions may omit these even when the user's manager already exists.
  export XDG_RUNTIME_DIR="$runtime" DBUS_SESSION_BUS_ADDRESS="unix:path=$runtime/bus"
  if ! systemctl --user show-environment >/dev/null 2>&1; then
    say '无法连接此账户的 systemd 用户服务。请安装 dbus-user-session，退出后重新登录（SSH 请重新连接），再使用同一账户运行安装程序。尚未开始云端登记。\n' 'Cannot connect to this account’s systemd user manager. Install dbus-user-session, log out and back in (reconnect for SSH), then rerun as the same account. Cloud enrollment has not started.\n' >&2
    return 1
  fi
  # A lingering manager can retain old group membership after the SSH shell reconnects.
  if ! progress "$(translate '正在检查后台服务的 Docker 权限' 'Checking Docker access from background services')" check_linux_background_docker; then
    say 'systemd 用户服务无法通过权限启动器访问 Docker。请检查上方错误及账户的 docker 组权限。安装程序不会重启主机或其他用户服务。尚未开始云端登记。\n' 'The systemd user service cannot access Docker through the group launcher. Check the error above and this account’s docker group access. The installer will not restart the host or other user services. Cloud enrollment has not started.\n' >&2
    return 1
  fi
}
select_linux_docker() {
  # Production bind mounts and boot units require this host's system Docker engine.
  local endpoint="${DOCKER_HOST:-}"
  if command -v docker >/dev/null && [[ -n "${DOCKER_CONTEXT:-}" || -z "$endpoint" ]]; then
    if [[ -n "${DOCKER_CONTEXT:-}" ]]; then
      endpoint="$(docker context inspect "$DOCKER_CONTEXT" --format '{{.Endpoints.docker.Host}}')" || return
    else
      endpoint="$(docker context inspect --format '{{.Endpoints.docker.Host}}')" || return
    fi
  elif [[ -z "$endpoint" ]]; then endpoint=unix:///var/run/docker.sock; fi
  case "$endpoint" in
    unix:///var/run/docker.sock|unix:///run/docker.sock) ;;
    *)
      say '生产安装需要本机的系统 Docker Engine（/var/run/docker.sock）。当前选择了其他 Docker 端点；请清除 DOCKER_HOST/DOCKER_CONTEXT 覆盖并选择系统 Docker 后重试。未修改 Docker 上下文或现有容器。\n' 'Production installation requires this host’s system Docker Engine (/var/run/docker.sock). Another Docker endpoint is selected; clear DOCKER_HOST/DOCKER_CONTEXT overrides and select the system engine before retrying. Docker contexts and existing containers were not changed.\n' >&2
      return 1 ;;
  esac
  export DOCKER_HOST=unix:///var/run/docker.sock
  unset DOCKER_CONTEXT
}
prepare_docker() {
  if [[ "$OX_INSTALL_PLATFORM" == Darwin && "${OX_MODE:-dev}" == production ]]; then prepare_headless_docker; return; fi
  if [[ "$OX_INSTALL_PLATFORM" == Darwin ]]; then
    # Include Desktop's bundled CLI even before its optional system symlink exists.
    export PATH="$PATH:/Applications/Docker.app/Contents/Resources/bin:$HOME/.docker/bin"
    if command -v docker >/dev/null && docker info >/dev/null 2>&1; then
      docker compose version >/dev/null || return
      docker_login_help
      return
    fi
    if [[ ! -d /Applications/Docker.app ]]; then
      local architecture
      architecture="$(uname -m)"
      # Detect Apple silicon even from a terminal running under Rosetta.
      if [[ "$(sysctl -n hw.optional.arm64 2>/dev/null || true)" == 1 ]]; then architecture=arm64; fi
      case "$architecture" in arm64) architecture=arm64 ;; x86_64) architecture=amd64 ;; *)
        say '不支持此 Mac 架构。\n' 'Unsupported Mac architecture.\n' >&2; return 1 ;; esac
      docker_temporary="$(mktemp -d)"
      download "$(translate '正在从 Docker 官方下载 Docker Desktop' 'Downloading Docker Desktop from Docker')" "$docker_temporary/Docker.dmg" 1800 "https://desktop.docker.com/mac/main/$architecture/Docker.dmg" || return
      docker_mount="$docker_temporary/volume"
      mkdir -p "$docker_mount"
      progress "$(translate '正在打开 Docker 安装包' 'Mounting the Docker installer')" hdiutil attach "$docker_temporary/Docker.dmg" -mountpoint "$docker_mount" -nobrowse || return
      # Let macOS verify the downloaded application before executing its installer.
      spctl --assess --type execute "$docker_mount/Docker.app" || return
      say '安装 Docker 需要 macOS 管理员密码。请输入系统密码（输入时不显示）。\n' 'Docker installation needs your macOS administrator password. Enter your system password (characters are hidden).\n'
      progress "$(translate '正在安装 Docker Desktop' 'Installing Docker Desktop')" sudo "$docker_mount/Docker.app/Contents/MacOS/install" || return
      hdiutil detach "$docker_mount" >/dev/null || return
      docker_mount=''
      rm -rf "$docker_temporary"; docker_temporary=''
    fi
    say '请在 Docker Desktop 窗口完成以下步骤：\n  1. 阅读 Docker 使用协议；如同意，点击 Accept。\n  2. 选择推荐设置，按系统提示授权并完成设置。\n  3. 如要求登录，请按下面说明登录；若提供 Skip 且您的组织允许，可跳过。\n  4. 在 Settings > General 启用登录时启动 Docker Desktop。\n  5. 保持 Docker Desktop 运行；此脚本将等待最多 10 分钟并自动继续。\n' 'Complete these steps in Docker Desktop:\n  1. Read the Docker agreement and click Accept if you agree.\n  2. Choose recommended settings, approve system permission prompts, and finish setup.\n  3. If sign-in is required, follow the instructions below; use Skip if offered and allowed by your organization.\n  4. Enable starting Docker Desktop at login in Settings > General.\n  5. Keep Docker Desktop running. This script waits up to 10 minutes and continues automatically.\n'
    docker_login_help
    open -a Docker || return
  else
    check_linux_services || return
    if [[ "${OX_MODE:-dev}" == production ]]; then select_linux_docker || return; fi
    if ! command -v docker >/dev/null; then
      docker_temporary="$(mktemp -d)"
      download "$(translate '正在下载 Docker 官方 Linux 安装脚本' 'Downloading the official Docker Linux installer')" "$docker_temporary/install-docker.sh" 120 https://get.docker.com || return
      OX_PROGRESS_NATIVE=1 progress "$(translate '正在安装 Docker Engine 和 Compose（可能需要系统管理员密码）' 'Installing Docker Engine and Compose (administrator password may be required)')" "${runner[@]}" sh "$docker_temporary/install-docker.sh" || return
      rm -rf "$docker_temporary"; docker_temporary=''
    fi
    if ! docker compose version >/dev/null 2>&1; then
      if command -v apt-get >/dev/null; then
        progress "$(translate '正在更新 Docker Compose 安装列表' 'Updating Docker Compose package lists')" "${runner[@]}" apt-get -q update || return
        local compose_package=docker-compose-plugin
        if dpkg-query -W -f='${Status}' docker.io 2>/dev/null | grep -q 'install ok installed'; then compose_package=docker-compose-v2; fi
        progress "$(translate '正在安装 Docker Compose' 'Installing Docker Compose')" "${runner[@]}" env DEBIAN_FRONTEND=noninteractive apt-get -q install -y "$compose_package" || return
      elif command -v dnf >/dev/null; then
        OX_PROGRESS_NATIVE=1 progress "$(translate '正在安装 Docker Compose' 'Installing Docker Compose')" "${runner[@]}" dnf install -y docker-compose-plugin || return
      elif command -v yum >/dev/null; then
        OX_PROGRESS_NATIVE=1 progress "$(translate '正在安装 Docker Compose' 'Installing Docker Compose')" "${runner[@]}" yum install -y docker-compose-plugin || return
      else
        say '现有 Docker 缺少 Compose 插件。请通过安装 Docker 所用的软件仓库安装 docker-compose-plugin，然后重新运行。\n' 'Your existing Docker installation lacks Compose. Install docker-compose-plugin from the repository used for Docker, then rerun.\n' >&2
        return 1
      fi
    fi
    if ! docker info >/dev/null 2>&1; then
      progress "$(translate '正在启动 Docker 并启用开机启动' 'Starting Docker and enabling startup at boot')" "${runner[@]}" systemctl enable --now docker || return
      if [[ "$EUID" != 0 ]] && ! docker info >/dev/null 2>&1 && sudo -n docker --host "${DOCKER_HOST:-unix:///var/run/docker.sock}" info >/dev/null 2>&1; then
        [[ "${OX_INSTALL_DOCKER_REEXEC:-0}" != 1 ]] || { continue_with_docker_group; return $?; }
        prepare_linux_group_tool || return
        say '当前账户没有 Docker 访问权限。将加入 docker 组（此组具有管理员级权限）。\n' 'Your account lacks Docker access. It will be added to the docker group, which grants administrator-level privileges.\n'
        progress "$(translate '正在配置 Docker 访问权限' 'Configuring Docker access')" "${runner[@]}" usermod -aG docker "$(id -un)" || return
        continue_with_docker_group
        return $?
      fi
    fi
    progress "$(translate '正在确认 Docker 开机启动' 'Ensuring Docker starts at boot')" "${runner[@]}" systemctl enable docker || return
    docker_login_help
  fi
  if ! progress "$(translate '正在等待 Docker 就绪' 'Waiting for Docker to become ready')" wait_for_docker; then
    say 'Docker 未能在 10 分钟内就绪。请检查 Docker 窗口或运行 docker info 查看原因，解决后重新运行安装脚本。\n' 'Docker was not ready within 10 minutes. Check its window or run docker info for details, resolve the issue, then rerun the installer.\n' >&2
    return 1
  fi
  docker compose version >/dev/null
}
if [[ "$OX_DEPENDENCIES_ONLY" != 1 ]]; then
  OX_INSTALL_ROOT="$(host_installation resolve)"
  reuse_running_installation
  OX_DEV_RESERVED_PORTS="$(host_installation ports)"
  export OX_DEV_RESERVED_PORTS
fi
if [[ "$OX_DEPENDENCIES_ONLY" != 1 && "$OX_INSTALL_PLATFORM" == Linux && "$OX_MODE" == production && ! -f "$OX_INSTALL_ROOT/instance.json" ]]; then
  confirm_host_mode || exit 1
fi
if ! prepare_docker; then
  say 'Docker 设置未完成。请查看上面的提示，解决后重新运行安装脚本。\n' 'Docker setup is incomplete. Resolve the issue shown above, then rerun the installer.\n' >&2
  exit 1
fi
unset OX_INSTALL_DOCKER_REEXEC
node -e 'if(Number(process.versions.node.split(".")[0])<18)process.exit(1)'
if [[ "$OX_DEPENDENCIES_ONLY" == 1 ]]; then exit 0; fi
if [[ "$OX_INSTALL_PLATFORM" == Linux && "$OX_MODE" == production ]]; then prepare_linux_user_services; fi
if [[ "$OX_INSTALL_PLATFORM" == Linux && "$OX_MODE" == production && ! -f "$OX_INSTALL_ROOT/instance.json" ]]; then
  configure_host_mode || exit 1
fi
if [[ "$OX_MODE" == dev ]]; then
  # A new named dev registration must be claimed before claimHome writes its marker.
  if [[ -n "$OX_INSTALL_INSTANCE" && "$OX_INSTALL_INSTANCE" != default && ! -f "$OX_HOST_REGISTRY/$OX_MODE--$OX_INSTALL_INSTANCE.json" ]]; then
    OX_SELF_HOST_HOME="$OX_INSTALL_ROOT" node -e 'import(require("url").pathToFileURL(process.argv[1])).then(async ({validateHome}) => {const fs=require("fs");const directory=validateHome(process.env.OX_SELF_HOST_HOME,process.argv[2]);fs.mkdirSync(directory,{recursive:true,mode:0o700});if(fs.realpathSync(directory)!==directory||fs.readdirSync(directory).length)throw Error(process.env.OX_INSTALL_LANGUAGE === "en" ? "Choose an empty, dedicated development directory" : "请选择空的专用开发实例目录");}).catch(error=>{console.error(error.message);process.exitCode=1;});' "$OX_SOURCE_ROOT/apps/self-host/setup/resources.js" "$OX_SOURCE_ROOT"
    claim_host_installation
  fi
  # Validate ownership before launching; never adopt an arbitrary folder.
  OX_SELF_HOST_HOME="$OX_INSTALL_ROOT" node -e 'import(require("url").pathToFileURL(process.argv[1])).then(({claimHome}) => claimHome(process.env.OX_SELF_HOST_HOME, process.argv[2])).catch(error => { console.error(error.message); process.exitCode=1; });' "$OX_SOURCE_ROOT/apps/self-host/setup/resources.js" "$OX_SOURCE_ROOT"
  claim_host_installation
  cd "$OX_SOURCE_ROOT"
  if [[ ! -d node_modules ]]; then progress "$(translate '正在安装开发依赖' 'Installing checkout dependencies')" npm ci --no-progress; fi
  if ! command -v mkcert >/dev/null; then
    if [[ "$OX_INSTALL_PLATFORM" == Darwin ]] && command -v brew >/dev/null; then
      OX_PROGRESS_NATIVE=1 progress "$(translate '正在安装 mkcert' 'Installing mkcert')" brew install mkcert
    elif [[ "$OX_INSTALL_PLATFORM" == Linux ]] && command -v apt-get >/dev/null; then
      progress "$(translate '正在更新证书软件包列表' 'Updating certificate package lists')" "${runner[@]}" apt-get -q update
      progress "$(translate '正在安装 mkcert' 'Installing mkcert')" "${runner[@]}" env DEBIAN_FRONTEND=noninteractive apt-get -q install -y mkcert libnss3-tools
    elif [[ "$OX_INSTALL_PLATFORM" == Darwin ]]; then
      mkcert_arch=amd64; [[ "$(uname -m)" == arm64 ]] && mkcert_arch=arm64
      mkdir -p "$HOME/.local/bin"
      download "$(translate '正在下载 mkcert' 'Downloading mkcert')" "$HOME/.local/bin/mkcert.download" 180 "https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/mkcert-v1.4.4-darwin-$mkcert_arch"
      chmod 700 "$HOME/.local/bin/mkcert.download"
      mv "$HOME/.local/bin/mkcert.download" "$HOME/.local/bin/mkcert"
    else printf 'Install mkcert, then rerun.\n' >&2; exit 1; fi
  fi
  authorize_admin
  progress "$(translate '正在准备本地证书信任（可能需要系统授权）' 'Preparing local certificate trust (system authorization may be required)')" mkcert -install
  export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"
  export OX_SELF_HOST_HOME="$OX_INSTALL_ROOT" OX_DEV_CLOUD_ORIGIN="$OX_CLOUD_ORIGIN"
  export OX_PSSP_BINARY="${OX_PSSP_BINARY:-$(dirname "$OX_SOURCE_ROOT")/oxrecorder-pssp/target/release/pssp-broker}"
  if [[ ! -x "$OX_PSSP_BINARY" ]]; then
    broker_source="$(dirname "$OX_SOURCE_ROOT")/oxrecorder-pssp"
    [[ -f "$broker_source/Cargo.toml" ]] || { printf 'Provide OX_PSSP_BINARY or the sibling oxrecorder-pssp checkout.\n' >&2; exit 1; }
    export PATH="$HOME/.cargo/bin:$PATH"
    if ! command -v cargo >/dev/null || ! rustc --version | awk '{split($2,v,"."); exit !(v[1]>1 || (v[1]==1 && v[2]>=85))}'; then
      if [[ "$OX_INSTALL_PLATFORM" == Linux ]]; then
        progress "$(translate '正在更新编译器软件包列表' 'Updating compiler package lists')" "${runner[@]}" apt-get -q update
        progress "$(translate '正在安装原生构建依赖' 'Installing native build dependencies')" "${runner[@]}" env DEBIAN_FRONTEND=noninteractive apt-get -q install -y build-essential pkg-config libssl-dev
      fi
      rust_installer="$(mktemp)"
      download "$(translate '正在下载 Rust 安装程序' 'Downloading Rust installer')" "$rust_installer" 120 https://sh.rustup.rs
      OX_PROGRESS_NATIVE=1 progress "$(translate '正在安装 Rust 工具链' 'Installing Rust toolchain')" sh "$rust_installer" -y --profile minimal --default-toolchain stable --no-modify-path
      rm -f "$rust_installer"
    fi
    progress "$(translate '正在构建原生 PSSP Broker' 'Building the native PSSP broker')" env CARGO_TERM_PROGRESS_WHEN=never cargo build --release --manifest-path "$broker_source/Cargo.toml"
  fi
  exec node "$OX_SOURCE_ROOT/scripts/self-host-dev.js" --installer
fi
while ! node -e 'try { const u=new URL(process.argv[1]); if(u.protocol!=="https:"||u.username||u.password||u.pathname!=="/"||u.search||u.hash)throw Error(); } catch { console.error(process.env.OX_INSTALL_LANGUAGE === "en" ? "Enter a valid cloud HTTPS origin." : "请输入有效的云端 HTTPS 来源地址。");process.exit(1); }' "$OX_CLOUD_ORIGIN"; do
  invalid_choice "$(translate '云端地址应为 HTTPS 来源，不含路径、用户名或密码。' 'Use an HTTPS origin without a path, username, or password.')"
  ask OX_CLOUD_ORIGIN "$(translate '重新输入云端控制台 HTTPS 地址' 'Enter the cloud portal HTTPS URL again')" 'https://portal.oxrecorder.cn'
done
node - "$OX_INSTALL_ROOT" <<'NODE'
const path = require('path'), os = require('os');
const directory = path.resolve(process.argv[2]), home = os.homedir();
if (/[\r\n]/.test(directory)) throw Error('Installation directory must not contain line breaks');
if (directory === path.parse(directory).root || directory === home || home.startsWith(directory + path.sep)) throw Error('Choose a dedicated installation directory');
const fs = require('fs');
if (fs.existsSync(directory)) {
  if (fs.realpathSync(directory) !== directory) throw Error('Installation directory must not use symbolic links');
  const names = fs.readdirSync(directory);
  if (names.includes('.git') || names.includes('package.json')) throw Error('Use a dedicated directory outside the source checkout');
  if (names.length && !names.includes('installation.json') && !names.includes('source-path') && !names.every(name => name === 'headless-runtime.json')) throw Error('Refusing an unrecognized nonempty installation directory');
  if (names.includes('installation.json')) {
    const marker = JSON.parse(fs.readFileSync(path.join(directory, 'installation.json'), 'utf8'));
    if (marker.mode !== 'production' || marker.directory !== directory) throw Error('Installation identity mismatch');
  }
}
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
NODE
claim_host_installation
node -e 'const fs=require("fs"),path=require("path");const directory=process.argv[1];fs.writeFileSync(path.join(directory,"installation.json"),JSON.stringify({version:1,mode:"production",directory}),{mode:0o600});' "$OX_INSTALL_ROOT"
mkdir -p "$OX_INSTALL_ROOT"
OX_INSTALL_ROOT="$(cd "$OX_INSTALL_ROOT" && pwd)"
say '安装目录：%s\n' 'Installation directory: %s\n' "$OX_INSTALL_ROOT"
if [[ -z "$OX_RELEASE_DIRECTORY" ]]; then
  export OX_INSTALL_ROOT OX_CLOUD_ORIGIN
  progress "$(translate '正在下载并验证已签名的自托管服务' 'Downloading and verifying the signed self-host release')" node --input-type=module - <<'NODE'
import fs from 'node:fs/promises';
import { createWriteStream } from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { execFileSync } from 'node:child_process';
try {
const origin = new URL(process.env.OX_CLOUD_ORIGIN);
if(origin.protocol!=='https:'||origin.username||origin.password||origin.pathname!=='/'||origin.search||origin.hash)throw Error((process.env.OX_INSTALL_LANGUAGE === 'en' ? 'Invalid cloud HTTPS origin' : '云端 HTTPS 地址无效'));
const response=await fetch(`${origin.origin}/api/self-host/releases/current?platform=${process.platform}&architecture=${process.arch}`,{redirect:'error',signal:AbortSignal.timeout(20000)});
if(!response.ok) {
 const target=`${process.platform}/${process.arch}`;
 if(response.status===503)throw Error(process.env.OX_INSTALL_LANGUAGE === 'en'
  ? `No approved self-host release is available for ${target} on ${origin.origin}. Docker is ready. Ask the cloud administrator to configure the signed release catalog, then rerun this installer.`
  : `${origin.origin} 尚无可下载的已批准自托管版本（${target}）。Docker 已就绪。请联系云端管理员配置已签名的发布目录，然后重新运行此安装脚本。`);
 throw Error(process.env.OX_INSTALL_LANGUAGE === 'en' ? `Release request failed: HTTP ${response.status} (${target}, ${origin.origin})` : `获取发布版本失败：HTTP ${response.status}（${target}，${origin.origin}）`);
}
const descriptor=await response.json();
const key=crypto.createPublicKey(descriptor.signingPublicKey);
const envelope=descriptor.manifest;
if(key.asymmetricKeyType!=='ed25519'||typeof envelope?.data!=='string'||envelope.data.length>65536||!crypto.verify(null,Buffer.from(envelope.data),key,Buffer.from(envelope.signature,'base64url')))throw Error((process.env.OX_INSTALL_LANGUAGE === 'en' ? 'Invalid release signature' : '版本签名无效'));
const release=JSON.parse(Buffer.from(envelope.data,'base64url').toString());
if(release.version!==1||release.purpose!=='self-host-release'||release.protocolVersion!==1||release.databaseVersion!==1||release.architecture!==process.arch||release.platform!==process.platform||!/^\d+\.\d+\.\d+$/.test(release.releaseVersion)||!Number.isSafeInteger(release.bytes)||release.bytes<1||release.bytes>1073741824||!/^[a-f0-9]{64}$/.test(release.sha256)||new URL(release.url).origin!==origin.origin)throw Error((process.env.OX_INSTALL_LANGUAGE === 'en' ? 'The release bundle is incompatible with this system' : '安装包不兼容'));
console.info(process.env.OX_INSTALL_LANGUAGE === 'en'
 ? `Verified self-host release: ${release.releaseVersion} (${release.platform}/${release.architecture})`
 : `已验证自托管发布版本：${release.releaseVersion}（${release.platform}/${release.architecture}）`);
const home=process.env.OX_INSTALL_ROOT;
const temporary=await fs.mkdtemp(path.join(home,'.download-'));
try{
 const bundle=path.join(temporary,'bundle.tgz');
 for(let attempt=1;attempt<=3;attempt++) {
  let count=0;const hash=crypto.createHash('sha256');
  try {
   const download=await fetch(release.url,{redirect:'error',signal:AbortSignal.timeout(1800000)});
   if(!download.ok||!download.body)throw Error(process.env.OX_INSTALL_LANGUAGE === 'en' ? 'Release download failed' : '安装包下载失败');
   await pipeline(Readable.fromWeb(download.body),new Transform({transform(chunk,encoding,done){count+=chunk.length;if(count>release.bytes)return done(Error(process.env.OX_INSTALL_LANGUAGE === 'en' ? 'The bundle exceeds its signed size' : '安装包超过签名大小'));hash.update(chunk);done(null,chunk);}}),createWriteStream(bundle,{flags:'wx',mode:0o600}));
   if(count!==release.bytes||hash.digest('hex')!==release.sha256)throw Error(process.env.OX_INSTALL_LANGUAGE === 'en' ? 'Release bundle checksum or size verification failed' : '安装包校验失败');
   break;
  } catch(error) {
   await fs.rm(bundle,{force:true});
   if(attempt===3)throw error;
   console.info(process.env.OX_INSTALL_LANGUAGE === 'en' ? `Retrying verified release download (${attempt+1}/3)…` : `正在重试已签名安装包下载（${attempt+1}/3）…`);
   await new Promise(resolve=>setTimeout(resolve,attempt*1000));
  }
 }
 const names=execFileSync('tar',['-tzf',bundle],{encoding:'utf8',maxBuffer:20000000}).trim().split('\n');
 const types=execFileSync('tar',['-tvzf',bundle],{encoding:'utf8',maxBuffer:20000000}).trim().split('\n');
 if(types.some(line=>!['-','d'].includes(line[0]))||names.some(name=>{const clean=name.replace(/^\.\//,'');return !clean||clean.startsWith('/')||clean.split('/').includes('..')||clean.includes('\\')||/[\x00-\x1f]/.test(clean)||!/^(apps|src|packages|workers|public|node_modules|bin|package\.json|package-lock\.json)(\/|$)/.test(clean);}))throw Error((process.env.OX_INSTALL_LANGUAGE === 'en' ? 'The bundle contains unsafe paths' : '安装包包含不安全路径'));
 const staging=path.join(temporary,'source');await fs.mkdir(staging);
 execFileSync('tar',['-xzf',bundle,'-C',staging,'--no-same-owner']);
 await fs.access(path.join(staging,'apps/self-host/setup/server.js'));
 await fs.access(path.join(staging,'bin/pssp-broker'));
 await fs.writeFile(path.join(staging,'release-manifest.json'),JSON.stringify(envelope),{mode:0o600});
 const destination=path.join(home,`source-${release.sha256}`);
 async function sameTree(expected,actual) {
  const left=await fs.lstat(expected),right=await fs.lstat(actual);
  if(left.isDirectory()&&right.isDirectory()) {
   const a=(await fs.readdir(expected)).sort(),b=(await fs.readdir(actual)).sort();
   if(JSON.stringify(a)!==JSON.stringify(b))return false;
   for(const name of a)if(!await sameTree(path.join(expected,name),path.join(actual,name)))return false;
   return true;
  }
  return left.isFile()&&right.isFile()&&(await fs.readFile(expected)).equals(await fs.readFile(actual));
 }
 try{await fs.rename(staging,destination);}catch(error){
  if(!['EEXIST','ENOTEMPTY'].includes(error.code)||!await sameTree(staging,destination))throw error;
 }
 await fs.writeFile(path.join(home,'source-path'),destination,{mode:0o600});
}finally{await fs.rm(temporary,{recursive:true,force:true});}
} catch (error) {
  console.error(error.message);
  process.exitCode = 1;
}
NODE
  OX_RELEASE_DIRECTORY="$(cat "$OX_INSTALL_ROOT/source-path")"
  export OX_PSSP_BINARY="$OX_RELEASE_DIRECTORY/bin/pssp-broker"
fi
OX_RELEASE_DIRECTORY="$(cd "$OX_RELEASE_DIRECTORY" && pwd)"
if [[ ! -f "$OX_RELEASE_DIRECTORY/apps/self-host/setup/server.js" ]]; then say '发布安装目录无效，请重新下载安装。\n' 'Invalid release directory. Download and install the release again.\n' >&2; exit 1; fi
if [[ ! -f "$OX_RELEASE_DIRECTORY/apps/self-host/setup/resources.js" || ! -f "$OX_RELEASE_DIRECTORY/apps/self-host/setup/teardown.js" || ! -f "$OX_RELEASE_DIRECTORY/apps/self-host/setup/lanAddress.js" ]]; then
  say '此发布包不支持新版统一安装流程。请先提供包含新版设置和卸载功能的已签名发布包；已有数据未修改。\n' 'This release does not support the unified setup lifecycle. Provide a signed release with the updated setup and teardown helpers; existing instance data was preserved.\n' >&2
  exit 1
fi
if [[ ! -x "${OX_PSSP_BINARY:-}" ]]; then say '发布包中的 PSSP Broker 不存在或不可执行，请重新下载安装。\n' 'The bundled PSSP Broker is missing or not executable. Download and install the release again.\n' >&2; exit 1; fi
export OX_SERVICE_MODE=self-host
export OX_SELF_HOST_HOME="$OX_INSTALL_ROOT"
export OX_SELF_HOST_CONFIG="$OX_INSTALL_ROOT/instance.json"
if [[ ! -d "$OX_RELEASE_DIRECTORY/node_modules" ]]; then say '发布包缺少运行依赖，请联系管理员提供完整安装包。\n' 'The release bundle is missing runtime dependencies. Contact the administrator for a complete bundle.\n' >&2; exit 1; fi
# Apply sibling reservations before launching older signed setup helpers too.
# No release artifact is modified or rebuilt by this installer.
if [[ "$OX_DEV_RESERVED_PORTS" != '[]' && ! -f "$OX_SELF_HOST_CONFIG" ]]; then
  selected_ports="$(node - "$OX_RELEASE_DIRECTORY" <<'PORTS_NODE'
const fs = require('fs'), path = require('path');
(async () => {
  const { availablePort } = await import(require('url').pathToFileURL(path.join(process.argv[2], 'apps/self-host/setup/defaults.js')));
  const reserved = JSON.parse(process.env.OX_DEV_RESERVED_PORTS);
  const addressFile = path.join(process.env.OX_SELF_HOST_HOME, 'setup-address.json');
  const saved = fs.existsSync(addressFile) ? JSON.parse(fs.readFileSync(addressFile, 'utf8')).lanOrigin : null;
  const explicit = saved ? Number(new URL(saved).port || 443) : process.env.OX_LAN_PORT ? Number(process.env.OX_LAN_PORT) : null;
  const lan = await availablePort(explicit || 3080, { reserved: [...reserved, Number(process.env.OX_SETUP_PORT || 3081)] });
  if (explicit && lan !== explicit) throw Error(process.env.OX_INSTALL_LANGUAGE === 'en' ? 'The selected LAN port conflicts with another instance or listener. Existing settings were preserved.' : '所选局域网端口与另一实例或服务冲突，已有设置未更改。');
  const setup = await availablePort(Number(process.env.OX_SETUP_PORT || 3081), { reserved: [...reserved, lan] });
  process.stdout.write(lan + '\n' + setup);
})().catch(error => { console.error(error.message); process.exitCode = 1; });
PORTS_NODE
)"
  OX_LAN_PORT="${selected_ports%%$'\n'*}"
  OX_SETUP_PORT="${selected_ports#*$'\n'}"
  export OX_LAN_PORT OX_SETUP_PORT
fi
# Keep cleanup from racing a still-running production installer.
node - "$$" <<'INSTALL_LOCK'
const fs = require('fs'), path = require('path');
const directory = path.join(process.env.OX_SELF_HOST_HOME, '.installation-run');
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
const guard = path.join(directory, '.guard');
fs.mkdirSync(guard);
try {
  const file = path.join(directory, 'supervisor.pid');
  let saved; try { saved = JSON.parse(fs.readFileSync(file)); } catch (error) { if (error.code !== 'ENOENT') throw error; }
  if (saved) {
    if (!Number.isSafeInteger(saved.pid) || saved.pid < 1) throw Error('Invalid installer lock');
    try { process.kill(saved.pid, 0); throw Error('Another installer is still running; close its terminal before cleanup'); }
    catch (error) { if (error.code !== 'ESRCH') throw error; }
  }
  fs.writeFileSync(file, JSON.stringify({ pid: Number(process.argv[2]) }), { mode: 0o600 });
} finally { fs.rmdirSync(guard); }
INSTALL_LOCK
progress "$(translate '正在准备局域网地址' 'Preparing LAN address')" node "$OX_RELEASE_DIRECTORY/apps/self-host/setup/lanAddress.js"
if [[ ! -f "$OX_SELF_HOST_CONFIG" ]]; then
  if [[ "$(uname -s)" == Linux ]] && command -v ufw >/dev/null 2>&1; then authorize_admin; fi
  say '请在设置页面填写安装密钥并设置管理员密码。\n' 'Enter the enrollment key and choose your administrator password on the setup page.\n'
  node "$OX_RELEASE_DIRECTORY/apps/self-host/setup/server.js" &
  OX_SETUP_PID=$!
  # The setup server draws its own status line (firewall, image import, enrollment) on this
  # terminal, so no spinner is drawn here; non-interactive runs still get periodic status lines.
  wait_for_setup() {
    local waited=0
    while [[ ! -f "$OX_SELF_HOST_CONFIG" ]]; do
      kill -0 "$OX_SETUP_PID" 2>/dev/null || { wait "$OX_SETUP_PID"; return $?; }
      sleep 1
      waited=$((waited + 1))
      if [[ ! -t 1 && $((waited % 5)) == 0 ]]; then printf '%s …\n' "$(translate '正在等待浏览器设置' 'Waiting for browser setup')"; fi
    done
  }
  OX_PROGRESS_NATIVE=2 progress "$(translate '正在等待浏览器设置' 'Waiting for browser setup')" wait_for_setup
fi
if [[ ! -f "$OX_SELF_HOST_CONFIG" ]]; then say '配置尚未完成。请重新运行安装脚本。\n' 'Setup is incomplete. Run the installer again.\n' >&2; exit 1; fi
OX_NODE_BINARY="$(command -v node)"
if [[ "$OX_INSTALL_PLATFORM" == Darwin ]]; then
  OX_UNIT_DIRECTORY="$OX_INSTALL_ROOT/boot-services"
else
  if [[ "$EUID" == 0 ]]; then OX_UNIT_DIRECTORY=/etc/systemd/system; else OX_UNIT_DIRECTORY="$HOME/.config/systemd/user"; fi
fi
mkdir -p "$OX_UNIT_DIRECTORY"
if [[ "$OX_INSTALL_PLATFORM" == Linux && "$EUID" != 0 ]]; then
  prepare_linux_docker_launcher
  # Stable instance-owned entry point survives upgrades and works with older
  # signed application releases. Never modify code inside a signed release.
  cp "$linux_docker_launcher" "$OX_INSTALL_ROOT/linux-docker-exec.sh.next"
  chmod 600 "$OX_INSTALL_ROOT/linux-docker-exec.sh.next"
  mv "$OX_INSTALL_ROOT/linux-docker-exec.sh.next" "$OX_INSTALL_ROOT/linux-docker-exec.sh"
fi
OX_SERVICE_SUFFIX="$(node -e 'const fs=require("fs"),path=require("path");const home=process.env.OX_SELF_HOST_HOME;let suffix="";try{suffix=fs.readFileSync(path.join(home,"service-suffix"),"utf8").trim();}catch{if(!fs.existsSync(path.join(home,"launch.mjs"))){const config=JSON.parse(fs.readFileSync(process.env.OX_SELF_HOST_CONFIG,"utf8"));if(!/^[a-f0-9]{24}$/.test(config.instanceId))process.exit(1);suffix="-"+config.instanceId;}}if(!/^(-[a-f0-9]{24})?$/.test(suffix))process.exit(1);process.stdout.write(suffix);')"
export OX_SERVICE_SUFFIX
node --input-type=module - <<'NODE'
import fs from 'node:fs';
import path from 'node:path';
const directory = fs.realpathSync(process.env.OX_SELF_HOST_HOME);
fs.writeFileSync(path.join(directory, 'installation.json'), JSON.stringify({ version: 1, mode: 'production', directory }), { mode: 0o600 });
NODE
export OX_RELEASE_DIRECTORY OX_NODE_BINARY OX_UNIT_DIRECTORY
node --input-type=module - <<'NODE'
import fs from 'node:fs/promises';
import path from 'node:path';
const suffix = process.env.OX_SERVICE_SUFFIX || '';
const quote=value=>JSON.stringify(value.replace(/%/g,'%%'));
const execQuote=value=>quote(value.replace(/\$/g,'$$$$'));
// WorkingDirectory is a literal path, not a quoted command-line argument.
const workingDirectory=value=>{if(/[\r\n\0]/.test(value))throw Error('Invalid systemd working directory');return value.replace(/%/g,'%%')+'/.';};
const launcher=path.join(process.env.OX_SELF_HOST_HOME,'launch.mjs');
await fs.writeFile(launcher,`import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport { pathToFileURL } from 'node:url';\nconst filename=process.env.OX_SELF_HOST_CONFIG;\nconst config=JSON.parse(await fs.readFile(filename,'utf8'));\nlet source=config.sourceRoot;\ntry { source=await fs.realpath(path.join(path.dirname(filename),'current')); } catch {}\nconst { isolatedEnvironment } = await import(pathToFileURL(path.join(source,'apps/self-host/setup/resources.js')));\nconst env = { ...isolatedEnvironment(process.env), OX_SERVICE_MODE:'self-host', OX_SELF_HOST_CONFIG:filename };\nfor (const key of Object.keys(process.env)) delete process.env[key];\nObject.assign(process.env,env);\nawait import(pathToFileURL(path.join(source,'apps/self-host/supervisor/index.js')));\n`,{mode:0o600});
if (process.env.OX_INSTALL_PLATFORM === 'Darwin') {
  const xml = value => String(value).replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' })[c]);
  const string = value => `<string>${xml(value)}</string>`;
  const directory = process.env.OX_SELF_HOST_HOME;
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key>${string('cn.oxrecorder.self-host'+suffix)}
<key>ProgramArguments</key><array>${string(process.env.OX_NODE_BINARY)}${string(launcher)}</array>
<key>UserName</key>${string((await import('node:os')).userInfo().username)}
<key>WorkingDirectory</key>${string(directory)}
<key>EnvironmentVariables</key><dict>
<key>OX_SERVICE_MODE</key>${string('self-host')}
<key>OX_SELF_HOST_CONFIG</key>${string(process.env.OX_SELF_HOST_CONFIG)}
<key>HOME</key>${string(process.env.HOME)}
${process.env.DOCKER_HOST ? '<key>DOCKER_HOST</key>' + string(process.env.DOCKER_HOST) : ''}
<key>PATH</key>${string(process.env.PATH)}
${process.env.NODE_EXTRA_CA_CERTS ? '<key>NODE_EXTRA_CA_CERTS</key>' + string(process.env.NODE_EXTRA_CA_CERTS) : ''}
</dict>
<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>5</integer>
<key>StandardOutPath</key>${string(path.join(directory, 'supervisor.log'))}
<key>StandardErrorPath</key>${string(path.join(directory, 'supervisor-error.log'))}
</dict></plist>`;
  await fs.writeFile(path.join(process.env.OX_UNIT_DIRECTORY, 'cn.oxrecorder.self-host'+suffix+'.plist'), plist, { mode: 0o600 });
} else {
const groupLauncher=process.getuid()===0?'':`/bin/bash ${execQuote(path.join(process.env.OX_SELF_HOST_HOME,'linux-docker-exec.sh'))} `;
const unit=`[Unit]\nDescription=OX Self-Host service supervisor\nAfter=network.target\nStartLimitIntervalSec=0\n[Service]\nType=simple\nEnvironment=OX_SERVICE_MODE=self-host\nEnvironment=${quote('PATH='+process.env.PATH)}\nEnvironment=${quote('HOME='+process.env.HOME)}\nEnvironment=DOCKER_HOST=unix:///var/run/docker.sock\nUnsetEnvironment=DOCKER_CONTEXT\nEnvironment=${quote('OX_SELF_HOST_CONFIG='+process.env.OX_SELF_HOST_CONFIG)}\nWorkingDirectory=${workingDirectory(process.env.OX_SELF_HOST_HOME)}\nExecStart=${execQuote(process.env.OX_NODE_BINARY)} ${execQuote(launcher)}\nRestart=always\nRestartSec=5\nKillMode=control-group\nTimeoutStopSec=90\nUMask=0077\n[Install]\nWantedBy=${process.getuid()===0?'multi-user.target':'default.target'}\n`;
await fs.writeFile(path.join(process.env.OX_UNIT_DIRECTORY,'ox-self-host'+suffix+'.service'),unit.replace('ExecStart=', () => 'ExecStart='+groupLauncher),{mode:0o600});
}
NODE
progress "$(translate '正在安装独立管理代理' 'Installing the independent management agent')" node "$OX_RELEASE_DIRECTORY/apps/self-host/setup/managementAgent.js"
if [[ "$OX_INSTALL_PLATFORM" == Linux && "$EUID" != 0 ]]; then
  # Older signed releases generate a direct ExecStart. Adapt only that known
  # instance-owned unit, so a new installer need not require a new app release.
  node --input-type=module - <<'DOCKER_AGENT_UNIT'
import fs from 'node:fs/promises';
import path from 'node:path';
const quote = value => JSON.stringify(value.replace(/%/g, '%%').replace(/\$/g, '$$$$'));
const directory = process.env.OX_SELF_HOST_HOME;
const file = path.join(process.env.OX_UNIT_DIRECTORY, `ox-self-host-agent${process.env.OX_SERVICE_SUFFIX}.service`);
const direct = `ExecStart=${quote(process.execPath)} ${quote(path.join(directory, 'agent-watchdog.mjs'))}\n`;
const wrapped = `ExecStart=/bin/bash ${quote(path.join(directory, 'linux-docker-exec.sh'))} ${quote(process.execPath)} ${quote(path.join(directory, 'agent-watchdog.mjs'))}\n`;
const text = await fs.readFile(file, 'utf8');
if (!text.includes(wrapped)) {
  if (!text.includes(direct)) throw new Error('Unexpected management agent service command');
  await fs.writeFile(file, text.replace(direct, () => wrapped), { mode: 0o600 });
}
DOCKER_AGENT_UNIT
fi
if [[ "$OX_INSTALL_PLATFORM" == Linux ]]; then
  node --input-type=module - <<'HOST_AGENT_RETRY'
import fs from 'node:fs/promises';
import path from 'node:path';
const file = path.join(process.env.OX_UNIT_DIRECTORY, `ox-self-host-agent${process.env.OX_SERVICE_SUFFIX}.service`);
let text = await fs.readFile(file, 'utf8');
if (!text.startsWith('[Unit]\n') || !text.includes('Restart=always\n') || !text.includes('agent-watchdog.mjs')) throw new Error('Unexpected management agent service unit');
if (!text.includes('StartLimitIntervalSec=0\n')) {
  text = text.replace('[Unit]\n', '[Unit]\nStartLimitIntervalSec=0\n');
  await fs.writeFile(file, text, { mode: 0o600 });
}
HOST_AGENT_RETRY
fi
printf '%s' "$OX_SERVICE_SUFFIX" > "$OX_INSTALL_ROOT/service-suffix"
if [[ "$OX_INSTALL_PLATFORM" == Darwin ]]; then
  node -e 'const fs=require("fs"),path=require("path");const file=path.join(process.env.OX_SELF_HOST_HOME,"installation.json");const data=JSON.parse(fs.readFileSync(file));data.bootMode=process.env.OX_INSTALL_PLATFORM==="Darwin"?"system":"systemd";fs.writeFileSync(file,JSON.stringify(data),{mode:0o600});'
  for label in "cn.oxrecorder.self-host$OX_SERVICE_SUFFIX" "cn.oxrecorder.self-host-agent$OX_SERVICE_SUFFIX"; do
    registered_unit="/Library/LaunchDaemons/$label.plist"
    if [[ -f "$registered_unit" ]]; then
      node - "$registered_unit" <<'SERVICE_UNIT'
const fs = require('fs');
const escaped = process.env.OX_SELF_HOST_CONFIG.replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' })[c]);
if (!fs.readFileSync(process.argv[2], 'utf8').includes(escaped)) throw Error('Service belongs to another installation');
SERVICE_UNIT
    fi
    progress "$(translate '正在安装系统启动服务' 'Installing system startup services')" sudo install -o root -g wheel -m 644 "$OX_UNIT_DIRECTORY/$label.plist" "/Library/LaunchDaemons/$label.plist"
    if ! sudo launchctl print "system/$label" >/dev/null 2>&1; then
      progress "$(translate '正在启用登录前启动' 'Enabling startup before login')" sudo launchctl bootstrap system "/Library/LaunchDaemons/$label.plist"
    else
      progress "$(translate '正在恢复系统服务' 'Resuming the system service')" sudo launchctl kickstart "system/$label"
    fi
  done
  # Grant only the exact service-recovery command, not general sudo access.
  [[ "$(id -un)" =~ ^[a-zA-Z_][a-zA-Z0-9_-]*$ ]] || exit 1
  printf '%s ALL=(root) NOPASSWD: /bin/launchctl kickstart -k system/cn.oxrecorder.self-host%s\n' "$(id -un)" "$OX_SERVICE_SUFFIX" > "$OX_UNIT_DIRECTORY/recovery.sudoers"
  sudo visudo -cf "$OX_UNIT_DIRECTORY/recovery.sudoers"
  sudo install -d -o root -g wheel -m 755 /etc/sudoers.d
  sudo install -o root -g wheel -m 440 "$OX_UNIT_DIRECTORY/recovery.sudoers" "/etc/sudoers.d/ox-self-host$OX_SERVICE_SUFFIX"
  say '已配置登录前启动。FileVault 的开机磁盘解锁仍需现场或带外处理；开发模式不提供无人值守启动。\n' 'Startup before login is configured. FileVault pre-boot disk unlock still requires local or out-of-band assistance; development mode does not provide unattended startup.\n'
elif [[ "$EUID" == 0 ]]; then
  systemctl daemon-reload
  progress "$(translate '正在启用自托管服务自动启动' 'Enabling automatic service startup')" systemctl enable --now "ox-self-host$OX_SERVICE_SUFFIX.service" "ox-self-host-agent$OX_SERVICE_SUFFIX.service"
else
  systemctl --user daemon-reload
  progress "$(translate '正在启用自托管服务自动启动' 'Enabling automatic service startup')" systemctl --user enable --now "ox-self-host$OX_SERVICE_SUFFIX.service" "ox-self-host-agent$OX_SERVICE_SUFFIX.service"
fi
node -e 'const fs=require("fs"),path=require("path");const file=path.join(process.env.OX_SELF_HOST_HOME,"installation.json");const data=JSON.parse(fs.readFileSync(file));data.bootMode=process.env.OX_INSTALL_PLATFORM==="Darwin"?"system":"systemd";fs.writeFileSync(file,JSON.stringify(data),{mode:0o600});'
if node -e 'try { process.exit(JSON.parse(require("fs").readFileSync(process.argv[1])).authorization ? 0 : 1); } catch { process.exit(1); }' "$OX_INSTALL_ROOT/lifecycle.json"; then
  say '正在继续已授权的数据删除；云端确认前将显示待删除状态。\n' 'Resuming authorized data removal; cloud status remains pending until completion is confirmed.\n'
else
  progress "$(translate '正在检查控制台和后台服务' 'Checking the portal and background services')" node "$OX_RELEASE_DIRECTORY/apps/self-host/setup/readiness.js"
  say '自托管服务已启用。请在设置控制台显示的 HTTPS 地址登录。\n' 'The self-host service is enabled. Sign in at the HTTPS URL shown on the setup page.\n'
fi

if [[ -n "${OX_SETUP_PID:-}" ]]; then
  say '请在设置页面查看完成提示并点击打开控制台。\n' 'Review the completion dialog in the setup page and click Open portal.\n'
  wait "$OX_SETUP_PID"
  OX_SETUP_PID=
fi
