#!/bin/sh
# Talos installer — https://talosfleet.site
#
# Installs (or adopts and updates) a self-hosted Talos on this machine:
#   checks the host, writes docker-compose.yml + .env (secrets generated, hex), pins a
#   version, starts the stack, waits until it is healthy and reachable, prints the URL.
#
#   sh install.sh --help
#
# Safe to re-run. It never regenerates a secret that protects existing data, and it reuses
# an existing install's directory, compose project name and volumes, so no data is
# orphaned. When it cannot be sure which data belongs to which install, it stops and asks.
#
# Plan: docs/deployment/INSTALL_EASE_PLAN.md. Tests: deploy/self-hosted/tests/.
# Everything runs inside main(), called on the last line, so a truncated download fails to
# parse instead of half-running.

INSTALLER_VERSION="0.1.0"
# Stamped at release time (docs/deployment/RELEASE.md). Empty = no published default yet,
# so --version is required.
DEFAULT_VERSION="1.1.0"
DEFAULT_REGISTRY="talosfleet"

# ── Windows and macOS (Docker Desktop) ──
# There this installer runs INSIDE a small Docker CLI container, started by install.ps1 on
# Windows and by desktop_reexec below on macOS, talking to Docker Desktop through its socket.
# One implementation for every system: the same tested logic, not a second copy to drift.
IN_CONTAINER="${TALOS_IN_CONTAINER:-0}"
HOST_OS="${TALOS_HOST_OS:-}"                       # windows | macos (container mode only)
# The image this runs in; also used to probe ports. Pinned: an exact, known Compose.
SELF_IMAGE="${TALOS_SELF_IMAGE:-docker:28.5.2-cli}"
# Ports the HOST reports in use (Get-NetTCPConnection / lsof), comma-separated. A Docker
# probe cannot see them: Docker Desktop publishes a port a Windows program already holds.
HOST_BUSY_PORTS="${TALOS_HOST_BUSY_PORTS:-}"
# Container paths backed by host folders, ";"-separated. Anything else is the container's own
# throw-away filesystem — an install written there would vanish with the container.
MOUNT_ROOTS="${TALOS_MOUNT_ROOTS:-}"
# Where a copy of this installer is fetched from when it was piped in (curl | sh) and so has
# no file of its own to hand to the container.
INSTALLER_URL="${TALOS_INSTALLER_URL:-https://talosfleet.site/install.sh}"
DOCKER_DESKTOP_URL="https://www.docker.com/products/docker-desktop/"

# ─── output ──────────────────────────────────────────────────────────────────

if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
  C_RED="$(printf '\033[31m')"; C_GRN="$(printf '\033[32m')"; C_YLW="$(printf '\033[33m')"
  C_DIM="$(printf '\033[2m')"; C_BLD="$(printf '\033[1m')"; C_RST="$(printf '\033[0m')"
else
  C_RED=""; C_GRN=""; C_YLW=""; C_DIM=""; C_BLD=""; C_RST=""
fi

# In the Windows container every path the installer prints is the container's (/c/Users/ada);
# the person's is C:/Users/ada. Messages are rewritten here, at the one place they all pass
# through, and only for the folders actually mounted — nothing else in a message is touched.
# Forward slashes are kept: Windows accepts them, and a blanket swap could hit a URL.
hostify() {
  if [ "$IN_CONTAINER" != 1 ] || [ "$HOST_OS" != windows ] || [ -z "$MOUNT_ROOTS" ]; then cat; return; fi
  _hsed=""; _rest="$MOUNT_ROOTS"
  while [ -n "$_rest" ]; do
    _r="${_rest%%;*}"; [ "$_r" = "$_rest" ] && _rest="" || _rest="${_rest#*;}"
    case "$_r" in /[a-zA-Z]|/[a-zA-Z]/*) ;; *) continue ;; esac
    _hr="$(printf '%s' "$_r" | cut -c2 | tr '[:lower:]' '[:upper:]'):$(printf '%s' "$_r" | cut -c3-)"
    _hsed="$_hsed;s#$_r#$_hr#g"
  done
  if [ -n "$_hsed" ]; then sed "${_hsed#;}"; else cat; fi
}
say()  { printf '%s\n' "$*" | hostify; }
step() { printf '\n%s==> %s%s\n' "$C_BLD" "$*" "$C_RST" | hostify; }
pass() { printf '  %sPASS%s           %s\n' "$C_GRN" "$C_RST" "$*" | hostify; }
warn() { printf '  %sWARN%s           %s\n' "$C_YLW" "$C_RST" "$*" | hostify; }
noev() { printf '  %sNOT_EVALUATED%s  %s\n' "$C_DIM" "$C_RST" "$*" | hostify; }
fail() { printf '  %sFAIL%s           %s\n' "$C_RED" "$C_RST" "$*" | hostify; }
die()  {
  printf '\n%sError:%s %s\n' "$C_RED" "$C_RST" "$*" | hostify >&2
  # With --motd nobody is watching (a cloud start-up script), so the reason must survive to
  # the next login. Guarded against recursion: recording must never call die again.
  if [ "${MOTD:-0}" = 1 ] && [ -z "${_DYING:-}" ]; then
    _DYING=1; record_result failed "$*" 2>/dev/null; install_login_message 2>/dev/null
  fi
  exit 1
}

usage() {
  cat <<EOF
Talos installer ${INSTALLER_VERSION}

Usage: sh install.sh [options]

  --version X.Y.Z       Talos version (image tag) to install. No leading "v".${DEFAULT_VERSION:+
                        Default: ${DEFAULT_VERSION}. Without it, a re-run never moves an
                        existing install to an older version.}
  --dir PATH            Install directory. Default: an existing Talos install on this
                        machine if one is found (or the last one installed from this
                        account), else /opt/talos (root) or ~/talos.
  --url URL             Address users will open (used in email links), e.g.
                        http://203.0.113.10:3000 or https://talos.example.com.
                        Default: http://localhost:<frontend port>
  --frontend-port N     Host port for the web UI (default 3000).
  --backend-port N      Host port for the API (default 3001).
  --registry NAME       Image registry/namespace (default ${DEFAULT_REGISTRY}).
  --timeout SECONDS     How long to wait for the first start (default 900).
  -y, --yes             Do not ask for confirmation.
  --no-start            Write the files but do not start Talos.
  --dry-run             Check the host and show what would happen; change nothing.
  --eval                Try Talos on this computer: only this computer can open it, in
                        its own folder (~/talos-eval) and Compose project ("talos-eval")
                        that never touch a regular install, no setup token, and a
                        remove-talos-eval.sh that deletes everything again.
  --motd                For unattended installs (a cloud server's start-up script): record
                        the result and show it at every login — the link, how to read the
                        setup token, or why the install failed.
  -h, --help            Show this help.

Every option can also be set as an environment variable: TALOS_VERSION, TALOS_DIR,
TALOS_URL, TALOS_FRONTEND_PORT, TALOS_BACKEND_PORT, TALOS_REGISTRY, TALOS_MOTD=1, TALOS_EVAL=1.
EOF
}

# ─── helpers ─────────────────────────────────────────────────────────────────

# Hex secret of $1 random bytes. hex is URL-safe by construction, which matters because
# POSTGRES_PASSWORD is placed inside a connection URL.
gen_hex() {
  if command -v openssl >/dev/null 2>&1; then
    openssl rand -hex "$1"
  else
    od -An -tx1 -N "$1" /dev/urandom | tr -d ' \n'
  fi
}

# Value of KEY in an env file (last assignment wins, like Compose). CR (a file edited on
# Windows) and surrounding quotes are stripped.
env_get() {
  [ -f "$2" ] || return 0
  tr -d '\r' < "$2" | sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*//p" | tail -n 1 \
    | sed -e 's/[[:space:]]*$//' -e "s/^\"\(.*\)\"$/\1/" -e "s/^'\(.*\)'$/\1/"
}

# Set KEY=VALUE in env file $3: replace the existing assignment(s), else append. The value
# goes through ENVIRON, not `awk -v`, so backslashes in it are not interpreted, and the
# match avoids POSIX character classes, which mawk 1.3.3 (Debian 10, Ubuntu 18.04) lacks.
# The result is read back; a write that did not take effect is an error, not a PASS.
env_set() {
  _k="$1"; _v="$2"; _f="$3"; _t="$_f.tmp.$$"
  if grep -q "^[ 	]*${_k}[ 	]*=" "$_f"; then
    _ENV_SET_VALUE="$_v" awk -v k="$_k" '
      { l = $0; sub(/^[ \t]+/, "", l) }
      index(l, k) == 1 && substr(l, length(k) + 1) ~ /^[ \t]*=/ { print k "=" ENVIRON["_ENV_SET_VALUE"]; next }
      { print }
    ' "$_f" > "$_t" || die "could not update $_f"
  else
    cp "$_f" "$_t" || die "could not update $_f"
    # A file whose last line has no newline would otherwise get KEY=VALUE glued onto it.
    if [ -s "$_t" ] && [ -n "$(tail -c 1 "$_t")" ]; then printf '\n' >> "$_t"; fi
    printf '%s=%s\n' "$_k" "$_v" >> "$_t" || die "could not update $_f"
  fi
  cat "$_t" > "$_f" || die "could not write $_f"   # keeps the original file's mode
  rm -f "$_t"
  [ "$(env_get "$_k" "$_f")" = "$_v" ] || die "could not set $_k in $_f"
}

# Compose project name as Compose derives it from a directory name: lowercase, only
# [a-z0-9_-], starting with a letter or digit. Empty when nothing usable remains.
project_from_dir() {
  basename "$1" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_-' | sed 's/^[^a-z0-9]*//'
}

# A path as the person sees it. In the Windows container /c/Users/ada/talos is the folder
# C:\Users\ada\talos; everywhere else a path is already what the person sees.
host_path() {
  if [ "$IN_CONTAINER" = 1 ] && [ "$HOST_OS" = windows ]; then
    case "$1" in
      /[a-zA-Z]|/[a-zA-Z]/*)
        _hd="$(printf '%s' "$1" | cut -c2 | tr '[:lower:]' '[:upper:]')"
        _hr="$(printf '%s' "$1" | cut -c3- | tr '/' '\\')"
        printf '%s:%s\n' "$_hd" "${_hr:-\\}"; return ;;
    esac
  fi
  printf '%s\n' "$1"
}

# A Compose working_dir label written by Compose running ON WINDOWS ("C:\Users\ada\talos",
# an install made by hand from the old docs) as the path this container mounts it at.
# Anything else is returned unchanged, so this is harmless on Linux and macOS.
container_path() {
  case "$1" in
    [a-zA-Z]:[\\/]*|[a-zA-Z]:)
      _cd="$(printf '%s' "$1" | cut -c1 | tr '[:upper:]' '[:lower:]')"
      _cr="$(printf '%s' "$1" | cut -c3- | tr '\\' '/')"
      printf '/%s%s\n' "$_cd" "$_cr" ;;
    *) printf '%s\n' "$1" ;;
  esac
}

# 0 when $1 is inside a host-backed folder (always, outside container mode).
on_host_mount() {
  [ "$IN_CONTAINER" = 1 ] || return 0
  _rest="$MOUNT_ROOTS"
  while [ -n "$_rest" ]; do
    _r="${_rest%%;*}"; [ "$_r" = "$_rest" ] && _rest="" || _rest="${_rest#*;}"
    [ -n "$_r" ] || continue
    case "$1" in "$_r"|"$_r"/*) return 0 ;; esac
  done
  return 1
}

# A shell command line as the person should type it: PowerShell has no "&&".
cmd_in_dir() {  # $1 = folder (container path), $2 = command
  if [ "$IN_CONTAINER" = 1 ] && [ "$HOST_OS" = windows ]; then
    printf 'cd "%s"; %s\n' "$(host_path "$1")" "$2"
  else
    printf 'cd %s && %s\n' "$1" "$2"
  fi
}

# Absolute, symlink-free form of a path, whether or not it exists yet. Compares equal to
# Compose's own working_dir label, and makes `--dir .` mean the current directory's name.
canonical_dir() {
  case "$1" in /*) _p="$1" ;; *) _p="$(pwd)/$1" ;; esac
  if [ -d "$_p" ]; then
    (cd "$_p" 2>/dev/null && pwd -P)
  else
    _parent="$(dirname "$_p")"; _base="$(basename "$_p")"
    case "$_base" in .|..) return 1 ;; esac
    while [ ! -d "$_parent" ] && [ "$_parent" != / ]; do
      _base="$(basename "$_parent")/$_base"; _parent="$(dirname "$_parent")"
    done
    printf '%s/%s\n' "$(cd "$_parent" && pwd -P | sed 's|/$||')" "$_base"
  fi
}

timestamp() { date +%Y%m%d%H%M%S; }

# ── Redis on arm64: the kernel copy-on-write check ──
#
# On arm64, Redis 7 refuses to start (and keeps restarting) on a kernel that loses a page's
# "dirty" bit across fork(): the snapshot it writes during a background save can then be
# silently corrupted. Redis decides this EMPIRICALLY at startup (syscheck.c,
# checkLinuxMadvFreeForkBug: MADV_FREE a page, fork, read /proc/self/smaps), not from a
# version list, and exposes the same test as `redis-server --check-system`. So the
# installer runs exactly that, with the image the stack will use, and lets Redis decide.
#
# The kernel fix is ff1712f953e2 "arm64: pgtable: Ensure dirty bit is preserved across
# pte_wrprotect()" (2020-11-23): mainline 5.10, and stable 5.9.12, 5.4.81, 4.19.161 and
# 4.14.210 (each verified against kernel.org's ChangeLog-<version>). Distribution kernels
# often carry it under an older number (e.g. Ubuntu linux-aws 5.4.0-1034), which is why a
# version comparison is only ever context here, never the verdict.

# The Redis image the stack runs, read from the embedded compose file so the two cannot drift.
redis_image() {
  compose_file | sed -n 's/^[[:space:]]*image:[[:space:]]*\(redis:[^[:space:]]*\).*/\1/p' | head -n 1
}

