#!/usr/bin/env bash
# YacareOS - lo que la capa de configuración no puede dejar en un archivo.
#
# Casi toda la configuración de YacareOS son archivos y los instala el paquete
# yacareos-configuracion. Acá queda lo que no se puede escribir de antemano:
#
#   - dar de alta repositorios de terceros, que es bajar una llave, mirarle la
#     huella y recién entonces confiar en ella;
#   - generar el locale y fijar el teclado y la zona horaria, que en Debian
#     son de debconf y tienen su propio mecanismo;
#   - el cortafuegos, que se configura con órdenes y no con un archivo;
#   - lo que depende de la máquina que haya delante;
#   - y aplicar el tema sobre una sesión que ya está abierta, que es el único
#     caso en que /etc/xdg no alcanza.
#
# Se corre una vez, con sudo, y se puede volver a correr: ningún paso duplica
# nada ni deshace lo que ya esté hecho.
#
# Uso:
#   yacareos-configurar                aplica todos los pasos
#   yacareos-configurar --lista        muestra los pasos y sale
#   yacareos-configurar --solo idioma  aplica solo ese paso
#   yacareos-configurar --salvo aplicaciones
#
# Variables de entorno:
#   YACARE_EN_CHROOT=1   fuerza el modo chroot (lo usa el armado de la ISO)

set -Eeuo pipefail

PASOS=(
  "idioma:Español de Argentina, teclado y zona horaria"
  "aplicaciones:Firefox, Visual Studio Code y Discord"
  "cortafuegos:ufw, con lo entrante denegado"
  "equipo:Ajustes que dependen de la máquina"
  "sesion:El tema, sobre la sesión que ya está abierta"
)

# ---------------------------------------------------------------------------
# Salida
# ---------------------------------------------------------------------------
if [[ -t 1 ]]; then
  _TIT=$'\033[1;36m'; _OK=$'\033[0;32m'; _AV=$'\033[0;33m'
  _ER=$'\033[0;31m'; _TEN=$'\033[0;90m'; _OFF=$'\033[0m'
else
  _TIT=''; _OK=''; _AV=''; _ER=''; _TEN=''; _OFF=''
fi

titulo()  { printf '\n%s> %s%s\n' "$_TIT" "$*" "$_OFF"; }
info()    { printf '  %s\n' "$*"; }
ok()      { printf '  %sOK%s %s\n' "$_OK" "$_OFF" "$*"; }
aviso()   { printf '  %s!%s %s\n' "$_AV" "$_OFF" "$*" >&2; }
detalle() { printf '  %s%s%s\n' "$_TEN" "$*" "$_OFF"; }
morir()   { printf '  %sERROR%s %s\n' "$_ER" "$_OFF" "$*" >&2; exit 1; }

# ---------------------------------------------------------------------------
# Contexto
# ---------------------------------------------------------------------------

# Dentro del chroot que arma la ISO no hay systemd corriendo, no hay usuario de
# escritorio y no hay bus de sesión: varios pasos hacen otra cosa o no hacen
# nada.
en_chroot() {
  [[ "${YACARE_EN_CHROOT:-0}" == "1" ]] && return 0
  [[ ! -d /run/systemd/system ]] && return 0
  return 1
}

# El usuario real detrás del sudo. Sin esto, todo lo que se escriba en su
# carpeta personal termina en la de root.
usuario_real() {
  if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then
    printf '%s' "${SUDO_USER}"
  else
    printf '%s' "${USER:-root}"
  fi
}

# Corre algo como el usuario de escritorio con un bus de sesión utilizable, que
# es lo que necesitan kwriteconfig6 y qdbus6 para hablarle a la sesión viva.
# Devuelve 1, sin matar el guion, si no hay sesión gráfica.
como_usuario() {
  local u uid
  u=$(usuario_real)
  [[ "$u" == "root" ]] && return 1
  uid=$(id -u "$u" 2>/dev/null) || return 1
  [[ -S "/run/user/${uid}/bus" ]] || return 1
  sudo -u "$u" env "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${uid}/bus" \
                   "XDG_RUNTIME_DIR=/run/user/${uid}" "$@"
}

