gioser

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…

⎇ main scripts/takana-live-install.sh Shell · 671 lines · 33.7 KB History raw

imagen e instalador: UTC por defecto — nacían SIN /etc/localtime fac9bb0b3 · Sergio ·

1#!/bin/sh
2# takana-install — instalador a disco DESDE el medio live (Etapa E5 / refinamiento #1).
3#
4# Se inyecta en el rootfs del producto como /usr/bin/takana-install (ver scripts/iso-image.sh
5# INSTALLER=1). Corre DENTRO del live, como root real (arje-zero es PID1) ⇒ NO necesita unshare -r
6# ni los rodeos rootless de install-image.sh: particiona, formatea, monta y copia con privilegios
7# reales, usando sólo herramientas que el producto ya trae (busybox fdisk/mke2fs/mount/dd + uutils cp).
8#
9# El sistema que se instala es EL PROPIO LIVE: la raíz en RAM se copia al disco (autoinstalador). El
10# payload de arranque (kernel + GRUB) viaja en /usr/lib/hammer/install/, armado en build-time (el host
11# tiene grub-mkimage; el live no lo necesita).
12#
13# Layout en disco (MBR, no GPT — más simple y busybox-friendly; GRUB va en el hueco post-MBR):
14#   /dev/<dev>1  /                ext2(montable ext4)   + /boot/bzImage + /boot/grub + wrapper /sbin/init
15#   /dev/<dev>2  /store           ext2
16#   /dev/<dev>3  /var/lib/hammer  ext2
17#
18# Cadena de arranque del disco resultante: BIOS → MBR (boot.img) → core.img (hueco post-MBR) →
19# (hd0,msdos1)/boot/grub/grub.cfg → linux /boot/bzImage root=/dev/<dev>1 → wrapper /sbin/init.
20#
21# Modo de uso:
22#   INTERACTIVO (sin argumentos, con TTY):  takana-install
23#       TUI en busybox sh: detecta discos, el usuario elige destino + confirma el borrado, recoge
24#       hostname / credencial de root / zona horaria / teclado / tamaños de partición, y recién ahí
25#       particiona+instala. Es la cara humana del instalador (el /init del ISO installer lo lanza).
26#   DESATENDIDO (con device):  takana-install /dev/vdb [ROOT_MB] [STORE_MB]
27#       Autoinstalador (lo usa iso-install-test.sh con AUTO_INSTALL). La config sale de env:
28#       TAKANA_HOSTNAME / TAKANA_ROOT_PW / TAKANA_ROOT_AUTHKEYS / TAKANA_TZ / TAKANA_KEYMAP.
29#       Los nombres viejos HAMMER_* se siguen aceptando (ADR 0016); gana el nuevo si están los dos.
30set -eu
31
32DEV="${1:-}"
33ROOT_MB="${2:-}"
34STORE_MB="${3:-}"        # /var/lib/hammer toma el resto
35PAYLOAD=/usr/lib/hammer/install
36BB="${BB:-/bin/busybox}"
37SYS_BLOCK="${SYS_BLOCK:-/sys/block}"   # override para tests
38MOUNTS="${MOUNTS:-/proc/mounts}"       # override para tests
39
40# bb: usa el busybox del live si existe; si no (p.ej. tests en el host), cae a las herramientas del PATH.
41# Sólo lo usan las funciones NUEVAS (config/TUI). Las ramas EFI/BIOS validadas siguen con "$BB" literal
42# y jamás corren en el host (el dry-run sale antes).
43bb() { if [ -x "$BB" ]; then "$BB" "$@"; else "$@"; fi; }
44
45_human() {  # bytes → humano
46    _b="$1"
47    if   [ "$_b" -ge 1073741824 ]; then printf '%s.%s GiB' "$((_b/1073741824))" "$(( (_b%1073741824)*10/1073741824 ))"
48    elif [ "$_b" -ge 1048576    ]; then printf '%s MiB' "$((_b/1048576))"
49    else printf '%s B' "$_b"; fi
50}
51
52_is_mounted() {  # ¿alguna partición del disco $1 aparece en MOUNTS? (heurística "es el medio live")
53    _d="$1"
54    while read -r _src _rest; do
55        case "$_src" in "/dev/$_d"|"/dev/$_d"[0-9]*|"/dev/$_d"p[0-9]*) return 0 ;; esac
56    done < "$MOUNTS" 2>/dev/null
57    return 1
58}
59
60list_disks() {  # emite por disco candidato:  name<TAB>bytes<TAB>model<TAB>mounted(0|1)
61    for _p in "$SYS_BLOCK"/*; do
62        [ -d "$_p" ] || continue
63        _n=${_p##*/}
64        case "$_n" in loop*|ram*|sr*|fd*|dm-*|md*|zram*) continue ;; esac
65        [ -r "$_p/size" ] || continue
66        _sz=$(cat "$_p/size" 2>/dev/null || echo 0); [ "$_sz" -gt 0 ] || continue
67        _model=$(cat "$_p/device/model" 2>/dev/null | tr -d '\n'); _model=$(printf '%s' "$_model" | sed 's/[[:space:]]*$//')
68        _m=0; _is_mounted "$_n" && _m=1
69        printf '%s\t%s\t%s\t%s\n' "$_n" "$((_sz*512))" "${_model:-disco}" "$_m"
70    done
71}
72
73ask() {  # ask VAR "prompt" "default"
74    if [ -n "$3" ]; then printf '%s [%s]: ' "$2" "$3"; else printf '%s: ' "$2"; fi
75    IFS= read -r _ans || _ans=""
76    [ -n "$_ans" ] || _ans="$3"
77    eval "$1=\$_ans"
78}
79
80read_secret() {  # read_secret VAR "prompt"  (sin eco si hay stty)
81    printf '%s: ' "$2"
82    bb stty -echo 2>/dev/null || true
83    IFS= read -r _sec || _sec=""
84    bb stty echo 2>/dev/null || true
85    echo
86    eval "$1=\$_sec"
87}
88
89have_cryptpw() { bb 2>&1 | grep -qw cryptpw; }
90
91# ── LA CUENTA DE QUIEN VA A USAR LA MÁQUINA (lib/cuenta.sh) ───────────────────────────────────────
92# Este instalador preguntaba hostname, zona, teclado y la credencial de ROOT — y ningún usuario. En
93# una imagen de escritorio eso es una máquina donde el greeter no puede autenticar a nadie, porque
94# no hay nadie. La sección es compartida con `takana-install-partition.sh` y vive en la librería:
95# son DOS caminos y UNA pregunta, el mismo argumento que escribe `lib/usuarios-del-home.sh`.
96#
97# ⚠ Se busca en dos sitios porque este script **se inyecta como `/usr/bin/takana-install`** dentro
98# del producto (`iso-image.sh`), donde `scripts/lib/` no existe: ahí la librería viaja a
99# `/usr/lib/takana/cuenta.sh`. Y si no viajó, no se muere el instalador — se dice en el resumen,
100# que es donde alguien lo va a leer antes de apretar.
101for _lib_cuenta in "$(dirname "$0")/lib/cuenta.sh" /usr/lib/takana/cuenta.sh; do
102    [ -r "$_lib_cuenta" ] && { . "$_lib_cuenta"; break; }
103done
104if ! command -v cuenta_preguntar >/dev/null 2>&1; then
105    cuenta_preguntar() { :; }
106    cuenta_aplicar() { :; }
107    cuenta_resumen() { printf 'NINGUNA — lib/cuenta.sh no viajó en este medio'; }
108fi
109
110# Reescribe el campo de contraseña de un usuario en $mnt/etc/shadow (o crea el fichero).
111_set_shadow() {
112    _u="$1"; _h="$2"; _f="$3/etc/shadow"
113    if [ ! -f "$_f" ]; then
114        bb mkdir -p "$3/etc"
115        printf '%s:%s:19000:0:99999:7:::\n' "$_u" "$_h" > "$_f"; bb chmod 600 "$_f"; return
116    fi
117    _t="$_f.hammer-tmp"
118    bb awk -F: -v u="$_u" -v h="$_h" 'BEGIN{OFS=":"}
119        { if ($1==u){ $2=h; seen=1 } print }
120        END{ if(!seen) print u,h,"19000","0","99999","7","","","" }' "$_f" > "$_t" && bb mv "$_t" "$_f"
121    bb chmod 600 "$_f"
122}
123
124# Aplica la config recogida al rootfs YA copiado y montado en $1 (llamada por ambas ramas tras la copia).
125apply_post_install_config() {
126    _mnt="$1"
127    bb mkdir -p "$_mnt/etc/hammer"
128    if [ -n "${CFG_HOSTNAME:-}" ]; then
129        printf '%s\n' "$CFG_HOSTNAME" > "$_mnt/etc/hostname"
130        if ! grep -q "[[:space:]]$CFG_HOSTNAME\$" "$_mnt/etc/hosts" 2>/dev/null; then
131            printf '127.0.0.1\tlocalhost %s\n::1\t\tlocalhost %s\n' "$CFG_HOSTNAME" "$CFG_HOSTNAME" >> "$_mnt/etc/hosts"
132        fi
133    fi
134    if [ -n "${CFG_ROOT_HASH:-}" ]; then _set_shadow root "$CFG_ROOT_HASH" "$_mnt"; fi
135    # La cuenta: usuario, su contraseña, su hogar, los grupos del hardware y la regla de doas. Lleva
136    # su propio brazo de control adentro y devuelve ≠0 si no quedó escrita — se dice fuerte, porque
137    # una cuenta que no quedó sólo se descubre al primer arranque, y arreglarla desde ahí pide montar
138    # el disco desde otro sistema.
139    cuenta_aplicar "$_mnt" || echo "  ⚠ LA CUENTA NO QUEDÓ ESCRITA: revisá /etc/passwd y /etc/shadow antes de reiniciar" >&2
140    if [ -n "${CFG_ROOT_AUTHKEYS:-}" ]; then
141        bb mkdir -p "$_mnt/root/.ssh"; bb chmod 700 "$_mnt/root/.ssh"
142        printf '%s\n' "$CFG_ROOT_AUTHKEYS" >> "$_mnt/root/.ssh/authorized_keys"
143        bb chmod 600 "$_mnt/root/.ssh/authorized_keys"
144    fi
145    # ⚠ **UTC POR DEFECTO, no "sin zona".** Antes esto estaba guardado por `[ -n "$CFG_TZ" ]` a
146    # secas, así que quien no elegía zona en el TUI se quedaba SIN `/etc/localtime`. Parece
147    # inofensivo —musl asume UTC y la hora sale bien— pero cada `localtime()` se come un
148    # `open("/etc/localtime") = -1 ENOENT` que deja `errno` sucio, y cualquier error posterior que
149    # reporte `errno` sin ser un fallo de `errno` miente con esa cadena. Medido el 2026-10-21:
150    # `makewhatis` abortaba por otro motivo y culpaba a «No such file or directory»; con `TZ=UTC`
151    # el mismo fallo decía «No error information». Horas de diagnóstico por un fichero ausente.
152    # ⇒ Poner UTC no cambia el comportamiento (es lo que ya se asumía) y quita la trampa.
153    _tz="${CFG_TZ:-UTC}"
154    [ -e "$_mnt/usr/share/zoneinfo/$_tz" ] || _tz=UTC
155    if [ -e "$_mnt/usr/share/zoneinfo/$_tz" ]; then
156        bb ln -sf "/usr/share/zoneinfo/$_tz" "$_mnt/etc/localtime"
157        printf '%s\n' "$_tz" > "$_mnt/etc/timezone"
158    else
159        # Sin zoneinfo en el destino un enlace apuntaría a la nada, que es PEOR que no tenerlo.
160        echo "  ⚠ sin /etc/localtime: el sistema instalado no trae /usr/share/zoneinfo (¿falta tzdata?)" >&2
161    fi
162    _ts=$(bb date -u +%FT%TZ 2>/dev/null || echo unknown)
163    {
164        echo "# escrito por takana-install en $_ts"
165        echo "HOSTNAME=${CFG_HOSTNAME:-}"
166        echo "TIMEZONE=${CFG_TZ:-}"
167        echo "KEYMAP=${CFG_KEYMAP:-}"     # best-effort: el producto aún no trae kbd/loadkeys
168        echo "TARGET=${DEV:-}"
169    } > "$_mnt/etc/hammer/install.conf"
170}
171
172# ---- recolección de config: interactiva (TUI) o desde env (desatendido) ----
173CFG_HOSTNAME="${TAKANA_HOSTNAME:-${HAMMER_HOSTNAME:-takana}}"
174CFG_ROOT_AUTHKEYS="${TAKANA_ROOT_AUTHKEYS:-${HAMMER_ROOT_AUTHKEYS:-}}"
175CFG_ROOT_HASH="${TAKANA_ROOT_HASH:-${HAMMER_ROOT_HASH:-}}"
176CFG_TZ="${TAKANA_TZ:-${HAMMER_TZ:-}}"
177CFG_KEYMAP="${TAKANA_KEYMAP:-${HAMMER_KEYMAP:-us}}"
178
179interactive_setup() {
180    echo
181    echo "   #  instalador de hammer"
182    echo "   ─────────────────────────"
183    echo
184    _disks=$(list_disks)
185    [ -n "$_disks" ] || { echo "No detecté discos instalables (¿el medio live es el único dispositivo?)."; exit 1; }
186    echo "Discos detectados:"
187    OLDIFS=$IFS; IFS='
188'; set --
189    for _l in $_disks; do set -- "$@" "$_l"; done
190    IFS=$OLDIFS
191    _i=1
192    for _l in "$@"; do
193        _n=$(printf '%s' "$_l" | cut -f1); _by=$(printf '%s' "$_l" | cut -f2)
194        _mo=$(printf '%s' "$_l" | cut -f3); _mm=$(printf '%s' "$_l" | cut -f4)
195        _w=""; [ "$_mm" = 1 ] && _w="   ⚠ montado (¿medio live?)"
196        printf '  %s) /dev/%-8s %10s   %s%s\n' "$_i" "$_n" "$(_human "$_by")" "$_mo" "$_w"
197        _i=$((_i+1))
198    done
199    _total=$#
200    while :; do
201        ask _sel "Disco destino (1-$_total)" ""
202        case "$_sel" in ''|*[!0-9]*) echo "  número inválido"; continue ;; esac
203        [ "$_sel" -ge 1 ] && [ "$_sel" -le "$_total" ] && break
204        echo "  fuera de rango"
205    done
206    eval "_line=\${$_sel}"
207    _name=$(printf '%s' "$_line" | cut -f1); _bytes=$(printf '%s' "$_line" | cut -f2)
208    DEV="/dev/$_name"
209    _disk_mb=$((_bytes/1048576))
210
211    # --- tamaños de partición ---
212    _esp=64; _slack=16; _avail=$((_disk_mb - _esp - _slack))
213    [ "$_avail" -ge 600 ] || { echo "Disco demasiado chico ($(_human "$_bytes")); se necesitan ~700 MiB."; exit 1; }
214    _defr=4096; [ "$_defr" -gt $((_avail*6/10)) ] && _defr=$((_avail*6/10))
215    _defs=1024; [ "$_defs" -gt $((_avail/5)) ] && _defs=$((_avail/5))
216    while :; do
217        ask ROOT_MB  "Tamaño de / en MB"      "$_defr"
218        ask STORE_MB "Tamaño de /store en MB" "$_defs"
219        case "$ROOT_MB$STORE_MB" in *[!0-9]*) echo "  usá sólo números"; continue ;; esac
220        if [ $((ROOT_MB + STORE_MB + _esp + _slack)) -le "$_disk_mb" ]; then break; fi
221        echo "  no entra en el disco ($_disk_mb MiB) — / + /store + ESP deben sumar menos"
222    done
223
224    # --- identidad ---
225    ask CFG_HOSTNAME "Hostname" "${CFG_HOSTNAME:-hammer}"
226    echo "Credencial de root:"
227    echo "  1) contraseña"
228    echo "  2) clave SSH (authorized_keys)"
229    echo "  3) omitir"
230    ask _cred "Opción" "1"
231    case "$_cred" in
232        1)  if have_cryptpw; then
233                while :; do
234                    read_secret _pw1 "Contraseña de root"
235                    read_secret _pw2 "Repetir"
236                    [ "$_pw1" = "$_pw2" ] && [ -n "$_pw1" ] && break
237                    echo "  no coinciden (o vacía) — reintentá"
238                done
239                CFG_ROOT_HASH=$(printf '%s' "$_pw1" | bb cryptpw -m sha512 2>/dev/null || true)
240                [ -n "$CFG_ROOT_HASH" ] || echo "  ⚠ no pude generar el hash; root queda como está"
241            else
242                echo "  (cryptpw no disponible en este busybox — usá la opción de clave SSH)"
243                ask CFG_ROOT_AUTHKEYS "Pegá una línea authorized_keys" ""
244            fi ;;
245        2)  ask CFG_ROOT_AUTHKEYS "Pegá una línea authorized_keys (o ruta a un fichero .pub)" ""
246            if [ -n "$CFG_ROOT_AUTHKEYS" ] && [ -f "$CFG_ROOT_AUTHKEYS" ]; then
247                CFG_ROOT_AUTHKEYS=$(cat "$CFG_ROOT_AUTHKEYS")
248            fi ;;
249        *)  echo "  (root sin credencial nueva)" ;;
250    esac
251
252    # --- la cuenta de quien va a usar la máquina ---
253    # Va después de la credencial de root y le pasa lo ya decidido, para no preguntar dos veces por
254    # lo mismo: la librería sólo insiste con root si NADIE puede escalar, que es la única
255    # combinación que deja una máquina inadministrable.
256    CUENTA_ROOT_HASH="$CFG_ROOT_HASH"
257    # La raíz que se MIRA para saber si hay con qué escalar. En producción es "/" —el medio live es
258    # el sistema que se copia—; en el arnés es el rootfs de scratch, porque si no el test
259    # dependería de si la máquina que lo corre tiene sudo instalado.
260    cuenta_preguntar "${TAKANA_INSTALL_TESTROOT:-${HAMMER_INSTALL_TESTROOT:-/}}"
261    CFG_ROOT_HASH="$CUENTA_ROOT_HASH"
262
263    # --- locale (best-effort) ---
264    _zi="${ZONEINFO:-/usr/share/zoneinfo}"
265    if [ -d "$_zi" ]; then
266        ask CFG_TZ "Zona horaria (ej. America/La_Paz; vacío = UTC)" "${CFG_TZ:-}"
267        if [ -n "$CFG_TZ" ] && [ ! -e "$_zi/$CFG_TZ" ]; then
268            echo "  ⚠ zona desconocida, se ignora"; CFG_TZ=""
269        fi
270    else
271        echo "(tzdata no instalado — la zona horaria se omite)"; CFG_TZ=""
272    fi
273    ask CFG_KEYMAP "Layout de teclado" "${CFG_KEYMAP:-us}"
274
275    # --- resumen + confirmación fuerte ---
276    _cd="sin cambios"; [ -n "$CFG_ROOT_HASH" ] && _cd="contraseña"; [ -n "$CFG_ROOT_AUTHKEYS" ] && _cd="clave SSH"
277    echo
278    echo "Resumen de instalación:"
279    echo "  destino : $DEV   ($(_human "$_bytes"))"
280    echo "  / = ${ROOT_MB} MiB    /store = ${STORE_MB} MiB    /var/lib/hammer = resto"
281    echo "  hostname: $CFG_HOSTNAME"
282    echo "  root    : $_cd"
283    echo "  usuario : $(cuenta_resumen)"
284    echo "  zona    : ${CFG_TZ:-UTC}    teclado: $CFG_KEYMAP"
285    echo
286    echo "  ⚠  ESTO BORRA TODO EN $DEV."
287    ask _ok "Escribí el nombre del disco ($DEV) para confirmar, o Enter para cancelar" ""
288    [ "$_ok" = "$DEV" ] || { echo "Cancelado — no se tocó ningún disco."; exit 1; }
289}
290
291# --- resolución del modo: interactivo (sin device + TTY) vs desatendido (device dado) ---
292if [ -z "$DEV" ]; then
293    if [ -t 0 ] || [ "${TAKANA_INSTALL_INTERACTIVE:-${HAMMER_INSTALL_INTERACTIVE:-}}" = 1 ]; then
294        interactive_setup
295    else
296        echo "uso: takana-install <device> [ROOT_MB] [STORE_MB]   (o sin args con un TTY para el modo interactivo)" >&2
297        exit 2
298    fi
299fi
300ROOT_MB="${ROOT_MB:-1500}"
301STORE_MB="${STORE_MB:-400}"
302# En modo desatendido con contraseña por env, derivar el hash acá (interactivo ya lo hizo).
303if [ -z "$CFG_ROOT_HASH" ] && [ -n "${TAKANA_ROOT_PW:-${HAMMER_ROOT_PW:-}}" ] && have_cryptpw; then
304    CFG_ROOT_HASH=$(printf '%s' "${TAKANA_ROOT_PW:-$HAMMER_ROOT_PW}" | bb cryptpw -m sha512 2>/dev/null || true)
305fi
306
307# --- gancho de prueba: valida TUI + config sin tocar disco (TAKANA_INSTALL_DRYRUN=1) ---
308if [ "${TAKANA_INSTALL_DRYRUN:-${HAMMER_INSTALL_DRYRUN:-}}" = 1 ]; then
309    echo "== DRYRUN: plan de instalación =="
310    echo "  DEV=$DEV  ROOT_MB=$ROOT_MB  STORE_MB=$STORE_MB"
311    echo "  HOSTNAME=$CFG_HOSTNAME  TZ=${CFG_TZ:-UTC}  KEYMAP=$CFG_KEYMAP"
312    echo "  ROOT_HASH=$([ -n "$CFG_ROOT_HASH" ] && echo set || echo -)  AUTHKEYS=$([ -n "$CFG_ROOT_AUTHKEYS" ] && echo set || echo -)"
313    _testroot="${TAKANA_INSTALL_TESTROOT:-${HAMMER_INSTALL_TESTROOT:-}}"
314    if [ -n "$_testroot" ]; then
315        apply_post_install_config "$_testroot"
316        echo "  config aplicada a $_testroot"
317    fi
318    exit 0
319fi
320
321[ -b "$DEV" ] || { echo "takana-install: $DEV no es un block device" >&2; exit 1; }
322[ -d "$PAYLOAD" ] || { echo "takana-install: falta el payload $PAYLOAD (¿ISO sin INSTALLER=1?)" >&2; exit 1; }
323
324# --- Detección de firmware: UEFI ⇒ rama EFI-stub SOBERANA (ADR 0010 paso 5); BIOS ⇒ MBR+GRUB (abajo) ---
325# La rama EFI usa una ESP tipo 0xEF en tabla MBR (UEFI arranca \EFI\BOOT\BOOTX64.EFI de una ESP MBR igual
326# que de GPT) ⇒ alcanza con busybox fdisk/mkfs.vfat/mount, sin GPT-tool ni mtools. El bzImage metal ES el
327# binario EFI (EFI-stub, cmdline horneada carga el initrd); un initramfs mínimo pivota por LABEL. Mismos
328# artefactos que scripts/install-image-efi.sh (validado en OVMF), armados desde dentro del live.
329if [ -d /sys/firmware/efi ]; then
330    [ -r "$PAYLOAD/kernel/bzImage-efi" ] || { echo "takana-install: live en UEFI pero falta $PAYLOAD/kernel/bzImage-efi (ISO sin kernel metal)" >&2; exit 1; }
331    ESP_MB="${ESP_MB:-64}"
332    echo "==> takana-install (UEFI): EFI-stub soberano en $DEV (ESP ${ESP_MB}M, root ${ROOT_MB}M, store ${STORE_MB}M, estado=resto)"
333    echo "    ¡esto BORRA $DEV!"
334
335    # Informe del terreno ANTES de tocar nada (ADR 0018 §4). Esta rama se lleva el disco ENTERO, así
336    # que lo que hay que decir en voz alta no es sólo «cuánto espacio hay»: es **con qué se va a
337    # llevar puesto**. Si en la ESP de este disco hay un `\EFI\Microsoft`, el survey lo nombra, y el
338    # usuario se entera ANTES y no después de que su Windows dejó de arrancar.
339    #
340    # No escribe nada y no puede fallar el instalador: `|| true`. Su trabajo es informar.
341    if [ -x "$PAYLOAD/hammer" ]; then
342        echo "==> terreno actual de $DEV (nada de esto se ha tocado todavía):"
343        "$PAYLOAD/hammer" boot entry survey --disk "$DEV" 2>&1 | "$BB" sed 's/^/    /' || true
344    fi
345
346    # MBR, 4 primarias: p1=ESP(0xEF) p2=root p3=store p4=estado. Sectores explícitos (como la rama BIOS).
347    SPM=2048
348    P1S=2048;            P1E=$(( P1S + ESP_MB  * SPM - 1 ))
349    P2S=$(( P1E + 1 ));  P2E=$(( P2S + ROOT_MB * SPM - 1 ))
350    P3S=$(( P2E + 1 ));  P3E=$(( P3S + STORE_MB* SPM - 1 ))
351    P4S=$(( P3E + 1 ))   # estado = resto del disco
352    echo "==> particionando $DEV (MBR: ESP ${P1S}-${P1E} tipo ef, root ${P2S}-${P2E}, store ${P3S}-${P3E}, estado ${P4S}-fin)"
353    "$BB" fdisk "$DEV" >/dev/null 2>&1 <<FDISK || true
354o
355n
356p
3571
358$P1S
359$P1E
360n
361p
3622
363$P2S
364$P2E
365n
366p
3673
368$P3S
369$P3E
370n
371p
3724
373$P4S
374
375t
3761
377ef
378a
3791
380w
381FDISK
382    "$BB" partprobe "$DEV" 2>/dev/null || true
383    "$BB" sync; sleep 1
384    P1="${DEV}1"; P2="${DEV}2"; P3="${DEV}3"; P4="${DEV}4"
385    for p in "$P1" "$P2" "$P3" "$P4"; do
386        [ -b "$p" ] || { echo "takana-install: no apareció la partición $p tras particionar" >&2; exit 1; }
387    done
388
389    echo "==> formateando (ESP FAT32 + 3 ext2 etiquetadas para el pivote por LABEL)"
390    "$BB" mkfs.vfat -F 32 -n HAMMER "$P1" >/dev/null 2>&1
391    "$BB" mke2fs -F -L hammer-root  "$P2" >/dev/null 2>&1
392    "$BB" mke2fs -F -L hammer-store "$P3" >/dev/null 2>&1
393    "$BB" mke2fs -F -L hammer-state "$P4" >/dev/null 2>&1
394
395    # initramfs de pivote (busybox estático del live + /init: findfs LABEL=takana-root → switch_root).
396    echo "==> armando el initramfs de pivote (busybox + /init switch_root a hammer-root)"
397    IRD_TREE=/run/hammer-efi-ird
398    "$BB" rm -rf "$IRD_TREE"
399    "$BB" mkdir -p "$IRD_TREE/bin" "$IRD_TREE/dev" "$IRD_TREE/proc" "$IRD_TREE/sys" "$IRD_TREE/newroot"
400    cp "$BB" "$IRD_TREE/bin/busybox"; "$BB" chmod 0755 "$IRD_TREE/bin/busybox"
401    for a in sh mount umount findfs switch_root mkdir echo sleep; do "$BB" ln -sf busybox "$IRD_TREE/bin/$a"; done
402    cat > "$IRD_TREE/init" <<'INIT'
403#!/bin/busybox sh
404export PATH=/bin
405mount -t proc     proc /proc 2>/dev/null || true
406mount -t sysfs    sys  /sys  2>/dev/null || true
407mount -t devtmpfs dev  /dev  2>/dev/null || true
408log() { echo "$*"; echo "$*" > /dev/ttyS0 2>/dev/null || true; }
409log "HAMMER-EFI: initramfs de pivote — resolviendo hammer-root por LABEL"
410ROOTDEV=""
411for _try in 1 2 3 4 5; do
412    ROOTDEV="$(findfs LABEL=hammer-root 2>/dev/null || true)"
413    [ -n "$ROOTDEV" ] && break
414    log "HAMMER-EFI: hammer-root aún no visible, reintento $_try…"; sleep 1
415done
416[ -n "$ROOTDEV" ] || { log "HAMMER-EFI-FALLO: no encuentro LABEL=hammer-root"; exec sh; }
417log "HAMMER-EFI: hammer-root = $ROOTDEV → montando en /newroot"
418mount -t ext4 "$ROOTDEV" /newroot || { log "HAMMER-EFI-FALLO: no pude montar $ROOTDEV"; exec sh; }
419STOREDEV="$(findfs LABEL=hammer-store 2>/dev/null || true)"
420STATEDEV="$(findfs LABEL=hammer-state 2>/dev/null || true)"
421[ -n "$STOREDEV" ] && mount -t ext4 "$STOREDEV" /newroot/store          2>/dev/null || true
422[ -n "$STATEDEV" ] && mount -t ext4 "$STATEDEV" /newroot/var/lib/hammer  2>/dev/null || true
423log "HAMMER-EFI-DISK-PIVOT-OK: switch_root → /sbin/init"
424exec switch_root /newroot /sbin/init
425INIT
426    "$BB" chmod +x "$IRD_TREE/init"
427    ( cd "$IRD_TREE" && "$BB" find . | "$BB" cpio -o -H newc 2>/dev/null | "$BB" gzip -1 ) > /run/hammer-efi-ird.cpio.gz
428
429    # Poblar la ESP con el kernel en DOS rutas (ADR 0018 §3), porque una sola es un punto único de
430    # fallo que NO controlamos:
431    #   \EFI\takana\takanax64.efi  el vendor path — nuestro, no lo reclama nadie más.
432    #   \EFI\BOOT\BOOTX64.EFI      la ruta FALLBACK de medios removibles (spec UEFI): tierra de nadie
433    #                                y lo único que arranca SIN entrada NVRAM, que es lo que hay hoy
434    #                                (takana todavía no crea entradas: hace falta efibootmgr/efivar).
435    # Es el mismo fichero dos veces: cuesta bytes de ESP, no complejidad.
436    echo "==> poblando la ESP (\\EFI\\takana\\takanax64.efi + \\EFI\\BOOT\\BOOTX64.EFI = kernel metal + \\initramfs.cpio.gz)"
437    ESPMNT=/run/hammer-esp
438    "$BB" mkdir -p "$ESPMNT"
439    "$BB" mount -t vfat "$P1" "$ESPMNT"
440    "$BB" mkdir -p "$ESPMNT/EFI/BOOT" "$ESPMNT/EFI/takana"
441
442    # Guardián de capacidad (ADR 0018 §4): comprobar ANTES, con los números a la vista. Un `cp` que
443    # se queda sin sitio en FAT32 deja un fichero TRUNCADO y devuelve error tarde — y un BOOTX64.EFI
444    # a medias no falla: arranca y muere en el firmware, que es la peor forma de enterarse.
445    _k_kb=$( "$BB" du -k "$PAYLOAD/kernel/bzImage-efi" | "$BB" awk '{print $1}' )
446    _i_kb=$( "$BB" du -k /run/hammer-efi-ird.cpio.gz   | "$BB" awk '{print $1}' )
447    _free_kb=$( "$BB" df -k "$ESPMNT" | "$BB" awk 'NR==2{print $4}' )
448    _need_kb=$(( _k_kb * 2 + _i_kb + 256 ))
449    echo "    kernel $(( _k_kb / 1024 ))M x2 + initramfs $(( _i_kb / 1024 ))M = $(( _need_kb / 1024 ))M sobre $(( _free_kb / 1024 ))M libres en la ESP"
450    if [ "$_need_kb" -gt "$_free_kb" ]; then
451        "$BB" umount "$ESPMNT"
452        echo "TAKANA-INSTALL-FAIL: no entra en la ESP — hacen falta $(( _need_kb / 1024 ))M y hay $(( _free_kb / 1024 ))M libres." >&2
453        echo "                     subí ESP_MB (actual ${ESP_MB}M) o liberá la ESP." >&2
454        exit 1
455    fi
456
457    cp "$PAYLOAD/kernel/bzImage-efi" "$ESPMNT/EFI/takana/takanax64.efi"
458    cp "$PAYLOAD/kernel/bzImage-efi" "$ESPMNT/EFI/BOOT/BOOTX64.EFI"
459    cp /run/hammer-efi-ird.cpio.gz   "$ESPMNT/initramfs.cpio.gz"
460    "$BB" sync
461    # Verificar las DOS rutas y el initramfs por TAMAÑO, no por presencia: un `cp` truncado deja el
462    # fichero ahí y `test -e` diría que todo salió bien (regla 3 de CLAUDE.md).
463    for _pair in "EFI/takana/takanax64.efi:$_k_kb" "EFI/BOOT/BOOTX64.EFI:$_k_kb" "initramfs.cpio.gz:$_i_kb"; do
464        _f="${_pair%:*}"; _want="${_pair##*:}"
465        _got=$( "$BB" du -k "$ESPMNT/$_f" 2>/dev/null | "$BB" awk '{print $1}' )
466        [ "${_got:-0}" = "$_want" ] || {
467            "$BB" umount "$ESPMNT"
468            echo "TAKANA-INSTALL-FAIL: $_f quedó en ${_got:-0}K, se esperaban ${_want}K (ESP llena o cp truncado)" >&2
469            exit 1
470        }
471    done
472    echo "    ESP verificada: las dos rutas del kernel y el initramfs con su tamaño completo"
473    "$BB" umount "$ESPMNT"
474
475    # Poblar la root (copia del live) — sin wrapper que monte particiones (el pivote ya montó store/estado).
476    echo "==> copiando la raíz del live → $P2 (excluye virtuales/efímeros, /store, /var/lib/hammer, payload)"
477    MNT=/run/takana-install
478    "$BB" mkdir -p "$MNT"
479    "$BB" mount -t ext4 "$P2" "$MNT"
480    for entry in /* /.[!.]*; do
481        [ -e "$entry" ] || continue
482        name="${entry##*/}"
483        case "$name" in
484            proc|sys|dev|run|tmp|store|init) continue ;;
485            *) cp -a "$entry" "$MNT/" 2>/dev/null || true ;;
486        esac
487    done
488    "$BB" rm -rf "$MNT/var/lib/hammer" 2>/dev/null || true
489    "$BB" rm -rf "$MNT/usr/lib/hammer/install" 2>/dev/null || true
490    "$BB" mkdir -p "$MNT/store" "$MNT/var/lib/hammer" "$MNT/proc" "$MNT/sys" "$MNT/dev" "$MNT/run" "$MNT/tmp"
491    # /sbin/init: wrapper que auto-sana un upgrade interrumpido y arranca arje-zero (el pivote ya montó
492    # store/estado por LABEL, así que acá NO hace falta montar nada — sólo hammer-recover + exec).
493    if [ -x "$PAYLOAD/hammer-recover" ]; then
494        "$BB" mkdir -p "$MNT/usr/sbin"
495        cp "$PAYLOAD/hammer-recover" "$MNT/usr/sbin/hammer-recover"; "$BB" chmod 0755 "$MNT/usr/sbin/hammer-recover"
496    fi
497    # CLI takana (static musl) para el menú de arranque por grafo (ADR 0010): lo copia al disco desde el
498    # payload; el wrapper lo invoca como `takana boot menu`.
499    if [ -x "$PAYLOAD/hammer" ]; then
500        cp "$PAYLOAD/hammer" "$MNT/usr/bin/hammer"; "$BB" chmod 0755 "$MNT/usr/bin/hammer"
501    fi
502    "$BB" rm -f "$MNT/sbin/init"
503    cat > "$MNT/sbin/init" <<'INIT'
504#!/bin/sh
505# Wrapper PID1 (rama UEFI): el initramfs de pivote ya montó / /store /var/lib/hammer por LABEL antes del
506# switch_root; sólo auto-sanamos un upgrade interrumpido y arrancamos el init real. El marcador a
507# /dev/ttyS0 sobrevive `quiet` (que suprime el printk kernel-side) ⇒ prueba serie de que llegó a PID1.
508# busybox switch_root NO mueve /dev ⇒ montamos devtmpfs antes (si no, /dev/ttyS0 aún no existe).
509/bin/busybox mount -t devtmpfs dev /dev 2>/dev/null || true
510echo "HAMMER-EFI-INIT-OK: arje-zero PID1" > /dev/ttyS0 2>/dev/null || true
511[ -x /usr/sbin/hammer-recover ] && /usr/sbin/hammer-recover || true
512# Menú de arranque por grafo (ADR 0010): emite /run/hammer/boot-graph.json y, si hay compositor (mirada),
513# lo pinta sobre KMS. Graceful (sin compositor sigue) y al serial (no pinta tty0 ⇒ cero-parpadeo).
514[ -x /usr/bin/hammer ] && /usr/bin/hammer boot menu > /dev/ttyS0 2>&1 || true
515# efivarfs: sin montarlo la NVRAM del firmware no se ve. El directorio sólo existe si arrancamos por
516# UEFI, así que el `[ -d ]` distingue UEFI de BIOS sin preguntarle a nadie.
517# switch_root no arrastra /sys (ni /dev): sin esto el directorio de efivars no existe y el
518# reconciliador diagnostica «no arrancó por UEFI» en una máquina que sí lo hizo.
519/bin/busybox mount -t sysfs sys /sys 2>/dev/null || true
520[ -d /sys/firmware/efi/efivars ] && /bin/busybox mount -t efivarfs efivarfs /sys/firmware/efi/efivars 2>/dev/null || true
521# Reconciliador del arranque (ADR 0018 §2): la NVRAM es estado compartido y el vecino la reescribe
522# sin avisar. Converger en cada arranque, y DECIRLO en pantalla cuando hubo que actuar — si repara
523# en silencio, el usuario nunca se entera de que algo le está pisando el arranque.
524[ -x /usr/bin/hammer ] && /bin/busybox timeout 15 /usr/bin/hammer boot entry reconcile 2>&1 \
525    | /bin/busybox tee /dev/tty0 > /dev/ttyS0 2>/dev/null || true
526
527# Respaldo del arranque (ADR 0018 §5). Se hace DESPUÉS de reconciliar, así lo que queda guardado es
528# el estado bueno y no el que dejó el vecino. El nombre del fichero sale del CONTENIDO: un arranque
529# que no cambió produce el mismo fichero, así que esto corre en cada arranque sin llenar la
530# partición de estado con copias idénticas.
531#
532# Vive en /var/lib/hammer, que en las imágenes es su propia partición y sobrevive a una reinstalación
533# del sistema. **No va al store**: el store lo barre `store-gc.sh`, que clasifica por nombre de
534# receta — un respaldo ahí sería «huérfano» y se borraría en el primer `--huerfanos`.
535[ -x /usr/bin/hammer ] && /bin/busybox timeout 15 /usr/bin/hammer boot entry backup > /dev/ttyS0 2>&1 || true
536exec /usr/bin/arje-zero
537INIT
538    "$BB" chmod +x "$MNT/sbin/init"
539    # config del usuario (hostname / root / zona / teclado) recogida al inicio
540    apply_post_install_config "$MNT"
541    "$BB" sync
542    "$BB" umount "$MNT"
543
544    echo "✓ takana-install (UEFI): $DEV instalado con EFI-stub SOBERANO (sin GRUB ni systemd-boot). Reiniciá sin el medio live."
545    exit 0
546fi
547
548# ===================== rama BIOS (MBR + GRUB) — el firmware NO es UEFI ==============================
549[ -r "$PAYLOAD/grub/boot.img" ] && [ -r "$PAYLOAD/grub/core.img" ] || { echo "takana-install: payload GRUB incompleto" >&2; exit 1; }
550[ -r "$PAYLOAD/kernel/bzImage" ] || { echo "takana-install: falta el kernel en el payload" >&2; exit 1; }
551
552echo "==> takana-install: instalando el sistema live en $DEV (root ${ROOT_MB}M, store ${STORE_MB}M, estado=resto)"
553echo "    ¡esto BORRA $DEV!"
554
555# --- particionar MBR con busybox fdisk (3 primarias; p1 bootable) ---
556# Pasamos sectores de inicio/fin EXPLÍCITOS para las tres: busybox fdisk, al pedir el inicio por
557# defecto, ofrece el sector LIBRE MÁS BAJO (= el hueco 63..2047 que dejamos antes de p1) ⇒ con <enter>
558# colocaría p2 en ese hueco. Por eso computamos los sectores y los tecleamos.
559#   p1 ARRANCA EN 2048 (1 MiB) A PROPÓSITO: deja un hueco post-MBR de 2047 sectores para el core.img de
560#   GRUB (~281 sectores). El default CHS de busybox (sector 63 ⇒ hueco de 62) lo DESBORDA, pisando el
561#   filesystem de p1 ⇒ GRUB cae a `grub>`.
562SPM=2048
563P1S=2048;                  P1E=$(( P1S + ROOT_MB  * SPM - 1 ))
564P2S=$(( P1E + 1 ));        P2E=$(( P2S + STORE_MB * SPM - 1 ))
565P3S=$(( P2E + 1 ))         # p3 toma hasta el final del disco (último sector = default)
566echo "==> particionando $DEV (MBR: p1 ${P1S}-${P1E}, p2 ${P2S}-${P2E}, p3 ${P3S}-fin)"
567"$BB" fdisk "$DEV" >/dev/null 2>&1 <<FDISK || true
568o
569n
570p
5711
572$P1S
573$P1E
574n
575p
5762
577$P2S
578$P2E
579n
580p
5813
582$P3S
583
584a
5851
586w
587FDISK
588"$BB" partprobe "$DEV" 2>/dev/null || true
589"$BB" sync; sleep 1
590
591P1="${DEV}1"; P2="${DEV}2"; P3="${DEV}3"
592for p in "$P1" "$P2" "$P3"; do
593    [ -b "$p" ] || { echo "takana-install: no apareció la partición $p tras particionar" >&2; exit 1; }
594done
595
596# --- formatear (busybox mke2fs = ext2; el driver ext4 del kernel lo monta) ---
597echo "==> formateando particiones (ext2, montables como ext4)"
598"$BB" mke2fs -F -L hammer-root  "$P1" >/dev/null 2>&1
599"$BB" mke2fs -F -L hammer-store "$P2" >/dev/null 2>&1
600"$BB" mke2fs -F -L hammer-state "$P3" >/dev/null 2>&1
601
602# --- copiar la raíz del live a la partición root, excluyendo virtuales + el payload ---
603MNT=/run/takana-install
604"$BB" mkdir -p "$MNT"
605"$BB" mount -t ext4 "$P1" "$MNT"
606echo "==> copiando la raíz del live → $P1 (excluye virtuales/efímeros, /store, /var/lib/hammer y el payload)"
607# Enumeramos TODO el top-level de / y copiamos salvo lo virtual/efímero — así no se nos escapa ningún
608# dir del producto (p.ej. /ente con la seed.card, /linuxrc). uutils cp -a preserva modos/symlinks.
609for entry in /* /.[!.]*; do
610    [ -e "$entry" ] || continue
611    name="${entry##*/}"
612    case "$name" in
613        proc|sys|dev|run|tmp|store|init) continue ;;  # virtuales/efímeros + el /init marcador del live
614        *) cp -a "$entry" "$MNT/" 2>/dev/null || true ;;
615    esac
616done
617# /var sin /var/lib/hammer (estado) — se recrea vacío como mountpoint.
618"$BB" rm -rf "$MNT/var/lib/hammer" 2>/dev/null || true
619# Quitar el payload del disco instalado (sólo sirve en el live) y recrear mountpoints.
620"$BB" rm -rf "$MNT/usr/lib/hammer/install" 2>/dev/null || true
621"$BB" mkdir -p "$MNT/store" "$MNT/var/lib/hammer" "$MNT/proc" "$MNT/sys" "$MNT/dev" "$MNT/run" "$MNT/tmp" "$MNT/boot/grub/i386-pc"
622
623# --- /boot: kernel + módulos GRUB + grub.cfg, y wrapper /sbin/init (monta las particiones dedicadas) ---
624echo "==> poblando /boot (kernel + GRUB) y wrapper /sbin/init"
625cp "$PAYLOAD/kernel/bzImage" "$MNT/boot/bzImage"; "$BB" chmod 0644 "$MNT/boot/bzImage"
626cp "$PAYLOAD"/grub/i386-pc/*.mod "$MNT/boot/grub/i386-pc/" 2>/dev/null || true
627cp "$PAYLOAD"/grub/i386-pc/*.lst "$MNT/boot/grub/i386-pc/" 2>/dev/null || true
628ROOTDEV="${P1##*/}"   # p.ej. vda1 cuando el disco instalado sea el único
629cat > "$MNT/boot/grub/grub.cfg" <<CFG
630set timeout=2
631set default=0
632serial --unit=0 --speed=115200
633terminal_input serial console
634terminal_output serial console
635insmod part_msdos
636insmod ext2
637menuentry "hammer" {
638    linux /boot/bzImage console=ttyS0 root=/dev/vda1 rw rdinit=/sbin/init
639}
640CFG
641# hammer-recover: auto-sana un upgrade interrumpido al arranque (E4 #4b). Lo dejamos en /usr/sbin del
642# disco; el wrapper lo corre tras montar /store y /var/lib/hammer (que necesita) y antes de incarnar.
643if [ -x "$PAYLOAD/hammer-recover" ]; then
644    "$BB" mkdir -p "$MNT/usr/sbin"
645    cp "$PAYLOAD/hammer-recover" "$MNT/usr/sbin/hammer-recover"; "$BB" chmod 0755 "$MNT/usr/sbin/hammer-recover"
646fi
647"$BB" rm -f "$MNT/sbin/init"
648cat > "$MNT/sbin/init" <<'INIT'
649#!/bin/sh
650# Wrapper PID1: el kernel monta la partición root; montamos las dedicadas, auto-sanamos un upgrade
651# interrumpido (si lo hay) y arrancamos el init real.
652/bin/busybox mount -t ext4 /dev/vda2 /store          || echo "init: no pude montar /store (/dev/vda2)"
653/bin/busybox mount -t ext4 /dev/vda3 /var/lib/hammer  || echo "init: no pude montar /var/lib/hammer (/dev/vda3)"
654[ -x /usr/sbin/hammer-recover ] && /usr/sbin/hammer-recover || true
655exec /usr/bin/arje-zero
656INIT
657"$BB" chmod +x "$MNT/sbin/init"
658# config del usuario (hostname / root / zona / teclado) recogida al inicio
659apply_post_install_config "$MNT"
660"$BB" sync
661"$BB" umount "$MNT"
662
663# --- instalar GRUB: boot.img→MBR (440 B, sin pisar la tabla de particiones) + core.img→hueco post-MBR.
664#     Con install contiguo MBR los punteros por defecto de GRUB (kernel_sector=1, blocklist=2) ya valen
665#     ⇒ NO hace falta el parcheo binario que sí pide el caso GPT de install-image.sh.
666echo "==> instalando GRUB (boot.img→MBR + core.img→hueco post-MBR)"
667"$BB" dd if="$PAYLOAD/grub/boot.img"  of="$DEV" bs=1 count=440 conv=notrunc 2>/dev/null
668"$BB" dd if="$PAYLOAD/grub/core.img"  of="$DEV" bs=512 seek=1 conv=notrunc 2>/dev/null
669"$BB" sync
670
671echo "✓ takana-install: $DEV instalado. Reiniciá sin el medio live para arrancar del disco."