#!/usr/bin/env bash
# =============================================================================
#  Innoslate launcher  (Linux, macOS, WSL, Git Bash)
# =============================================================================
#  A wrapper around "docker compose" that adds the checks and the
#  backup/update steps you would otherwise have to remember. Start with
#
#      ./innoslate setup
#
#  which asks what you need and writes the configuration, then
#
#      ./innoslate help
#
#  for the list of commands.
#
#  Your installation is ".env", "compose.yaml" and your data folder, none of
#  which this script owns, so plain "docker compose" works instead of any of
#  this -- see ../docs/06-advanced-customization.md.
# =============================================================================

set -euo pipefail

# Git Bash and MSYS on Windows helpfully rewrite anything that looks like a
# Unix path into a Windows path before handing it to a program it did not
# build. That is wrong for us: a path like /tmp/dump.sql here means a path
# inside a Linux container, and rewriting it to C:\...\Temp\dump.sql makes the
# command fail with a confusing "no such file" from inside the container.
# These two variables turn the rewriting off. They do nothing on Linux, macOS
# or WSL, so it is safe to set them unconditionally.
export MSYS_NO_PATHCONV=1
export MSYS2_ARG_CONV_EXCL='*'

cd "$(dirname "${BASH_SOURCE[0]}")"

KIT_DIR="$PWD"
SERVICE=innoslate              # the service name in compose.yaml
CONTAINER=innoslate            # container_name: it happens to match
DB_SERVICE=postgres
DB_CONTAINER=innoslate-postgres
PG_VOLUME=innoslate-postgres-data  # volumes: in compose.yaml
NETWORK=innoslate                 # networks: in compose.yaml
INNOSLATE_UID_FALLBACK=1001   # what every published image has used so far
INNOSLATE_UID_CACHE=          # filled in on first use by innoslate_uid
INNOSLATE_GID_FALLBACK=1001   # the group that goes with it
INNOSLATE_GID_CACHE=          # filled in on first use by innoslate_gid
MIN_COMPOSE=2.20.0            # depends_on.required needs Docker Compose 2.20.0
KIT_VERSION="$(cat "$KIT_DIR/VERSION" 2>/dev/null || echo unknown)"

# -----------------------------------------------------------------------------
#  Output helpers
# -----------------------------------------------------------------------------
if [[ -t 1 ]]; then
  C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'
  C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_BLUE=$'\033[36m'
else
  C_RESET=; C_BOLD=; C_DIM=; C_RED=; C_GREEN=; C_YELLOW=; C_BLUE=
fi

say()  { printf '%s\n' "$*"; }
info() { printf '%s==>%s %s\n' "$C_BLUE$C_BOLD" "$C_RESET" "$*"; }
ok()   { printf '%s  ok%s  %s\n' "$C_GREEN" "$C_RESET" "$*"; }
warn() { printf '%s!!>%s %s%s\n' "$C_YELLOW$C_BOLD" "$C_RESET$C_YELLOW" "$*" "$C_RESET" >&2; }
die()  { printf '\n%sProblem:%s %s\n\n' "$C_RED$C_BOLD" "$C_RESET" "$*" >&2; exit 1; }
rule() { printf '%s%s%s\n' "$C_DIM" "-----------------------------------------------------------------------" "$C_RESET"; }

# -----------------------------------------------------------------------------
#  Version comparison, for the Docker Compose check
# -----------------------------------------------------------------------------
# Turns 2.20.0 into a single number so two versions can be compared with -ge.
# Trailing junk ("v", "-rc1", build metadata) is dropped rather than guessed at.
ver_num() {
  local v a b c
  v="${1#v}"; v="${v%%-*}"; v="${v%%+*}"; v="$v.0.0"
  a="${v%%.*}"; v="${v#*.}"
  b="${v%%.*}"; v="${v#*.}"
  c="${v%%.*}"
  [[ "$a" =~ ^[0-9]+$ ]] || a=0
  [[ "$b" =~ ^[0-9]+$ ]] || b=0
  [[ "$c" =~ ^[0-9]+$ ]] || c=0
  printf '%d' $(( a * 1000000 + b * 1000 + c ))
}

version_ge() { [[ "$(ver_num "$1")" -ge "$(ver_num "$2")" ]]; }

# -----------------------------------------------------------------------------
#  Find Docker and Docker Compose
# -----------------------------------------------------------------------------
COMPOSE=()
COMPOSE_VER=""

require_docker() {
  command -v docker >/dev/null 2>&1 || die \
"Docker is not installed, or is not on your PATH.

  Windows and macOS: install Docker Desktop from https://docker.com/products/docker-desktop
  Linux:             follow https://docs.docker.com/engine/install/

Then run this command again."

  docker info >/dev/null 2>&1 || die \
"Docker is installed but not running.

  Windows and macOS: start Docker Desktop and wait until it reports that it
                     is running, then try again.
  Linux:             sudo systemctl start docker"

  require_compose
}

# compose.yaml uses "depends_on: required: false", which the old standalone
# docker-compose (v1) does not understand -- it would reject the file with a
# confusing schema error, or silently ignore the setting. Say so plainly
# instead of falling back to it.
require_compose() {
  [[ ${#COMPOSE[@]} -gt 0 ]] && return 0

  if docker compose version >/dev/null 2>&1; then
    COMPOSE=(docker compose)
    COMPOSE_VER="$(docker compose version --short 2>/dev/null | tr -d ' \r' | head -1)"
  elif command -v docker-compose >/dev/null 2>&1; then
    die \
"Only the old standalone \"docker-compose\" (v1) is available on this machine,
and this kit needs Docker Compose v${MIN_COMPOSE} or newer.

  Windows and macOS: update Docker Desktop; it includes a current Compose.
  Linux:             install the Compose plugin --
                     https://docs.docker.com/compose/install/linux/

Then run \"docker compose version\" to confirm, and try again."
  else
    die \
"Docker Compose is not available.

Docker Desktop includes it. On Linux, install the plugin:
  https://docs.docker.com/compose/install/linux/"
  fi

  if [[ -n "$COMPOSE_VER" ]] && ! version_ge "$COMPOSE_VER" "$MIN_COMPOSE"; then
    die \
"Docker Compose ${COMPOSE_VER} is too old; this kit needs v${MIN_COMPOSE} or newer.

  Windows and macOS: update Docker Desktop.
  Linux:             https://docs.docker.com/compose/install/linux/"
  fi
}

# Innoslate is published for Intel and AMD processors (linux/amd64) only, so
# an ARM host cannot run it natively. The two ARM cases are very different and
# must not be reported the same way:
#
#   Apple Silicon Mac -- Docker Desktop emulates amd64 for you. Supported, and
#     fine for evaluating; only the first start is slow.
#   ARM Linux server (AWS Graviton t4g/m7g, Ampere, a Pi) -- there is no
#     emulation unless someone has registered QEMU with binfmt_misc, so the
#     container usually fails outright, and emulated Java is too slow to run
#     Innoslate on in production even when it does start.
host_is_arm64() {
  case "$(uname -m 2>/dev/null)" in
    arm64|aarch64) return 0 ;;
  esac
  # A shell running under Rosetta -- an Intel Homebrew bash, or a terminal set
  # to "Open using Rosetta" -- is told x86_64 by uname on an Apple Silicon Mac.
  # The hardware answer is the one that decides how Docker runs the image.
  [[ "$(uname -s 2>/dev/null)" == Darwin ]] \
    && [[ "$(sysctl -n hw.optional.arm64 2>/dev/null)" == 1 ]]
}

host_is_macos() { [[ "$(uname -s 2>/dev/null)" == Darwin ]]; }

# Runs a compose command with the right project files. Compose's own output is
# shown; the command line itself is not -- it tells the customer nothing.
dc() { "${COMPOSE[@]}" "$@"; }

# =============================================================================
#  .env: reading, and writing without losing anything
# =============================================================================

# Turns the raw right-hand side of a KEY=VALUE line into the value Docker
# Compose would actually use, so that what this script reports and what the
# container receives cannot disagree. Compose's own .env reader:
#
#   * strips one layer of surrounding quotes, and stops at the closing quote,
#     so   PASS="s3cret"   is  s3cret;
#   * treats a "#" that follows whitespace as the start of a comment, so
#     INNOSLATE_PORT=8080   # main port   is  8080, not "8080   # main port".
#
# Without this, a trailing comment in .env would put itself into the URL that
# "status" prints and into the port that "start" checks, and quotes around
# POSTGRES_PASSWORD would make the redactor mask the wrong string.
env_clean_value() {
  local v="$1"
  # Leading whitespace. (Removing everything from the first non-space onward
  # leaves just that whitespace, which is then removed from the front.)
  v="${v#"${v%%[![:space:]]*}"}"
  case "$v" in
    '"'*)  v="${v#\"}"; v="${v%%\"*}" ;;
    "'"*)  v="${v#\'}"; v="${v%%\'*}" ;;
    '#'*)  v="" ;;                       # the whole value is a comment
    *)
      # An unquoted value ends at the first whitespace-then-#. A "#" with no
      # space before it is an ordinary character and is kept -- passwords
      # contain them.
      case "$v" in *[[:space:]]'#'*) v="${v%%[[:space:]]'#'*}" ;; esac
      # Trailing whitespace, by the mirror of the trick above.
      v="${v%"${v##*[![:space:]]}"}"
      ;;
  esac
  printf '%s' "$v"
}

# The mirror of env_clean_value: turns a value into the text to put on the
# right of the "=" so that Compose's own reader hands back exactly that value.
#
# Writing it raw is wrong for the same three cases env_clean_value has to cope
# with on the way in -- a leading or trailing space, an embedded " #", or a
# leading quote character are all eaten by Compose. A password is where that
# bites: PostgreSQL is CREATED with the truncated value, the customer types the
# one they chose into Innoslate's setup page, and every check here still passes
# because they all read through env_clean_value too.
#
# A "$" is the fourth case, and the least visible of them. Compose reads an
# unquoted value as a variable reference: "pa$HOME" arrives as "pa" followed by
# this machine's home directory, and "pa$$word" arrives as "pa$word". The
# customer's own password manager produces both shapes. Nothing shows the
# difference -- .env still reads back as what was typed, because the reader
# above knows nothing about interpolation -- while PostgreSQL was created with
# something else entirely.
#
# Single quotes are the answer, and the only one: Compose takes a
# single-quoted value completely literally, while a double-quoted value is
# still interpolated. Single quotes are therefore preferred for everything
# that needs quoting at all, and double quotes are kept only for a value
# containing a single quote, which cannot go inside them -- dotenv has no
# escape for it. A value needing both is refused rather than stored wrong.
env_format_value() {
  local v="$1"
  if [[ "$(env_clean_value "$v")" == "$v" && "$v" != *'$'* ]]; then
    printf '%s' "$v"
    return 0
  fi
  case "$v" in
    *"'"*)
      # Double quotes cannot make a "$" literal, so this is the one
      # combination with no representation in .env at all.
      case "$v" in *'"'*|*'$'*) return 1 ;; esac
      printf '"%s"' "$v"
      ;;
    *) printf "'%s'" "$v" ;;
  esac
}

# The three bytes a Windows editor writes at the start of a UTF-8 file. Not
# [[:space:]], so without allowing for them the anchors below miss the FIRST
# key in the file -- and in .env.example that key is COMPOSE_PROFILES, the
# one that decides whether the bundled database exists. Docker Compose
# strips a BOM and would run that database; the launcher would not see it,
# and "backup" and "migrate export" would both report success having
# skipped it.
ENV_BOM=$'\357\273\277'

# Reads one value out of .env without executing the file.
env_value() {
  local key="$1" file="${2:-.env}" raw
  [[ -f "$file" ]] || return 0
  raw="$(sed -n -E -e "1s/^${ENV_BOM}//" \
             -e "s/^[[:space:]]*${key}[[:space:]]*=(.*)$/\\1/p" "$file" \
          | tail -n1 | tr -d '\r')"
  env_clean_value "$raw"
}

env_or() {
  local v; v="$(env_value "$1")"
  if [[ -n "$v" ]]; then printf '%s' "$v"; else printf '%s' "$2"; fi
}

# The pending set of changes, as "KEY=VALUE" strings. A value of __DELETE__
# removes the key. Collected with env_stage, then written in one go by
# env_commit so that a cancelled setup leaves .env exactly as it was.
ENV_PENDING=()

env_stage() { ENV_PENDING[${#ENV_PENDING[@]}]="$1=$2"; }

env_pending_value() {
  local key="$1" item found=""
  for item in ${ENV_PENDING[@]+"${ENV_PENDING[@]}"}; do
    [[ "${item%%=*}" == "$key" ]] && found="${item#*=}"
  done
  printf '%s' "$found"
}

env_pending_has() {
  local key="$1" item
  for item in ${ENV_PENDING[@]+"${ENV_PENDING[@]}"}; do
    [[ "${item%%=*}" == "$key" ]] && return 0
  done
  return 1
}

# Writes the staged changes into .env, keeping every other line -- including
# comments and any keys this kit knows nothing about -- exactly where it found
# them. Written to a temporary file and moved into place, so an interruption
# cannot leave a half-written .env behind.
env_commit() {
  local base="${1:-}" target="$KIT_DIR/.env"
  if [[ -z "$base" ]]; then
    if [[ -f "$target" ]]; then base="$target"; else base="$KIT_DIR/.env.example"; fi
  fi
  [[ -f "$base" ]] || die "Cannot find $base to work from. Is this the right folder?"

  # Nothing is written until every staged value is known to survive the round
  # trip, so the one value that cannot be stored stops the command instead of
  # being quietly truncated into the file.
  local check_item check_key check_value
  for check_item in ${ENV_PENDING[@]+"${ENV_PENDING[@]}"}; do
    check_key="${check_item%%=*}"; check_value="${check_item#*=}"
    [[ "$check_value" == "__DELETE__" ]] && continue
    env_format_value "$check_value" >/dev/null || die \
"The value for $check_key cannot be stored in .env: it has a single quote
together with a double quote or a dollar sign. Choose one without the single
quote.

Nothing has been changed."
  done

  # Checked here rather than left to fail later: under "set -e" a redirection
  # that cannot be made kills the script where it stands, with no message and
  # a half-answered wizard. Somewhere unwritable is a place people genuinely
  # unpack this -- Program Files, a read-only share, a folder owned by root.
  local tmp="$target.new.$$"
  if ! : > "$tmp" 2>/dev/null; then
    die \
"Cannot write in $KIT_DIR, so .env cannot be changed.

Nothing has been changed. The folder is not yours to write in, or the disk is
full. Unpack the kit somewhere you own, such as your home directory, and run
./innoslate setup there."
  fi
  local done_keys=" "

  # Pass 1: rewrite the keys that already have a line.
  local lineno=0
  while IFS= read -r line || [[ -n "$line" ]]; do
    local key=""
    # A byte-order mark hides the first key from the match below, so setup
    # would append a second copy of that key rather than rewrite the line
    # already there. Dropping it here rewrites that line, and leaves the
    # file with no mark for whatever reads it next.
    lineno=$((lineno + 1))
    if (( lineno == 1 )); then line="${line#"$ENV_BOM"}"; fi
    if [[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*= ]]; then
      key="${BASH_REMATCH[1]}"
    fi
    if [[ -n "$key" ]] && env_pending_has "$key"; then
      local value; value="$(env_pending_value "$key")"
      case "$done_keys" in *" $key "*) continue ;; esac
      done_keys="$done_keys$key "
      [[ "$value" == "__DELETE__" ]] && continue
      printf '%s=%s\n' "$key" "$(env_format_value "$value")" >> "$tmp"
    else
      printf '%s\n' "${line%$'\r'}" >> "$tmp"
    fi
  done < "$base"

  # Pass 2: append anything that had no line yet.
  local appended=0 item key value
  for item in ${ENV_PENDING[@]+"${ENV_PENDING[@]}"}; do
    key="${item%%=*}"; value="${item#*=}"
    case "$done_keys" in *" $key "*) continue ;; esac
    done_keys="$done_keys$key "
    [[ "$value" == "__DELETE__" ]] && continue
    if (( appended == 0 )); then
      printf '\n# Added by ./innoslate setup\n' >> "$tmp"
      appended=1
    fi
    printf '%s=%s\n' "$key" "$(env_format_value "$value")" >> "$tmp"
  done

  # The temporary file holds the database password, so it is removed rather
  # than left behind for whoever can read the folder.
  if ! mv -f "$tmp" "$target" 2>/dev/null; then
    rm -f "$tmp"
    die \
"Could not replace $target.

Nothing has been changed. Check that the file is not read-only and that no
other program is holding it open, then run this again."
  fi
  # .env holds the database password on a bundled-database install. On Linux
  # and macOS take it out of other users' reach; Windows filesystems have no
  # equivalent and Docker Desktop ignores the attempt, which is harmless.
  chmod 600 "$target" 2>/dev/null || true
  ENV_PENDING=()
}

# Convenience for the one-key writes outside setup (update, mainly).
env_set_one() {
  ENV_PENDING=()
  env_stage "$1" "$2"
  env_commit
}

profiles()    { env_value COMPOSE_PROFILES; }
has_profile() { [[ ",$(profiles)," == *",$1,"* ]]; }
data_dir()    { env_or DATA_DIR ./data; }
backups_dir() { printf '%s/backups' "$(data_dir)"; }
image_ref()   { printf '%s:%s' "$(env_or INNOSLATE_IMAGE innoslate/innoslate)" "$(env_or INNOSLATE_VERSION latest)"; }
# True when Tomcat's proxy configuration is in play. "setup" records the choice
# in KIT_ACCESS_MODE, but docs/02-https.md also documents turning it on by hand
# with nothing but the two COMPOSE_FILE lines -- so believe either. Without the
# second test, doctor silently skips the server.xml mount check for everyone who
# followed that by-hand route, which is the one check they most need.
proxy_mode() {
  [[ "$(env_value KIT_ACCESS_MODE)" == proxy ]] && return 0
  [[ "$(env_value COMPOSE_FILE)" == *compose.proxy.yaml* ]]
}

# Translates a path on this machine into one the "docker" binary understands.
#
# Under Git Bash / MSYS the shell hands out "/c/Users/...", but docker.exe is a
# native Windows program and reads that as the relative path "C:\c" -- so
# "docker compose cp /c/Users/.../database.sql db:/tmp/x" fails with
# "GetFileAttributesEx C:\c: The system cannot find the file specified", and
# the failure looks exactly like a corrupt dump. Git Bash is a documented
# platform for this launcher, so translate rather than leave people guessing.
# A no-op everywhere else.
host_path() {
  local p="$1"
  case "$(uname -s 2>/dev/null)" in
    MINGW*|MSYS*|CYGWIN*)
      if command -v cygpath >/dev/null 2>&1; then cygpath -w "$p"; else printf '%s' "$p"; fi
      ;;
    *) printf '%s' "$p" ;;
  esac
}

# =============================================================================
#  The data folders
# =============================================================================

# The id Innoslate runs as inside the image. The image creates its user with a
# plain "useradd", so the number it lands on follows whichever ids the base
# image already uses -- it is not fixed, and a base image change could move it.
# Reading it out of the image keeps this correct without a second copy of the
# number living here. Asked once per run; falls back to the id every published
# image has had when the image is not on this machine yet, which is the case on
# a first install, before "docker compose up" pulls it.
innoslate_uid() {
  if [[ -z "$INNOSLATE_UID_CACHE" ]]; then
    INNOSLATE_UID_CACHE="$INNOSLATE_UID_FALLBACK"
    local ref; ref="$(image_ref)"
    if docker image inspect "$ref" >/dev/null 2>&1; then
      local found
      found="$(docker run --rm --entrypoint id "$ref" -u innoslate 2>/dev/null | tr -dc '0-9')"
      [[ -n "$found" ]] && INNOSLATE_UID_CACHE="$found"
    fi
  fi
  printf '%s' "$INNOSLATE_UID_CACHE"
}

# The group that goes with it, read the same way and for the same reason.
innoslate_gid() {
  if [[ -z "$INNOSLATE_GID_CACHE" ]]; then
    INNOSLATE_GID_CACHE="$INNOSLATE_GID_FALLBACK"
    local ref; ref="$(image_ref)"
    if docker image inspect "$ref" >/dev/null 2>&1; then
      local found
      found="$(docker run --rm --entrypoint id "$ref" -g innoslate 2>/dev/null | tr -dc '0-9')"
      [[ -n "$found" ]] && INNOSLATE_GID_CACHE="$found"
    fi
  fi
  printf '%s' "$INNOSLATE_GID_CACHE"
}

