tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1181Shell 82Rust 62Markdown 521515 Definiciones

instalador: TUI interactivo (disco/identidad/locale/tamaños) sobre el autoinstalador

hammer-install crece una cara humana sin tocar la mecánica EFI/BIOS validada:
- sin device + TTY ⇒ interactive_setup: detecta discos (/sys/block, marca el
  montado como posible medio live), el usuario elige + confirma escribiendo el
  nombre del disco, y recoge hostname / credencial de root (contraseña vía
  cryptpw o authorized_keys) / zona horaria (best-effort si hay tzdata) /
  teclado / tamaños de / y /store.
- apply_post_install_config escribe hostname+hosts, reemplaza el hash de root en
  /etc/shadow, instala authorized_keys, symlinkea localtime y deja install.conf;
  se llama tras la copia en AMBAS ramas (EFI y BIOS).
- con device ⇒ autoinstalador de siempre (iso/efi-install-test intactos); la
  config sale de env (HAMMER_HOSTNAME/ROOT_PW/ROOT_AUTHKEYS/TZ/KEYMAP).
- iso-image.sh: ISO instalador sin AUTO_INSTALL ahora recibe con la TUI en la
  consola (INSTALLER_INTERACTIVE, default on) y ofrece reiniciar/caer al live.

Verificado: scripts/install-tui-test.sh (4/4, dry-run con sysfs+rootfs falsos)
+ iso-install-test.sh VERDE end-to-end en QEMU (autoinstalador con la config
nueva bajo busybox real → disco bootea solo y sirve SSH).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

9305bb16e96c3352ebaf963d64a54ac655f2d19e · por sergio · (hace 3 meses) · padre 494d3fd61 · ver el árbol en este commit

Delta semántico

Ninguna definición cambió: el cambio es de texto, documentación o datos.

3 archivos · +359 −5

diff --git a/scripts/hammer-live-install.sh b/scripts/hammer-live-install.sh
index b098b2d1..491f9426 100755
--- a/scripts/hammer-live-install.sh
+++ b/scripts/hammer-live-install.sh
@@ -18,14 +18,254 @@
 # Cadena de arranque del disco resultante: BIOS → MBR (boot.img) → core.img (hueco post-MBR) →
 # (hd0,msdos1)/boot/grub/grub.cfg → linux /boot/bzImage root=/dev/<dev>1 → wrapper /sbin/init.
 #
-# Uso (en el live):  hammer-install /dev/vdb [ROOT_MB] [STORE_MB]
+# Modo de uso:
+#   INTERACTIVO (sin argumentos, con TTY):  hammer-install
+#       TUI en busybox sh: detecta discos, el usuario elige destino + confirma el borrado, recoge
+#       hostname / credencial de root / zona horaria / teclado / tamaños de partición, y recién ahí
+#       particiona+instala. Es la cara humana del instalador (el /init del ISO installer lo lanza).
+#   DESATENDIDO (con device):  hammer-install /dev/vdb [ROOT_MB] [STORE_MB]
+#       Autoinstalador (lo usa iso-install-test.sh con AUTO_INSTALL). La config sale de env:
+#       HAMMER_HOSTNAME / HAMMER_ROOT_PW / HAMMER_ROOT_AUTHKEYS / HAMMER_TZ / HAMMER_KEYMAP.
 set -eu
 
-DEV="${1:-/dev/vdb}"
-ROOT_MB="${2:-1500}"
-STORE_MB="${3:-400}"     # /var/lib/hammer toma el resto
+DEV="${1:-}"
+ROOT_MB="${2:-}"
+STORE_MB="${3:-}"        # /var/lib/hammer toma el resto
 PAYLOAD=/usr/lib/hammer/install