ram_mb() { awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo; }
equipo_modesto() { [[ $(ram_mb) -lt 6000 ]]; }

# ¿El disco de la raíz es rotacional? Decide si conviene indexar.
raiz_es_hdd() {
  local dev base rot
  dev=$(findmnt -no SOURCE / 2>/dev/null) || return 1
  base=$(lsblk -no PKNAME "$dev" 2>/dev/null | head -1)
  [[ -z "$base" ]] && return 1
  rot=$(cat "/sys/block/${base}/queue/rotational" 2>/dev/null) || return 1
  [[ "$rot" == "1" ]]
}

# Escribe un archivo solo si el contenido cambió. Devolver "sin cambios" no es
# cosmético: hay pasos que recargan un servicio únicamente si tocaron algo.
escribir_archivo() {
  local destino="$1" modo="${2:-0644}" tmp
  tmp=$(mktemp)
  cat > "$tmp"
  mkdir -p "$(dirname "$destino")"
  if [[ -f "$destino" ]] && cmp -s "$tmp" "$destino"; then
    rm -f "$tmp"; detalle "sin cambios: $destino"; return 1
  fi
  install -m "$modo" "$tmp" "$destino"
  rm -f "$tmp"
  ok "escrito: $destino"
  return 0
}

# ---------------------------------------------------------------------------
# apt
# ---------------------------------------------------------------------------
export DEBIAN_FRONTEND=noninteractive

# -q con Dpkg::Use-Pty=0 por el volumen: sin pty, dpkg escribe una línea por
# paquete en vez de decenas de miles con retornos de carro, que es lo que hace
# que una corrida larga parezca colgada. Las dos --force-conf* para que un
# conflicto de configuración no se pare a preguntar: esto corre con la entrada
# cerrada y esa pregunta no la puede contestar nadie.
APT_OPC=(-q -o Dpkg::Use-Pty=0
         -o Dpkg::Options::=--force-confdef
         -o Dpkg::Options::=--force-confold)

_apt_al_dia=0
apt_actualizar() {
  [[ ${_apt_al_dia} -eq 1 ]] && return 0
  apt-get update -qq "${APT_OPC[@]}"
  _apt_al_dia=1
}

instalado() {
  dpkg-query -W -f='${Status}' "$1" 2>/dev/null \
    | grep -q "^install ok installed$"
}

