#!/usr/bin/env bash
# DevGrail uninstaller — companion to install.sh.
#
#   curl -fsSL https://<devgrail-web>/uninstall.sh | sudo bash              # keep workspaces + data
#   curl -fsSL https://<devgrail-web>/uninstall.sh | sudo bash -s -- --purge # full wipe
#   sudo bash uninstall.sh            # tear down, PRESERVE workspaces, data + config
#   sudo bash uninstall.sh --purge    # full wipe: also remove data, certs, files
#   sudo bash uninstall.sh --nuke     # --purge, plus undo the host-level changes
#                                     # install.sh made, so the VPS is as it was
#   sudo bash uninstall.sh --dry-run  # report what would be destroyed, change nothing
#
# Every run opens with an inventory of what it is about to destroy and what it
# will keep, named object by object, before the first question is asked.
# --dry-run prints that same report and stops there. It is the safe first move
# on a host you are not certain about, and it combines with the flags below:
# `--nuke --dry-run` reports what a --nuke would do without doing any of it.
#
# --purge takes a backup (database + config.yaml) before deleting anything, so
# the one irreversible command in the product still leaves a way back. Pass
# --no-backup to skip it — which is the only way to destroy the data outright.
#
# Removes what install.sh created: the Compose stack (infra containers +
# managed networks + volumes), the separately-spawned per-workspace networks,
# the external `devgrail` edge network, the loaded images (every tag, not just
# :latest) and — with --purge — the workspace containers and volumes (labeled
# `devgrail.managed`, not owned by Compose), the persistent data volumes and the
# on-disk config in /opt/devgrail and /etc/devgrail.
#
# Default run deletes nothing of yours. The workspace containers are stopped and
# left where they are — a workspace's filesystem is not all in its volume, so
# removing the container would throw away installed packages, shell history and
# whatever else lives in its writable layer — and so are their volumes, the
# devgrail-container image they run on, the SQLite data volume (devgrail-data),
# the Let's Encrypt certs (traefik-acme) and the config files. Only the stack
# containers and the per-workspace networks go, and install.sh and the server
# recreate both, so a later re-run resumes in place with every workspace intact.
# --purge leaves nothing of DevGrail's own.
#
# --purge still leaves the host itself changed, because install.sh changes more
# than DevGrail's own namespace: it edits /etc/docker/daemon.json (userns-remap,
# default-address-pools), which moves Docker's data root and creates the
# `dockremap` user, and it installs Docker and jq. --nuke reverts all of that
# except the packages; --remove-docker additionally uninstalls Docker Engine.
# Neither ever removes openssl — too much on a VPS depends on it.
#
# Vars:
#   DEVGRAIL_INSTALL_DIR  install root (default /opt/devgrail)
#   DEVGRAIL_DAEMON_JSON  Docker daemon config to revert (default /etc/docker/daemon.json)
set -euo pipefail

INSTALL_DIR="${DEVGRAIL_INSTALL_DIR:-/opt/devgrail}"
DEPLOY_DIR="$INSTALL_DIR/deploy"
CONFIG_DIR=/etc/devgrail
COMPOSE_FILE="$DEPLOY_DIR/docker-compose.yml"
OVERRIDE_FILE="$DEPLOY_DIR/docker-compose.override.yml"
# Compose derives the project name from the project directory, so this is the
# prefix on every object the stack owns (deploy_devgrail-data, deploy-traefik-1).
# It is how orphans are found once $COMPOSE_FILE itself is gone.
PROJECT="$(basename "$DEPLOY_DIR")"
DAEMON_JSON="${DEVGRAIL_DAEMON_JSON:-/etc/docker/daemon.json}"

PURGE=0
ASSUME_YES=0
# Report and stop. Nothing below this flag's check may write, delete or restart
# anything, which is what lets --dry-run skip the no-terminal guard entirely.
DRY_RUN=0
# --nuke implies --purge and additionally reverts the host-level changes;
# --remove-docker implies --nuke and additionally uninstalls Docker Engine.
NUKE=0
REMOVE_DOCKER=0
# A --purge takes a backup first unless the operator explicitly opts out. It is
# the only irreversible operation here: the database, every workspace volume and
# the config.yaml whose jwt_secret decrypts the database all go at once.
NO_BACKUP=0

log()  { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mWARNING:\033[0m %s\n' "$*" >&2; }
die()  { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }

usage() {
  cat <<EOF
Usage: sudo bash uninstall.sh [--dry-run] [--purge] [--nuke] [--remove-docker]
                              [--no-backup] [--yes]

  --dry-run Print the inventory of what this invocation would destroy and what
            it would keep, then exit without touching anything. Combines with
            every flag below, so --nuke --dry-run reports a --nuke. Needs no
            terminal, so it is safe to pipe from curl.
  --purge   Also remove the workspace containers and everything persistent
            (workspace volumes, devgrail-data volume, traefik-acme certs) and
            the on-disk config in $INSTALL_DIR and $CONFIG_DIR. Without this,
            the workspaces are only stopped and the rest is preserved, so
            install.sh can resume in place.
  --nuke    Implies --purge, and additionally undoes what install.sh changed
            outside DevGrail's own namespace: the userns-remap and
            default-address-pools keys it added to $DAEMON_JSON
            (restarting Docker), the now-orphaned remapped data root, and the
            dockremap user. Leaves the host as install.sh found it, so a
            re-run installs from scratch. Docker, jq and openssl stay.
  --remove-docker
            Implies --nuke, and also uninstalls Docker Engine, containerd and
            /var/lib/docker. This stops and deletes EVERY container on this
            host, DevGrail's or not. Only for a host that is DevGrail's alone.
  --yes     Do not prompt for confirmation.
  --no-backup
            With --purge, skip the automatic pre-wipe backup. Without this,
            --purge writes a database + config.yaml archive to
            $INSTALL_DIR/backups first and refuses to continue if it cannot.
  -h, --help  Show this help.
EOF
}

while [ $# -gt 0 ]; do
  case "$1" in
    --purge)     PURGE=1 ;;
    --nuke)      NUKE=1; PURGE=1 ;;
    --remove-docker) REMOVE_DOCKER=1; NUKE=1; PURGE=1 ;;
    --no-backup) NO_BACKUP=1 ;;
    --dry-run)   DRY_RUN=1 ;;
    --yes|-y)    ASSUME_YES=1 ;;
    -h|--help)   usage; exit 0 ;;
    *) die "unknown argument: $1 (see --help)" ;;
  esac
  shift
done

[ "$(id -u)" = 0 ] || die "run as root: sudo bash uninstall.sh"