# DATA_DIR is a customer-supplied path and the ownership fix below is a
# recursive chown, so refuse to touch anything that looks like a system
# location or a home directory root. Only the three folders this kit creates
# are ever modified -- never DATA_DIR itself.
data_dir_is_safe_to_chown() {
  local dir="$1"
  case "$dir" in
    ""|.|./|/) return 1 ;;
  esac
  # Relative paths are inside the kit folder; those are always ours.
  [[ "$dir" != /* ]] && return 0

  local d="${dir%/}"
  case "$d" in
    /bin|/boot|/dev|/etc|/home|/lib|/lib64|/media|/mnt|/opt|/proc|/root|/run|/sbin|/srv|/sys|/tmp|/usr|/var)
      return 1 ;;
  esac
  [[ -n "${HOME:-}" && "$d" == "${HOME%/}" ]] && return 1
  # Anything a single level below "/" (/data, /work) is too broad to be sure of.
  local rest="${d#/}"
  [[ "$rest" == *"/"* ]] || return 1
  return 0
}

# The command to run by hand when this script cannot set the ownership itself.
# Assembled with printf rather than written out as literal text: a line of this
# file that only tells the customer about a chown should not read -- to someone
# skimming, or to anything grepping for one -- like a line that performs one.
ownership_fix_hint() { # ownership_fix_hint <path>...
  printf 'sudo chown -R %s:%s %s && ' "$(innoslate_uid)" "$(id -g)" "$*"
  printf 'sudo chmod -R g+w %s' "$*"
}

# An image already on this machine, for a moment's root-level housekeeping.
# Nothing is pulled: this is only ever an alternative to sudo, and an
# installation with no route to a registry still deserves one.
maintenance_image() {
  local ref
  for ref in "$(image_ref)" "$(env_or POSTGRES_IMAGE postgres:17-alpine)"; do
    [[ -n "$ref" ]] || continue
    if docker image inspect "$ref" >/dev/null 2>&1; then printf '%s' "$ref"; return 0; fi
  done
  return 1
}

# Whether the account inside the container can write to a folder on this host.
# "-w" answers that question about the account running this script, which is a
# different one, so it says "writable" about folders Innoslate cannot touch --
# exactly the state a first install on Linux lands in, and the reason this is
# not simply a -w test. Ownership and mode are what actually decide it.
# Docker Desktop ignores host ownership on shared folders, so anywhere but
# Linux the answer is yes.
innoslate_can_write() { # innoslate_can_write <path>
  local path="$1" owner group mode uid gid
  [[ "$(uname -s 2>/dev/null)" == Linux ]] || return 0
  [[ -d "$path" ]] || return 1
  owner="$(stat -c '%u' "$path" 2>/dev/null)" || return 1
  group="$(stat -c '%g' "$path" 2>/dev/null)" || return 1
  mode="$(stat -c '%a' "$path" 2>/dev/null)" || return 1
  [[ "$mode" =~ ^[0-7]+$ ]] || return 1
  while (( ${#mode} < 3 )); do mode="0$mode"; done
  uid="$(innoslate_uid)"; gid="$(innoslate_gid)"
  # The last three digits are the owner, group and other bits; 2 is write.
  local o="${mode: -3:1}" g="${mode: -2:1}" w="${mode: -1:1}"
  if [[ "$owner" == "$uid" ]] && (( o & 2 )); then return 0; fi
  if [[ "$group" == "$gid" ]] && (( g & 2 )); then return 0; fi
  if (( w & 2 )); then return 0; fi
  return 1
}

# Gives Innoslate's user id ownership of the named folders under DATA_DIR,
# recursively. On Windows and macOS, Docker Desktop makes shared folders
# writable regardless of ownership, so this is a no-op there. On a Linux host
# the folders keep their real ownership, and a folder owned by someone else is
# simply not writable by Innoslate -- the single most common reason a first
# install cannot save its settings.
#
# The group is set to the account running this script, with group write, so
# both sides can work in these folders. Innoslate writes as the owner; this
# script writes as the group -- "backup" and "migrate export" create folders
# here themselves, and handing the folders to Innoslate alone would leave them
# unable to, on exactly the installations where the ownership fix was needed.
#
# Three ways are tried, each assuming less than the last: this account may
# already own the folders; it may hold passwordless sudo; and failing both,
# Docker is running as root and is certainly installed -- which sudo is not,
# and which a customer who may not become root often cannot be given. Anyone
# able to run this kit can reach Docker by definition, so the third way works
# on the ordinary Linux server where the first two do not.
chown_data_dirs() { # chown_data_dirs <sub>...
  [[ "$(uname -s 2>/dev/null)" == Linux ]] || return 0

  local dir sub
  dir="$(data_dir)"
  local targets=()
  for sub in "$@"; do targets[${#targets[@]}]="$dir/$sub"; done
  (( ${#targets[@]} )) || return 0

  if ! data_dir_is_safe_to_chown "$dir"; then
    warn "Not changing ownership under $dir automatically: it is a system folder,"
    warn "a home directory or a folder directly under /."
    warn "Point DATA_DIR at a folder of its own, or run this yourself:"
    warn "    $(ownership_fix_hint "${targets[@]}")"
    return 0
  fi

  local uid group failed=0 target helper=""
  uid="$(innoslate_uid)"; group="$(id -g)"

  for target in "${targets[@]}"; do
    chown -R "$uid:$group" "$target" 2>/dev/null || failed=1
    chmod -R g+w "$target" 2>/dev/null || failed=1
  done

  if (( failed )) && command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then
    failed=0
    for target in "${targets[@]}"; do
      sudo chown -R "$uid:$group" "$target" 2>/dev/null || failed=1
      sudo chmod -R g+w "$target" 2>/dev/null || failed=1
    done
  fi

  # Docker as the root this account does not have. DATA_DIR is bind-mounted
  # into a throwaway container which changes it from the inside; only the
  # subfolders named above are touched, exactly as they are above.
  if (( failed )); then
    local abs inside=()
    abs="$(cd "$dir" 2>/dev/null && pwd)" || abs=""
    helper="$(maintenance_image)" || helper=""
    if [[ -n "$abs" && -n "$helper" ]]; then
      for sub in "$@"; do inside[${#inside[@]}]="/data-dir/$sub"; done
      # --entrypoint is not optional: the Innoslate image starts Tomcat, so a
      # trailing "sh -c ..." would be handed to that instead of being run.
      if docker run --rm --user 0:0 --entrypoint sh \
           -v "$(host_path "$abs")":/data-dir "$helper" \
           -c 'u="$1"; g="$2"; shift 2; chown -R "$u:$g" "$@" && chmod -R g+w "$@"' \
           sh "$uid" "$group" "${inside[@]}" >/dev/null 2>&1; then
        failed=0
      fi
    fi
  fi

  (( failed )) || return 0
  # With no image here yet -- a first setup, before anything has been pulled --
  # the Docker way could not be tried. Say nothing: late_ownership_repair tries
  # again once there is an image, and setup says what to run if Innoslate is
  # not started. Asking for sudo now would send the customer off to fix by hand
  # something that is about to fix itself.
  [[ -n "$helper" ]] || return 1
  ownership_warn "${targets[@]}"
  return 1
}

# The fix to run by hand when chown_data_dirs could not do it. Said once per
# run: late_ownership_repair and bring_up_and_report can both get here.
OWNERSHIP_WARNED=0
ownership_warn() { # ownership_warn <path>...
  (( OWNERSHIP_WARNED )) && return 0
  OWNERSHIP_WARNED=1
  warn "Could not change ownership automatically. Run this once, then try again:"
  warn "    $(ownership_fix_hint "$@")"
}

# Whether the three data folders need their ownership put right: Innoslate
# must own them, and this account must be able to write to them as their
# group -- "backup" and "migrate export" create folders there themselves.
data_dirs_need_fix() {
  local dir sub uid
  dir="$(data_dir)"; uid="$(innoslate_uid)"
  for sub in filestore backups logs; do
    [[ "$(stat -c '%u' "$dir/$sub" 2>/dev/null || echo unknown)" == "$uid" ]] || return 0
    [[ "$(stat -c '%g' "$dir/$sub" 2>/dev/null || echo unknown)" == "$(id -g)" && -w "$dir/$sub" ]] || return 0
  done
  return 1
}

ensure_data_dirs() {
  local dir sub
  dir="$(data_dir)"
  [[ -n "$dir" ]] || die "DATA_DIR in .env is empty. Set it to a folder, for example ./data"

  for sub in filestore backups logs; do
    mkdir -p "$dir/$sub" 2>/dev/null || die \
"Could not create $dir/$sub.

Check that DATA_DIR in .env points somewhere this account may write to."
  done

  [[ "$(uname -s 2>/dev/null)" == Linux ]] || return 0

  # Only the folders themselves are sampled, not everything under them: on an
  # installation with a large filestore, walking it on every start would cost
  # more than it is worth. The one command that puts files into those folders
  # with somebody else's ownership -- "migrate import", unpacking a tar made on
  # another machine -- does its own recursive pass afterwards.
  data_dirs_need_fix || return 0
  chown_data_dirs filestore backups logs || true
}

# GNU tar, rather than the bsdtar macOS ships. Only GNU tar has
# --no-overwrite-dir, and bsdtar rejects an option it does not know outright.
tar_is_gnu() {
  local v
  v="$(tar --version 2>/dev/null || true)"
  [[ "$v" == *"GNU tar"* ]]
}

# Re-checks the three folders once an image is certainly on this machine, and
# repairs them if they still need it. A first start is the one moment the fix
# above can arrive too early to work: before "up" there may be no image here to
# run the Docker fallback with. The same test as ensure_data_dirs, so this also
# covers a folder Innoslate can write but this account cannot, and ids read
# before the image was here. Returns 0 only when something was actually put
# right, so the caller knows whether a restart is worth it.
late_ownership_repair() {
  [[ "$(uname -s 2>/dev/null)" == Linux ]] || return 1
  data_dirs_need_fix || return 1
  chown_data_dirs filestore backups logs || true
  ! data_dirs_need_fix
}

# The data folders Innoslate cannot write to, one per line. Empty output means
# there is nothing wrong.
unwritable_data_dirs() {
  local dir sub
  dir="$(data_dir)"
  for sub in filestore backups logs; do
    innoslate_can_write "$dir/$sub" || printf '%s\n' "$dir/$sub"
  done
}

# =============================================================================
#  Containers: health, presence, waiting
# =============================================================================
# Nothing here pipes into "grep -q", and that is deliberate everywhere it
# appears below.
#
# This script runs under "set -o pipefail". "grep -q" stops at the first match
# and exits, which closes the pipe while the command on the left is still
# writing; that command is killed by SIGPIPE, and pipefail then reports the
# whole pipeline as failed. The answer comes back "no" precisely BECAUSE it
# was "yes" -- not always, only when the writer had not finished, so it turns
# up as an occasional mystery on a loaded machine and never on a quiet one.
# The damage is real: "the container is not running" stops a command that
# should work, and "Innoslate has no configuration" makes restart restore one
# over the top of a perfectly good installation. Capturing the output and
# matching it in the shell has neither a pipe nor a second process.
container_running() {
  local out
  out="$(docker ps --filter "name=^${1:-$CONTAINER}$" --format '{{.Names}}' 2>/dev/null || true)"
  [[ -n "$out" ]]
}

container_exists() {
  local out
  out="$(docker ps -a --filter "name=^${1:-$CONTAINER}$" --format '{{.Names}}' 2>/dev/null || true)"
  [[ -n "$out" ]]
}

# "docker inspect" on a container that does not exist prints a blank line to
# stdout as well as failing, so the output is filtered rather than relying on
# the exit code alone.
health() {
  local h
  h="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' \
        "$CONTAINER" 2>/dev/null | head -1)"
  if [[ -n "$h" ]]; then printf '%s' "$h"; else printf 'missing'; fi
}

# Identifies one particular run of the container: which container, and when it
# started. "up -d" leaves both untouched when nothing in .env or the compose
# files has changed, which is how "restart" tells "Compose replaced it" apart
# from "Compose did nothing".
container_boot_id() {
  docker inspect --format '{{.Id}}@{{.State.StartedAt}}' "$CONTAINER" 2>/dev/null | head -1
}

running_version() {
  local v
  v="$(docker inspect --format '{{.Config.Image}}' "$CONTAINER" 2>/dev/null | head -1)"
  if [[ -n "$v" ]]; then printf '%s' "$v"; else printf 'not running'; fi
}

# True when the container has stopped on its own more than once and is not
# running now. Nothing else notices that: the restart policy puts it back a
# moment later, so "docker ps" lists a crash-looping container as though it
# were still coming up, and an image with no HEALTHCHECK has no health to
# report either -- which makes waiting for one look exactly like waiting for a
# slow start.
#
# This is not hypothetical. No image published before 4.12.1.0 can run here:
# they have ENTRYPOINT ["/bin/bash"] and compose.yaml supplies no command, so
# the container starts bash on nothing and exits at once (docs/05). Measured
# without this check, "update 4.11.0.2" sat for twenty-five minutes before it
# said a word, and then rolled back perfectly -- the recovery was never the
# problem, the silence was.
#
# Two formats rather than one naming both fields: a single format is ambiguous
# to anything that matches on the format string, and the second is asked only
# once the first has said something is wrong -- which is almost never.
container_keeps_stopping() {
  local status restarts
  status="$(docker inspect --format '{{.State.Status}}' "$CONTAINER" 2>/dev/null | head -1 || true)"
  [[ -n "$status" && "$status" != running ]] || return 1
  restarts="$(docker inspect --format '{{.RestartCount}}' "$CONTAINER" 2>/dev/null | head -1 || true)"
  [[ "$restarts" =~ ^[0-9]+$ ]] || return 1
  (( restarts >= 2 ))
}

wait_until_ready() {
  local limit=${1:-240} started waited=0 next_notice=30
  # Wall clock, not the sum of the sleeps. Each turn of this loop also inspects
  # the container and may probe HTTP, and http_probe spends up to two lots of
  # --max-time 10 on a port that accepts the connection and then never answers
  # -- which a crash-looping container's published port does. Counting only the
  # sleeps, a limit of 600 took 1532 seconds of the customer's time.
  started="$(date +%s)"
  info "Waiting for Innoslate to start (usually under a minute, longer on a first start)"
  while (( waited < limit )); do
    if container_keeps_stopping; then
      warn "The Innoslate container starts and then stops again -- see: ./innoslate logs"
      return 1
    fi
    case "$(health)" in
      healthy)   return 0 ;;
      unhealthy) warn "Innoslate started but is not answering yet"; return 1 ;;
      missing)   warn "The Innoslate container is not running -- see: ./innoslate logs"; return 1 ;;
      none)
        # The image defines no HEALTHCHECK, so there is no health to wait for
        # and waiting out the whole timeout would report a working container as
        # a failure. Every published innoslate/innoslate image has one; this is
        # reachable through INNOSLATE_IMAGE pointing at a custom or re-tagged
        # build. Fall back to asking over HTTP, which is what the healthcheck
        # does anyway.
        http_probe "$(env_or INNOSLATE_PORT 8080)" && return 0
        ;;
    esac
    sleep 3
    waited=$(( $(date +%s) - started ))
    if (( waited >= next_notice )); then
      say "  still starting (${waited}s)..."
      next_notice=$(( waited + 30 ))
    fi
  done
  if [[ "$(health)" == none ]]; then
    warn "This image defines no health check, and Innoslate did not answer on"
    warn "port $(env_or INNOSLATE_PORT 8080) within ${limit}s. Check ./innoslate logs"
  else
    warn "Innoslate is taking longer than expected. Check ./innoslate logs"
  fi
  return 1
}

port_in_use() {
  local port="$1"
  # Already published by one of our own containers? Then it is not a conflict.
  local line
  while IFS= read -r line; do
    case "$line" in
      "$CONTAINER "*|"$DB_CONTAINER "*)
        if [[ "$line" == *":${port}->"* ]]; then return 1; fi ;;
    esac
  done < <(docker ps --format '{{.Names}} {{.Ports}}' 2>/dev/null || true)
  (exec 3<>"/dev/tcp/127.0.0.1/${port}") >/dev/null 2>&1 && return 0
  return 1
}

# =============================================================================
#  Innoslate's own configuration
#
#  Innoslate keeps settings.properties inside the container, and the image
#  already ships the tool for getting it in and out: "innoslate configure
#  backup" writes a timestamped copy into /usr/local/innoslate/backups, and
#  "innoslate configure restore" reads one back. compose.yaml mounts that
#  folder to data/backups, so saved configurations outlive the container.
#
#  Everything below is convenience around those two commands. Nothing is added
#  to the container to make them work, so they keep working if you stop using
#  this script -- and the container keeps working if you delete it.
# =============================================================================
in_container() { docker exec "$CONTAINER" "$@"; }

# The folder the containers on this machine actually belong to, when that is a
# different installation from this one. Prints nothing when they are ours, when
# there are none, or when they were left by this installation under a path it
# has since moved from -- moving the folder is supported and must stay silent.
#
# "name: innoslate" and the two container_name lines are fixed, so every copy
# of this kit on a machine describes the same two containers. Starting from a
# second folder therefore does not start a second installation: it rebuilds the
# first one's containers against the second folder's .env and data, and leaves
# the first folder reporting a container it no longer owns. Compose records the
# folder each container was created from, which is what makes this detectable.
foreign_install_dir() {
  local name here there
  here="$(cd "$KIT_DIR" 2>/dev/null && pwd -P)" || return 0
  for name in "$CONTAINER" "$DB_CONTAINER"; do
    there="$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' \
               "$name" 2>/dev/null || true)"
    [[ -n "$there" ]] || continue
    there="$(cd "$there" 2>/dev/null && pwd -P)" || continue
    [[ "$there" == "$here" ]] && continue
    # A path that no longer holds an installation is not somewhere to send
    # anybody, and not a reason to refuse.
    [[ -f "$there/compose.yaml" ]] || continue
    printf '%s' "$there"
    return 0
  done
  return 0
}

# Refuses to READ containers that are somebody else's, for the two commands
# that turn what they read into a file somebody will later trust.
#
# These two were left unguarded on the reasoning that they would degrade
# noisily -- the container writes its configuration into the OTHER folder's
# mounted backups, so this folder sees nothing new and the completeness check
# fails. That is true only of a second folder that has never backed anything up.
# Give this folder one settings file of its own, which every installation that
# has ever run "start" has, and newest_saved_config finds that stale file,
# copies it in as settings.properties, and the check passes. The dump beside it
# is a real dump of the other installation's database. The result is announced
# "backup complete": somebody else's data, under this folder's name, with a
# stale configuration that does not match it.
#
# "migrate import" is deliberately NOT guarded. Arriving from somewhere else is
# what it is for.
guard_foreign_read() { # guard_foreign_read <command-as-typed>
  local other; other="$(foreign_install_dir)"
  [[ -n "$other" ]] || return 0
  die \
"These containers belong to a different installation:

    $other

Anything saved here would describe that installation, not this folder -- and
this folder's own saved configuration would be copied in beside it as though
it belonged with it.

  To do this for that installation:
      cd $other
      ./innoslate $1"
}

# Refuses to touch containers that are somebody else's. Used by every command
# that would create, replace or remove them.
guard_foreign_installation() {
  local other; other="$(foreign_install_dir)"
  [[ -n "$other" ]] || return 0
  die \
"The containers on this machine belong to a different installation:

    $other

Both folders use the same two containers, \"$CONTAINER\" and \"$DB_CONTAINER\",
so running this here would take them over. A machine runs one installation at
a time.

  To work with that one:
      cd $other
      ./innoslate status

  To use this folder instead, release the containers there first:
      cd $other
      ./innoslate down
      cd $KIT_DIR
      ./innoslate start

Nothing has been started, stopped or replaced, and neither installation's data
has been touched. If $other is an old copy you no longer want, delete it."
}

# The container's log, limited to its current run.
#
# A container keeps its whole log across restarts, so "docker logs --tail 400"
# reaches back into runs that are over. The restart that fixes something is
# exactly when somebody looks, and the errors it fixed are still sitting there
# -- a repaired installation reads as a broken one, in the section people trust
# most. Docker records when the current run began; that is where to start.
container_log() { # container_log <tail-count>
  local started
  started="$(docker inspect --format '{{.State.StartedAt}}' "$CONTAINER" 2>/dev/null || true)"
  case "$started" in
    # No time, or the zero time a container that has never run carries.
    ''|0001-01-01*) docker logs --tail "$1" "$CONTAINER" 2>&1 ;;
    *)              docker logs --since "$started" --tail "$1" "$CONTAINER" 2>&1 ;;
  esac
}

# True once the setup page has been completed and Innoslate knows its database.
config_present() {
  container_running || return 1
  local out
  out="$(in_container innoslate configure export 2>/dev/null || true)"
  [[ "$out" == *DATABASE_JDBC_URL* ]]
}

# Prints the newest saved configuration's filename, or nothing.
#
# Newest by modification TIME, not by name. The image writes
# "settings-<YYYYMMDDHHMM>.properties", while a restore or an import writes
# "settings-restored-..." / "settings-imported-...". Sorting those by name puts
# the letters after the digits, so a name sort would keep choosing the restored
# file for ever -- handing back a stale configuration after every later save.
#
# Written with "ls <dir> | grep" rather than a glob on purpose: with
# "set -o pipefail", an unmatched glob makes ls exit non-zero, which would take
# the whole script down with it. Listing the directory always succeeds, and the
# grep is allowed to find nothing.
newest_saved_config() {
  local dir; dir="$(backups_dir)"
  [[ -d "$dir" ]] || return 0
  ls -1t "$dir" 2>/dev/null | grep -E '^settings-.*[.]properties$' | head -1 || true
}

save_config() {
  if ! config_present; then
    warn "nothing to save yet -- setup has not been completed in the browser"
    return 1
  fi
  in_container innoslate configure backup >/dev/null 2>&1 || {
    warn "could not save the configuration"
    return 1
  }
  local newest; newest="$(newest_saved_config)"
  [[ -n "$newest" ]] || { warn "the save reported success but no file appeared in $(backups_dir)"; return 1; }
}

# Loads a saved configuration back into the container. Takes a filename from
# data/backups, or the newest one if not given.
restore_config() {
  local which="${1:-}"
  [[ -n "$which" ]] || which="$(newest_saved_config)"
  if [[ -z "$which" ]]; then
    warn "no saved configuration in $(backups_dir) to restore"
    return 1
  fi
  in_container innoslate configure restore "$which" >/dev/null 2>&1 || {
    warn "could not restore $which"
    return 1
  }
}

# -----------------------------------------------------------------------------
#  The configuration guard
#
#  The one thing in this deployment that lives *inside* the container is
#  Innoslate's own configuration, and every Compose change that replaces the
#  container therefore threatens it. Rather than repeat the same three steps in
#  start, restart, down and update, they are here once:
#
#    config_guard_save     before anything that may remove the container
#    config_guard_restore  after it, if the new container came up blank
#
#  Both are safe to call when there is nothing to do, which is what makes a
#  first-ever install work normally.
# -----------------------------------------------------------------------------
config_guard_save() {
  container_running || return 0
  config_present   || return 0
  if ! save_config; then
    die \
"Innoslate is configured, but its configuration could not be written out, and
this command would replace the container -- which would lose it.

Nothing has been changed. Try:
    ./innoslate save-config        to see the error in full
    ./innoslate doctor            to check the container and the backups folder

The backups folder is $(backups_dir)."
  fi
}

# Puts a saved configuration back if the container came up without one. Called
# after every change that may have recreated it. Returns non-zero only if a
# restore was needed and did not work.
config_guard_restore() {
  container_running || return 0
  config_present && return 0

  local saved; saved="$(newest_saved_config)"
  if [[ -z "$saved" ]]; then
    # Nothing was ever saved: this is a first install, and the browser setup
    # page is the expected next step.
    return 0
  fi

  restore_config "$saved" || return 1
  dc restart "$SERVICE"
  wait_until_ready 600 || true
  config_present && return 0
  warn "the configuration was restored but Innoslate still reports none"
  return 1
}

# =============================================================================
#  Bringing the stack up
#
#  "up -d" on its own is the right thing almost always: it recreates only what
#  actually changed. When it fails, the failure is reported rather than
#  guessed at -- an earlier version of this script responded to any failure by
#  running "docker compose down", which turned a rejected change into an
#  outage. The one case handled automatically is narrow and provably ours: our
#  own Innoslate container still holding the port or the name that the
#  replacement needs.
# =============================================================================

# Runs "up -d", showing its output as it happens and keeping a copy so the
# failure can be classified. Returns Compose's own exit status. The only
# automatic recovery is the narrow one below.
compose_up_try() {
  local tmp rc=0
  tmp="$(mktemp 2>/dev/null || printf '%s' "$KIT_DIR/.compose-output.$$")"
  set +e
  dc up -d --remove-orphans 2>&1 | tee "$tmp"
  rc=${PIPESTATUS[0]}
  set -e
  COMPOSE_UP_OUT="$(cat "$tmp" 2>/dev/null || true)"
  rm -f "$tmp"
  (( rc == 0 )) && return 0

  local lowered; lowered="$(printf '%s' "$COMPOSE_UP_OUT" | tr 'A-Z' 'a-z')"
  case "$lowered" in
    *"is already in use by container"*|*"port is already allocated"*|*"address already in use"*|*"bind for"*"failed"*)
      # Is it our own Innoslate container that is in the way? Only then is
      # replacing it safe and obviously right. Everything else -- another
      # program on the host, another project's container -- is the customer's
      # to decide about, so it is reported rather than bulldozed. Note that
      # only this one service is removed; the database and its volume are not.
      if container_exists "$CONTAINER"; then
        info "Replacing the old Innoslate container, which was holding the port (data untouched)"
        set +e
        dc rm --stop --force "$SERVICE" >/dev/null 2>&1
        dc up -d --remove-orphans 2>&1 | tee "$tmp"
        rc=${PIPESTATUS[0]}
        set -e
        # Take the retry's own output. Without this, a retry that fails for a
        # new reason would be explained using the first attempt's message --
        # sending the customer after a port conflict that is no longer there.
        COMPOSE_UP_OUT="$(cat "$tmp" 2>/dev/null || true)"
        rm -f "$tmp"
        return $rc
      fi
      ;;
  esac
  return $rc
}

# Explains the failure in the terms the customer can act on. Kept separate from
# compose_up_try so that "update" can print this and then roll back, instead of
# the script exiting halfway through an update.
compose_up_explain() {
  local port lowered
  port="$(env_or INNOSLATE_PORT 8080)"
  lowered="$(printf '%s' "${COMPOSE_UP_OUT:-}" | tr 'A-Z' 'a-z')"
  say ""
  case "$lowered" in
    *"port is already allocated"*|*"address already in use"*|*"bind for"*"failed"*)
      say "Docker could not publish port $port: another program is using it."
      say ""
      say "  1. Find it:  docker ps"
      if host_is_macos; then
        say "               macOS: sudo lsof -nP -iTCP:$port -sTCP:LISTEN"
      else
        say "               Linux: sudo ss -lptn 'sport = :$port'"
      fi
      say "  2. Stop it, or change INNOSLATE_PORT in .env (./innoslate setup can do"
      say "     it), and start again."
      ;;
    *"mounts denied"*|*"not shared from the host"*)
      say "Docker Desktop is not sharing this folder with containers, so the data"
      say "folder cannot be mounted: $(data_dir)"
      say ""
      say "  Move this installation under your home folder, or add its location in"
      say "  Docker Desktop under Settings > Resources > File sharing, then start again."
      ;;
    *"no such file"*|*"cannot find the file"*)
      say "Docker Compose could not find a file it was told to read. If COMPOSE_FILE"
      say "is set in .env, check every name in it exists in this folder."
      ;;
    *)
      say "Docker Compose could not apply this configuration. Its error is above."
      say ""
      say "Common causes:"
      say "  * a value in .env Compose does not accept   -- ./innoslate doctor"
      say "  * a mistake in compose.override.yaml        -- docker compose config"
      say "  * COMPOSE_FILE naming a file that is absent -- check .env"
      ;;
  esac
  say ""
  # Do NOT assert that nothing was torn down. Compose applies a change service
  # by service, so a failure part-way through can leave an earlier service
  # already recreated and stopped -- publishing the database on a port the host
  # reserves does exactly that, and the old wording sent people away believing
  # their database was still up when it was not. Look, then say.
  local app_up=1 db_up=1
  container_running "$CONTAINER" || app_up=0
  if has_profile postgres; then container_running "$DB_CONTAINER" || db_up=0; fi
  if (( app_up && db_up )); then
    say "Nothing was torn down: what was running before this command still is."
  else
    warn "Part of this installation is NOT running now:"
    (( app_up )) || say "  * Innoslate            ($CONTAINER)"
    (( db_up ))  || say "  * the bundled database ($DB_CONTAINER)"
    say ""
    say "Your data is untouched. Undo the change above, then:"
    say "    ./innoslate start"
  fi
  say ""
  say "Confirm either way with:  ./innoslate doctor"
  say "If you are stuck, send ./innoslate doctor --report to support@innoslate.com."
  say ""
}

# The everyday form: try, and stop with an explanation if it will not go.
compose_up() {
  compose_up_try && return 0
  compose_up_explain
  exit 1
}

print_address() {
  local port bind url
  port="$(env_or INNOSLATE_PORT 8080)"
  bind="$(env_or INNOSLATE_BIND 0.0.0.0)"
  url="$(env_value KIT_PUBLIC_URL)"
  say ""
  if [[ -n "$url" ]]; then
    say "  Users open Innoslate at:  ${C_BOLD}${url}${C_RESET}"
    say "  ${C_DIM}(through your proxy; this machine serves it on ${bind}:${port})${C_RESET}"
  else
    say "  Open Innoslate at:  ${C_BOLD}http://localhost:${port}${C_RESET}"
    if [[ "$bind" == "0.0.0.0" ]]; then
      say "  ${C_DIM}(other machines on your network: http://<this-machine>:${port})${C_RESET}"
    else
      say "  ${C_DIM}(published on ${bind} only)${C_RESET}"
    fi
  fi
  if ! config_present; then
    say ""
    say "  First time here? The browser will show Innoslate's setup page."
    say "  ${C_DIM}Run ./innoslate setup-summary for exactly what to type into it.${C_RESET}"
  fi
  say ""
}

# =============================================================================
#  setup -- requirements in, a working configuration out
# =============================================================================

ask() { # ask <prompt> <default>  -> answer on stdout
  local prompt="$1" default="${2:-}" answer=""
  if [[ -n "$default" ]]; then
    printf '%s [%s]: ' "$prompt" "$default" >&2
  else
    printf '%s: ' "$prompt" >&2
  fi
  IFS= read -r answer || die "Cancelled. Nothing has been changed."
  answer="$(printf '%s' "$answer" | tr -d '\r')"
  [[ -z "$answer" ]] && answer="$default"
  printf '%s' "$answer"
}

# Like ask(), but the answer is not echoed to the terminal (read -s) and
# nothing about it is ever logged or printed back -- it only ever lives in
# .env, which the customer can open themselves. -s does not print the newline
# the Enter key would normally show, so one is added by hand.
#
# End of input returns non-zero rather than calling die here. Every caller
# reads this through "$(...)", and a die inside a command substitution exits
# only that subshell: the caller went on with an empty answer, asked again,
# and never stopped. Ctrl+D at this prompt, or answers piped in from a file,
# spun here for ever. The exit status is what crosses the subshell boundary,
# so the caller is what has to act on it.
ask_password() { # ask_password <prompt>  -> answer on stdout
  local prompt="$1" answer=""
  printf '%s: ' "$prompt" >&2
  IFS= read -rs answer || return 1
  say "" >&2
  printf '%s' "$answer" | tr -d '\r'
}

# Asks until a password is given. No strength rules on purpose: it refuses
# only blank input, the .env.example placeholder, and the one shape that
# cannot be written to .env at all -- so a password from the customer's own
# manager is never second-guessed. Nothing is echoed while typing, so it is
# asked for twice: a typo here would otherwise only surface later, as a
# password the customer cannot reproduce on Innoslate's setup page.
ask_new_db_password() {
  local pw again trimmed
  while :; do
    pw="$(ask_password "  Password")" || die "Cancelled. Nothing has been changed."
    trimmed="$(printf '%s' "$pw" | tr -d '[:space:]')"
    if [[ -z "$trimmed" ]]; then
      say "  A password is required." >&2
    elif [[ "$pw" == "CHANGE_ME" ]]; then
      say "  That is the .env.example placeholder, not a real password." >&2
    elif ! env_format_value "$pw" >/dev/null; then
      say "  A password with a single quote and also a dollar sign or double quote" >&2
      say "  cannot be stored in .env. Choose one without the single quote." >&2
    else
      again="$(ask_password "  Confirm password")" || die "Cancelled. Nothing has been changed."
      [[ "$pw" == "$again" ]] && break
      say "  The passwords did not match. Try again." >&2
    fi
  done
  printf '%s' "$pw"
}

ask_yes_no() { # ask_yes_no <prompt> <y|n default>
  local prompt="$1" default="${2:-y}" answer
  while :; do
    if [[ "$default" == y ]]; then
      printf '%s [Y/n]: ' "$prompt" >&2
    else
      printf '%s [y/N]: ' "$prompt" >&2
    fi
    IFS= read -r answer || die "Cancelled. Nothing has been changed."
    answer="$(printf '%s' "$answer" | tr -d '\r' | tr 'A-Z' 'a-z')"
    [[ -z "$answer" ]] && answer="$default"
    case "$answer" in
      y|yes) return 0 ;;
      n|no)  return 1 ;;
      *) say "  Please answer y or n." ;;
    esac
  done
}

# ask_choice <question> <option>...   -> the chosen number (1-based) on stdout
# The options are labelled a), b), c)... and answered with that letter, but
# the answer is handed back as a number, so callers match on 1, 2, 3.
ask_choice() {
  local question="$1"; shift
  local count=$# i answer before
  local letters=abcdefghijklmnopqrstuvwxyz
  local last="${letters:count-1:1}"
  say "" >&2
  printf '%s%s%s\n' "$C_BOLD" "$question" "$C_RESET" >&2
  i=1
  for opt in "$@"; do
    printf '  %s) %s\n' "${letters:i-1:1}" "$opt" >&2
    i=$((i + 1))
  done
  while :; do
    printf 'Choose a-%s: ' "$last" >&2
    IFS= read -r answer || die "Cancelled. Nothing has been changed."
    answer="$(printf '%s' "$answer" | tr -d '\r ' | tr 'A-Z' 'a-z')"
    if [[ "$answer" =~ ^[a-z]$ ]]; then
      before="${letters%%"$answer"*}"
      if (( ${#before} < count )); then
        printf '%s' "$(( ${#before} + 1 ))"
        return 0
      fi
    fi
    say "  Please type a letter between a and $last." >&2
  done
}

valid_port() { [[ "$1" =~ ^[0-9]+$ ]] && (( $1 >= 1 && $1 <= 65535 )); }

cmd_setup() {
  local existing=0
  [[ -f "$KIT_DIR/.env" ]] && existing=1

  say ""
  rule
  say "${C_BOLD}  Innoslate setup${C_RESET}"
  rule
  say ""
  say "  This asks a few questions and writes .env. No container is touched"
  say "  until you say so at the end."
  say ""

  if host_is_arm64; then
    if host_is_macos; then
      warn "This Mac has Apple Silicon; Innoslate is built for Intel and runs here"
      warn "under emulation. That is fine for evaluating, but the first start takes a"
      warn "few minutes. Turning on \"Use Rosetta for x86_64/amd64 emulation\" in"
      warn "Docker Desktop (Settings > General) makes it faster."
    else
      warn "This machine has an ARM processor, and Innoslate is built for Intel and"
      warn "AMD only. Unless QEMU emulation is registered on this host the container"
      warn "will fail to start, and emulated Java is too slow for real work."
      warn "On AWS, choose an Intel or AMD instance (t3, m5, m7i), not Graviton"
      warn "(t4g, m6g, m7g). Setup will still write the configuration if you continue."
    fi
    say ""
  fi

  if (( existing )); then
    warn "A configuration already exists in this folder."
    say ""
    say "  It currently describes:"
    say "    database:   $(describe_db_choice)"
    say "    reached by: $(describe_access_choice)"
    say "    address:    $(env_or INNOSLATE_BIND 0.0.0.0):$(env_or INNOSLATE_PORT 8080)"
    say "    version:    $(env_or INNOSLATE_VERSION latest)"
    say ""
    say "  Answering again replaces these settings; the rest of .env is kept."
    say ""
    ask_yes_no "  Reconfigure this installation?" n || {
      say ""
      say "Nothing has been changed."
      say "To see what to type into Innoslate's setup page, run:  ./innoslate setup-summary"
      say ""
      return 0
    }
  fi

  # --- 1. the database -----------------------------------------------------
  local db_choice
  db_choice="$(ask_choice "Which database should Innoslate use?" \
    "Run PostgreSQL in a container alongside Innoslate" \
    "A PostgreSQL that is already set up" \
    "A Microsoft SQL Server that is already set up")"

  local db_kind db_name="" db_user="" new_password=""
  case "$db_choice" in
    1)
      db_kind=bundled
      say ""
      say "  Its data will live in the Docker volume innoslate-postgres-data."
      local current_pw; current_pw="$(env_value POSTGRES_PASSWORD)"
      if [[ -n "$current_pw" && "$current_pw" != "CHANGE_ME" ]]; then
        say ""
        if ask_yes_no "  Keep the existing database password?" y; then
          # new_password stays empty; env_commit leaves POSTGRES_PASSWORD as it is
          :
        else
          say ""
          warn "The database keeps the password it was created with. A new one here only"
          warn "works if that database does not exist yet; otherwise Innoslate cannot connect."
          new_password="$(ask_new_db_password)"
        fi
      else
        say ""
        say "  Choose a password for it. You will type it into Innoslate's setup"
        say "  page; it is saved in .env as POSTGRES_PASSWORD."
        new_password="$(ask_new_db_password)"
      fi
      db_name="$(env_or POSTGRES_DB innoslate)"
      db_user="$(env_or POSTGRES_USER innoslate)"
      ;;
    2|3)
      [[ "$db_choice" == 2 ]] && db_kind=postgres || db_kind=sqlserver
      # Nothing about this database is asked for here. Innoslate connects with
      # what is typed into its own setup page, and only that; a copy in .env
      # would be one more thing to type and one more thing to fall out of step.
      say ""
      say "  Innoslate's setup page will ask for this database's host, port, name,"
      say "  user and password. Use a new empty database owned by that user, or one"
      say "  that already holds Innoslate's data (back it up first: it is upgraded"
      say "  if this version is newer)."
      say ""
      say "  If it runs on this machine, enter ${C_BOLD}host.docker.internal${C_RESET} as the host, not"
      say "  localhost: inside the container, localhost is the container itself."
      # A Linux host's own firewall -- ufw on Ubuntu, firewalld on RHEL -- denies
      # incoming connections by default, and a container reaching back to its own
      # host counts as incoming. It is the one cause that writes nothing to either
      # side's log: the database never sees the attempt, and Innoslate only times
      # out.
      if [[ "$(uname -s 2>/dev/null)" == Linux ]]; then
        say "  In that case this machine's firewall also has to allow the connection."
      fi
      say "  ${C_DIM}Details: ../docs/04-database.md${C_RESET}"
      ;;
  esac

  # --- 2. how people reach it ----------------------------------------------
  local access_choice
  access_choice="$(ask_choice "How will users reach Innoslate?" \
    "Only from this machine -- http://localhost:<port>" \
    "From other machines on the network, over plain HTTP" \
    "Through an HTTPS reverse proxy or load balancer we already have")"

  local access_mode bind proxy_location="" proxy_kind="" public_url=""
  case "$access_choice" in
    1) access_mode=local;   bind=127.0.0.1 ;;
    2) access_mode=network; bind=0.0.0.0
       say ""
       say "  ${C_YELLOW}Note:${C_RESET} plain HTTP sends passwords and project data unencrypted."
       say "  Fine for an evaluation on a trusted network. To add HTTPS later, see"
       say "  ../docs/02-https.md."
       ;;
    3) access_mode=proxy
       local where kind
       where="$(ask_choice "Where does that proxy run?" \
         "On this same machine" \
         "On a different machine, or it is a load balancer")"
       if [[ "$where" == 1 ]]; then
         proxy_location=same-host; bind=127.0.0.1
         say ""
         say "  Innoslate will listen on 127.0.0.1 only, so nobody can bypass the proxy."
       else
         proxy_location=other-host; bind=0.0.0.0
         say ""
         say "  ${C_YELLOW}Innoslate's plain-HTTP port will be open on the network.${C_RESET} Restrict"
         say "  it to the proxy with a firewall rule or AWS security group, or anyone"
         say "  can bypass the proxy and its HTTPS."
       fi
       kind="$(ask_choice "Which proxy is it?" \
         "nginx" "Apache HTTP Server" "IIS (Windows)" "Caddy" \
         "AWS Application Load Balancer" "Something else")"
       case "$kind" in
         1) proxy_kind=nginx ;; 2) proxy_kind=apache ;; 3) proxy_kind=iis ;;
         4) proxy_kind=caddy ;; 5) proxy_kind=alb ;;   *) proxy_kind=other ;;
       esac
       while :; do
         public_url="$(ask "  The address people will type, e.g. https://innoslate.example.com" \
                           "$(env_value KIT_PUBLIC_URL)")"
         case "$public_url" in
           https://*|http://*) break ;;
           "") say "  An address is required." ;;
           *)  say "  Start it with https:// (or http:// if the proxy serves plain HTTP)." ;;
         esac
       done
       ;;
  esac

  # --- 3. the port ---------------------------------------------------------
  local port
  say ""
  if [[ "$access_mode" == proxy ]]; then
    say "  Your proxy will forward to Innoslate on this machine's port:"
  else
    say "  The port on this machine that serves Innoslate:"
  fi
  local current_port; current_port="$(env_or INNOSLATE_PORT 8080)"
  while :; do
    port="$(ask "  Port" "$current_port")"
    if ! valid_port "$port"; then say "  A port is a number between 1 and 65535."; continue; fi
    # Check unless this is the port an existing installation already publishes:
    # on a first install there is no "current" port to be already using, so
    # accepting the default must still be checked.
    if { (( ! existing )) || [[ "$port" != "$current_port" ]]; } && port_in_use "$port"; then
      warn "Something is already listening on port $port; Innoslate could not start on it."
      ask_yes_no "  Use it anyway?" n && break
      continue
    fi
    break
  done

  # --- stage everything ----------------------------------------------------
  ENV_PENDING=()
  env_stage INNOSLATE_PORT "$port"
  env_stage INNOSLATE_BIND "$bind"
  env_stage KIT_ACCESS_MODE "$access_mode"
  env_stage KIT_DB_KIND "$db_kind"
  # No longer asked for (see the database question), so a value left by an
  # earlier setup could only disagree with what Innoslate really uses.
  env_stage KIT_DB_HOST __DELETE__
  env_stage KIT_DB_PORT __DELETE__
  env_stage KIT_DB_NAME __DELETE__
  env_stage KIT_DB_USER __DELETE__

  if [[ "$db_kind" == bundled ]]; then
    env_stage COMPOSE_PROFILES postgres
    env_stage POSTGRES_DB "$db_name"
    env_stage POSTGRES_USER "$db_user"
    [[ -n "$new_password" ]] && env_stage POSTGRES_PASSWORD "$new_password"
  else
    env_stage COMPOSE_PROFILES ""
  fi

  if [[ "$access_mode" == proxy ]]; then
    env_stage KIT_PROXY_LOCATION "$proxy_location"
    env_stage KIT_PROXY_KIND "$proxy_kind"
    env_stage KIT_PUBLIC_URL "$public_url"
    env_stage COMPOSE_PATH_SEPARATOR ","
    local files="compose.yaml,compose.proxy.yaml"
    # Naming files in COMPOSE_FILE stops Compose loading compose.override.yaml
    # by itself, so keep it in the list when the customer has one.
    [[ -f "$KIT_DIR/compose.override.yaml" ]] && files="$files,compose.override.yaml"
    [[ -f "$KIT_DIR/compose.override.yml"  ]] && files="$files,compose.override.yml"
    env_stage COMPOSE_FILE "$files"
  else
    env_stage KIT_PROXY_LOCATION __DELETE__
    env_stage KIT_PROXY_KIND __DELETE__
    env_stage KIT_PUBLIC_URL __DELETE__
    env_stage COMPOSE_FILE __DELETE__
    env_stage COMPOSE_PATH_SEPARATOR __DELETE__
  fi

  # --- confirm -------------------------------------------------------------
  say ""
  say "  Database ............. $(describe_db_kind "$db_kind")"
  say "  Reached by ........... $(describe_access "$access_mode" "$proxy_kind")"
  [[ -n "$public_url" ]] && say "                         $public_url"
  say "  Published on ......... $bind:$port"
  say "  Data folder .......... $(data_dir)"
  say ""
  if ! ask_yes_no "  Save these settings?" y; then
    ENV_PENDING=()
    say ""
    say "Cancelled. Nothing has been changed."
    say ""
    return 0
  fi

  env_commit
  ensure_data_dirs

  # Prove the result is something Compose accepts before claiming success.
  # Only a failure is worth a line.
  if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
    require_compose
    if ! dc config >/dev/null 2>&1; then
      say ""
      warn "Docker Compose did not accept the result:"
      dc config 2>&1 | sed 's/^/      /' | head -20
      warn "Run ./innoslate doctor for help."
    fi
  fi

  if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
    say ""
    if ask_yes_no "  Start Innoslate now?" y; then
      say ""
      cmd_start
    else
      say ""
      say "  Start it when you are ready with:  ./innoslate start"
      setup_pending_ownership_note
      say ""
    fi
  else
    say ""
    say "  Docker is not running yet. Start Docker, then run:  ./innoslate start"
    setup_pending_ownership_note
    say ""
  fi

  cmd_setup_summary
}

# Setup could not give Innoslate its data folders yet (no image to do it with).
# ./innoslate start will; anyone starting it another way has to do it first.
setup_pending_ownership_note() {
  [[ "$(uname -s 2>/dev/null)" == Linux ]] || return 0
  (( ! OWNERSHIP_WARNED )) && data_dirs_need_fix || return 0
  local dir; dir="$(data_dir)"
  say "  ${C_DIM}It also gives Innoslate its data folders. Starting it with docker compose${C_RESET}"
  say "  ${C_DIM}instead? Run this first:${C_RESET}"
  say "      $(ownership_fix_hint "$dir/filestore" "$dir/backups" "$dir/logs")"
}

describe_db_kind() {
  case "$1" in
    bundled)   printf 'the PostgreSQL container this kit runs' ;;
    postgres)  printf 'your own PostgreSQL server' ;;
    sqlserver) printf 'your own Microsoft SQL Server' ;;
    *)         printf 'not recorded' ;;
  esac
}

describe_access() {
  case "$1" in
    local)   printf 'this machine only, over HTTP' ;;
    network) printf 'the network, over plain HTTP' ;;
    proxy)   printf 'an existing %s reverse proxy' "$(describe_proxy_kind "${2:-other}")" ;;
    *)       printf 'not recorded' ;;
  esac
}

describe_proxy_kind() {
  case "$1" in
    nginx) printf 'nginx' ;; apache) printf 'Apache' ;; iis) printf 'IIS' ;;
    caddy) printf 'Caddy' ;; alb) printf 'AWS ALB' ;; *) printf 'HTTPS' ;;
  esac
}

describe_db_choice()     { describe_db_kind "$(env_or KIT_DB_KIND unknown)"; }
describe_access_choice() { describe_access "$(env_or KIT_ACCESS_MODE unknown)" "$(env_or KIT_PROXY_KIND other)"; }

# -----------------------------------------------------------------------------
#  The scenario-specific checklist. Printed at the end of setup, and available
#  on its own afterwards, because the values it lists are exactly the ones
#  people come back for.
# -----------------------------------------------------------------------------
cmd_setup_summary() {
  local db_kind access port url
  db_kind="$(env_or KIT_DB_KIND bundled)"
  access="$(env_or KIT_ACCESS_MODE local)"
  port="$(env_or INNOSLATE_PORT 8080)"
  url="$(env_value KIT_PUBLIC_URL)"

  say ""
  rule
  say "${C_BOLD}  What to do next${C_RESET}"
  rule
  say ""

  local n=1
  say "  ${C_BOLD}${n}. Open Innoslate${C_RESET}"
  say "     http://localhost:${port}"
  if [[ "$access" == proxy ]]; then
    say "     ${C_DIM}Use this direct address for the setup page. Point your proxy at it${C_RESET}"
    say "     ${C_DIM}afterwards; users then arrive at ${url}.${C_RESET}"
  fi
  say ""

  n=$((n+1))
  say "  ${C_BOLD}${n}. Fill in Innoslate's setup page${C_RESET}"
  say ""
  case "$db_kind" in
    bundled)
      say "     Database type ......... PostgreSQL"
      say "     Host .................. ${C_BOLD}postgres${C_RESET}   ${C_DIM}(the service name -- not localhost)${C_RESET}"
      say "     Port .................. 5432"
      say "     Database name ......... $(env_or POSTGRES_DB innoslate)"
      say "     Username .............. $(env_or POSTGRES_USER innoslate)"
      say "     Password .............. ${C_DIM}POSTGRES_PASSWORD in .env${C_RESET}"
      ;;
    postgres|sqlserver)
      say "     Database type ......... $( [[ "$db_kind" == postgres ]] && echo PostgreSQL || echo "Microsoft SQL Server" )"
      # Setup no longer records these, but a .env written by hand may still
      # carry them.
      if [[ -n "$(env_value KIT_DB_HOST)" ]]; then
        say "     Host .................. ${C_BOLD}$(env_value KIT_DB_HOST)${C_RESET}"
        say "     Port .................. $(env_value KIT_DB_PORT)"
        say "     Database name ......... $(env_value KIT_DB_NAME)"
        say "     Username .............. $(env_value KIT_DB_USER)"
      else
        say "     Host .................. your database server ${C_DIM}(host.docker.internal if it is this machine)${C_RESET}"
        say "     Port, name, username .. your database's"
      fi
      say "     Password .............. ${C_DIM}your database's (this kit never stores it)${C_RESET}"
      ;;
  esac
  say ""
  say "     Filestore path ........ ${C_BOLD}/usr/local/innoslate/filestore${C_RESET}"
  say "     ${C_DIM}(inside the container; $(data_dir)/filestore on this machine)${C_RESET}"
  say ""
  say "     You will also be asked for your Innoslate license key."
  say ""

  if [[ "$access" == proxy ]]; then
    n=$((n+1))
    say "  ${C_BOLD}${n}. Configure your proxy${C_RESET}"
    local kind; kind="$(env_or KIT_PROXY_KIND other)"
    say "     Send $url to http://$( [[ "$(env_value KIT_PROXY_LOCATION)" == same-host ]] && echo 127.0.0.1 || echo "<this-machine>" ):${port}"
    say "     and forward these headers:  Host, X-Forwarded-For, X-Forwarded-Proto"
    say ""
    case "$kind" in
      nginx)  say "     Ready-made nginx server block:  ../docs/03-existing-reverse-proxy.md#nginx" ;;
      apache) say "     Ready-made Apache VirtualHost:  ../docs/03-existing-reverse-proxy.md#apache-httpd" ;;
      iis)    say "     IIS / ARR walkthrough:          ../docs/03-existing-reverse-proxy.md#iis-windows" ;;
      caddy)  say "     Caddyfile:                      ../docs/03-existing-reverse-proxy.md#caddy" ;;
      alb)    say "     ALB target group and listener:  ../docs/03-existing-reverse-proxy.md#aws-application-load-balancer" ;;
      *)      say "     What any proxy has to do:       ../docs/03-existing-reverse-proxy.md" ;;
    esac
    say ""
    say "     ${C_DIM}The TLS certificate goes on your proxy, not on Innoslate.${C_RESET}"
    say ""
  fi

  n=$((n+1))
  say "  ${C_BOLD}${n}. Save the configuration, then check it${C_RESET}"
  say "     ./innoslate save-config     ${C_DIM}once the setup page is finished${C_RESET}"
  say "     ./innoslate doctor          ${C_DIM}confirms the whole thing end to end${C_RESET}"
  say ""
  say "  ${C_DIM}Which files here are your installation:  ./innoslate files${C_RESET}"
  say ""
  rule
  say ""
}

# =============================================================================
#  Pre-flight checks, so a bad start fails with an explanation instead of a
#  wall of Docker output.
# =============================================================================
require_env_file() {
  [[ -f "$KIT_DIR/.env" ]] && return 0
  die \
"There is no configuration in this folder yet.

Run:

    ./innoslate setup

or copy .env.example to .env, edit it, and try again."
}

# The files COMPOSE_FILE names that are not in this folder, one per line.
# Compose's own message for this is a bare "no such file" with no hint that
# COMPOSE_FILE is where the name came from, or that it is only needed behind a
# reverse proxy -- so every command that hits it explains it from here instead.
missing_compose_files() {
  local cf; cf="$(env_value COMPOSE_FILE)"
  [[ -n "$cf" ]] || return 0
  local sep f old_ifs
  sep="$(env_or COMPOSE_PATH_SEPARATOR ,)"
  # Split in this shell, not a pipeline, so a caller's "die" is not swallowed
  # by a subshell.
  old_ifs="$IFS"; IFS="$sep"
  for f in $cf; do
    IFS="$old_ifs"
    if [[ -n "$f" && ! -f "$KIT_DIR/$f" && ! -f "$f" ]]; then printf '%s\n' "$f"; fi
    IFS="$sep"
  done
  IFS="$old_ifs"
}

# What to tell somebody whose COMPOSE_FILE names a file that is not there.
# Shared so that start, status and doctor say the same thing; the raw Compose
# error they used to show instead sends people looking at Docker.
missing_compose_files_advice() { # missing_compose_files_advice <missing-list>
  say "COMPOSE_FILE in .env names a file that is not in this folder:"
  say ""
  # Split on newlines only. The list arrives newline-separated, and an
  # unquoted expansion here splits on spaces too -- so "my file.yaml", a
  # name COMPOSE_FILE can legitimately hold, printed as two lines that each
  # looked like a missing file of their own.
  while IFS= read -r missing_file; do
    if [[ -n "$missing_file" ]]; then say "    $missing_file"; fi
  done <<< "$1"
  say ""
  say "COMPOSE_FILE is only needed behind a reverse proxy. If you do not use one,"
  say "run ./innoslate setup, or delete the COMPOSE_FILE and COMPOSE_PATH_SEPARATOR"
  say "lines from .env. Otherwise put the missing file back."
}

preflight() {
  if has_profile postgres; then
    local pw; pw="$(env_value POSTGRES_PASSWORD)"
    [[ -z "$pw" ]] && die \
"POSTGRES_PASSWORD is empty in .env, and the bundled database needs one.
Run ./innoslate setup to choose one, or set it yourself in .env."
    [[ "$pw" == "CHANGE_ME" ]] && die \
"POSTGRES_PASSWORD in .env is still the placeholder \"CHANGE_ME\".
Run ./innoslate setup to choose a real one, or set it yourself in .env."
  fi

  # An "if" rather than "&&" on purpose: under set -e, an && list that fails as
  # the last statement of a function exits the whole script, silently.
  local port; port="$(env_or INNOSLATE_PORT 8080)"
  if port_in_use "$port"; then
    die "Something is already listening on port $port on this machine.

Stop that program, or change INNOSLATE_PORT in .env (for example to 8090) and
start again. ./innoslate setup can do it."
  fi

  # COMPOSE_FILE naming a file that is not there is a hard Compose error with a
  # bare "no such file" message; say which file and why it is listed.
  local missing; missing="$(missing_compose_files)"
  if [[ -n "$missing" ]]; then
    die "$(missing_compose_files_advice "$missing")"
  fi
}

# =============================================================================
#  Commands
# =============================================================================

# Shared by start and restart: bring the stack up, put the configuration back
# if the container came up blank, and be honest about whether it worked.
# Reporting an address for a container that never answered is how a broken
# install gets mistaken for a working one.
bring_up_and_report() {
  # With a first argument of 1, the container is restarted when "up -d" turns
  # out to have left it alone -- see cmd_restart.
  local restart_if_untouched="${1:-0}"
  local ready=0 boot_before=""
  (( restart_if_untouched )) && boot_before="$(container_boot_id)"
  compose_up
  # An image is certainly on this machine now, which it may not have been when
  # the data folders were prepared. If that lets the ownership finally be put
  # right, give Tomcat a clean start rather than leaving it holding the log
  # file it was already refused.
  if late_ownership_repair; then
    dc restart "$SERVICE"
    boot_before=""
  fi
  if (( restart_if_untouched )) && [[ -n "$boot_before" && "$boot_before" == "$(container_boot_id)" ]]; then
    dc restart "$SERVICE"
  fi
  wait_until_ready && ready=1
  config_guard_restore || warn "Innoslate is running but has no configuration -- see ./innoslate doctor"

  # The restore path restarts the container, so ask again rather than trusting
  # the answer from before it.
  [[ "$(health)" == healthy ]] && ready=1

  # A container can be healthy and still be unable to write a single thing it
  # is given. Saying "up" and printing an address for that is how a broken
  # install gets mistaken for a working one -- uploads, Tomcat's logs and every
  # saved configuration fail later, one at a time, far from the cause.
  # Read into an array rather than split on spaces: DATA_DIR is a path the
  # customer chose, and it is allowed to contain them.
  local stuck=() line
  while IFS= read -r line; do
    [[ -n "$line" ]] && stuck[${#stuck[@]}]="$line"
  done < <(unwritable_data_dirs)
  # Unless late_ownership_repair has just said what to run.
  if (( ${#stuck[@]} && ! OWNERSHIP_WARNED )); then
    say ""
    warn "Innoslate cannot write to these folders:"
    printf '    %s\n' "${stuck[@]}" >&2
    warn "It runs as user id $(innoslate_uid), and they belong to someone else."
    warn "Uploads, logs and saved configurations will fail until you run:"
    warn "    $(ownership_fix_hint "${stuck[@]}")"
    warn "then ./innoslate restart."
    say ""
  fi

  if (( ready )); then
    print_address
    return 0
  fi

  say ""
  warn "Innoslate did not come up."
  say ""
  say "  It is $( [[ "$(health)" == missing ]] && echo "not running" || echo "running but not answering" )."
  say "  Nothing has been deleted -- your database and filestore are untouched."
  say ""
  say "  Next:"
  say "    1. ./innoslate logs        see why"
  say "    2. ./innoslate doctor      the usual checks, with what to do about them"
  say "    3. Send ./innoslate doctor --report to support@innoslate.com"
  say ""
  return 1
}

cmd_start() {
  require_docker; require_env_file; guard_foreign_installation; ensure_data_dirs; preflight
  config_guard_save
  bring_up_and_report
}

cmd_stop() {
  require_docker; require_env_file; guard_foreign_installation
  # Saving now means a later "down", or a container removed by hand, cannot
  # take the configuration with it.
  config_guard_save
  dc stop
}

cmd_restart() {
  require_docker; require_env_file; guard_foreign_installation; ensure_data_dirs; preflight
  # "up -d" first, so any change you made to .env is picked up. That means
  # Compose may replace the container, which is why the configuration is
  # written out first and put back afterwards.
  #
  # But when neither .env nor the compose files have changed, "up -d" is a
  # no-op: it leaves the running container exactly as it was. Anything changed
  # *inside* the container -- a setting appended to settings.properties, an
  # edited kit/tomcat/server.xml -- is only read while Tomcat starts, so the
  # container is restarted explicitly in that case. Without it "restart" would
  # report success having changed nothing.
  config_guard_save
  bring_up_and_report 1
}

cmd_down() {
  require_docker; require_env_file; guard_foreign_installation
  # The containers are about to be removed, and Innoslate's configuration is
  # inside one of them. Write it out first -- without this, "down" quietly
  # loses it, whatever the message says.
  config_guard_save

  # The one case the guard cannot cover: a stopped container cannot be asked
  # for its configuration. Say so rather than printing a reassurance that might
  # not be true.
  if ! container_running && container_exists && [[ -z "$(newest_saved_config)" ]]; then
    say ""
    warn "Innoslate is stopped, so its configuration cannot be saved, and no copy"
    warn "exists in $(backups_dir). If it was configured, removing the container"
    warn "means completing Innoslate's setup page again. The database and"
    warn "filestore are not affected."
    warn "To keep it:  ./innoslate start   then   ./innoslate down"
    say ""
  fi

  dc down --remove-orphans
}

# -----------------------------------------------------------------------------
#  update -- transactional: .env is only changed once the new image is here,
#  and is put back if the new version cannot start.
# -----------------------------------------------------------------------------
valid_image_tag() { [[ "$1" =~ ^[A-Za-z0-9_][A-Za-z0-9._-]{0,126}$ ]]; }

cmd_update() {
  require_docker; require_env_file; guard_foreign_installation; ensure_data_dirs
  local requested="${1:-}"
  local current target image
  current="$(env_or INNOSLATE_VERSION latest)"
  target="${requested:-$current}"
  image="$(env_or INNOSLATE_IMAGE innoslate/innoslate)"

  if ! valid_image_tag "$target"; then
    die "\"$target\" is not a valid version tag.

A tag is letters, digits, dots, dashes and underscores -- for example 4.15.0.0
or latest. Released versions: https://hub.docker.com/r/innoslate/innoslate"
  fi

  info "Updating Innoslate from $current to $target"

  # 1. Get the image FIRST. Nothing about the deployment has changed yet, so a
  #    failure here is genuinely a no-op -- including .env.
  if ! docker pull "$image:$target"; then
    say ""
    warn "That image could not be downloaded."
    say ""
    say "  Nothing has been changed. Innoslate is still running $current, and"
    say "  INNOSLATE_VERSION in .env still says $current."
    say ""
    say "  Check that the version exists:"
    say "      https://hub.docker.com/r/innoslate/innoslate"
    say "  If this machine has no internet access and you were given a .tar file,"
    say "  load it, back up, then set INNOSLATE_VERSION in .env to that version:"
    say "      docker load -i innoslate-<version>.tar"
    say "      ./innoslate backup"
    say "      ./innoslate start"
    say ""
    return 1
  fi

  # 2. Back up what is easy to lose, before anything is replaced.
  cmd_backup || die \
"The backup before the update did not complete, so the update has stopped.

Nothing has been changed: INNOSLATE_VERSION in .env still says $current and
the container has not been replaced. The messages above say what was missing."

  # 3. Save the live configuration, then commit the new version.
  config_guard_save
  if [[ "$target" != "$current" ]]; then
    env_set_one INNOSLATE_VERSION "$target"
  fi

  # 4. Replace the container.
  if ! compose_up_try; then
    compose_up_explain
    update_rollback "$current" "$target" "Docker could not start the new version."
    return 1
  fi
  # As in bring_up_and_report: the image the ownership fix needs is here now.
  if late_ownership_repair; then
    dc restart "$SERVICE"
  fi

  if ! wait_until_ready 600; then
    update_rollback "$current" "$target" "The new version did not become healthy."
    return 1
  fi

  # 5. A new container starts with the image's defaults, so the saved
  #    configuration has to be loaded back into it. These are the same two
  #    commands you would run by hand without this script:
  #      docker compose exec innoslate innoslate configure list
  #      docker compose exec innoslate innoslate configure restore <file>
  if ! config_guard_restore; then
    say ""
    warn "The new version is running, but its configuration could not be restored."
    say ""
    say "  Innoslate is up on the new image and will show its setup page. Your"
    say "  database and filestore are untouched. To go back:"
    say "      ./innoslate update $current"
    say "  Or restore by hand from the backup taken a moment ago:"
    say "      ./innoslate restore"
    say ""
    return 1
  fi

  # 6. Only now is it true.
  ok "Updated to $(running_version)"
  print_address
}

# Puts INNOSLATE_VERSION back and tries to bring the previous version up again.
# Always leaves the customer with an accurate account of where they stand.
update_rollback() {
  local previous="$1" attempted="$2" reason="$3"
  say ""
  warn "$reason"
  say ""
  info "Putting INNOSLATE_VERSION back to $previous and restarting that version"
  env_set_one INNOSLATE_VERSION "$previous"

  local recovered=0
  if compose_up_try >/dev/null 2>&1 && wait_until_ready 600 >/dev/null 2>&1; then
    config_guard_restore >/dev/null 2>&1 || true
    config_present && recovered=1
  fi

  say ""
  if (( recovered )); then
    ok "Innoslate $previous is running again with its configuration."
    say ""
    say "  The update to $attempted was not applied. .env is back to $previous."
    say "  Look at what went wrong with:  ./innoslate logs"
  else
    warn "Innoslate $previous could not be brought back automatically."
    say ""
    say "  The update to $attempted was not applied, and .env has been set back"
    say "  to INNOSLATE_VERSION=$previous."
    say ""
    say "  Your data is intact: the database and $(data_dir)/filestore were not"
    say "  touched, and a backup was taken before the update. List backups with:"
    say "      ./innoslate restore"
    say ""
    say "  Recover with:"
    say "      ./innoslate logs             see why it will not start"
    say "      ./innoslate start            try again"
    say "      ./innoslate restore <name>   put the configuration and database back"
    say ""
    say "  Send ./innoslate doctor --report to support@innoslate.com if it stays down."
  fi
  say ""
}

cmd_status() {
  require_docker; require_env_file
  # Compose refuses to do anything at all while a file it is told to read is
  # absent, so there is no status to show -- only the reason.
  local missing_files; missing_files="$(missing_compose_files)"
  if [[ -n "$missing_files" ]]; then
    die "$(missing_compose_files_advice "$missing_files")"
  fi
  local other; other="$(foreign_install_dir)"
  if [[ -n "$other" ]]; then
    warn "These containers belong to a different installation, in"
    warn "    $other"
    warn "so what follows describes that one. Commands here will not touch them."
    say ""
  fi
  info "Containers"
  dc ps
  say ""
  info "Innoslate"
  say "  image:   $(running_version)"
  say "  health:  $(health)"
  # "docker compose ps" only lists what is up, so a stopped database simply
  # vanishes from the table above -- leaving Innoslate reported as healthy and
  # nothing at all said about the database it cannot reach. Say it plainly.
  if has_profile postgres && ! container_running "$DB_CONTAINER"; then
    say ""
    warn "The bundled database is NOT running -- Innoslate cannot reach its data."
    say "  Start it with:  ./innoslate start"
    say "  Then check:     ./innoslate doctor"
  fi
  local dir; dir="$(data_dir)"
  say ""
  info "Your data"
  say "  filestore:      $dir/filestore"
  say "  backups:        $dir/backups   (saved configurations, database dumps)"
  say "  logs:           $dir/logs"
  if has_profile postgres; then
    say "  database:       bundled PostgreSQL, in the Docker volume innoslate-postgres-data"
  else
    say "  database:       $(describe_db_choice)"
  fi
  if [[ "$(health)" == healthy ]]; then print_address; fi
}

cmd_logs() {
  require_docker; require_env_file
  local svc="${1:-}"
  if [[ -n "$svc" ]]; then dc logs -f --tail=200 "$svc"; else dc logs -f --tail=200; fi
}

cmd_shell() {
  require_docker
  info "Opening a shell inside the Innoslate container (type exit to leave)"
  docker exec -it "$CONTAINER" bash
}

# =============================================================================
#  backup / restore
#
#  This backs up the two things that are easy to lose: the configuration file,
#  and the bundled database if you use one. The filestore is deliberately left
#  out -- it is a plain folder on this machine and can be very large, so it
#  belongs in whatever backup tool already protects this server's disks. Use
#  "./innoslate migrate export" when you do want everything in one bundle.
# =============================================================================
# True when a file is a plain-text pg_dump that reaches its end.
#
# pg_dump writes a "database dump complete" trailer as its last act, so a dump
# without one stopped early. Nothing else in the file says so: a dump cut off by
# a full disk -- during the dump itself, or during the copy out of the container
# -- is real, readable, and restores cleanly as far as it goes, which is the
# worst way for a backup to be wrong. Both commands that write a dump check it,
# and both that load one check it again.
DUMP_TRAILER='PostgreSQL database dump complete'
dump_is_complete() { # dump_is_complete <path>
  local tail_text
  [[ -s "$1" ]] || return 1
  # Matched in the shell rather than piped into grep -q: this script runs under
  # "set -o pipefail", where grep -q exits at the first match, the writer dies
  # of SIGPIPE, and the pipeline reports failure BECAUSE the text was found.
  tail_text="$(tail -c 400 -- "$1" 2>/dev/null)"
  [[ "$tail_text" == *"$DUMP_TRAILER"* ]]
}

# What to say about one that does not. Shared so the wording cannot drift.
explain_short_dump() { # explain_short_dump <path>
  local size; size="$(wc -c < "$1" 2>/dev/null | tr -d ' ')"
  warn "the database dump is not complete: $1"
  say "  It is ${size:-0} bytes and lacks pg_dump's closing line, so it stopped"
  say "  part-way -- usually because the disk filled up (check: df -h $(data_dir))."
  say "  Free some space, then run it again."
}

# Loads a .sql dump into the bundled PostgreSQL, and is honest about whether it
# worked. Two psql settings that are not the default matter here:
#
#   ON_ERROR_STOP=1      Without it, psql prints an error, carries on to the
#                        next statement, and still exits 0 -- so a dump that is
#                        truncated, hand-edited or from an incompatible version
#                        restores PARTIALLY and reports success.
#   --single-transaction Makes the whole dump all-or-nothing, so a failure
#                        leaves the database exactly as it was rather than
#                        half-replaced. pg_dump writes plain SQL with no
#                        transaction control of its own, and PostgreSQL runs
#                        DDL transactionally, so wrapping it is safe.
#
# The customer types the word "replace" to get here, having been told this
# overwrites their database. Reporting success when it did not work is the
# worst thing this script could do, so the exit status is checked rather than
# assumed.
load_sql_dump() { # load_sql_dump <path to a .sql file on this machine>
  local file="$1" db user rc=0
  db="$(env_or POSTGRES_DB innoslate)"; user="$(env_or POSTGRES_USER innoslate)"

  # Checked before anything is loaded rather than after. A dump that stops
  # part-way still applies without error as far as it goes -- psql reaches the
  # end of the file and commits -- so the tables it never reached are simply
  # gone, and the restore reports success.
  if ! dump_is_complete "$file"; then
    explain_short_dump "$file"
    DUMP_ALREADY_EXPLAINED=1
    say ""
    say "  Nothing has been loaded and the database is untouched. Restore an"
    say "  earlier backup instead: ./innoslate restore"
    say ""
    return 1
  fi

  dc cp "$(host_path "$file")" "$DB_SERVICE:/tmp/innoslate-restore.sql" || {
    warn "could not copy the dump into the database container:"
    warn "  $(host_path "$file")"
    warn "Docker could not read that path. Check the file is there and readable."
    return 1
  }

  # psql's own errors go to stderr and are left visible on purpose: they name
  # the statement that failed, which is the only useful thing to go on.
  set +e
  dc exec -T "$DB_SERVICE" psql -U "$user" -d "$db" \
    -v ON_ERROR_STOP=1 --single-transaction -q -o /dev/null \
    -f /tmp/innoslate-restore.sql
  rc=$?
  set -e

  dc exec -T "$DB_SERVICE" rm -f /tmp/innoslate-restore.sql >/dev/null 2>&1 || true
  return $rc
}

# What to say when it did not work. Shared by restore and migrate import so the
# two cannot drift apart.
DUMP_ALREADY_EXPLAINED=0
explain_dump_failure() {
  # A dump refused for being short has already been explained, and psql never
  # ran, so "psql's error is above" would be pointing at nothing.
  if (( DUMP_ALREADY_EXPLAINED )); then DUMP_ALREADY_EXPLAINED=0; return 0; fi
  say ""
  warn "The database was NOT loaded. psql's error is above."
  say ""
  say "  Your database is unchanged: the dump is applied as a single transaction."
  say ""
  say "  The usual causes:"
  say "    * the dump came from a newer PostgreSQL than this container runs"
  say "      -- check POSTGRES_IMAGE in .env against the machine it came from"
  say "    * the file is truncated, or was opened and saved by an editor"
  say "    * POSTGRES_USER in .env is not the user that owns those objects"
  say ""
}

cmd_backup() {
  require_docker; require_env_file; guard_foreign_read backup

  # Everything a backup holds is read out of the running containers: Innoslate
  # writes its own configuration out, and the dump comes from the database.
  # Stopping a service before backing it up is a reasonable habit, and here it
  # produced a folder with nothing in it, announced as "backup complete".
  # Refusing is far better than that: a refusal is noticed the day it happens,
  # and an empty folder is noticed the day it is needed.
  if ! container_running; then
    die \
"Innoslate is not running, and backups are taken from the running containers.

Start it, then back it up:
    ./innoslate start
    ./innoslate backup

The filestore, $(data_dir)/filestore, is not in backups; copy that folder at any
time."
  fi
  if has_profile postgres && ! container_running "$DB_CONTAINER"; then
    die \
"The bundled database is not running, so a backup taken now would not contain
the database.

Start it, then back it up:
    ./innoslate start
    ./innoslate backup"
  fi

  local dir stamp dest
  dir="$(data_dir)"; stamp="$(date +%Y%m%d-%H%M%S)"
  dest="$dir/backups/backup-$stamp"
  mkdir -p "$dest" 2>/dev/null || die \
"Could not create the backup folder \"$dest\".

$dir/backups has to be writable by you as well as by Innoslate. Run:
    $(ownership_fix_hint "$dir/filestore" "$dir/backups" "$dir/logs")
then run this again."

  # Ask Innoslate to write out its own configuration, then take a copy of it.
  # Using the image's tool rather than reaching into the container means this
  # is the same operation you would do by hand.
  #
  # save_config's own message is kept and shown when it fails, rather than
  # replaced with a guess: "setup has not been completed" is only one of the
  # reasons it can refuse, and an unwritable backups folder reported as an
  # unfinished wizard sends people looking in the wrong place entirely.
  local save_out
  if save_out="$(save_config 2>&1)"; then
    local newest; newest="$(newest_saved_config)"
    if ! cp "$dir/backups/$newest" "$dest/settings.properties" 2>/dev/null; then
      rm -f "$dest/settings.properties"
      warn "the configuration could not be written into $dest -- the disk may be full"
    fi
  else
    warn "the configuration was not backed up:"
    printf '%s\n' "$save_out" | sed 's/^/      /' >&2
  fi

  if has_profile postgres && container_running "$DB_CONTAINER"; then
    local db user
    db="$(env_or POSTGRES_DB innoslate)"; user="$(env_or POSTGRES_USER innoslate)"
    # The dump is written inside the container and then copied out, rather than
    # redirected into a file here. Redirecting works in a POSIX shell but
    # mangles the file on Windows, and writing straight into the mounted folder
    # would need the database container to have write access to a folder owned
    # by Innoslate. Copying it out avoids both problems.
    if dc exec -T "$DB_SERVICE" sh -c \
         "pg_dump -U '$user' -d '$db' --clean --if-exists > /tmp/innoslate-dump.sql" \
         2>"$dest/database.err"; then
      # Neither the copy out nor the dump itself is taken on trust. Both fail
      # silently on a full disk: the copy leaves nothing or leaves a file that
      # stops mid-statement, and either one used to be announced as a dump.
      if ! dc cp "$DB_SERVICE:/tmp/innoslate-dump.sql" "$(host_path "$dest/database.sql")"; then
        printf '%s\n' "the dump could not be copied out of the database container" \
          >> "$dest/database.err"
        warn "the dump could not be copied out of the database container;"
        warn "details in $dest/database.err"
        rm -f "$dest/database.sql"
      elif ! dump_is_complete "$dest/database.sql"; then
        explain_short_dump "$dest/database.sql"
        # Removed, not kept: a short dump in a backup folder is indistinguishable
        # from a good one until the day somebody restores it.
        rm -f "$dest/database.sql"
      else
        rm -f "$dest/database.err"
      fi
      dc exec -T "$DB_SERVICE" rm -f /tmp/innoslate-dump.sql || true
    else
      warn "database dump failed; details in $dest/database.err"
    fi
  elif has_profile postgres; then
    warn "the database was NOT backed up: its container is not running"
  fi

  {
    say "Innoslate backup"
    say "taken:    $(date)"
    say "image:    $(running_version)"
    say "profiles: $(profiles)"
    say "kit:      $KIT_VERSION"
    say ""
    say "Contains:"
    if [[ -f "$dest/settings.properties" ]]; then
      say "  settings.properties -- Innoslate's configuration"
    elif config_present; then
      say "  (NO CONFIGURATION -- it could not be read when this was taken)"
    else
      say "  (no configuration -- the setup page had not been finished yet)"
    fi
    if [[ -f "$dest/database.sql" ]]; then
      say "  database.sql        -- a dump of the bundled PostgreSQL"
    elif has_profile postgres; then
      say "  (NO DATABASE -- it could not be dumped when this was taken)"
    else
      say "  (no database -- this installation uses your own server)"
    fi
    say ""
    say "Restore with:  ./innoslate restore backup-$stamp"
    say ""
    say "Or by hand, without this kit: copy settings.properties into the"
    say "backups folder the container mounts, then run"
    say "  docker exec innoslate innoslate configure restore <that filename>"
    say "  docker restart innoslate"
    say ""
    say "Not included: the filestore, at $dir/filestore -- copy that folder"
    say "with your normal file backup tool, or use ./innoslate migrate export"
    say "to get everything in one bundle. An external database is also not"
    say "included; back that up with your database tools."
    say ""
    say "settings.properties contains the database password Innoslate uses AND"
    say "your Innoslate license key. Keep this folder as private as you keep them."
  } > "$dest/README.txt"

  # A folder holding nothing but its own README is not a restore point, and
  # leaving one behind is worse than leaving none: it is indistinguishable from
  # a real backup until the day somebody reaches for it.
  if [[ ! -f "$dest/settings.properties" && ! -f "$dest/database.sql" ]]; then
    rm -rf "$dest"
    die \
"Nothing could be backed up -- neither Innoslate's configuration nor the
database could be read. The messages above say why.

No backup folder has been left behind. ./innoslate doctor checks what this
depends on."
  fi

  # An installation whose setup page has never been finished has no
  # configuration to capture, and saying a backup of it is incomplete would be
  # wrong -- and would stop "update", which backs up before it replaces
  # anything. Only a configuration that exists and was missed counts.
  local incomplete=0
  if config_present && [[ ! -f "$dest/settings.properties" ]]; then incomplete=1; fi
  if has_profile postgres && [[ ! -f "$dest/database.sql" ]]; then incomplete=1; fi

  if (( incomplete )); then
    warn "backup INCOMPLETE: $dest"
    warn "What is missing is named above and in its README.txt."
    warn "Do not rely on this as a restore point."
    prune_backups
    return 1
  fi

  ok "backup complete: $dest"
  prune_backups
}

# How many "backup-<stamp>" folders to keep. Each one holds a full database
# dump, so on an installation that backs up on a schedule -- a cron job or a
# systemd timer -- they would otherwise grow without limit and eventually fill
# the disk Innoslate also writes its filestore to. Running out
# of space that way takes Innoslate down, which is a worse outcome than losing
# the oldest backup.
#
# Override with KEEP_BACKUPS in .env. 0 turns pruning off entirely, for anyone
# whose retention policy is handled elsewhere.
prune_backups() {
  local keep dir count
  keep="$(env_or KEEP_BACKUPS 14)"
  [[ "$keep" =~ ^[0-9]+$ ]] || return 0
  (( keep == 0 )) && return 0

  dir="$(backups_dir)"
  [[ -d "$dir" ]] || return 0

  # Newest first, so everything after the first $keep is what goes. Sorted by
  # name rather than time on purpose: the stamp in "backup-YYYYmmdd-HHMMSS"
  # sorts correctly as text, and unlike mtime it cannot be disturbed by copying
  # the folder around.
  count=0
  while IFS= read -r name; do
    [[ -n "$name" ]] || continue
    count=$((count + 1))
    (( count <= keep )) && continue
    # Only ever a directory this command created, matching its own name shape.
    [[ -d "$dir/$name" ]] || continue
    # Not allowed to fail the whole command: a backup that was taken and
    # written successfully must not report failure because an older folder
    # could not be removed -- which happens when an earlier backup was run
    # under sudo and this one was not.
    if rm -rf -- "${dir:?}/$name" 2>/dev/null; then
      say "  ${C_DIM}removed old backup $name (keeping the newest $keep)${C_RESET}"
    else
      warn "could not remove the old backup $name -- delete it yourself, or set"
      warn "KEEP_BACKUPS=0 in .env if something else handles retention."
    fi
  done < <(ls -1 "$dir" 2>/dev/null | grep -E '^backup-[0-9]{8}-[0-9]{6}$' | sort -r || true)
}

cmd_restore() {
  require_docker; require_env_file
  local name="${1:-}"
  local dir; dir="$(data_dir)"
  local db_failed=0
  local config_failed=0

  if [[ -z "$name" ]]; then
    say "Available backups in $dir/backups:"
    say ""
    ls -1 "$dir/backups" 2>/dev/null | grep -E '^backup-' | sed 's/^/  /' || say "  (none)"
    say ""
    say "Restore one with:  ./innoslate restore <name>"
    return 0
  fi

  local src="$dir/backups/$name"
  [[ -d "$src" ]] || die "No backup named \"$name\" in $dir/backups. Run ./innoslate restore with no name to list them."

  # Without either of these there is nothing to put back, and restarting the
  # container would report success for a restore that did not happen.
  if [[ ! -f "$src/settings.properties" && ! -f "$src/database.sql" ]]; then
    die \
"The backup \"$name\" holds neither a configuration nor a database dump, so
there is nothing in it to restore.

./innoslate restore with no name lists the backups; each one's README.txt says
what it holds."
  fi

  if [[ -f "$src/settings.properties" ]]; then
    # Put it where Innoslate's own restore command looks, then use that.
    local stamped="settings-restored-$(date +%Y%m%d%H%M%S).properties"
    cp "$src/settings.properties" "$dir/backups/$stamped"
    restore_config "$stamped" || { warn "could not load it into the container"; config_failed=1; }
  fi

  if [[ -f "$src/database.sql" ]]; then
    say ""
    warn "This backup contains a database dump. Restoring it REPLACES the bundled"
    warn "database; any work done since the backup was taken is lost."
    local answer
    read -r -p "Type the word 'replace' to restore the database, or press Enter to skip: " answer || answer=""
    if [[ "$answer" == "replace" ]]; then
      info "Restoring the database"
      if ! load_sql_dump "$src/database.sql"; then
        explain_dump_failure
        say "  To try the database again once the cause is dealt with:"
        say "      ./innoslate restore $name"
        say ""
        db_failed=1
      fi
    else
      warn "Skipped the database: it has not been restored."
    fi
  fi

  cmd_restart
  # Restarted either way, but a restore that did not actually load what it was
  # asked to -- the configuration OR the database -- must not exit 0. A failed
  # configuration load used to only warn: the command still reported success,
  # which is exactly the silent-no-op a customer's own script would trust.
  (( db_failed == 0 && config_failed == 0 )) || return 1
  ok "restored from $name"
}

# =============================================================================
#  migrate -- moving a working installation to another machine
#
#  Everything here is the backup/restore mechanism above plus the filestore,
#  bundled into one folder you can copy. See ../docs/09-migration.md.
# =============================================================================
cmd_migrate() {
  local sub="${1:-}"; shift || true
  case "$sub" in
    export) migrate_export "$@" ;;
    import) migrate_import "$@" ;;
    *) die "Usage:
    ./innoslate migrate export [folder]    package this installation up
    ./innoslate migrate import <folder>    load a package on the new machine

See ../docs/09-migration.md." ;;
  esac
}

migrate_export() {
  require_docker; require_env_file; guard_foreign_read "migrate export"
  local dest="${1:-}"
  [[ -n "$dest" ]] || dest="$(data_dir)/migrate-$(date +%Y%m%d-%H%M%S)"
  mkdir -p "$dest" 2>/dev/null || die \
"Cannot write the export to \"$dest\".

Give a folder you can write to, for example:
    ./innoslate migrate export /mnt/usb/innoslate-move
or leave it off to write the export inside the data folder:
    ./innoslate migrate export"
  [[ -w "$dest" ]] || die "The folder \"$dest\" is not writable by you."

  info "Packaging this installation into $dest"

  # 1. Configuration, via Innoslate's own command. Its own message is kept
  #    when it fails -- see the note in cmd_backup.
  local save_out have_config=0 have_db=0
  if save_out="$(save_config 2>&1)"; then
    if cp "$(backups_dir)/$(newest_saved_config)" "$dest/settings.properties" 2>/dev/null; then
      have_config=1
    else
      rm -f "$dest/settings.properties"
      warn "the configuration could not be written into $dest -- the disk may be full"
    fi
  else
    warn "Innoslate's configuration is NOT in this package:"
    printf '%s\n' "$save_out" | sed 's/^/      /' >&2
  fi

  # 2. Database -- only the bundled one. An external server is the customer's
  #    own to dump, and saying so is more honest than pretending otherwise.
  if has_profile postgres && container_running "$DB_CONTAINER"; then
    local db user
    db="$(env_or POSTGRES_DB innoslate)"; user="$(env_or POSTGRES_USER innoslate)"
    if dc exec -T "$DB_SERVICE" sh -c \
         "pg_dump -U '$user' -d '$db' --clean --if-exists > /tmp/innoslate-dump.sql" 2>/dev/null; then
      if ! dc cp "$DB_SERVICE:/tmp/innoslate-dump.sql" "$(host_path "$dest/database.sql")"; then
        warn "the dump could not be copied out of the database container"
        rm -f "$dest/database.sql"
      elif ! dump_is_complete "$dest/database.sql"; then
        explain_short_dump "$dest/database.sql"
        rm -f "$dest/database.sql"
      else
        have_db=1
      fi
      dc exec -T "$DB_SERVICE" rm -f /tmp/innoslate-dump.sql || true
    else
      warn "the database dump failed -- see ./innoslate logs postgres"
    fi
  elif has_profile postgres; then
    warn "The bundled database is not running, so no database is included."
    warn "Start it with ./innoslate start and run this again."
  else
    say "  The database is external, so no database is included. Dump it with its"
    say "  own tools."
  fi

  # 3. Filestore. A tar keeps permissions and nested folders intact across
  #    machines in a way that copying a folder tree between Windows and Linux
  #    does not.
  local fs have_fs=0; fs="$(data_dir)/filestore"
  if [[ -d "$fs" ]]; then
    # macOS's tar otherwise adds a "._" file beside anything carrying extended
    # attributes, and those land in the filestore of the Linux machine this
    # package is usually going to (COPYFILE_DISABLE, ignored everywhere else).
    # It also records the attributes themselves as pax headers, which GNU tar
    # warns about once per file on the way in; Innoslate has no use for them.
    local no_xattrs=()
    tar_is_gnu || no_xattrs=(--no-xattrs)
    if COPYFILE_DISABLE=1 tar -czf "$dest/filestore.tar.gz" ${no_xattrs[@]+"${no_xattrs[@]}"} \
         -C "$fs" . 2>/dev/null; then
      have_fs=1
    else
      warn "could not package the filestore; copy $fs across by hand"
      rm -f "$dest/filestore.tar.gz"
    fi
  fi

  # A package holding nothing but its own README is not a package, and
  # ../docs/09-migration.md has people decommission the old machine on the
  # strength of one. Whatever went wrong is named above; this is the line that
  # stops it being followed by "package complete".
  if (( have_config == 0 && have_db == 0 && have_fs == 0 )); then
    rm -f "$dest/README.txt"
    die \
"Nothing could be packaged -- not the configuration, not the database, not the
filestore. The messages above say why.

Nothing has been left in $dest. ./innoslate doctor checks what this depends on."
  fi

  {
    say "Innoslate migration package"
    say "==========================="
    say "taken:      $(date)"
    say "from:       $(uname -s 2>/dev/null || echo unknown)"
    say "image:      $(running_version)"
    say "kit:        $KIT_VERSION"
    say "database:   $(describe_db_choice)"
    say ""
    say "WHAT IS IN HERE"
    if (( have_config )); then
      say "  settings.properties   Innoslate's configuration, including the database"
      say "                        password and the license key it uses."
      say "                        Treat this folder as a secret."
    fi
    (( have_db )) && say "  database.sql          A dump of the bundled PostgreSQL."
    (( have_fs )) && say "  filestore.tar.gz      Uploaded files and generated diagram images."
    say ""
    say "WHAT IS NOT"
    if (( ! have_config )); then
      say "  * Innoslate's configuration. It could not be read out of the container"
      say "    when this package was made -- usually because Innoslate's setup page"
      say "    was never finished. The new machine will show that page itself, and"
      say "    your license key is not in here, so have it to hand."
    fi
    if ! has_profile postgres; then
      say "  * An external database. Dump and restore that with your own tools."
    elif (( ! have_db )); then
      say "  * The bundled database. It could not be dumped when this package was"
      say "    made; the export said why. Do not decommission the old machine on"
      say "    the strength of this package."
    fi
    if (( ! have_fs )); then
      if [[ -d "$fs" ]]; then
        say "  * The filestore. It could not be packaged; copy $fs from the old"
        say "    machine by hand."
      else
        say "  * A filestore. There was no $fs to package."
      fi
    fi
    say "  * Your .env. The new machine gets its own from ./innoslate setup --"
    say "    ports, bind address and data paths are properties of the machine,"
    say "    not of the installation, and a Windows path will not work on Linux."
    say "  * The Innoslate image. The new machine pulls it."
    say ""
    say "ON THE NEW MACHINE"
    say "  1. Install Docker and unpack this kit."
    say "  2. ./innoslate setup        answer for the NEW machine"
    say "  3. ./innoslate start        let it come up unconfigured once"
    say "  4. ./innoslate migrate import <this folder>"
    say "  5. ./innoslate doctor       confirm it came across"
    say ""
    say "  Full walkthrough: ../docs/09-migration.md"
  } > "$dest/README.txt"

  chmod 700 "$dest" 2>/dev/null || true
  # As with backup: a bundled database that was not dumped leaves a package
  # that looks whole and is not the installation.
  if has_profile postgres && (( ! have_db )); then
    warn "package INCOMPLETE: $dest has no database dump -- what went wrong is above."
    warn "Do not decommission the old machine on the strength of it."
    return 1
  fi
  ok "package complete: $dest"
  say ""
  if (( have_config )); then
    say "  ${C_YELLOW}This folder contains your database password and license key.${C_RESET}"
    say "  Move it the way you would move a password -- not through a public link."
  else
    say "  ${C_YELLOW}Innoslate's configuration is not in this folder.${C_RESET} On the new machine"
    say "  you will finish Innoslate's setup page yourself, so have your license key"
    say "  to hand."
  fi
  say ""
  say "  Next: ../docs/09-migration.md"
  say ""
}

migrate_import() {
  require_docker; require_env_file
  local src="${1:-}" db_skipped=0
  [[ -n "$src" ]] || die "Which package? Usage: ./innoslate migrate import <folder>"
  [[ -d "$src" ]] || die "There is no folder called \"$src\"."
  # Checked before anything is unpacked: loading the configuration without the
  # database it belongs with is the state the load-failure path stops to avoid.
  if [[ -f "$src/database.sql" ]] && has_profile postgres && ! container_running "$DB_CONTAINER"; then
    die "This package has a database dump, but the bundled database is not running.

Nothing has been changed. Start it, then run this again:
    ./innoslate start
    ./innoslate migrate import $src"
  fi

  say ""
  say "  Loading $src into this installation."
  say "  This machine's .env (port, bind address, data folder) is kept; only"
  say "  Innoslate's data and configuration come across."
  say ""
  ask_yes_no "  Continue?" y || { say "Cancelled."; return 0; }

  # 1. Filestore first: Innoslate's configuration will point at it.
  if [[ -f "$src/filestore.tar.gz" ]]; then
    local fs; fs="$(data_dir)/filestore"
    mkdir -p "$fs"
    info "Unpacking the filestore into $fs"
    # --no-overwrite-dir: $fs already exists, usually owned by the container's
    # own user id rather than whoever is running this command -- the normal
    # state on a Linux host once this kit has repaired it once. Without this,
    # tar's own final step -- restoring "." to the mode and mtime the archive
    # recorded for it -- needs to chmod/utime a directory it does not own,
    # fails with EPERM, and exits non-zero even though every file inside
    # extracted correctly, dying here with the filestore already restored.
    #
    # The flag is GNU tar's. macOS ships bsdtar, which refuses the whole command
    # over an option it does not know; it is left off there, where the folder
    # is owned by whoever runs this and the EPERM above cannot happen.
    local keep_dir=()
    tar_is_gnu && keep_dir=(--no-overwrite-dir)
    tar -xzf "$src/filestore.tar.gz" -C "$fs" ${keep_dir[@]+"${keep_dir[@]}"} \
      || die "could not unpack $src/filestore.tar.gz"
    # The tar carries whatever ownership was recorded when it was made -- root's
    # if the package came off a Windows machine, or if this import is being run
    # under sudo, as ../docs/09-migration.md has you do on AWS. Those files land
    # inside a folder whose own ownership is already right, which is the only
    # thing ensure_data_dirs looks at, so nothing else would ever notice:
    # Innoslate could read them and not replace them, and this command would
    # report success. Set them now, while it is known which files are new.
    chown_data_dirs filestore || true
  fi

  # 2. Database.
  if [[ -f "$src/database.sql" ]]; then
    if has_profile postgres && container_running "$DB_CONTAINER"; then
      say ""
      warn "About to load database.sql into the bundled PostgreSQL, replacing"
      warn "anything already in it."
      local answer
      read -r -p "Type the word 'replace' to load it, or press Enter to skip: " answer || answer=""
      if [[ "$answer" == "replace" ]]; then
        info "Loading the database"
        if ! load_sql_dump "$src/database.sql"; then
          explain_dump_failure
          die \
"Stopping before the configuration is loaded, since the database was not.

The filestore was unpacked, which is harmless to repeat. Deal with the cause
above and run the same command again:
    ./innoslate migrate import $src"
        fi
      else
        db_skipped=1
        warn "Skipped the database: database.sql has not been loaded."
      fi
    else
      warn "This package contains a database dump, but this installation uses its"
      warn "own database server. Load $src/database.sql into it with its tools."
    fi
  fi

  # 3. Configuration last, and then make it take effect.
  if [[ -f "$src/settings.properties" ]]; then
    say ""
    say "  ${C_DIM}Its database host and filestore path came from the old machine. If${C_RESET}"
    say "  ${C_DIM}either differs here, correct it in Innoslate's admin pages afterwards;${C_RESET}"
    say "  ${C_DIM}./innoslate doctor shows both.${C_RESET}"
    local stamped="settings-imported-$(date +%Y%m%d%H%M%S).properties"
    mkdir -p "$(backups_dir)"
    cp "$src/settings.properties" "$(backups_dir)/$stamped"

    # The database password is the one value in here that is certain to be
    # wrong, and the only one nothing else would catch.
    #
    # settings.properties carries the password of the OLD machine's database.
    # The bundled PostgreSQL on THIS machine was created with the password
    # "setup" asked for a few minutes ago, in .env, and PostgreSQL will not
    # accept any other. Left alone, Innoslate comes up unable to reach its own
    # database while the container is healthy, .env and the database agree and
    # the hosts match -- an installation that looks finished and is not. So the
    # value is corrected on its way in, rather than being reported afterwards.
    if has_profile postgres; then
      local newpw oldpw esc tmp
      newpw="$(env_value POSTGRES_PASSWORD)"
      oldpw="$(sed -n -E 's/^[[:space:]]*DATABASE_PASSWORD[[:space:]]*=[[:space:]]*(.*)/\1/p' \
                 "$(backups_dir)/$stamped" | tail -1 | unescape_properties)"
      if [[ -n "$newpw" && "$newpw" != "$oldpw" ]]; then
        # Escape for the properties format here, where the shell can do it
        # plainly, rather than inside awk's replacement rules.
        esc="${newpw//\\/\\\\}"
        tmp="$(backups_dir)/$stamped.tmp"
        if PW="$esc" awk '
             BEGIN { pw = ENVIRON["PW"]; done = 0 }
             /^[[:space:]]*DATABASE_PASSWORD[[:space:]]*=/ {
               print "DATABASE_PASSWORD=" pw; done = 1; next
             }
             { print }
             END { if (!done) print "DATABASE_PASSWORD=" pw }
           ' "$(backups_dir)/$stamped" > "$tmp"; then
          mv "$tmp" "$(backups_dir)/$stamped"
        else
          rm -f "$tmp"
          warn "could not correct the database password in the configuration"
          warn "-- ./innoslate doctor will show whether Innoslate can sign in"
        fi
      fi
    fi

    restore_config "$stamped" || die "could not load the configuration into the container"
    dc restart "$SERVICE"
    wait_until_ready 600 || true
  fi

  say ""
  if config_present && (( db_skipped )); then
    warn "migration loaded WITHOUT its database -- run this again and type 'replace'"
  elif config_present; then
    ok "migration loaded"
  else
    warn "Innoslate still reports no configuration -- run ./innoslate doctor"
  fi
  say ""
  say "  Now run ./innoslate doctor, and open Innoslate to confirm your projects"
  say "  and uploaded files are there."
  say ""
}

# =============================================================================
#  doctor -- the questions support would ask you first
# =============================================================================
# Lines the Innoslate image prints on every healthy start. Showing these under
# "Recent errors", directly above "Everything checked out", teaches people to
# ignore the whole section -- so they are filtered out. Anything NOT on this
# list is shown. ../docs/07-troubleshooting.md explains each one.
KNOWN_HARMLESS_LOG='SEVERE.*Error waiting for multi-thread deployment|Unable to create directory for deployment|Script support is not enabled|ScriptAppenderSelector|Unable to locate appender'

DOCTOR_PROBLEMS=0
DOCTOR_WARNINGS=0

# One check, one line, one verdict -- so the eye can run down the right-hand
# column and stop at the first thing that is not OK.
d_line() {
  local label="$1" status="$2" color="$3" pad
  pad=$(( 44 - ${#label} )); (( pad < 2 )) && pad=2
  printf '  %s %s %s%s%s\n' "$label" \
    "$(printf '%*s' "$pad" '' | tr ' ' '.')" "$color" "$status" "$C_RESET"
}
d_pass() { d_line "$1" "OK"     "$C_GREEN"; }
d_warn() { d_line "$1" "CHECK"  "$C_YELLOW"; DOCTOR_WARNINGS=$((DOCTOR_WARNINGS+1)); }
d_fail() { d_line "$1" "FAILED" "$C_RED";    DOCTOR_PROBLEMS=$((DOCTOR_PROBLEMS+1)); }
d_note() { printf '        %s\n' "$*"; }
d_next() {
  say ""
  printf '    %sNext:%s\n' "$C_BOLD" "$C_RESET"
  local i=1 a
  for a in "$@"; do printf '      %d. %s\n' "$i" "$a"; i=$((i+1)); done
  say ""
}

cmd_doctor() {
  if [[ "${1:-}" == "--report" || "${1:-}" == "report" ]]; then
    doctor_report
    return 0
  fi

  DOCTOR_PROBLEMS=0; DOCTOR_WARNINGS=0
  say ""
  rule
  say "${C_BOLD}  Checking this Innoslate installation${C_RESET}"
  rule
  say ""

  # --- Docker ---------------------------------------------------------------
  info "Docker"
  if ! command -v docker >/dev/null 2>&1; then
    d_fail "Docker installed"
    d_next "Install Docker Desktop (Windows/macOS) from https://www.docker.com/products/docker-desktop" \
           "Or Docker Engine on Linux: https://docs.docker.com/engine/install/"
    doctor_finish; return 1
  fi
  d_pass "Docker installed"

  if ! docker info >/dev/null 2>&1; then
    d_fail "Docker running"
    d_next "Windows/macOS: start Docker Desktop and wait for it to report ready" \
           "Linux: sudo systemctl start docker" \
           "Then run ./innoslate doctor again"
    doctor_finish; return 1
  fi
  d_pass "Docker running"

  if docker compose version >/dev/null 2>&1; then
    COMPOSE=(docker compose)
    COMPOSE_VER="$(docker compose version --short 2>/dev/null | tr -d ' \r' | head -1)"
    if version_ge "$COMPOSE_VER" "$MIN_COMPOSE"; then
      d_pass "Docker Compose $COMPOSE_VER"
    else
      d_fail "Docker Compose $COMPOSE_VER"
      d_note "This kit needs Compose v$MIN_COMPOSE or newer."
      d_next "Windows/macOS: update Docker Desktop" \
             "Linux: https://docs.docker.com/compose/install/linux/"
    fi
  else
    d_fail "Docker Compose available"
    if command -v docker-compose >/dev/null 2>&1; then
      d_note "Only the old standalone docker-compose (v1) is installed."
    fi
    d_next "Update Docker Desktop, or install the Compose plugin:" \
           "https://docs.docker.com/compose/install/linux/"
    doctor_finish; return 1
  fi

  if host_is_arm64; then
    if host_is_macos; then
      # Deliberately a note and not a warning. Emulation is expected and
      # supported on Apple Silicon, and counting it as something to fix would
      # make doctor report a problem on every Mac sold since 2020.
      d_note "processor ....... Apple Silicon -- Innoslate is built for Intel and runs"
      d_note "                  emulated. Docker Desktop > Settings > General >"
      d_note "                  \"Use Rosetta for x86_64/amd64 emulation\" makes it faster."
    else
      d_warn "Processor supported"
      d_note "This is an ARM machine and Innoslate is built for Intel and AMD only."
      d_note "Without QEMU registered on this host the container cannot start."
      d_next "Move to an Intel or AMD machine -- on AWS a t3, m5 or m7i instance" \
             "rather than a Graviton t4g, m6g or m7g."
    fi
  fi

  # --- configuration --------------------------------------------------------
  say ""
  info "Configuration"
  if [[ -f "$KIT_DIR/.env" ]]; then
    d_pass ".env present"
  else
    d_fail ".env present"
    d_next "Run ./innoslate setup -- it asks a few questions and writes it"
    doctor_finish; return 1
  fi

  d_note "profiles ........ $( [[ -n "$(profiles)" ]] && profiles || echo "(none -- your own database)" )"
  d_note "image ........... $(image_ref)"
  d_note "published on .... $(env_or INNOSLATE_BIND 0.0.0.0):$(env_or INNOSLATE_PORT 8080)"
  d_note "access mode ..... $(describe_access_choice)"
  d_note "database ........ $(describe_db_choice)"
  d_note "data folder ..... $(data_dir)"

  # bundled DB password sanity
  if has_profile postgres; then
    local pw; pw="$(env_value POSTGRES_PASSWORD)"
    if [[ -z "$pw" || "$pw" == CHANGE_ME ]]; then
      d_fail "Bundled database password set"
      d_next "Run ./innoslate setup to choose one" \
             "Or set POSTGRES_PASSWORD in .env yourself"
    else
      d_pass "Bundled database password set"
    fi
  fi

  # profile vs recorded intent
  local db_kind; db_kind="$(env_value KIT_DB_KIND)"
  if [[ "$db_kind" == bundled ]] && ! has_profile postgres; then
    # This state is broken either way -- nothing will serve the database -- so
    # it stays a failure. What it must not do is assume how you got here. Two
    # people arrive at it: one meant to run the bundled database and switched it
    # off by mistake, and one followed ../docs/04-database.md to their own
    # server using the single line it documents, which leaves KIT_DB_KIND saying
    # "bundled" because only "setup" ever writes it. Telling the second to put
    # the profile back undoes work they did on purpose, so offer both remedies
    # and let them pick.
    d_fail "Profile matches the chosen database"
    d_note "COMPOSE_PROFILES lacks \"postgres\", so no database container will run,"
    d_note "but KIT_DB_KIND in .env still records the bundled PostgreSQL."
    d_next "If you moved to your own database server, run ./innoslate setup" \
           "  and choose that kind of database, so KIT_DB_KIND records it" \
           "If you meant to keep the bundled one, set COMPOSE_PROFILES=postgres" \
           "See ../docs/04-database.md"
  elif [[ -n "$db_kind" && "$db_kind" != bundled ]] && has_profile postgres; then
    d_warn "Profile matches the chosen database"
    d_note "Setup recorded your own $db_kind server, but the bundled PostgreSQL"
    d_note "container is also switched on. It will run and do nothing."
    d_next "Remove \"postgres\" from COMPOSE_PROFILES in .env if it is not wanted"
  elif [[ -n "$db_kind" ]]; then
    d_pass "Profile matches the chosen database"
  fi

  # COMPOSE_FILE hygiene
  local cf; cf="$(env_value COMPOSE_FILE)"
  if proxy_mode; then
    if [[ "$cf" == *compose.proxy.yaml* && ! -f "$KIT_DIR/compose.proxy.yaml" ]]; then
      # Named but missing. Saying "on" here is how a reader concluded the proxy
      # side was fine while nothing could start at all.
      d_fail "Reverse-proxy compatibility on"
      d_note "COMPOSE_FILE names compose.proxy.yaml, but that file is not in this"
      d_note "folder, so Compose will not start anything."
      d_next "Put compose.proxy.yaml back, or run ./innoslate setup again"
    elif [[ "$cf" == *compose.proxy.yaml* ]]; then
      d_pass "Reverse-proxy compatibility on"
    else
      d_fail "Reverse-proxy compatibility on"
      d_note "This install is recorded as sitting behind a proxy, but COMPOSE_FILE"
      d_note "does not include compose.proxy.yaml -- so Tomcat will not trust the"
      d_note "X-Forwarded-* headers and users will be redirected to the wrong URL."
      d_next "Run ./innoslate setup again and choose the reverse-proxy option" \
             "Or add these two lines to .env, then ./innoslate restart:" \
             "  COMPOSE_PATH_SEPARATOR=,  and  COMPOSE_FILE=compose.yaml,compose.proxy.yaml"
    fi
  fi
  if [[ -n "$cf" ]] && [[ -f "$KIT_DIR/compose.override.yaml" || -f "$KIT_DIR/compose.override.yml" ]]; then
    if [[ "$cf" == *compose.override.y* ]]; then
      d_pass "compose.override.yaml still loaded"
    else
      d_warn "compose.override.yaml still loaded"
      d_note "You have a compose.override.yaml, but naming files in COMPOSE_FILE"
      d_note "stops Compose loading it automatically -- your changes are inactive."
      d_next "Add it to the end of COMPOSE_FILE in .env:" \
             "  COMPOSE_FILE=compose.yaml,compose.proxy.yaml,compose.override.yaml"
    fi
  fi

  # --- does Compose accept it ----------------------------------------------
  say ""
  info "Docker Compose"
  # Checked before asking Compose, because Compose's answer to this is a bare
  # "no such file" that names neither .env nor the reason the file is listed.
  local missing_files; missing_files="$(missing_compose_files)"
  if [[ -n "$missing_files" ]]; then
    d_fail "Every compose file .env names is here"
    local mf
    for mf in $missing_files; do
      d_note "COMPOSE_FILE lists \"$mf\", which is not in this folder."
    done
    d_note "Compose will not run until this is fixed, so the next check fails too."
    d_next "Not behind a reverse proxy? Delete the COMPOSE_FILE and COMPOSE_PATH_SEPARATOR lines from .env" \
           "Otherwise put the file back, or run ./innoslate setup"
  fi
  if dc config >/dev/null 2>&1; then
    d_pass "compose.yaml and .env are valid together"
  else
    d_fail "compose.yaml and .env are valid together"
    dc config 2>&1 | sed 's/^        /        /;s/^/        /' | head -12
    d_next "Fix the value the message names in .env" \
           "Undo any recent change in compose.override.yaml" \
           "Run docker compose config to see the whole message"
  fi

  # --- data folders --------------------------------------------------------
  say ""
  info "Data folders"
  local dir sub; dir="$(data_dir)"
  local refused=()
  for sub in filestore backups logs; do
    if [[ ! -d "$dir/$sub" ]]; then
      d_warn "$sub exists"
      d_next "Run ./innoslate start -- it creates the three data folders"
      continue
    fi
    # Two different accounts, two different answers. Innoslate writes from
    # inside the container as another user id entirely, so "-w" -- which speaks
    # for the account running this script -- says "writable" about folders
    # Innoslate is refused by. Reporting that as OK is what let an install come
    # up unable to save anything while doctor called it healthy.
    if innoslate_can_write "$dir/$sub"; then
      d_pass "$sub writable by Innoslate"
    else
      d_fail "$sub writable by Innoslate"
      d_note "belongs to user id $(stat -c '%u' "$dir/$sub" 2>/dev/null); Innoslate runs as $(innoslate_uid)."
      refused[${#refused[@]}]="$dir/$sub"
    fi
    if [[ ! -w "$dir/$sub" ]]; then
      d_warn "$sub writable by you"
      d_note "$dir/$sub is not writable by this account, so backup and"
      d_note "migrate export cannot write into it."
    fi
    d_note "$dir/$sub  $(du -sh "$dir/$sub" 2>/dev/null | cut -f1)"
  done
  # Said once for all three rather than after each: the fix is the same one
  # command, and repeating it three times buries the rest of the report.
  if (( ${#refused[@]} )); then
    say ""
    d_note "Innoslate cannot write to these, so uploads, logs and saved"
    d_note "configurations will fail."
    d_next "Run ./innoslate restart -- it tries to repair this itself" \
           "Or grant access yourself: $(ownership_fix_hint "${refused[@]}")"
  fi
  if [[ "$dir" == /* ]] && ! data_dir_is_safe_to_chown "$dir"; then
    d_warn "DATA_DIR is a folder of its own"
    d_note "DATA_DIR is $dir: a system folder, a home directory or a folder directly"
    d_note "under /. This kit will not change ownership there, so on Linux Innoslate"
    d_note "may not be able to write to it."
    d_next "Point DATA_DIR at a folder of its own, e.g. /srv/innoslate-data" \
           "Or grant access yourself: $(ownership_fix_hint "$dir/filestore" "$dir/backups" "$dir/logs")"
  fi

  # --- port ----------------------------------------------------------------
  say ""
  info "Network"
  local port; port="$(env_or INNOSLATE_PORT 8080)"
  if container_running; then
    d_pass "Port $port"
    d_note "held by the Innoslate container, as expected"
  elif port_in_use "$port"; then
    d_fail "Port $port"
    d_note "Innoslate is not running, but something else is using port $port."
    local finder="Linux: sudo ss -lptn 'sport = :$port'"
    host_is_macos && finder="macOS: sudo lsof -nP -iTCP:$port -sTCP:LISTEN"
    d_next "Find it: docker ps  ($finder)" \
           "Stop it, or change INNOSLATE_PORT in .env and run ./innoslate start"
  else
    d_pass "Port $port"
    d_note "free"
  fi
  if [[ "$(env_or INNOSLATE_BIND 0.0.0.0)" == "0.0.0.0" ]] && [[ "$(env_value KIT_ACCESS_MODE)" == local ]]; then
    d_warn "Bind address matches the chosen access"
    d_note "Setup recorded this as a this-machine-only install, but the port is"
    d_note "published on every interface."
    d_next "Set INNOSLATE_BIND=127.0.0.1 in .env and run ./innoslate restart"
  fi

  # --- containers ----------------------------------------------------------
  say ""
  info "Containers"
  dc ps 2>/dev/null | sed 's/^/  /'
  say ""

  # Every copy of this kit on a machine names the same two containers, so a
  # second folder does not get a second installation -- it takes over the
  # first. Checked before anything below, because every check after this one
  # would otherwise be describing somebody else's container.
  local owner; owner="$(foreign_install_dir)"
  if [[ -n "$owner" ]]; then
    d_fail "These containers belong to this folder"
    d_note "They were created from $owner, a different installation."
    d_note "Everything below describes that one; commands here will not touch them."
    d_next "Work in $owner instead" \
           "Or release them there first: cd $owner && ./innoslate down"
  fi

  if has_profile postgres; then
    if container_running "$DB_CONTAINER"; then
      local dbh
      dbh="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$DB_CONTAINER" 2>/dev/null | head -1)"
      if [[ "$dbh" == healthy || "$dbh" == none ]]; then
        d_pass "Database container"

        # POSTGRES_PASSWORD in .env is only a claim about the database. Postgres
        # applies it when it creates its data directory and never again, so a
        # volume left over from an earlier installation quietly keeps the
        # password it was first given. Every other check here still passes, and
        # the disagreement surfaces only as "password authentication failed" on
        # Innoslate's setup page, with nothing pointing at the cause. So ask
        # PostgreSQL itself.
        #
        # The address matters. The image's pg_hba.conf trusts the local socket
        # AND 127.0.0.1 without a password, so a probe over either passes
        # whatever the password is. Connecting to the container's own hostname
        # arrives on its network address instead, which falls to the
        # scram-sha-256 rule -- the same one Innoslate authenticates against.
        local dbpw dbuser dbname
        dbpw="$(env_value POSTGRES_PASSWORD)"
        dbuser="$(env_or POSTGRES_USER innoslate)"
        dbname="$(env_or POSTGRES_DB innoslate)"
        if [[ -n "$dbpw" ]]; then
          if docker exec -e PGPASSWORD="$dbpw" -e PROBE_USER="$dbuser" -e PROBE_DB="$dbname" \
                 "$DB_CONTAINER" \
                 sh -c 'exec psql -h "$(hostname)" -U "$PROBE_USER" -d "$PROBE_DB" -tAc "select 1"' \
                 >/dev/null 2>&1; then
            d_pass "Database accepts the password in .env"
          else
            d_fail "Database accepts the password in .env"
            d_note "PostgreSQL rejected POSTGRES_PASSWORD for user \"$dbuser\". The volume"
            d_note "innoslate-postgres-data keeps the password it was created with, and"
            d_note "Innoslate will fail with \"password authentication failed\" until the"
            d_note "two agree."
            d_next "Put the earlier password back in .env, if you know it" \
                   "Or change the database to match .env, replacing NEW below:" \
                   "docker exec -it $DB_CONTAINER psql -U $dbuser -c \"ALTER USER $dbuser PASSWORD 'NEW'\"" \
                   "Or start over, losing what is in the database: ./innoslate destroy" \
                   "See ../docs/04-database.md"
          fi
        fi
      else
        d_fail "Database container"
        d_note "PostgreSQL is running but reports \"$dbh\"."
        d_note "Last lines from it:"
        docker logs --tail 8 "$DB_CONTAINER" 2>&1 | sed 's/^/        /'
        d_next "Look at the whole log: ./innoslate logs postgres" \
               "See ../docs/04-database.md"
      fi
    else
      d_fail "Database container"
      d_note "The bundled PostgreSQL is selected but is not running."
      d_next "Run ./innoslate start" \
             "Then ./innoslate logs postgres if it stops again"
    fi
  fi

  local h; h="$(health)"
  case "$h" in
    healthy)
      d_pass "Innoslate container"
      ;;
    missing)
      d_fail "Innoslate container"
      d_note "It is not running."
      d_next "Run ./innoslate start" \
             "If it stops again: ./innoslate logs"
      ;;
    *)
      d_fail "Innoslate container"
      d_note "It is running but reports \"$h\"."
      d_note "Last lines:"
      docker logs --tail 12 "$CONTAINER" 2>&1 | sed 's/^/        /'
      d_next "Read more: ./innoslate logs" \
             "Check the database is reachable from the container -- ../docs/04-database.md" \
             "Send ./innoslate doctor --report to support@innoslate.com"
      ;;
  esac

  # --- HTTP ----------------------------------------------------------------
  if [[ "$h" == healthy ]]; then
    if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
      d_note "HTTP check skipped: no curl or wget here. Open http://localhost:$port yourself."
    elif http_probe "$port"; then
      d_pass "Answers HTTP on this machine"
      d_note "http://localhost:$port responded"
    else
      d_warn "Answers HTTP on this machine"
      d_note "The container reports healthy, but http://localhost:$port did not"
      d_note "answer from here. Check INNOSLATE_BIND in .env if you expected it to."
    fi
    if proxy_mode; then
      d_note "The public address $(env_value KIT_PUBLIC_URL) is not checked; it runs"
      d_note "through your proxy."
    fi
  fi

  # --- mounts --------------------------------------------------------------
  if container_running; then
    say ""
    info "Persistent storage"
    local mounts
    mounts="$(docker inspect --format '{{range .Mounts}}{{.Destination}}
{{end}}' "$CONTAINER" 2>/dev/null || true)"
    local want
    for want in /usr/local/innoslate/filestore /usr/local/innoslate/backups /usr/local/tomcat/logs; do
      if [[ $'\n'"$mounts"$'\n' == *$'\n'"$want"$'\n'* ]]; then
        # Mounted is not the same as usable. Asking the container to create and
        # remove one file is the only answer that cannot be wrong, and it is
        # the same thing Innoslate does the moment somebody uploads a file.
        if in_container sh -c "touch '$want/.doctor-write-test' && rm -f '$want/.doctor-write-test'" >/dev/null 2>&1; then
          d_pass "$want"
        else
          d_fail "$want"
          d_note "mounted, but not writable from inside the container -- see Data folders above"
        fi
      else
        d_fail "$want"
        d_note "This path is NOT mounted from the host: its contents live in the"
        d_note "container and are lost when it is replaced."
        d_next "Run: docker compose config    -- it shows the mounts you really have" \
               "Look in compose.override.yaml, and any file named in COMPOSE_FILE, for" \
               "a mount on this same container path: it replaces this one" \
               "See ../docs/06-advanced-customization.md"
      fi
    done
    if proxy_mode; then
      if [[ $'\n'"$mounts"$'\n' == *$'\n'/usr/local/tomcat/conf/server.xml$'\n'* ]]; then
        d_pass "Tomcat proxy configuration mounted"
      else
        d_fail "Tomcat proxy configuration mounted"
        d_note "Behind a proxy, Tomcat needs kit/tomcat/server.xml mounted or it"
        d_note "will send users back to http://<host>:$port instead of your HTTPS URL."
        d_next "Run ./innoslate restart -- COMPOSE_FILE in .env should pull it in" \
               "Check .env has COMPOSE_FILE=compose.yaml,compose.proxy.yaml"
      fi
    fi
  fi

  # --- Innoslate's own configuration ---------------------------------------
  say ""
  info "Innoslate's configuration"
  # Read it from Innoslate itself rather than from a file on this machine: the
  # container is the authority on its own settings.
  local cfg
  # The image prints this with CRLF line endings, and a trailing carriage
  # return captured into a value sends the cursor back to column 0 and
  # overwrites whatever was printed before it.
  cfg="$(in_container innoslate configure export 2>/dev/null | tr -d '\r' || true)"
  if [[ $'\n'"$cfg" =~ $'\n'[[:space:]]*DATABASE_JDBC_URL ]]; then
    d_pass "Innoslate is configured"
    # Redacted even here: a JDBC URL can carry credentials in its query string,
    # and doctor output is exactly what people paste into a support ticket.
    d_note "database URL: $(printf '%s' "$cfg" | sed -n -E 's/^[[:space:]]*DATABASE_JDBC_URL[[:space:]]*=[[:space:]]*(.*)/\1/p' | tail -1 | unescape_properties | redact_url)"
    d_note "filestore:    $(printf '%s' "$cfg" | sed -n -E 's/^[[:space:]]*STORAGE_FILESTOREPATH[[:space:]]*=[[:space:]]*(.*)/\1/p' | tail -1 | unescape_properties)"
    d_note "${C_DIM}(this says what Innoslate was told, not that the database answered)${C_RESET}"

    # Does Innoslate agree with .env about which database this is?
    #
    # These are two independent records and nothing keeps them in step: .env is
    # this kit's, settings.properties is Innoslate's own and survives inside the
    # container. Change the database in "setup" and the next lifecycle command
    # restores the saved configuration -- which still names the OLD database.
    # Everything then reads as healthy: the profile matches, the container is
    # up, setup-summary prints the new host, and Innoslate quietly runs against
    # the old one. Both facts were already printed here; all that was missing
    # was comparing them.
    local expected_host actual_host raw_url
    if has_profile postgres; then expected_host=postgres
    else expected_host="$(env_value KIT_DB_HOST)"; fi
    if [[ -n "$expected_host" ]]; then
      # Java properties escape ":" as "\:" -- strip that before matching.
      raw_url="$(printf '%s' "$cfg" | sed -n -E 's/^[[:space:]]*DATABASE_JDBC_URL[[:space:]]*=[[:space:]]*(.*)/\1/p' | tail -1 | tr -d '\\')"
      actual_host="$(printf '%s' "$raw_url" | sed -n -E 's#^.*//([^:/;[:space:]]+).*$#\1#p')"
      if [[ -n "$actual_host" && "$actual_host" != "$expected_host" ]]; then
        d_fail "Innoslate is using the database .env describes"
        d_note ".env says the database is at:  $expected_host"
        d_note "Innoslate is actually using:   $actual_host"
        d_note "Innoslate keeps its own configuration, and it is using $actual_host."
        local host_fix="set KIT_DB_HOST=$actual_host in .env, or delete that line"
        has_profile postgres && host_fix="run ./innoslate setup and choose your own server"
        d_next "If $expected_host is correct, open Innoslate's admin pages and point" \
               "  it at that database, then run ./innoslate save-config" \
               "If $actual_host is correct, $host_fix" \
               "See ../docs/04-database.md"
      fi
    fi

    # Can Innoslate actually sign in to that database?
    #
    # The password in .env is checked further up, but it is not the one
    # Innoslate uses -- Innoslate has its own copy in settings.properties, and
    # "migrate import" brings that copy across from the machine being moved off,
    # whose bundled PostgreSQL had a different password from the one "setup"
    # asked for here. Nothing else notices: the container is healthy, .env and
    # the database agree, the hosts match -- and Innoslate cannot log in to its
    # own database. Only asking with Innoslate's own password finds it.
    if has_profile postgres && container_running "$DB_CONTAINER"; then
      local cfg_user cfg_pw
      cfg_user="$(printf '%s' "$cfg" | sed -n -E 's/^[[:space:]]*DATABASE_USERNAME[[:space:]]*=[[:space:]]*(.*)/\1/p' | tail -1 | unescape_properties)"
      cfg_pw="$(printf '%s' "$cfg" | sed -n -E 's/^[[:space:]]*DATABASE_PASSWORD[[:space:]]*=[[:space:]]*(.*)/\1/p' | tail -1 | unescape_properties)"
      [[ -z "$cfg_user" ]] && cfg_user="$(env_or POSTGRES_USER innoslate)"
      if [[ -n "$cfg_pw" ]]; then
        if docker exec -e PGPASSWORD="$cfg_pw" -e PROBE_USER="$cfg_user" \
               -e PROBE_DB="$(env_or POSTGRES_DB innoslate)" "$DB_CONTAINER" \
               sh -c 'exec psql -h "$(hostname)" -U "$PROBE_USER" -d "$PROBE_DB" -tAc "select 1"' \
               >/dev/null 2>&1; then
          d_pass "Innoslate can sign in to its database"
        else
          d_fail "Innoslate can sign in to its database"
          d_note "The password in Innoslate's own configuration is not the bundled"
          d_note "PostgreSQL's, so Innoslate cannot reach its database."
          d_next "Give Innoslate this machine's password -- POSTGRES_PASSWORD in .env:" \
                 "docker compose exec innoslate innoslate configure append --DATABASE_PASSWORD 'THAT'" \
                 "./innoslate restart   then   ./innoslate save-config" \
                 "See ../docs/09-migration.md"
        fi
      fi
    fi
  elif [[ -n "$cfg" ]]; then
    d_warn "Innoslate is configured"
    d_note "No database configured yet -- Innoslate is showing its setup page."
    d_next "Open http://localhost:$port and complete it" \
           "Run ./innoslate setup-summary for the exact values to type in"
  else
    d_warn "Innoslate is configured"
    d_note "Could not ask the container (is Innoslate running?)"
  fi

  local saved; saved="$(newest_saved_config)"
  if [[ -n "$saved" ]]; then
    d_pass "A saved configuration exists"
    d_note "$(backups_dir)/$saved"
  elif [[ "$cfg" == *DATABASE_JDBC_URL* ]]; then
    d_warn "A saved configuration exists"
    d_note "Innoslate is configured but nothing has been written out yet."
    d_next "Run ./innoslate save-config now" \
           "start, stop, restart, down and update all do it for you from here on"
  else
    d_note "nothing saved yet -- expected before the setup page is finished"
  fi

  # --- recent trouble in the logs ------------------------------------------
  if container_exists; then
    local errs
    errs="$(container_log 400 | grep -E 'SEVERE|ERROR|Caused by' \
              | grep -vE "$KNOWN_HARMLESS_LOG" | tail -5 || true)"
    if [[ -n "$errs" ]]; then
      say ""
      info "Recent errors in the log"
      printf '%s\n' "$errs" | redact_stream | cut -c1-160 | sed 's/^/  /'
      say ""
      say "  ${C_DIM}From this run only. Full log, all runs: ./innoslate logs${C_RESET}"
    fi
  fi

  doctor_finish
}

doctor_finish() {
  say ""
  rule
  if (( DOCTOR_PROBLEMS == 0 && DOCTOR_WARNINGS == 0 )); then
    ok "Everything checked out."
  elif (( DOCTOR_PROBLEMS == 0 )); then
    warn "$DOCTOR_WARNINGS thing(s) worth a look. Nothing is broken."
  else
    warn "$DOCTOR_PROBLEMS problem(s) and $DOCTOR_WARNINGS thing(s) worth a look."
  fi
  rule
  say ""
  say "  For support@innoslate.com, a summary with no passwords or secrets in it:"
  say ""
  say "      ./innoslate doctor --report"
  say ""
  (( DOCTOR_PROBLEMS == 0 ))
}

# A local HTTP probe using whatever the machine already has. If it has none of
# them the check is skipped -- this kit does not install software to test
# itself.
http_probe() {
  local port="$1"
  if command -v curl >/dev/null 2>&1; then
    curl -fsS -o /dev/null --max-time 10 "http://127.0.0.1:${port}/" 2>/dev/null && return 0
    # A redirect, a 401 or even a 500 is still Innoslate answering; only a
    # failure to connect at all is not, and curl writes "000" for that.
    #
    # Read on its own, with no "|| echo 000" fallback: when curl cannot connect
    # it prints 000 AND exits non-zero, so a fallback would append a second 000
    # and leave "000000" -- which is not equal to "000", making every refused
    # connection look like a success. That is the opposite of what this is for.
    local code
    code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "http://127.0.0.1:${port}/" 2>/dev/null)"
    code="${code//[^0-9]/}"
    [[ -n "$code" && "$code" != 000 ]]
    return
  fi
  if command -v wget >/dev/null 2>&1; then
    wget -q -T 10 -O /dev/null "http://127.0.0.1:${port}/" 2>/dev/null && return 0
    return 1
  fi
  return 1
}

