aboutsummaryrefslogtreecommitdiff
path: root/build.sh
blob: 6dbdef0fb1178c47d70eb10dbc93823df0a2861a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
#!/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