-BB=/bin/busybox
+BB="${BB:-/bin/busybox}"
+SYS_BLOCK="${SYS_BLOCK:-/sys/block}"   # override para tests
+MOUNTS="${MOUNTS:-/proc/mounts}"       # override para tests
+
+# bb: usa el busybox del live si existe; si no (p.ej. tests en el host), cae a las herramientas del PATH.
+# Sólo lo usan las funciones NUEVAS (config/TUI). Las ramas EFI/BIOS validadas siguen con "$BB" literal
+# y jamás corren en el host (el dry-run sale antes).
+bb() { if [ -x "$BB" ]; then "$BB" "$@"; else "$@"; fi; }
+
+_human() {  # bytes → humano
+    _b="$1"
+    if   [ "$_b" -ge 1073741824 ]; then printf '%s.%s GiB' "$((_b/1073741824))" "$(( (_b%1073741824)*10/1073741824 ))"
+    elif [ "$_b" -ge 1048576    ]; then printf '%s MiB' "$((_b/1048576))"
+    else printf '%s B' "$_b"; fi
+}
+
+_is_mounted() {  # ¿alguna partición del disco $1 aparece en MOUNTS? (heurística "es el medio live")
+    _d="$1"
+    while read -r _src _rest; do
+        case "$_src" in "/dev/$_d"|"/dev/$_d"[0-9]*|"/dev/$_d"p[0-9]*) return 0 ;; esac
+    done < "$MOUNTS" 2>/dev/null
+    return 1
+}
+
+list_disks() {  # emite por disco candidato:  name<TAB>bytes<TAB>model<TAB>mounted(0|1)
+    for _p in "$SYS_BLOCK"/*; do
+        [ -d "$_p" ] || continue
+        _n=${_p##*/}
+        case "$_n" in loop*|ram*|sr*|fd*|dm-*|md*|zram*) continue ;; esac
+        [ -r "$_p/size" ] || continue
+        _sz=$(cat "$_p/size" 2>/dev/null || echo 0); [ "$_sz" -gt 0 ] || continue
+        _model=$(cat "$_p/device/model" 2>/dev/null | tr -d '\n'); _model=$(printf '%s' "$_model" | sed 's/[[:space:]]*$//')
+        _m=0; _is_mounted "$_n" && _m=1
+        printf '%s\t%s\t%s\t%s\n' "$_n" "$((_sz*512))" "${_model:-disco}" "$_m"
+    done
+}
+
+ask() {  # ask VAR "prompt" "default"
+    if [ -n "$3" ]; then printf '%s [%s]: ' "$2" "$3"; else printf '%s: ' "$2"; fi
+    IFS= read -r _ans || _ans=""
+    [ -n "$_ans" ] || _ans="$3"
+    eval "$1=\$_ans"
+}
+
+read_secret() {  # read_secret VAR "prompt"  (sin eco si hay stty)
+    printf '%s: ' "$2"
+    bb stty -echo 2>/dev/null || true
+    IFS= read -r _sec || _sec=""
+    bb stty echo 2>/dev/null || true
+    echo
+    eval "$1=\$_sec"
+}
+
+have_cryptpw() { bb 2>&1 | grep -qw cryptpw; }
+
+# Reescribe el campo de contraseña de un usuario en $mnt/etc/shadow (o crea el fichero).
+_set_shadow() {
+    _u="$1"; _h="$2"; _f="$3/etc/shadow"
+    if [ ! -f "$_f" ]; then
+        bb mkdir -p "$3/etc"
+        printf '%s:%s:19000:0:99999:7:::\n' "$_u" "$_h" > "$_f"; bb chmod 600 "$_f"; return
+    fi
+    _t="$_f.hammer-tmp"
+    bb awk -F: -v u="$_u" -v h="$_h" 'BEGIN{OFS=":"}
+        { if ($1==u){ $2=h; seen=1 } print }
+        END{ if(!seen) print u,h,"19000","0","99999","7","","","" }' "$_f" > "$_t" && bb mv "$_t" "$_f"
+    bb chmod 600 "$_f"
+}
+
+# Aplica la config recogida al rootfs YA copiado y montado en $1 (llamada por ambas ramas tras la copia).
+apply_post_install_config() {
+    _mnt="$1"
+    bb mkdir -p "$_mnt/etc/hammer"
+    if [ -n "${CFG_HOSTNAME:-}" ]; then
+        printf '%s\n' "$CFG_HOSTNAME" > "$_mnt/etc/hostname"
+        if ! grep -q "[[:space:]]$CFG_HOSTNAME\$" "$_mnt/etc/hosts" 2>/dev/null; then
+            printf '127.0.0.1\tlocalhost %s\n::1\t\tlocalhost %s\n' "$CFG_HOSTNAME" "$CFG_HOSTNAME" >> "$_mnt/etc/hosts"
+        fi
+    fi
+    if [ -n "${CFG_ROOT_HASH:-}" ]; then _set_shadow root "$CFG_ROOT_HASH" "$_mnt"; fi
+    if [ -n "${CFG_ROOT_AUTHKEYS:-}" ]; then
+        bb mkdir -p "$_mnt/root/.ssh"; bb chmod 700 "$_mnt/root/.ssh"
+        printf '%s\n' "$CFG_ROOT_AUTHKEYS" >> "$_mnt/root/.ssh/authorized_keys"
+        bb chmod 600 "$_mnt/root/.ssh/authorized_keys"
+    fi
+    if [ -n "${CFG_TZ:-}" ] && [ -e "$_mnt/usr/share/zoneinfo/$CFG_TZ" ]; then
+        bb ln -sf "/usr/share/zoneinfo/$CFG_TZ" "$_mnt/etc/localtime"
+        printf '%s\n' "$CFG_TZ" > "$_mnt/etc/timezone"
+    fi
+    _ts=$(bb date -u +%FT%TZ 2>/dev/null || echo unknown)
+    {
+        echo "# escrito por hammer-install en $_ts"
+        echo "HOSTNAME=${CFG_HOSTNAME:-}"
+        echo "TIMEZONE=${CFG_TZ:-}"
+        echo "KEYMAP=${CFG_KEYMAP:-}"     # best-effort: el producto aún no trae kbd/loadkeys
+        echo "TARGET=${DEV:-}"
+    } > "$_mnt/etc/hammer/install.conf"
+}
+
+# ---- recolección de config: interactiva (TUI) o desde env (desatendido) ----
+CFG_HOSTNAME="${HAMMER_HOSTNAME:-hammer}"
+CFG_ROOT_AUTHKEYS="${HAMMER_ROOT_AUTHKEYS:-}"
+CFG_ROOT_HASH="${HAMMER_ROOT_HASH:-}"
+CFG_TZ="${HAMMER_TZ:-}"
+CFG_KEYMAP="${HAMMER_KEYMAP:-us}"
+
+interactive_setup() {
+    echo
+    echo "   #  instalador de hammer"
+    echo "   ─────────────────────────"
+    echo
+    _disks=$(list_disks)
+    [ -n "$_disks" ] || { echo "No detecté discos instalables (¿el medio live es el único dispositivo?)."; exit 1; }
+    echo "Discos detectados:"
+    OLDIFS=$IFS; IFS='
+'; set --
+    for _l in $_disks; do set -- "$@" "$_l"; done
+    IFS=$OLDIFS
+    _i=1
+    for _l in "$@"; do
+        _n=$(printf '%s' "$_l" | cut -f1); _by=$(printf '%s' "$_l" | cut -f2)
+        _mo=$(printf '%s' "$_l" | cut -f3); _mm=$(printf '%s' "$_l" | cut -f4)
+        _w=""; [ "$_mm" = 1 ] && _w="   ⚠ montado (¿medio live?)"
+        printf '  %s) /dev/%-8s %10s   %s%s\n' "$_i" "$_n" "$(_human "$_by")" "$_mo" "$_w"
+        _i=$((_i+1))
+    done
+    _total=$#
+    while :; do
+        ask _sel "Disco destino (1-$_total)" ""
+        case "$_sel" in ''|*[!0-9]*) echo "  número inválido"; continue ;; esac
+        [ "$_sel" -ge 1 ] && [ "$_sel" -le "$_total" ] && break
+        echo "  fuera de rango"
+    done
+    eval "_line=\${$_sel}"
+    _name=$(printf '%s' "$_line" | cut -f1); _bytes=$(printf '%s' "$_line" | cut -f2)
+    DEV="/dev/$_name"
+    _disk_mb=$((_bytes/1048576))
+
+    # --- tamaños de partición ---
+    _esp=64; _slack=16; _avail=$((_disk_mb - _esp - _slack))
+    [ "$_avail" -ge 600 ] || { echo "Disco demasiado chico ($(_human "$_bytes")); se necesitan ~700 MiB."; exit 1; }
+    _defr=4096; [ "$_defr" -gt $((_avail*6/10)) ] && _defr=$((_avail*6/10))
+    _defs=1024; [ "$_defs" -gt $((_avail/5)) ] && _defs=$((_avail/5))
+    while :; do
+        ask ROOT_MB  "Tamaño de / en MB"      "$_defr"
+        ask STORE_MB "Tamaño de /store en MB" "$_defs"
+        case "$ROOT_MB$STORE_MB" in *[!0-9]*) echo "  usá sólo números"; continue ;; esac
+        if [ $((ROOT_MB + STORE_MB + _esp + _slack)) -le "$_disk_mb" ]; then break; fi
+        echo "  no entra en el disco ($_disk_mb MiB) — / + /store + ESP deben sumar menos"
+    done
+
+    # --- identidad ---
+    ask CFG_HOSTNAME "Hostname" "${CFG_HOSTNAME:-hammer}"
+    echo "Credencial de root:"
+    echo "  1) contraseña"
+    echo "  2) clave SSH (authorized_keys)"
+    echo "  3) omitir"
+    ask _cred "Opción" "1"
+    case "$_cred" in
+        1)  if have_cryptpw; then
+                while :; do
+                    read_secret _pw1 "Contraseña de root"
+                    read_secret _pw2 "Repetir"
+                    [ "$_pw1" = "$_pw2" ] && [ -n "$_pw1" ] && break
+                    echo "  no coinciden (o vacía) — reintentá"
+                done
+                CFG_ROOT_HASH=$(printf '%s' "$_pw1" | bb cryptpw -m sha512 2>/dev/null || true)
+                [ -n "$CFG_ROOT_HASH" ] || echo "  ⚠ no pude generar el hash; root queda como está"
+            else
+                echo "  (cryptpw no disponible en este busybox — usá la opción de clave SSH)"
+                ask CFG_ROOT_AUTHKEYS "Pegá una línea authorized_keys" ""
+            fi ;;
+        2)  ask CFG_ROOT_AUTHKEYS "Pegá una línea authorized_keys (o ruta a un fichero .pub)" ""
+            if [ -n "$CFG_ROOT_AUTHKEYS" ] && [ -f "$CFG_ROOT_AUTHKEYS" ]; then
+                CFG_ROOT_AUTHKEYS=$(cat "$CFG_ROOT_AUTHKEYS")
+            fi ;;
+        *)  echo "  (root sin credencial nueva)" ;;
+    esac
+
+    # --- locale (best-effort) ---
+    _zi="${ZONEINFO:-/usr/share/zoneinfo}"
+    if [ -d "$_zi" ]; then
+        ask CFG_TZ "Zona horaria (ej. America/La_Paz; vacío = UTC)" "${CFG_TZ:-}"
+        if [ -n "$CFG_TZ" ] && [ ! -e "$_zi/$CFG_TZ" ]; then
+            echo "  ⚠ zona desconocida, se ignora"; CFG_TZ=""
+        fi
+    else
+        echo "(tzdata no instalado — la zona horaria se omite)"; CFG_TZ=""
+    fi
+    ask CFG_KEYMAP "Layout de teclado" "${CFG_KEYMAP:-us}"
+
+    # --- resumen + confirmación fuerte ---
+    _cd="sin cambios"; [ -n "$CFG_ROOT_HASH" ] && _cd="contraseña"; [ -n "$CFG_ROOT_AUTHKEYS" ] && _cd="clave SSH"
+    echo
+    echo "Resumen de instalación:"
+    echo "  destino : $DEV   ($(_human "$_bytes"))"
+    echo "  / = ${ROOT_MB} MiB    /store = ${STORE_MB} MiB    /var/lib/hammer = resto"
+    echo "  hostname: $CFG_HOSTNAME"
+    echo "  root    : $_cd"
+    echo "  zona    : ${CFG_TZ:-UTC}    teclado: $CFG_KEYMAP"
+    echo
+    echo "  ⚠  ESTO BORRA TODO EN $DEV."
+    ask _ok "Escribí el nombre del disco ($DEV) para confirmar, o Enter para cancelar" ""
+    [ "$_ok" = "$DEV" ] || { echo "Cancelado — no se tocó ningún disco."; exit 1; }
+}
+
+# --- resolución del modo: interactivo (sin device + TTY) vs desatendido (device dado) ---
+if [ -z "$DEV" ]; then
+    if [ -t 0 ] || [ "${HAMMER_INSTALL_INTERACTIVE:-}" = 1 ]; then
+        interactive_setup
+    else
+        echo "uso: hammer-install <device> [ROOT_MB] [STORE_MB]   (o sin args con un TTY para el modo interactivo)" >&2
+        exit 2
+    fi
+fi
+ROOT_MB="${ROOT_MB:-1500}"
+STORE_MB="${STORE_MB:-400}"
+# En modo desatendido con contraseña por env, derivar el hash acá (interactivo ya lo hizo).
+if [ -z "$CFG_ROOT_HASH" ] && [ -n "${HAMMER_ROOT_PW:-}" ] && have_cryptpw; then
+    CFG_ROOT_HASH=$(printf '%s' "$HAMMER_ROOT_PW" | bb cryptpw -m sha512 2>/dev/null || true)
+fi
+
+# --- gancho de prueba: valida TUI + config sin tocar disco (HAMMER_INSTALL_DRYRUN=1) ---
+if [ "${HAMMER_INSTALL_DRYRUN:-}" = 1 ]; then
+    echo "== DRYRUN: plan de instalación =="
+    echo "  DEV=$DEV  ROOT_MB=$ROOT_MB  STORE_MB=$STORE_MB"
+    echo "  HOSTNAME=$CFG_HOSTNAME  TZ=${CFG_TZ:-UTC}  KEYMAP=$CFG_KEYMAP"
+    echo "  ROOT_HASH=$([ -n "$CFG_ROOT_HASH" ] && echo set || echo -)  AUTHKEYS=$([ -n "$CFG_ROOT_AUTHKEYS" ] && echo set || echo -)"
+    if [ -n "${HAMMER_INSTALL_TESTROOT:-}" ]; then
+        apply_post_install_config "$HAMMER_INSTALL_TESTROOT"
+        echo "  config aplicada a $HAMMER_INSTALL_TESTROOT"
+    fi
+    exit 0
+fi
 
 [ -b "$DEV" ] || { echo "hammer-install: $DEV no es un block device" >&2; exit 1; }
 [ -d "$PAYLOAD" ] || { echo "hammer-install: falta el payload $PAYLOAD (¿ISO sin INSTALLER=1?)" >&2; exit 1; }