# =============================================================================
#  The support report
#
#  Built from an explicit list of fields. Nothing is dumped: no .env, no
#  docker inspect, no environment. Anything that could carry a secret goes
#  through redact_stream on the way out as a second line of defence.
# =============================================================================

# Strips credentials out of a single URL or JDBC string: the user:pass@host
# form, and any password= / user= parameter in a query string.
redact_url() {
  sed -E \
    -e 's#//[^/:@[:space:]]+:[^/@[:space:]]+@#//***REDACTED***@#g' \
    -e 's#([Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Pp][Ww][Dd]|[Uu][Ss][Ee][Rr]([Nn][Aa][Mm][Ee])?)=[^;&[:space:]]*#\1=***REDACTED***#g'
}

# Java's Properties format escapes ":" and "=" with a backslash when it writes
# a file, so settings.properties holds
#   DATABASE_JDBC_URL=jdbc\:postgresql\://postgres\:5432/innoslate
# That is correct on disk and wrong on screen: someone comparing it with what
# they typed into the setup page reads the backslashes as corruption. Undo them
# for display only -- the comparisons elsewhere strip them for themselves.
unescape_properties() { sed -E 's/\\(.)/\1/g'; }

# Masks secrets in free text. Written in awk rather than sed so that it behaves
# the same with GNU and BSD tools, and so the .env password can be matched as a
# literal string rather than as a regular expression.
redact_stream() {
  local pw=""
  pw="$(env_value POSTGRES_PASSWORD 2>/dev/null || true)"
  [[ "$pw" == "CHANGE_ME" ]] && pw=""
  awk -v pw="$pw" '
    function mask_literal(s, needle,   out, i) {
      if (needle == "" || length(needle) < 6) return s
      out = ""
      while ((i = index(s, needle)) > 0) {
        out = out substr(s, 1, i - 1) "***REDACTED***"
        s = substr(s, i + length(needle))
      }
      return out s
    }
    {
      line = $0
      line = mask_literal(line, pw)
      low = tolower(line)

      # A line that ASSIGNS something secret loses the value. The delimiter has
      # to belong to the name itself -- "DATABASE_PASSWORD=", "token:",
      # "authorization :" -- rather than being any = or : anywhere on the line.
      #
      # That distinction matters more than it looks. Every log line begins with
      # a timestamp, whose first colon arrives long before anything else, so a
      # rule that cut at the first delimiter turned
      #   2026-09-15 21:23:30 [ERROR] LicenseFactory - Global License is bad!
      # into "2026-09-15 21: ***REDACTED***" -- the one message that explains a
      # licensing failure, blanked out of the report sent to support to
      # diagnose it. Prose that merely mentions a licence or a session is not a
      # secret; "SECRET=..." is.
      # The trailing group is what catches a name whose secret word is not the
      # last part of it: "license.key=", "innoslate.license.key=". Without it
      # the rule needs the delimiter to follow the secret word itself, so a
      # licence key -- which the header of this report promises is not in it --
      # went out in full. It is deliberately a short list of endings rather
      # than "anything up to the delimiter", so that "license.txt:" in a log
      # line about a file is still ordinary text.
      if (match(low, /(password|passwd|pwd|secret|token|licen[cs]e|authorization|api[._-]?key|credential|access[._-]?key|session)[a-z0-9_]*([._-](key|keys|token|secret|id|pass|password|pw|value))?["]?[ \t]*[=:]/)) {
        line = substr(line, 1, RSTART + RLENGTH - 1) " ***REDACTED***"
        low = tolower(line)
      }

      # A secret introduced by name with no delimiter at all: "license key
      # INNO-LIC-9999-8888", "Bearer eyJhbGci...". Two conditions keep ordinary
      # prose out of this: the value has to be one unbroken run of at least 12
      # characters, and it has to contain a digit. That is what separates a key
      # from the next English word, so
      #   FATAL: password authentication failed for user "innoslate"
      # keeps the word that tells you what went wrong.
      if (match(low, /(password|passwd|pwd|secret|token|licen[cs]e|credential|bearer)([ \t]+(key|token|id))?[ \t]+[^ \t]{12,}/)) {
        ostart = RSTART; olen = RLENGTH
        seg = substr(line, ostart, olen)
        if (match(seg, /[ \t]+[^ \t]+$/)) {
          gap = RSTART
          tok = substr(seg, gap)
          sub(/^[ \t]+/, "", tok)
          if (tok ~ /[0-9]/) {
            line = substr(line, 1, ostart + gap - 2) " ***REDACTED***" substr(line, ostart + olen)
            low = tolower(line)
          }
        }
      }
      # user:password@host in a URL or JDBC string
      gsub(/\/\/[^\/:@[:space:]]+:[^\/@[:space:]]+@/, "//***REDACTED***@", line)
      print line
    }'
}

# Prints a value through the redactor, so a field that unexpectedly contains a
# secret cannot get out just because it was on the whitelist.
r_field() { printf '%-26s %s\n' "$1" "$2" | redact_stream; }

# "docker inspect" on a container that does not exist prints a blank line to
# stdout as well as failing, and "grep -c" prints 0 while exiting non-zero when
# it counts nothing. Taken at face value, either turns a one-line field into
# two. These keep every field on one line.
inspect_field() { # inspect_field <format> <container>
  local v
  v="$(docker inspect --format "$1" "$2" 2>/dev/null | head -1 | tr -d '\r')"
  if [[ -n "$v" ]]; then printf '%s' "$v"; else printf 'n/a'; fi
}

count_matching() { # count_matching <dir> <extended regex>
  local n
  n="$(ls -1 "$1" 2>/dev/null | grep -cE "$2" | head -1)"
  [[ "$n" =~ ^[0-9]+$ ]] || n=0
  printf '%s' "$n"
}

doctor_report() {
  local port dir
  port="$(env_or INNOSLATE_PORT 8080)"
  dir="$(data_dir)"

  say "==============================================================="
  say " Innoslate quick-start support report"
  say " Generated $(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date)"
  say ""
  say " This report is assembled from a fixed list of fields. It does"
  say " not contain passwords, license keys, tokens, your .env, or"
  say " Innoslate's configuration file. Safe to attach to a ticket."
  say "==============================================================="
  say ""

  say "-- This machine ----------------------------------------------"
  r_field "quick-start version" "$KIT_VERSION"
  r_field "operating system" "$(uname -s 2>/dev/null || echo unknown) $(uname -r 2>/dev/null || true)"
  r_field "architecture" "$(uname -m 2>/dev/null || echo unknown)"
  if command -v docker >/dev/null 2>&1; then
    r_field "docker" "$(docker version --format '{{.Server.Version}}' 2>/dev/null || echo "installed, not running")"
  else
    r_field "docker" "not installed"
  fi
  if docker compose version >/dev/null 2>&1; then
    r_field "docker compose" "$(docker compose version --short 2>/dev/null | tr -d ' \r')"
  else
    r_field "docker compose" "not available"
  fi
  say ""

  say "-- Chosen setup ----------------------------------------------"
  r_field "access mode" "$(env_or KIT_ACCESS_MODE 'not recorded')"
  if proxy_mode; then
    r_field "proxy runs" "$(env_or KIT_PROXY_LOCATION 'not recorded')"
    r_field "proxy type" "$(env_or KIT_PROXY_KIND 'not recorded')"
    # The public host name is the customer's own DNS name; report the scheme
    # and whether it is set, not the name itself.
    case "$(env_value KIT_PUBLIC_URL)" in
      https://*) r_field "public address" "set, https" ;;
      http://*)  r_field "public address" "set, http (not encrypted)" ;;
      *)         r_field "public address" "not recorded" ;;
    esac
  fi
  r_field "database" "$(env_or KIT_DB_KIND 'not recorded')"
  if [[ "$(env_value KIT_DB_KIND)" != bundled && -n "$(env_value KIT_DB_HOST)" ]]; then
    r_field "database host" "$(env_value KIT_DB_HOST)"
    r_field "database port" "$(env_value KIT_DB_PORT)"
    r_field "database name" "$(env_value KIT_DB_NAME)"
    # The username is deliberately omitted: half a credential is still half a
    # credential, and support does not need it to read this report.
  fi
  r_field "compose profiles" "$( [[ -n "$(profiles)" ]] && profiles || echo "(none)" )"
  r_field "compose files" "$(env_or COMPOSE_FILE 'compose.yaml (+ compose.override.yaml if present)')"
  r_field "desired version" "$(env_or INNOSLATE_VERSION latest)"
  r_field "image name" "$(env_or INNOSLATE_IMAGE innoslate/innoslate)"
  r_field "published on" "$(env_or INNOSLATE_BIND 0.0.0.0):$port"
  r_field "JAVA_OPTS" "$(env_or JAVA_OPTS '(default)')"
  r_field "timezone" "$(env_or TZ UTC)"
  r_field "data folder is absolute" "$( [[ "$(data_dir)" == /* || "$(data_dir)" =~ ^[A-Za-z]: ]] && echo yes || echo "no (inside the kit folder)" )"
  say ""

  say "-- What is running -------------------------------------------"
  if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
    r_field "innoslate container" "$( container_exists && echo present || echo absent )"
    r_field "innoslate running" "$( container_running && echo yes || echo no )"
    r_field "innoslate health" "$(health)"
    r_field "running image" "$(running_version)"
    r_field "restarts" "$(inspect_field '{{.RestartCount}}' "$CONTAINER")"
    r_field "started at" "$(inspect_field '{{.State.StartedAt}}' "$CONTAINER")"
    if has_profile postgres; then
      r_field "database container" "$( container_running "$DB_CONTAINER" && echo running || echo "not running" )"
      r_field "database health" "$(inspect_field '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$DB_CONTAINER")"
      r_field "database image" "$(inspect_field '{{.Config.Image}}' "$DB_CONTAINER")"
    fi
  else
    r_field "docker" "not running -- nothing to report"
  fi
  say ""

  say "-- Persistent storage ----------------------------------------"
  local sub
  for sub in filestore backups logs; do
    if [[ -d "$dir/$sub" ]]; then
      r_field "$sub" "present, $(du -sh "$dir/$sub" 2>/dev/null | cut -f1 || echo '?')"
    else
      r_field "$sub" "MISSING"
    fi
  done
  if container_running; then
    say ""
    say "  mounted into the container:"
    docker inspect --format '{{range .Mounts}}    {{.Destination}}
{{end}}' "$CONTAINER" 2>/dev/null | grep -v '^[[:space:]]*$' || true
    say "  (host-side paths are left out on purpose -- they can name people)"
  fi
  say ""

  say "-- Innoslate's own state -------------------------------------"
  if config_present; then
    r_field "configured" "yes"
    local jdbc
    jdbc="$(in_container innoslate configure export 2>/dev/null | tr -d '\r' \
             | sed -n -E 's/^[[:space:]]*DATABASE_JDBC_URL[[:space:]]*=[[:space:]]*(.*)/\1/p' | tail -1)"
    # Report the driver and port only. A JDBC URL can carry a username and
    # password in its query string, so nothing else from it is printed.
    case "$jdbc" in
      jdbc:postgresql:*) r_field "database driver" "postgresql" ;;
      jdbc:sqlserver:*)  r_field "database driver" "sqlserver" ;;
      jdbc:*)            r_field "database driver" "$(printf '%s' "$jdbc" | cut -d: -f2)" ;;
      *)                 r_field "database driver" "not readable" ;;
    esac
  elif container_running; then
    r_field "configured" "no -- setup page not finished"
  else
    r_field "configured" "unknown -- container not running"
  fi
  r_field "saved configurations" "$(count_matching "$dir/backups" '^settings-.*[.]properties$')"
  r_field "newest saved" "$(newest_saved_config)"
  r_field "backups taken" "$(count_matching "$dir/backups" '^backup-')"
  say ""

  say "-- Recent errors this run (redacted, newest last) ------------"
  if container_exists; then
    local errs
    errs="$(container_log 400 \
              | grep -E 'SEVERE|ERROR|Caused by|Exception' \
              | grep -vE "$KNOWN_HARMLESS_LOG" | tail -12 || true)"
    if [[ -n "$errs" ]]; then
      printf '%s\n' "$errs" | redact_stream | cut -c1-200 | sed 's/^/  /'
    else
      say "  none in the last 400 lines"
    fi
  else
    say "  no container to read"
  fi
  say ""
  say "==============================================================="
  say " End of report. Send it with a description of what you expected"
  say " to happen and what happened instead."
  say " support@innoslate.com  /  (571) 485-7800"
  say "==============================================================="
}