# affected | ok | unknown, from `redis-server --check-system` output.
redis_cow_verdict() {
  case "$1" in
    *"[madvise-free-fork-bug]...WARNING"*) echo affected ;;
    *"[madvise-free-fork-bug]...OK"*)      echo ok ;;
    *)                                     echo unknown ;;
  esac
}

# fixed | unfixed | unknown for an UPSTREAM kernel version string (uname -r style).
kernel_cow_status() {
  _kv="$(printf '%s' "$1" | sed -n 's/^\([0-9][0-9]*\)\.\([0-9][0-9]*\)\.\{0,1\}\([0-9]*\).*/\1 \2 \3/p')"
  [ -n "$_kv" ] || { echo unknown; return 0; }
  _kM="$(echo "$_kv" | cut -d' ' -f1)"; _km="$(echo "$_kv" | cut -d' ' -f2)"; _kp="$(echo "$_kv" | cut -d' ' -f3)"
  [ -n "$_kp" ] || _kp=0
  if [ "$_kM" -gt 5 ] || { [ "$_kM" -eq 5 ] && [ "$_km" -ge 10 ]; }; then echo fixed; return 0; fi
  case "$_kM.$_km" in
    5.9)  _need=12 ;;
    5.4)  _need=81 ;;
    4.19) _need=161 ;;
    4.14) _need=210 ;;
    5.[0-8]|4.1[5-8]|4.20) echo unfixed; return 0 ;;   # series that ended without the fix
    *)    echo unknown; return 0 ;;
  esac
  if [ "$_kp" -ge "$_need" ]; then echo fixed; else echo unfixed; fi
}

check_redis_cow() {
  [ "$ARCH" = arm64 ] || return 0
  _kver="$(docker info --format '{{.KernelVersion}}' 2>/dev/null)"; [ -n "$_kver" ] || _kver="$(uname -r)"
  _rimg="$(redis_image)"
  _rout="$(docker run --rm "$_rimg" redis-server --check-system 2>&1)"
  case "$(redis_cow_verdict "$_rout")" in
    ok)
      pass "kernel $_kver passes Redis's arm64 background-save check" ;;
    affected)
      fail "Redis will refuse to start on this kernel ($_kver)"
      cat <<EOF

                 On arm64, older Linux kernels lose a memory page's "dirty" flag when a
                 process forks, so the snapshot Redis writes in the background can be
                 silently corrupted. Redis tests for this itself at startup and exits,
                 and $_rimg just reported this kernel as affected.

                 Fix: upgrade the kernel and reboot, then run this installer again. The
                 fix ("arm64: pgtable: Ensure dirty bit is preserved across
                 pte_wrprotect()") is in Linux 5.10 and later, and in 5.9.12, 5.4.81,
                 4.19.161 and 4.14.210. Current kernels from Ubuntu, Debian and other
                 distributions include it, often under an older-looking version number.
                 Disabling the check is not a fix: it is there to prevent data loss.
EOF
      PREFLIGHT_FAILED=1 ;;
    *)
      case "$(kernel_cow_status "$_kver")" in
        unfixed) warn "could not run Redis's arm64 kernel check; kernel $_kver predates the upstream fix (5.10 / 5.4.81 / 4.19.161), so Redis may refuse to start. Distribution kernels often carry the fix anyway." ;;
        fixed)   noev "Redis arm64 kernel check could not run; kernel $_kver includes the upstream fix" ;;
        *)       noev "Redis arm64 kernel check could not run ($(printf '%s' "$_rout" | head -n 1 | cut -c1-60))" ;;
      esac ;;
  esac
}

# Image architecture for a machine name as `docker info` / `uname -m` report it.
arch_name() {
  case "$1" in
    x86_64|amd64)  echo amd64 ;;
    aarch64|arm64) echo arm64 ;;
    *)             return 1 ;;
  esac
}

# ─── questions ───────────────────────────────────────────────────────────────
#
# Asked on the terminal even when stdin is the script itself (`curl … | sh`): the answer
# is read from /dev/tty. With --yes every question takes its default-for-automation
# (yes, or the suggested value). With no terminal and no --yes, the installer stops rather
# than guess.

# Read one line from the person at the terminal into ANSWER. Fails when there is no terminal.
ask_line() {
  ANSWER=""
  if [ -t 0 ]; then
    printf '%s' "$1"; read -r ANSWER || ANSWER=""
  elif (exec < /dev/tty) 2>/dev/null; then
    printf '%s' "$1" > /dev/tty; read -r ANSWER < /dev/tty || ANSWER=""
  else
    return 1
  fi
  # Trim surrounding whitespace.
  ANSWER="$(printf '%s' "$ANSWER" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
  return 0
}

# y/N question. Enter means NO. Returns 0 for yes, 1 for no.
ask_yes_no() {
  if [ "$ASSUME_YES" = 1 ]; then
    say "  $1 [y/N] y (--yes)"
    return 0
  fi
  while :; do
    ask_line "  $1 [y/N] " || die "no terminal to answer \"$1\". Re-run with --yes to accept the defaults."
    case "$ANSWER" in
      y|Y|yes|YES|Yes) return 0 ;;
      ''|n|N|no|NO|No) return 1 ;;
      *) say "  Please answer y or n." ;;
    esac
  done
}

confirm() {
  ask_yes_no "$1" || die "cancelled. Nothing was changed."
}

compose() { (cd "$INSTALL_DIR" && docker compose -p "$PROJECT" "$@"); }

# ─── Docker: install / start / sudo ──────────────────────────────────────────

# Run a command as root (directly, or through sudo).
as_root() {
  if [ "$(id -u)" = 0 ]; then "$@"
  elif command -v sudo >/dev/null 2>&1; then sudo "$@"
  else die "this step needs root, and sudo is not available. Re-run the installer as root."
  fi
}

# Download $1 to $2 with curl or wget.
fetch() {
  if command -v curl >/dev/null 2>&1; then curl -fsSL "$1" -o "$2"
  elif command -v wget >/dev/null 2>&1; then wget -qO "$2" "$1"
  else die "neither curl nor wget is installed, so $1 cannot be downloaded."
  fi
}

# After this, every `docker` in this script runs through sudo. Used when this user may not
# talk to the Docker daemon directly (not in the docker group, or just added to it: group
# membership only takes effect at the next login).
use_sudo_for_docker() {
  DOCKER_SUDO=1
  # sudo runs the docker BINARY, not this function — which is exactly the intent.
  # shellcheck disable=SC2032,SC2033
  docker() { sudo docker "$@"; }
}

docker_daemon_ok() { docker info >/dev/null 2>&1; }

start_docker_service() {
  say "  Starting the Docker service..."
  if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
    as_root systemctl enable --now docker >/dev/null 2>&1 || as_root systemctl start docker
  elif command -v service >/dev/null 2>&1; then
    as_root service docker start >/dev/null 2>&1
  elif command -v rc-service >/dev/null 2>&1; then
    as_root rc-service docker start >/dev/null 2>&1
  fi
  wait_for_daemon 30 && return 0
  # No service manager started it: a machine without systemd (WSL2 without systemd, a
  # container, a minimal VM). Docker's packages ship only a systemd unit, so start the
  # daemon directly. It will NOT come back after a reboot; say so.
  if command -v dockerd >/dev/null 2>&1; then
    say "  No service manager started Docker; starting dockerd directly."
    as_root sh -c 'nohup dockerd >/var/log/dockerd.log 2>&1 &'
    if wait_for_daemon 60; then
      warn "Docker was started directly, without a service manager: it will NOT start again after a reboot. Enable systemd (or start dockerd at boot) for a permanent install."
      return 0
    fi
  fi
  return 1
}

wait_for_daemon() {
  _i=0
  while [ "$_i" -lt "$1" ]; do
    docker_daemon_ok && return 0
    # shellcheck disable=SC2033  # the binary, deliberately
    { [ "$(id -u)" != 0 ] && sudo -n docker info >/dev/null 2>&1; } && return 0
    _i=$(( _i + 1 )); sleep 1
  done
  return 1
}

# ─── unattended installs: package locks and the login message ───────────────

# On a server's first boot the OS runs its own updates (unattended-upgrades, dnf-automatic)
# and holds the package lock — often for minutes. Docker's installer then fails with "could
# not get lock", the classic failure of a cloud start-up script. So wait for it.
PKG_WAIT="${TALOS_PKG_WAIT:-600}"

package_manager_busy() {
  # A package tool running. `ps -o comm=` truncates to 15 characters, hence "unattended-upgr".
  ps -e -o comm= 2>/dev/null | grep -Eq '^(apt|apt-get|aptitude|dpkg|unattended-upgr|dnf|yum|zypper|rpm)$' && return 0
  # A lock held by something not on that list (fuser is not always installed).
  if command -v fuser >/dev/null 2>&1; then
    for _l in /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock; do
      [ -e "$_l" ] && as_root fuser "$_l" >/dev/null 2>&1 && return 0
    done
  fi
  return 1
}

# 0 = free (now or after waiting), 1 = still busy after PKG_WAIT seconds.
wait_for_package_manager() {
  package_manager_busy || return 0
  say "  The system's package manager is busy (first-boot updates?). Waiting for it, up to $(( PKG_WAIT / 60 )) minutes..."
  _w=0
  while package_manager_busy; do
    if [ "$_w" -ge "$PKG_WAIT" ]; then
      warn "the package manager was still busy after $(( PKG_WAIT / 60 )) minutes; trying anyway."
      return 1
    fi
    sleep 5; _w=$(( _w + 5 ))
    [ $(( _w % 60 )) = 0 ] && say "  ...still waiting ($(( _w / 60 )) min)"
  done
  pass "the package manager is free"
  return 0
}

STATE_DIR="${TALOS_STATE_DIR:-/var/lib/talos-installer}"
PROFILE_SCRIPT="${TALOS_PROFILE_SCRIPT:-/etc/profile.d/talos.sh}"

# Record the outcome for the login message: $1 = ok|failed, $2 = the reason when failed.
record_result() {
  as_root mkdir -p "$STATE_DIR" || return 1
  _rs="$(mktemp 2>/dev/null || echo "/tmp/talos-state.$$")"
  {
    printf 'STATE=%s\n' "$1"
    printf 'WHEN=%s\n' "$(date -u '+%Y-%m-%d %H:%M UTC')"
    printf 'VERSION=%s\n' "${VERSION:-}"
    printf 'DIR=%s\n' "${INSTALL_DIR:-}"
    printf 'PORT=%s\n' "${FRONTEND_PORT:-3000}"
    printf 'URL=%s\n' "${PUBLIC_URL:-}"
  } > "$_rs"
  as_root sh -c "cat '$_rs' > '$STATE_DIR/state' && chmod 644 '$STATE_DIR/state'" || { rm -f "$_rs"; return 1; }
  if [ "$1" = failed ]; then
    printf '%s\n' "$2" > "$_rs"
    as_root sh -c "cat '$_rs' > '$STATE_DIR/reason' && chmod 644 '$STATE_DIR/reason'"
  else
    as_root rm -f "$STATE_DIR/reason"
  fi
  rm -f "$_rs"
}

# The script every login shell runs (/etc/profile.d). It reads the recorded result and asks
# Talos LIVE, so it stops mentioning the setup token once the first account exists. The token
# itself is printed only to someone who can already read .env (root); everyone else is told
# the command. /etc/motd is world-readable, which is why the token never goes there.
install_login_message() {
  _lm="$(mktemp 2>/dev/null || echo "/tmp/talos-profile.$$")"
  login_message_script > "$_lm"
  as_root sh -c "mkdir -p '$(dirname "$PROFILE_SCRIPT")' && cat '$_lm' > '$PROFILE_SCRIPT' && chmod 644 '$PROFILE_SCRIPT'"
  _r=$?
  rm -f "$_lm"
  return $_r
}

login_message_script() {
  cat <<'TALOS_PROFILE_EOF'
# Talos — written by install.sh --motd. Shows the result of the unattended install at login.
# Remove this file to stop the message; it changes nothing else.
_talos_login_message() {
  _d="${TALOS_STATE_DIR:-/var/lib/talos-installer}"
  [ -r "$_d/state" ] || return 0
  _st="$(sed -n 's/^STATE=//p' "$_d/state")"; _dir="$(sed -n 's/^DIR=//p' "$_d/state")"
  _port="$(sed -n 's/^PORT=//p' "$_d/state")"; _url="$(sed -n 's/^URL=//p' "$_d/state")"
  _when="$(sed -n 's/^WHEN=//p' "$_d/state")"
  echo
  if [ "$_st" = failed ]; then
    echo "  Talos: the install on $_when FAILED."
    [ -r "$_d/reason" ] && sed 's/^/    /' "$_d/reason" | head -n 8
    echo "  Full log: /var/log/talos-install.log (or /var/log/cloud-init-output.log)"
    echo "  Fix the cause, then run again:  sudo sh /root/talos-install.sh --yes --motd"
    echo
    return 0
  fi
  if command -v curl >/dev/null 2>&1; then _get="curl -fsS --max-time 3"
  elif command -v wget >/dev/null 2>&1; then _get="wget -qO- -T 3"
  else _get=""; fi
  _status=""
  [ -n "$_get" ] && _status="$($_get "http://127.0.0.1:${_port:-3000}/api/setup/status" 2>/dev/null)"
  if [ -z "$_status" ]; then
    echo "  Talos is installed in $_dir but is not answering on port ${_port:-3000}."
    echo "  Check it:  cd $_dir && sudo docker compose ps"
    echo
    return 0
  fi
  echo "  Talos is running."
  [ -n "$_url" ] && echo "    Open:  $_url"
  echo "    From outside this server: http://<its public IP or domain>:${_port:-3000}"
  echo "    (the port must be open in your cloud provider's firewall)"
  case "$_status" in
    *'"setupTokenRequired":true'*)
      echo "  No account yet. Choose \"Create the first one\" and enter the setup token:"
      if [ -r "$_dir/.env" ]; then
        echo "    $(sed -n 's/^SETUP_TOKEN=//p' "$_dir/.env")"
      else
        echo "    sudo grep ^SETUP_TOKEN= $_dir/.env"
      fi ;;
  esac
  echo
}
case $- in *i*) _talos_login_message 2>/dev/null ;; esac
unset -f _talos_login_message 2>/dev/null
unset _d _st _dir _port _url _when _get _status
TALOS_PROFILE_EOF
}