# A host with no Docker has no DevGrail objects to remove, but it may still
# carry the daemon config and the dockremap user install.sh left behind — so
# --nuke still has work to do, and only the Docker-object steps are skipped.
HAVE_DOCKER=1
command -v docker >/dev/null 2>&1 || HAVE_DOCKER=0
if [ "$HAVE_DOCKER" = 0 ] && [ "$NUKE" = 0 ]; then
  die "docker not found — nothing to do, or already removed"
fi

# compose runs the stack's own compose file, including the operator's override
# if there is one. Matching install.sh matters here: an override that adds a
# service means `down` without it leaves that service running.
compose() {
  if [ -f "$OVERRIDE_FILE" ]; then
    docker compose --project-directory "$DEPLOY_DIR" -f "$COMPOSE_FILE" -f "$OVERRIDE_FILE" "$@"
  else
    docker compose --project-directory "$DEPLOY_DIR" -f "$COMPOSE_FILE" "$@"
  fi
}

# --- prompting -------------------------------------------------------------
# stdin is the piped installer, so questions go to /dev/tty. Returns 0 on yes.
ask() {
  local msg="$1" ans
  printf '%s ' "$msg" > /dev/tty
  read -r ans < /dev/tty || ans=""
  case "$ans" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
}

# have_tty — whether there is a terminal to ask questions on. It *opens*
# /dev/tty rather than testing that the path exists, which is the same test
# install.sh makes and for the same reason: the device node is present in
# plenty of contexts that cannot open it (a detached systemd unit, a container
# started without a tty, some CI runners). Testing existence there sends the
# run down the prompt path, every `read` fails, and a --purge aborts with
# "aborted" instead of taking the documented no-terminal branch below.
have_tty() { { : < /dev/tty; } 2>/dev/null; }


# --- host revert helpers ---------------------------------------------------
# Extracted by test/installer/uninstall_nuke_test.sh and by
# test/installer/uninstall_inventory_test.sh between this marker and the
# matching "end host revert helpers" one. Keep both comments intact, and keep
# everything between them free of top-level side effects so the tests can source
# it on a host with no Docker and no root.
#
# These sit above the questions rather than below them because the report that
# precedes those questions asks classify_daemon_json what a --nuke would
# actually revert. Definitions only — nothing here runs until it is called.

# Restart dockerd across the init systems install.sh supports: systemd on the
# Debian/RHEL images, OpenRC on Alpine. Non-zero means no known mechanism
# worked, which callers treat as "put the config back".
restart_docker() {
  if command -v systemctl >/dev/null 2>&1; then
    systemctl restart docker >/dev/null 2>&1 && return 0
  fi
  if command -v rc-service >/dev/null 2>&1; then
    rc-service docker restart >/dev/null 2>&1 && return 0
  fi
  if command -v service >/dev/null 2>&1; then
    service docker restart >/dev/null 2>&1 && return 0
  fi
  return 1
}

# Wait for the daemon to answer again. Coming off a remapped data root it has a
# different tree to re-index, so this window is generous on purpose.
wait_for_docker() {
  local i=0
  while [ "$i" -lt 90 ]; do
    docker info >/dev/null 2>&1 && return 0
    sleep 1
    i=$((i + 1))
  done
  return 1
}

# Which of install.sh's keys this run actually removed. Read afterwards by the
# data-root and dockremap cleanup, which must only run when userns-remap was
# genuinely ours to turn off. classify_daemon_json sets them as candidates;
# revert_daemon_json clears them again if the write or the restart fails.
REVERTED_USERNS=0
REVERTED_POOLS=0
# Newline-separated "<kind>:<text>" lines explaining whatever
# classify_daemon_json declined to touch, rendered by the caller rather than
# printed where they are found. That is what lets the --dry-run report and the
# real revert share one classification without the report warning about the
# same file twice.
DAEMON_JSON_NOTE=""

# classify_daemon_json — which of install.sh's two keys are still ours to
# remove. Pure and silent: reads $DAEMON_JSON, sets REVERTED_USERNS /
# REVERTED_POOLS and fills DAEMON_JSON_NOTE, and returns 1 when nothing
# install.sh wrote is left in the file. Writes nothing, restarts nothing, so
# the dry-run report can call it too.
#
# Keys count as ours only while they still hold the exact values install.sh
# writes. An operator who has since set userns-remap to a named user, or tuned
# the pools to their own addressing, made a decision this script has no business
# reversing — those are reported and left. A daemon.json that does not parse is
# never touched, for the same reason install.sh does not touch it: that is an
# operator edit in progress, and clobbering it would destroy work we cannot see.
classify_daemon_json() {
  local current remap

  REVERTED_USERNS=0
  REVERTED_POOLS=0
  DAEMON_JSON_NOTE=""

  if [ ! -s "$DAEMON_JSON" ]; then
    DAEMON_JSON_NOTE="log:No $DAEMON_JSON on this host — nothing to revert."
    return 1
  fi
  if ! jq -e . "$DAEMON_JSON" >/dev/null 2>&1; then
    DAEMON_JSON_NOTE="unparseable:$DAEMON_JSON exists but is not valid JSON — leaving it alone."
    return 1
  fi
  current="$(cat "$DAEMON_JSON")"

  remap="$(printf '%s' "$current" | jq -r '."userns-remap" // empty')"
  case "$remap" in
    default) REVERTED_USERNS=1 ;;
    '')      ;;
    *)       DAEMON_JSON_NOTE="warn:$DAEMON_JSON sets userns-remap to \"$remap\", which install.sh never writes — leaving it." ;;
  esac

  # The two candidate ranges install.sh offers, each carved into a /24 (see its
  # "default address pools" section). Anything else is the operator's own
  # addressing plan, and narrowing or widening it is their call.
  if printf '%s' "$current" | jq -e '
        ."default-address-pools" as $p
        | ($p | type) == "array"
          and ($p | length) == 1
          and ($p[0].size == 24)
          and ($p[0].base == "172.16.0.0/12" or $p[0].base == "10.201.0.0/16")' >/dev/null 2>&1; then
    REVERTED_POOLS=1
  elif printf '%s' "$current" | jq -e 'has("default-address-pools")' >/dev/null 2>&1; then
    DAEMON_JSON_NOTE="${DAEMON_JSON_NOTE:+$DAEMON_JSON_NOTE
}warn:$DAEMON_JSON has default-address-pools install.sh did not write — leaving them."
  fi

  if [ "$REVERTED_USERNS" = 0 ] && [ "$REVERTED_POOLS" = 0 ]; then
    DAEMON_JSON_NOTE="${DAEMON_JSON_NOTE:+$DAEMON_JSON_NOTE
}log:Nothing in $DAEMON_JSON came from install.sh — leaving it untouched."
    return 1
  fi
  return 0
}