# =============================================================================
cmd_destroy() {
  case "${1:-}" in
    "")    ;;
    --all) destroy_all; return ;;
    *)     die "Unknown option for destroy: $1
Use  ./innoslate destroy        to remove the containers and the bundled database
or   ./innoslate destroy --all  to remove everything and start over from setup" ;;
  esac
  require_docker; require_env_file; guard_foreign_installation
  say ""
  say "${C_RED}${C_BOLD}This removes the containers and, if you use it, the bundled database${C_RESET}"
  say "${C_RED}${C_BOLD}with all your projects, requirements and users.${C_RESET}"
  say ""
  say "Kept: $(data_dir) -- your filestore, saved configurations and backups."
  say "Not touched: an external database, if you use one."
  say ""
  local answer
  read -r -p "Type 'delete everything' to continue: " answer || answer=""
  [[ "$answer" == "delete everything" ]] || { say "Cancelled."; return 0; }
  dc down --volumes --remove-orphans
  ok "Removed."
  say ""
  say "  ./innoslate start would load the newest saved configuration from"
  say "  $(backups_dir) into the fresh container. To start over, delete them first:"
  say "      rm \"$(backups_dir)\"/settings-*.properties"
  say "  or remove everything, including the backups and .env:"
  say "      ./innoslate destroy --all"
  say ""
}

