diff --git a/airootfs/usr/local/bin/alvin-install b/airootfs/usr/local/bin/alvin-install index 5222c2c..4185844 100755 --- a/airootfs/usr/local/bin/alvin-install +++ b/airootfs/usr/local/bin/alvin-install @@ -48,7 +48,110 @@ cleanup() { true } trap cleanup EXIT -trap 'gum style --foreground 9 --bold "Failed at line $LINENO. See $LOG"' ERR + +# ── error surfacing ─────────────────────────────────────────────────────────── +# Step output normally ends up in $LOG only. These helpers make sure a failure +# is ALSO shown on screen, paged through `gum pager` so long outputs stay +# inspectable instead of scrolling away or hiding in a file nobody opens. + +# Page stdin to the user with a context line. $1 = context. gum is a hard +# prerequisite of this installer (check_prereqs), so its pager is used +# instead of depending on less being present. +page_output() { + local ctx="$1" + if [[ -c /dev/tty && -t 1 ]]; then + { gum style --foreground 11 --bold "$ctx"; echo ""; cat; } | gum pager || true + else + gum style --foreground 9 --bold "$ctx" || true + cat >&2 || true + fi +} + +# Page the last $2 lines (default 200) of $LOG - for output that was already +# redirected there. $1 = context. +page_log_tail() { + [[ -f "$LOG" ]] || return 0 + tail -n "${2:-200}" "$LOG" | page_output "$1" +} + +# Page the $LOG lines appended after $1 (a line count taken with +# `wc -l < "$LOG"` before the step ran). $2 = context. A mark at or past the +# end of the log means nothing new was written - skip instead of opening a +# pager on an empty screen. +page_log_since() { + local mark="$1" total=0 + [[ -f "$LOG" ]] || return 0 + total=$(wc -l <"$LOG") || total=0 + (( mark < total )) || return 0 + sed -n "$((mark + 1)),\$p" "$LOG" | page_output "$2" +} + +# Run a command, tee its combined output into $LOG (as the old +# `>>"$LOG" 2>&1` redirects did - live, so an interrupted command keeps its +# partial output) and - on failure - page the lines it added so the error +# reaches the user, not just the log. Returns the command's exit status. +# Usage: run_logged [-s SPINNER [-t TITLE]] "what failed" cmd [args...] +# -s show a gum spinner while cmd runs: dot for linear commands, jump for +# ones that compile. Frames render on stderr (the terminal), never $LOG. +# -t spinner title; omit when an info line already says what is running. +run_logged() { + local ctx="" spinner="" title="" rc=0 mark=0 total=0 + while [[ "${1:-}" == -[st] ]]; do + case "$1" in + -s) spinner="$2"; shift 2 ;; + -t) title="$2"; shift 2 ;; + esac + done + ctx="$1" + shift + # Line count before the command, so only ITS output gets paged on failure. + # A missing $LOG (mark 0) is fine: tee creates it, so lines 1..n are + # exactly this command's output. + if [[ -f "$LOG" ]]; then + mark=$(wc -l <"$LOG") || mark=0 + fi + # tee appends to $LOG while /dev/null keeps the terminal clean. With + # pipefail the pipeline status is the command's own; the only false + # failure possible is tee itself failing to write (e.g. disk full). + if [[ -n "$spinner" ]]; then + # gum spin propagates the command's exit status; --show-output keeps + # its stdout (the command's output) free of spinner frames for tee, + # while the spinner itself renders on stderr = the terminal. The sh + # wrapper merges the command's stderr into that stdout first, so no + # output bypasses $LOG. + local -a spin=(gum spin --show-output --spinner "$spinner") + if [[ -n "$title" ]]; then + spin+=(--title "$title") + fi + "${spin[@]}" -- sh -c '"$@" 2>&1' sh "$@" \ + | tee -a "$LOG" >/dev/null || rc=$? + else + "$@" 2>&1 | tee -a "$LOG" >/dev/null || rc=$? + fi + if (( rc != 0 )); then + if [[ -f "$LOG" ]]; then + total=$(wc -l <"$LOG") || total=0 + fi + if (( mark < total )); then + page_log_since "$mark" "${ctx} (exit ${rc}) - output below, full log: $LOG" + else + # Silent failure: nothing to page, but still surface where and why. + gum style --foreground 11 --bold \ + "${ctx} (exit ${rc}) - no output captured, full log: $LOG" || true + fi + fi + return "$rc" +} + +# ERR trap: a fatal failure aborts the install (errexit) - say where, then +# page the recent log so the actual error is on screen, not log-only. +on_err() { + local rc=$? + trap - ERR + gum style --foreground 9 --bold "Installation failed (exit ${rc}) at line $1. Full log: $LOG" || true + page_log_tail "Last output before the failure - q to quit, scroll for more" 300 || true +} +trap 'on_err "$LINENO"' ERR (( EUID == 0 )) || { echo "Run as root" >&2; exit 1; } @@ -122,13 +225,14 @@ gvariant_unescape() { sync_target_dbs() { [[ "${TARGET_DB_SYNCED}" == "true" ]] && return 0 info "Syncing target package databases..." - arch-chroot "$TARGET" pacman-key --populate archlinux cachyos chaotic \ - >>"$LOG" 2>&1 || true - if arch-chroot "$TARGET" pacman -Syy --noconfirm >>"$LOG" 2>&1; then + run_logged -s dot "pacman-key --populate failed" \ + arch-chroot "$TARGET" pacman-key --populate archlinux cachyos chaotic || true + if run_logged -s dot "Target pacman -Syy failed" \ + arch-chroot "$TARGET" pacman -Syy --noconfirm; then TARGET_DB_SYNCED="true" ok "Target databases synced." else - warn "Target pacman -Syy failed (see $LOG) - continuing with existing databases." + warn "Target pacman -Syy failed (output above) - continuing with existing databases." fi } @@ -552,7 +656,8 @@ install_pacstrap() { # Parse only the first field, then drop names not resolvable in the live # session's repos (e.g. local-aur packages are not configured there). info "Syncing package databases for resolvability check..." - if pacman -Syy --noconfirm >>"$LOG" 2>&1; then + if run_logged -s dot "pacman -Syy failed (resolvability check)" \ + pacman -Syy --noconfirm; then declare -A available=() local db_line while IFS= read -r db_line; do @@ -560,7 +665,7 @@ install_pacstrap() { done < <(pacman -Slq 2>>"$LOG" || true) (( ${#available[@]} > 0 )) && filter_ok=true else - warn "pacman -Syy failed (see $LOG) — installing without resolvability filter." + warn "pacman -Syy failed (output above) — installing without resolvability filter." fi local line pkg @@ -608,9 +713,11 @@ install_pacstrap() { log "Pacstrap: openssh guaranteed in package list (sshd requested)" fi - info "Installing ${#pkgs[@]} packages (this may take a while)..." log "Pacstrap: ${#pkgs[@]} packages, ${#dropped[@]} skipped" - pacstrap "$TARGET" "${pkgs[@]}" --noconfirm 2>>"$LOG" + run_logged -s dot -t "Installing ${#pkgs[@]} packages (this may take a while)" \ + "pacstrap failed" \ + pacstrap "$TARGET" "${pkgs[@]}" --noconfirm \ + || die "pacstrap failed - the target system is incomplete (output above, full log: $LOG)." ok "Packages installed." } @@ -696,12 +803,13 @@ install_sshd() { log "SSH server: enable skipped (sshd binary missing in target)" return 0 fi - if arch-chroot "$TARGET" systemctl enable sshd.service >>"$LOG" 2>&1; then + if run_logged "Could not enable sshd.service" \ + arch-chroot "$TARGET" systemctl enable sshd.service; then SSHD_APPLIED="true" log "SSH server: enabled" ok "sshd enabled (starts at boot)." else - warn "Could not enable sshd.service (see $LOG)." + warn "Could not enable sshd.service (output above)." log "SSH server: enable failed" fi } @@ -732,12 +840,14 @@ install_git_ssh() { if [[ -n "$GIT_EMAIL" ]]; then info "Configuring git..." local git_name="${GIT_NAME:-$USERNAME}" - arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ - git config --global user.name "$git_name" >>"$LOG" 2>&1 \ - || warn "git config user.name failed (see $LOG)." - arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ - git config --global user.email "$GIT_EMAIL" >>"$LOG" 2>&1 \ - || warn "git config user.email failed (see $LOG)." + run_logged "git config user.name failed" \ + arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ + git config --global user.name "$git_name" \ + || warn "git config user.name failed (output above)." + run_logged "git config user.email failed" \ + arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ + git config --global user.email "$GIT_EMAIL" \ + || warn "git config user.email failed (output above)." ok "Git configured for ${USERNAME} (${git_name} <${GIT_EMAIL}>)." log "Git: ${git_name} <${GIT_EMAIL}>" else @@ -775,27 +885,33 @@ install_git_ssh() { else echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" > "${TARGET}/etc/sudoers.d/99-ssh-import-id" chmod 440 "${TARGET}/etc/sudoers.d/99-ssh-import-id" - arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ - env HOME="$home_in_target" yay -Ss ssh-import-id >>"$LOG" 2>&1 \ - || warn "yay -Ss ssh-import-id found nothing (see $LOG)." - if arch-chroot "$TARGET" pacman -S --noconfirm python-distro >>"$LOG" 2>&1 \ - && arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ - env HOME="$home_in_target" yay -S --noconfirm ssh-import-id >>"$LOG" 2>&1; then + run_logged "yay -Ss ssh-import-id found nothing" \ + arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ + env HOME="$home_in_target" yay -Ss ssh-import-id \ + || warn "yay -Ss ssh-import-id found nothing (output above)." + if run_logged -s dot -t "Installing ssh-import-id" \ + "pacman -S python-distro failed" \ + arch-chroot "$TARGET" pacman -S --noconfirm python-distro \ + && run_logged -s jump -t "Installing ssh-import-id" \ + "yay failed to install ssh-import-id" \ + arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ + env HOME="$home_in_target" yay -S --noconfirm ssh-import-id; then sid_ok="true" log "ssh-import-id: installed via yay" else - warn "yay could not install ssh-import-id (see $LOG) - falling back to github.com keys." + warn "yay could not install ssh-import-id (output above) - falling back to github.com keys." fi rm -f "${TARGET}/etc/sudoers.d/99-ssh-import-id" fi if [[ "$sid_ok" == "true" ]]; then - if arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ - env HOME="$home_in_target" ssh-import-id "gh:${GH_USERNAME}" >>"$LOG" 2>&1; then + if run_logged "ssh-import-id failed" \ + arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ + env HOME="$home_in_target" ssh-import-id "gh:${GH_USERNAME}"; then ok "SSH keys imported via ssh-import-id." log "SSH keys: ssh-import-id gh:${GH_USERNAME}" else - warn "ssh-import-id failed (see $LOG) - falling back to github.com keys." + warn "ssh-import-id failed (output above) - falling back to github.com keys." sid_ok="false" fi fi @@ -808,6 +924,8 @@ install_git_ssh() { keys=$(curl -fsSL --max-time 30 "$keys_url" 2>>"$LOG") || curl_rc=$? if (( curl_rc != 0 )); then warn "Could not fetch ${keys_url} (curl exit ${curl_rc}) - no SSH keys imported." + # curl's stderr went to $LOG only - put it in front of the user. + page_log_tail "curl error output (q to quit)" 60 || true log "SSH keys: curl failed (exit ${curl_rc}) for ${keys_url}" elif [[ -z "$keys" ]]; then # HTTP 200 with an empty body: the account exists but simply has @@ -882,16 +1000,27 @@ install_end4_dots() { # --skip-alldeps: every dependency was already installed at ISO build time # (customize_airootfs.sh runs ./setup install-deps), so this only copies # configs, sets up services and builds the python venv from the warm cache. - if ! arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ - env HOME="$home_in_target" bash -c \ - "cd ${repo_in_target} && ./setup install -f -s --skip-alldeps" \ - < <(yes i) >>"$LOG" 2>&1; then - warn "end-4 setup failed, retrying files only (see $LOG)." - setups_done=false + # Both attempts run under a jump spinner (this step compiles the venv and + # pip bits): --show-output + the sh stderr-merge keep every line flowing + # into $LOG, the frames render on the terminal, and stdin still feeds the + # `yes i` process substitution through to ./setup. + if ! gum spin --show-output --spinner jump -- \ + sh -c '"$@" 2>&1' sh \ arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ env HOME="$home_in_target" bash -c \ - "cd ${repo_in_target} && ./setup install -f -s --skip-alldeps --skip-allsetups" \ - < <(yes i) >>"$LOG" 2>&1 || dots_ok=false + "cd ${repo_in_target} && ./setup install -f -s --skip-alldeps" \ + < <(yes i) >>"$LOG"; then + warn "end-4 setup failed, retrying files only (see $LOG)." + setups_done=false + gum spin --show-output --spinner jump -- \ + sh -c '"$@" 2>&1' sh \ + arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ + env HOME="$home_in_target" bash -c \ + "cd ${repo_in_target} && ./setup install -f -s --skip-alldeps --skip-allsetups" \ + < <(yes i) >>"$LOG" || dots_ok=false + # Both attempts' output exists only in $LOG - page it so the user can + # inspect what end-4 actually choked on. + page_log_since "$log_mark" "end-4 setup output (both attempts) - q to quit, scroll for more" || true fi rm -f "${TARGET}/etc/sudoers.d/99-end4-install" @@ -912,8 +1041,9 @@ install_end4_dots() { fi if [[ -f "${TARGET}/usr/lib/systemd/system/bluetooth.service" ]]; then - arch-chroot "$TARGET" systemctl enable bluetooth.service >>"$LOG" 2>&1 \ - || warn "Could not enable bluetooth.service (see $LOG)." + run_logged "Could not enable bluetooth.service" \ + arch-chroot "$TARGET" systemctl enable bluetooth.service \ + || warn "Could not enable bluetooth.service (output above)." fi # `systemctl --user` cannot work in this chroot either: it needs # /run/user/UID/bus, which does not exist until the target boots (it only @@ -921,13 +1051,21 @@ install_end4_dots() { # `enable` would write — the same absolute link systemctl produces. if [[ -f "${TARGET}/usr/lib/systemd/user/ydotool.service" ]]; then local yd_wants="${TARGET}${home_in_target}/.config/systemd/user/default.target.wants" - # stderr goes to $LOG so the failure warn below points at something - # that actually records it (as the bluetooth block does). - if { mkdir -p "$yd_wants" \ - && ln -sfn /usr/lib/systemd/user/ydotool.service "$yd_wants/ydotool.service"; } 2>>"$LOG"; then + # Capture instead of redirecting: on failure the output is both + # appended to $LOG and paged to the user (as the bluetooth block's + # run_logged does), never kept in the log alone. + local yd_rc=0 yd_err="" + yd_err=$( { mkdir -p "$yd_wants" \ + && ln -sfn /usr/lib/systemd/user/ydotool.service "$yd_wants/ydotool.service"; } 2>&1 ) \ + || yd_rc=$? + if (( yd_rc == 0 )); then log "ydotool.service enabled for ${USERNAME} (symlinked in user unit dir)." else - warn "Could not enable ydotool.service for ${USERNAME} (see $LOG)." + if [[ -n "$yd_err" ]]; then + printf '%s\n' "$yd_err" >>"$LOG" + printf '%s\n' "$yd_err" | page_output "ydotool.service symlink failed (exit ${yd_rc}) - full log: $LOG" + fi + warn "Could not enable ydotool.service for ${USERNAME} (output above)." fi fi @@ -941,7 +1079,7 @@ install_end4_dots() { # matching value counts as persisted. if ((log_mark > 0)) \ && [[ -x "${TARGET}/usr/bin/dbus-run-session" && -x "${TARGET}/usr/bin/gsettings" ]]; then - local gs_line gs_schema gs_key gs_val gs_got gs_norm gs_raw + local gs_line gs_schema gs_key gs_val gs_got gs_norm gs_raw gs_err gs_rc # Snapshot and parse first (the loop below appends to $LOG), keeping # log order: a key set twice replays chronologically so the last # write wins, exactly as end-4 ran it. Dedupe identical repeats only. @@ -971,12 +1109,21 @@ install_end4_dots() { gs_val=${BASH_REMATCH[3]} # env -i: root's env is exactly what broke dconf; rebuild it for # the target user with only what gsettings/dbus need. - if arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ + # gsettings exits 0 even when the commit fails, so both the exit + # status AND the read-back below decide success; its output is + # kept in $LOG and paged on failure. + gs_err="" + gs_rc=0 + gs_err=$(arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ env -i HOME="$home_in_target" USER="$USERNAME" LOGNAME="$USERNAME" \ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin \ XDG_CONFIG_HOME="${home_in_target}/.config" \ dbus-run-session -- \ - gsettings set "$gs_schema" "$gs_key" "$gs_val" >>"$LOG" 2>&1; then + gsettings set "$gs_schema" "$gs_key" "$gs_val" 2>&1) || gs_rc=$? + if [[ -n "$gs_err" ]]; then + printf '%s\n' "$gs_err" >>"$LOG" + fi + if (( gs_rc == 0 )); then gs_got=$(arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ env -i HOME="$home_in_target" USER="$USERNAME" LOGNAME="$USERNAME" \ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin \ @@ -990,9 +1137,15 @@ install_end4_dots() { if [[ "$gs_norm" == "$gs_val" ]]; then log "gsettings persisted: ${gs_schema} ${gs_key} = ${gs_val}" else + if [[ -n "$gs_err" ]]; then + printf '%s\n' "$gs_err" | page_output "gsettings ${gs_schema} ${gs_key} command output (q to quit) - full log: $LOG" + fi warn "gsettings ${gs_schema} ${gs_key} did not persist (read back: ${gs_got:-nothing}, see $LOG)." fi else + if [[ -n "$gs_err" ]]; then + printf '%s\n' "$gs_err" | page_output "gsettings set failed (exit ${gs_rc}) - output below, full log: $LOG" + fi warn "Could not replay gsettings ${gs_schema} ${gs_key} (see $LOG)." fi done @@ -1207,20 +1360,96 @@ install_bootloader() { printf '\nGRUB_DISABLE_BOOTNEXT=true\n' >> "${TARGET}/etc/default/grub" fi - arch-chroot "$TARGET" grub-install \ - --target=x86_64-efi --efi-directory=/boot --bootloader-id=ALVIN + # Two installs, both verified - this is the step whose failure strands a + # machine with no bootable firmware entry: + # 1. --removable → writes the removable fallback \EFI\BOOT\BOOTX64.EFI + # to the ESP FIRST, so it exists no matter what the NVRAM step does + # 2. normal → registers the ALVIN entry in the firmware NVRAM + # Some firmwares drop their NVRAM entries over time (the classic + # boot-stranding failure); with the fallback on the ESP the firmware + # still finds GRUB and the machine is never bricked. + # Caveat: the fallback is an unsigned direct copy of grubx64.efi, so it + # only boots with Secure Boot disabled or in setup mode - which booting + # the unsigned Arch ISO already implies. If Secure Boot support is ever + # added, the fallback MUST be shimx64.efi instead - otherwise the + # firmware rejects BOOTX64.EFI. + if ! run_logged -s dot "grub-install (removable fallback) failed" \ + arch-chroot "$TARGET" grub-install \ + --target=x86_64-efi --efi-directory=/boot --bootloader-id=ALVIN \ + --removable --recheck; then + die "grub-install --removable failed - \\EFI\\BOOT\\BOOTX64.EFI was not written (output above, full log: $LOG)." + fi + + # Verify the fallback file really landed: $TARGET/boot IS the ESP mount, + # so this checks the exact file firmware will look for. This is the "is + # there any bootloader at all" gate - it must run before any NVRAM + # failure is allowed to abort the install. + if [[ ! -f "${TARGET}/boot/EFI/BOOT/BOOTX64.EFI" ]]; then + die "Fallback bootloader missing: ${TARGET}/boot/EFI/BOOT/BOOTX64.EFI was not created - this system could be unbootable if the firmware drops its boot entry." + fi + log "Bootloader: removable fallback present (EFI/BOOT/BOOTX64.EFI)" + + if ! run_logged -s dot "grub-install (NVRAM boot entry) failed" \ + arch-chroot "$TARGET" grub-install \ + --target=x86_64-efi --efi-directory=/boot --bootloader-id=ALVIN \ + --recheck; then + # grub copies its EFI binaries before it asks efibootmgr to register + # the entry, so "files present + non-zero exit" means only the NVRAM + # registration failed - fall through to the host-side creation below. + if [[ -f "${TARGET}/boot/EFI/ALVIN/grubx64.efi" ]]; then + warn "grub-install could not register the NVRAM entry (output above) - trying from the live medium." + log "Bootloader: grub-install NVRAM registration failed; EFI/ALVIN/grubx64.efi present" + else + # Note the fallback honestly: BOOTX64.EFI exists, but grub.cfg is + # only generated at the END of this function - an install aborted + # here leaves no config, so the fallback alone does NOT boot yet. + die "grub-install failed - EFI/ALVIN/grubx64.efi was not written (output above, full log: $LOG). \\EFI\\BOOT\\BOOTX64.EFI exists, but the install stops before grub-mkconfig runs, so no bootable configuration was written." + fi + fi + + # Verify the NVRAM entry persisted. arch-chroot rbinds /sys and mounts + # efivarfs, so grub-install inside the chroot wrote the same variables + # efibootmgr reads here - there is no visibility difference; we verify + # and create on the live medium because that is the direct path to the + # real disk. Create the entry if grub-install could not persist it + # (write-protected or flaky NVRAM), then re-verify. Verification failure + # aborts the install. + local esp_partnum boot_entry + esp_partnum=$(lsblk -no PARTN "$(part 1)" 2>/dev/null | head -n1) || esp_partnum="" + esp_partnum=${esp_partnum:-1} + # Match the loader path, not just the label: a stale ALVIN entry left by + # a previous install on another disk would satisfy a bare label match. + boot_entry=$(efibootmgr -v 2>/dev/null \ + | grep -iF '\EFI\ALVIN\grubx64.efi' | head -n1) || boot_entry="" + if [[ -z "$boot_entry" ]]; then + warn "No ALVIN entry in the firmware NVRAM - creating one now." + if ! run_logged "efibootmgr could not create the NVRAM boot entry" \ + efibootmgr -c -d "$DISK" -p "$esp_partnum" -L ALVIN \ + -l '\EFI\ALVIN\grubx64.efi'; then + die "Could not create the firmware NVRAM boot entry (output above). Refusing to finish: the install stops before grub-mkconfig runs, so \\EFI\\BOOT\\BOOTX64.EFI exists but is not bootable yet. Check UEFI setup (NVRAM write access) and re-run." + fi + boot_entry=$(efibootmgr -v 2>/dev/null \ + | grep -iF '\EFI\ALVIN\grubx64.efi' | head -n1) || boot_entry="" + if [[ -z "$boot_entry" ]]; then + die "NVRAM boot entry still missing after efibootmgr -c. Refusing to finish: the install stops before grub-mkconfig runs, so the system is not bootable yet - check UEFI setup and re-run." + fi + fi + log "Bootloader: NVRAM entry verified: ${boot_entry}" if [[ "$UNL0KR" == "true" ]]; then # Mode B: grub.cfg is written in install_unl0kr together with the # final initramfs (a config generated now would not match it). log "grub-mkconfig deferred (Mode B, after unl0kr build)" - ok "Bootloader installed (config finalized after unl0kr)." + ok "Bootloader installed (NVRAM entry + fallback verified; config finalized after unl0kr)." return fi - arch-chroot "$TARGET" grub-mkconfig -o /boot/grub/grub.cfg + if ! run_logged -s dot "grub-mkconfig failed" \ + arch-chroot "$TARGET" grub-mkconfig -o /boot/grub/grub.cfg; then + die "grub-mkconfig failed - the installed system would boot into an empty GRUB menu (output above, full log: $LOG)." + fi - ok "Bootloader installed." + ok "Bootloader installed (NVRAM entry + removable fallback verified)." } # ── TPM2 auto-unlock ────────────────────────────────────────────────────────── @@ -1280,8 +1509,16 @@ install_tpm() { # $PASSWORD (stdin would instead trigger the ask-password agent and block # until someone typed it on a console). It only adds a keyslot - no # --wipe-slot - so the passphrase slot always stays as a fallback. - if PASSWORD="$PASS" systemd-cryptenroll --tpm2-device=auto \ - --tpm2-pcrs=0+7 "$(part 2)" >>"$LOG" 2>&1; then + # Exported rather than put on the command line (env PASSWORD=...): argv + # is world-readable via /proc, the environment is not - and unset right + # after, so the secret exists only for the duration of this call. + export PASSWORD="$PASS" + local cenrc=0 + run_logged "systemd-cryptenroll failed" \ + systemd-cryptenroll --tpm2-device=auto \ + --tpm2-pcrs=0+7 "$(part 2)" || cenrc=$? + unset PASSWORD + if (( cenrc == 0 )); then TPM_STATUS="enrolled" # Enrolment alone does nothing at boot: systemd-cryptsetup only uses # the TPM2 keyslot when the cmdline asks for it. install_bootloader @@ -1291,12 +1528,13 @@ install_tpm() { local luks_uuid luks_uuid=$(blkid -s UUID -o value "$(part 2)") write_mode_a_cmdline "$luks_uuid" "rd.luks.options=${luks_uuid}=tpm2-device=auto" - arch-chroot "$TARGET" grub-mkconfig -o /boot/grub/grub.cfg >>"$LOG" 2>&1 \ - || warn "grub-mkconfig failed after adding rd.luks.options (see $LOG) - TPM2 unlock will not activate at boot." + run_logged -s dot "grub-mkconfig failed (rd.luks.options)" \ + arch-chroot "$TARGET" grub-mkconfig -o /boot/grub/grub.cfg \ + || warn "grub-mkconfig failed after adding rd.luks.options (output above) - TPM2 unlock will not activate at boot." ok "TPM2 auto-unlock enrolled and activated (rd.luks.options, PCR 0+7)." else TPM_STATUS="failed" - warn "TPM enrollment failed (see $LOG) - passphrase unlock still works." + warn "TPM enrollment failed (output above) - passphrase unlock still works." fi # TPM-only unlock with an unencrypted /boot is weak: anyone with physical @@ -1336,10 +1574,12 @@ unl0kr_fallback_mode_a() { TPM_STATUS="skipped-unl0kr-fallback" fi - arch-chroot "$TARGET" mkinitcpio -P >>"$LOG" 2>&1 \ - || warn "mkinitcpio -P failed during unl0kr fallback (see $LOG)." - arch-chroot "$TARGET" grub-mkconfig -o /boot/grub/grub.cfg >>"$LOG" 2>&1 \ - || warn "grub-mkconfig failed during unl0kr fallback (see $LOG)." + run_logged -s dot "mkinitcpio -P failed (unl0kr fallback)" \ + arch-chroot "$TARGET" mkinitcpio -P \ + || warn "mkinitcpio -P failed during unl0kr fallback (output above)." + run_logged -s dot "grub-mkconfig failed (unl0kr fallback)" \ + arch-chroot "$TARGET" grub-mkconfig -o /boot/grub/grub.cfg \ + || warn "grub-mkconfig failed during unl0kr fallback (output above)." warn "The system will ask for the LUKS passphrase at boot - test a reboot before relying on it." } @@ -1355,8 +1595,10 @@ install_unl0kr() { # yay runs as the new user and needs passwordless sudo for this one call. echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" > "${TARGET}/etc/sudoers.d/99-unl0kr-install" chmod 440 "${TARGET}/etc/sudoers.d/99-unl0kr-install" - arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ - env HOME="$home_in_target" yay -S --noconfirm unl0kr >>"$LOG" 2>&1 || build_rc=$? + run_logged -s jump -t "Building unl0kr from the AUR (this may take a while)" \ + "yay failed to build/install unl0kr" \ + arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ + env HOME="$home_in_target" yay -S --noconfirm unl0kr || build_rc=$? rm -f "${TARGET}/etc/sudoers.d/99-unl0kr-install" if (( build_rc != 0 )); then @@ -1388,7 +1630,10 @@ install_unl0kr() { cat "$hook" echo "-- install/unl0kr --" cat "$hook_install" - } >>"$LOG" 2>&1 || warn "Could not log the unl0kr hook files (see $LOG)." + } >>"$LOG" 2>&1 || { + warn "Could not log the unl0kr hook files (see $LOG)." + page_log_tail "unl0kr hook file output (q to quit)" 40 || true + } # Touchscreen/input stack for the initramfs: mkinitcpio fails the build # on a MODULES entry it cannot resolve, and -P applies the list to every @@ -1423,12 +1668,14 @@ install_unl0kr() { fi info "Rebuilding initramfs with unl0kr..." - if ! arch-chroot "$TARGET" mkinitcpio -P >>"$LOG" 2>&1; then + if ! run_logged -s dot "mkinitcpio -P failed (unl0kr image)" \ + arch-chroot "$TARGET" mkinitcpio -P; then unl0kr_fallback_mode_a "mkinitcpio -P failed" return 0 fi # grub.cfg is written now, next to the final image (Mode B deferred it). - if ! arch-chroot "$TARGET" grub-mkconfig -o /boot/grub/grub.cfg >>"$LOG" 2>&1; then + if ! run_logged -s dot "grub-mkconfig failed (unl0kr image)" \ + arch-chroot "$TARGET" grub-mkconfig -o /boot/grub/grub.cfg; then unl0kr_fallback_mode_a "grub-mkconfig failed" return 0 fi @@ -1437,6 +1684,7 @@ install_unl0kr() { # `lsinitcpio -a` prints the hook run order and the included module list. local analysis if ! analysis=$(lsinitcpio -a "${TARGET}/boot/initramfs-linux.img" 2>>"$LOG"); then + page_log_tail "lsinitcpio error output (q to quit)" 40 || true unl0kr_fallback_mode_a "lsinitcpio could not read the image" return 0 fi @@ -1567,8 +1815,9 @@ install_login_manager() { local u for u in greetd sddm gdm; do if [[ -f "${TARGET}/usr/lib/systemd/system/${u}.service" ]]; then - arch-chroot "$TARGET" systemctl disable "$u.service" >>"$LOG" 2>&1 \ - || warn "Could not disable ${u}.service (see $LOG)." + run_logged "Could not disable ${u}.service" \ + arch-chroot "$TARGET" systemctl disable "$u.service" \ + || warn "Could not disable ${u}.service (output above)." fi done rm -f "${TARGET}/etc/systemd/system/getty@tty1.service.d/autologin.conf" @@ -1586,9 +1835,10 @@ install_login_manager() { install_none_profile return fi - if ! arch-chroot "$TARGET" pacman -S --noconfirm greetd greetd-tuigreet \ - >>"$LOG" 2>&1; then - warn "greetd install failed (see $LOG) - falling back to 'none'." + if ! run_logged -s dot -t "Installing greetd + tuigreet" \ + "greetd install failed" \ + arch-chroot "$TARGET" pacman -S --noconfirm greetd greetd-tuigreet; then + warn "greetd install failed (output above) - falling back to 'none'." LOGIN_MANAGER="none" install_none_profile return @@ -1596,8 +1846,9 @@ install_login_manager() { # The package normally provides the greeter account; create it if # this version does not (ArchWiki: user = "greeter"). if ! arch-chroot "$TARGET" id greeter >/dev/null 2>&1; then - arch-chroot "$TARGET" useradd -r -M -s /usr/bin/nologin greeter \ - >>"$LOG" 2>&1 || warn "Could not create the greeter user (see $LOG)." + run_logged "Could not create the greeter user" \ + arch-chroot "$TARGET" useradd -r -M -s /usr/bin/nologin greeter \ + || warn "Could not create the greeter user (output above)." fi mkdir -p "${TARGET}/etc/greetd" cat > "${TARGET}/etc/greetd/config.toml" <<'TOML' @@ -1608,11 +1859,13 @@ vt = 1 command = "tuigreet --cmd Hyprland" user = "greeter" TOML - if arch-chroot "$TARGET" systemctl enable greetd.service >>"$LOG" 2>&1; then - arch-chroot "$TARGET" systemctl disable getty@tty1.service >>"$LOG" 2>&1 || true + if run_logged "Could not enable greetd.service" \ + arch-chroot "$TARGET" systemctl enable greetd.service; then + run_logged "Could not disable getty@tty1.service" \ + arch-chroot "$TARGET" systemctl disable getty@tty1.service || true ok "greetd + tuigreet enabled (Hyprland)." else - warn "Could not enable greetd.service (see $LOG) - keeping getty@tty1 as the login path." + warn "Could not enable greetd.service (output above) - keeping getty@tty1 as the login path." fi ;; sddm) @@ -1623,18 +1876,22 @@ TOML install_none_profile return fi - if ! arch-chroot "$TARGET" pacman -S --noconfirm sddm >>"$LOG" 2>&1; then - warn "sddm install failed (see $LOG) - falling back to 'none'." + if ! run_logged -s dot -t "Installing sddm" \ + "sddm install failed" \ + arch-chroot "$TARGET" pacman -S --noconfirm sddm; then + warn "sddm install failed (output above) - falling back to 'none'." LOGIN_MANAGER="none" install_none_profile return fi ensure_hyprland_session - if arch-chroot "$TARGET" systemctl enable sddm.service >>"$LOG" 2>&1; then - arch-chroot "$TARGET" systemctl disable getty@tty1.service >>"$LOG" 2>&1 || true + if run_logged "Could not enable sddm.service" \ + arch-chroot "$TARGET" systemctl enable sddm.service; then + run_logged "Could not disable getty@tty1.service" \ + arch-chroot "$TARGET" systemctl disable getty@tty1.service || true ok "sddm enabled (Hyprland)." else - warn "Could not enable sddm.service (see $LOG) - keeping getty@tty1 as the login path." + warn "Could not enable sddm.service (output above) - keeping getty@tty1 as the login path." fi ;; gdm) @@ -1646,25 +1903,29 @@ TOML install_none_profile return fi - if ! arch-chroot "$TARGET" pacman -S --noconfirm gdm >>"$LOG" 2>&1; then - warn "gdm install failed (see $LOG) - falling back to 'none'." + if ! run_logged -s dot -t "Installing gdm" \ + "gdm install failed" \ + arch-chroot "$TARGET" pacman -S --noconfirm gdm; then + warn "gdm install failed (output above) - falling back to 'none'." LOGIN_MANAGER="none" install_none_profile return fi ensure_hyprland_session - if arch-chroot "$TARGET" systemctl enable gdm.service >>"$LOG" 2>&1; then - arch-chroot "$TARGET" systemctl disable getty@tty1.service >>"$LOG" 2>&1 || true + if run_logged "Could not enable gdm.service" \ + arch-chroot "$TARGET" systemctl enable gdm.service; then + run_logged "Could not disable getty@tty1.service" \ + arch-chroot "$TARGET" systemctl disable getty@tty1.service || true ok "gdm enabled (Hyprland session)." else - warn "Could not enable gdm.service (see $LOG) - keeping getty@tty1 as the login path." + warn "Could not enable gdm.service (output above) - keeping getty@tty1 as the login path." fi ;; *) # none: keep getty on tty1; install_none_profile writes the # login profile that starts the compositor. - arch-chroot "$TARGET" systemctl enable getty@tty1.service >>"$LOG" 2>&1 \ - || true + run_logged "Could not enable getty@tty1.service" \ + arch-chroot "$TARGET" systemctl enable getty@tty1.service || true install_none_profile ;; esac @@ -1829,6 +2090,16 @@ install_cleanup() { gum style --foreground 10 --align center \ "Run 'reboot' to boot into your new system." echo "" + # Debug aid: every failure was already paged at the moment it happened; + # this offers the complete record (all step output) on demand. + if gum confirm --affirmative "View install log" --negative "No thanks" \ + "Open the full install log (${LOG})?"; then + gum pager <"$LOG" || true + fi + echo "" + # Last line on screen after the optional log view - the instruction to + # reboot must be the final thing the user reads. + echo "Run 'reboot' to boot into your new system." } # ── Main ──────────────────────────────────────────────────────────────────────