#!/usr/bin/env bash # build.sh - Build the custom Arch ZFS installation ISO # Must be run as root # # Uses linux-lts kernel with zfs-dkms from archzfs GitHub releases. # DKMS builds ZFS from source, ensuring it always matches the kernel version. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROFILE_DIR="$SCRIPT_DIR/profile" WORK_DIR="$SCRIPT_DIR/work" OUT_DIR="$SCRIPT_DIR/out" INSTALLER_DIR="$SCRIPT_DIR/installer" # AUR local-repo build helpers (build_aur_packages, stanza/manifest/package # helpers). See docs/aur-local-repo-spec.org. # shellcheck source=build-aur.sh source "$SCRIPT_DIR/build-aur.sh" # Live ISO root password (for SSH access during testing/emergencies) LIVE_ROOT_PASSWORD="archangel" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' info() { echo -e "${GREEN}[INFO]${NC} $1"; } warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } # Safe cleanup function - unmounts bind mounts before removing work directory # This prevents damage to host /dev, /sys, /proc if build is interrupted safe_cleanup_work_dir() { local airootfs="$WORK_DIR/x86_64/airootfs" if [[ -d "$airootfs" ]]; then # Unmount in reverse order of typical mount hierarchy # Use lazy unmount (-l) to handle busy filesystems for mount_point in \ "$airootfs/dev/pts" \ "$airootfs/dev/shm" \ "$airootfs/dev/mqueue" \ "$airootfs/dev/hugepages" \ "$airootfs/dev" \ "$airootfs/sys" \ "$airootfs/proc" \ "$airootfs/run"; do if mountpoint -q "$mount_point" 2>/dev/null; then umount -l "$mount_point" 2>/dev/null || true fi done # Catch any other mounts under airootfs (bind mounts not in the # explicit list above). Deepest-first via reverse sort. # grep exits 1 on no-match; with pipefail that would propagate and # trip set -e, so swallow it — "no leftover mounts" is the common case. local leftover leftover=$(findmnt --list --noheadings -o TARGET 2>/dev/null \ | grep "$airootfs" | sort -r || true) if [[ -n "$leftover" ]]; then while IFS= read -r mp; do umount -l "$mp" 2>/dev/null || true done <<< "$leftover" fi # Small delay to let lazy unmounts complete sleep 1 fi # Now safe to remove rm -rf "$WORK_DIR" } # Trap to ensure cleanup on interruption (Ctrl+C, errors, etc.) # This prevents host /dev damage from interrupted builds cleanup_on_exit() { local exit_code=$? if [[ $exit_code -ne 0 ]] && [[ -d "$WORK_DIR" ]]; then warn "Build interrupted or failed - cleaning up safely..." safe_cleanup_work_dir fi } trap cleanup_on_exit EXIT INT TERM # Argument parsing. --skip-aur skips the whole AUR local-repo path (build, # profile injection, live config) so the normal ISO builds fast when the AUR # set isn't what's being worked on. SKIP_AUR=false for arg in "$@"; do case "$arg" in --skip-aur) SKIP_AUR=true ;; *) error "Unknown argument: $arg (supported: --skip-aur)" ;; esac done # Preflight checks [[ $EUID -ne 0 ]] && error "This script must be run as root" [[ -f /etc/arch-release ]] || error "This script must be run on Arch Linux" MIN_FREE_GB=10 free_kb=$(df --output=avail "$SCRIPT_DIR" | tail -1) free_gb=$((free_kb / 1024 / 1024)) [[ $free_gb -lt $MIN_FREE_GB ]] && error "Insufficient disk space: ${free_gb}GB free, ${MIN_FREE_GB}GB required" command -v mkarchiso >/dev/null 2>&1 || { info "Installing archiso..." pacman -Sy --noconfirm archiso } # Pre-create the build log in out/ so it survives work/ cleanup and captures # both the AUR build and mkarchiso. Owned by SUDO_USER from the start so a # failed build leaves a user-readable log; tee writes to it as root, but the # file mode stays as set. BUILD_LOG="$OUT_DIR/build-$(date +%Y-%m-%d-%H%M).log" mkdir -p "$OUT_DIR" touch "$BUILD_LOG" if [[ -n "${SUDO_USER:-}" ]]; then chown "$SUDO_USER:$SUDO_USER" "$BUILD_LOG" fi # Clean previous builds (using safe cleanup to handle any leftover mounts) if [[ -d "$WORK_DIR" ]]; then warn "Removing previous work directory..." safe_cleanup_work_dir fi # Always start fresh from releng profile info "Copying base releng profile..." rm -rf "$PROFILE_DIR" cp -r /usr/share/archiso/configs/releng "$PROFILE_DIR" # Switch from linux to linux-lts info "Switching to linux-lts kernel..." sed -i 's/^linux$/linux-lts/' "$PROFILE_DIR/packages.x86_64" sed -i 's/^linux-headers$/linux-lts-headers/' "$PROFILE_DIR/packages.x86_64" # broadcom-wl depends on linux, use DKMS version instead sed -i 's/^broadcom-wl$/broadcom-wl-dkms/' "$PROFILE_DIR/packages.x86_64" # Update bootloader configs to use linux-lts kernel info "Updating bootloader configurations for linux-lts..." # UEFI systemd-boot entries for entry in "$PROFILE_DIR"/efiboot/loader/entries/*.conf; do if [[ -f "$entry" ]]; then sed -i 's/vmlinuz-linux/vmlinuz-linux-lts/g' "$entry" sed -i 's/initramfs-linux\.img/initramfs-linux-lts.img/g' "$entry" fi done # BIOS syslinux entries for cfg in "$PROFILE_DIR"/syslinux/*.cfg; do if [[ -f "$cfg" ]]; then sed -i 's/vmlinuz-linux/vmlinuz-linux-lts/g' "$cfg" sed -i 's/initramfs-linux\.img/initramfs-linux-lts.img/g' "$cfg" fi done # GRUB config if [[ -f "$PROFILE_DIR/grub/grub.cfg" ]]; then sed -i 's/vmlinuz-linux/vmlinuz-linux-lts/g' "$PROFILE_DIR/grub/grub.cfg" sed -i 's/initramfs-linux\.img/initramfs-linux-lts.img/g' "$PROFILE_DIR/grub/grub.cfg" fi # Update mkinitcpio preset for linux-lts (archiso uses custom preset) if [[ -f "$PROFILE_DIR/airootfs/etc/mkinitcpio.d/linux.preset" ]]; then # Rename to linux-lts.preset and update paths mv "$PROFILE_DIR/airootfs/etc/mkinitcpio.d/linux.preset" \ "$PROFILE_DIR/airootfs/etc/mkinitcpio.d/linux-lts.preset" sed -i 's/vmlinuz-linux/vmlinuz-linux-lts/g' \ "$PROFILE_DIR/airootfs/etc/mkinitcpio.d/linux-lts.preset" sed -i 's/initramfs-linux/initramfs-linux-lts/g' \ "$PROFILE_DIR/airootfs/etc/mkinitcpio.d/linux-lts.preset" sed -i "s/'linux' package/'linux-lts' package/g" \ "$PROFILE_DIR/airootfs/etc/mkinitcpio.d/linux-lts.preset" fi # Add archzfs repository to pacman.conf # SigLevel=Never: archzfs GPG key import is unreliable in clean build environments; # repo is explicitly added and served over HTTPS, GPG adds no real value here info "Adding archzfs repository..." cat >> "$PROFILE_DIR/pacman.conf" << 'EOF' [archzfs] Server = https://github.com/archzfs/archzfs/releases/download/experimental SigLevel = Never EOF # Route pacstrap through a local pacoloco caching proxy when one is # running on localhost:9129. Pacoloco proxies Arch core/extra and the # archzfs GitHub-releases URL, caching successful fetches and serving # them on subsequent builds. Catches the recurring archzfs corruption # class — pacoloco re-fetches when a hashed file misses, and once a # good copy lands it stays good. Falls back to direct upstream when # pacoloco isn't listening so builds work on machines without the # optional proxy installed. See README "Build Host Requirements" for # the install steps. if (echo > /dev/tcp/localhost/9129) 2>/dev/null; then info "Routing pacstrap through pacoloco at localhost:9129..." sed -i 's|^Include = /etc/pacman.d/mirrorlist|Server = http://localhost:9129/repo/archlinux/$repo/os/$arch|' \ "$PROFILE_DIR/pacman.conf" sed -i 's|^Server = https://github.com/archzfs/archzfs/releases/download/experimental$|Server = http://localhost:9129/repo/archzfs|' \ "$PROFILE_DIR/pacman.conf" else info "pacoloco not detected — using upstream mirrors directly" fi # Build the AUR local repository and expose it to mkarchiso. build_aur_packages # compiles the v1 AUR set under $SUDO_USER into $SCRIPT_DIR/aur-packages with a # manifest; the build-host [aur] stanza points pacman at that dir with an # absolute file:// path so mkarchiso installs the packages into airootfs. Added # after the pacoloco block so the file:// Server isn't rewritten to localhost. if [[ "$SKIP_AUR" != true ]]; then info "Building AUR local repository..." build_aur_packages 2>&1 | tee -a "$BUILD_LOG" # Guard on the dir: build_aur_packages skips repo creation for an empty # AUR set, and a stanza pointing at a missing dir would fail mkarchiso. if [[ -d "$SCRIPT_DIR/aur-packages" ]]; then info "Adding build-host [aur] repository for mkarchiso..." aur_repo_stanza "file://$SCRIPT_DIR/aur-packages" >> "$PROFILE_DIR/pacman.conf" fi else info "Skipping AUR local repository (--skip-aur)" fi # Add ZFS and our custom packages info "Adding ZFS and custom packages..." cat >> "$PROFILE_DIR/packages.x86_64" << 'EOF' # ZFS support (DKMS builds from source - always matches kernel) zfs-dkms zfs-utils linux-lts-headers # Additional networking wget networkmanager # mDNS for network discovery (ssh root@archangel.local) avahi nss-mdns # Development tools for Claude Code nodejs npm jq # Additional utilities inetutils zsh htop ripgrep eza fd fzf emacs # For installation scripts dialog # Rescue/Recovery tools tealdeer pv rsync mbuffer lsof # Data recovery ddrescue testdisk foremost sleuthkit smartmontools # Boot repair os-prober syslinux # Windows recovery chntpw ntfs-3g hivex # Hardware diagnostics memtester stress-ng lm_sensors lshw dmidecode nvme-cli hdparm iotop # Disk operations partclone fsarchiver partimage xfsprogs btrfs-progs snapper f2fs-tools exfatprogs ncdu tree # Network diagnostics mtr iperf3 iftop nethogs ethtool tcpdump bind nmap wireshark-cli speedtest-cli mosh aria2 tmate sshuttle # Security pass # System tracing and profiling (eBPF/DTrace-like) bpftrace bcc-tools perf # Terminal web browsers w3m EOF # Audited official extra packages (reclassified out of the AUR — installed # from the normal repos, not built) plus, unless skipped, the baked # genuine-AUR set (resolved from the build-host [aur] repo during mkarchiso). # Package names come from build-aur.sh so the build array, this list, and the # manifest never drift. { echo "" echo "# Audited official extra utilities" aur_official_packages # AUR names only when the repo was actually built — otherwise mkarchiso # would try to install them with no [aur] repo to resolve from. if [[ "$SKIP_AUR" != true ]] && [[ -d "$SCRIPT_DIR/aur-packages" ]]; then echo "" echo "# Baked genuine-AUR packages (local [aur] repo)" aur_v1_packages fi } >> "$PROFILE_DIR/packages.x86_64" # Get kernel version for ISO naming info "Querying kernel version..." KERNEL_VER=$(pacman -Si linux-lts 2>/dev/null | grep "^Version" | awk '{print $3}' | cut -d- -f1) if [[ -z "$KERNEL_VER" ]]; then KERNEL_VER="unknown" warn "Could not determine kernel version, using 'unknown'" fi info "LTS Kernel version: $KERNEL_VER" # Update profiledef.sh with our ISO name info "Updating ISO metadata..." # Format: archangel-2026-01-18-vmlinuz-6.12.65-lts-x86_64.iso # mkarchiso builds: {iso_name}-{iso_version}-{arch}.iso ISO_DATE=$(date +%Y-%m-%d) sed -i "s/^iso_name=.*/iso_name=\"archangel-${ISO_DATE}\"/" "$PROFILE_DIR/profiledef.sh" sed -i "s/^iso_version=.*/iso_version=\"vmlinuz-${KERNEL_VER}-lts\"/" "$PROFILE_DIR/profiledef.sh" # Fixed label for stable GRUB boot entry (default is date-based ARCH_YYYYMM) sed -i "s/^iso_label=.*/iso_label=\"ARCHANGEL\"/" "$PROFILE_DIR/profiledef.sh" # Create airootfs directories mkdir -p "$PROFILE_DIR/airootfs/usr/local/bin" mkdir -p "$PROFILE_DIR/airootfs/code" mkdir -p "$PROFILE_DIR/airootfs/etc/systemd/system/multi-user.target.wants" # Ship the baked AUR repo into the live ISO and give it a complete runtime # pacman.conf. archangel ships no airootfs pacman.conf today, so this file # REPLACES the live system's stock /etc/pacman.conf — it must keep the normal # repos and mirrorlist (copied from the pristine releng config, not the # pacoloco-rewritten profile config) and only append [aur]. An [aur]-only file # would break live pacman and the installer's pacstrap. The runtime Server # resolves /usr/share/aur-packages inside the live system. if [[ "$SKIP_AUR" != true ]] && [[ -d "$SCRIPT_DIR/aur-packages" ]]; then info "Shipping AUR repo into the live ISO..." mkdir -p "$PROFILE_DIR/airootfs/usr/share/aur-packages" cp -r "$SCRIPT_DIR/aur-packages/." \ "$PROFILE_DIR/airootfs/usr/share/aur-packages/" info "Creating live pacman.conf with [aur] (normal repos preserved)..." cp /usr/share/archiso/configs/releng/pacman.conf \ "$PROFILE_DIR/airootfs/etc/pacman.conf" aur_repo_stanza "file:///usr/share/aur-packages" \ >> "$PROFILE_DIR/airootfs/etc/pacman.conf" fi # Enable SSH on live ISO info "Enabling SSH on live ISO..." ln -sf /usr/lib/systemd/system/sshd.service \ "$PROFILE_DIR/airootfs/etc/systemd/system/multi-user.target.wants/sshd.service" # Enable Avahi mDNS for network discovery (ssh root@archangel.local) info "Enabling Avahi mDNS..." ln -sf /usr/lib/systemd/system/avahi-daemon.service \ "$PROFILE_DIR/airootfs/etc/systemd/system/multi-user.target.wants/avahi-daemon.service" # Set hostname to "archangel" for mDNS discovery info "Setting hostname to archangel..." echo "archangel" > "$PROFILE_DIR/airootfs/etc/hostname" # Create /etc/hosts with proper hostname entries cat > "$PROFILE_DIR/airootfs/etc/hosts" << 'EOF' 127.0.0.1 localhost ::1 localhost 127.0.1.1 archangel.localdomain archangel EOF # Configure nsswitch.conf for mDNS resolution # Add mdns_minimal before dns in hosts line info "Configuring nss-mdns..." mkdir -p "$PROFILE_DIR/airootfs/etc" cat > "$PROFILE_DIR/airootfs/etc/nsswitch.conf" << 'EOF' # Name Service Switch configuration file. # See nsswitch.conf(5) for details. passwd: files systemd group: files [SUCCESS=merge] systemd shadow: files systemd gshadow: files systemd publickey: files hosts: mymachines mdns_minimal [NOTFOUND=return] resolve [!UNAVAIL=return] files dns networks: files protocols: files services: files ethers: files rpc: files netgroup: files EOF # Set root password for live ISO info "Setting root password for live ISO..." # Generate password hash PASS_HASH=$(openssl passwd -6 "$LIVE_ROOT_PASSWORD") # Modify the existing shadow file's root entry (don't replace entire file) # The releng template has multiple accounts; replacing breaks the file if [[ -f "$PROFILE_DIR/airootfs/etc/shadow" ]]; then sed -i "s|^root:[^:]*:|root:${PASS_HASH}:|" "$PROFILE_DIR/airootfs/etc/shadow" else # Fallback: create complete shadow file if it doesn't exist cat > "$PROFILE_DIR/airootfs/etc/shadow" << EOF root:${PASS_HASH}:19000:0:99999:7::: bin:!*:19000:::::: daemon:!*:19000:::::: mail:!*:19000:::::: ftp:!*:19000:::::: http:!*:19000:::::: nobody:!*:19000:::::: dbus:!*:19000:::::: systemd-coredump:!*:19000:::::: systemd-network:!*:19000:::::: systemd-oom:!*:19000:::::: systemd-journal-remote:!*:19000:::::: systemd-resolve:!*:19000:::::: systemd-timesync:!*:19000:::::: tss:!*:19000:::::: uuidd:!*:19000:::::: polkitd:!*:19000:::::: avahi:!*:19000:::::: EOF fi chmod 400 "$PROFILE_DIR/airootfs/etc/shadow" # Allow root SSH login with password (for testing) mkdir -p "$PROFILE_DIR/airootfs/etc/ssh/sshd_config.d" cat > "$PROFILE_DIR/airootfs/etc/ssh/sshd_config.d/allow-root.conf" << 'EOF' PermitRootLogin yes PasswordAuthentication yes EOF # Copy our custom scripts info "Copying custom scripts..." cp "$INSTALLER_DIR/archangel" "$PROFILE_DIR/airootfs/usr/local/bin/" cp -r "$INSTALLER_DIR/lib" "$PROFILE_DIR/airootfs/usr/local/bin/" cp "$INSTALLER_DIR/install-claude" "$PROFILE_DIR/airootfs/usr/local/bin/" # Copy zfssnapshot for ZFS snapshot management (list/create/rollback/delete) info "Copying zfssnapshot..." cp "$INSTALLER_DIR/zfssnapshot" "$PROFILE_DIR/airootfs/usr/local/bin/" # Copy example config for unattended installs mkdir -p "$PROFILE_DIR/airootfs/root" cp "$INSTALLER_DIR/archangel.conf.example" "$PROFILE_DIR/airootfs/root/" # Copy rescue guide info "Copying rescue guide..." cp "$INSTALLER_DIR/RESCUE-GUIDE.txt" "$PROFILE_DIR/airootfs/root/" # Set permissions in profiledef.sh info "Setting file permissions..." if grep -q "file_permissions=" "$PROFILE_DIR/profiledef.sh"; then sed -i '/^file_permissions=(/,/)/ { /)/ i\ ["/usr/local/bin/archangel"]="0:0:755" }' "$PROFILE_DIR/profiledef.sh" sed -i '/^file_permissions=(/,/)/ { /)/ i\ ["/usr/local/bin/install-claude"]="0:0:755" }' "$PROFILE_DIR/profiledef.sh" sed -i '/^file_permissions=(/,/)/ { /)/ i\ ["/usr/local/bin/zfssnapshot"]="0:0:755" }' "$PROFILE_DIR/profiledef.sh" sed -i '/^file_permissions=(/,/)/ { /)/ i\ ["/usr/local/bin/lib/common.sh"]="0:0:755" }' "$PROFILE_DIR/profiledef.sh" sed -i '/^file_permissions=(/,/)/ { /)/ i\ ["/usr/local/bin/lib/config.sh"]="0:0:755" }' "$PROFILE_DIR/profiledef.sh" sed -i '/^file_permissions=(/,/)/ { /)/ i\ ["/usr/local/bin/lib/disk.sh"]="0:0:755" }' "$PROFILE_DIR/profiledef.sh" sed -i '/^file_permissions=(/,/)/ { /)/ i\ ["/usr/local/bin/lib/btrfs.sh"]="0:0:755" }' "$PROFILE_DIR/profiledef.sh" sed -i '/^file_permissions=(/,/)/ { /)/ i\ ["/usr/local/bin/lib/raid.sh"]="0:0:755" }' "$PROFILE_DIR/profiledef.sh" sed -i '/^file_permissions=(/,/)/ { /)/ i\ ["/etc/shadow"]="0:0:400" }' "$PROFILE_DIR/profiledef.sh" fi # Copy archsetup into airootfs (exclude large/unnecessary directories) ARCHSETUP_DIR="${ARCHSETUP_DIR:-}" if [[ -d "$ARCHSETUP_DIR" ]]; then info "Copying archsetup into ISO..." mkdir -p "$PROFILE_DIR/airootfs/code" rsync -a --exclude='.git' \ --exclude='.claude' \ --exclude='vm-images' \ --exclude='test-results' \ --exclude='*.qcow2' \ --exclude='*.iso' \ "$ARCHSETUP_DIR" "$PROFILE_DIR/airootfs/code/" fi # Pre-populate tealdeer (tldr) cache for offline use info "Pre-populating tealdeer cache..." if command -v tldr &>/dev/null; then tldr --update 2>/dev/null || true if [[ -d "$HOME/.cache/tealdeer" ]]; then mkdir -p "$PROFILE_DIR/airootfs/root/.cache" cp -r "$HOME/.cache/tealdeer" "$PROFILE_DIR/airootfs/root/.cache/" info "Tealdeer cache copied (~27MB)" fi else warn "tealdeer not installed on build host, skipping cache pre-population" warn "Install with: pacman -S tealdeer && tldr --update" fi # Ensure scripts are executable in the profile chmod +x "$PROFILE_DIR/airootfs/usr/local/bin/"* # Build the ISO info "Building ISO (this will take a while)..." # Drop cached archzfs packages so pacstrap fetches fresh copies. The # upstream archzfs mirror has produced corrupted .pkg.tar.zst files # several times now (sessions 04-21, 04-26, 05-19), and pacstrap aborts # the whole build with "invalid or corrupted package" when it hits # them. Re-downloading costs ~30s on a warm mirror; debugging a # corrupted-cache failure after the fact costs much more. info "Clearing archzfs packages from host pacman cache..." rm -f /var/cache/pacman/pkg/zfs-dkms-*.pkg.tar.zst* rm -f /var/cache/pacman/pkg/zfs-utils-*.pkg.tar.zst* # Same hazard one layer up: pacoloco caches the archzfs GitHub-releases # download by filename, so a re-uploaded asset keeps serving a stale # package that mismatches the fresh archzfs.db checksum — which also bites # the VM test installs that route through this same pacoloco. build.sh # runs as root, so clear it here too; rm -f no-ops when pacoloco isn't # installed. rm -f /var/cache/pacoloco/pkgs/archzfs/zfs-dkms-*.pkg.tar.zst* rm -f /var/cache/pacoloco/pkgs/archzfs/zfs-utils-*.pkg.tar.zst* # BUILD_LOG was pre-created right after the archiso preflight (above) so the # AUR build could append to it; mkarchiso appends here too. mkarchiso -v -w "$WORK_DIR" -o "$OUT_DIR" "$PROFILE_DIR" 2>&1 | tee -a "$BUILD_LOG" # Restore ownership to the user who invoked sudo # mkarchiso runs as root and creates root-owned files if [[ -n "${SUDO_USER:-}" ]]; then info "Restoring ownership to $SUDO_USER..." chown -R "$SUDO_USER:$SUDO_USER" "$OUT_DIR" "$WORK_DIR" "$PROFILE_DIR" 2>/dev/null || true fi # Report results ISO_FILE=$(ls -t "$OUT_DIR"/*.iso 2>/dev/null | head -1) if [[ -f "$ISO_FILE" ]]; then # Rename the build log to match the ISO so they pair on disk. A # failed build keeps the build-YYYY-MM-DD-HHMM.log name and stays # in out/ for inspection. ISO_BASENAME=$(basename "$ISO_FILE" .iso) RENAMED_LOG="$OUT_DIR/${ISO_BASENAME}.log" mv "$BUILD_LOG" "$RENAMED_LOG" BUILD_LOG="$RENAMED_LOG" # Drop the AUR manifest beside the ISO so a given ISO's exact AUR set # (version + commit + SHA256) is auditable without mounting it. if [[ -f "$SCRIPT_DIR/aur-packages/manifest.tsv" ]]; then cp "$SCRIPT_DIR/aur-packages/manifest.tsv" \ "$OUT_DIR/${ISO_BASENAME}-aur-manifest.tsv" if [[ -n "${SUDO_USER:-}" ]]; then chown "$SUDO_USER:$SUDO_USER" \ "$OUT_DIR/${ISO_BASENAME}-aur-manifest.tsv" 2>/dev/null || true fi fi echo "" info "Build complete!" info "ISO location: $ISO_FILE" info "ISO size: $(du -h "$ISO_FILE" | cut -f1)" info "Build log: $BUILD_LOG" echo "" info "To test: ./scripts/test-vm.sh" echo "" info "After booting:" echo " - ZFS is pre-loaded (no setup needed)" echo " - SSH is enabled (see LIVE_ROOT_PASSWORD in build.sh)" echo " - Run 'archangel' to start installation" echo "" info "SSH access (from host):" echo " ssh -p 2222 root@localhost" else error "Build failed - no ISO file found" fi