@@ -177,6 +417,8 @@ echo "HAMMER-EFI-INIT-OK: arje-zero PID1" > /dev/ttyS0 2>/dev/null || true
 exec /usr/bin/arje-zero
 INIT
     "$BB" chmod +x "$MNT/sbin/init"
+    # config del usuario (hostname / root / zona / teclado) recogida al inicio
+    apply_post_install_config "$MNT"
     "$BB" sync
     "$BB" umount "$MNT"
 
@@ -294,6 +536,8 @@ cat > "$MNT/sbin/init" <<'INIT'
 exec /usr/bin/arje-zero
 INIT
 "$BB" chmod +x "$MNT/sbin/init"
+# config del usuario (hostname / root / zona / teclado) recogida al inicio
+apply_post_install_config "$MNT"
 "$BB" sync
 "$BB" umount "$MNT"
 
diff --git a/scripts/install-tui-test.sh b/scripts/install-tui-test.sh
new file mode 100755
index 00000000..32a3c7ac
--- /dev/null
+++ b/scripts/install-tui-test.sh
@@ -0,0 +1,101 @@
+#!/usr/bin/env bash
+# install-tui-test.sh — valida la capa NUEVA del instalador interactivo (hammer-live-install.sh) SIN
+# tocar disco: usa el gancho HAMMER_INSTALL_DRYRUN=1, un /sys/block falso y un rootfs de scratch.
+# Ejercita (1) el modo interactivo (detección de discos + prompts + confirmación fuerte) y (2) el modo
+# desatendido por env; en ambos verifica que apply_post_install_config escribe hostname/hosts/shadow/
+# authorized_keys/localtime/install.conf correctamente. Las ramas EFI/BIOS reales (particionado) las
+# cubre iso-install-test.sh / efi-install-test.sh en QEMU; acá validamos la lógica de UI + config.
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT"
+INST="$ROOT/scripts/hammer-live-install.sh"
+W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT
+fail() { echo "✗ FALLO: $*" >&2; exit 1; }
+
+# --- sysfs falso: vda (montado = simula el medio live) + vdb (target) ---
+SB="$W/sys-block"
+mkdir -p "$SB/vda/device" "$SB/vdb/device"
+echo $((8*1024*1024*1024/512))  > "$SB/vda/size"   # 8 GiB
+printf 'LIVE-USB\n'             > "$SB/vda/device/model"
+echo $((16*1024*1024*1024/512)) > "$SB/vdb/size"   # 16 GiB
+printf 'TARGET-SSD\n'           > "$SB/vdb/device/model"
+MOUNTS="$W/mounts"; printf '/dev/vda1 / ext4 ro 0 0\n' > "$MOUNTS"
+
+# --- zoneinfo falso (hermético) ---
+ZI="$W/zoneinfo"; mkdir -p "$ZI/America"; : > "$ZI/America/La_Paz"
+
+# --- rootfs de scratch #1 (destino de la config del modo interactivo) ---
+R1="$W/root1"; mkdir -p "$R1/etc" "$R1/usr/share/zoneinfo/America"
+printf 'root:!:19000:0:99999:7:::\ndaemon:!:19000::::::\n' > "$R1/etc/shadow"
+printf '127.0.0.1\tlocalhost\n' > "$R1/etc/hosts"
+: > "$R1/usr/share/zoneinfo/America/La_Paz"
+
+echo "== test 1: modo INTERACTIVO (TUI dry-run) =="
+# Respuestas: disco #2 (vdb) · root 2048M · store 512M · hostname midistro · cred 2 (ssh) · authkeys ·
+#             zona America/La_Paz · teclado (default us) · confirmación "/dev/vdb"
+OUT1="$W/out1.txt"
+BB=/nonexistent SYS_BLOCK="$SB" MOUNTS="$MOUNTS" ZONEINFO="$ZI" \
+  HAMMER_INSTALL_INTERACTIVE=1 HAMMER_INSTALL_DRYRUN=1 HAMMER_INSTALL_TESTROOT="$R1" \
+  sh "$INST" >"$OUT1" 2>&1 <<'ANSWERS' || { cat "$OUT1"; fail "el instalador salió con error"; }
+2
+2048
+512
+midistro
+2
+ssh-ed25519 AAAATESTKEY user@host
+America/La_Paz
+
+/dev/vdb
+ANSWERS
+
+grep -q 'TARGET-SSD' "$OUT1" || { cat "$OUT1"; fail "no listó el disco vdb con su modelo"; }
+grep -q 'montado' "$OUT1"    || { cat "$OUT1"; fail "no marcó vda como montado (medio live)"; }
+grep -q 'DRYRUN' "$OUT1"     || { cat "$OUT1"; fail "no llegó al plan dry-run (¿se cortó antes?)"; }
+
+[ "$(cat "$R1/etc/hostname")" = midistro ] || fail "hostname no quedó en midistro"
+grep -q 'midistro' "$R1/etc/hosts"          || fail "hosts no menciona el hostname"
+grep -q 'AAAATESTKEY' "$R1/root/.ssh/authorized_keys" || fail "authorized_keys no escrito"
+[ -L "$R1/etc/localtime" ] || fail "localtime no es symlink"
+case "$(readlink "$R1/etc/localtime")" in */America/La_Paz) : ;; *) fail "localtime no apunta a La_Paz"; esac
+grep -q 'HOSTNAME=midistro' "$R1/etc/hammer/install.conf" || fail "install.conf sin hostname"
+grep -q 'KEYMAP=us'         "$R1/etc/hammer/install.conf" || fail "install.conf sin keymap default"
+# root en shadow debe seguir intacto (interactivo eligió ssh, no tocó la contraseña)
+grep -q '^root:!:' "$R1/etc/shadow" || fail "shadow de root alterado sin querer"
+echo "  ✓ interactivo: disco elegido + confirmado, config escrita (hostname/hosts/ssh/tz/keymap)"
+
+echo "== test 2: confirmación ERRÓNEA cancela sin tocar nada =="
+OUT2="$W/out2.txt"
+if BB=/nonexistent SYS_BLOCK="$SB" MOUNTS="$MOUNTS" ZONEINFO="$ZI" \
+   HAMMER_INSTALL_INTERACTIVE=1 HAMMER_INSTALL_DRYRUN=1 HAMMER_INSTALL_TESTROOT="$W/root-none" \
+   sh "$INST" >"$OUT2" 2>&1 <<'ANSWERS'
+2
+2048
+512
+h
+3
+us
+sda-equivocado
+ANSWERS
+then fail "debió cancelar con confirmación errónea (exit != 0)"; fi
+grep -qi 'Cancelado' "$OUT2" || { cat "$OUT2"; fail "no reportó cancelación"; }
+[ ! -d "$W/root-none/etc" ] || fail "escribió config pese a cancelar"
+echo "  ✓ confirmación errónea ⇒ aborta sin escribir"
+
+echo "== test 3: modo DESATENDIDO por env (device + HAMMER_ROOT_HASH) =="
+R3="$W/root3"; mkdir -p "$R3/etc"
+printf 'root:!:19000:0:99999:7:::\n' > "$R3/etc/shadow"
+OUT3="$W/out3.txt"
+BB=/nonexistent HAMMER_INSTALL_DRYRUN=1 HAMMER_INSTALL_TESTROOT="$R3" \
+  HAMMER_HOSTNAME=srv01 HAMMER_ROOT_HASH='$6$salt$deadbeefHASH' HAMMER_TZ=UTC HAMMER_KEYMAP=es \
+  sh "$INST" /dev/vdb 3000 700 >"$OUT3" 2>&1 || { cat "$OUT3"; fail "desatendido salió con error"; }
+grep -q 'DEV=/dev/vdb  ROOT_MB=3000  STORE_MB=700' "$OUT3" || { cat "$OUT3"; fail "no respetó device/tamaños posicionales"; }
+grep -q '^root:\$6\$salt\$deadbeefHASH:' "$R3/etc/shadow" || { cat "$R3/etc/shadow"; fail "no reemplazó el hash de root"; }
+[ "$(cat "$R3/etc/hostname")" = srv01 ] || fail "hostname desatendido incorrecto"
+grep -q 'KEYMAP=es' "$R3/etc/hammer/install.conf" || fail "keymap por env no propagado"
+echo "  ✓ desatendido: hash de root reemplazado + hostname/keymap por env"
+
+echo "== test 4: sin device y sin TTY ⇒ error de uso (no cuelga) =="
+if BB=/nonexistent sh "$INST" </dev/null >/dev/null 2>&1; then fail "debió fallar sin device ni TTY"; fi
+echo "  ✓ uso inválido reportado"
+
+echo
+echo "✓✓ install-tui-test: TODOS los casos verdes"
diff --git a/scripts/iso-image.sh b/scripts/iso-image.sh
index b7f355bc..cb216f77 100755
--- a/scripts/iso-image.sh
+++ b/scripts/iso-image.sh
@@ -77,6 +77,15 @@ cp -al "$ROOTFS" "$RFS/root"
     echo "if /usr/bin/hammer-install '${AUTO_INSTALL}'; then echo 'HAMMER-INSTALL-OK'; else echo 'HAMMER-INSTALL-FAIL'; fi"
     echo '/bin/busybox sync'
     echo '/bin/busybox poweroff -f'
+  elif [ "${INSTALLER:-0}" = 1 ] && [ "${INSTALLER_INTERACTIVE:-1}" = 1 ]; then
+    # ISO instalador SIN AUTO_INSTALL ⇒ recibir al usuario con la TUI del instalador en la consola.
+    # Tras instalar (o cancelar) ofrece reiniciar o caer al live. HAMMER_INSTALL_INTERACTIVE fuerza el
+    # modo aunque /dev/console no pase el test -t (a esta altura stdin puede no ser un tty).
+    echo '[ -e /dev/console ] && exec 0</dev/console 1>/dev/console 2>/dev/console'
+    echo 'HAMMER_INSTALL_INTERACTIVE=1 /usr/bin/hammer-install || true'
+    echo 'echo; echo "Instalación finalizada.  [r] reiniciar  ·  [Enter] shell en vivo"'
+    echo 'read -r _a || _a=""'
+    echo 'case "$_a" in r|R) /bin/busybox reboot -f ;; *) exec /sbin/init ;; esac'
   else
     echo 'exec /sbin/init'
   fi