# ─── macOS: run this same installer inside the Docker CLI container ─────────

# Ports in use on the Mac itself, comma-separated — what a Docker probe cannot see.
mac_busy_ports() {
  command -v lsof >/dev/null 2>&1 || return 0
  lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null \
    | awk 'NR > 1 { n = split($9, a, ":"); if (a[n] ~ /^[0-9]+$/) print a[n] }' | sort -un | paste -sd, - 2>/dev/null
}

# The value given to --dir, if any (the container must be able to reach that folder).
arg_dir() {
  while [ $# -gt 0 ]; do
    case "$1" in --dir) echo "${2:-}"; return ;; --dir=*) echo "${1#*=}"; return ;; esac
    shift
  done
  echo "${TALOS_DIR:-}"
}

desktop_reexec() {
  step "Checking Docker (macOS)"
  if ! command -v docker >/dev/null 2>&1; then
    warn "Docker is not installed."
    say "  Talos runs in Docker. On a Mac install Docker Desktop ($DOCKER_DESKTOP_URL),"
    say "  or OrbStack, Colima or Rancher Desktop, start it once, then run this installer again."
    if ask_yes_no "Open the Docker Desktop download page now?"; then open "$DOCKER_DESKTOP_URL" 2>/dev/null || true; fi
    exit 1
  fi
  if ! docker info >/dev/null 2>&1; then
    if [ -d /Applications/Docker.app ]; then
      say "  Starting Docker Desktop and waiting for it (up to 2 minutes)..."
      open -a Docker 2>/dev/null || true
      _w=0
      until docker info >/dev/null 2>&1; do
        [ "$_w" -ge 120 ] && die "Docker Desktop did not finish starting within 2 minutes. Open it, wait until it says it is running, then run this installer again."
        sleep 3; _w=$(( _w + 3 ))
      done
    else
      die "Docker is installed but not running. Start Docker Desktop (or OrbStack, Colima, Rancher Desktop), then run this installer again."
    fi
  fi
  pass "Docker answers ($(docker info --format '{{.ServerVersion}}' 2>/dev/null))"

  # This file, for the container. Piped in (curl | sh) there is none, so fetch a copy.
  _self="$0"; _tmpself=""
  if ! { [ -f "$_self" ] && grep -q '^TALOS_COMPOSE_EOF$' "$_self" 2>/dev/null; }; then
    _tmpself="$HOME/.talos-install.$$.sh"
    fetch "$INSTALLER_URL" "$_tmpself" || die "could not download the installer from $INSTALLER_URL."
    _self="$_tmpself"
  fi

  # Your home folder is mounted at the same path, so every path reads the same inside and
  # out. A --dir outside it is mounted too.
  _roots="$HOME"; _xm=""
  _ad="$(arg_dir "$@")"
  if [ -n "$_ad" ]; then
    case "$_ad" in /*) ;; *) _ad="$(pwd)/$_ad" ;; esac
    case "$_ad" in "$HOME"|"$HOME"/*) ;; *)
      mkdir -p "$_ad" || die "cannot create $_ad"
      _roots="$_roots;$_ad"; _xm="$_ad" ;;
    esac
  fi

  # Whether a terminal can actually be OPENED — the same test the questions use. `-r /dev/tty`
  # is true even with no terminal attached (ssh without -t, automation), and then
  # `< /dev/tty` failed with "can't open /dev/tty" and the install never started.
  _tty="-i"; (exec < /dev/tty) 2>/dev/null && _tty="-it"
  _envs=""
  for _v in TALOS_VERSION TALOS_DIR TALOS_URL TALOS_FRONTEND_PORT TALOS_BACKEND_PORT TALOS_REGISTRY TALOS_EVAL TALOS_PKG_WAIT; do
    eval "_val=\${$_v:-}"; [ -n "$_val" ] && _envs="$_envs -e $_v"
  done
  say "  Running the installer in a Docker container ($SELF_IMAGE), as on every system."
  # shellcheck disable=SC2086  # _envs is a list of flags (variable names only, no spaces)
  if [ "$_tty" = "-it" ]; then
    docker run --rm -it --add-host host.docker.internal:host-gateway -v /var/run/docker.sock:/var/run/docker.sock -v "$HOME:$HOME" ${_xm:+-v "$_xm:$_xm"} \
      -v "$_self:/tmp/talos-install.sh:ro" -e HOME="$HOME" -e TALOS_IN_CONTAINER=1 -e TALOS_HOST_OS=macos \
      -e TALOS_MOUNT_ROOTS="$_roots" -e TALOS_HOST_BUSY_PORTS="$(mac_busy_ports)" -e TALOS_SELF_IMAGE="$SELF_IMAGE" \
      $_envs "$SELF_IMAGE" sh /tmp/talos-install.sh "$@" < /dev/tty
  else
    docker run --rm -i --add-host host.docker.internal:host-gateway -v /var/run/docker.sock:/var/run/docker.sock -v "$HOME:$HOME" ${_xm:+-v "$_xm:$_xm"} \
      -v "$_self:/tmp/talos-install.sh:ro" -e HOME="$HOME" -e TALOS_IN_CONTAINER=1 -e TALOS_HOST_OS=macos \
      -e TALOS_MOUNT_ROOTS="$_roots" -e TALOS_HOST_BUSY_PORTS="$(mac_busy_ports)" -e TALOS_SELF_IMAGE="$SELF_IMAGE" \
      $_envs "$SELF_IMAGE" sh /tmp/talos-install.sh "$@"
  fi
  _rc=$?
  [ -n "$_tmpself" ] && rm -f "$_tmpself"
  exit $_rc
}

install_docker() {
  case "$(uname -s)" in
    Linux) ;;
    Darwin) die "on macOS, install Docker Desktop (https://docs.docker.com/desktop/setup/install/mac-install/), start it, then run this installer again." ;;
    *) die "install Docker for $(uname -s) (https://docs.docker.com/get-started/get-docker/), then run this installer again." ;;
  esac
  step "Installing Docker"
  say "  Using Docker's official installer (https://get.docker.com): Docker Engine and the"
  say "  Docker Compose plugin from Docker's own package repositories."
  _gd="$(mktemp 2>/dev/null || echo /tmp/get-docker.$$)"
  fetch https://get.docker.com "$_gd" || die "could not download Docker's installer from https://get.docker.com."
  wait_for_package_manager || true
  if ! as_root sh "$_gd"; then
    # The usual cause on a fresh server: first-boot updates took the lock mid-install.
    if package_manager_busy; then
      say "  Docker's installer failed while the package manager was busy. Waiting, then trying once more."
      wait_for_package_manager || true
      as_root sh "$_gd" || { rm -f "$_gd"; die "Docker's installer failed twice (output above). Nothing of Talos was installed."; }
    else
      rm -f "$_gd"; die "Docker's installer failed (output above). Nothing of Talos was installed."
    fi
  fi
  rm -f "$_gd"
  start_docker_service || die "Docker was installed but its service did not start. Start it (e.g. sudo systemctl start docker), then run this installer again."
  if [ "$(id -u)" != 0 ]; then
    as_root usermod -aG docker "$(id -un)" 2>/dev/null && \
      say "  Added $(id -un) to the docker group (takes effect at your next login)."
    use_sudo_for_docker
  fi
  pass "Docker installed ($(docker --version 2>/dev/null | sed 's/^Docker version //;s/,.*//'))"
}

install_compose_plugin() {
  step "Installing the Docker Compose plugin"
  _ok=0
  wait_for_package_manager || true
  if command -v apt-get >/dev/null 2>&1; then
    as_root apt-get update -qq >/dev/null 2>&1; as_root apt-get install -y -qq docker-compose-plugin >/dev/null 2>&1 && _ok=1
  elif command -v dnf >/dev/null 2>&1; then
    as_root dnf install -y -q docker-compose-plugin >/dev/null 2>&1 && _ok=1
  elif command -v yum >/dev/null 2>&1; then
    as_root yum install -y -q docker-compose-plugin >/dev/null 2>&1 && _ok=1
  fi
  if [ "$_ok" = 0 ]; then
    # Docker's documented manual install: the plugin binary into cli-plugins.
    case "$(uname -m)" in x86_64|amd64) _ca=x86_64 ;; aarch64|arm64) _ca=aarch64 ;; *) die "no Compose plugin build for $(uname -m)." ;; esac
    _cp="$(mktemp 2>/dev/null || echo /tmp/docker-compose.$$)"
    fetch "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$_ca" "$_cp" \
      || die "could not download the Compose plugin. Install it by hand: https://docs.docker.com/compose/install/linux/"
    as_root mkdir -p /usr/local/lib/docker/cli-plugins
    as_root install -m 0755 "$_cp" /usr/local/lib/docker/cli-plugins/docker-compose
    rm -f "$_cp"
  fi
  docker compose version >/dev/null 2>&1 || die "the Compose plugin did not install. Install it by hand: https://docs.docker.com/compose/install/linux/"
  pass "Docker Compose installed ($(docker compose version --short 2>/dev/null))"
}

# Docker installed, daemon reachable, Compose v2 present — offering to fix each, asking first.
ensure_docker() {
  step "Checking Docker"
  DOCKER_SUDO=""
  if [ "$IN_CONTAINER" = 1 ]; then
    # The wrapper started Docker Desktop and waited for it; here it is only confirmed.
    docker_daemon_ok || die "Docker Desktop does not answer from the installer. Is it running?"
    pass "Docker Desktop answers ($(docker info --format '{{.ServerVersion}}' 2>/dev/null))"
    docker compose version >/dev/null 2>&1 || die "docker compose is missing from $SELF_IMAGE."
    return 0
  fi
  if ! command -v docker >/dev/null 2>&1; then
    warn "Docker is not installed."
    if ask_yes_no "Install Docker Engine and Docker Compose now?"; then
      install_docker
    else
      die "Talos needs Docker. Install Docker Engine and the Compose plugin
(https://docs.docker.com/engine/install/), then run this installer again."
    fi
  else
    pass "docker is installed ($(command docker --version 2>/dev/null | sed 's/^Docker version //;s/,.*//'))"
  fi

  if ! docker_daemon_ok; then
    _err="$(command docker info 2>&1 | tail -n 3)"
    case "$_err" in
      *"permission denied"*)
        warn "this user ($(id -un)) may not use Docker directly (not in the docker group)."
        if ask_yes_no "Use sudo for Docker commands during this install?"; then
          use_sudo_for_docker
          docker_daemon_ok || die "Docker does not answer even through sudo. Is it running?  sudo systemctl status docker"
        else
          die "add this user to the docker group (sudo usermod -aG docker $(id -un)), log in again, then run this installer again."
        fi ;;
      *)
        warn "the Docker daemon is not running."
        if ask_yes_no "Start the Docker service now?"; then
          start_docker_service || die "the Docker service did not start. Look at:  sudo systemctl status docker"
          docker_daemon_ok || { [ "$(id -u)" != 0 ] && use_sudo_for_docker; }
          docker_daemon_ok || die "Docker started but does not answer this user. Run the installer with sudo."
        else
          die "start Docker (e.g. sudo systemctl start docker), then run this installer again."
        fi ;;
    esac
  fi
  pass "the Docker daemon is running and reachable${DOCKER_SUDO:+ (through sudo)}"

  if ! _cv="$(docker compose version --short 2>/dev/null)"; then
    warn "Docker Compose v2 ('docker compose') is not installed."
    if ask_yes_no "Install the Docker Compose plugin now?"; then
      install_compose_plugin
    else
      die "Talos needs Docker Compose v2. Install it (https://docs.docker.com/compose/install/linux/), then run this installer again."
    fi
  else
    pass "Docker Compose v2 is available ($_cv)"
  fi
}

# ─── arguments ───────────────────────────────────────────────────────────────