# render_daemon_json_note — say out loud what classify_daemon_json left alone.
# Separate from the classification so calling it twice (once for the report,
# once for the revert) does not print the same complaint twice.
render_daemon_json_note() {
  local line
  [ -n "$DAEMON_JSON_NOTE" ] || return 0
  while IFS= read -r line; do
    case "$line" in
      warn:*)        warn "${line#warn:}" ;;
      unparseable:*) warn "${line#unparseable:}"
                     echo "  Remove the userns-remap and default-address-pools keys by hand." >&2 ;;
      log:*)         log "${line#log:}" ;;
    esac
  done <<NOTE
$DAEMON_JSON_NOTE
NOTE
  return 0
}

# revert_daemon_json — drop the two keys install.sh adds to $DAEMON_JSON and
# restart the daemon. Returns 0 only if something was changed and Docker came
# back on the new config. The decision of *what* is ours to remove belongs to
# classify_daemon_json; this is the half that writes.
revert_daemon_json() {
  local current merged filter backup stamp rc

  classify_daemon_json
  rc=$?
  render_daemon_json_note
  [ "$rc" = 0 ] || return 1

  current="$(cat "$DAEMON_JSON")"

  filter='.'
  [ "$REVERTED_USERNS" = 1 ] && filter="$filter | del(.\"userns-remap\")"
  [ "$REVERTED_POOLS" = 1 ]  && filter="$filter | del(.\"default-address-pools\")"

  if ! merged="$(printf '%s' "$current" | jq "$filter" 2>/dev/null)" || [ -z "$merged" ]; then
    warn "could not compute the reverted $DAEMON_JSON — leaving it alone."
    REVERTED_USERNS=0; REVERTED_POOLS=0
    return 1
  fi

  # The file is left as {} rather than deleted when it empties out: whether it
  # predated the install is unknowable from here, and an empty object is inert.
  stamp="$(date +%Y%m%d%H%M%S)"
  backup="$DAEMON_JSON.devgrail-revert-$stamp"
  if ! cp -p "$DAEMON_JSON" "$backup"; then
    warn "could not back up $DAEMON_JSON — not changing it."
    REVERTED_USERNS=0; REVERTED_POOLS=0
    return 1
  fi
  if ! printf '%s\n' "$merged" > "$DAEMON_JSON.devgrail-new"; then
    warn "could not write $DAEMON_JSON — leaving it alone."
    rm -f "$backup" "$DAEMON_JSON.devgrail-new"
    REVERTED_USERNS=0; REVERTED_POOLS=0
    return 1
  fi
  chmod 0644 "$DAEMON_JSON.devgrail-new"
  mv -f "$DAEMON_JSON.devgrail-new" "$DAEMON_JSON"

  # No daemon to restart — the file is the whole change. This is the ordinary
  # case when --nuke follows an earlier --remove-docker, and treating it as a
  # failed restart would restore the keys and abort over nothing.
  if ! command -v docker >/dev/null 2>&1; then
    log "Docker is not installed on this host, so there is no daemon to restart."
    rm -f "$backup"
    return 0
  fi

  log "Restarting Docker on the reverted daemon config..."
  if restart_docker && wait_for_docker; then
    rm -f "$backup"
    return 0
  fi

  warn "Docker did not come back after the revert — restoring the previous $DAEMON_JSON."
  mv -f "$backup" "$DAEMON_JSON"
  REVERTED_USERNS=0; REVERTED_POOLS=0
  if restart_docker && wait_for_docker; then
    warn "Docker is back on its previous configuration; $DAEMON_JSON was NOT reverted."
    return 1
  fi
  die "Docker is not responding and restoring the previous configuration did not revive it.
  $DAEMON_JSON holds what it held before this run. Investigate with:
    journalctl -u docker --no-pager -n 50"
}

# remapped_root_reclaimable <prior_root> <current_root> — true when <prior_root>
# is the per-UID data root userns-remap was using and the daemon has since moved
# off it, leaving that whole tree unreachable.
#
# Deliberately narrow: the path must be a direct child of the daemon's current
# root, named <uid>.<gid>, and still exist. Anything else — a configured
# data-root, a bind mount, an unreadable `docker info` — is not a path to hand
# to `rm -rf` on a guess.
remapped_root_reclaimable() {
  local prior="$1" now="$2" base
  [ -n "$prior" ] && [ -n "$now" ] || return 1
  [ "$prior" != "$now" ] || return 1
  [ "$(dirname "$prior")" = "$now" ] || return 1
  base="$(basename "$prior")"
  case "$base" in
    *[!0-9.]*) return 1 ;;   # digits and dots only
    .*|*.)     return 1 ;;   # no leading or trailing dot
    *.*.*)     return 1 ;;   # exactly one dot
    *.*)       ;;
    *)         return 1 ;;   # ...and it must have one
  esac
  [ -d "$prior" ]
}

# Drop the subordinate-id ranges dockerd allocated for its remap user, and the
# user itself. Only called when userns-remap was actually reverted, so this
# never removes a dockremap the operator still needs. /etc/subuid and
# /etc/subgid themselves stay: install.sh only ever creates them empty, which is
# indistinguishable from a stock host.
remove_dockremap() {
  local f tmpf
  for f in /etc/subuid /etc/subgid; do
    [ -f "$f" ] || continue
    grep -q '^dockremap:' "$f" 2>/dev/null || continue
    tmpf="$f.devgrail-tmp"
    { grep -v '^dockremap:' "$f" || true; } > "$tmpf"
    # Written back through the original inode so ownership and mode survive.
    cat "$tmpf" > "$f"
    rm -f "$tmpf"
    log "Removed the dockremap ranges from $f"
  done
  if command -v userdel >/dev/null 2>&1 && id dockremap >/dev/null 2>&1; then
    userdel dockremap >/dev/null 2>&1 && log "Removed the dockremap user." || true
  fi
  return 0
}
# --- end host revert helpers -----------------------------------------------

