#!/usr/bin/env bash set -euo pipefail # The chroot's /dev is the kernel's single shared devtmpfs, which only gets the # fd/stdin/stdout/stderr symlinks at boot (from mkinitcpio's init). If the # runner environment lacks them, process substitution (< <(...)) and upstream # tools (pacman-key) fail with "… /dev/fd/63: No such file or directory". # Self-heal here; the CI build-iso workflow does the same before pacstrap so # that mkinitcpio's hook (which pipes through /dev/stdin) works too. for _link in "fd:/proc/self/fd" "stdin:/proc/self/fd/0" \ "stdout:/proc/self/fd/1" "stderr:/proc/self/fd/2"; do [[ -e "/dev/${_link%%:*}" || -L "/dev/${_link%%:*}" ]] || ln -sfn "${_link#*:}" "/dev/${_link%%:*}" done unset _link # Populate the keyring in the built root from the keyring bundles # (/usr/share/pacman/keyrings, incl. the copied cachyos keyring). # This mirrors the guide; it runs inside the image you are making. # pacman-key --populate needs no network. pacman-key --init pacman-key --populate archlinux cachyos chaotic # Restore the strict live /etc/pacman.conf after pacstrap installs the # 'pacman' package, which ships a stock /etc/pacman.conf and would otherwise # overwrite this directory's configured version. install -m 0644 /root/.live-pacman.conf /etc/pacman.conf rm -f /root/.live-pacman.conf # Build stamp staged by profiledef.sh (absent for non-git, non-CI builds): # keep the stamp file itself as a reference, drop the staging copy, and # stamp the live medium's motd so the booted ISO identifies its build (the # installed system keeps the stock package motd - alvin-install never # writes /etc/motd). if [[ -f /root/.iso-build-stamp ]]; then install -m 0644 /root/.iso-build-stamp /etc/alvin-build-stamp cat /etc/alvin-build-stamp >> /etc/motd rm -f /root/.iso-build-stamp fi # ============================================================================ # Pre-install dotfile dependencies into /etc/skel so new users get them # instantly on first login (no network required). # ============================================================================ SKEL="/etc/skel" # ── Oh-My-Zsh ──────────────────────────────────────────────────────────────── if [[ ! -d "${SKEL}/.oh-my-zsh" ]]; then echo "[customize] Cloning Oh-My-Zsh into skel..." git clone --depth=1 https://github.com/ohmyzsh/ohmyzsh.git "${SKEL}/.oh-my-zsh" fi # Oh-My-Zsh custom plugins ZSH_CUSTOM="${SKEL}/.oh-my-zsh/custom" mkdir -p "${ZSH_CUSTOM}/plugins" declare -A OMZ_PLUGINS=( [zsh-history-substring-search]="https://github.com/zsh-users/zsh-history-substring-search.git" [zsh-autosuggestions]="https://github.com/zsh-users/zsh-autosuggestions.git" [zsh-eza]="https://github.com/z-shell/zsh-eza.git" [fzf-zsh-plugin]="https://github.com/unixorn/fzf-zsh-plugin.git" [zsh-syntax-highlighting]="https://github.com/zsh-users/zsh-syntax-highlighting.git" [zsh-autocomplete]="https://github.com/marlonrichert/zsh-autocomplete.git" ) for plugin in "${!OMZ_PLUGINS[@]}"; do target="${ZSH_CUSTOM}/plugins/${plugin}" if [[ ! -d "${target}" ]]; then echo "[customize] Cloning OMZ plugin: ${plugin}" git clone --depth=1 "${OMZ_PLUGINS[${plugin}]}" "${target}" fi done # grml-zsh-config owns /etc/skel/.zshrc, so shipping ours in # airootfs/etc/skel would make pacstrap abort ("exists in filesystem"). # Stage it under /root and overwrite the package's copy here, after pacstrap. if [[ -f /root/.zshrc-skel ]]; then install -m 0644 /root/.zshrc-skel "${SKEL}/.zshrc" rm -f /root/.zshrc-skel fi # ── LazyVim (lazy.nvim bootstraps on first launch, but pre-clone the plugin) ─ NVIM_DATA="${SKEL}/.local/share/nvim" mkdir -p "${NVIM_DATA}" if [[ ! -d "${NVIM_DATA}/lazy/lazy.nvim" ]]; then echo "[customize] Pre-cloning lazy.nvim for LazyVim..." git clone --filter=blob:none --branch=stable \ https://github.com/folke/lazy.nvim.git "${NVIM_DATA}/lazy/lazy.nvim" fi # ── end-4 dots-hyprland, staged for alvin-install (avoids a clone at install) ─ DOTS_REPO="/opt/dots-hyprland" if [[ ! -d "${DOTS_REPO}/.git" ]]; then echo "[customize] Cloning end-4 dots-hyprland..." git clone --depth=1 https://github.com/end-4/dots-hyprland.git "${DOTS_REPO}" git -C "${DOTS_REPO}" submodule update --init --depth=1 fi # end-4 pins quickshell to 41651d7 (Sep 25), which predates quickshell's # Qt 6.12 fix (5d5d498, Oct 3: "use MOC includes instead of opaque pointer # definitions - Fixes linking issues and Qt 6.12 compatibility"). Arch # shipped qt6 6.12.0 into extra on Oct 8; with the old pin qmetatype.h's # "Meta Types must be fully defined" static_assert kills the moc build of # quickshell-hyprland-ipc (failed CI run 137) and the whole ISO build with # it. Force the fixed commit, but ONLY while end-4 still carries the old # pin - once they bump it themselves the guard stops matching and their # pin wins untouched. localrepo/check-quickshell-pin.sh (weekly CI) # verifies both this pin and end-4's stay Qt 6.12-compatible. QS_PKG="${DOTS_REPO}/sdata/dist-arch/illogical-impulse-quickshell-git/PKGBUILD" if [[ -f "${QS_PKG}" ]] && grep -q "^_commit='41651d7" "${QS_PKG}"; then echo "[customize] Bumping end-4 quickshell pin to 11ca60be (Qt 6.12 fix)..." sed -i "s|^_commit='41651d7[^']*'|_commit='11ca60be22b063478ed9586ca1d7f92f0f261caf'|" "${QS_PKG}" fi # ── end4-pC: the default quickshell config, shipped in skel ────────────────── # pctrade/end4-pC is a fork of the ii shell config. It lives next to ii in # ~/.config/quickshell and does not modify it; which one runs is decided by # qsConfig (patched just below). The repo has no submodules. if [[ ! -d "${SKEL}/.config/quickshell/end4-pC" ]]; then echo "[customize] Cloning end4-pC quickshell config into skel..." git clone --depth=1 https://github.com/pctrade/end4-pC.git \ "${SKEL}/.config/quickshell/end4-pC" fi # ── Default quickshell config: ii -> end4-pC ───────────────────────────────── # alvin-install copies this staged repo onto the target and runs `./setup # install`, whose rsync writes hypr/hyprland/variables.lua into the user's # home - so patching the staged copy is what sets qsConfig on the installed # system. Re-running is a no-op; anything other than ii/end4-pC aborts. VARIABLES_LUA="${DOTS_REPO}/dots/.config/hypr/hyprland/variables.lua" if grep -Fq 'hl.env("qsConfig", "ii")' "${VARIABLES_LUA}"; then echo "[customize] Setting qsConfig to end4-pC..." sed -i 's|hl\.env("qsConfig", "ii")|hl.env("qsConfig", "end4-pC")|' "${VARIABLES_LUA}" fi if ! grep -Fq 'hl.env("qsConfig", "end4-pC")' "${VARIABLES_LUA}"; then echo "[customize] qsConfig is neither ii nor end4-pC in ${VARIABLES_LUA}" >&2 exit 1 fi # ============================================================================ # Pre-install end-4 dependencies into the image (network heavy, once per ISO) # ============================================================================ # ./setup install-deps resolves and *builds* every AUR/source dependency the # dotfiles need (quickshell, MicroTeX, theming, fonts, ...). Doing it here lets # alvin-install run its end-4 step with --skip-alldeps, so an install only # copies files - no AUR clones, no makepkg, no dependency resolution. # # The script refuses to run as root, hence the throwaway builder account. # ============================================================================ BUILD_USER="ii-builder" if ! id -u "${BUILD_USER}" &>/dev/null; then useradd -m -u 1500 "${BUILD_USER}" fi echo "${BUILD_USER} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/99-ii-builder chmod 440 /etc/sudoers.d/99-ii-builder # makepkg writes src/, pkg/ and *.pkg.tar.zst into the PKGBUILD directories, # so the tree must be owned by the account that runs it. chown -R "${BUILD_USER}:${BUILD_USER}" "${DOTS_REPO}" # -f: never prompt (ask=false); -s: skip "pacman -Syu"; # --skip-plasmaintg: optional Firefox widget that pulls 410 pkgs / 526 MiB of # KDE packages onto a non-KDE system; # stdin: answer end-4's x() failure prompt with "e", so a failed command # aborts the build instead of blocking forever without a TTY. # shellcheck disable=SC2024 # the redirect is meant for the child, not sudo if ! sudo -u "${BUILD_USER}" env HOME="/home/${BUILD_USER}" \ GIT_TERMINAL_PROMPT=0 \ bash -c "cd ${DOTS_REPO} && ./setup install-deps -f -s --skip-plasmaintg" \ < <(yes e); then echo "[customize] end-4 ./setup install-deps failed" >&2 exit 1 fi # ── Harvest the packages install-deps just built into the in-image local repo ─ # The live /etc/pacman.conf points [local-aur] at /opt/localrepo; without the # harvested copies pacstrap cannot resolve these packages at install time and # would silently drop them from the target system. mkdir -p /opt/localrepo shopt -s nullglob # from a package's embedded .PKGINFO (update.sh does the same) pkg_meta() { tar -xOf "$1" .PKGINFO 2>/dev/null | awk -F' = ' '/^pkgname = /{n=$2} /^pkgver = /{v=$2} END{print n, v}' } mapfile -d '' -t built_pkgs < <( find "${DOTS_REPO}" "/home/${BUILD_USER}/.cache/yay" \ -name '*.pkg.tar.zst' -print0 2>/dev/null ) declare -A built_file=() built_ver=() for p in "${built_pkgs[@]}"; do read -r n v < <(pkg_meta "$p") [[ -n "${n:-}" && -n "${v:-}" ]] || continue built_file["$n"]="$p" built_ver["$n"]="$v" done # One entry per package name, and the entry must be the version the image # actually installed - a stale copy with the same name but another version # would let pacstrap install something else than what was tested. for existing in /opt/localrepo/*.pkg.tar.zst; do [[ -e "$existing" ]] || continue read -r n v < <(pkg_meta "$existing") [[ -n "${n:-}" ]] || continue if [[ -n "${built_ver[$n]:-}" ]]; then echo "[customize] Replacing localrepo ${n} ${v} with built ${built_ver[$n]}" rm -f "$existing" fi done if ((${#built_file[@]} > 0)); then echo "[customize] Harvesting ${#built_file[@]} package(s) into /opt/localrepo" cp -f -- "${built_file[@]}" /opt/localrepo/ # makepkg -Afsi leaves its archives next to the PKGBUILD. They are already # harvested (and installed in this image); keeping them in the dots tree # would ship a second copy inside /opt/dots-hyprland, which alvin-install # copies to ~/.dots-hyprland on every installed system. find "${DOTS_REPO}" -type f -name '*.pkg.tar.zst' -delete fi # Rebuild the repo database (CI/update.sh ship a pre-built one alongside the # packages) and refresh the live system's copy of it. No network needed: the # remote repos were synced during pacstrap. local_pkgs=(/opt/localrepo/*.pkg.tar.zst) if ((${#local_pkgs[@]} > 0)); then echo "[customize] Rebuilding local-aur db (${#local_pkgs[@]} packages)..." repo-add -R /opt/localrepo/local-aur.db.tar.gz "${local_pkgs[@]}" mkdir -p /var/lib/pacman/sync cp -fL /opt/localrepo/local-aur.db /var/lib/pacman/sync/local-aur.db if [[ -f /opt/localrepo/local-aur.files ]]; then cp -fL /opt/localrepo/local-aur.files /var/lib/pacman/sync/local-aur.files fi fi # ── makepkg leftovers + throwaway account do not belong in the ISO ────────── if [[ -d "${DOTS_REPO}/sdata/dist-arch" ]]; then find "${DOTS_REPO}/sdata/dist-arch" -mindepth 2 -maxdepth 2 -type d \ \( -name src -o -name pkg \) -prune -exec rm -rf {} + fi rm -f /etc/sudoers.d/99-ii-builder # Processes still running as BUILD_USER make userdel exit 8 and abort the # build. end-4's ./setup is the usual source: sudo_init_keepalive forks # `(sleep 60; sudo true) &` and its EXIT trap kills only the subshell, # orphaning the sleep child for up to a minute. Mirrors shadow's # user_busy_processes(): match real/effective/saved uid AND require the # process to live inside this build chroot - the runner shares our PID # namespace, and a host process could carry the same uid. # # mkarchiso runs this script via arch-chroot, which mounts proc *before* # `unshare --fork --pid`: /proc entry numbers belong to the outer pid # namespace, while kill(2) resolves pids in our (nested) namespace. Use the # last NSpid field - the pid in our own namespace - for signalling; keep the # /proc entry number for logging, since that is what userdel reports. nspid_fields() { awk -F'\t' '/^NSpid:/{ $1=""; sub(/^ +/, ""); print; exit }' "$1" } builder_procs() { local uid broot st p key real eff saved kpid cmd local -a nsf self_nsf=() uid=$(id -u "${BUILD_USER}") || return 0 broot=$(stat -Lc '%d:%i' /) read -r -a self_nsf < <(nspid_fields /proc/self/status) for st in /proc/[0-9]*/status; do [[ -r "$st" ]] || continue real=""; eff=""; saved="" while read -r key real eff saved _; do [[ $key == "Uid:" ]] && break done < "$st" || continue [[ $real == "$uid" || $eff == "$uid" || $saved == "$uid" ]] || continue p=${st#/proc/}; p=${p%/status} # Stat of /proc//root needs CAP_SYS_PTRACE (or same-uid): exactly # what shadow's userdel uses, so a process we cannot see here is one # userdel cannot flag either. Compare by device+inode; skip on failure. [[ $(stat -Lc '%d:%i' "/proc/$p/root" 2>/dev/null) == "$broot" ]] || continue # NSpid chain length must equal ours, i.e. the process must live in our # pid namespace - only then is its last field killable from here. nsf=() read -r -a nsf < <(nspid_fields "/proc/$p/status") || continue if ((${#nsf[@]} > 0 && ${#nsf[@]} == ${#self_nsf[@]})); then kpid=${nsf[${#nsf[@]} - 1]} else kpid="-" fi cmd=$(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null) printf '%s\t%s\t%s\n' "$p" "$kpid" "$cmd" done return 0 } sweep_builder_procs() { local sig=$1 p kpid cmd kerr local -a pids=() while IFS=$'\t' read -r p kpid cmd; do [[ -n $p ]] || continue if [[ -z $kpid || $kpid == "-" ]]; then echo "[customize] ${BUILD_USER} process outside our pid namespace (cannot signal): pid $p: $cmd" continue fi echo "[customize] ${BUILD_USER} process ($sig): pid $p: $cmd" pids+=("$kpid") done < <(builder_procs) if ((${#pids[@]} > 0)); then # Surface signal failures (they used to be swallowed, hiding a # pid-namespace mismatch as a silent no-op sweep). if ! kerr=$(kill -"$sig" "${pids[@]}" 2>&1); then [[ -n $kerr ]] && printf '[customize] kill -%s: %s\n' "$sig" "$kerr" fi fi return 0 } sweep_builder_procs TERM sleep 2 sweep_builder_procs KILL sleep 1 if ! userdel -r "${BUILD_USER}"; then rm -rf "/home/${BUILD_USER:?}" if ! userdel "${BUILD_USER}"; then echo "[customize] FATAL: cannot remove user ${BUILD_USER}; leftover processes:" >&2 builder_procs >&2 || true exit 1 fi fi chown -R root:root "${DOTS_REPO}" # ── Google Sans Flex into skel: install_google_sans_flex() early-returns once # fc-list sees the font, so the install step needs no network ────────────── FONT_DIR="${SKEL}/.local/share/fonts/illogical-impulse-google-sans-flex" if [[ ! -f "${FONT_DIR}/.git/HEAD" ]]; then echo "[customize] Pre-installing Google Sans Flex into skel..." rm -rf "${FONT_DIR}" git clone --depth=1 --recurse-submodules --shallow-submodules \ https://github.com/end-4/google-sans-flex "${FONT_DIR}" fi # ── uv into skel: CPython 3.12 + the end-4 requirements wheel cache ────────── # install-python-packages() runs `uv venv -p 3.12` and `uv pip install -r ...` # during setup. Arch has no python312 package (uv would download one) and the # pinned requirements are ~250 MiB of wheels, so warm both into skel: the venv # is then created and filled straight from the image. if command -v uv &>/dev/null; then echo "[customize] Pre-warming uv (CPython 3.12 + requirements cache)..." warm_venv="/tmp/uv-warm-venv" rm -rf "${warm_venv}" UV_CACHE_DIR="${SKEL}/.cache/uv" \ UV_PYTHON_INSTALL_DIR="${SKEL}/.local/share/uv/python" \ uv venv -p 3.12 "${warm_venv}" UV_CACHE_DIR="${SKEL}/.cache/uv" \ UV_PYTHON_INSTALL_DIR="${SKEL}/.local/share/uv/python" \ uv pip install --python "${warm_venv}/bin/python" \ -r "${DOTS_REPO}/sdata/uv/requirements.txt" rm -rf "${warm_venv}" else echo "[customize] uv not found - skipping python/wheel pre-warm." fi # ── Fish: default shell for the live root user ─────────────────────────────── # (alvin-install creates the installed user with fish as well.) if command -v fish &>/dev/null; then echo "[customize] Setting fish as root's shell on the live medium..." chsh -s /usr/bin/fish root 2>/dev/null || true fi # ── Ensure skel directories have correct permissions ───────────────────────── # Only take away group/other write and make everything readable: forcing # files to 644 stripped +x from executables that are not *.sh (python/fish # helpers in the cloned quickshell configs). chmod -R go-w,a+rX "${SKEL}/.config" "${SKEL}/.local" 2>/dev/null || true find "${SKEL}/.config" -name '*.sh' -exec chmod 755 {} + 2>/dev/null || true echo "[customize] Dotfile pre-installation complete."