# "destroy", and then everything "destroy" deliberately keeps: the images, the
# three data folders -- backups included -- and .env. What is left is this
# kit's own files, and ./innoslate setup starts over as a first install. It is
# the only command that deletes backups, so the warning lists each thing by
# name and the answer it wants is not the one plain "destroy" wants: typing
# that one from habit has to stop here.
#
# It works from what is actually on the machine, not from the files in this
# folder. A half-deleted installation -- no .env, no data folders, even no
# compose.yaml -- is exactly the one somebody wants gone, so none of them is
# required: Compose is tried first, and whatever it leaves is removed by name.
destroy_all() {
  require_docker; guard_foreign_installation

  local dir images="" name found_containers=() has_volume=0 has_network=0
  dir="$(data_dir)"
  # Everything is read before anything is removed. The images come from two
  # places: what Compose would run, and what the containers were actually
  # started from -- without .env, Compose does not know about the database.
  for name in "$CONTAINER" "$DB_CONTAINER"; do
    if docker inspect "$name" >/dev/null 2>&1; then
      found_containers[${#found_containers[@]}]="$name"
      images="$images$(docker inspect --format '{{.Config.Image}}' "$name" 2>/dev/null || true)"$'\n'
    fi
  done
  images="$(printf '%s\n%s' "$images" "$(dc config --images 2>/dev/null || true)" \
              | grep -v '^[[:space:]]*$' | sort -u || true)"
  docker volume inspect "$PG_VOLUME" >/dev/null 2>&1 && has_volume=1
  docker network inspect "$NETWORK" >/dev/null 2>&1 && has_network=1

  say ""
  say "${C_RED}${C_BOLD}This removes EVERYTHING this installation has. It cannot be undone.${C_RESET}"
  say ""
  if (( ${#found_containers[@]} )); then
    say "  Containers ...... ${found_containers[*]}"
  else
    say "  Containers ...... none found"
  fi
  (( has_network )) && say "  Network ......... $NETWORK"
  if (( has_volume )); then
    say "  Database ........ the bundled PostgreSQL volume, holding all your projects,"
    say "                    requirements and users"
  fi
  if [[ -n "$images" ]]; then
    say "  Images .......... $(printf '%s' "$images" | tr '\n' ' ')"
    say "                    (downloaded again by the next start)"
  fi
  say "  Filestore ....... $dir/filestore -- every file uploaded to Innoslate$(present_or_not "$dir/filestore")"
  say "  Backups ......... $dir/backups -- $(count_matching "$(backups_dir)" '^backup-') backup(s) and $(count_matching "$(backups_dir)" '^settings-.*[.]properties$') saved configuration(s)$(present_or_not "$dir/backups")"
  say "  Logs ............ $dir/logs$(present_or_not "$dir/logs")"
  say "  Settings ........ .env -- the database password and every answer given to setup$(present_or_not "$KIT_DIR/.env")"
  say ""
  say "Not touched: an external database, if you use one, and this kit's own files"
  say "(compose.override.yaml included)."
  say ""
  say "${C_BOLD}To keep a copy, stop here${C_RESET} and copy $dir/backups somewhere else first."
  say "./innoslate backup takes a fresh one; ./innoslate migrate export <folder>"
  say "packages the whole installation into a folder outside $dir."
  say ""
  local answer
  read -r -p "Type 'delete everything including backups' to continue: " answer || answer=""
  [[ "$answer" == "delete everything including backups" ]] || { say "Cancelled. Nothing has been changed."; return 0; }
  say ""

  if [[ -f "$KIT_DIR/compose.yaml" ]]; then
    dc down --volumes --remove-orphans \
      || warn "Docker Compose could not finish. Removing what is left by name instead."
  fi
  # Whatever Compose did not know about. Each is checked first, so a clean
  # "down" above leaves nothing for these to do.
  for name in "$CONTAINER" "$DB_CONTAINER"; do
    if docker inspect "$name" >/dev/null 2>&1; then
      docker rm -f "$name" >/dev/null 2>&1 || warn "Could not remove the container $name."
    fi
  done
  if docker volume inspect "$PG_VOLUME" >/dev/null 2>&1; then
    docker volume rm "$PG_VOLUME" >/dev/null 2>&1 || warn "Could not remove the volume $PG_VOLUME."
  fi
  if docker network inspect "$NETWORK" >/dev/null 2>&1; then
    docker network rm "$NETWORK" >/dev/null 2>&1 || warn "Could not remove the network $NETWORK."
  fi

  # Before the images: on Linux the folders belong to Innoslate's user id, and
  # the way round that without sudo is a container started from one of them.
  remove_data_dirs || true

  if [[ -n "$images" ]]; then
    local image
    while IFS= read -r image; do
      [[ -n "$image" ]] || continue
      docker image inspect "$image" >/dev/null 2>&1 || continue
      # Another folder's containers may still use the same image. Theirs to keep.
      docker rmi "$image" >/dev/null 2>&1 \
        || warn "Kept $image: something else on this machine is still using it."
    done <<<"$images"
  fi

  # Last, because every step above reads it.
  rm -f "$KIT_DIR/.env"
  ok "Everything is removed. To start over:  ./innoslate setup"
}

# " (not there)" for a path that does not exist, so the list above stays one
# list rather than changing shape with what happens to be left.
present_or_not() { [[ -e "$1" ]] || printf ' (not there)'; }

# Deletes the three folders this kit creates under DATA_DIR, and DATA_DIR
# itself only when that leaves it empty and it is inside this folder. Held to
# the same rule as chown_data_dirs: nothing that looks like a system location
# or a home directory is touched, and nothing under DATA_DIR but those three.
remove_data_dirs() {
  local dir sub
  dir="$(data_dir)"
  if ! data_dir_is_safe_to_chown "$dir"; then
    warn "Not deleting anything under $dir: it is a system folder, a home directory"
    warn "or a folder directly under /. Remove its filestore, backups and logs"
    warn "folders yourself."
    return 1
  fi

  local targets=()
  for sub in filestore backups logs; do
    [[ -e "$dir/$sub" ]] && targets[${#targets[@]}]="$dir/$sub"
  done
  if (( ${#targets[@]} )); then
    rm -rf "${targets[@]}" 2>/dev/null || true

    # What this account cannot delete -- files Innoslate wrote, on Linux -- is
    # deleted from inside a throwaway container instead, as root.
    local left=0 abs helper
    for sub in filestore backups logs; do [[ -e "$dir/$sub" ]] && left=1; done
    if (( left )); then
      abs="$(cd "$dir" 2>/dev/null && pwd)" || abs=""
      if [[ -n "$abs" ]] && helper="$(maintenance_image)"; then
        docker run --rm --user 0:0 --entrypoint sh \
          -v "$(host_path "$abs")":/data-dir "$helper" \
          -c 'rm -rf /data-dir/filestore /data-dir/backups /data-dir/logs' >/dev/null 2>&1 || true
      fi
    fi

    for sub in filestore backups logs; do
      if [[ -e "$dir/$sub" ]]; then
        warn "Could not delete $dir/$sub. Remove it by hand:  sudo rm -rf $dir/$sub"
        return 1
      fi
    done
  fi
  [[ "$dir" != /* ]] && rmdir "$dir" 2>/dev/null || true
}

cmd_save_config() {
  require_docker; require_env_file
  save_config && ok "configuration saved: $(backups_dir)/$(newest_saved_config)"
}

cmd_restore_config() {
  require_docker; require_env_file
  if restore_config "${1:-}"; then
    dc restart "$SERVICE"
    wait_until_ready || true
    config_present && ok "configuration restored" \
      || warn "Innoslate still reports no configuration -- see ./innoslate doctor"
  fi
}

# =============================================================================
#  files -- what in this folder to keep, and what you can ignore
#
#  This folder IS the installation: compose.yaml reads every path relative to
#  wherever it lives, and the project, network and volume names inside it are
#  pinned rather than derived from the path, so moving the whole folder needs
#  no edits -- ../docs/08-leaving-the-kit.md has the reasoning. This command
#  says what is worth knowing inside it, and -- while the kit's docs/ and README
#  are still next to it -- that that folder is optional.
# =============================================================================

f_row() {
  if [[ -n "${2:-}" ]]; then printf '    %-30s %s\n' "$1" "$2"
  else                       printf '    %s\n' "$1"
  fi
}

cmd_files() {
  local have_env=0 proxy=0 override=""
  [[ -f "$KIT_DIR/.env" ]] && have_env=1
  # proxy_mode already believes either signal -- KIT_ACCESS_MODE, or
  # compose.proxy.yaml named in COMPOSE_FILE by hand.
  proxy_mode && proxy=1
  [[ -f "$KIT_DIR/compose.override.yaml" ]] && override=compose.override.yaml
  [[ -f "$KIT_DIR/compose.override.yml" ]]  && override=compose.override.yml

  local dir; dir="$(data_dir)"
  # "Outside" means anywhere this folder does not contain: an absolute path,
  # or one that climbs out of it. It is the one case where moving this folder
  # does NOT bring everything with it, so it gets a warning of its own below.
  local outside=0
  case "$dir" in ./*|data|data/*) ;; *) outside=1 ;; esac

  say ""
  rule
  say "${C_BOLD}  What is yours, and what is only this kit${C_RESET}"
  rule
  say ""

  say "  ${C_BOLD}THIS WHOLE FOLDER is your installation.${C_RESET} Move it, rename it, copy it"
  say "  anywhere on this machine -- nothing else is required."
  say ""

  if has_profile postgres; then
    say "  ${C_DIM}Moving to another machine needs the database too: it is a Docker volume,${C_RESET}"
    say "  ${C_DIM}not a file in here. Use ./innoslate migrate export.${C_RESET}"
    say ""
  fi

  if (( outside )); then
    warn "your data is not in this folder"
    say "  ${C_DIM}DATA_DIR is $dir, outside this folder. Moving this folder will NOT${C_RESET}"
    say "  ${C_DIM}bring your data with it -- move $dir too, or point DATA_DIR in here first.${C_RESET}"
    say ""
  fi

  if (( ! have_env )); then
    say "  ${C_DIM}Setup has not run yet, so this is the general picture.${C_RESET}"
    say ""
  fi

  say "  ${C_BOLD}Never delete${C_RESET}"
  f_row ".env" "your settings, and the database password"
  f_row "$dir/filestore" "uploaded files and diagram images"
  f_row "$dir/backups" "saved copies of the configuration"
  f_row "$dir/logs" "Tomcat's logs"
  if has_profile postgres; then
    f_row "(the database)" "Docker volume innoslate-postgres-data"
  elif (( have_env )); then
    f_row "(the database)" "on $(env_or KIT_DB_HOST "your own server") -- yours already"
  else
    f_row "(the database)" "a Docker volume, or your own server"
  fi
  say ""

  say "  ${C_BOLD}Needed to start it${C_RESET}"
  f_row "compose.yaml" "the stack itself"
  if (( proxy )); then
    f_row "compose.proxy.yaml" "in use -- COMPOSE_FILE in .env names it"
    f_row "kit/tomcat/server.xml" "the Tomcat setting that file mounts"
  fi
  [[ -n "$override" ]] && f_row "$override" "your own additions to the stack"
  [[ -d "$KIT_DIR/certs" ]] && f_row "certs/" "your TLS certificate and key"
  say ""
  if (( proxy )); then
    say "  ${C_DIM}Compose refuses to start if a file named in COMPOSE_FILE is missing,${C_RESET}"
    say "  ${C_DIM}and losing server.xml turns a working login into a login loop.${C_RESET}"
    say ""
  fi

  say "  ${C_BOLD}Convenience, not required${C_RESET}"
  say "  Delete these and \"docker compose ...\" still works -- you only lose the"
  say "  shortcuts (update, backup, doctor, migrate, this very command)."
  say ""
  f_row "innoslate, innoslate.cmd, innoslate.ps1" "the launcher"
  f_row "VERSION" "which release of it this is"
  f_row ".env.example" "a template; nothing reads it once .env exists"
  f_row "compose.override.example.yaml" "a template to copy if you want one"
  if (( ! proxy )); then
    if (( have_env )); then
      f_row "compose.proxy.yaml" "not in use -- kept in case you turn on a reverse proxy later"
    else
      f_row "compose.proxy.yaml" "used only behind a reverse proxy"
    fi
    f_row "kit/tomcat/server.xml" "the one setting that file mounts"
  fi
  say ""

  # The outer kit (docs/ and the README) is a SIBLING of this
  # folder, not inside it, so it is only worth mentioning while it is still
  # actually there -- after a move, it usually is not.
  if [[ -d "$KIT_DIR/../docs" ]]; then
    say "  One level up are docs/ and a README -- reading only, never used by"
    say "  Innoslate. Once this folder is where you want it, you can delete them."
    say ""
  fi

  rule
  say ""
}
cmd_help() {
  cat <<'HELP'
Innoslate launcher

  Getting started
    ./innoslate setup              Answer a few questions; writes the whole
                                   configuration and tells you what to do next
    ./innoslate setup-summary      Show those instructions again, any time
    ./innoslate files              Which files here are your installation,
                                   and which are only this kit

  Everyday use
    ./innoslate start              Start Innoslate
    ./innoslate stop               Stop it, keeping the containers
    ./innoslate restart            Restart, picking up any config changes
    ./innoslate status             What is running, where the data is, the URL
    ./innoslate logs [service]     Follow the logs; Ctrl+C to stop watching

  Updating
    ./innoslate update             Back up, pull the image again, replace the
                                   container. Database and filestore are kept.
    ./innoslate update 4.15.0.0    Same, but move to a specific version.
                                   If the download fails, nothing changes.

  Configuration
    ./innoslate save-config        Write Innoslate's configuration out to
                                   data/backups. start, stop, restart, down and
                                   update do this for you; run it once by hand
                                   after finishing the setup page.
    ./innoslate restore-config [file]
                                   Load a saved configuration back in (the
                                   newest, if no file is named)

  Backups and moving
    ./innoslate backup             Save the configuration and, if the bundled
                                   database is in use, a dump of it
    ./innoslate restore            List available backups
    ./innoslate restore <name>     Restore one
    ./innoslate migrate export     Package this installation up to move it
    ./innoslate migrate import <d> Load such a package here

  When something is wrong
    ./innoslate doctor             Check the installation and say what is wrong
    ./innoslate doctor --report    A summary with no secrets in it, for support
    ./innoslate shell              Open a shell inside the container

  Less often
    ./innoslate down               Stop and remove the containers (data kept)
    ./innoslate destroy            Remove the containers AND the bundled
                                   database contents (asks for confirmation)
    ./innoslate destroy --all      Remove everything, including the images,
                                   .env and data/ with its backups, so setup
                                   starts from scratch (asks for confirmation)
    ./innoslate version            Which release of this launcher this is

  This is a wrapper around Docker Compose, so "docker compose ..." works here
  directly too. Nothing is added to the container, so you can delete this
  script and keep running Innoslate -- "./innoslate files" says what is safe
  to delete, and ../docs/08-leaving-the-kit.md walks through leaving.
  Guides are in ../docs/.
HELP
}

# =============================================================================
main() {
  local cmd="${1:-help}"; shift || true
  case "$cmd" in
    setup)           cmd_setup "$@" ;;
    setup-summary|summary|next) require_env_file; cmd_setup_summary ;;
    start|up)        cmd_start "$@" ;;
    stop)            cmd_stop "$@" ;;
    restart)         cmd_restart "$@" ;;
    down)            cmd_down "$@" ;;
    update|upgrade)  cmd_update "$@" ;;
    status|ps)       cmd_status "$@" ;;
    logs|log)        cmd_logs "$@" ;;
    shell|exec|bash) cmd_shell "$@" ;;
    save-config)     cmd_save_config "$@" ;;
    restore-config)  cmd_restore_config "$@" ;;
    backup)          cmd_backup "$@" ;;
    restore)         cmd_restore "$@" ;;
    migrate)         cmd_migrate "$@" ;;
    doctor|check)    cmd_doctor "$@" ;;
    files|keep)      cmd_files "$@" ;;
    destroy)         cmd_destroy "$@" ;;
    version)         say "Innoslate quick-start $KIT_VERSION" ;;
    help|-h|--help)  cmd_help ;;
    *)               say "Unknown command: $cmd"; say ""; cmd_help; exit 1 ;;
  esac
}

# Run only when executed. Sourcing the file -- which is how its functions are
# exercised under test -- defines them without doing anything.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  main "$@"
fi