parse_args() {
  VERSION="${TALOS_VERSION:-$DEFAULT_VERSION}"
  # Asked for by name: only then may a re-run move an install to an older version.
  VERSION_GIVEN=""; [ -n "${TALOS_VERSION:-}" ] && VERSION_GIVEN=1
  INSTALL_DIR="${TALOS_DIR:-}"
  PUBLIC_URL="${TALOS_URL:-}"
  FRONTEND_PORT="${TALOS_FRONTEND_PORT:-}"
  BACKEND_PORT="${TALOS_BACKEND_PORT:-}"
  REGISTRY="${TALOS_REGISTRY:-}"
  TIMEOUT=900; ASSUME_YES=0; NO_START=0; DRY_RUN=0; MOTD="${TALOS_MOTD:-0}"; EVAL="${TALOS_EVAL:-0}"
  # Ports passed explicitly are written to .env; ports only defaulted are not.
  PORTS_GIVEN=0; [ -n "$FRONTEND_PORT$BACKEND_PORT" ] && PORTS_GIVEN=1

  while [ $# -gt 0 ]; do
    case "$1" in
      --version)        [ $# -ge 2 ] || die "--version needs a value"; VERSION="$2"; VERSION_GIVEN=1; shift 2 ;;
      --version=*)      VERSION="${1#*=}"; VERSION_GIVEN=1; shift ;;
      --dir)            [ $# -ge 2 ] || die "--dir needs a value"; INSTALL_DIR="$2"; shift 2 ;;
      --dir=*)          INSTALL_DIR="${1#*=}"; shift ;;
      --url)            [ $# -ge 2 ] || die "--url needs a value"; PUBLIC_URL="$2"; shift 2 ;;
      --url=*)          PUBLIC_URL="${1#*=}"; shift ;;
      --frontend-port)  [ $# -ge 2 ] || die "--frontend-port needs a value"; FRONTEND_PORT="$2"; PORTS_GIVEN=1; shift 2 ;;
      --frontend-port=*) FRONTEND_PORT="${1#*=}"; PORTS_GIVEN=1; shift ;;
      --backend-port)   [ $# -ge 2 ] || die "--backend-port needs a value"; BACKEND_PORT="$2"; PORTS_GIVEN=1; shift 2 ;;
      --backend-port=*) BACKEND_PORT="${1#*=}"; PORTS_GIVEN=1; shift ;;
      --registry)       [ $# -ge 2 ] || die "--registry needs a value"; REGISTRY="$2"; shift 2 ;;
      --registry=*)     REGISTRY="${1#*=}"; shift ;;
      --timeout)        [ $# -ge 2 ] || die "--timeout needs a value"; TIMEOUT="$2"; shift 2 ;;
      --timeout=*)      TIMEOUT="${1#*=}"; shift ;;
      -y|--yes)         ASSUME_YES=1; shift ;;
      --no-start)       NO_START=1; shift ;;
      --dry-run)        DRY_RUN=1; shift ;;
      --motd)           MOTD=1; shift ;;
      --eval)           EVAL=1; shift ;;
      -h|--help)        usage; exit 0 ;;
      *)                die "unknown option: $1 (see --help)" ;;
    esac
  done

  case "$VERSION" in
    v[0-9]*) die "image tags have no leading \"v\": use --version ${VERSION#v}" ;;
    latest)  die "--version latest is not allowed: pin a version so upgrades happen when you choose." ;;
  esac
  case "$TIMEOUT" in ''|*[!0-9]*|0) die "--timeout must be a positive number of seconds" ;; esac
  if [ "$EVAL" = 1 ]; then
    [ -z "$PUBLIC_URL" ] || die "--eval is reachable only from this computer, so --url does not apply."
    [ "$MOTD" != 1 ] || die "--eval is for trying Talos on this computer; --motd is for cloud servers."
  fi
  for _p in "$FRONTEND_PORT" "$BACKEND_PORT"; do
    case "$_p" in *[!0-9]*) die "not a valid port: $_p" ;; esac
  done
  for _p in "$FRONTEND_PORT" "$BACKEND_PORT"; do
    [ -n "$_p" ] || continue
    if [ "$_p" -lt 1 ] || [ "$_p" -gt 65535 ]; then die "not a valid port: $_p"; fi
  done
}

# ─── find / choose the install ───────────────────────────────────────────────

# Every Talos backend container on this machine, as "project|working_dir", one per line.
# Identified by the service name AND the image it was created from (.Config.Image keeps
# the repository name even when the tag has since moved and `docker ps` shows an ID).
existing_installs() {
  for _c in $(docker ps -aq --filter "label=com.docker.compose.service=backend" 2>/dev/null); do
    docker inspect -f '{{.Config.Image}}|{{index .Config.Labels "com.docker.compose.project"}}|{{index .Config.Labels "com.docker.compose.project.working_dir"}}' "$_c" 2>/dev/null
  done | awk -F'|' '$1 ~ /talos-backend/ {print $2 "|" $3}' | sort -u
}

volume_exists() { docker volume inspect "$1" >/dev/null 2>&1; }

# Compare two image tags as versions: prints lt, eq or gt ($1 against $2), or unknown when
# either is not X.Y.Z[-pre] (a sha-... or custom tag). A pre-release orders before its
# release; pre-release identifiers compare as in semver.
version_cmp() {
  awk -v a="$1" -v b="$2" '
    function valid(v) { return v ~ /^[0-9]+([.][0-9]+)*(-[0-9A-Za-z.-]+)?$/ }
    function core(v) { return index(v, "-") ? substr(v, 1, index(v, "-") - 1) : v }
    function pre(v)  { return index(v, "-") ? substr(v, index(v, "-") + 1) : "" }
    function isnum(s) { return s ~ /^[0-9]+$/ }
    function cmpcore(x, y,   xs, ys, n, m, k, p, q) {
      n = split(x, xs, "[.]"); m = split(y, ys, "[.]")
      for (k = 1; k <= (n > m ? n : m); k++) {
        p = (k <= n ? xs[k] : 0) + 0; q = (k <= m ? ys[k] : 0) + 0
        if (p != q) return p < q ? -1 : 1
      }
      return 0
    }
    function cmppre(x, y,   xs, ys, n, m, k) {
      n = split(x, xs, "[.]"); m = split(y, ys, "[.]")
      for (k = 1; k <= (n > m ? n : m); k++) {
        if (k > n) return -1
        if (k > m) return 1
        if (isnum(xs[k]) && isnum(ys[k])) { if (xs[k] + 0 != ys[k] + 0) return xs[k] + 0 < ys[k] + 0 ? -1 : 1 }
        else if (isnum(xs[k])) return -1
        else if (isnum(ys[k])) return 1
        else if (xs[k] != ys[k]) return xs[k] < ys[k] ? -1 : 1
      }
      return 0
    }
    BEGIN {
      if (!valid(a) || !valid(b)) { print "unknown"; exit }
      c = cmpcore(core(a), core(b))
      if (c == 0) {
        if (pre(a) == "" && pre(b) != "") c = 1
        else if (pre(a) != "" && pre(b) == "") c = -1
        else if (pre(a) != "") c = cmppre(pre(a), pre(b))
      }
      print (c < 0 ? "lt" : (c > 0 ? "gt" : "eq"))
    }'
}

# The folder of the last regular install made from this account. Discovery otherwise reads
# it from the containers, and `docker compose down` removes those: without this record a
# bare re-run (how people start a stopped Talos) would miss an install in a custom --dir.
install_record() { echo "${HOME:-/root}/.talos/install-dir"; }

# Prints the recorded folder if it still holds an install (.env present), else nothing.
recorded_install() {
  _rf="$(install_record)"
  [ -f "$_rf" ] || return 0
  _rd="$(head -n 1 "$_rf" 2>/dev/null | tr -d '\r')"
  if [ -n "$_rd" ] && [ -f "$_rd/.env" ]; then printf '%s\n' "$_rd"; fi
  return 0
}

# Never fails the install: losing the record only costs the convenience it adds.
remember_install() {
  _rf="$(install_record)"
  { mkdir -p "$(dirname "$_rf")" && printf '%s\n' "$INSTALL_DIR" > "$_rf"; } 2>/dev/null \
    || warn "could not record the install folder in $_rf (a re-run will need --dir $(host_path "$INSTALL_DIR"))."
}

# Compose projects that own a Talos database volume, from the volume's own labels. This
# still works after `docker compose down` removed every container.
data_projects() {
  docker volume ls -q --filter "label=com.docker.compose.volume=talos_postgres" 2>/dev/null \
    | while read -r _v; do
        docker volume inspect -f '{{index .Labels "com.docker.compose.project"}}' "$_v" 2>/dev/null
      done | grep . | sort -u
}

default_dir() {
  if [ "$EVAL" = 1 ]; then echo "${HOME:-/root}/talos-eval"; return; fi
  # In the container HOME is the person's own folder (mounted), and the container is root —
  # /opt/talos would be inside the throw-away container.
  if [ "$IN_CONTAINER" = 1 ]; then echo "${HOME:-/root}/talos"; return; fi
  if [ "$(id -u)" = 0 ]; then echo /opt/talos; else echo "${HOME:-/root}/talos"; fi
}

# The trial's Compose project. Fixed, so a trial and a regular install can never share
# containers or volumes, and each side can recognise — and leave alone — the other.
EVAL_PROJECT=talos-eval

resolve_install() {
  step "Locating the install"
  MODE=fresh; PROJECT=""
  _found="$(existing_installs | while IFS='|' read -r _p _d; do
              _d="$(container_path "$_d")"
              printf '%s|%s\n' "$_p" "$(canonical_dir "$_d" 2>/dev/null || printf '%s' "$_d")"
            done)"
  _data="$(data_projects)"
  # A trial and a regular install never see each other: --eval looks only at the trial's
  # project, and a regular run never adopts, updates or worries about the trial.
  if [ "$EVAL" = 1 ]; then
    _found="$(printf '%s\n' "$_found" | awk -F'|' -v p="$EVAL_PROJECT" '$1 == p')"
    _data="$(printf '%s\n' "$_data" | grep -x "$EVAL_PROJECT" || true)"
  else
    _found="$(printf '%s\n' "$_found" | awk -F'|' -v p="$EVAL_PROJECT" 'NF && $1 != p')"
    _data="$(printf '%s\n' "$_data" | grep -vx "$EVAL_PROJECT" | grep . || true)"
  fi
  _count=$(printf '%s' "$_found" | grep -c . || true)

  if [ -z "$INSTALL_DIR" ]; then
    if [ "$_count" = 1 ]; then
      INSTALL_DIR="$(printf '%s' "$_found" | cut -d'|' -f2)"
      PROJECT="$(printf '%s' "$_found" | cut -d'|' -f1)"
      MODE=adopt
      say "  Found an existing Talos install in $(host_path "$INSTALL_DIR") (compose project \"$PROJECT\")."
      say "  It will be updated in place: same directory, same project name, same volumes."
    elif [ "$_count" -gt 1 ]; then
      say "  More than one Talos install was found on this machine:"
      printf '%s\n' "$_found" | awk -F'|' '{print "    - " $2 "   (project " $1 ")"}'
      die "choose one with --dir PATH."
    elif [ "$EVAL" != 1 ] && _rec="$(recorded_install)" && [ -n "$_rec" ]; then
      INSTALL_DIR="$_rec"
      say "  Found the Talos install recorded in $(host_path "$(install_record)"): $(host_path "$INSTALL_DIR")."
    else
      INSTALL_DIR="$(default_dir)"
    fi
  fi

  INSTALL_DIR="$(canonical_dir "$INSTALL_DIR")" || die "--dir must name a directory, not \".\" or \"..\" of a missing path."
  [ "$INSTALL_DIR" != / ] || die "refusing to install into /. Choose a directory with --dir."
  on_host_mount "$INSTALL_DIR" || die "$(host_path "$INSTALL_DIR") is outside the folders this installer can reach
(your user folder). Run it again with a folder inside your user folder, e.g. -Dir / --dir."
  ENV_FILE="$INSTALL_DIR/.env"
  COMPOSE_FILE="$INSTALL_DIR/docker-compose.yml"
  if [ -f "$ENV_FILE" ]; then HAVE_ENV=1; else HAVE_ENV=0; fi
  if [ "$HAVE_ENV" = 1 ] && [ ! -r "$ENV_FILE" ]; then
    die "$ENV_FILE is not readable by this user (the install was probably made as root). Re-run with sudo."
  fi
  ENV_PROJECT="$(env_get COMPOSE_PROJECT_NAME "$ENV_FILE")"

  # The .env says which kind of install a folder holds; never turn one into the other.
  _is_eval="$(env_get TALOS_EVAL "$ENV_FILE")"
  if [ "$EVAL" = 1 ] && [ "$HAVE_ENV" = 1 ] && [ "$_is_eval" != 1 ]; then
    die "$INSTALL_DIR holds a regular Talos install, not a trial. --eval never touches a
regular install; leave out --dir, or choose an empty folder."
  fi
  if [ "$EVAL" != 1 ] && [ "$_is_eval" = 1 ]; then
    die "$INSTALL_DIR holds a Talos TRIAL (--eval). Update it with --eval, or remove it with:
    sh $INSTALL_DIR/remove-talos-eval.sh"
  fi

  if [ "$MODE" != adopt ]; then
    _match="$(printf '%s' "$_found" | awk -F'|' -v d="$INSTALL_DIR" '$2 == d {print $1; exit}')"
    if [ -n "$_match" ]; then
      PROJECT="$_match"; MODE=adopt
      say "  $INSTALL_DIR is an existing Talos install (compose project \"$PROJECT\"); updating it in place."
    elif [ -n "$ENV_PROJECT" ]; then
      PROJECT="$ENV_PROJECT"
    else
      PROJECT="$(project_from_dir "$INSTALL_DIR")"
      [ -n "$PROJECT" ] || die "cannot derive a Compose project name from \"$(basename "$INSTALL_DIR")\". Use a directory name with letters or digits."
    fi
    [ "$HAVE_ENV" = 1 ] && MODE=update
  fi

  if [ "$EVAL" = 1 ]; then PROJECT="$EVAL_PROJECT"; fi
  DB_VOLUME="${PROJECT}_talos_postgres"
  if volume_exists "$DB_VOLUME"; then HAVE_DATA=1; else HAVE_DATA=0; fi

  # Another install already uses this project name from a DIFFERENT directory (same
  # directory basename elsewhere). Sharing it would make both installs share volumes.
  _clash="$(printf '%s' "$_found" | awk -F'|' -v p="$PROJECT" -v d="$INSTALL_DIR" '$1 == p && $2 != d {print $2; exit}')"
  if [ -n "$_clash" ]; then
    die "the Compose project name \"$PROJECT\" is already used by the Talos install in $_clash.
Two installs with one project name would share the same volumes. Use a directory with a
different name, or set COMPOSE_PROJECT_NAME in this install's .env."
  fi

  # Talos data exists for some OTHER project, and this run would start one with no data.
  # That is the signature of a moved or renamed install, or of a mistyped --dir: starting
  # here would quietly create a second, EMPTY Talos while the real data sits orphaned.
  # Allowed, because each is explicit intent: a brand-new install in a directory named with
  # --dir, and a .env that sets COMPOSE_PROJECT_NAME itself.
  _others="$(printf '%s\n' "$_data" | grep -vx "$PROJECT" | grep . || true)"
  if [ "$HAVE_DATA" = 0 ] && [ -n "$_others" ] && [ -z "$ENV_PROJECT" ]; then
    if [ "$HAVE_ENV" = 1 ] || [ -z "${TALOS_DIR:-}${DIR_GIVEN:-}" ]; then
      hostify >&2 <<EOF