# compose_project_containers — ids of containers belonging to *this* install's
# Compose project. The project label alone is not enough (it is just the deploy
# directory's basename, which another stack could share), so each candidate is
# confirmed against the config-file path Compose records on it.
compose_project_containers() {
  local id conf
  for id in $({ docker ps -aq --filter "label=com.docker.compose.project=$PROJECT" 2>/dev/null || true; }); do
    conf="$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project.config_files"}}' "$id" 2>/dev/null || true)"
    case "$conf" in
      "$DEPLOY_DIR"/*) printf '%s\n' "$id" ;;
    esac
  done
}

# compose_project_object <volume|network> <compose name> — the project's copy of
# a volume or network declared in docker-compose.yml. Volumes and networks carry
# no config_files label, so identity comes from the project label plus Compose's
# own name label; an unrelated stack would have to match both exactly.
compose_project_object() {
  local kind="$1" name="$2"
  docker "$kind" ls -q \
    --filter "label=com.docker.compose.project=$PROJECT" \
    --filter "label=com.docker.compose.$kind=$name" 2>/dev/null || true
}

# image_refs <reference> — every repo:tag currently pointing into <reference>.
# install.sh leaves three per image (:latest, the displaced build as :previous,
# and the release tag `docker load` unpacked), so removing :latest by name used
# to leave two behind.
#
# Every stage is guarded: on a host where the images are already gone, `grep -v`
# matches nothing and exits 1, which under `set -o pipefail` would surface as a
# failed assignment and kill the script mid-uninstall.
image_refs() {
  { docker images --filter "reference=$1" --format '{{.Repository}}:{{.Tag}}' 2>/dev/null || true; } \
    | { grep -v ':<none>$' || true; } | sort -u
}

remove_image_refs() {
  local ref refs
  for ref in "$@"; do
    refs="$(image_refs "$ref")"
    [ -n "$refs" ] || continue
    # shellcheck disable=SC2086 # deliberate word splitting over the ref list
    docker rmi $refs >/dev/null 2>&1 || true
  done
}

# --- inventory helpers -----------------------------------------------------
# Extracted by test/installer/uninstall_inventory_test.sh between this marker
# and the matching "end inventory helpers" one. Keep both comments intact, and
# keep everything between them free of top-level side effects so the tests can
# source it on a host with no Docker and no root.
#
# Nothing in here writes, deletes or restarts anything. That is load-bearing
# twice over: the report is printed before the first question is asked, so the
# answer to that question is an informed one, and the same code is the whole of
# what --dry-run does.

# How many names to print per category before summarising the remainder. A host
# with sixty workspaces should not bury the verdict under sixty lines.
INVENTORY_LIST_CAP=20

# Counts the report gathers and the confirmation prompts below quote back, so
# "delete ALL DevGrail data" can name what "all" actually is on this host.
INV_WS_N=0
INV_WS_VOL_N=0
INV_BACKUP_N=0
INV_OTHER_C_N=0
INV_OTHER_V_N=0

# inv_count <lines> — how many non-empty lines. `grep -c .` rather than
# `wc -l`: it counts a final line that has no trailing newline and skips blank
# ones, which is exactly the shape `docker ... -q` hands back. wc -l reports 0
# for a one-line answer that ended without a newline.
inv_count() { printf '%s\n' "${1:-}" | grep -c '.' || true; }

# inv_container_name <id> — the name Docker knows a container by, without the
# leading slash, falling back to the id when inspect cannot answer. A report
# that aborts because one container is being removed underneath it is worse
# than one that prints a short id.
inv_container_name() {
  local n
  n="$(docker inspect -f '{{.Name}}' "$1" 2>/dev/null || true)"
  n="${n#/}"
  [ -n "$n" ] || n="$1"
  printf '%s' "$n"
}

# inv_verdict <destroy?> — the word this run has earned for a category.
inv_verdict() { if [ "$1" = 1 ]; then printf 'DESTROY'; else printf 'keep'; fi; }

# inv_head <label> <count> <verdict> [note] — one category summary line.
inv_head() {
  printf '  %-22s %4s  %s%s\n' "$1" "$2" "$3" "${4:+ — $4}"
}

# inv_names <lines> — the names under a category, capped.
inv_names() {
  local names="${1:-}" n shown=0 total
  [ -n "$names" ] || return 0
  total="$(inv_count "$names")"
  while IFS= read -r n; do
    [ -n "$n" ] || continue
    if [ "$shown" -ge "$INVENTORY_LIST_CAP" ]; then
      printf '        ... and %s more\n' "$((total - shown))"
      return 0
    fi
    printf '        %s\n' "$n"
    shown=$((shown + 1))
  done <<NAMES
$names
NAMES
  return 0
}

# inventory_report — print what this invocation would destroy and what it would
# keep, then return. Reads the flags as they stand: the prompts below can still
# escalate --purge, which is why the closing line says so and why those prompts
# quote the INV_* counts this leaves behind.
inventory_report() {
  local ws_c ws_disp ws_note ws_v ws_net_n stack stack_n dvols imgs ws_imgs
  local files backups_dir
  local id name state kept=0 anything=0 rel

  ws_c=""; ws_disp=""; ws_v=""; ws_net_n=0; stack=""; dvols=""; imgs=""
  ws_imgs=""; files=""

  if [ "$HAVE_DOCKER" = 1 ]; then
    ws_c="$(docker ps -a --filter label=devgrail.managed --format '{{.Names}}|{{.State}}' 2>/dev/null || true)"
    while IFS='|' read -r name state; do
      [ -n "$name" ] || continue
      ws_disp="${ws_disp:+$ws_disp
}$(printf '%-28s %s' "$name" "${state:-unknown}")"
    done <<WSC
$ws_c
WSC
    ws_v="$(docker volume ls --filter label=devgrail.managed --format '{{.Name}}' 2>/dev/null || true)"
    ws_net_n="$(inv_count "$(docker network ls -q --filter label=devgrail.managed 2>/dev/null || true)")"

    for id in $(compose_project_containers); do
      stack="${stack:+$stack
}$(inv_container_name "$id")"
    done
    for name in devgrail-data traefik-acme; do
      id="$(compose_project_object volume "$name")"
      [ -n "$id" ] || continue
      case "$name" in
        devgrail-data) rel="the database" ;;
        *)             rel="TLS certificates" ;;
      esac
      dvols="${dvols:+$dvols
}$(printf '%-28s %s' "$id" "$rel")"
    done

    # The workspace image goes with the workspaces and not before: a container
    # that survives this run pins the image it was built from, so `docker rmi`
    # would refuse it anyway — and a resumed install.sh wants to find it loaded.
    imgs="$(image_refs devgrail-server)"
    if [ "$PURGE" = 1 ]; then
      imgs="$imgs
$(image_refs devgrail-container)"
    else
      ws_imgs="$(image_refs devgrail-container)"
    fi
    if [ "$NUKE" = 1 ]; then
      imgs="$imgs
$(image_refs traefik; image_refs tecnativa/docker-socket-proxy)"
    fi
    imgs="$(printf '%s\n' "$imgs" | { grep -v '^$' || true; })"
  fi

  for name in "$INSTALL_DIR" "$CONFIG_DIR" /usr/local/bin/devgrail \
              /etc/systemd/system/devgrail-backup.timer \
              /etc/systemd/system/devgrail-backup.service; do
    [ -e "$name" ] || continue
    files="${files:+$files
}$name"
  done

  backups_dir="$INSTALL_DIR/backups"
  INV_BACKUP_N=0
  [ -d "$backups_dir" ] && INV_BACKUP_N="$(inv_count "$(ls -1 "$backups_dir" 2>/dev/null || true)")"

  INV_WS_N="$(inv_count "$ws_c")"
  INV_WS_VOL_N="$(inv_count "$ws_v")"

  echo
  echo "============================================================"
  if [ "$DRY_RUN" = 1 ]; then
    echo "  Dry run — nothing on this host has been changed."
  else
    echo "  Uninstalling the DevGrail install at $INSTALL_DIR"
  fi
  echo
  if [ "$REMOVE_DOCKER" = 1 ]; then
    echo "  Mode: full wipe, host changes reverted, Docker Engine removed"
  elif [ "$NUKE" = 1 ]; then
    echo "  Mode: full wipe, and install.sh's host changes reverted"
  elif [ "$PURGE" = 1 ]; then
    echo "  Mode: full wipe — workspaces, data, certs and config all go"
  else
    echo "  Mode: preserve workspaces + data (add --purge to wipe them too)"
  fi
  echo

  if [ "$HAVE_DOCKER" = 0 ]; then
    echo "  Docker is not installed on this host, so there are no containers,"
    echo "  volumes, networks or images to account for."
    echo
  else
    if [ "${INV_WS_N:-0}" -gt 0 ]; then
      anything=1
      ws_note=""
      [ "$PURGE" = 1 ] || { kept=1; ws_note="stopped, left in place"; }
      inv_head "Workspaces" "$INV_WS_N" "$(inv_verdict "$PURGE")" "$ws_note"
      inv_names "$ws_disp"
    fi
    if [ "${ws_net_n:-0}" -gt 0 ]; then
      anything=1
      inv_head "Workspace networks" "$ws_net_n" "$(inv_verdict 1)" "recreated on next start"
    fi
    if [ "${INV_WS_VOL_N:-0}" -gt 0 ]; then
      anything=1
      [ "$PURGE" = 1 ] || kept=1
      inv_head "Workspace volumes" "$INV_WS_VOL_N" "$(inv_verdict "$PURGE")" "your source code lives here"
      inv_names "$ws_v"
    fi
    stack_n="$(inv_count "$stack")"
    if [ "${stack_n:-0}" -gt 0 ]; then
      anything=1
      inv_head "Stack containers" "$stack_n" "$(inv_verdict 1)"
      inv_names "$stack"
    fi
    if [ -n "$dvols" ]; then
      anything=1
      [ "$PURGE" = 1 ] || kept=1
      inv_head "Data volumes" "$(inv_count "$dvols")" "$(inv_verdict "$PURGE")"
      inv_names "$dvols"
    fi
    if docker network inspect devgrail >/dev/null 2>&1; then
      anything=1
      inv_head "Edge network" "1" "$(inv_verdict 1)" "devgrail"
    fi
    if [ -n "$imgs" ]; then
      anything=1
      inv_head "Images" "$(inv_count "$imgs")" "$(inv_verdict 1)"
      inv_names "$imgs"
    fi
    if [ -n "$ws_imgs" ]; then
      anything=1
      kept=1
      inv_head "Workspace image" "$(inv_count "$ws_imgs")" "$(inv_verdict 0)" "the workspaces above run on it"
      inv_names "$ws_imgs"
    fi
  fi

  if [ -n "$files" ]; then
    anything=1
    [ "$PURGE" = 1 ] || kept=1
    inv_head "Files" "$(inv_count "$files")" "$(inv_verdict "$PURGE")"
    inv_names "$files"
  fi
  if [ "${INV_BACKUP_N:-0}" -gt 0 ]; then
    anything=1
    [ "$PURGE" = 1 ] || kept=1
    inv_head "Backup archives" "$INV_BACKUP_N" "$(inv_verdict "$PURGE")" "in $backups_dir"
  fi

  if [ "$anything" = 0 ]; then
    echo "  Nothing of DevGrail's was found on this host."
    echo "  It is either already uninstalled, or was installed somewhere other"
    echo "  than $INSTALL_DIR (set DEVGRAIL_INSTALL_DIR to point at it)."
  fi

  # The host-level sections, each one wider than DevGrail's own namespace.
  if [ "$NUKE" = 1 ]; then
    echo
    echo "  Host changes install.sh made"
    if ! command -v jq >/dev/null 2>&1; then
      echo "        jq is not installed, so $DAEMON_JSON cannot be read or edited"
    elif classify_daemon_json; then
      [ "$REVERTED_USERNS" = 1 ] && echo "        $DAEMON_JSON: userns-remap would be removed"
      [ "$REVERTED_POOLS" = 1 ]  && echo "        $DAEMON_JSON: default-address-pools would be removed"
      # One key can be ours while the other has been changed since. Say which,
      # rather than let the revert be the first mention of it.
      [ -n "$DAEMON_JSON_NOTE" ] && printf '%s\n' "$DAEMON_JSON_NOTE" | sed 's/^[a-z]*:/        /'
      echo "        the Docker daemon would be restarted"
      if [ "$REVERTED_USERNS" = 1 ]; then
        echo "        the dockremap subordinate id ranges would be dropped"
        id dockremap >/dev/null 2>&1 && echo "        the dockremap user would be deleted"
        name="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
        [ -n "$name" ] && echo "        the remapped data root under $name would be reclaimed"
      fi
    else
      # classify_daemon_json declined. Its reason is the useful part; strip the
      # severity tag the renderer would otherwise consume.
      printf '%s\n' "$DAEMON_JSON_NOTE" | sed 's/^[a-z]*:/        /'
    fi
  fi

  if [ "$REMOVE_DOCKER" = 1 ] && [ "$HAVE_DOCKER" = 1 ]; then
    INV_OTHER_C_N="$(inv_count "$(docker ps -aq 2>/dev/null || true)")"
    INV_OTHER_V_N="$(inv_count "$(docker volume ls -q 2>/dev/null || true)")"
    echo
    echo "  Docker Engine itself"
    echo "        EVERY container on this host goes, not only DevGrail's:"
    printf '        %s container(s) and %s volume(s) exist in total\n' \
      "$INV_OTHER_C_N" "$INV_OTHER_V_N"
    echo "        /var/lib/docker, /var/lib/containerd and /etc/docker are removed"
  fi

  echo
  if [ "$DRY_RUN" = 1 ]; then
    echo "  Re-run without --dry-run to apply this."
  elif [ "$kept" = 1 ]; then
    echo "  The items marked 'keep' survive this run. --purge destroys them too,"
    echo "  and you are asked about that next."
  else
    echo "  Nothing above is touched until you confirm."
  fi
  echo "============================================================"
  echo
  return 0
}
# --- end inventory helpers -------------------------------------------------

# --- report, then confirm --------------------------------------------------
# The report comes first so the questions below are answered by someone who has
# seen the list. It is also the whole of --dry-run, which exits here having read
# the host and written nothing.
#
# Note what this ordering buys: --dry-run returns *before* the no-terminal guard
# at the bottom of this block, so `curl ... | sudo bash -s -- --purge --dry-run`
# reports instead of refusing. That is by construction, not by an exemption —
# keep the exit above the guard.
inventory_report

if [ "$DRY_RUN" = 1 ]; then
  exit 0
fi

# --- confirm + decide whether to wipe data ---------------------------------
if [ "$ASSUME_YES" = 1 ]; then
  : # honour --purge/flags as given, no prompts
elif have_tty; then
  if [ "$PURGE" = 1 ]; then
    warn "PURGE mode: this permanently deletes the DevGrail database, TLS certs,"
    warn "$INV_WS_N workspace container(s), $INV_WS_VOL_N workspace volume(s), and config in"
    warn "$INSTALL_DIR and $CONFIG_DIR."
    [ "${INV_BACKUP_N:-0}" -gt 0 ] &&       warn "$INV_BACKUP_N existing backup archive(s) in $INSTALL_DIR/backups go with it."
    ask "Permanently delete everything? [y/N]" || die "aborted"
  else
    # Preserve is the default; offer the wipe rather than requiring the flag.
    # The counts come from the report above, so "ALL" is a number here rather
    # than a word the operator has to take on trust.
    if ask "Also delete ALL DevGrail data — $INV_WS_N workspace(s) and their $INV_WS_VOL_N volume(s), the database, certs, config? [y/N]"; then
      PURGE=1
    fi
  fi

  # Offered separately, because it is a different blast radius: everything above
  # is DevGrail's own, everything here is the host's.
  if [ "$PURGE" = 1 ] && [ "$NUKE" = 0 ]; then
    echo >&2
    echo "  install.sh also changed this host outside DevGrail's own namespace:" >&2
    echo "  the userns-remap / default-address-pools keys in $DAEMON_JSON," >&2
    echo "  Docker's data root, and the dockremap user. Reverting those restarts" >&2
    echo "  the Docker daemon, and is what makes a later install.sh run behave as" >&2
    echo "  if this VPS were new." >&2
    echo >&2
    if ask "Also revert those host-level changes? [y/N]"; then NUKE=1; fi
  fi

  if [ "$NUKE" = 1 ] && [ "$REMOVE_DOCKER" = 0 ]; then
    echo >&2
    warn "The next question is not limited to DevGrail."
    echo "  Uninstalling Docker Engine stops and deletes EVERY container, image" >&2
    echo "  and volume on this host, including anything unrelated to DevGrail," >&2
    echo "  and removes /var/lib/docker. Say no if this VPS runs anything else." >&2
    # Counted here rather than read from the report: --remove-docker was still
    # off when that ran, so this is the first point at which the number means
    # anything. It is the whole host, deliberately unfiltered by label.
    if [ "$HAVE_DOCKER" = 1 ]; then
      INV_OTHER_C_N="$(inv_count "$(docker ps -aq 2>/dev/null || true)")"
      INV_OTHER_V_N="$(inv_count "$(docker volume ls -q 2>/dev/null || true)")"
      echo "  This host has $INV_OTHER_C_N container(s) and $INV_OTHER_V_N volume(s) in total." >&2
    fi
    echo >&2
    if ask "Also uninstall Docker Engine itself? [y/N]"; then REMOVE_DOCKER=1; fi
  fi
else
  # No terminal: never prompt. Preserve is safe to run unattended; a wipe is not,
  # so --purge without a terminal must be confirmed with --yes. --nuke and
  # --remove-docker imply --purge, so this covers them too.
  [ "$PURGE" = 1 ] && die "refusing to --purge without a terminal — pass --yes to confirm"
fi

if [ "$REMOVE_DOCKER" = 1 ]; then
  log "Uninstalling DevGrail — FULL WIPE, host changes reverted, Docker removed."
elif [ "$NUKE" = 1 ]; then
  log "Uninstalling DevGrail — FULL WIPE, and reverting install.sh's host changes."
elif [ "$PURGE" = 1 ]; then
  log "Uninstalling DevGrail — FULL WIPE (data, certs, and config will be removed)."
else
  log "Uninstalling DevGrail (workspaces stopped; they, the data volumes and the config are preserved; use --purge to wipe)."
fi

[ "$HAVE_DOCKER" = 0 ] && warn "docker is not installed — skipping every container, volume and image step."

# --- 0. pre-wipe backup (purge only) ---------------------------------------
# Taken before anything is torn down, while the server is still up and the
# maintenance CLI can still resolve the data volume. A failure here aborts:
# proceeding would destroy the data this backup exists to preserve, and an
# operator who genuinely wants that has --no-backup to say so.
if [ "$PURGE" = 1 ] && [ "$NO_BACKUP" = 0 ] && [ "$HAVE_DOCKER" = 1 ]; then
  if [ -x /usr/local/bin/devgrail ]; then
    log "Taking a final backup before wiping..."
    /usr/local/bin/devgrail backup || die "the pre-wipe backup failed — refusing to destroy data that has not been backed up.
  Fix the cause and re-run, or pass --no-backup to wipe anyway."
    log "Backup kept in $INSTALL_DIR/backups — it survives this uninstall only if you copy it off this host first."
  else
    warn "no /usr/local/bin/devgrail on this host, so no pre-wipe backup can be taken."
    warn "This install predates the maintenance CLI. Re-run install.sh to get it, or"
    warn "pass --no-backup to confirm you are destroying the data unrecoverably."
    [ "$ASSUME_YES" = 1 ] || die "aborted (pass --no-backup to wipe without a backup)"
  fi
fi

# The daemon's data root, read while userns-remap is still in effect. Under
# remap it is a per-UID subdirectory; once the revert in step 5 moves the daemon
# off it, that tree is unreachable disk with nothing left to read it.
PRIOR_DOCKER_ROOT=""
if [ "$NUKE" = 1 ] && [ "$HAVE_DOCKER" = 1 ]; then
  PRIOR_DOCKER_ROOT="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
fi

if [ "$HAVE_DOCKER" = 1 ]; then

# --- 1. tear down the Compose stack ---------------------------------------
# Infra containers (traefik, devgrail-server, docker-socket-proxy, and the exited
# devgrail-data-init one-shot), the managed devgrail-control/devgrail-docker
# networks, and — with --purge — the named volumes (devgrail-data, traefik-acme).
if [ -f "$COMPOSE_FILE" ] && docker compose version >/dev/null 2>&1; then
  log "Stopping the DevGrail Compose stack..."
  if [ "$PURGE" = 1 ]; then
    compose down -v --remove-orphans || warn "compose down reported an error — continuing"
  else
    compose down --remove-orphans || warn "compose down reported an error — continuing"
  fi
else
  warn "no compose file at $COMPOSE_FILE — removing the stack's objects directly"
fi

# Belt and braces, and the only path that works at all once $COMPOSE_FILE has
# been deleted by an earlier --purge: sweep whatever the stack still owns.
# Traefik is why this cannot be a list of names — it declares no container_name,
# so it is "<project>-traefik-1", and the old name-based fallback never touched
# it (nor the project's volumes and networks).
orphans="$(compose_project_containers)"
if [ -n "$orphans" ]; then
  log "Removing leftover Compose containers..."
  # shellcheck disable=SC2086 # deliberate word splitting over the id list
  docker rm -f $orphans >/dev/null 2>&1 || true
fi
for net in devgrail-control devgrail-docker; do
  ids="$(compose_project_object network "$net")"
  # shellcheck disable=SC2086
  [ -n "$ids" ] && docker network rm $ids >/dev/null 2>&1 || true
done
if [ "$PURGE" = 1 ]; then
  for vol in devgrail-data traefik-acme; do
    ids="$(compose_project_object volume "$vol")"
    # shellcheck disable=SC2086
    [ -n "$ids" ] && docker volume rm $ids >/dev/null 2>&1 || true
  done
fi

# --- 2. workspace containers, networks + volumes (labeled, not Compose-owned) --
# Only --purge removes the containers. A workspace's filesystem is not all in its
# volume — installed packages, shell history, whatever the agent set up outside
# /home live in the container's writable layer — so a default run stops them and
# leaves them in place, and a re-run of install.sh finds the same workspaces
# rather than empty replacements. Reconcile on server startup reads their real
# state back out of Docker, so the dashboard shows them stopped, not missing.
ws_containers="$(docker ps -aq --filter label=devgrail.managed 2>/dev/null || true)"
if [ "$PURGE" = 1 ]; then
  log "Removing workspace containers..."
  # shellcheck disable=SC2086
  [ -n "$ws_containers" ] && docker rm -f $ws_containers >/dev/null 2>&1 || true
elif [ -n "$ws_containers" ]; then
  log "Stopping $(inv_count "$ws_containers") workspace container(s), keeping them (use --purge to remove)..."
  # One call for the whole labeled set: `docker stop` on an already-stopped
  # container is a no-op, so the exited ones cost nothing here.
  # shellcheck disable=SC2086
  docker stop $ws_containers >/dev/null 2>&1 ||
    warn "not every workspace container stopped cleanly — they were left in place regardless"
fi

# Removed in both modes, even though the containers now survive a default run.
# A per-workspace network holds no state — the server recreates it, and
# re-attaches Traefik, the next time the workspace starts, which is exactly the
# path a resumed install takes — but one left behind holds an address-pool slot,
# and is enough to make install.sh see this as a host with Docker state.
ws_networks="$(docker network ls -q --filter label=devgrail.managed 2>/dev/null || true)"
if [ -n "$ws_networks" ]; then
  log "Removing workspace networks..."
  # shellcheck disable=SC2086
  docker network rm $ws_networks >/dev/null 2>&1 || true
fi

if [ "$PURGE" = 1 ]; then
  log "Removing workspace volumes..."
  ws_volumes="$(docker volume ls -q --filter label=devgrail.managed 2>/dev/null || true)"
  # shellcheck disable=SC2086
  [ -n "$ws_volumes" ] && docker volume rm $ws_volumes >/dev/null 2>&1 || true
else
  ws_volumes="$(docker volume ls -q --filter label=devgrail.managed 2>/dev/null || true)"
  [ -n "$ws_volumes" ] && log "Preserved $(printf '%s\n' "$ws_volumes" | grep -c .) workspace volume(s) (use --purge to remove)."
fi

# --- 3. external edge network ----------------------------------------------
# Created out-of-band by install.sh (external: true in the compose), so `down`
# never removes it. Only removable once all attached containers are gone.
if docker network inspect devgrail >/dev/null 2>&1; then
  log "Removing the devgrail edge network..."
  docker network rm devgrail >/dev/null 2>&1 || \
    warn "could not remove the 'devgrail' network (still in use?) — remove manually once containers are gone"
fi

# --- 4. images -------------------------------------------------------------
# Every tag, not only :latest — see image_refs.
log "Removing DevGrail images..."
remove_image_refs devgrail-server
# The workspace image only goes once the workspaces built on it have. In a
# default run `docker rmi` would refuse it anyway ("image is being used by
# stopped container"), and keeping it means a resumed install.sh already has the
# bytes instead of downloading ~750 MiB again.
if [ "$PURGE" = 1 ]; then
  remove_image_refs devgrail-container
elif [ -n "$(image_refs devgrail-container)" ]; then
  log "Kept the devgrail-container image — the preserved workspaces run on it."
fi
# Third-party support images pulled by the stack. Left in place unless purging,
# since they're shared/base images that may be reused.
if [ "$NUKE" = 1 ]; then
  # --nuke returns the host to its pre-install state, so superseded pins from
  # earlier releases go too: one image left behind anywhere is enough to make
  # install.sh refuse to offer userns-remap on the next run.
  remove_image_refs traefik tecnativa/docker-socket-proxy
  docker image prune -f >/dev/null 2>&1 || true
elif [ "$PURGE" = 1 ]; then
  docker rmi tecnativa/docker-socket-proxy:v0.4.2 traefik:v3.7.6 >/dev/null 2>&1 || true
fi

fi  # HAVE_DOCKER

# --- 5. host-level changes (nuke only) -------------------------------------
# After every Docker object is gone, and before the on-disk config: under
# userns-remap the daemon's data root is a per-UID subdirectory, so reverting
# first would hide DevGrail's own volumes and images behind a root nothing can
# reach, leaving them undeletable.
#
# Run even when Docker is about to be removed outright. It costs one restart,
# and it means a host left half-done by a failed package removal is still
# coherent rather than pointing at a data root that no longer exists.
if [ "$NUKE" = 1 ]; then
  log "Reverting install.sh's changes to $DAEMON_JSON..."
  if ! command -v jq >/dev/null 2>&1; then
    warn "jq is not installed, so $DAEMON_JSON cannot be edited safely — skipping."
    echo "  Remove these keys by hand and restart Docker:" >&2
    echo "    \"userns-remap\": \"default\"" >&2
    echo "    \"default-address-pools\": [...]" >&2
  elif revert_daemon_json; then
    if [ "$REVERTED_USERNS" = 1 ]; then
      now_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
      if remapped_root_reclaimable "$PRIOR_DOCKER_ROOT" "$now_root"; then
        log "Reclaiming the orphaned remapped data root $PRIOR_DOCKER_ROOT..."
        rm -rf "$PRIOR_DOCKER_ROOT"
      elif [ -d "$PRIOR_DOCKER_ROOT" ] && [ "$PRIOR_DOCKER_ROOT" != "$now_root" ]; then
        warn "Docker's previous data root was $PRIOR_DOCKER_ROOT, which does not look"
        warn "like a userns-remap root — leaving it. Remove it by hand if it is stale."
      fi
      remove_dockremap
    fi
    log "$DAEMON_JSON reverted."
  fi
  # The installer's own backups of the file, unambiguously ours.
  rm -f "$DAEMON_JSON".devgrail-bak-* 2>/dev/null || true
fi

# --- 6. on-disk config (purge only) ----------------------------------------
if [ "$PURGE" = 1 ]; then
  log "Removing config and deploy files..."
  # $INSTALL_DIR holds the backups directory, including the one just taken —
  # say where it went before it goes, rather than silently deleting the only
  # copy of the data seconds after making it.
  if [ "$NO_BACKUP" = 0 ] && [ -d "$INSTALL_DIR/backups" ]; then
    warn "Deleting $INSTALL_DIR/backups along with the rest of $INSTALL_DIR."
    warn "If you want the pre-wipe backup, Ctrl-C now and copy it off this host."
  fi
  rm -rf "$INSTALL_DIR" "$CONFIG_DIR"
  rm -f /usr/local/bin/devgrail
  if command -v systemctl >/dev/null 2>&1; then
    systemctl disable --now devgrail-backup.timer >/dev/null 2>&1 || true
    rm -f /etc/systemd/system/devgrail-backup.timer /etc/systemd/system/devgrail-backup.service
    systemctl daemon-reload >/dev/null 2>&1 || true
  fi
fi

# --- 7. Docker Engine (--remove-docker only) -------------------------------
# Last, because everything above needs a working daemon. Best-effort throughout:
# by this point DevGrail is already gone, and a failure here should report what
# is left rather than abort an uninstall that has already succeeded.
if [ "$REMOVE_DOCKER" = 1 ] && [ "$HAVE_DOCKER" = 1 ]; then
  log "Uninstalling Docker Engine..."
  if command -v systemctl >/dev/null 2>&1; then
    systemctl disable --now docker.socket >/dev/null 2>&1 || true
    systemctl disable --now docker >/dev/null 2>&1 || true
    systemctl disable --now containerd >/dev/null 2>&1 || true
  elif command -v rc-service >/dev/null 2>&1; then
    rc-service docker stop >/dev/null 2>&1 || true
  elif command -v service >/dev/null 2>&1; then
    service docker stop >/dev/null 2>&1 || true
  fi

  # The package set get.docker.com installs. Missing ones are not an error — a
  # host that got Docker some other way simply has fewer of them.
  DOCKER_PKGS="docker-ce docker-ce-cli docker-ce-rootless-extras containerd.io docker-buildx-plugin docker-compose-plugin"
  # shellcheck disable=SC2086 # deliberate word splitting over the package list
  if command -v apt-get >/dev/null 2>&1; then
    DEBIAN_FRONTEND=noninteractive apt-get purge -y $DOCKER_PKGS >/dev/null 2>&1 || true
    DEBIAN_FRONTEND=noninteractive apt-get autoremove -y >/dev/null 2>&1 || true
    rm -f /etc/apt/sources.list.d/docker.list /etc/apt/keyrings/docker.asc /etc/apt/keyrings/docker.gpg
  elif command -v dnf >/dev/null 2>&1; then
    dnf remove -y $DOCKER_PKGS >/dev/null 2>&1 || true
    rm -f /etc/yum.repos.d/docker-ce.repo
  elif command -v yum >/dev/null 2>&1; then
    yum remove -y $DOCKER_PKGS >/dev/null 2>&1 || true
    rm -f /etc/yum.repos.d/docker-ce.repo
  elif command -v apk >/dev/null 2>&1; then
    apk del docker docker-cli docker-engine docker-cli-compose containerd >/dev/null 2>&1 || true
  else
    warn "no known package manager — Docker's packages were not removed."
    echo "  Remove them by hand; the directories below are cleared regardless." >&2
  fi

  rm -rf /var/lib/docker /var/lib/containerd /etc/docker
  command -v groupdel >/dev/null 2>&1 && groupdel docker >/dev/null 2>&1 || true
  if command -v docker >/dev/null 2>&1; then
    warn "a 'docker' binary is still on PATH at $(command -v docker) — remove it by hand."
  else
    HAVE_DOCKER=0
    log "Docker Engine removed."
  fi
fi

cat <<EOF

============================================================
  DevGrail has been uninstalled.
EOF
if [ "$NUKE" = 1 ]; then
  cat <<EOF

  Full wipe complete — data volumes, certs, images and config
  removed, and install.sh's host changes reverted. A re-run of
  install.sh will install from scratch, as on a new VPS.
EOF
elif [ "$PURGE" = 1 ]; then
  cat <<EOF

  Full wipe complete — data volumes, certs, and config removed.
EOF
else
  cat <<EOF

  Nothing of yours was deleted: the workspace containers were
  stopped and left in place, along with the image they run on,
  their volumes, the devgrail-data + traefik-acme volumes and
  the config in $INSTALL_DIR / $CONFIG_DIR. Re-run install.sh to
  resume with the same workspaces, or re-run this with --purge
  to delete everything.
EOF
fi
if [ "$REMOVE_DOCKER" = 1 ]; then
  cat <<EOF

  Docker Engine was uninstalled. jq and openssl were left
  installed — openssl in particular is depended on by much of
  the system, so this script never removes it.
============================================================
EOF
elif [ "$NUKE" = 1 ]; then
  cat <<EOF

  Docker, jq and openssl were left installed. Pass
  --remove-docker to uninstall Docker Engine as well.
============================================================
EOF
else
  cat <<EOF

  Docker and jq were left installed. userns-remap in
  $DAEMON_JSON (if you enabled it) was left unchanged;
  pass --nuke to revert it.
============================================================
EOF
fi
