Files
AlvinArch/airootfs/usr/local/bin/alvin-install
T
alvinlollo c64c64dc97
Tests / tests (push) Successful in 2m14s
feat(installer): ask GitHub username first in git screen
screen_git now prompts GitHub username before git name/email, and the
screen description plus installation summary list SSH before git to
match the new prompt order. Behavior unchanged - prompts are
independent and all assignment sites are read after the screen returns.
2026-10-05 17:42:15 +11:00

1700 lines
65 KiB
Bash
Executable File

#!/usr/bin/env bash
# alvin-install — Gum TUI Arch Linux installer with LUKS encryption
# shellcheck disable=SC2034
set -Eeuo pipefail
readonly LOG=/var/log/alvin-install.log
readonly PASS_MIN=8
readonly ESP_SIZE="1GiB"
readonly TARGET="/mnt"
readonly -a PKGS_EXCLUDE=(archiso mkinitcpio-archiso mkinitcpio-nfs-utils syslinux memtest86+)
skip() { local x; for x in "${PKGS_EXCLUDE[@]}"; do [[ "$x" == "$1" ]] && return 0; done; return 1; }
DISK=""
KEYMAP=""
USERNAME=""
PASS=""
HOSTNAME=""
GIT_NAME=""
GIT_EMAIL=""
TIMEZONE=""
LOCALE=""
LUKS="true"
FILESYSTEM="ext4"
GH_USERNAME=""
UNL0KR="false"
TPM_STATUS=""
LOGIN_MANAGER="none"
AUTOLOGIN=""
TPM_DISCLAIMER=""
TARGET_DB_SYNCED="false"
log() { echo "[$(date '+%H:%M:%S')] $*" >> "$LOG"; }
cleanup() {
set +e
rm -f "${TARGET}/etc/sudoers.d/99-end4-install" \
"${TARGET}/etc/sudoers.d/99-ssh-import-id" \
"${TARGET}/etc/sudoers.d/99-unl0kr-install" 2>/dev/null
umount -R "$TARGET" 2>/dev/null
cryptsetup close cryptroot 2>/dev/null
true
}
trap cleanup EXIT
trap 'gum style --foreground 9 --bold "Failed at line $LINENO. See $LOG"' ERR
(( EUID == 0 )) || { echo "Run as root" >&2; exit 1; }
die() { gum style --foreground 9 --bold "ERROR: $*"; exit 1; }
info() { gum style --foreground 12 --bold "$*"; }
ok() { gum style --foreground 10 --bold "$*"; }
warn() { gum style --foreground 11 --bold "$*"; }
header() {
gum style \
--foreground "#FAFAFA" --background "#6C91BF" \
--padding "1 2" --bold --align center --width 70 \
"$1"
}
divider() {
gum style --foreground 8 "──────────────────────────────────────────────────────────"
}
part() { [[ "$DISK" =~ [0-9]$ ]] && echo "${DISK}p$1" || echo "${DISK}$1"; }
valid_username() { [[ "$1" =~ ^[a-z_][a-z0-9_-]*$ ]] && (( ${#1} <= 32 )); }
# Numeric uid:gid of $USERNAME as seen inside $TARGET. Always needed for
# chowns from the live medium, whose /etc/passwd has no $USERNAME. Both ids
# come from one arch-chroot call chained with && : separate substitutions
# would only propagate the last status, so a lone `id -u` failure could
# silently produce ':1000' and abort at the wrong line.
target_owner() {
arch-chroot "$TARGET" sh -c "id -u '$USERNAME' && id -g '$USERNAME'" | paste -sd:
}
# Refresh the target's package databases once before the first target-side
# pacman/yay call (cachyos is appended to the target's pacman.conf after
# pacstrap, so its db is not there yet; keyrings are re-populated as a
# cheap safety net). Failures only warn - later steps fall back on their own.
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
TARGET_DB_SYNCED="true"
ok "Target databases synced."
else
warn "Target pacman -Syy failed (see $LOG) - continuing with existing databases."
fi
}
check_prereqs() {
command -v gum >/dev/null 2>&1 || die "gum is not installed"
[[ -d /sys/firmware/efi ]] || die "UEFI not detected. This installer requires UEFI boot."
}
# ── Screens ───────────────────────────────────────────────────────────────────
screen_welcome() {
clear
header "Alvin Arch Linux Installer"
echo ""
gum style --foreground 15 --align center \
"This will install Arch Linux with:" \
" * LUKS disk encryption (default)" \
" * GRUB bootloader" \
" * NetworkManager" \
" * Your user + git config" \
""
divider
echo ""
gum confirm --affirmative "Begin installation" --negative "Abort" \
"Ready to begin?" || die "Installation aborted by user."
}
screen_keymap() {
clear
header "Keyboard Layout"
echo ""
gum style --foreground 15 \
"Select the keyboard layout of your physical keyboard." \
"This affects passwords and the LUKS passphrase at boot." \
"" \
"The default is the US layout (us)."
echo ""
if gum confirm --affirmative "Use us (default)" --negative "Choose another layout" \
"Use US keyboard layout (us)?"; then
KEYMAP="us"
else
KEYMAP=$(localectl list-keymaps | gum filter --header "+ Keymap" --placeholder "Search keymaps...") \
|| die "No keymap selected."
fi
[[ -z "$KEYMAP" ]] && die "No keymap selected."
loadkeys "$KEYMAP" 2>/dev/null || true
log "Keymap: $KEYMAP"
}
screen_network() {
clear
header "Network Setup"
echo ""
gum style --foreground 15 --align center \
"A network connection is required for installation." \
"" \
"When nmtui opens:" \
" 1. Select 'Activate a connection'" \
" 2. Use arrow keys to pick your WiFi network" \
" 3. Enter the password and press Enter" \
" 4. Press Esc to exit nmtui"
echo ""
divider
echo ""
gum confirm --affirmative "Open nmtui" --negative "Skip (already connected)" \
"Open NetworkManager TUI?" && nmtui || true
echo ""
info "Verifying network connection..."
local try=0
while ! ping -c1 -W3 archlinux.org &>/dev/null; do
try=$((try + 1))
if (( try >= 5 )); then
gum confirm --affirmative "Retry" --negative "Abort" \
"Network check failed. Retry?" || die "No network connection."
try=0
fi
sleep 2
done
ok "Network verified."
log "Network: connected"
}
screen_disk() {
clear
header "Select Installation Disk"
echo ""
local live_dev=""
if [[ -d /run/archiso/bootmnt ]]; then
local src
src=$(findmnt -no SOURCE /run/archiso/bootmnt 2>/dev/null || true)
[[ -n "$src" ]] && live_dev="/dev/$(lsblk -no PKNAME "$src" 2>/dev/null | head -n1)"
fi
local disks=()
while IFS= read -r line; do
local name size model
name=$(echo "$line" | awk '{print $1}')
size=$(echo "$line" | awk '{print $2}')
model=$(echo "$line" | awk '{$1=$2=""; sub(/^[ \t]+/,""); print}')
[[ "$name" == "$live_dev" ]] && continue
disks+=("${name} ${size} ${model}")
done < <(lsblk -dpno NAME,SIZE,MODEL,TYPE | awk '$NF=="disk" {print $1,$2,$3}')
(( ${#disks[@]} > 0 )) || die "No disks found."
while true; do
local choice
choice=$(gum choose --header "+ Target disk (live USB hidden)" "${disks[@]}") \
|| die "No disk selected."
DISK=$(echo "$choice" | awk '{print $1}')
gum confirm --affirmative "Wipe ${DISK}" --negative "Go back" \
"WARNING: ALL data on ${DISK} will be destroyed!" && break
done
log "Disk: $DISK"
}
screen_luks() {
clear
header "Disk Encryption"
echo ""
gum style --foreground 15 --align center \
"LUKS encrypts your root filesystem." \
"The passphrase is the same as your login password." \
"Your data stays protected if the device is lost or stolen."
echo ""
echo ""
if gum confirm --affirmative "Enable LUKS encryption" --negative "No encryption" \
"Enable LUKS disk encryption? (default: yes)"; then
LUKS="true"
else
LUKS="false"
fi
log "LUKS: $LUKS"
}
screen_unl0kr() {
UNL0KR="false"
if [[ "$LUKS" != "true" ]]; then
log "unl0kr: disabled (LUKS off)"
return
fi
clear
header "Touchscreen Unlock (unl0kr)"
echo ""
gum style --foreground 15 --align center \
"[EXPERIMENTAL] unl0kr lets you type the LUKS passphrase" \
"on screen - for tablets and touch-only devices." \
"" \
"It puts a modified busybox crypt hook plus touchscreen" \
"drivers into the initramfs. Test in a VM first: if it" \
"breaks, the installed system will not boot." \
"" \
"Note: enabling unl0kr disables TPM2 auto-unlock" \
"(the two are mutually exclusive)."
echo ""
# gum confirm selects its affirmative on Enter, which would default this
# experimental feature to ON; gum choose makes Enter pick the first
# (recommended) row instead.
local unl0kr_choice
unl0kr_choice=$(gum choose --header "+ unl0kr (default: no)" \
"No - standard unlock (recommended)" \
"Yes - experimental touchscreen unlock") || die "unl0kr question cancelled."
if [[ "$unl0kr_choice" == Yes* ]]; then
UNL0KR="true"
fi
log "unl0kr: $UNL0KR"
}
screen_filesystem() {
clear
header "Filesystem"
echo ""
FILESYSTEM=$(gum choose --header "+ Root filesystem" "ext4" "btrfs") \
|| die "No filesystem selected."
log "Filesystem: $FILESYSTEM"
}
screen_username() {
clear
header "Create User"
echo ""
local input
input=$(gum input --placeholder "username" --char-limit 32 --header "+ Username") \
|| die "No username entered."
[[ -z "$input" ]] && die "Username cannot be empty."
valid_username "$input" || die "Invalid username. Use lowercase letters, digits, _ or -."
USERNAME="$input"
log "Username: $USERNAME"
}
screen_password() {
clear
header "Set Password"
echo ""
gum style --foreground 15 \
"This password will be used for login and LUKS disk encryption."
echo ""
local pass1 pass2
pass1=$(gum input --password --header "+ Password") || die "No password entered."
(( ${#pass1} >= PASS_MIN )) || die "Password must be at least ${PASS_MIN} characters."
pass2=$(gum input --password --header "+ Confirm password") || die "No confirmation entered."
[[ "$pass1" == "$pass2" ]] || die "Passwords do not match."
PASS="$pass1"
log "Password: set (${#PASS} chars)"
}
screen_hostname() {
clear
header "Hostname"
echo ""
HOSTNAME=$(gum input --placeholder "archlinux" --char-limit 64 --header "+ Hostname") \
|| die "No hostname entered."
[[ -z "$HOSTNAME" ]] && HOSTNAME="archlinux"
log "Hostname: $HOSTNAME"
}
screen_git() {
clear
header "Git & SSH Configuration"
echo ""
gum style --foreground 15 \
"Optional steps - press Enter on any field to skip it." \
"The GitHub username imports that account's SSH public keys;" \
"Git config sets user.name/user.email for new repositories."
echo ""
GH_USERNAME=$(gum input --placeholder "octocat (empty to skip)" \
--header "+ GitHub username for SSH keys (optional)") || die "Git configuration cancelled."
GIT_NAME=$(gum input --value "$USERNAME" --header "+ Git name (Enter keeps '${USERNAME}')") \
|| die "Git configuration cancelled."
GIT_EMAIL=$(gum input --placeholder "user@example.com (empty to skip)" \
--header "+ Git email (optional)") || die "Git configuration cancelled."
log "Git: name='${GIT_NAME}' email='${GIT_EMAIL}' github='${GH_USERNAME}'"
}
screen_timezone() {
clear
header "Timezone"
echo ""
TIMEZONE=$(timedatectl list-timezones | gum filter --header "+ Timezone" --placeholder "Search...") \
|| die "No timezone selected."
log "Timezone: $TIMEZONE"
}
screen_locale() {
clear
header "Locale"
echo ""
local locales
locales=$(sed -n 's/^#\?\([a-z][a-z]*_[A-Z][A-Z]\.UTF-8\) UTF-8.*/\1/p' /etc/locale.gen | sort -u)
[[ -z "$locales" ]] && locales="en_US.UTF-8"
LOCALE=$(echo "$locales" | gum filter --header "+ Locale" --placeholder "Search...") \
|| die "No locale selected."
log "Locale: $LOCALE"
}
screen_login_manager() {
clear
header "Login Manager"
echo ""
gum style --foreground 15 \
"How should the desktop session start after boot?" \
"" \
" none - plain tty login, Hyprland starts automatically" \
" greetd - lightweight TUI greeter (tuigreet)" \
" sddm - Qt display manager (already installed)" \
" gdm - GNOME display manager (large download)"
echo ""
LOGIN_MANAGER=$(gum choose --header "+ Login manager (Enter = none)" \
"none" "greetd" "sddm" "gdm") || die "No login manager selected."
log "Login manager: $LOGIN_MANAGER"
}
screen_summary() {
clear
header "Installation Summary"
echo ""
local enc_status="No"
[[ "$LUKS" == "true" ]] && enc_status="Yes (uses login password)"
local unl0kr_status="${UNL0KR}"
[[ "$LUKS" != "true" ]] && unl0kr_status="n/a"
local git_line="skipped"
if [[ -n "$GIT_EMAIL" ]]; then
git_line="${GIT_NAME:-$USERNAME} <${GIT_EMAIL}>"
fi
local ssh_line="skipped"
if [[ -n "$GH_USERNAME" ]]; then
ssh_line="gh:${GH_USERNAME}"
fi
gum style --foreground 15 --border normal --border-foreground 8 \
--padding "0 2" --width 60 \
"Disk: ${DISK}" \
"Encryption: ${enc_status}" \
"unl0kr: ${unl0kr_status}" \
"Filesystem: ${FILESYSTEM}" \
"Username: ${USERNAME}" \
"Hostname: ${HOSTNAME}" \
"Keymap: ${KEYMAP}" \
"Locale: ${LOCALE}" \
"Timezone: ${TIMEZONE}" \
"Login mgr: ${LOGIN_MANAGER}" \
"SSH keys: ${ssh_line}" \
"Git: ${git_line}"
echo ""
echo "TPM2 auto-unlock (if applicable) and autologin are asked about during installation."
echo ""
gum confirm --affirmative "Install" --negative "Abort" \
"Proceed with installation?" || die "Installation aborted by user."
}
# ── Install steps ─────────────────────────────────────────────────────────────
install_partition() {
info "Partitioning ${DISK}..."
log "Partitioning $DISK"
wipefs -af "$DISK" 2>>"$LOG"
sgdisk --zap-all "$DISK" 2>>"$LOG"
parted -s "$DISK" \
mklabel gpt \
mkpart ESP fat32 1MiB "${ESP_SIZE}" \
set 1 esp on \
mkpart root "${ESP_SIZE}" 100%
partprobe "$DISK"
udevadm settle --timeout=30
wipefs -af "$(part 1)" "$(part 2)"
ok "Partitioned."
}
install_luks() {
[[ "$LUKS" != "true" ]] && return
info "Setting up LUKS encryption on $(part 2)..."
log "LUKS format $(part 2)"
printf '%s' "$PASS" | cryptsetup luksFormat \
--batch-mode --type luks2 --key-file=- "$(part 2)"
printf '%s' "$PASS" | cryptsetup open \
--key-file=- "$(part 2)" cryptroot
ok "LUKS unlocked."
}
install_format_mount() {
info "Formatting..."
mkfs.fat -F32 -n ESP "$(part 1)" 2>>"$LOG"
local root_dev
if [[ "$LUKS" == "true" ]]; then
root_dev="/dev/mapper/cryptroot"
else
root_dev="$(part 2)"
fi
if [[ "$FILESYSTEM" == "btrfs" ]]; then
mkfs.btrfs -f -L root "$root_dev" 2>>"$LOG"
else
mkfs.ext4 -F -L root "$root_dev" 2>>"$LOG"
fi
info "Mounting..."
mount "$root_dev" "$TARGET"
mkdir -p "${TARGET}/boot"
mount "$(part 1)" "${TARGET}/boot"
ok "Formatted and mounted."
}
install_pacstrap() {
info "Building package list..."
local pkglist="/run/archiso/bootmnt/arch/pkglist.x86_64.txt"
local pkgs=()
local dropped=()
local filter_ok=false
if [[ -f "$pkglist" ]]; then
# The medium's pkglist comes from `pacman -Q`: lines are "name version".
# 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
declare -A available=()
local db_line
while IFS= read -r db_line; do
[[ -n "$db_line" ]] && available["$db_line"]=1
done < <(pacman -Slq 2>>"$LOG" || true)
(( ${#available[@]} > 0 )) && filter_ok=true
else
warn "pacman -Syy failed (see $LOG) — installing without resolvability filter."
fi
local line pkg
while IFS= read -r line; do
[[ -z "$line" ]] && continue
pkg=${line%% *}
skip "$pkg" && continue
if $filter_ok && [[ -z "${available[$pkg]:-}" ]]; then
dropped+=("$pkg")
continue
fi
pkgs+=("$pkg")
done < "$pkglist"
fi
if (( ${#dropped[@]} > 0 )); then
warn "Skipped ${#dropped[@]} pkglist package(s) not found in configured repos:"
gum style --foreground 11 "${dropped[@]/#/ - }"
local d
for d in "${dropped[@]}"; do
log "Pacstrap: skipped unresolvable: $d"
done
fi
# Hardcoded essentials are never filtered — they must be in allowed repos.
for p in base linux linux-firmware linux-cachyos linux-cachyos-headers \
networkmanager grub efibootmgr sudo git intel-ucode amd-ucode \
mkinitcpio cryptsetup dosfstools e2fsprogs btrfs-progs \
cachyos-keyring cachyos-mirrorlist; do
local found=false
for existing in "${pkgs[@]+"${pkgs[@]}"}"; do
[[ "$existing" == "$p" ]] && found=true && break
done
$found || pkgs+=("$p")
done
info "Installing ${#pkgs[@]} packages (this may take a while)..."
log "Pacstrap: ${#pkgs[@]} packages, ${#dropped[@]} skipped"
pacstrap "$TARGET" "${pkgs[@]}" --noconfirm 2>>"$LOG"
ok "Packages installed."
}
install_fstab() {
info "Generating fstab..."
genfstab -U "$TARGET" >> "${TARGET}/etc/fstab"
ok "fstab generated."
}
install_cachyos_repo() {
info "Configuring CachyOS repository..."
if ! grep -q '\[cachyos\]' "${TARGET}/etc/pacman.conf" 2>/dev/null; then
cat >> "${TARGET}/etc/pacman.conf" <<'REPO'
[cachyos]
Include = /etc/pacman.d/cachyos-mirrorlist
REPO
fi
# [local-aur] lives only on the live medium (file:///opt/localrepo). The
# target's pacman.conf does not normally contain it - pacstrap is run
# without -P, so it keeps the stock file shipped by the pacman package -
# but drop it anyway in case that ever changes.
sed -i '/^\[local-aur\]$/,/^$/d' "${TARGET}/etc/pacman.conf" 2>/dev/null || true
ok "CachyOS repo configured."
}
install_timezone() {
info "Setting timezone..."
arch-chroot "$TARGET" ln -sf "/usr/share/zoneinfo/${TIMEZONE}" /etc/localtime
arch-chroot "$TARGET" hwclock --systohc || true
ok "Timezone set."
}
install_locale() {
info "Setting locale..."
arch-chroot "$TARGET" sed -i "s/^#${LOCALE}/${LOCALE}/" /etc/locale.gen
arch-chroot "$TARGET" locale-gen
echo "LANG=${LOCALE}" > "${TARGET}/etc/locale.conf"
echo "KEYMAP=${KEYMAP}" > "${TARGET}/etc/vconsole.conf"
ok "Locale and keymap set."
}
install_hostname() {
info "Setting hostname..."
echo "$HOSTNAME" > "${TARGET}/etc/hostname"
cat > "${TARGET}/etc/hosts" <<EOF
127.0.0.1 localhost
::1 localhost
127.0.1.1 ${HOSTNAME}.localdomain ${HOSTNAME}
EOF
ok "Hostname set."
}
install_network() {
info "Configuring network..."
arch-chroot "$TARGET" systemctl enable NetworkManager
local nm_dir="/etc/NetworkManager/system-connections"
if [[ -d "$nm_dir" ]] && ls "$nm_dir"/* &>/dev/null; then
mkdir -p "${TARGET}/etc/NetworkManager/system-connections"
cp -a "$nm_dir"/* "${TARGET}/etc/NetworkManager/system-connections/"
chmod 600 "${TARGET}/etc/NetworkManager/system-connections"/*
log "WiFi profiles copied."
fi
ok "NetworkManager enabled."
}
install_user() {
info "Creating user..."
cp -rT /etc/skel "${TARGET}/etc/skel"
arch-chroot "$TARGET" useradd -m -G wheel -s /bin/bash "$USERNAME"
printf '%s:%s\n' "$USERNAME" "$PASS" | arch-chroot "$TARGET" chpasswd
arch-chroot "$TARGET" passwd -l root
echo '%wheel ALL=(ALL:ALL) ALL' > "${TARGET}/etc/sudoers.d/10-wheel"
chmod 440 "${TARGET}/etc/sudoers.d/10-wheel"
ok "User ${USERNAME} created (wheel group, sudo enabled). Root locked."
}
install_git_ssh() {
local home_in_target="/home/${USERNAME}"
local owner
owner=$(target_owner)
# ── git identity (skipped when no email was entered) ─────────────────────
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)."
ok "Git configured for ${USERNAME} (${git_name} <${GIT_EMAIL}>)."
log "Git: ${git_name} <${GIT_EMAIL}>"
else
warn "No git email entered - skipping git identity."
log "Git: skipped"
fi
# ── SSH keys (skipped when no GitHub username was entered) ───────────────
if [[ -z "$GH_USERNAME" ]]; then
log "SSH keys: skipped (no GitHub username)"
return 0
fi
info "Importing SSH keys for github.com/${GH_USERNAME}..."
local ssh_dir="${TARGET}${home_in_target}/.ssh"
mkdir -p "$ssh_dir"
# ssh-import-id runs as $USERNAME and opens ~/.ssh/authorized_keys itself;
# the mkdir above ran as root, so hand the directory over BEFORE the
# import - otherwise it dies on PermissionError every single time.
chown "$owner" "$ssh_dir" 2>/dev/null || true
chmod 700 "$ssh_dir" 2>/dev/null || true
sync_target_dbs
# ssh-import-id from the AUR via yay, as the new user. The AUR PKGBUILD
# does not declare its runtime dependency python-distro, so install that
# first - without it the tool dies with ModuleNotFoundError. update.sh may
# already have put ssh-import-id into the ISO (localrepo), so build only
# when the target does not have it yet. yay needs passwordless sudo here;
# the drop-in is removed immediately afterwards.
local sid_ok="false"
if arch-chroot "$TARGET" pacman -Q ssh-import-id >/dev/null 2>&1; then
sid_ok="true"
log "ssh-import-id: already present in target"
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
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."
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
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."
sid_ok="false"
fi
fi
# Fallback: pull the account's public keys straight from GitHub (per-key
# dedupe keeps re-runs idempotent).
if [[ "$sid_ok" != "true" ]]; then
local keys_url="https://github.com/${GH_USERNAME}.keys"
local keys="" curl_rc=0
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."
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
# no registered keys - not a network problem.
warn "github.com/${GH_USERNAME} has no SSH keys registered - nothing to import."
log "SSH keys: github.com/${GH_USERNAME} has no keys"
else
local auth="${ssh_dir}/authorized_keys"
local line
touch "$auth"
while IFS= read -r line; do
[[ -z "$line" ]] && continue
grep -qxF -- "$line" "$auth" 2>/dev/null || printf '%s\n' "$line" >> "$auth"
done <<< "$keys"
ok "SSH keys appended from ${keys_url}."
log "SSH keys: curl ${keys_url}"
fi
fi
chmod 700 "$ssh_dir" 2>/dev/null || true
chown "$owner" "$ssh_dir" 2>/dev/null || true
if [[ -f "${ssh_dir}/authorized_keys" ]]; then
chmod 600 "${ssh_dir}/authorized_keys" 2>/dev/null || true
chown "$owner" "${ssh_dir}/authorized_keys" 2>/dev/null || true
fi
ok "SSH setup finished for ${USERNAME}."
}
install_end4_dots() {
if [[ ! -d /opt/dots-hyprland ]]; then
warn "No staged dots-hyprland at /opt/dots-hyprland — skipping end-4 dotfiles."
return
fi
info "Installing end-4 dotfiles (this may take a while)..."
local home_in_target="/home/${USERNAME}"
local repo_in_target="${home_in_target}/.dots-hyprland"
local repo_on_host="${TARGET}${repo_in_target}"
local dots_ok=true
local setups_done=true
# These chowns run on the live medium, whose /etc/passwd has no
# $USERNAME — the user exists only inside $TARGET — so name lookup fails
# ("chown: invalid user"). Resolve the numeric ids from the target and
# chown by number; the catch-all at the end of this function does the same.
local owner
owner=$(target_owner)
cp -a /opt/dots-hyprland "$repo_on_host"
chown -R "$owner" "$repo_on_host"
echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" > "${TARGET}/etc/sudoers.d/99-end4-install"
chmod 440 "${TARGET}/etc/sudoers.d/99-end4-install"
# Everything appended to $LOG from here belongs to this attempt; used
# further down to report which steps end-4 had to skip.
local log_mark=0
if [[ -f "$LOG" ]]; then
log_mark=$(wc -l < "$LOG")
fi
# stdin feeds "i" to end-4's x() [R/e:i] failure prompt, which otherwise
# blocks forever on a TTY whose prompt text is redirected to $LOG.
# Its systemd lines cannot work from inside this chroot: the medium's /run
# is bind-mounted in, so `systemctl --machine=…@.host --user enable … --now`
# has no target session (and --now would start services on the live medium
# anyway) - they fail on every install. "i" lets the run carry on with the
# venv, groups and config sync instead of aborting at the first such line;
# the usable part of that step is re-applied below and every skip is
# reported afterwards.
# --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
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
fi
rm -f "${TARGET}/etc/sudoers.d/99-end4-install"
if ! arch-chroot "$TARGET" id -nG "$USERNAME" | grep -qw input; then
arch-chroot "$TARGET" groupadd -f i2c
arch-chroot "$TARGET" usermod -aG video,i2c,input "$USERNAME"
warn "end-4 group setup was missing and has been applied."
fi
# ── steps end-4 cannot take from inside the chroot ───────────────────────
# `enable` alone is a pure file operation and needs no running session, so
# redo those here - without --now, since the target is not booted yet.
local mods_conf="${TARGET}/etc/modules-load.d/i2c-dev.conf"
if [[ ! -f "$mods_conf" ]]; then
mkdir -p "$(dirname "$mods_conf")"
echo i2c-dev > "$mods_conf"
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)."
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
# ever reports "Failed to connect to user scope bus"). Write the symlink
# `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
log "ydotool.service enabled for ${USERNAME} (symlinked in user unit dir)."
else
warn "Could not enable ydotool.service for ${USERNAME} (see $LOG)."
fi
fi
# Everything end-4 answered "i" to. systemctl/gsettings are the lines known
# to be unusable in this chroot; anything else it had to skip is worth
# telling the user about.
local -a ignored=()
if ((log_mark > 0)); then
mapfile -t ignored < <(
sed -n "$((log_mark + 1)),\$p" "$LOG" | sed $'s/\x1b\\[[0-9;]*m//g' |
sed -n 's/^.*Command "\(.*\)" has failed but ignored by user\..*$/\1/p'
)
fi
if ((${#ignored[@]} > 0)); then
log "end-4 skipped ${#ignored[@]} step(s): ${ignored[*]}"
local step
local -a skipped=()
for step in "${ignored[@]}"; do
case "$step" in
*systemctl* | *gsettings*) ;;
*) skipped+=("$step") ;;
esac
done
if ((${#skipped[@]} > 0)); then
warn "end-4 had to skip: ${skipped[*]} (see $LOG)"
fi
fi
if ! $setups_done; then
warn "end-4 setup did not complete — rerun as ${USERNAME}: cd ~/.dots-hyprland && ./setup install-setups"
fi
# end-4's install step rsync --delete's the whole ~/.config/quickshell
# directory (sdata/subcmd-install/3.files-legacy.sh), which drops the
# end4-pC fork skel put into the user's home - restore it afterwards.
local fork_src="/etc/skel/.config/quickshell/end4-pC"
local fork_dst="${TARGET}${home_in_target}/.config/quickshell/end4-pC"
if [[ -d "$fork_src" ]]; then
mkdir -p "$(dirname "$fork_dst")"
rm -rf "$fork_dst"
cp -a "$fork_src" "$fork_dst"
fi
# Same for fish: end-4 syncs ~/.config/fish with rsync --delete, keeping
# only what upstream ships (auto-Hypr.fish, config.fish, fish_variables),
# which drops skel's extras (fisher/nvm functions, completions,
# fish_plugins) and restores its own `alias q 'qs -c ii'`. The default
# quickshell config on this ISO is end4-pC, so put the extras back and
# repoint that one alias.
local fish_src="/etc/skel/.config/fish"
local fish_dst="${TARGET}${home_in_target}/.config/fish"
if [[ -d "$fish_src" && -d "$fish_dst" ]]; then
rsync -a --exclude config.fish --exclude fish_variables \
"$fish_src/" "$fish_dst/"
if [[ -f "${fish_dst}/config.fish" ]]; then
sed -i "s/alias q 'qs -c ii'/alias q 'qs -c end4-pC'/" "${fish_dst}/config.fish"
grep -qF "alias q 'qs -c end4-pC'" "${fish_dst}/config.fish" \
|| warn "fish 'q' alias still points at the upstream ii config (see $LOG)."
fi
fi
local configs_ok=true
[[ -e "${TARGET}${home_in_target}/.config/hypr/hyprland.lua" \
&& -d "${TARGET}${home_in_target}/.config/quickshell/ii" \
&& -d "${TARGET}${home_in_target}/.config/quickshell/end4-pC" ]] || configs_ok=false
# Files alone are not enough: with --skip-alldeps the dependencies must
# have come from the ISO. hyprland and quickshell are installed at ISO
# build time, so their binaries missing here means pacstrap dropped them
# (they were not resolvable in the live repos). The venv is created from
# the image's uv cache at install time, so a missing python means the
# python step of end-4's setup never got to run.
if $configs_ok; then
[[ -x "${TARGET}/usr/bin/hyprctl" && -x "${TARGET}/usr/bin/quickshell" ]] \
|| configs_ok=false
[[ -x "${TARGET}${home_in_target}/.local/state/quickshell/.venv/bin/python" ]] \
|| configs_ok=false
fi
if [[ -d /etc/skel/.config/hypr/custom ]]; then
cp -rfT /etc/skel/.config/hypr/custom "${TARGET}${home_in_target}/.config/hypr/custom"
fi
# Presets ship with absolute paths from the build machine; repoint them at
# the home that was just created (the images they name are not shipped).
local presets="${TARGET}${home_in_target}/.config/illogical-impulse/presets"
local preset
for preset in "$presets"/*.json; do
[[ -f "$preset" ]] || continue
sed -i "s|/home/alvin/|/home/${USERNAME}/|g" "$preset"
done
# Everything above that ran as root from the live medium (repo copy,
# fork/fish/hypr restores, and the presets sed -i just rewrote through a
# temp file) left root-owned files in the home — one pass fixes them all.
# Numeric ids again: this chown also runs on the medium, not in the chroot.
chown -R "$owner" "${TARGET}${home_in_target}"
if $dots_ok && $configs_ok; then
log "end-4 dotfiles installed for ${USERNAME} (setups_done=${setups_done} skipped=${#ignored[@]})."
ok "end-4 dotfiles installed."
else
log "end-4 dotfiles incomplete for ${USERNAME} (setup_exit_ok=${dots_ok} configs=${configs_ok} setups=${setups_done} skipped=${#ignored[@]})."
warn "end-4 dotfiles incomplete (see $LOG) — as ${USERNAME}, run: cd ~/.dots-hyprland && ./setup install"
fi
}
# ── Mode A boot configuration (systemd/sd-encrypt) ────────────────────────────
# Shared by install_mkinitcpio/install_bootloader on the happy path and by
# unl0kr_fallback_mode_a, so the hook list and kernel cmdline are written in
# exactly one shape everywhere - drift between the two places that write them
# is precisely the failure mode the fallback exists to undo.
write_mode_a_hooks() {
cat > "${TARGET}/etc/mkinitcpio.conf.d/alvin.conf" <<'HOOKS'
HOOKS=(base systemd autodetect microcode modconf kms keyboard sd-vconsole block sd-encrypt filesystems fsck)
HOOKS
}
# $1 = LUKS partition UUID, $2 = optional extra cmdline option(s), appended
# after root= (install_tpm passes rd.luks.options=...tpm2-device=auto to
# activate an enrolled TPM2 slot; without it systemd-cryptsetup never uses it).
write_mode_a_cmdline() {
local luks_uuid="$1" extra="${2:-}"
sed -i "s|^GRUB_CMDLINE_LINUX=.*|GRUB_CMDLINE_LINUX=\"rd.luks.name=${luks_uuid}=cryptroot root=/dev/mapper/cryptroot${extra:+ ${extra}}\"|" \
"${TARGET}/etc/default/grub"
}
install_mkinitcpio() {
info "Generating initramfs configuration..."
if [[ "$LUKS" != "true" ]]; then
arch-chroot "$TARGET" mkinitcpio -P
ok "initramfs generated."
return
fi
# Keep a pristine copy of the main config; everything this installer
# writes goes through the drop-in below (the image's archiso.conf drop-in
# and both kernel presets are untouched - the fallback image is built
# with `-S autodetect`).
local main_conf="${TARGET}/etc/mkinitcpio.conf"
if [[ -f "$main_conf" && ! -f "${main_conf}.alvin-bak" ]]; then
cp -a "$main_conf" "${main_conf}.alvin-bak"
log "mkinitcpio.conf backed up to mkinitcpio.conf.alvin-bak"
fi
mkdir -p "${TARGET}/etc/mkinitcpio.conf.d"
if [[ "$UNL0KR" == "true" ]]; then
# Mode B: busybox hooks + unl0kr (its ash hook parses `cryptdevice=`
# and drives the on-screen keyboard). The build is deferred to
# install_unl0kr: the package must exist first, and grub-mkconfig
# needs the final image for its initrd line. The two hook families
# must never be mixed in one HOOKS list.
cat > "${TARGET}/etc/mkinitcpio.conf.d/alvin.conf" <<'HOOKS'
HOOKS=(base udev autodetect microcode modconf kms keyboard keymap consolefont block unl0kr encrypt filesystems fsck)
HOOKS
log "mkinitcpio: Mode B (unl0kr) HOOKS written; build deferred to install_unl0kr"
ok "initramfs hooks staged (image built after unl0kr install)."
return
fi
# Mode A: systemd initramfs with sd-encrypt (matches the rd.luks.name
# kernel cmdline written by install_bootloader).
write_mode_a_hooks
arch-chroot "$TARGET" mkinitcpio -P
ok "initramfs generated."
}
install_bootloader() {
info "Installing GRUB bootloader..."
if [[ "$LUKS" == "true" ]]; then
local luks_uuid
luks_uuid=$(blkid -s UUID -o value "$(part 2)")
if [[ "$UNL0KR" == "true" ]]; then
# Mode B: busybox unl0kr/encrypt hooks consume cryptdevice=.
sed -i "s|^GRUB_CMDLINE_LINUX=.*|GRUB_CMDLINE_LINUX=\"cryptdevice=UUID=${luks_uuid}:cryptroot root=/dev/mapper/cryptroot\"|" \
"${TARGET}/etc/default/grub"
else
# Mode A: systemd/sd-encrypt consumes rd.luks.name=.
write_mode_a_cmdline "$luks_uuid"
fi
log "GRUB cmdline set for UNL0KR=${UNL0KR} (uuid=${luks_uuid})"
fi
# Force-disable EFI BootNext menu entries on the installed system:
# /etc/grub.d/31_efi_bootnext exits immediately when this is set, so a
# one-shot firmware boot selection never shows up in the GRUB menu.
if grep -q '^GRUB_DISABLE_BOOTNEXT=' "${TARGET}/etc/default/grub"; then
sed -i 's/^GRUB_DISABLE_BOOTNEXT=.*/GRUB_DISABLE_BOOTNEXT=true/' \
"${TARGET}/etc/default/grub"
else
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
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)."
return
fi
arch-chroot "$TARGET" grub-mkconfig -o /boot/grub/grub.cfg
ok "Bootloader installed."
}
# ── TPM2 auto-unlock ──────────────────────────────────────────────────────────
secure_boot_enabled() {
if bootctl status 2>/dev/null | grep -q 'Secure Boot: enabled'; then
return 0
fi
# bootctl unavailable or unclear: read the efivar directly
# (bytes 0-3 = attributes, byte 4 = SecureBoot value).
local sbvar byte
for sbvar in /sys/firmware/efi/efivars/SecureBoot-*; do
[[ -f "$sbvar" ]] || continue
byte=$(od -An -tu1 -j4 -N1 "$sbvar" 2>/dev/null | tr -d ' \n') || byte=""
[[ "$byte" == "1" ]] && return 0
return 1
done
return 1
}
install_tpm() {
TPM_STATUS=""
if [[ "$LUKS" != "true" ]]; then
TPM_STATUS="skipped-no-luks"
log "TPM: skipped (no LUKS)"
return
fi
if [[ "$UNL0KR" == "true" ]]; then
TPM_STATUS="skipped-unl0kr"
info "TPM auto-unlock skipped: unl0kr handles unlock on screen instead."
log "TPM: skipped (unl0kr enabled)"
return
fi
info "Checking for a TPM2 device..."
# Detected on the live medium: it sees the same /dev, /sys and efivarfs
# the installed system will boot against.
if [[ ! -e /sys/class/tpm/tpm0 ]] || ! systemd-analyze has-tpm2 >>"$LOG" 2>&1; then
TPM_STATUS="unsupported"
log "TPM: unsupported (no usable TPM2 device)"
return
fi
ok "TPM2 device detected."
local tpm_choice
tpm_choice=$(gum choose --header "+ TPM2 auto-unlock (default: no)" \
"No - keep the passphrase only" \
"Yes - enrol TPM2 key (PCR 0+7)") || tpm_choice="No"
if [[ "$tpm_choice" != Yes* ]]; then
TPM_STATUS="skipped"
log "TPM: declined by user"
return
fi
info "Enrolling TPM2 key (the LUKS passphrase is supplied automatically)..."
# systemd-cryptenroll never reads stdin: the unlock passphrase comes from
# $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
TPM_STATUS="enrolled"
# Enrolment alone does nothing at boot: systemd-cryptsetup only uses
# the TPM2 keyslot when the cmdline asks for it. install_bootloader
# (step 7) already wrote rd.luks.name= and ran grub-mkconfig, so
# rewrite the line with rd.luks.options=...tpm2-device=auto appended
# and regenerate grub.cfg now.
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."
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."
fi
# TPM-only unlock with an unencrypted /boot is weak: anyone with physical
# access can swap kernel/initrd. Secure Boot is what closes that hole.
if [[ "$TPM_STATUS" == "enrolled" ]] && ! secure_boot_enabled; then
warn "Secure Boot is off: with an unencrypted /boot, anyone can replace the kernel/initrd and reach the decrypted disk. Consider enabling Secure Boot."
log "TPM: enrolled, Secure Boot off"
fi
}
# ── unl0kr (experimental touchscreen unlock) ──────────────────────────────────
unl0kr_fallback_mode_a() {
local why="$1"
warn "unl0kr unavailable (${why}) - falling back to the standard systemd initramfs."
log "unl0kr fallback to Mode A: ${why}"
local main_conf="${TARGET}/etc/mkinitcpio.conf"
if [[ -f "${main_conf}.alvin-bak" ]]; then
cp -a "${main_conf}.alvin-bak" "$main_conf"
fi
# Mode A drop-in + matching cmdline; this also undoes install_mkinitcpio's
# Mode B HOOKS and install_bootloader's cryptdevice= line.
write_mode_a_hooks
if [[ "$LUKS" == "true" ]]; then
local luks_uuid
luks_uuid=$(blkid -s UUID -o value "$(part 2)")
write_mode_a_cmdline "$luks_uuid"
fi
UNL0KR="false"
if [[ "$TPM_STATUS" == "skipped-unl0kr" ]]; then
# TPM enrollment was skipped because unl0kr was selected; now that the
# system is back on Mode A it would be possible, but the prompt has
# passed. Keep a distinct status so the summary can say so plainly
# instead of the generic "it was skipped".
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)."
warn "The system will ask for the LUKS passphrase at boot - test a reboot before relying on it."
}
install_unl0kr() {
[[ "$UNL0KR" == "true" ]] || return 0
info "Installing unl0kr (touchscreen LUKS unlock)..."
sync_target_dbs
local home_in_target="/home/${USERNAME}"
local build_rc=0
# 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=$?
rm -f "${TARGET}/etc/sudoers.d/99-unl0kr-install"
if (( build_rc != 0 )); then
unl0kr_fallback_mode_a "yay failed to build it (exit ${build_rc})"
return 0
fi
# Record what the AUR actually gave us - advertised and PKGBUILD versions
# have disagreed before, so trust pacman, not the web page.
local built_ver
built_ver=$(arch-chroot "$TARGET" pacman -Qi unl0kr 2>/dev/null \
| awk -F': ' '/^Version/{print $2}') || built_ver=""
log "unl0kr built version: ${built_ver:-unknown}"
# The package must ship busybox initcpio hooks (its prepare() strips the
# systemd ones). Read both into the log to confirm hook order and which
# modules the install script pulls in by itself: it adds dm-crypt, crypto
# and DRI libraries but NO touchscreen/HID drivers - those come from the
# MODULES list below.
local hook="${TARGET}/usr/lib/initcpio/hooks/unl0kr"
local hook_install="${TARGET}/usr/lib/initcpio/install/unl0kr"
if [[ ! -f "$hook" || ! -f "$hook_install" ]]; then
unl0kr_fallback_mode_a "initcpio hook files missing after install"
return 0
fi
{
echo "== unl0kr initcpio files (as installed) =="
echo "-- hooks/unl0kr --"
cat "$hook"
echo "-- install/unl0kr --"
cat "$hook_install"
} >>"$LOG" 2>&1 || warn "Could not log the unl0kr hook files (see $LOG)."
# Touchscreen/input stack for the initramfs: mkinitcpio fails the build
# on a MODULES entry it cannot resolve, and -P applies the list to every
# preset - so a module may enter MODULES only if EVERY installed kernel
# knows it (modinfo -k runs inside the target, where /lib/modules holds
# linux + linux-cachyos, not the live kernel).
local -a candidates=(
i2c_hid i2c_hid_acpi hid_multitouch hid_generic usbhid
intel_lpss i2c_i801 i2c_piix4 i2c_amd_mp2
)
local -a keep=() kvers=()
local kdir kver m ok_all
for kdir in "${TARGET}"/lib/modules/*; do
[[ -d "$kdir" ]] && kvers+=("$(basename "$kdir")")
done
for m in "${candidates[@]}"; do
ok_all=1
for kver in "${kvers[@]+"${kvers[@]}"}"; do
if ! arch-chroot "$TARGET" modinfo -k "$kver" "$m" >/dev/null 2>&1; then
ok_all=0
break
fi
done
if (( ok_all )); then
keep+=("$m")
fi
done
log "unl0kr MODULES: ${keep[*]:-none}"
if ((${#keep[@]} > 0)); then
printf 'MODULES=(%s)\n' "${keep[*]}" \
>> "${TARGET}/etc/mkinitcpio.conf.d/alvin.conf"
fi
info "Rebuilding initramfs with unl0kr..."
if ! arch-chroot "$TARGET" mkinitcpio -P >>"$LOG" 2>&1; 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
unl0kr_fallback_mode_a "grub-mkconfig failed"
return 0
fi
# Prove the image really carries the hook and every module we asked for:
# `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
unl0kr_fallback_mode_a "lsinitcpio could not read the image"
return 0
fi
if ! grep -qw 'unl0kr' <<< "$analysis"; then
unl0kr_fallback_mode_a "unl0kr hook missing from initramfs"
return 0
fi
# Compare against the image's module list, expecting only modules that
# are LOADABLE for the kernel that built initramfs-linux.img: a builtin
# may legally sit in MODULES (mkinitcpio preloads it) but never appears
# in `lsinitcpio -a`'s list - it is part of the kernel itself - and
# expecting it there would make the check false-fail and discard a
# perfectly good unl0kr install on every run.
local -a expect=() missing=()
local linux_kver mi m_norm mods_section
linux_kver=$(arch-chroot "$TARGET" pacman -Ql linux 2>/dev/null \
| sed -n 's|.* /usr/lib/modules/\([^/]*\)/modules\.builtin.*|\1|p' \
| head -n1) || linux_kver=""
for m in "${keep[@]+"${keep[@]}"}"; do
mi=$(arch-chroot "$TARGET" modinfo -k "$linux_kver" "$m" 2>/dev/null || true)
if ! grep -q '(builtin)' <<< "$mi"; then
expect+=("$m")
fi
done
log "unl0kr image check: kernel=${linux_kver:-unknown} expect=${expect[*]:-none}"
mods_section=$(awk '/==> Included modules/{f=1;next} f&&/^==>/{exit} f' <<< "$analysis" \
| tr -s ' \t' '\n' | tr '_' '-')
for m in "${expect[@]+"${expect[@]}"}"; do
m_norm=${m//_/-}
grep -qxF -- "$m_norm" <<< "$mods_section" || missing+=("$m")
done
if ((${#missing[@]} > 0)); then
unl0kr_fallback_mode_a "modules missing from initramfs: ${missing[*]}"
return 0
fi
# Every other preset image from this mkinitcpio -P (linux-cachyos,
# fallbacks) shares the same drop-in, but verify rather than assume.
local img
for img in "${TARGET}"/boot/initramfs-*.img; do
[[ -f "$img" ]] || continue
[[ "$img" == "${TARGET}/boot/initramfs-linux.img" ]] && continue
if ! lsinitcpio -a "$img" 2>/dev/null | grep -qw 'unl0kr'; then
warn "$(basename "$img") lacks the unl0kr hook (see $LOG) - the default image is fine."
fi
done
ok "unl0kr installed; initramfs verified."
log "unl0kr: done (version=${built_ver:-unknown} modules=${#keep[@]})"
}
# ── Login manager ─────────────────────────────────────────────────────────────
ensure_hyprland_session() {
local desktop="${TARGET}/usr/share/wayland-sessions/hyprland.desktop"
if [[ -f "$desktop" ]]; then
return 0
fi
mkdir -p "$(dirname "$desktop")"
cat > "$desktop" <<'DESKTOP'
[Desktop Entry]
Name=Hyprland
Comment=Hyprland wayland session
Exec=Hyprland
Type=Application
DesktopNames=Hyprland
DESKTOP
log "wayland-sessions/hyprland.desktop was missing - created a minimal one."
}
install_none_profile() {
local home_in_target="/home/${USERNAME}"
local owner
owner=$(target_owner)
local shell_name
shell_name=$(arch-chroot "$TARGET" getent passwd "$USERNAME" \
| awk -F: '{print $NF}') || shell_name="/bin/bash"
local profile=""
case "$shell_name" in
*zsh)
profile="${home_in_target}/.zprofile"
;;
*fish)
warn "fish is the login shell - skipping tty autostart; run 'Hyprland' after login."
log "tty profile: skipped (fish shell)"
return 0
;;
*)
if [[ -f "${TARGET}${home_in_target}/.bash_profile" \
|| ! -f "${TARGET}${home_in_target}/.profile" ]]; then
profile="${home_in_target}/.bash_profile"
else
profile="${home_in_target}/.profile"
fi
;;
esac
# Already launching Hyprland (skel, end-4 dotfiles, a previous run)?
if [[ -f "${TARGET}${profile}" ]] && grep -qi 'hyprland' "${TARGET}${profile}"; then
log "tty profile: ${profile} already launches Hyprland - skipped"
return 0
fi
# Launch plain Hyprland - the same way greetd/sddm/gdm paths do, so the
# login method does not silently change with the chosen manager. (The
# hyprland package also ships hyprland-uwsm.desktop for anyone who wants
# a uwsm-managed session; it stays pickable in the DM's session menu.)
{
echo ""
echo "# alvin-install: start Hyprland on tty1 (pam_systemd sets XDG_VTNR)."
echo "if [[ -z \"\${WAYLAND_DISPLAY:-}\" && \"\${XDG_VTNR:-}\" == \"1\" ]]; then"
echo " exec Hyprland"
echo "fi"
} >> "${TARGET}${profile}"
chown "$owner" "${TARGET}${profile}" 2>/dev/null || true
ok "tty login will start Hyprland via ${profile}."
log "tty profile: ${profile} (exec Hyprland on VT1)"
}
install_login_manager() {
info "Configuring login manager: ${LOGIN_MANAGER}..."
# Exactly one way in: disable everything known first (this also clears
# the display-manager.service alias and any previous run's leftovers).
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)."
fi
done
rm -f "${TARGET}/etc/systemd/system/getty@tty1.service.d/autologin.conf"
# greetd/sddm/gdm all bind tty1 themselves - getty must not fight them
# for the device ('none' re-enables it in its branch). Each DM branch
# disables getty only AFTER its enable succeeded, so a failed enable
# never leaves the system without any login path.
case "$LOGIN_MANAGER" in
greetd)
sync_target_dbs
if ! arch-chroot "$TARGET" pacman -Si greetd >/dev/null 2>&1; then
warn "greetd is not resolvable in the target repos - falling back to 'none'."
LOGIN_MANAGER="none"
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'."
LOGIN_MANAGER="none"
install_none_profile
return
fi
# 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)."
fi
mkdir -p "${TARGET}/etc/greetd"
cat > "${TARGET}/etc/greetd/config.toml" <<'TOML'
[terminal]
vt = 1
[default_session]
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
ok "greetd + tuigreet enabled (Hyprland)."
else
warn "Could not enable greetd.service (see $LOG) - keeping getty@tty1 as the login path."
fi
;;
sddm)
sync_target_dbs
if ! arch-chroot "$TARGET" pacman -Si sddm >/dev/null 2>&1; then
warn "sddm is not resolvable in the target repos - falling back to 'none'."
LOGIN_MANAGER="none"
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'."
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
ok "sddm enabled (Hyprland)."
else
warn "Could not enable sddm.service (see $LOG) - keeping getty@tty1 as the login path."
fi
;;
gdm)
sync_target_dbs
warn "gdm pulls in GNOME components - expect a large download."
if ! arch-chroot "$TARGET" pacman -Si gdm >/dev/null 2>&1; then
warn "gdm is not resolvable in the target repos - falling back to 'none'."
LOGIN_MANAGER="none"
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'."
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
ok "gdm enabled (Hyprland session)."
else
warn "Could not enable gdm.service (see $LOG) - 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
install_none_profile
;;
esac
log "Login manager: ${LOGIN_MANAGER}"
}
# ── Autologin ─────────────────────────────────────────────────────────────────
install_autologin() {
AUTOLOGIN="disabled"
if [[ "$TPM_STATUS" == "enrolled" ]]; then
info "Autologin stays disabled: TPM2 auto-unlock is enrolled (the password is still required at login)."
log "Autologin: disabled (TPM enrolled)"
else
# Default: yes when LUKS is on (TPM did not take over unlock), no
# when nothing is encrypted - then autologin would mean zero auth.
local choice header_text
if [[ "$LUKS" == "true" ]]; then
header_text="+ Autologin (default: yes)"
choice=$(gum choose --header "$header_text" \
"Yes - autologin to Hyprland" \
"No - require the password") || choice="No"
else
header_text="+ Autologin (default: no) - no encryption + no password = no auth at all"
choice=$(gum choose --header "$header_text" \
"No - require the password" \
"Yes - autologin to Hyprland") || choice="No"
fi
if [[ "$choice" == Yes* ]]; then
AUTOLOGIN="enabled"
fi
log "Autologin: ${AUTOLOGIN} (luks=${LUKS} tpm=${TPM_STATUS})"
fi
# Apply idempotently: every run either writes its file or removes the one
# a previous run left behind, so turning autologin off stays clean.
case "$LOGIN_MANAGER" in
none)
local dropin="${TARGET}/etc/systemd/system/getty@tty1.service.d/autologin.conf"
if [[ "$AUTOLOGIN" == "enabled" ]]; then
mkdir -p "$(dirname "$dropin")"
cat > "$dropin" <<EOF
[Service]
ExecStart=
ExecStart=-/usr/bin/agetty --autologin ${USERNAME} --noclear %I \$TERM
EOF
log "Autologin: getty@tty1 drop-in written"
else
rm -f "$dropin"
fi
;;
greetd)
# Whole-file rewrite makes both directions idempotent.
if [[ "$AUTOLOGIN" == "enabled" ]]; then
cat > "${TARGET}/etc/greetd/config.toml" <<EOF
[terminal]
vt = 1
[default_session]
command = "tuigreet --cmd Hyprland"
user = "greeter"
[initial_session]
user = "${USERNAME}"
command = "Hyprland"
EOF
log "Autologin: greetd [initial_session] enabled"
else
cat > "${TARGET}/etc/greetd/config.toml" <<'TOML'
[terminal]
vt = 1
[default_session]
command = "tuigreet --cmd Hyprland"
user = "greeter"
TOML
fi
;;
sddm)
local sddm_auto="${TARGET}/etc/sddm.conf.d/autologin.conf"
if [[ "$AUTOLOGIN" == "enabled" ]]; then
mkdir -p "$(dirname "$sddm_auto")"
cat > "$sddm_auto" <<EOF
[Autologin]
User=${USERNAME}
Session=hyprland.desktop
EOF
log "Autologin: sddm autologin.conf written"
else
rm -f "$sddm_auto"
fi
;;
gdm)
local gdm_conf="${TARGET}/etc/gdm/custom.conf"
if [[ "$AUTOLOGIN" == "enabled" ]]; then
mkdir -p "$(dirname "$gdm_conf")"
cat > "$gdm_conf" <<EOF
[daemon]
AutomaticLoginEnable=true
AutomaticLogin=${USERNAME}
EOF
log "Autologin: gdm custom.conf written"
elif [[ -f "$gdm_conf" ]]; then
sed -i -e '/^AutomaticLoginEnable=/d' \
-e '/^AutomaticLogin=/d' "$gdm_conf"
fi
;;
esac
# TPM disclaimer for the final summary. Scoped to LUKS installs: without
# encryption there is no passphrase/TPM story to disclaim.
TPM_DISCLAIMER=""
if [[ "$LUKS" == "true" && "$TPM_STATUS" != "enrolled" ]]; then
local reason
case "$TPM_STATUS" in
unsupported) reason="no usable TPM2 device was detected" ;;
failed) reason="enrollment failed (see ${LOG})" ;;
skipped-unl0kr) reason="it is disabled because unl0kr is enabled" ;;
skipped-unl0kr-fallback) reason="unl0kr was selected, so TPM2 enrollment was never offered (and was not re-offered after unl0kr fell back to the standard initramfs)" ;;
skipped) reason="it was skipped during installation" ;;
*) reason="it was not set up" ;;
esac
TPM_DISCLAIMER="TPM2 auto-unlock is disabled: ${reason}. The LUKS passphrase is still required at boot."
log "TPM disclaimer: ${TPM_DISCLAIMER}"
fi
ok "Login set: manager=${LOGIN_MANAGER}, autologin=${AUTOLOGIN}."
}
# ── Build stamp ───────────────────────────────────────────────────────────────
install_motd() {
# Only stamped media carry a stamp; without one (local non-git build)
# the installed system keeps whatever motd the target packages shipped.
local stamp="/etc/alvin-build-stamp"
if [[ ! -f "$stamp" ]]; then
log "motd: no build stamp on this medium - target motd left as-is"
return 0
fi
cp /etc/motd "${TARGET}/etc/motd" 2>/dev/null || true
cat "$stamp" >> "${TARGET}/etc/motd"
log "motd: appended build stamp: $(tr -d '\n' < "$stamp")"
ok "Build stamp appended to the installed /etc/motd."
}
install_cleanup() {
info "Cleaning up..."
cp "$LOG" "${TARGET}/var/log/alvin-install.log" 2>/dev/null || true
unset PASS
log "Installation complete."
ok "Installation complete!"
echo ""
gum style --foreground 10 --align center \
"Arch Linux installed successfully." \
"" \
"Login manager: ${LOGIN_MANAGER} Autologin: ${AUTOLOGIN:-disabled}" \
"TPM2 auto-unlock: ${TPM_STATUS:-n/a} unl0kr: ${UNL0KR}"
if [[ -n "${TPM_DISCLAIMER}" ]]; then
echo ""
warn "${TPM_DISCLAIMER}"
fi
echo ""
gum style --foreground 10 --align center \
"Run 'reboot' to boot into your new system."
echo ""
}
# ── Main ──────────────────────────────────────────────────────────────────────
main() {
check_prereqs
screen_welcome
screen_keymap
screen_network
screen_disk
screen_luks
screen_unl0kr
screen_filesystem
screen_username
screen_password
screen_hostname
screen_git
screen_timezone
screen_locale
screen_login_manager
screen_summary
install_partition
install_luks
install_format_mount
install_pacstrap
install_fstab
install_cachyos_repo
install_timezone
install_locale
install_hostname
install_network
install_user
install_git_ssh
install_end4_dots
install_mkinitcpio
install_bootloader
install_tpm
install_unl0kr
install_login_manager
install_autologin
install_motd
install_cleanup
}
main "$@"