${C_YLW}Stopping: existing Talos data belongs to a different Compose project.${C_RST}

This directory would run as project "$PROJECT", which has no database yet. Talos data
exists on this machine for:
$(printf '%s\n' "$_others" | sed 's/^/    - /')

The project name is normally the name of the directory an install was first started
from, so a moved or renamed install gets a new, EMPTY database under its new name.

  - To keep using that data from this directory, add this line to $ENV_FILE
    (create the file from your old .env if needed), then run the installer again:
        COMPOSE_PROJECT_NAME=<one of the projects above>
  - Or run again with --dir pointing at the original directory.
  - To create a separate, new install, use --dir with a NEW, empty directory.
EOF
      exit 1
    fi
  fi

  # Data without its .env: the database password and ENCRYPTION_KEY are gone, and every
  # stored credential is encrypted with that key. New secrets cannot open that data.
  if [ "$HAVE_DATA" = 1 ] && [ "$HAVE_ENV" = 0 ]; then
    hostify >&2 <<EOF

${C_RED}Stopping:${C_RST} the database volume "$DB_VOLUME" already exists, but there is no
.env in $INSTALL_DIR.

That volume holds a Talos database. Its password and the ENCRYPTION_KEY that protects
every stored credential were in that install's .env, and new secrets cannot open it.

  - If you have that .env: put it at $ENV_FILE and run this installer again.
  - If the install lives in another directory: run again with --dir pointing at it.
  - Nothing has been changed or deleted.
EOF
    exit 1
  fi

  say "  Directory:       $INSTALL_DIR"
  say "  Compose project: $PROJECT   (database volume: $DB_VOLUME$( [ "$HAVE_DATA" = 1 ] && printf ', exists' ))"

  # Defaults that depend on an existing .env
  OLD_FRONTEND_PORT="$(env_get FRONTEND_PORT "$ENV_FILE")"; OLD_BACKEND_PORT="$(env_get BACKEND_PORT "$ENV_FILE")"
  [ -n "$FRONTEND_PORT" ] || FRONTEND_PORT="${OLD_FRONTEND_PORT:-3000}"
  [ -n "$BACKEND_PORT" ]  || BACKEND_PORT="${OLD_BACKEND_PORT:-3001}"
  [ -n "$REGISTRY" ]      || REGISTRY="$(env_get TALOS_REGISTRY "$ENV_FILE")"
  [ -n "$REGISTRY" ]      || REGISTRY="$DEFAULT_REGISTRY"
  INSTALLED_VERSION=""
  if [ "$HAVE_ENV" = 1 ]; then
    INSTALLED_VERSION="$(env_get TALOS_VERSION "$ENV_FILE")"
    case "$INSTALLED_VERSION" in latest) INSTALLED_VERSION="" ;; esac
  fi
  [ -n "$VERSION" ] || VERSION="$INSTALLED_VERSION"
  # A re-run is also how a stopped Talos is started again, so it must not surprise anyone.
  # Without --version it only ever moves an install forwards, and the plan says so; an
  # installed version newer than the default, or not comparable with it, is kept as it is.
  VERSION_CHANGE=""
  if [ -n "$INSTALLED_VERSION" ] && [ -n "$VERSION" ] && [ "$VERSION" != "$INSTALLED_VERSION" ]; then
    _vc="$(version_cmp "$INSTALLED_VERSION" "$VERSION")"
    if [ "$VERSION_GIVEN" = 1 ]; then
      case "$_vc" in lt) VERSION_CHANGE=upgrade ;; gt) VERSION_CHANGE=downgrade ;; *) VERSION_CHANGE=change ;; esac
    elif [ "$_vc" = lt ]; then
      VERSION_CHANGE=upgrade
    else
      VERSION_CHANGE="kept $VERSION"; VERSION="$INSTALLED_VERSION"
    fi
  fi
  [ -n "$VERSION" ] || die "no version to install. Pass --version X.Y.Z (the latest version is on https://talosfleet.site/changelog)."

  # How a person must address this stack by hand. Plain `docker compose` in the directory
  # uses the .env's COMPOSE_PROJECT_NAME, or else the directory name; when that is not the
  # project actually in use, every printed command needs -p.
  _implied="${ENV_PROJECT:-$(project_from_dir "$INSTALL_DIR")}"
  if [ "$_implied" = "$PROJECT" ]; then DC="docker compose"; else DC="docker compose -p $PROJECT"; fi
}

# A changed docker-compose.yml must still put the database on the SAME volume. A file a
# person customised (bind mount, different volume name) would otherwise be replaced and
# Postgres restarted on a new, empty volume.
check_compose_compat() {
  # Runs whether or not $DB_VOLUME exists: a customised file may keep the data on a volume
  # with a different name, which is exactly the case where $DB_VOLUME does not exist.
  _cid="$(compose ps -aq postgres 2>/dev/null | head -n 1)"
  if [ -n "$_cid" ]; then
    _mnt="$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/lib/postgresql/data"}}{{.Type}}:{{.Name}}{{end}}{{end}}' "$_cid" 2>/dev/null)"
    if [ "$_mnt" = "volume:$DB_VOLUME" ]; then
      pass "the database lives on volume $DB_VOLUME, which the new compose file keeps"
      return 0
    fi
    die "the running database is stored on \"$_mnt\", not on the volume $DB_VOLUME that the
standard compose file uses. This install's docker-compose.yml was customised, and replacing it
would start Postgres on an empty volume. Update this install by hand instead; nothing was changed."
  fi
  if [ -f "$COMPOSE_FILE" ] && ! compose_file | cmp -s - "$COMPOSE_FILE"; then
    _cfg="$(compose -f docker-compose.yml config --no-interpolate 2>/dev/null)" || \
      die "cannot read the existing docker-compose.yml to check where its database lives; nothing was changed."
    if printf '%s\n' "$_cfg" | grep -q "name: ${DB_VOLUME}\$" && \
       printf '%s\n' "$_cfg" | grep -B3 'target: /var/lib/postgresql/data' | grep -q 'source: talos_postgres$'; then
      pass "the existing compose file keeps its database on volume $DB_VOLUME"
    else
      die "the existing docker-compose.yml does not store the database on the volume
$DB_VOLUME (it was customised). Replacing it would start Postgres on an empty volume. Update
this install by hand instead; nothing was changed."
    fi
  fi
}

# ─── preflight ───────────────────────────────────────────────────────────────

# Minimum and recommended sizes. Below the minimum the installer asks before continuing;
# between minimum and recommended it only advises.
MIN_CPUS=2;     REC_CPUS=4
MIN_MEM_MB=1900; REC_MEM_MB=3800    # 2 GB / 4 GB, with room for what the kernel reserves
MIN_DISK_GB=10; REC_DISK_GB=20

# below | low | ok — where $1 sits against minimum $2 and recommended $3.
resource_level() {
  if [ "$1" -lt "$2" ]; then echo below
  elif [ "$1" -lt "$3" ]; then echo low
  else echo ok
  fi
}

# What the Docker daemon can use (on Docker Desktop that is its VM, not the whole computer),
# falling back to this machine's own figures. Empty when unknown.
detect_cpus() {
  _n="$(docker info --format '{{.NCPU}}' 2>/dev/null)"
  case "$_n" in ''|*[!0-9]*) _n="$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null)" ;; esac
  case "$_n" in ''|*[!0-9]*) echo "" ;; *) echo "$_n" ;; esac
}
detect_mem_mb() {
  _b="$(docker info --format '{{.MemTotal}}' 2>/dev/null)"
  case "$_b" in
    ''|*[!0-9]*)
      if [ -r /proc/meminfo ]; then
        echo $(( $(sed -n 's/^MemTotal:[[:space:]]*\([0-9]*\).*/\1/p' /proc/meminfo) / 1024 ))
      fi ;;
    *) echo $(( _b / 1048576 )) ;;
  esac
}
# Free GB where the images go (Docker's data directory) — or the install directory when
# that is not visible from here (Docker Desktop keeps it inside its VM).
detect_disk() {
  _d="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null)"
  [ -n "$_d" ] && [ -d "$_d" ] || _d="$INSTALL_DIR"
  while [ ! -d "$_d" ] && [ "$_d" != / ]; do _d="$(dirname "$_d")"; done
  _kb="$(df -Pk "$_d" 2>/dev/null | awk 'NR==2 {print $4}')"
  case "$_kb" in ''|*[!0-9]*) echo "" ;; *) echo "$(( _kb / 1048576 )) $_d" ;; esac
}

check_resources() {
  BELOW=""
  _cpus="$(detect_cpus)"
  if [ -z "$_cpus" ]; then noev "CPU: could not count the CPUs"
  else case "$(resource_level "$_cpus" "$MIN_CPUS" "$REC_CPUS")" in
    below) warn "CPU: $_cpus. Talos needs at least $MIN_CPUS; $REC_CPUS are recommended."; BELOW="$BELOW CPU"
           [ "$IN_CONTAINER" = 1 ] && say "                 This is Docker Desktop's share: raise it in Docker Desktop -> Settings -> Resources." ;;
    low)   warn "CPU: $_cpus. Enough to run; $REC_CPUS are recommended." ;;
    ok)    pass "CPU: $_cpus" ;;
  esac; fi

  _mem="$(detect_mem_mb)"
  if [ -z "$_mem" ]; then noev "memory: could not measure it on this system"
  else case "$(resource_level "$_mem" "$MIN_MEM_MB" "$REC_MEM_MB")" in
    below) warn "memory: ${_mem} MB. Talos needs at least 2 GB; 4 GB is recommended."; BELOW="$BELOW memory"
           [ "$IN_CONTAINER" = 1 ] && say "                 This is Docker Desktop's memory, not the computer's: raise it in Docker Desktop -> Settings -> Resources." ;;
    low)   warn "memory: ${_mem} MB. Enough to run; 4 GB is recommended." ;;
    ok)    pass "memory: ${_mem} MB" ;;
  esac; fi

  _disk="$(detect_disk)"
  if [ -z "$_disk" ]; then noev "disk: could not measure free space"
  else
    _gb="${_disk%% *}"; _at="${_disk#* }"
    case "$(resource_level "$_gb" "$MIN_DISK_GB" "$REC_DISK_GB")" in
      below) warn "disk: ${_gb} GB free at $_at. Talos needs at least ${MIN_DISK_GB} GB; ${REC_DISK_GB} GB is recommended."; BELOW="$BELOW disk" ;;
      low)   warn "disk: ${_gb} GB free at $_at. Enough to start; ${REC_DISK_GB} GB is recommended." ;;
      ok)    pass "disk: ${_gb} GB free at $_at" ;;
    esac
  fi

  if [ -n "$BELOW" ]; then
    say ""
    say "  This machine is below the minimum for:$BELOW. Talos may be slow or fail to start."
    ask_yes_no "Continue with the installation anyway?" \
      || die "installation stopped at your request. Nothing was changed."
  fi
}

preflight() {
  step "Checking this machine"
  PREFLIGHT_FAILED=0

  # The images run on the DAEMON's architecture, which is not always this shell's
  # (a remote DOCKER_HOST, or an emulated terminal).
  _darch="$(docker info --format '{{.Architecture}}' 2>/dev/null)"
  if ! ARCH="$(arch_name "${_darch:-$(uname -m)}")"; then
    fail "CPU architecture ${_darch:-$(uname -m)} is not supported (amd64 or arm64 only)"
    die "unsupported CPU architecture."
  fi
  pass "CPU architecture: $ARCH${_darch:+ (Docker daemon)}"

  check_resources

  if [ -d "$INSTALL_DIR" ] && [ ! -w "$INSTALL_DIR" ]; then
    fail "$INSTALL_DIR is not writable by this user"; PREFLIGHT_FAILED=1
  elif [ "$HAVE_ENV" = 1 ] && [ ! -w "$ENV_FILE" ]; then
    fail "$ENV_FILE is not writable by this user (made as root?)"; PREFLIGHT_FAILED=1
  fi

  choose_port FRONTEND_PORT "web UI" "$OLD_FRONTEND_PORT" 3000 "$BACKEND_PORT" "API"
  choose_port BACKEND_PORT "API" "$OLD_BACKEND_PORT" 3001 "$FRONTEND_PORT" "web UI"

  check_image talos-backend
  check_image talos-frontend
  check_redis_cow

  [ "$PREFLIGHT_FAILED" = 0 ] || die "the checks above failed. Nothing was changed."
}

# Listening TCP ports on this machine, one "address" per line, or nothing if unknown.
listeners() {
  if command -v ss >/dev/null 2>&1; then
    ss -ltn 2>/dev/null | awk 'NR > 1 {print $4}'
  elif command -v netstat >/dev/null 2>&1; then
    # Linux prints LISTEN in column 6; macOS/BSD (`-p tcp`) too, with "*.3000" addresses.
    { netstat -ltn 2>/dev/null || netstat -an -p tcp 2>/dev/null; } | awk '$6 == "LISTEN" {print $4}'
  fi
}

# ok | nan | range — can $1 be used as a port number?
port_valid() {
  case "$1" in ''|*[!0-9]*) echo nan; return 0 ;; esac
  if [ "$1" -lt 1 ] || [ "$1" -gt 65535 ]; then echo range; else echo ok; fi
}

