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 1572Shell 203Rust 109Markdown 993728 Definitions

⎇ fa027e246e93b8f6b42f49fe85690071dad08f6a scripts/instalador-variantes.sh Shell · 337 lines · 22.6 KB History raw

fix(medio): el medio declaraba ser `base` mientras llevaba el escritorio entero adentro 6b7526cba · Sergio ·

1#!/bin/sh
2# instalador-variantes.sh — las tres variantes del instalador, en sus dos medios.
3#
4# ══ QUÉ SON ═══════════════════════════════════════════════════════════════════════════════════
5#   base      sólo el piso de arranque: arranca a una consola y nada más
6#   servidor  el piso de arranque + el userland base + el perfil `servidor`
7#   mirada    el piso de arranque + el userland base + el escritorio propio de la suite
8#   full      el userland, el toolbox (`cli`), los servicios y **los cinco escritorios** —mirada,
9#             sway, cosmic, gnome y kde—. Pide un pendrive de 32 GB.
10#
11# ── LA COMPOSICIÓN, Y POR QUÉ NO ES UN PERFIL SOLO ────────────────────────────────────────────
12# ⚠ **«base» son DOS cosas con el mismo nombre**, y confundirlas da un rootfs que no arranca:
13#
14#   · el **piso de ARRANQUE** — `product-rootfs`: arje-zero como PID 1 + busybox. Es lo único que
15#     hace que la imagen encienda, y NO está en ningún perfil de `targets.toml`.
16#   · el **userland base** — `[perfil.base]`: bash, git, sudo, dhcpcd, coreutils… 359 binarios y
17#     **ni busybox ni arje-zero adentro** (medido: `ls base/bin` no los tiene).
18#
19# El [SDD 27 §1](../docs/27-perfiles-instalables-y-el-instalador.md) ya había medido que los
20# escritorios NO heredan `base`; esto es la otra mitad del mismo hueco. Cada variante se compone
21# entonces así, y en este orden:
22#
23#     product-rootfs  ∪  perfil.base  ∪  <los perfiles de la variante>
24#
25# ── DOS MONTAJES, DOS COPIAS, Y NO ES DESPERDICIO ─────────────────────────────────────────────
26# `hydrate` proyecta por HARDLINK y `linkat()` no cruza un punto de montaje: la composición tiene
27# que armarse **bajo el montaje del store**. Y el ensamblado de la imagen vuelve a enlazar, esta vez
28# hacia `work/`, que está en OTRO disco — así que la composición se copia una vez a este lado. Las
29# dos restricciones son la misma física y ya mordieron dos veces en este repo.
30#
31# Uso:  scripts/instalador-variantes.sh [servidor|mirada|full]...   (sin argumentos: las tres)
32# Vars: KERNEL · SALIDA (def work/variantes) · STORE (def /store)
33set -eu
34ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT"
35STORE="${STORE:-/store}"
36SALIDA="${SALIDA:-work/variantes}"
37COMPOSICION="$STORE/.perfiles"
38# ⚠ Configurable desde el 2026-10-15, y no por gusto: el árbol local pesa lo que la imagen (con
39# firmware, ~3 GiB) y `/work` se quedó en 1,3 GiB libres mientras `/store` tenía 17. Un armador que
40# sólo puede trabajar en un filesystem se frena por el disco equivocado.
41LOCAL="${LOCAL:-work/.rootfs-local}"
42
43# ── perfiles_de <variante> <medio> ────────────────────────────────────────────────────────────
44# ⚠ **El medio manda sobre el contenido, y `servidor` es el caso donde se nota.** El fichero único
45# carga el sistema ENTERO en memoria —es lo que le permite soltar el disco para poder formatearlo—
46# así que cada MiB del rootfs es RAM exigida en la máquina de quien instala. El USB no tiene ese
47# límite: corre desde el propio medio.
48#
49# Por eso `servidor` no es la misma cosa en los dos:
50#   · por el archivo único va `servidor-minimo` — los mismos servicios, sin la cadena de compilar
51#     (`rust` 364 MiB, `lld21` 109) ni el toolbox de `cli` (54 raíces). 1,6 GiB contra 3,2.
52#   · por USB va `servidor` entero, que es el destino de verdad.
53#
54# No es una opinión sobre qué debe llevar un servidor: es una restricción física del medio, y está
55# escrita en los dos sitios —acá y en el `descripcion` del perfil— para que nadie la lea como un
56# recorte arbitrario.
57perfiles_de() {
58    case "$1:${2:-usb}" in
59        # `base` no compone NINGÚN perfil: es el piso de arranque solo, que es lo que la variante
60        # promete. Devuelve vacío a propósito y el bucle de hidratación no da ni una vuelta.
61        base:*)       echo "" ;;
62        servidor:run) echo "base servidor-minimo" ;;
63        servidor:*)   echo "base servidor" ;;
64        mirada:*)     echo "base escritorio-mirada" ;;
65        # ⚠ **GNOME estuvo fuera medio día, por dos cosas suyas, y las dos están resueltas.**
66        #   · `accountsservice` trae `/var/lib/AccountsService/users` en 0700 ⇒ hidratar como
67        #     usuario normal no podía leerlo y el nodo no proyectaba. Se arma con privilegio, que es
68        #     lo que ya hacía falta igual por los 0500 de ModemManager.
69        #   · `usr/libexec/libgtop_server2` llega 4555 desde el tarball de upstream y el perfil no lo
70        #     declara. El aviso del auditor decía «o entra a un `[[setuid]]` con su motivo, **o
71        #     sale**» y hasta ese día sólo existía la primera mitad. Ahora `takana setuid --apply`
72        #     RETIRA el bit no declarado, rompiendo el enlace duro: el store queda intacto y se dice
73        #     cuál se quitó. Es la opción segura —quita privilegio, nunca lo da— y la coherente con
74        #     el invariante: `of_tree` no hashea ese bit, así que un artefacto que lo trae está
75        #     fuera de contrato.
76        full:*)       echo "base cli escritorio-mirada escritorio-sway escritorio-cosmic escritorio-gnome escritorio-kde servidor" ;;
77        *) echo "variante desconocida: $1 (servidor|mirada|full)" >&2; return 1 ;;
78    esac
79}
80
81# ── QUÉ MEDIOS TIENE CADA VARIANTE, Y POR QUÉ `full` NO TIENE FICHERO ÚNICO ────────────────────────────
82# El fichero único carga el instalador ENTERO en memoria —es lo que le permite soltar el disco— y
83# en RAM viven a la vez el initramfs comprimido y su contenido desempaquetado. El generador ya lo
84# comprueba y se niega por encima del 40 % de la RAM; con `full` (3,2 GiB) eso pide ~8 GiB en la
85# máquina donde se instale, o sea que el fichero único sería uno de 1,5 GiB que en la mitad de las
86# máquinas no se puede usar. **El USB no tiene ese límite**: corre desde el propio medio.
87#
88# ⚠ Y hay un defecto conocido en esa guarda: mide la RAM de la máquina que CONSTRUYE, no la de la
89# que instala. Acá da el resultado correcto por casualidad —el hub tiene 7,6 GiB, parecido a un
90# portátil— pero construir en una caja grande dejaría pasar una imagen que allá no arranca. Está
91# anotado y no barrido.
92medios_de() {
93    case "$1" in
94        full) echo "usb" ;;
95        *)    echo "run usb" ;;
96    esac
97}
98
99VARIANTES="${*:-base mirada servidor full}"
100for v in $VARIANTES; do perfiles_de "$v" usb >/dev/null || exit 2; done
101
102KERNEL="${KERNEL:-$(ls -dt "$STORE"/*-linux-metal/boot/bzImage 2>/dev/null | head -1)}"
103[ -n "${KERNEL:-}" ] && [ -r "$KERNEL" ] || { echo "no encuentro un bzImage de linux-metal (set KERNEL=)" >&2; exit 1; }
104PISO="${PISO:-$(ls -dt "$STORE"/*-product-rootfs 2>/dev/null | head -1)}"
105[ -n "${PISO:-}" ] && [ -d "$PISO" ] || { echo "no encuentro un product-rootfs (set PISO=)" >&2; exit 1; }
106echo "==> piso de arranque: $PISO"
107echo "==> kernel: $KERNEL"
108mkdir -p "$SALIDA" "$COMPOSICION" "$LOCAL" work
109
110# ── LA GUARDA DEL MONTAJE, antes de componer un solo byte ─────────────────────────────────────────
111# El empaquetador (`instalador-paquete.sh`, `instalador-usb.sh`) enlaza el árbol local a su stage
112# `work/.instalador-initramfs-stage` por HARDLINK, y un hardlink no cruza montajes. Si `LOCAL` está
113# en otro filesystem, el armado compone los ~3,9 GiB de los dos perfiles, aplica el bit de
114# privilegio, compone la semilla, y RECIÉN ENTONCES muere con `EXDEV`.
115#
116# Medido el 2026-10-15: con `/work` sin espacio puse `LOCAL=/store/.rootfs-local` y perdí veinte
117# minutos de composición por esto. Y la advertencia existía —«el árbol tiene que quedar en el MISMO
118# montaje que el stage del ensamblado», unas líneas más abajo— o sea que la información estaba
119# escrita y no frenó a nadie, ni al que la escribió. Una prosa no es una guarda.
120_dev_local=$(stat -c%d "$LOCAL" 2>/dev/null || echo desconocido-local)
121# ⚠ `work/.` y no `work`: `work` es un SYMLINK a /work y `stat` no lo sigue, así que comparaba el
122# dev de /opt/takana (sda2) contra el de /work (sda4) y se negaba SIEMPRE, incluso con el default.
123# Lo dijo el propio control de esta guarda en su primera corrida — y casi lo publico igual.
124_dev_stage=$(stat -c%d work/. 2>/dev/null || echo desconocido-stage)
125if [ "$_dev_local" != "$_dev_stage" ]; then
126	echo "✗ LOCAL=$LOCAL está en OTRO filesystem que work/ (dev $_dev_local contra $_dev_stage)." >&2
127	echo "  El empaquetador enlaza el árbol local a su stage por hardlink, así que esto fallaría con" >&2
128	echo "  EXDEV después de componer varios GiB. Poné LOCAL en el mismo montaje que work/, o libera" >&2
129	echo "  espacio ahí: \`df -h work\` dice $(df -h work 2>/dev/null | awk 'NR==2{print $4}') libres." >&2
130	exit 1
131fi
132
133compone() {   # <variante> <medio> → deja el árbol local en $LOCAL/<variante>-<medio>
134    _cv="$1"; _cm="$2"
135    C="$COMPOSICION/$_cv-$_cm"
136    # `cp -al`: el piso entra por hardlinks, que en el mismo filesystem es gratis. Un `cp -a` de
137    # 265 MiB por variante es tiempo y disco que no hacen falta.
138    rm -rf "$C"; cp -al "$PISO" "$C"
139    chmod -R u+w "$C" 2>/dev/null || true
140    # ⚠ **Cada perfil se hidrata en SU PROPIO árbol y después se funden**, y no uno encima de otro
141    # con `--keep`. La razón es la auditoría del bit de privilegio, que corre contra las
142    # declaraciones `[[setuid]]` DEL PERFIL QUE SE ESTÁ HIDRATANDO: al poner `escritorio-mirada`
143    # encima de `base`, veía el `doas` 4755 que dejó `base` —declarado, legítimo— y lo denunciaba
144    # como «un bit que nadie declaró». La auditoría tiene razón en lo suyo; el que estaba mal era
145    # el método. Medido el 2026-09-23.
146    for p in $(perfiles_de "$_cv" "$_cm"); do
147        echo "  ── perfil $p"
148        H="$COMPOSICION/$_cv-$_cm-$p"; rm -rf "$H"
149        python3 scripts/hydrate-profile.py "$p" --into "$H" --store "$STORE" \
150            > "work/.variante-$_cv-$p.log" 2>&1 || {
151            echo "     ✗ falló la hidratación de $p — ver work/.variante-$_cv-$p.log" >&2
152            grep -A6 'SIN artefacto\|✗' "work/.variante-$_cv-$p.log" | head -14 >&2
153            exit 1; }
154        # `-n` (no pisar): si dos perfiles traen el mismo fichero es el mismo artefacto, así que
155        # gana el primero y da igual cuál sea. Pisar daría el mismo resultado y un error si el
156        # hardlink ya existe.
157        chmod -R u+w "$H" 2>/dev/null || true
158        # ⚠ **Un `-n` silencioso es una decisión escondida.** Cuando dos perfiles traen el MISMO
159        # camino gana el primero, y con un solo escritorio eso es siempre el mismo artefacto y da
160        # igual. Con cinco escritorios en la misma imagen ya no: dos de ellos pueden querer el
161        # mismo `/etc/xdg/...` o el mismo `.desktop`, y quedarse callado significa que uno pisa al
162        # otro sin que nadie lo sepa. No se resuelve acá —hace falta saber CUÁL gana y por qué—
163        # pero se CUENTA, que es lo mínimo para que exista la pregunta.
164        _antes=$(find "$C" ! -type d 2>/dev/null | wc -l)
165        _trae=$(find "$H" ! -type d 2>/dev/null | wc -l)
166        ( cd "$H" && find . -type d -exec mkdir -p "$C/{}" \; ) 2>/dev/null || true
167        # ── UN SYMLINK A BUSYBOX no tapa el binario REAL que necesita el bit ──────────────────────
168        # Ningún perfil declara `busybox`, pero el PISO lo trae horneado con sus ~331 symlinks, y el
169        # piso entra primero: con `-n`, `bin/su → busybox` gana sobre el `bin/su` de shadow. El bit
170        # de privilegio se aplicó sobre el árbol del perfil —donde ESE `bin/su` es el binario real y
171        # quedó 4755— y la fusión lo descartaba, dejando la capacidad muerta. Medido el 2026-10-15:
172        # `su`, `passwd`, `mount` y `umount` eran symlinks a un busybox sin bit.
173        #
174        # Se liberan SÓLO las rutas con `[[setuid]]` declarado, y el criterio es duro: **a un symlink
175        # no se le puede poner el bit**, así que un symlink ahí es una capacidad muerta por
176        # construcción. Los otros ~325 no se tocan — reemplazar un applet por un binario con otras
177        # banderas puede romper un script del arranque, y eso es el trabajo de
178        # `docs/plan-botar-busybox.md`, con su propia medición.
179        _recetas_p=""
180        for _pk in $(python3 scripts/targets.py "$p" 2>/dev/null); do
181            [ -f "recipes/$_pk.toml" ] && _recetas_p="$_recetas_p recipes/$_pk.toml"
182        done
183        _liberados=0
184        if [ -n "$_recetas_p" ] && [ -x ./target/release/takana ]; then
185            for _sp in $(./target/release/takana setuid $_recetas_p --json 2>/dev/null \
186                | python3 -c 'import json,sys
187try:
188    print("\n".join(x["path"] for x in json.load(sys.stdin)))
189except Exception:
190    pass' 2>/dev/null); do
191                [ -L "$C/$_sp" ] || continue
192                [ -f "$H/$_sp" ] && [ ! -L "$H/$_sp" ] || continue
193                case "$(readlink "$C/$_sp")" in
194                *busybox*)
195                    rm -f "$C/$_sp"
196                    echo "     · $_sp: el symlink a busybox del piso NO tapa al de $p (el bit no va en un symlink)"
197                    _liberados=$((_liberados + 1)) ;;
198                esac
199            done
200        fi
201        [ "$_liberados" -gt 0 ] && echo "     ✓ $_liberados binario(s) privilegiado(s) liberado(s) del symlink heredado"
202        ( cd "$H" && find . ! -type d -exec cp -a -l -n {} "$C/{}" \; ) 2>/dev/null || true
203        _despues=$(find "$C" ! -type d 2>/dev/null | wc -l)
204        _choques=$(( _antes + _trae - _despues ))
205        [ "$_choques" -gt 0 ] && echo "     ⚠ $_choques fichero(s) ya estaban y NO se pisaron (gana el primero)"
206        rm -rf "$H"
207        echo "     $(grep -oE '[0-9]+/[0-9]+' "work/.variante-$_cv-$p.log" | tail -1) artefactos · acumulado $(du -sm "$C" | cut -f1) MiB"
208    done
209
210    # ── LA IDENTIDAD DEL CONJUNTO, QUE LA FUSIÓN DESCARTABA ──────────────────────────────────
211    # `hydrate-profile.py` escribe un `/etc/os-release` por perfil, en SU árbol. Acá se funden con
212    # `-n` —gana el primero— y ese fichero es uno de los que colisionan: con `base` entrando
213    # primero, el medio quedaba declarando `TAKANA_PERFIL=base`, 74 nodos y el cierre de `base`
214    # mientras llevaba el escritorio entero adentro (medido el 2026-10-15 sobre el `.img` ya
215    # armado: 505 binarios en /usr/bin, compositor, greeter, pata, fuentes).
216    #
217    # ⚠ Y no era cosmético: el digest de `base` es HOY un digest VÁLIDO, así que
218    # `imagen-rancia.py` lo comparaba contra el cierre vigente de `base`, coincidía, y daba por
219    # **AL DÍA** un medio que no es ninguno de sus dos perfiles. Un control en verde sobre una
220    # imagen que no es la clausura que dice ser es peor que no tener control.
221    #
222    # La identidad de una imagen compuesta es el digest de la UNIÓN de las clausuras de sus
223    # perfiles, y lo calcula `identidad-compuesta.py` con la MISMA `digest_de` que la vigía y que
224    # `hydrate-profile.py` — tres llamadores, una implementación.
225    # ⚠ NO se tuberea a `sed` directamente: este guion va con `set -eu` y SIN `pipefail`, así que
226    # el estado de `python3 … | sed` es el de `sed` y un fallo del generador saldría 0 — el medio
227    # quedaría sin identidad y el armado en verde. Se captura, se mira el estado, y después se
228    # imprime.
229    if [ -n "$(perfiles_de "$_cv" "$_cm")" ]; then
230        if python3 scripts/identidad-compuesta.py "$C" "$_cv" $(perfiles_de "$_cv" "$_cm") \
231               > "work/.identidad-$_cv-$_cm.log" 2>&1; then
232            sed 's/^/     /' "work/.identidad-$_cv-$_cm.log"
233        else
234            echo "     ✗ no pude escribir la identidad compuesta de $_cv:" >&2
235            sed 's/^/       /' "work/.identidad-$_cv-$_cm.log" >&2
236            exit 1
237        fi
238    else
239        # `base` no compone ningún perfil (es el piso solo) ⇒ no hay unión que resumir. Se dice,
240        # porque la AUSENCIA de declaración no debe leerse como conformidad.
241        echo "     · variante sin perfiles ⇒ sin identidad declarada (es el piso de arranque solo)"
242    fi
243    # ── el árbol tiene que quedar en el MISMO montaje que el stage del ensamblado ─────────────
244    # ⚠ **Algunos artefactos traen ficheros que el usuario normal no puede LEER** —ModemManager
245    # instala sus `fcc-unlock.available.d/*` en 0500, que es como vienen de upstream— así que copiar
246    # el árbol pide privilegio. No se hace `chown`: `cp -a` como root conserva dueño y modo, que es
247    # justo lo que la imagen tiene que llevar. Aparece en cuanto entra un escritorio (sway, cosmic,
248    # kde); las variantes chicas nunca lo tocaron. Medido el 2026-09-23.
249    # ── LA SEMILLA DEL MEDIO: quién arranca el escritorio ─────────────────────────────────────
250    # Medido el 2026-10-15: la semilla del piso trae `hammerd`, `console-getty` y `sshd` y nada más,
251    # y este armador no la tocaba ⇒ **el medio arrancaba a una consola de texto, con el escritorio
252    # adentro y nadie llamándolo**. Es la figura de `foot` una vuelta más arriba, y el propio
253    # `targets.py --services` la venía denunciando: «`mirada-compositor` está en la imagen y TRAE
254    # este servicio, pero el perfil no lo arranca».
255    #
256    # Las Cards NO se escriben acá: `service-cards` es la ÚNICA traducción `[[service]]` → Card y
257    # `inyectar-cards.py` la única que compone la seed (idempotente por label, con `os.replace`
258    # porque la seed es un hardlink al store). Es el mismo par que usan `servidor-image.sh` y los
259    # armadores de gnome/kde/cosmic; mirada era el único que no lo hacía.
260    _svc=""
261    for p in $(perfiles_de "$_cv" "$_cm"); do
262        _svc="$_svc $(python3 scripts/targets.py --service-paths "$p" 2>/dev/null | tr '\n' ' ')"
263    done
264    _svc=$(echo $_svc)
265    if [ -z "$_svc" ]; then
266        echo "     ⚠ ningún servicio habilitado en los perfiles: NADA arrancaría el escritorio" >&2
267    elif [ ! -r "$C/ente/seed.card.json" ]; then
268        echo "     ⚠ el árbol no tiene semilla: no hay dónde componer las Cards" >&2
269    else
270        # shellcheck disable=SC2086
271        if ./target/release/takana --store "$STORE" service-cards $_svc > "work/.cards-$_cv-$_cm.json" 2>/dev/null \
272           && python3 scripts/gnome/inyectar-cards.py "$C/ente/seed.card.json" \
273                "work/.cards-$_cv-$_cm.json" "$C/etc/arje/cards.d" >/dev/null; then
274            echo "     semilla: $(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))))' "work/.cards-$_cv-$_cm.json") card(s) compuestas · Entes: $(python3 -c 'import json,sys; print(", ".join(e.get("label","?") for e in json.load(open(sys.argv[1])).get("genesis",[])))' "$C/ente/seed.card.json")"
275        else
276            echo "     ✗ no pude componer la semilla: esta imagen arrancaría sin escritorio" >&2
277            exit 1
278        fi
279    fi
280    L="$LOCAL/$_cv-$_cm"
281    rm -rf "$L" 2>/dev/null || sudo rm -rf "$L"
282    if ! cp -a "$C" "$L" 2>/dev/null; then
283        rm -rf "$L" 2>/dev/null || sudo rm -rf "$L"
284        echo "     (hay ficheros que sólo root puede leer: copiando con privilegio)"
285        sudo cp -a "$C" "$L" || { echo "     ✗ no pude copiar el árbol ni con privilegio" >&2; exit 1; }
286    fi
287    rm -rf "$C" 2>/dev/null || sudo rm -rf "$C"
288    echo "     árbol local: $(du -sm "$L" | cut -f1) MiB"
289}
290
291for v in $VARIANTES; do
292    echo
293    echo "════ $v ════════════════════════════════════════════════════════════════════════"
294
295  if echo " $(medios_de "$v") " | grep -q ' run '; then
296    # ⚠ **El fichero único y el USB son DOS MEDIOS INDEPENDIENTES, y el primero tiene un techo
297    # físico que el segundo no tiene.** El autoextraíble carga el instalador ENTERO en RAM —en RAM
298    # viven a la vez el initramfs comprimido y su contenido— así que su generador se niega por
299    # encima del 40 % de la memoria. Con el firmware adentro (1047 MiB) el rootfs pasa de 3 GiB y
300    # esa negativa es CORRECTA; lo que estaba mal era que `exit 1` se llevaba puesto el USB, que no
301    # tiene ese límite porque corre desde el propio medio. Medido el 2026-10-15: el hub tiene
302    # 7,7 GiB de RAM ⇒ el tope son ~3,1, y el medio de mirada no entra.
303    #
304    # Va en un subshell para que su `exit 1` muera ahí y no en el bucle. Y el USB sigue siendo
305    # fatal: ése es el medio que alguien está esperando.
306    (
307    echo "  ── el fichero que se baja (.sh) — perfiles: $(perfiles_de "$v" run)"
308    compone "$v" run
309    ROOTFS="$L" KERNEL="$KERNEL" SALIDA="$SALIDA/paquete-$v" ./scripts/instalador-paquete.sh \
310        > "work/.variante-$v-paquete.log" 2>&1 || {
311        echo "     ✗ falló el paquete — ver work/.variante-$v-paquete.log" >&2; tail -6 "work/.variante-$v-paquete.log" >&2; exit 1; }
312    PAQUETE="$SALIDA/paquete-$v" SALIDA="$SALIDA/takana-instalador-$v.sh" ./scripts/instalador-autoextraible.sh \
313        > "work/.variante-$v-run.log" 2>&1 || {
314        echo "     ✗ falló el autoextraíble — ver work/.variante-$v-run.log" >&2; tail -6 "work/.variante-$v-run.log" >&2; exit 1; }
315    echo "     $SALIDA/takana-instalador-$v.sh  $(( $(wc -c < "$SALIDA/takana-instalador-$v.sh") / 1048576 )) MiB"
316    rm -rf "$L"
317    ) || {
318        echo "     ⚠ el fichero único no se pudo armar (lo de arriba dice por qué): SIGO con el USB" >&2
319        rm -rf "$LOCAL/$v-run" 2>/dev/null || sudo rm -rf "$LOCAL/$v-run" 2>/dev/null || true
320    }
321  else
322    echo "  ── sin fichero único: este rootfs no entra en RAM en una máquina normal (sólo USB)"
323  fi
324
325    echo "  ── la imagen USB — perfiles: $(perfiles_de "$v" usb)"
326    compone "$v" usb
327    ROOTFS="$L" KERNEL="$KERNEL" IRD_TREE="work/.arbol-$v" \
328        IMG="$SALIDA/takana-instalador-usb-$v.img" ./scripts/instalador-usb.sh \
329        > "work/.variante-$v-usb.log" 2>&1 || {
330        echo "     ✗ falló el USB — ver work/.variante-$v-usb.log" >&2; tail -6 "work/.variante-$v-usb.log" >&2; exit 1; }
331    echo "     $SALIDA/takana-instalador-usb-$v.img  $(( $(wc -c < "$SALIDA/takana-instalador-usb-$v.img") / 1048576 )) MiB"
332    rm -rf "$L"
333done
334
335echo
336echo "✓ en $SALIDA:"
337ls -l "$SALIDA"/*.sh "$SALIDA"/*.img 2>/dev/null | awk '{printf "  %-52s %s MiB\n", $9, int($5/1048576)}'