# Instala solo lo que falte.
instalar() {
  local faltan=() p
  for p in "$@"; do instalado "$p" || faltan+=("$p"); done
  if [[ ${#faltan[@]} -eq 0 ]]; then detalle "ya instalado: $*"; return 0; fi
  apt_actualizar
  info "instalando: ${faltan[*]}"
  apt-get install -y --no-install-recommends "${APT_OPC[@]}" "${faltan[@]}"
}

# Hay candidato instalable? No alcanza con que el nombre aparezca en algún
# lado: hay nombres que figuran en las dependencias de un tercero y no tienen
# candidato en la suite instalada.
hay_candidato() {
  local c
  c=$(LC_ALL=C apt-cache policy "$1" 2>/dev/null \
      | awk '/^  Candidate:/{print $2; exit}')
  [[ -n "$c" && "$c" != "(none)" ]]
}

# Instala lo que exista y avisa por lo que no. Un nombre de paquete que cambió
# entre versiones de la distribución no puede tumbar el guion entero.
instalar_disponibles() {
  local hay=() faltan=() p
  for p in "$@"; do
    if hay_candidato "$p"; then hay+=("$p"); else faltan+=("$p"); fi
  done
  [[ ${#faltan[@]} -gt 0 ]] && aviso "no están en los repositorios, se saltean: ${faltan[*]}"
  [[ ${#hay[@]} -gt 0 ]] && instalar "${hay[@]}"
  return 0
}

# Baja una llave de repositorio y le mira la huella antes de confiar en ella.
# Bajarla por HTTPS y usarla sin mirar es confiar en quien haya podido
# responder ese GET. Devuelve 1 si no cierra: el llamador no da de alta nada.
#
# Se pregunta si la huella esperada ESTÁ entre las del archivo, no si es la
# única: varios publican más de una llave en el mismo archivo porque las van
# rotando y dejan las viejas.
llave_verificada() {
  local destino="$1" url="$2" esperada="$3" nombre="$4"

  if [[ ! -s "$destino" ]]; then
    install -d -m 0755 /etc/apt/keyrings
    if ! curl -fsSL --retry 2 --connect-timeout 20 "$url" -o "$destino"; then
      aviso "no se pudo bajar la llave de ${nombre}"
      return 1
    fi
    chmod 0644 "$destino"
  fi

  if gpg --show-keys --with-colons "$destino" 2>/dev/null \
       | awk -F: '/^fpr:/ {print $10}' | grep -qx "$esperada"; then
    ok "huella de la llave de ${nombre} verificada"
    return 0
  fi

  aviso "La llave de ${nombre} no tiene la huella esperada (${esperada})."
  aviso "No se da de alta su repositorio. Verificala a mano antes de seguir."
  rm -f "$destino"
  return 1
}

# ---------------------------------------------------------------------------
# Paso: idioma
# ---------------------------------------------------------------------------
paso_idioma() {
  titulo "Español de Argentina, teclado y zona horaria"

  instalar_disponibles locales hunspell-es \
    fonts-liberation fonts-crosextra-carlito fonts-crosextra-caladea

  # El locale es_AR viene comentado en Debian: hay que descomentarlo y
  # generarlo. Se comprueba primero porque locale-gen tarda.
  if ! locale -a 2>/dev/null | grep -qi '^es_AR.utf8$'; then
    sed -i 's/^# *es_AR.UTF-8 UTF-8/es_AR.UTF-8 UTF-8/' /etc/locale.gen 2>/dev/null || true
    grep -q '^es_AR.UTF-8 UTF-8' /etc/locale.gen 2>/dev/null \
      || printf 'es_AR.UTF-8 UTF-8\n' >> /etc/locale.gen
    locale-gen
    ok "locale es_AR.UTF-8 generado"
  else
    detalle "el locale es_AR.UTF-8 ya estaba generado"
  fi

  escribir_archivo /etc/default/locale 0644 <<'EOF' || true
LANG="es_AR.UTF-8"
LANGUAGE="es_AR:es"
LC_TIME="es_AR.UTF-8"
LC_NUMERIC="es_AR.UTF-8"
LC_MONETARY="es_AR.UTF-8"
EOF

  timedatectl set-timezone America/Argentina/Buenos_Aires 2>/dev/null \
    || ln -sf /usr/share/zoneinfo/America/Argentina/Buenos_Aires /etc/localtime

  # El teclado de la consola y el del inicio de sesión salen de un archivo
  # distinto que el de Plasma: /etc/default/keyboard, que leen console-setup y
  # Xorg. El kxkbrc que instala el paquete manda dentro de la sesión, pero
  # antes de entrar -o sea, cuando se escribe la contraseña- vale este.
  #
  # No lo puede instalar el paquete: es de debconf, y dpkg-reconfigure lo
  # reescribe entero.
  local cambio=0
  escribir_archivo /etc/default/keyboard 0644 <<'EOF' && cambio=1 || true
# YacareOS - teclado latinoamericano. Lo escribe yacareos-configurar.
XKBMODEL="pc105"
XKBLAYOUT="latam"
XKBVARIANT=""
XKBOPTIONS="terminate:ctrl_alt_bksp"
BACKSPACE="guess"
EOF
  if [[ "$cambio" == "1" ]] && ! en_chroot; then
    setupcon --save 2>/dev/null || true
    ok "teclado latinoamericano aplicado también fuera de la sesión"
  fi
}

# ---------------------------------------------------------------------------
# Paso: aplicaciones
# ---------------------------------------------------------------------------
paso_aplicaciones() {
  titulo "Firefox, Visual Studio Code y Discord"

  instalar_disponibles ca-certificates curl gnupg apt-transport-https

  local hubo_repo=0

  # --- Firefox, del repositorio de Mozilla ---------------------------------
  # Debian empaqueta firefox-esr, que es la rama de soporte extendido: se queda
  # una versión mayor atrás y recibe solo parches de seguridad. Para un
  # escritorio que vive en el navegador la diferencia se nota en el motor. El
  # .deb de Mozilla es el canal estable y se actualiza por apt como cualquier
  # otro paquete.
  local llave_moz=/etc/apt/keyrings/packages.mozilla.org.asc
  if llave_verificada "$llave_moz" \
       https://packages.mozilla.org/apt/repo-signing-key.gpg \
       35BAA0B33E9EB396F59CA838C0BA5CE6DC6315A3 "Mozilla"; then

    escribir_archivo /etc/apt/sources.list.d/mozilla.sources 0644 <<EOF && hubo_repo=1
Types: deb
URIs: https://packages.mozilla.org/apt
Suites: mozilla
Components: main
Signed-By: ${llave_moz}
EOF

    # El pin fija que todo lo que venga de Mozilla le gane a cualquier otra
    # procedencia: es lo que evita que una actualización reemplace el navegador
    # por otro paquete que se llame igual.
    escribir_archivo /etc/apt/preferences.d/mozilla.pref 0644 <<'EOF' && hubo_repo=1
# YacareOS: el navegador sale de Mozilla, no de Debian.
# Para revertir: borrar este archivo y /etc/apt/sources.list.d/mozilla.sources,
# y `apt install firefox-esr`.
Package: *
Pin: origin packages.mozilla.org
Pin-Priority: 1000
EOF

    # Una capa anterior puede haber dado de alta el mismo repositorio con el
    # formato viejo de una linea. No se borra -es de otra capa y borrarlo a
    # ciegas es peor-, pero se avisa: dos fuentes al mismo repositorio hacen
    # que apt avise de una fuente duplicada en cada actualizacion.
    local otra
    for otra in /etc/apt/sources.list.d/*.list; do
      [[ -e "$otra" ]] || continue
      if grep -q 'packages\.mozilla\.org' "$otra" 2>/dev/null; then
        aviso "${otra} apunta al mismo repositorio que mozilla.sources"
        aviso "  apt va a avisar de una fuente duplicada hasta que lo borres"
      fi
    done
  fi

  # --- Visual Studio Code --------------------------------------------------
  # El archivo se llama vscode.sources y está donde el postinst del paquete
  # `code` lo busca: al encontrarlo no escribe el suyo, y así no quedan dos
  # fuentes apuntando al mismo lugar.
  local llave_ms=/etc/apt/keyrings/microsoft.asc
  if llave_verificada "$llave_ms" \
       https://packages.microsoft.com/keys/microsoft.asc \
       BC528686B50D79E339D3721CEB3E94ADBE1229CF "Microsoft"; then
    escribir_archivo /etc/apt/sources.list.d/vscode.sources 0644 <<EOF && hubo_repo=1
Types: deb
URIs: https://packages.microsoft.com/repos/code
Suites: stable
Components: main
Architectures: amd64
Signed-By: ${llave_ms}
EOF
  fi

  (( hubo_repo == 1 )) && _apt_al_dia=0
  apt_actualizar

  # --allow-downgrades por si la máquina venía con un firefox de otra fuente
  # con epoch: lo que decide cuál gana es el pin, no el número de versión.
  if hay_candidato firefox; then
    apt-get install -y --allow-downgrades "${APT_OPC[@]}" firefox
    if hay_candidato firefox-l10n-es-ar; then
      apt-get install -y --allow-downgrades "${APT_OPC[@]}" firefox-l10n-es-ar \
        || aviso "no se pudo instalar firefox-l10n-es-ar; Firefox queda en inglés"
    fi
  else
    aviso "sin el repositorio de Mozilla: se instala el firefox-esr de Debian"
    instalar_disponibles firefox-esr firefox-esr-l10n-es-ar
  fi

  # La red de seguridad: el ESR de Debian se saca solo cuando el de Mozilla
  # está realmente instalado. Un equipo sin navegador es un equipo inservible,
  # y dos Firefox son dos entradas en el menú y dos actualizadores.
  if instalado firefox; then
    local esr=() p
    for p in firefox-esr firefox-esr-l10n-es-ar firefox-esr-l10n-es-es; do
      instalado "$p" && esr+=("$p")
    done
    if [[ ${#esr[@]} -gt 0 ]]; then
      apt-get purge -y "${APT_OPC[@]}" "${esr[@]}"
      apt-get autoremove --purge -y "${APT_OPC[@]}"
      ok "quitado el Firefox ESR de Debian: ${esr[*]}"
    fi

    # El Firefox de Mozilla no trae política del sistema: se le apaga el
    # actualizador interno porque quien actualiza es apt. Si no, cada máquina
    # pide contraseña de administrador para actualizarse sola.
    escribir_archivo /etc/firefox/policies/policies.json 0644 <<'EOF' || true
{
  "policies": {
    "DisableAppUpdate": true,
    "DisableTelemetry": true,
    "DisableFirefoxStudies": true,
    "DontCheckDefaultBrowser": true
  }
}
EOF
  fi

  instalar_disponibles code

  # --- Discord -------------------------------------------------------------
  # No tiene repositorio: la URL es una redirección al .deb del día, así que
  # queda congelado en la versión que se instale. Discord se niega a arrancar
  # cuando queda atrás, y entonces hay que volver a correr este paso con
  # YACARE_REINSTALAR_DEB=1.
  if instalado discord && [[ "${YACARE_REINSTALAR_DEB:-0}" != "1" ]]; then
    detalle "Discord ya instalado (YACARE_REINSTALAR_DEB=1 para actualizarlo)"
  else
    local tmp archivo
    tmp=$(mktemp -d); archivo="${tmp}/discord.deb"
    if curl -fsSL --retry 2 --connect-timeout 20 \
         "https://discord.com/api/download?platform=linux&format=deb" -o "$archivo"; then
      # Un HTML de error de 3 kB también se baja "bien". dpkg-deb lo descarta
      # antes de que apt escupa un error que no se entiende.
      if dpkg-deb --info "$archivo" >/dev/null 2>&1; then
        apt-get install -y "${APT_OPC[@]}" "$archivo" \
          && ok "Discord instalado ($(dpkg-deb -f "$archivo" Version))" \
          || aviso "apt no pudo instalar Discord"
      else
        aviso "lo que se bajó de discord.com no es un .deb: queda sin instalar"
      fi
    else
      aviso "no se pudo bajar Discord"
    fi
    rm -rf "$tmp"
  fi
}

# ---------------------------------------------------------------------------
# Paso: cortafuegos
# ---------------------------------------------------------------------------
paso_cortafuegos() {
  titulo "Cortafuegos"

  # Un escritorio no ofrece servicios: se rechaza todo lo entrante y se permite
  # todo lo saliente. Se configura con órdenes y no con un archivo, que es por
  # lo que esto no está en el paquete.
  instalar ufw
  ufw --force default deny incoming >/dev/null
  ufw --force default allow outgoing >/dev/null
  # mDNS es lo que hace que las impresoras de red aparezcan solas. Sin esto hay
  # que dar de alta cada una a mano por su dirección.
  ufw allow in 5353/udp comment 'mDNS - descubrimiento de impresoras' >/dev/null

  if en_chroot; then
    systemctl enable ufw >/dev/null 2>&1 || true
    detalle "ufw queda activo en el primer arranque"
  else
    ufw --force enable >/dev/null
  fi
  ok "cortafuegos activo, con lo entrante denegado"
}

# ---------------------------------------------------------------------------
# Paso: equipo
# ---------------------------------------------------------------------------
paso_equipo() {
  titulo "Ajustes que dependen de la máquina"

  local ram; ram=$(ram_mb)
  info "RAM: ${ram} MB"
  if raiz_es_hdd; then info "disco de la raíz: rotacional"
  else info "disco de la raíz: estado sólido"; fi

  # El tamaño del zram NO se decide acá: es una expresión en
  # /etc/systemd/zram-generator.conf que se evalúa en cada arranque. Lo único
  # que falta es que el generador esté instalado.
  instalar_disponibles systemd-zram-generator

  # Baloo, el indexador de KDE, es lo primero que se nota en una máquina
  # modesta: indexa la carpeta personal entera la primera vez y deja el disco
  # al tope durante media hora. En un disco rotacional se apaga aunque haya
  # RAM, porque ahí el problema no es la memoria sino el cabezal moviéndose
  # mientras alguien trabaja.
  #
  # Esto sí depende de la máquina y por eso no está en el paquete: el archivo
  # que se escribe es el mismo, con un contenido u otro.
  if equipo_modesto || raiz_es_hdd; then
    escribir_archivo /etc/xdg/baloofilerc 0644 <<'EOF' || true
# YacareOS - máquina modesta o con disco rotacional: sin indexado.
# Lo escribe yacareos-configurar.

[Basic Settings]
Indexing-Enabled=false
EOF
    if ! en_chroot; then
      como_usuario balooctl6 disable >/dev/null 2>&1 \
        || como_usuario balooctl disable >/dev/null 2>&1 || true
      como_usuario balooctl6 purge >/dev/null 2>&1 || true
    fi
    ok "indexado de archivos desactivado"
  else
    escribir_archivo /etc/xdg/baloofilerc 0644 <<'EOF' || true
# YacareOS - hay RAM y disco de sobra: se indexan los nombres, no el
# contenido. Indexar el contenido de cada PDF es lo que hace que el índice
# crezca a varios GB sin que nadie se lo pida.
# Lo escribe yacareos-configurar.

[Basic Settings]
Indexing-Enabled=true
Only Basic Indexing=true
EOF
    detalle "indexado de nombres activo"
  fi

  # Esperar 90 segundos a que una interfaz levante es lo que hace que una
  # máquina sin cable tarde un minuto y medio en llegar al escritorio. Plasma
  # maneja la red después de entrar; no hace falta bloquear el arranque.
  if ! en_chroot; then
    systemctl disable NetworkManager-wait-online.service >/dev/null 2>&1 || true
    systemctl disable systemd-networkd-wait-online.service >/dev/null 2>&1 || true
  fi

  # TRIM semanal, y no la opción discard de montaje: discard hace un TRIM
  # sincrónico en cada borrado y en varios SSD baratos eso se siente.
  if ! raiz_es_hdd; then
    systemctl enable fstrim.timer >/dev/null 2>&1 || true
    detalle "fstrim semanal habilitado"
  fi

  ok "ajustes de la máquina aplicados"
}

# ---------------------------------------------------------------------------
# Paso: sesion
# ---------------------------------------------------------------------------
paso_sesion() {
  titulo "El tema, sobre la sesión que ya está abierta"

  if en_chroot; then
    detalle "en chroot: no hay sesión, y /etc/xdg alcanza para la que se cree"
    return 0
  fi

  if ! como_usuario true 2>/dev/null; then
    detalle "sin sesión gráfica: el tema se ve al entrar"
    return 0
  fi

  # Este es el único caso en que la capa de /etc/xdg no alcanza, y conviene
  # entender por qué: a quien ya venía usando la máquina, Plasma le escribió
  # esas claves en su propio kdeglobals la primera vez que entró, y lo suyo le
  # gana a la capa de sistema. Aplicar el tema sobre la sesión es lo único que
  # lo arregla, y no lo puede hacer un paquete porque necesita el bus de esa
  # sesión.
  local u; u=$(usuario_real)

  if ! como_usuario plasma-apply-lookandfeel -a org.yacareos.dark >/dev/null 2>&1; then
    aviso "no se pudo aplicar el tema global sobre la sesión de ${u}"
    aviso "  se puede poner a mano en Ajustes del sistema, en Apariencia"
  else
    ok "tema global aplicado sobre la sesión de ${u}"
  fi

  como_usuario plasma-apply-colorscheme YacareOSDark >/dev/null 2>&1 || true

  # KWin relee su configuración por D-Bus y toma en el acto el desenfoque, los
  # bordes activos y la posición de los botones.
  como_usuario qdbus6 org.kde.KWin /KWin reconfigure >/dev/null 2>&1 \
    || como_usuario qdbus org.kde.KWin /KWin reconfigure >/dev/null 2>&1 || true

  aviso "El Bloq Num del inicio de sesión y el teclado se ven al volver a entrar."
}

# ---------------------------------------------------------------------------
# Orquestador
# ---------------------------------------------------------------------------
listar_pasos() {
  printf '\nYacareOS - pasos de yacareos-configurar\n\n'
  local entrada nombre desc
  for entrada in "${PASOS[@]}"; do
    IFS=: read -r nombre desc <<< "$entrada"
    printf '  %s%-14s%s %s\n' "$_TIT" "$nombre" "$_OFF" "$desc"
  done
  printf '\n'
}

main() {
  local solo='' salvo=''

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --lista)   listar_pasos; exit 0 ;;
      --solo)    solo="${2:?--solo necesita el nombre de un paso}"; shift 2 ;;
      --salvo)   salvo="${2:?--salvo necesita el nombre de un paso}"; shift 2 ;;
      -h|--help) sed -n '2,27p' "$0" | sed -e 's/^# \?//' -e 's/^#$//'; exit 0 ;;
      *)         morir "opción desconocida: $1  (probá --help)" ;;
    esac
  done

  [[ ${EUID} -eq 0 ]] || morir "hace falta root. Probá: sudo $0 $*"

  printf '\n%sYacareOS%s\n' "$_TIT" "$_OFF"
  detalle "modo: $(en_chroot && echo 'chroot, armando la imagen' || echo 'sistema en marcha')"
  detalle "usuario: $(usuario_real)"

  local inicio; inicio=$(date +%s)
  local hechos=0 entrada nombre desc

  for entrada in "${PASOS[@]}"; do
    IFS=: read -r nombre desc <<< "$entrada"
    [[ -n "$solo"  && "$nombre" != "$solo"  ]] && continue
    [[ -n "$salvo" && "$nombre" == "$salvo" ]] && continue

    # Con la entrada CERRADA, y no es un detalle: si algo de adentro lee la
    # entrada estándar (apt o dpkg preguntando, un hook del initramfs), se come
    # lo que este shell todavía no leyó de la suya. Cuando el guion se invoca
    # desde una tubería, el efecto es que el comando siguiente desaparece sin
    # ningún error.
    "paso_${nombre}" < /dev/null
    hechos=$((hechos + 1))
  done

  [[ -n "$solo" && ${hechos} -eq 0 ]] && morir "no hay ningún paso que se llame '$solo'"

  local fin; fin=$(date +%s)
  printf '\n%slisto%s - %d paso(s) en %d s\n\n' "$_OK" "$_OFF" "$hechos" "$((fin - inicio))"

  en_chroot || info "Reiniciá para ver el arranque y el inicio de sesión."
}

main "$@"