# Exit status: 0 = port $1 is in use, 1 = free, 2 = cannot tell (no ss/netstat output).
port_in_use() {
  if [ "$IN_CONTAINER" = 1 ]; then
    # 1. What the host itself reports — Windows/macOS programs Docker cannot see.
    case ",$HOST_BUSY_PORTS," in *",$1,"*) return 0 ;; esac
    # 2. Ask Docker Desktop to publish it for an instant: refused when a container has it.
    if docker run --rm -p "${TALOS_BIND:+$TALOS_BIND:}$1:9" --entrypoint true "$SELF_IMAGE" >/dev/null 2>&1; then
      return 1
    fi
    return 0
  fi
  _pl="$(listeners)"
  [ -n "$_pl" ] || return 2
  printf '%s\n' "$_pl" | grep -q "[:.]$1\$" && return 0
  return 1
}

# The first free port after $1, skipping every port listed in $2 (space-separated).
suggest_port() {
  _sp="$1"; _tries=0
  while [ "$_tries" -lt 500 ]; do
    _sp=$(( _sp + 1 )); [ "$_sp" -le 65535 ] || _sp=1024
    case " $2 " in *" $_sp "*) ;; *)
      port_in_use "$_sp"; [ $? = 1 ] && { echo "$_sp"; return 0; } ;;
    esac
    _tries=$(( _tries + 1 ))
  done
  return 1
}

# Choose the host port for one service. When the wanted port is taken, explain, suggest a
# free one, and ask (Enter takes the suggestion). Every answer is checked: a whole number,
# 1–65535, free, and not the port of the other service.
#   $1 variable (FRONTEND_PORT/BACKEND_PORT)  $2 label  $3 port this install already uses
#   $4 default  $5 the other service's port   $6 the other service's label
# 0 when a container of THIS install (its Compose project) publishes host port $1.
own_port() {
  [ -n "${PROJECT:-}" ] || return 1
  docker ps --filter "label=com.docker.compose.project=$PROJECT" --format '{{.Ports}}' 2>/dev/null \
    | tr ',' '\n' | grep -q ":$1->"
}

choose_port() {
  _var="$1"; _label="$2"; _old="${3:-$4}"; _other="$5"; _olabel="$6"
  _want=""; eval "_want=\$$_var"
  # Re-running on a RUNNING install: its own containers hold its ports. That used to count
  # only in adopt mode, so `--dir <the install>` (update mode) saw its own ports as taken and
  # silently moved Talos to new ones — changing the address everyone uses.
  if [ "$_want" = "$_old" ] && { [ "$MODE" = adopt ] || own_port "$_want"; }; then
    pass "port $_want ($_label): used by this install already"
    return 0
  fi
  port_in_use "$_want"; _st=$?
  if [ "$_st" = 2 ]; then
    noev "port $_want ($_label): could not list listening ports to check it"
    return 0
  fi
  if [ "$_st" = 1 ] && [ "$_want" != "$_other" ]; then
    pass "port $_want ($_label) is free"
    return 0
  fi

  if [ "$_want" = "$_other" ]; then
    warn "port $_want is already chosen for the $_olabel."
  else
    warn "port $_want ($_label) is already in use by another program."
  fi
  _sug="$(suggest_port "$_want" "$_other")" || die "no free port found near $_want. Free one, or pass --frontend-port / --backend-port."

  if [ "$ASSUME_YES" = 1 ]; then
    say "  Using port $_sug for the $_label instead (--yes)."
    eval "$_var=\$_sug"; PORTS_GIVEN=1
    return 0
  fi
  while :; do
    ask_line "  Port for the $_label (press Enter for $_sug): " \
      || die "no terminal to choose a port. Re-run with --yes to use $_sug, or pass --frontend-port / --backend-port."
    [ -n "$ANSWER" ] || ANSWER="$_sug"
    case "$(port_valid "$ANSWER")" in
      nan)   say "  \"$ANSWER\" is not a port number. Enter a whole number, for example $_sug."; continue ;;
      range) say "  Ports go from 1 to 65535."; continue ;;
    esac
    if [ "$ANSWER" = "$_other" ]; then
      say "  Port $ANSWER is already chosen for the $_olabel. Choose a different one."; continue
    fi
    if port_in_use "$ANSWER"; then
      say "  Port $ANSWER is in use too. Choose another, or press Enter for $_sug."; continue
    fi
    if [ "$ANSWER" -lt 1024 ]; then
      warn "port $ANSWER is below 1024. Those are usually kept for system services (80 and 443 for web servers); make sure nothing else needs it."
    fi
    break
  done
  eval "$_var=\$ANSWER"; PORTS_GIVEN=1
  pass "port $ANSWER ($_label)"
}

# Does the tag exist, and does it include this machine's architecture? A tag pushed for
# one architecture fails much later with "no matching manifest", so it is checked first.
check_image() {
  _img="$REGISTRY/$1:$VERSION"
  _out="$(docker manifest inspect "$_img" 2>&1)"; _rc=$?
  # Against an HTTP registry, `docker manifest inspect` prints "no such manifest" even for
  # a tag that EXISTS — the same text as a genuinely missing tag (verified). Ask once more
  # with --insecure (read-only: it only fetches the manifest) before calling it missing.
  case "$_out" in *"no such manifest"*)
    if _out2="$(docker manifest inspect --insecure "$_img" 2>&1)"; then _out="$_out2"; _rc=0; fi ;;
  esac
  if [ "$_rc" != 0 ]; then
    case "$_out" in
      *"no such manifest"*|*"manifest unknown"*|*"not found"*)
        fail "image $_img does not exist (check --version)"; PREFLIGHT_FAILED=1 ;;
      *)
        noev "image $_img: could not query the registry ($(printf '%s' "$_out" | head -n 1 | cut -c1-70))" ;;
    esac
    return 0
  fi
  if printf '%s' "$_out" | grep -q '"manifests"'; then
    if printf '%s' "$_out" | grep -q "\"architecture\": *\"$ARCH\""; then
      pass "image $_img includes $ARCH"
    else
      fail "image $_img has no $ARCH build. This version cannot run on this machine."
      PREFLIGHT_FAILED=1
    fi
  else
    warn "image $_img is a single-architecture image; it may not match $ARCH"
  fi
}

# ─── files ───────────────────────────────────────────────────────────────────

write_compose() {
  _new="$INSTALL_DIR/.docker-compose.yml.new.$$"
  compose_file > "$_new" || die "could not write $_new"
  if [ -f "$COMPOSE_FILE" ] && cmp -s "$_new" "$COMPOSE_FILE"; then
    rm -f "$_new"
    pass "docker-compose.yml is already current"
    return 0
  fi
  if [ -f "$COMPOSE_FILE" ]; then
    _bak="$COMPOSE_FILE.bak.$(timestamp)"
    cp -p "$COMPOSE_FILE" "$_bak" || die "could not back up $COMPOSE_FILE"
    say "  Backed up the previous docker-compose.yml to $(basename "$_bak")"
  fi
  mv "$_new" "$COMPOSE_FILE" || die "could not write $COMPOSE_FILE"
  pass "wrote docker-compose.yml"
}

write_new_env() {
  _pg="$(gen_hex 24)"; _jwt="$(gen_hex 32)"; _enc="$(gen_hex 24)"; _setup="$(gen_hex 16)"
  if [ ${#_pg} -ne 48 ] || [ ${#_jwt} -ne 64 ] || [ ${#_enc} -ne 48 ] || [ ${#_setup} -ne 32 ]; then
    die "could not generate random secrets."
  fi
  ( umask 077
    cat > "$ENV_FILE" <<EOF
# Talos — written by install.sh ${INSTALLER_VERSION} on $(date -u '+%Y-%m-%d %H:%M UTC')
#
# ⚠ BACK UP THIS FILE together with your database backups. ⚠
# ENCRYPTION_KEY protects every stored SSH key, cloud credential and database password,
# and it can never be changed or recovered. A database backup without it is unreadable.

# Keeps the volume names stable (<project>_talos_postgres, ...) even if this directory
# is renamed. Do not change it on an existing install: a new name means new, empty volumes.
COMPOSE_PROJECT_NAME=${PROJECT}

# Generated secrets (hex). Never change POSTGRES_PASSWORD or ENCRYPTION_KEY after the
# first start: the database keeps its original password, and existing credentials can
# only be decrypted with the original key.
POSTGRES_PASSWORD=${_pg}
JWT_SECRET=${_jwt}
ENCRYPTION_KEY=${_enc}

# Needed ONCE, to create the first account, so nobody who reaches this install before you
# can make themselves its administrator. Generated once and never changed by the installer;
# it does nothing after the first account exists.
SETUP_TOKEN=${_setup}

# The version to run. Re-run the installer with --version to upgrade.
TALOS_VERSION=${VERSION}
TALOS_REGISTRY=${REGISTRY}

# Where users reach Talos (used in invitation and password-reset email links).
FRONTEND_URL=${PUBLIC_URL}
FRONTEND_PORT=${FRONTEND_PORT}
BACKEND_PORT=${BACKEND_PORT}

# Optional settings (email, Google/GitHub sign-in, multiple organizations):
# see https://talosfleet.site/docs/configuration
EOF
  ) || die "could not write $ENV_FILE"
  [ "$EVAL" = 1 ] && { eval_env_lines >> "$ENV_FILE" || die "could not write $ENV_FILE"; }
  for _key in POSTGRES_PASSWORD JWT_SECRET ENCRYPTION_KEY SETUP_TOKEN TALOS_VERSION; do
    [ -n "$(env_get "$_key" "$ENV_FILE")" ] || die "could not write $_key to $ENV_FILE"
  done
  pass "wrote .env with newly generated secrets (permissions 600)"
}

# The trial's settings. TALOS_BIND and SETUP_TOKEN_REQUIRED go together, always: no setup
# token is safe ONLY because nobody but this computer can reach the page it would guard.
eval_env_lines() {
  cat <<'EOF'

# ── Trial install (--eval) ──
# Only this computer can open Talos (127.0.0.1), so the first account needs no setup
# token. Remove everything with remove-talos-eval.sh in this folder.
TALOS_EVAL=1
TALOS_BIND=127.0.0.1
SETUP_TOKEN_REQUIRED=false
EOF
}

update_env() {
  _work="$INSTALL_DIR/.env.work.$$"
  cp -p "$ENV_FILE" "$_work" || die "could not copy $ENV_FILE"

  # Secrets: fill only what is missing, and never one that protects existing data.
  for _key in POSTGRES_PASSWORD ENCRYPTION_KEY JWT_SECRET; do
    [ -n "$(env_get "$_key" "$_work")" ] && continue
    if [ "$HAVE_DATA" = 1 ] && [ "$_key" != JWT_SECRET ]; then
      rm -f "$_work"
      die "$_key is empty in $ENV_FILE, but the database volume already exists. A new value
cannot open existing data. Restore the original value into .env and run again."
    fi
    case "$_key" in JWT_SECRET) _len=32 ;; *) _len=24 ;; esac
    env_set "$_key" "$(gen_hex $_len)" "$_work"
    say "  Generated the missing $_key (hex)."
  done

  # The setup token: generated only when missing, never replaced — an owner who comes back
  # weeks later must find the same value that was shown to them. Harmless on an install
  # that already has its first account: the backend ignores it from then on.
  if [ -z "$(env_get SETUP_TOKEN "$_work")" ]; then
    env_set SETUP_TOKEN "$(gen_hex 16)" "$_work"
    say "  Generated a SETUP_TOKEN (needed only to create the first account)."
  fi

  # Settings: only the ones this run was asked to set. COMPOSE_PROJECT_NAME is never
  # added to an existing install — switching project name means switching volumes.
  [ "$(env_get TALOS_VERSION "$_work")" = "$VERSION" ] || env_set TALOS_VERSION "$VERSION" "$_work"
  _old_reg="$(env_get TALOS_REGISTRY "$_work")"
  if [ "$REGISTRY" != "${_old_reg:-$DEFAULT_REGISTRY}" ]; then env_set TALOS_REGISTRY "$REGISTRY" "$_work"; fi
  if [ -n "$URL_GIVEN" ] && [ "$(env_get FRONTEND_URL "$_work")" != "$PUBLIC_URL" ]; then
    env_set FRONTEND_URL "$PUBLIC_URL" "$_work"
  fi
  if [ "$PORTS_GIVEN" = 1 ]; then
    [ "$(env_get FRONTEND_PORT "$_work")" = "$FRONTEND_PORT" ] || env_set FRONTEND_PORT "$FRONTEND_PORT" "$_work"
    [ "$(env_get BACKEND_PORT "$_work")" = "$BACKEND_PORT" ]   || env_set BACKEND_PORT "$BACKEND_PORT" "$_work"
  fi

  # A trial keeps its three settings, whatever an older installer wrote.
  if [ "$EVAL" = 1 ]; then
    for _kv in TALOS_EVAL=1 TALOS_BIND=127.0.0.1 SETUP_TOKEN_REQUIRED=false; do
      [ "$(env_get "${_kv%%=*}" "$_work")" = "${_kv#*=}" ] || env_set "${_kv%%=*}" "${_kv#*=}" "$_work"
    done
  fi

  if cmp -s "$_work" "$ENV_FILE"; then
    rm -f "$_work"
    pass ".env is already current (secrets kept)"
  else
    ENV_BACKUP="$ENV_FILE.bak.$(timestamp)"
    cp -p "$ENV_FILE" "$ENV_BACKUP" || die "could not back up $ENV_FILE"
    cat "$_work" > "$ENV_FILE" || die "could not write $ENV_FILE"
    rm -f "$_work"
    chmod 600 "$ENV_BACKUP" "$ENV_FILE" 2>/dev/null || true
    pass "updated .env (secrets kept; previous copy: $(basename "$ENV_BACKUP"))"
  fi
}

# ─── start + wait ────────────────────────────────────────────────────────────

