veilor-os/overlay/usr/local/bin/veilor-installer
veilor-org 8861e12485 v0.5.14: remove plymouth package entirely
v0.5.13 added omit_dracutmodules+=plymouth + dracut --force regen in
chroot %post. Boot test still showed plymouth-start.service running.
Theory: the chroot dracut --force --kver loop didn't fire (kver glob
may have been empty in chroot), or anaconda regenerated initramfs
AFTER our %post and ignored our config drop-in.

Simpler fix: don't ship plymouth at all. Add `-plymouth
-plymouth-plugin-label -plymouth-system-theme` to kickstart %packages.
With no plymouth package on disk, dracut can't bundle it into
initramfs regardless of dracut.conf state.

The /etc/dracut.conf.d snippet + /dev/null masks from v0.5.12-13 stay
as belt-and-braces — harmless once plymouth is absent.
2026-05-03 11:12:35 +01:00

711 lines
25 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# veilor-os installer — TUI wrapper around anaconda kickstart install.
# Runs on tty1 in place of getty (live ISO boot path).
#
# Flow:
# 1. ASCII banner (assets/installer/banner.txt)
# 2. Menu: Install / Live desktop / Live shell / Reboot / Power off
# 3. If Install: collect answers via gum (disk, hostname, LUKS pw,
# admin pw, locale)
# 4. Generate /run/install/veilor-generated.ks from template + answers
# (full veilor-os install: package list + overlay + scripts + harden)
# 5. Exec anaconda --kickstart=/run/install/veilor-generated.ks
# 6. On finish: reboot into installed system
#
# v0.5.1 — gum (charm.sh) replaces whiptail; whiptail kept as fallback.
# Generated kickstart now installs full veilor-os (matches live ks).
set -uo pipefail
export TERM="${TERM:-linux}"
LOG=/var/log/veilor-installer.log
# require_tty MUST run before the tee redirect — process substitution
# replaces fd1 with a pipe, breaking `[[ -t 1 ]]`.
require_tty() {
if ! [[ -t 0 && -t 1 ]]; then
echo "[ERR] veilor-installer must run on a real tty" >&2
exit 1
fi
}
require_tty
# Now safe to tee output for log persistence — tty detection already passed.
exec > >(tee -a "$LOG") 2>&1
# ── Branded styling for gum ─────────────────────────────────────────────
# colors.gum sets GUM_* env vars — pure-black palette from veilor-black KDE.
# Sourced at top so every prompt below picks up branded colors.
COLORS=/usr/share/veilor-os/assets/installer/colors.gum
[[ -r $COLORS ]] && source "$COLORS"
BANNER_FILE=/usr/share/veilor-os/assets/installer/banner.txt
# Detect TUI backend once. gum is preferred; whiptail is the fallback so the
# installer keeps working on minimal images or if /usr/local/bin/gum is
# missing (e.g. broken vendored binary, /usr remount issues).
if command -v gum >/dev/null 2>&1; then
TUI=gum
elif command -v whiptail >/dev/null 2>&1; then
TUI=whiptail
else
echo "[ERR] neither gum nor whiptail available — cannot run TUI" >&2
exit 1
fi
banner() {
clear
# Read version + date for version line. /etc/os-release has VERSION_ID.
local ver=""
[[ -r /etc/os-release ]] && ver=$(. /etc/os-release; echo "${VERSION_ID:-0.0}")
local d
d=$(date +%Y-%m-%d)
local vline="veilor-os ${ver} · ${d} · live"
if [[ -r $BANNER_FILE ]]; then
if [[ $TUI == gum ]]; then
# gum style: rounded border, banner + blank line + version line.
gum style --border rounded --margin "0 2" --padding "1 3" \
--border-foreground "${VEILOR_DIM:-240}" \
--foreground "${VEILOR_FG:-15}" \
"$(cat "$BANNER_FILE")" \
"" \
"$vline"
else
cat "$BANNER_FILE"
echo
echo " $vline"
echo
fi
else
# Fallback ASCII if banner.txt missing (older overlay).
cat << EOF
veilor-os installer
$vline
EOF
fi
}
# ── TUI wrapper functions ───────────────────────────────────────────────
# Each prompt_* call abstracts gum / whiptail. Always emit the chosen value
# on stdout; non-zero exit on cancel/ESC. Callers use `||` to propagate.
# prompt_choose <header> <opt1> [opt2 ...]
# Single-select menu. Returns the selected option literal on stdout.
prompt_choose() {
local header=$1; shift
if [[ $TUI == gum ]]; then
gum choose --header "$header" "$@"
else
# whiptail menu needs tag/desc pairs. Use the option as both.
local args=()
local opt
for opt in "$@"; do args+=("$opt" "$opt"); done
whiptail --title "veilor-os" --menu "$header" 18 70 8 \
"${args[@]}" 3>&1 1>&2 2>&3
fi
}
# prompt_choose_pairs <header> <tag1> <desc1> [tag2 desc2 ...]
# Tag/description menu. Returns the chosen tag.
# Used when display label differs from machine-readable value (e.g. disk
# path /dev/nvme0n1 vs description "476G WDC PC SN740").
prompt_choose_pairs() {
local header=$1; shift
if [[ $TUI == gum ]]; then
# gum has no tag/desc concept — render "tag — desc" lines, parse.
local lines=() i=1
while (( i <= $# )); do
local tag=${!i}; ((i++))
local desc=${!i}; ((i++))
lines+=("$tag$desc")
done
local picked
picked=$(printf '%s\n' "${lines[@]}" | gum choose --header "$header") || return 1
# Strip " — desc" suffix to recover the tag.
echo "${picked%% — *}"
else
whiptail --title "veilor-os" --menu "$header" 18 70 8 \
"$@" 3>&1 1>&2 2>&3
fi
}
# prompt_input <header> [default]
prompt_input() {
local header=$1 default=${2:-}
if [[ $TUI == gum ]]; then
gum input --header "$header" --value "$default"
else
whiptail --title "veilor-os" --inputbox "$header" 10 60 "$default" \
3>&1 1>&2 2>&3
fi
}
# prompt_password <header>
prompt_password() {
local header=$1
if [[ $TUI == gum ]]; then
gum input --password --header "$header"
else
whiptail --title "veilor-os" --passwordbox "$header" 10 60 \
3>&1 1>&2 2>&3
fi
}
# prompt_confirm <message>
# Exits 0 on yes, 1 on no — matches whiptail --yesno semantics.
prompt_confirm() {
local msg=$1
if [[ $TUI == gum ]]; then
gum confirm "$msg"
else
whiptail --title "Confirm" --yesno "$msg" 16 60
fi
}
# prompt_message <message>
# Non-blocking notice. gum has no msgbox; print styled + sleep.
prompt_message() {
local msg=$1
if [[ $TUI == gum ]]; then
gum style --foreground "${VEILOR_FG:-15}" --border rounded \
--border-foreground "${VEILOR_DIM:-240}" --padding "1 2" -- "$msg"
sleep 2
else
whiptail --title "veilor-os" --msgbox "$msg" 10 60
fi
}
# prompt_error <message>
# Same as message but with a red foreground for visibility.
prompt_error() {
local msg=$1
if [[ $TUI == gum ]]; then
gum style --foreground 1 --border rounded --border-foreground 1 \
--padding "1 2" -- "$msg"
sleep 2
else
whiptail --title "Error" --msgbox "$msg" 10 60
fi
}
main_menu() {
# Empty header — banner already provides context. `──────` line splits
# primary actions (top) from session controls (bottom). Cursor `` set
# via GUM_CHOOSE_CURSOR in colors.gum.
prompt_choose "" \
"Install" \
"live · KDE" \
"live · shell" \
"──────" \
"Reboot" \
"Power off"
}
collect_answers() {
local disk hostname luks_pw admin_pw locale
local disks_pairs
# ── Disk ──
# Build whiptail-style "tag desc" pairs. prompt_choose_pairs reshapes
# for gum if needed.
# shellcheck disable=SC2207
disks_pairs=($(lsblk -dpno NAME,SIZE,MODEL | grep -E '^/dev/(sd|nvme|vd|mmcblk)' | \
awk '{name=$1; size=$2; $1=""; $2=""; sub(/^ +/,""); gsub(/ /,"_"); model=$0; if(model=="")model="unknown"; print name, size"_"model}'))
if [[ ${#disks_pairs[@]} -eq 0 ]]; then
prompt_error "No installable disks found."
return 1
fi
disk=$(prompt_choose_pairs "[1/4] Select install disk · WILL BE ERASED" "${disks_pairs[@]}") || return 1
# ── Hostname ──
# Hardcoded for branded consistency. Post-install: `hostnamectl set-hostname`.
hostname="veilor"
# Reject shell-special and sed-special chars in passwords. Generated
# kickstart writes them via heredoc + sed substitution; bare $, ", \, `
# would corrupt the ks line or partially expand at heredoc time.
# &, |, /, newline are sed-special: & expands to the matched pattern
# (so `aA1!@#%^&*()` becomes `aA1!@#%^__ADMIN_PW__*()`), | is our
# delimiter, / would match if delimiter changes, newline breaks the
# sed expression. sed_escape() below adds defense-in-depth, but we
# also reject these at input so the user sees an immediate error
# rather than a corrupted ks file. 8-char min for entropy.
validate_pw() {
local pw=$1 label=$2
if [[ ${#pw} -lt 8 ]]; then
prompt_error "Weak $label — minimum 8 characters."
return 1
fi
if [[ $pw =~ [\"\$\\\`\&\|/$'\n'] ]]; then
prompt_error "Invalid $label — cannot contain: \" \$ \\ \` & | / newline"
return 1
fi
return 0
}
# ── LUKS passphrase ──
luks_pw=$(prompt_password "[2/4] Encryption · LUKS2 passphrase (min 8)") || return 1
validate_pw "$luks_pw" "passphrase" || return 1
# ── Admin password ──
admin_pw=$(prompt_password "[3/4] Admin user · password for 'admin'") || return 1
validate_pw "$admin_pw" "password" || return 1
# ── Locale ──
locale=$(prompt_choose "[4/4] Locale" \
"en_GB.UTF-8" \
"en_US.UTF-8") || return 1
# ── Confirmation ──
# Render summary box + danger lines via gum style, then gum confirm.
# Whiptail fallback: simple yesno.
if [[ $TUI == gum ]]; then
clear
gum style --border rounded --margin "1 2" --padding "1 3" \
--border-foreground "${VEILOR_DIM:-240}" \
--foreground "${VEILOR_FG:-15}" \
"Confirm install" \
"" \
" Disk $disk $(gum style --foreground 1 'WILL BE ERASED')" \
" Locale $locale" \
" LUKS ✓ set" \
" Admin ✓ set" \
"" \
"$(gum style --foreground 3 'This action is irreversible.')"
gum confirm --affirmative "Yes, install" --negative "Cancel" "Proceed?" || return 1
else
whiptail --title "Confirm install" --yesno \
"About to install veilor-os:
Disk: $disk (WILL BE ERASED)
Locale: $locale
LUKS: set
Admin pw: set
This action is irreversible.
Proceed?" 16 60 || return 1
fi
# Export to caller via globals
SEL_DISK=$disk
SEL_HOSTNAME=$hostname
SEL_LUKS_PW=$luks_pw
SEL_ADMIN_PW=$admin_pw
SEL_LOCALE=$locale
return 0
}
# sed_escape — escape sed special chars in a replacement string.
# Replacement-side metacharacters: & (matched pattern), \ (escape),
# | (our chosen delimiter), / (alternate delimiter — escape too in case
# delimiter ever changes). Newline is rejected in validate_pw because
# escaping it portably across BSD/GNU sed is fiddly.
# Order matters: \ must be escaped FIRST so we don't double-escape the
# backslashes we're about to emit for &, |, /.
sed_escape() {
printf '%s' "$1" | sed -e 's/[\\&|/]/\\&/g'
}
generate_ks() {
# Build kickstart for actual disk install.
# NOTE: passwords go in via --plaintext to avoid storing crypted hash
# collisions; anaconda hashes per /etc/login.defs at install time.
#
# %packages mirrors live ks lines 63-141 (full hardening pkg list,
# minus livesys/anaconda-live which are live-only).
# %post --nochroot copies overlay + scripts + assets from boot ISO.
# %post (chroot) runs the same hardening scripts the live build runs.
local out=/run/install/veilor-generated.ks
local disk_basename
disk_basename=$(basename "$SEL_DISK")
mkdir -p /run/install
# Single-quoted heredoc → no shell expansion. Substitute placeholders
# via sed afterwards. Bulletproof against $/`/" in passwords.
cat > "$out" << 'KSEOF' || return 1
# veilor-os installer-generated kickstart
# DO NOT commit this file — secrets inline.
url --mirrorlist="https://mirrors.fedoraproject.org/mirrorlist?repo=fedora-43&arch=x86_64"
repo --name=fedora --baseurl="https://download.fedoraproject.org/pub/fedora/linux/releases/43/Everything/x86_64/os/" --install
repo --name=updates --baseurl="https://download.fedoraproject.org/pub/fedora/linux/updates/43/Everything/x86_64/" --install
keyboard --xlayouts='us'
lang __LOCALE__
timezone Europe/London --utc
firstboot --disable
eula --agreed
selinux --enforcing
services --enabled=sshd,fail2ban,usbguard,tuned,auditd,firewalld,chronyd,sddm
network --bootproto=dhcp --device=link --activate --hostname=__HOSTNAME__
firewall --enabled --service=ssh
rootpw --lock
user --name=admin --groups=wheel --gecos="veilor admin" --password=__ADMIN_PW__ --plaintext
# Full hardening cmdline (installed system, not live):
# --location=none: anaconda auto-places bootloader (UEFI grub2-efi or BIOS).
bootloader --append="lockdown=integrity slab_nomerge init_on_alloc=1 init_on_free=1 randomize_kstack_offset=on vsyscall=none plymouth.enable=0 rd.plymouth=0"
# Disk: zero, LUKS2 (argon2id), btrfs subvolumes (no LVM intermediary).
# Native btrfs-on-LUKS matches Fedora KDE Spin defaults; LVM+btrfs combo
# triggered "mount failed: wrong fs type, bad option, bad superblock"
# under anaconda --cmdline. btrfs subvols give us the snapshot/rollback
# story without LVM's added complexity.
zerombr
clearpart --all --initlabel --drives=__DISK_BASENAME__
part /boot/efi --fstype=efi --size=600
part /boot --fstype=ext4 --size=1024
part btrfs.veilor --grow --encrypted --luks-version=luks2 --pbkdf=argon2id --passphrase=__LUKS_PW__
btrfs none --label=veilor btrfs.veilor
btrfs / --subvol --name=root LABEL=veilor
btrfs /home --subvol --name=home LABEL=veilor
# ── Packages — mirrors live ks (kickstart/veilor-os.ks 63-141), minus
# live-only entries (livesys-scripts, anaconda-live, @anaconda-tools,
# dracut-live, isomd5sum, xorriso) which are pointless on a real disk.
%packages --excludedocs
@^kde-desktop-environment
@kde-apps
@core
@hardware-support
@standard
kernel-modules
kernel-modules-extra
glibc-all-langpacks
dracut-config-generic
kernel
grub2-efi-x64
grub2-efi-x64-modules
grub2-pc
grub2-pc-modules
grub2-tools
grub2-tools-extra
shim-x64
efibootmgr
syslinux
# veilor-installer dependencies (kept on installed system so
# `sudo veilor-installer` from a recovery shell still works).
newt
parted
cryptsetup
lvm2
btrfs-progs
# core hardening tools
fail2ban
fail2ban-firewalld
usbguard
usbguard-tools
audit
policycoreutils-python-utils
tuned
chrony
firewalld
# plymouth: removed in v0.5.13. Splash unreliable in QEMU; LUKS prompt
# falls back to systemd-tty-ask-password-agent on tty1 (text). Cleaner
# boot, smaller initramfs, fewer moving parts.
# admin essentials
git
vim-enhanced
tmux
htop
podman
skopeo
NetworkManager
NetworkManager-wifi
# fonts
fontconfig
freetype
fira-code-fonts
# zram (no swap-on-disk)
zram-generator
# remove fluff
# Note: KDE Plasma 6 hard-deps on cups/geoclue2/ModemManager/PackageKit
# transitively; daemons disabled at runtime via 20-harden-kernel.sh.
-abrt*
-snapd
-kde-connect
-open-vm-tools-desktop
-mlocate
-plymouth
-plymouth-plugin-label
-plymouth-system-theme
%end
# ── Post-install (nochroot): copy overlay + scripts + assets from boot ISO.
# Mirror of live ks 144-196 trimmed to single-source: ISO mounted at
# /run/install/repo, veilor/ subtree contains overlay/, scripts/, assets/.
# (See build/Containerfile — ISO build copies the repo into veilor/.)
%post --nochroot --erroronfail
set -uo pipefail
DEST="${INSTALL_ROOT:-/mnt/sysimage}"
[[ -d $DEST ]] || { echo "[ERR] DEST=$DEST does not exist" >&2; exit 1; }
SRC=/run/install/repo/veilor
if [[ ! -d $SRC/overlay ]]; then
echo "[ERR] $SRC/overlay missing — boot ISO did not include veilor/ tree" >&2
exit 1
fi
echo "[INFO] using SRC=$SRC DEST=$DEST"
set -x
cp -a "$SRC/overlay/." "$DEST/" || echo "[ERR] overlay cp failed: $?"
mkdir -p "$DEST/usr/share/veilor-os" || echo "[ERR] mkdir failed: $?"
cp -a "$SRC/assets" "$DEST/usr/share/veilor-os/" || echo "[ERR] assets cp failed: $?"
cp -a "$SRC/scripts" "$DEST/usr/share/veilor-os/" || echo "[ERR] scripts cp failed: $?"
ls -la "$DEST/usr/share/veilor-os/" 2>&1 || echo "[ERR] dest dir missing post-cp"
# Force root ownership on everything we copied — `cp -a` preserves
# CI runner uid (1001), which makes sudo refuse to read /etc/sudoers.d.
chown -R 0:0 "$DEST/etc" "$DEST/usr/share/veilor-os" "$DEST/usr/local/bin" 2>&1 || echo "[WARN] chown failed"
set +x
{
echo "=== %post --nochroot trace ==="
date
echo "SRC=$SRC DEST=$DEST"
ls -la "$DEST/usr/share/veilor-os/" 2>&1
ls -la "$DEST/usr/local/bin/" 2>&1
} > "$DEST/var/log/veilor-nochroot.log" 2>&1 || true
%end
# ── Post-install (chroot): apply hardening, theme, branding ────────────
%post
set -uo pipefail
exec > >(tee -a /var/log/veilor-install.log) 2>&1
echo "════════════════════════════════════════════════════════"
echo " veilor-os install — %post (real install)"
echo "════════════════════════════════════════════════════════"
REPO=/usr/share/veilor-os
chmod +x $REPO/scripts/*.sh $REPO/scripts/selinux/*.sh \
/usr/local/bin/veilor-power /usr/local/bin/veilor-firstboot \
/usr/local/bin/veilor-installer 2>/dev/null || true
# /etc/machine-id reset on first boot
> /etc/machine-id
# Apply hardening
bash $REPO/scripts/10-harden-base.sh
bash $REPO/scripts/20-harden-kernel.sh
# Build SELinux module
bash $REPO/scripts/selinux/build-policy.sh || echo "[WARN] SELinux build failed; load on first boot"
# Apply KDE theme + DuckSans + os-release branding
bash $REPO/scripts/kde-theme-apply.sh
# Disable plymouth at TWO layers:
#
# 1. Initramfs (the boot stage where LUKS unlock happens). Plymouth is
# a dracut module; masking units in /etc/systemd/ has zero effect
# here because dracut bundles its own copies into initramfs/.
# Solution: omit_dracutmodules in dracut.conf.d, then regenerate
# initramfs so the new config takes effect.
#
# 2. Real root (post-pivot, before SDDM). /dev/null symlinks mask all
# plymouth services + the path-activated ask-password unit so they
# never start when systemd is up.
#
# After both, LUKS prompt falls back to systemd-tty-ask-password-agent
# on tty1 — text "Please enter passphrase for disk... :" — works in
# QEMU sendkey AND on real hardware.
# Layer 1: initramfs
mkdir -p /etc/dracut.conf.d
cat > /etc/dracut.conf.d/99-veilor-no-plymouth.conf << 'EOF'
omit_dracutmodules+=" plymouth "
EOF
# Regenerate initramfs for the installed kernel(s).
for kver in /lib/modules/*/; do
kver=$(basename "$kver")
dracut --force --kver "$kver" 2>/dev/null || true
done
# Layer 2: real-root masks
mkdir -p /etc/systemd/system
for unit in \
plymouth-start.service \
plymouth-quit.service \
plymouth-quit-wait.service \
plymouth-read-write.service \
plymouth-switch-root.service \
systemd-ask-password-plymouth.path \
systemd-ask-password-plymouth.service \
; do
ln -sf /dev/null /etc/systemd/system/$unit
done
# Symlink display-manager.service → sddm.service. (Anaconda usually
# handles this when sddm is the only DM, but be explicit.)
ln -sf /usr/lib/systemd/system/sddm.service /etc/systemd/system/display-manager.service
# Real install boots straight to SDDM (NOT to TTY1 installer like live).
systemctl set-default graphical.target
# zram swap (no disk swap; keys never leak to platter)
cat > /etc/systemd/zram-generator.conf << 'EOF'
[zram0]
zram-size = min(ram, 8192)
compression-algorithm = zstd
EOF
# Enable services
systemctl enable veilor-firstboot.service 2>/dev/null || true
systemctl enable veilor-modules-lock.service 2>/dev/null || true
systemctl enable sshd fail2ban usbguard tuned auditd firewalld chronyd sddm
# Default tuned profile = balanced (AC/battery udev rule will override)
tuned-adm profile veilor-balanced 2>/dev/null || true
# Force admin to set a fresh password on first login. The `user` directive
# above already created the account with the user's chosen password —
# `chage -d 0` expires it so SDDM/passwd prompts for change immediately.
chage -d 0 admin 2>/dev/null || true
# Lock root explicitly (kickstart --lock should already do this)
passwd -l root
# Sanity: zero references to onyx / personal IPs in installed system
if grep -rqi 'onyx\|192\.168\.0\.\|fedora\.local' /etc/veilor* /etc/tuned/profiles/veilor-* 2>/dev/null; then
echo "[ERR] brand leak detected in /etc — investigate"
fi
echo "════════════════════════════════════════════════════════"
echo " veilor-os install complete"
echo "════════════════════════════════════════════════════════"
%end
# Reboot when done
reboot
KSEOF
# Substitute placeholders. Use | as sed delimiter. validate_pw()
# already rejects "$\`&|/\n at input — sed_escape() is defence in
# depth in case future code paths feed unsanitised values (e.g.
# locale/hostname from a file, or a relaxed validator).
sed -i \
-e "s|__LOCALE__|$(sed_escape "$SEL_LOCALE")|" \
-e "s|__HOSTNAME__|$(sed_escape "$SEL_HOSTNAME")|" \
-e "s|__DISK_BASENAME__|$(sed_escape "$disk_basename")|" \
-e "s|__LUKS_PW__|$(sed_escape "$SEL_LUKS_PW")|" \
-e "s|__ADMIN_PW__|$(sed_escape "$SEL_ADMIN_PW")|" \
"$out"
echo "[INFO] generated kickstart at $out"
return 0
}
run_install() {
# Anaconda env setup (see comments below).
export LANG="${SEL_LOCALE:-en_GB.UTF-8}"
export LC_ALL="$LANG"
# LANG / LC_ALL: anaconda's keyboard.activate_keyboard() raises
# AnacondaError: 'LANG' if missing. tty1 inherits no locale by default.
# XDG_RUNTIME_DIR: anaconda's display.setup_display() probes
# os.getenv("XDG_RUNTIME_DIR") + Wayland socket and crashes with
# TypeError on None. We're running unattended so no display needed,
# but env var still has to exist.
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
mkdir -p "$XDG_RUNTIME_DIR"
chmod 0700 "$XDG_RUNTIME_DIR"
if [[ $TUI == gum ]]; then
clear
# gum spin runs anaconda as subprocess. --show-output forwards
# stdout/stderr to /tmp/anaconda-cmdline.log so we can debug.
# --title shows live-updating header. --spinner dot is minimal.
local rc=0
gum spin --spinner dot \
--title "Installing veilor-os to $SEL_DISK · this takes 10-30 min · logs on tty2" \
--show-output \
-- bash -c 'anaconda --cmdline --kickstart=/run/install/veilor-generated.ks 2>&1 | tee /tmp/anaconda-cmdline.log' || rc=$?
if [[ $rc -eq 0 ]]; then
gum style --foreground 2 --border rounded --margin "1 2" --padding "1 3" \
"✓ Install complete" \
"" \
"System will reboot in 5 seconds." \
"Remove the install media."
sleep 5
systemctl reboot
else
prompt_error "Anaconda exited non-zero (status $rc).
Logs at /tmp/anaconda.log + /var/log/veilor-installer.log.
Press OK to drop to shell."
return 1
fi
else
prompt_message "Installing veilor-os to $SEL_DISK ...
This will take 10-30 minutes.
Logs: /var/log/veilor-installer.log + /tmp/anaconda.log"
sleep 1
if anaconda --cmdline --kickstart=/run/install/veilor-generated.ks; then
prompt_message "Install complete. System will reboot.
Remove the install media after shutdown."
sleep 3
systemctl reboot
else
prompt_error "Anaconda exited non-zero.
Logs at /tmp/anaconda.log + /var/log/veilor-installer.log.
Press OK to drop to shell."
return 1
fi
fi
}
drop_to_shell() {
clear
cat << 'EOF'
═══════════════════════════════════════════════════
veilor-os live shell
═══════════════════════════════════════════════════
You are in a live, in-memory environment.
Nothing persists across reboot.
Re-run the installer: sudo veilor-installer
Reboot: sudo systemctl reboot
Power off: sudo systemctl poweroff
EOF
exec /bin/bash --login
}
launch_desktop() {
clear
echo "Launching KDE Plasma..."
sleep 1
systemctl isolate graphical.target
# systemd-isolate switches target; sddm spawns on tty1.
# If user logs out, they come back here. Loop continues.
}
# ── Entry ──
# (require_tty already called above before exec redirect — see top of file)
banner
while true; do
case "$(main_menu)" in
"Install")
if collect_answers && generate_ks; then
run_install || continue
fi
;;
"live · KDE") launch_desktop ;;
"live · shell") drop_to_shell ;;
"──────") banner ;; # separator clicked: redraw, no-op
"Reboot") systemctl reboot ;;
"Power off") systemctl poweroff ;;
"") banner ;; # ESC/cancel from menu: redraw
*) banner ;;
esac
done