start_stack() {
  step "Starting Talos $VERSION"
  if ! _out="$(compose up -d 2>&1)"; then
    printf '%s\n' "$_out" | tail -n 20
    _restore=""
    [ -z "${ENV_BACKUP:-}" ] || _restore="
To go back to the previous version:  cd $INSTALL_DIR && cp $(basename "$ENV_BACKUP") .env && $DC up -d"
    case "$_out" in
      *"no matching manifest"*)
        die "version $VERSION has no image for this machine's architecture ($ARCH).$_restore" ;;
      *"manifest unknown"*|*"not found"*)
        die "version $VERSION was not found in the registry ($REGISTRY). Check --version.$_restore" ;;
      *"port is already allocated"*|*"address already in use"*)
        die "a port is already in use. Choose others with --frontend-port / --backend-port.$_restore" ;;
      *) die "docker compose up failed (output above).$_restore" ;;
    esac
  fi
  pass "containers started"
}

backend_state() {
  _cid="$(compose ps -a -q backend 2>/dev/null | head -n 1)"
  [ -n "$_cid" ] || { echo "missing 0 none"; return 0; }
  docker inspect -f '{{.State.Status}} {{.RestartCount}} {{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$_cid" 2>/dev/null \
    || echo "missing 0 none"
}

wait_healthy() {
  step "Waiting for the first start (migrations and setup can take a few minutes)"
  _start=$(date +%s); _last=""
  while :; do
    _st="$(backend_state)"
    _status="$(echo "$_st" | cut -d' ' -f1)"
    _restarts="$(echo "$_st" | cut -d' ' -f2)"
    _health="$(echo "$_st" | cut -d' ' -f3)"
    if [ "$_health" = healthy ]; then
      pass "the backend is healthy ($(( $(date +%s) - _start ))s)"
      return 0
    fi
    if [ "$_restarts" -gt 0 ] || [ "$_status" = exited ] || [ "$_status" = dead ]; then
      fail "the backend stopped during start-up (restarts: $_restarts). Its last output:"
      say ""
      compose logs --no-color --tail 40 backend 2>&1 | sed 's/^[^|]*| //'
      say ""
      die "Talos did not start. Fix the problem above, then run this installer again (your .env is kept)."
    fi
    if [ $(( $(date +%s) - _start )) -ge "$TIMEOUT" ]; then
      die "the backend was not healthy after ${TIMEOUT}s (state: $_status, health: $_health).
Look at:  cd $INSTALL_DIR && $DC logs backend"
    fi
    _line="$(compose logs --no-color --tail 1 backend 2>/dev/null | sed 's/^[^|]*| //' | cut -c1-90)"
    if [ -n "$_line" ] && [ "$_line" != "$_last" ]; then say "  ${C_DIM}$_line${C_RST}"; _last="$_line"; fi
    sleep 5
  done
}

# The backend being healthy is not the same as a person being able to use Talos: the web
# UI must serve, and its /api proxy must reach the backend. Checked from inside the
# frontend container, so it works whatever tools the host has.
check_reachable() {
  _i=0
  while [ $_i -lt 24 ]; do
    if compose exec -T frontend wget -qO- http://127.0.0.1:3000/api/health >/dev/null 2>&1; then
      pass "the web UI is serving and reaches the API"
      return 0
    fi
    _i=$(( _i + 1 )); sleep 5
  done
  fail "the web UI did not answer within 2 minutes. Its last output:"
  compose logs --no-color --tail 30 frontend 2>&1 | sed 's/^[^|]*| //'
  die "Talos is not usable yet. Look at:  cd $INSTALL_DIR && $DC logs frontend"
}

# The check above runs INSIDE the frontend container. A person reaches Talos through the
# ports published on this machine, which a firewall, another program or a wrong mapping
# can break — so the same links are tried from here too, through those ports.
check_host_links() {
  if command -v curl >/dev/null 2>&1; then _get="curl -fsS -o /dev/null --max-time 5"
  elif command -v wget >/dev/null 2>&1; then _get="wget -q -O /dev/null -T 5"
  else noev "links not tested from this machine (neither curl nor wget is installed)"; return 0
  fi
  _bad=""
  # In the container 127.0.0.1 is the container itself. host.docker.internal is the computer
  # Docker runs on: Docker Desktop defines it, and the wrappers add it everywhere else
  # (--add-host host.docker.internal:host-gateway) — Colima and plain Docker have no such name.
  _h=127.0.0.1; [ "$IN_CONTAINER" = 1 ] && _h=host.docker.internal
  for _u in "http://$_h:${FRONTEND_PORT}/" "http://$_h:${FRONTEND_PORT}/api/health" "http://$_h:${BACKEND_PORT}/api/health"; do
    _i=0; _okl=0
    while [ $_i -lt 12 ]; do
      # shellcheck disable=SC2086  # $_get is a command and its flags
      if $_get "$_u" 2>/dev/null; then _okl=1; break; fi
      _i=$(( _i + 1 )); sleep 5
    done
    # Container mode, second way in: from Docker's own host network, at 127.0.0.1. That is
    # where a 127.0.0.1-bound trial listens on Colima or plain Linux Docker, which
    # host.docker.internal (the host-gateway address) does not reach.
    if [ "$_okl" = 0 ] && [ "$IN_CONTAINER" = 1 ]; then
      _lu="http://127.0.0.1:${_u#http://*:}"
      docker run --rm --network host --entrypoint wget "$SELF_IMAGE" -q -O /dev/null -T 5 "$_lu" >/dev/null 2>&1 \
        && { _okl=1; _u="$_lu (Docker's host network)"; }
    fi
    if [ "$_okl" = 1 ]; then pass "$_u answers"; else fail "$_u does not answer"; _bad="$_bad $_u"; fi
  done
  [ -z "$_bad" ] || die "Talos is running, but these links do not answer from this machine:$_bad
A firewall, or another program on the same port, can cause this. Look at:  cd $INSTALL_DIR && $DC ps"
}

# Whether the install still needs its setup token: 0 = yes (no account yet), 1 = no
# (claimed, or an image older than the setup token), 2 = could not tell. Asked of the backend
# through the frontend, the same path a browser uses.
setup_token_needed() {
  _st="$(compose exec -T frontend wget -qO- http://127.0.0.1:3000/api/setup/status 2>/dev/null)" || return 2
  case "$_st" in
    *'"setupTokenRequired":true'*)  return 0 ;;
    *'"setupTokenRequired":false'*) return 1 ;;
    *'"initialized"'*)              return 1 ;;  # an older image: it has no setup token
    *)                              return 2 ;;
  esac
}

# This machine's network addresses (IPv4), for links other computers can open.
machine_ips() {
  { hostname -I 2>/dev/null \
    || ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 \
    || ipconfig getifaddr en0 2>/dev/null; } | tr ' ' '\n' | grep -E '^[0-9]+(\.[0-9]+){3}$' | grep -v '^127\.' | head -n 3
}

# ─── main ────────────────────────────────────────────────────────────────────

# The plan line for a folder that already holds an install: start, upgrade or downgrade.
plan_existing() {
  _where="the install in $(host_path "$INSTALL_DIR")"
  [ "$MODE" = adopt ] && _where="the existing install in $(host_path "$INSTALL_DIR") (project \"$PROJECT\")"
  case "$VERSION_CHANGE" in
    upgrade)
      say "  UPGRADE $_where from Talos $INSTALLED_VERSION to $VERSION (data and secrets kept)."
      if [ "$VERSION_GIVEN" != 1 ]; then
        _pin="--version $INSTALLED_VERSION"
        [ "$HOST_OS" = windows ] && _pin="\$env:TALOS_VERSION = \"$INSTALLED_VERSION\" before the command"
        say "  To start it again WITHOUT upgrading, answer no and run again with $_pin."
      fi ;;
    downgrade)
      say "  DOWNGRADE $_where from Talos $INSTALLED_VERSION to $VERSION (data and secrets kept)."
      warn "$INSTALLED_VERSION may already have changed the database in ways $VERSION cannot read,"
      say  "                 and nothing undoes them. Back up the database before you continue." ;;
    change)
      say "  Change $_where from Talos $INSTALLED_VERSION to $VERSION (data and secrets kept)." ;;
    kept\ *)
      say "  Start $_where: Talos $VERSION, as installed (data and secrets kept)."
      say "  Not changed to this installer's default ${VERSION_CHANGE#kept }: without --version a re-run"
      say "  never downgrades, nor changes a version it cannot compare." ;;
    *)
      if [ -n "$INSTALLED_VERSION" ]; then
        say "  Start $_where: Talos $VERSION, already installed (data and secrets kept)."
      else
        say "  Update $_where to $VERSION (existing .env and secrets kept)."
      fi ;;
  esac
}

main() {
  say "${C_BLD}Talos installer ${INSTALLER_VERSION}${C_RST}"
  DIR_GIVEN=""
  for _a in "$@"; do case "$_a" in --dir|--dir=*) DIR_GIVEN=1 ;; esac; done
  parse_args "$@"

  # macOS: Docker Desktop. Hand over to this same installer inside the Docker CLI container.
  if [ "$(uname -s)" = Darwin ] && [ "$IN_CONTAINER" != 1 ]; then desktop_reexec "$@"; fi

  ensure_docker
  resolve_install
  preflight

  URL_GIVEN="$PUBLIC_URL"
  [ -n "$PUBLIC_URL" ] || PUBLIC_URL="$(env_get FRONTEND_URL "$ENV_FILE")"
  [ -n "$PUBLIC_URL" ] || PUBLIC_URL="http://localhost:${FRONTEND_PORT}"

  step "Plan"
  case "$MODE" in
    fresh)  if [ "$EVAL" = 1 ]; then
              say "  Install a Talos $VERSION TRIAL into $(host_path "$INSTALL_DIR"): only this computer can open it,"
              say "  no setup token, and one command removes it all again."
            else
              say "  Install Talos $VERSION into $(host_path "$INSTALL_DIR") (new install, secrets will be generated)."
            fi ;;
    update|adopt) plan_existing ;;
  esac
  say "  Web UI address: $PUBLIC_URL"
  case "$PUBLIC_URL" in
    *://localhost*|*://127.0.0.1*)
      if [ -n "${SSH_CONNECTION:-}${SSH_CLIENT:-}" ]; then
        _ips="$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -v ':' | grep . | head -n 3 | tr '\n' ' ')"
        warn "you are on a remote machine, but the address is localhost. Links in invitation"
        say  "                 and password-reset emails will point at localhost. Re-run with"
        say  "                 --url http://${_ips%% *}:${FRONTEND_PORT} (or your domain) to fix that.${_ips:+ This machine: $_ips}"
      fi ;;
  esac

  if [ "$MODE" != fresh ]; then
    check_compose_compat
  fi

  if [ "$DRY_RUN" = 1 ]; then
    say ""; say "Dry run: nothing was changed."; exit 0
  fi
  confirm "Continue?"

  step "Writing files"
  mkdir -p "$INSTALL_DIR" || die "cannot create $INSTALL_DIR (try sudo, or --dir in your home directory)."
  [ -w "$INSTALL_DIR" ] || die "$INSTALL_DIR is not writable by this user (made as root?). Re-run with sudo."
  write_compose
  if [ "$HAVE_ENV" = 1 ]; then update_env; else write_new_env; fi
  if [ "$EVAL" = 1 ]; then write_remove_script; else remember_install; fi

  if [ "$NO_START" = 1 ]; then
    say ""; say "Files written. Start Talos with:  cd $INSTALL_DIR && $DC up -d"
    exit 0
  fi

  start_stack
  wait_healthy
  check_reachable
  step "Testing the links from this machine"
  check_host_links

  if [ "$EVAL" = 1 ]; then
    cat <<EOF

${C_GRN}${C_BLD}Talos $VERSION (trial) is running — only this computer can open it.${C_RST}

  Open:        http://localhost:${FRONTEND_PORT}
               First visit: choose "Create a workspace". A trial needs no setup token.
  Folder:      $(host_path "$INSTALL_DIR")

  Remove everything (containers, data, this folder; asks about the images):
               $(remove_command)

  This is a trial. To use Talos for real, install it without --eval.
EOF
    return 0
  fi

  _links="  On this machine:  http://localhost:${FRONTEND_PORT}"
  if [ "$IN_CONTAINER" = 1 ]; then
    # The container's own addresses mean nothing to the person; say how to find the right one.
    _links="$_links
  From your network: http://<this computer's IP address>:${FRONTEND_PORT}
                     (the first time, $( [ "$HOST_OS" = windows ] && echo "Windows" || echo "macOS" ) may ask to allow Docker through its firewall)"
  else
    for _ip in $(machine_ips); do _links="$_links
  From your network: http://${_ip}:${FRONTEND_PORT}"; done
  fi

  # The setup token goes right next to the link, because that is where the owner looks.
  _token="$(env_get SETUP_TOKEN "$ENV_FILE")"
  _first=""
  if setup_token_needed; then _need=0; else _need=$?; fi
  if [ "$_need" = 0 ] || { [ "$_need" = 2 ] && [ -n "$_token" ]; }; then
    _first="
  ${C_BLD}Setup token: ${_token}${C_RST}
               Needed once: on your first visit choose \"Create the first one\" and enter it.
               It is also saved as SETUP_TOKEN in $ENV_FILE."
    [ "$_need" = 2 ] && _first="$_first
               (Could not check whether an account already exists; if one does, ignore this.)"
  fi

  cat <<EOF

${C_GRN}${C_BLD}Talos $VERSION is installed and running.${C_RST}

  Open:        $PUBLIC_URL
$_links
$_first
  Install dir: $(host_path "$INSTALL_DIR")

  ${C_YLW}Back up $(host_path "$ENV_FILE") now.${C_RST} Its ENCRYPTION_KEY cannot be recovered, and your
  database backups are unreadable without it.

  Logs:        $(cmd_in_dir "$INSTALL_DIR" "docker compose logs -f backend")
  Stop:        $(cmd_in_dir "$INSTALL_DIR" "docker compose down")        (keeps your data)
  Upgrade:     $(upgrade_command)
EOF

  if [ "$MOTD" = 1 ]; then
    if record_result ok && install_login_message; then
      say "  The result is also shown at every login (remove $PROFILE_SCRIPT to stop it)."
    else
      warn "could not write the login message ($PROFILE_SCRIPT); everything else is done."
    fi
  fi
}

# How to remove the trial / upgrade, in the person's own shell.
remove_command() {
  if [ "$IN_CONTAINER" = 1 ] && [ "$HOST_OS" = windows ]; then
    printf 'powershell -ExecutionPolicy Bypass -File "%s"\n' "$(host_path "$INSTALL_DIR/remove-talos-eval.ps1")"
  else
    printf 'sh %s\n' "$INSTALL_DIR/remove-talos-eval.sh"
  fi
}
upgrade_command() {
  if [ "$IN_CONTAINER" = 1 ] && [ "$HOST_OS" = windows ]; then
    printf '%s\n' '$env:TALOS_VERSION="<newer version>"; irm https://talosfleet.site/install.ps1 | iex'
  else
    printf '%s\n' 'sh install.sh --version <newer version>'
  fi
}

# Shell-quote $1 for a generated script: 'it'"'"'s' style.
shell_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }

# remove-talos-eval.sh: knows only this trial's folder and project, so it cannot remove
# anything else — and it refuses unless the folder's .env says TALOS_EVAL=1.
write_remove_script() {
  _rm="$INSTALL_DIR/remove-talos-eval.sh"
  {
    printf '#!/bin/sh\n'
    printf 'DIR=%s\n' "$(shell_quote "$INSTALL_DIR")"
    printf 'PROJECT=%s\n' "$(shell_quote "$PROJECT")"
    remove_script_body
  } > "$_rm" && chmod 755 "$_rm" || die "could not write $_rm"
  pass "wrote remove-talos-eval.sh"
  # Windows has no sh: a .ps1 that runs the same script in the same kind of container.
  if [ "$IN_CONTAINER" = 1 ] && [ "$HOST_OS" = windows ]; then
    _ps="$INSTALL_DIR/remove-talos-eval.ps1"
    {
      printf '%s\r\n' '# Removes the Talos trial in this folder: runs remove-talos-eval.sh (next to this file)'
      printf '%s\r\n' '# in a Docker CLI container, the same way the installer ran. Pass -Yes to skip questions.'
      printf '%s\r\n' 'param([switch]$Yes)'
      printf '%s\r\n' "\$hostHome = '$(host_path "$HOME" | sed "s/'/''/g")'"
      printf '%s\r\n' "\$boxHome = '$(printf '%s' "$HOME" | sed "s/'/''/g")'"
      printf '%s\r\n' "\$script = '$(printf '%s' "$_rm" | sed "s/'/''/g")'"
      printf '%s\r\n' '$tty = if ([Console]::IsInputRedirected) { "-i" } else { "-it" }'
      printf '%s\r\n' '$extra = @(); if ($Yes) { $extra += "--yes" }'
      printf '%s\r\n' "docker run --rm \$tty -v //var/run/docker.sock:/var/run/docker.sock -v \"\${hostHome}:\${boxHome}\" $SELF_IMAGE sh \$script @extra"
      printf '%s\r\n' 'exit $LASTEXITCODE'
    } > "$_ps" || die "could not write $_ps"
    pass "wrote remove-talos-eval.ps1"
  fi
}

remove_script_body() {
  cat <<'TALOS_REMOVE_EOF'
# Talos TRIAL (--eval): removes everything this trial created, and nothing else — its
# containers, data volumes and network (all labelled with its Compose project), and its
# folder. The downloaded images are kept unless you say yes: a regular Talos install on
# this computer may use the same ones.
#
#   sh remove-talos-eval.sh          asks first (Enter = No)
#   sh remove-talos-eval.sh --yes    removes everything, images included, without asking
set -u
YES=0
case "${1:-}" in -y|--yes) YES=1 ;; "") ;; *) echo "usage: sh remove-talos-eval.sh [--yes]"; exit 2 ;; esac

ask() {
  [ "$YES" = 1 ] && return 0
  printf '%s [y/N] ' "$1"
  read -r _a < /dev/tty 2>/dev/null || { echo; return 1; }
  case "$_a" in y|Y|yes|YES|Yes) return 0 ;; *) return 1 ;; esac
}

# Refuse unless this really is the trial: the project name AND the folder's own marker.
[ "$PROJECT" = talos-eval ] || { echo "Refusing: "$PROJECT" is not the Talos trial project."; exit 1; }
_mark="$(sed -n 's/^[[:space:]]*TALOS_EVAL[[:space:]]*=[[:space:]]*//p' "$DIR/.env" 2>/dev/null | tr -d '\r"'"'" | tail -n 1)"
[ "$_mark" = 1 ] || { echo "Refusing: $DIR/.env does not say TALOS_EVAL=1, so this is not a Talos trial."; exit 1; }

if docker info >/dev/null 2>&1; then D=docker
elif command -v sudo >/dev/null 2>&1 && sudo docker info >/dev/null 2>&1; then D="sudo docker"
else echo "Docker is not running or not reachable; start it and run this again."; exit 1; fi

echo "This removes the Talos trial in $DIR:"
echo "  its containers, its data (volumes ${PROJECT}_*), its network, and the folder."
ask "Remove it?" || { echo "Nothing was removed."; exit 0; }

_images="$($D ps -a --filter "label=com.docker.compose.project=$PROJECT" --format '{{.Image}}' 2>/dev/null | sort -u)"
[ -f "$DIR/docker-compose.yml" ] && (cd "$DIR" && $D compose -p "$PROJECT" down --volumes --remove-orphans)
# Whatever compose missed (a deleted compose file, a half-finished install): by label.
for _c in $($D ps -aq --filter "label=com.docker.compose.project=$PROJECT"); do $D rm -f "$_c" >/dev/null; done
for _v in $($D volume ls -q --filter "label=com.docker.compose.project=$PROJECT"); do $D volume rm "$_v" >/dev/null; done
for _n in $($D network ls -q --filter "label=com.docker.compose.project=$PROJECT"); do $D network rm "$_n" >/dev/null; done

if [ -n "$_images" ] && ask "Also remove the downloaded Talos images (about 1-2 GB)?"; then
  for _i in $_images; do
    if $D rmi "$_i" >/dev/null 2>&1; then echo "  removed image $_i"; else echo "  kept image $_i (still used by another container)"; fi
  done
fi

cd / && { rm -rf "$DIR" 2>/dev/null || { command -v sudo >/dev/null 2>&1 && sudo rm -rf "$DIR"; }; }

_left="$($D ps -aq --filter "label=com.docker.compose.project=$PROJECT")$($D volume ls -q --filter "label=com.docker.compose.project=$PROJECT")$($D network ls -q --filter "label=com.docker.compose.project=$PROJECT")"
if [ -z "$_left" ] && [ ! -e "$DIR" ]; then
  echo "Done. Nothing of the Talos trial is left."
else
  echo "Not everything could be removed:"
  [ -e "$DIR" ] && echo "  the folder $DIR"
  [ -n "$_left" ] && echo "  Docker objects labelled com.docker.compose.project=$PROJECT"
  exit 1
fi
TALOS_REMOVE_EOF
}

# The compose file, embedded so the installer is one self-contained file. It must stay
# byte-identical to deploy/self-hosted/docker-compose.yml: run embed-compose.sh after
# editing that file. deploy/self-hosted/tests/test-installer.sh fails until they match.
compose_file() {
  # shellcheck disable=SC2016  # the compose file's ${VARS} are for Compose, not this shell
  cat <<'TALOS_COMPOSE_EOF'
# Talos — self-hosted deployment
#
# This is the file we ship to customers. It runs PRE-BUILT images from a registry, so
# there is no source checkout, no build step, and no toolchain to install.
#
#   1. cp .env.example .env
#   2. follow the three instructions in .env
#   3. docker compose up -d
#   4. open http://localhost:3000
#
# Deliberately NOT the repository's root docker-compose.yml. That one builds from source
# and exposes every SaaS, billing and platform knob — handing it to a customer is handing
# them ninety lines of configuration to be confused by. This file has three variables.
#
# DEPLOYMENT_MODE is absent on purpose: it is baked into the image
# (TALOS_DISTRIBUTION=self-hosted), which also pins the mode and makes weak secrets fatal
# rather than a warning. See docs/deployment/MODES_DATABASES_AND_DISTRIBUTION.md.

services:
  postgres:
    # pgvector, not plain postgres:16 — the AI knowledge base needs the `vector`
    # extension. Drop-in replacement, same data directory.
    image: pgvector/pgvector:pg16
    restart: unless-stopped
    environment:
      POSTGRES_DB: talos
      POSTGRES_USER: talos
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
    volumes:
      - talos_postgres:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U talos -d talos']
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - talos_redis:/data
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
      interval: 10s
      timeout: 5s
      retries: 5

  backend:
    image: ${TALOS_REGISTRY:-talosfleet}/talos-backend:${TALOS_VERSION:-latest}
    restart: unless-stopped
    ports:
      - '${TALOS_BIND:+${TALOS_BIND:-}:}${BACKEND_PORT:-3001}:3001'
    environment:
      DATABASE_URL: postgresql://talos:${POSTGRES_PASSWORD}@postgres:5432/talos?schema=public
      # `redis`, not `localhost` — inside the compose network the hostname is the service
      # name. A stale `localhost` here makes the backend hang at boot and login fail with
      # an empty response, which is not an obvious symptom.
      REDIS_URL: redis://redis:6379

      # The only two secrets. The backend REFUSES TO BOOT if either is missing or is the
      # published default — see .env for why, and for how to generate them.
      JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env}
      ENCRYPTION_KEY: ${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env}

      # Needed once, to create the FIRST account — so a fresh install on a public IP
      # cannot be claimed by whoever reaches it first. The installer generates it and
      # shows it with the link. Left empty, the backend derives one and prints it in
      # `docker compose logs backend` until the first account exists.
      SETUP_TOKEN: ${SETUP_TOKEN:-}
      # Only `false` switches it off. The installer writes that ONLY for --eval, together
      # with TALOS_BIND=127.0.0.1, so nobody else can reach the page the token guards.
      SETUP_TOKEN_REQUIRED: ${SETUP_TOKEN_REQUIRED:-true}

      # Public URL of the frontend, used in the links inside emails.
      FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}

      # ── Optional, all defaulted ──
      # Every one of these must be listed here, not just documented in .env: an unlisted
      # variable is silently dropped by Compose, so a customer would set it and nothing
      # would happen — a failure with no error message anywhere.
      ALLOW_SELF_REGISTRATION: ${ALLOW_SELF_REGISTRATION:-false}
      ALLOW_MULTI_ORG: ${ALLOW_MULTI_ORG:-false}

      # Sign-in with Google / GitHub. The backend refuses to boot if a provider is
      # enabled without credentials, rather than showing a button that cannot work.
      OAUTH_CALLBACK_BASE_URL: ${OAUTH_CALLBACK_BASE_URL:-}
      OAUTH_GOOGLE_ENABLED: ${OAUTH_GOOGLE_ENABLED:-false}
      OAUTH_GOOGLE_CLIENT_ID: ${OAUTH_GOOGLE_CLIENT_ID:-}
      OAUTH_GOOGLE_CLIENT_SECRET: ${OAUTH_GOOGLE_CLIENT_SECRET:-}
      OAUTH_GITHUB_ENABLED: ${OAUTH_GITHUB_ENABLED:-false}
      OAUTH_GITHUB_CLIENT_ID: ${OAUTH_GITHUB_CLIENT_ID:-}
      OAUTH_GITHUB_CLIENT_SECRET: ${OAUTH_GITHUB_CLIENT_SECRET:-}

      # Outbound email. Defaults to `log`, which writes invitation and reset links to
      # `docker compose logs backend` — workable for one admin, awkward for a team.
      MAIL_TRANSPORT: ${MAIL_TRANSPORT:-log}
      SMTP_HOST: ${SMTP_HOST:-}
      SMTP_PORT: ${SMTP_PORT:-587}
      SMTP_USER: ${SMTP_USER:-}
      SMTP_PASSWORD: ${SMTP_PASSWORD:-}
      MAIL_FROM: ${MAIL_FROM:-}
    volumes:
      # Terraform workspaces and generated state. Persisted because losing a state file
      # orphans real cloud resources that Terraform can then neither see nor destroy.
      - talos_terraform:/app/infra/terraform/workspaces
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
    healthcheck:
      test: ['CMD', 'wget', '-qO-', 'http://localhost:3001/api/health']
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s

  frontend:
    image: ${TALOS_REGISTRY:-talosfleet}/talos-frontend:${TALOS_VERSION:-latest}
    restart: unless-stopped
    ports:
      - '${TALOS_BIND:+${TALOS_BIND:-}:}${FRONTEND_PORT:-3000}:3000'
    environment:
      # There is deliberately no API URL to set here.
      #
      # Next.js compiles NEXT_PUBLIC_* into the client bundle at BUILD time, so a prebuilt
      # image cannot be told its API address at run time — this block used to pass
      # NEXT_PUBLIC_API_URL and it silently did nothing, leaving every install that was not
      # reached at localhost with a UI that could not talk to its own API.
      #
      # The browser now calls this container on a relative /api, and the Next server
      # forwards to the backend below. So Talos works at whatever hostname you put in front
      # of it, with nothing to configure and no CORS to get wrong.
      BACKEND_INTERNAL_URL: http://backend:3001
    depends_on:
      - backend

volumes:
  talos_postgres:
  talos_redis:
  talos_terraform:
TALOS_COMPOSE_EOF
}

# Tests load the helpers without running anything: TALOS_INSTALLER_LIB=1 . ./install.sh
[ "${TALOS_INSTALLER_LIB:-}" = 1 ] || main "$@"
