feat: introduce a Textual TUI for interactive Arch Linux installation, guiding users through disk, partition, configuration, and package selection.
This commit is contained in:
parent
8349418546
commit
69274b3c05
2 changed files with 500 additions and 121 deletions
301
z.py
301
z.py
|
|
@ -5,20 +5,36 @@ import sys
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import getpass
|
import getpass
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
# Configuration variables
|
# Configuration variables
|
||||||
selected_disk = "/dev/vda"
|
# (Keeping global defaults for CLI fallbacks if needed, though they move to InstallConfig default mostly)
|
||||||
seed_device = "/dev/vda1"
|
|
||||||
sprout_device = "/dev/vda2"
|
|
||||||
efi_device = "/dev/vda3"
|
|
||||||
|
|
||||||
default_packages = [
|
default_packages = [
|
||||||
"base", "linux", "linux-firmware", "btrfs-progs", "nano", "sudo",
|
"base", "linux", "linux-firmware", "btrfs-progs", "nano", "sudo",
|
||||||
"networkmanager", "efibootmgr", "grub", "os-prober", "base-devel", "git"
|
"networkmanager", "efibootmgr", "grub", "os-prober", "base-devel", "git"
|
||||||
]
|
]
|
||||||
|
|
||||||
def run_command(command, check=True, shell=False, capture_output=False):
|
@dataclass
|
||||||
|
class InstallConfig:
|
||||||
|
seed_device: str
|
||||||
|
sprout_device: str
|
||||||
|
efi_device: str
|
||||||
|
hostname: str = "arch-z"
|
||||||
|
username: str = "zeev"
|
||||||
|
timezone: str = "Europe/Helsinki"
|
||||||
|
root_password: str = ""
|
||||||
|
user_password: str = ""
|
||||||
|
packages: List[str] = field(default_factory=lambda: list(default_packages))
|
||||||
|
dry_run: bool = False
|
||||||
|
|
||||||
|
def run_command(command, check=True, shell=False, capture_output=False, dry_run=False):
|
||||||
"""Result wrapper for subprocess.run"""
|
"""Result wrapper for subprocess.run"""
|
||||||
|
if dry_run:
|
||||||
|
print(f"[DRY RUN] Would execute: {command}")
|
||||||
|
# Return a dummy completed process for dry runs so logic doesn't crash on attribute access
|
||||||
|
return subprocess.CompletedProcess(args=command, returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# If command is a string and shell is False, split it (naive splitting)
|
# If command is a string and shell is False, split it (naive splitting)
|
||||||
# But better to rely on caller passing list if shell=False
|
# But better to rely on caller passing list if shell=False
|
||||||
|
|
@ -75,6 +91,132 @@ def get_partitions(disk):
|
||||||
parts.append({"path": columns[0], "display": display})
|
parts.append({"path": columns[0], "display": display})
|
||||||
return parts
|
return parts
|
||||||
|
|
||||||
|
def perform_installation(config: InstallConfig, log_func=print):
|
||||||
|
"""
|
||||||
|
Executes the installation process based on the provided configuration.
|
||||||
|
log_func is a function to display messages (defaults to print).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# helper for dry_run propagation
|
||||||
|
def run(cmd, **kwargs):
|
||||||
|
# Merge dry_run into kwargs if not explicitly set
|
||||||
|
if 'dry_run' not in kwargs:
|
||||||
|
kwargs['dry_run'] = config.dry_run
|
||||||
|
return run_command(cmd, **kwargs)
|
||||||
|
|
||||||
|
log_func("--- Starting Installation ---")
|
||||||
|
|
||||||
|
# Get Sprout PARTUUID
|
||||||
|
if config.dry_run:
|
||||||
|
sprout_partuuid = "DRY-RUN-UUID-1234"
|
||||||
|
else:
|
||||||
|
cmd_uuid = f"blkid -s PARTUUID -o value {config.sprout_device}"
|
||||||
|
sprout_partuuid = run(cmd_uuid, shell=True, capture_output=True).stdout.strip()
|
||||||
|
|
||||||
|
log_func(f"Sprout PARTUUID: {sprout_partuuid}")
|
||||||
|
|
||||||
|
# Check mountpoint
|
||||||
|
# For dry_run we might want to skip real checks or mock them
|
||||||
|
if not config.dry_run:
|
||||||
|
mount_check = subprocess.run(["mountpoint", "-q", "/mnt"], check=False)
|
||||||
|
if mount_check.returncode == 0:
|
||||||
|
log_func("/mnt is already mounted. Unmounting...")
|
||||||
|
run("umount -R /mnt", check=False)
|
||||||
|
|
||||||
|
log_func("Creating filesystems...")
|
||||||
|
run(f"mkfs.btrfs -f -L SEED {config.seed_device}", shell=True)
|
||||||
|
run(f"mkfs.btrfs -f -L SPROUT {config.sprout_device}", shell=True)
|
||||||
|
run(f"mkfs.fat -F 32 -n EFI {config.efi_device}", shell=True)
|
||||||
|
log_func("Filesystems created successfully.")
|
||||||
|
|
||||||
|
# Initial Mount
|
||||||
|
run(f"mount -o subvol=/ {config.seed_device} /mnt", shell=True)
|
||||||
|
|
||||||
|
# Check for @ subvolume
|
||||||
|
if not config.dry_run:
|
||||||
|
subvol_list = run("btrfs subvolume list /mnt", shell=True, capture_output=True).stdout
|
||||||
|
if any(line.endswith(" @") or line.endswith("path @") for line in subvol_list.splitlines()):
|
||||||
|
run("btrfs subvolume delete /mnt/@", shell=True)
|
||||||
|
|
||||||
|
run("btrfs su cr /mnt/@", shell=True)
|
||||||
|
run("umount -R /mnt", shell=True)
|
||||||
|
run(f"mount -o subvol=/@ {config.seed_device} /mnt", shell=True)
|
||||||
|
|
||||||
|
# Pacstrap
|
||||||
|
log_func(f"Installing packages: {' '.join(config.packages)}")
|
||||||
|
run(["pacstrap", "-K", "/mnt"] + config.packages)
|
||||||
|
run(f"mount -m {config.efi_device} /mnt/efi", shell=True)
|
||||||
|
|
||||||
|
# fstab
|
||||||
|
if not config.dry_run:
|
||||||
|
with open("/mnt/etc/fstab", "w") as f:
|
||||||
|
subprocess.run(["genfstab", "-U", "/mnt"], stdout=f, check=True)
|
||||||
|
else:
|
||||||
|
log_func("[DRY RUN] Would generate fstab")
|
||||||
|
|
||||||
|
# Chroot function
|
||||||
|
mkinitcpio_hooks = "base udev autodetect microcode modconf kms keyboard block btrfs filesystems"
|
||||||
|
grub_options = "--target=x86_64-efi --efi-directory=/efi --boot-directory=/boot --bootloader-id=GRUB"
|
||||||
|
|
||||||
|
install_script = [
|
||||||
|
"hwclock --systohc",
|
||||||
|
f"echo '{config.hostname}' > /etc/hostname",
|
||||||
|
"echo 'KEYMAP=us' > /etc/vconsole.conf",
|
||||||
|
"sed -i 's/^#en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen",
|
||||||
|
"locale-gen",
|
||||||
|
"echo 'LANG=en_US.UTF-8' > /etc/locale.conf",
|
||||||
|
f"ln -sf /usr/share/zoneinfo/{config.timezone} /etc/localtime",
|
||||||
|
f'sed -i "s/^HOOKS=.*/HOOKS=({mkinitcpio_hooks})/" /etc/mkinitcpio.conf',
|
||||||
|
f"echo 'root:{config.root_password}' | chpasswd",
|
||||||
|
f"useradd -m -G wheel -s /usr/bin/bash {config.username}",
|
||||||
|
f"echo '{config.username}:{config.user_password}' | chpasswd",
|
||||||
|
f"echo '{config.username} ALL=(ALL:ALL) ALL' > /etc/sudoers.d/{config.username}",
|
||||||
|
"systemctl enable systemd-timesyncd",
|
||||||
|
f"grub-install {grub_options}",
|
||||||
|
"echo 'GRUB_DISABLE_OS_PROBER=false' >> /etc/default/grub",
|
||||||
|
"grub-mkconfig -o /boot/grub/grub.cfg",
|
||||||
|
f"sed -i 's/root=UUID=[A-Fa-f0-9-]*/root=PARTUUID={sprout_partuuid}/g' /boot/grub/grub.cfg",
|
||||||
|
"passwd -l root",
|
||||||
|
"mkinitcpio -P"
|
||||||
|
]
|
||||||
|
|
||||||
|
full_script = "\n".join(install_script)
|
||||||
|
# arch-chroot /mnt /usr/bin/bash -c "$cmd"
|
||||||
|
# We pass the full script as one argument to bash -c
|
||||||
|
run(["arch-chroot", "/mnt", "/usr/bin/bash", "-c", full_script])
|
||||||
|
|
||||||
|
# Final cleanup
|
||||||
|
log_func("--- Finalizing Seed/Sprout setup ---")
|
||||||
|
log_func("Unmounting /mnt...")
|
||||||
|
run("umount -R /mnt", shell=True)
|
||||||
|
|
||||||
|
log_func(f"Converting {config.seed_device} to a seed device...")
|
||||||
|
run(f"btrfstune -S 1 {config.seed_device}", shell=True)
|
||||||
|
|
||||||
|
log_func("Mounting seed device to add sprout...")
|
||||||
|
run(f"mount -o subvol=/@ {config.seed_device} /mnt", shell=True)
|
||||||
|
|
||||||
|
log_func(f"Adding {config.sprout_device} as sprout device...")
|
||||||
|
run(f"btrfs device add -f {config.sprout_device} /mnt", shell=True)
|
||||||
|
|
||||||
|
log_func("Unmounting and remounting sprout device...")
|
||||||
|
run("umount -R /mnt", shell=True)
|
||||||
|
run(f"mount -o subvol=/@ {config.sprout_device} /mnt", shell=True)
|
||||||
|
|
||||||
|
log_func("Mounting EFI partition...")
|
||||||
|
run(f"mount -m {config.efi_device} /mnt/efi", shell=True)
|
||||||
|
|
||||||
|
log_func("Generating final fstab with PARTUUIDs...")
|
||||||
|
if not config.dry_run:
|
||||||
|
with open("/mnt/etc/fstab", "w") as f:
|
||||||
|
subprocess.run(["genfstab", "-t", "PARTUUID", "/mnt"], stdout=f, check=True)
|
||||||
|
else:
|
||||||
|
log_func("[DRY RUN] Would generate final fstab")
|
||||||
|
|
||||||
|
log_func("\n################################################################")
|
||||||
|
log_func("# INSTALLATION COMPLETE #")
|
||||||
|
log_func("################################################################\n")
|
||||||
|
|
||||||
def select_option(options, prompt_text, default_val=None):
|
def select_option(options, prompt_text, default_val=None):
|
||||||
"""Generic selection loop"""
|
"""Generic selection loop"""
|
||||||
if not options:
|
if not options:
|
||||||
|
|
@ -117,7 +259,11 @@ def select_option(options, prompt_text, default_val=None):
|
||||||
print("Invalid selection.")
|
print("Invalid selection.")
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
global selected_disk, seed_device, sprout_device, efi_device
|
# Helper defaults
|
||||||
|
current_disk_default = "/dev/vda"
|
||||||
|
seed_default = "/dev/vda1"
|
||||||
|
sprout_default = "/dev/vda2"
|
||||||
|
efi_default = "/dev/vda3"
|
||||||
|
|
||||||
# 1. Select Disk
|
# 1. Select Disk
|
||||||
print("Available storage disks:")
|
print("Available storage disks:")
|
||||||
|
|
@ -126,7 +272,7 @@ def main():
|
||||||
print("No disks found!")
|
print("No disks found!")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
choice = select_option(disks, "Select a disk to choose partitions from", selected_disk)
|
choice = select_option(disks, "Select a disk to choose partitions from", current_disk_default)
|
||||||
selected_disk = choice['name']
|
selected_disk = choice['name']
|
||||||
|
|
||||||
# 2. Select Partitions
|
# 2. Select Partitions
|
||||||
|
|
@ -140,73 +286,24 @@ def main():
|
||||||
choice = select_option(parts, prompt, current_val)
|
choice = select_option(parts, prompt, current_val)
|
||||||
return choice['path']
|
return choice['path']
|
||||||
|
|
||||||
seed_device = select_part("--- Select Seed Partition ---", "Seed device: ", seed_device)
|
seed_device = select_part("--- Select Seed Partition ---", "Seed device: ", seed_default)
|
||||||
sprout_device = select_part("--- Select Sprout Partition ---", "Sprout device: ", sprout_device)
|
sprout_device = select_part("--- Select Sprout Partition ---", "Sprout device: ", sprout_default)
|
||||||
efi_device = select_part("--- Select EFI Partition ---", "EFI device: ", efi_device)
|
efi_device = select_part("--- Select EFI Partition ---", "EFI device: ", efi_default)
|
||||||
|
|
||||||
# Get Sprout PARTUUID
|
|
||||||
cmd_uuid = f"blkid -s PARTUUID -o value {sprout_device}"
|
|
||||||
sprout_partuuid = run_command(cmd_uuid, shell=True, capture_output=True).stdout.strip()
|
|
||||||
print(f"Sprout PARTUUID: {sprout_partuuid}")
|
|
||||||
|
|
||||||
print("\nConfiguration Summary:")
|
print("\nConfiguration Summary:")
|
||||||
print(f"Seed device: {seed_device}")
|
print(f"Seed device: {seed_device}")
|
||||||
print(f"Sprout device: {sprout_device}")
|
print(f"Sprout device: {sprout_device}")
|
||||||
print(f"EFI device: {efi_device}\n")
|
print(f"EFI device: {efi_device}\n")
|
||||||
|
|
||||||
resp = input("Confirm formatting and installation? (yes/no/skip): ").lower()
|
|
||||||
if resp not in ["yes", "y", "skip"]:
|
|
||||||
print("Aborting.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Check mountpoint
|
|
||||||
mount_check = subprocess.run(["mountpoint", "-q", "/mnt"], check=False)
|
|
||||||
if mount_check.returncode == 0:
|
|
||||||
print("/mnt is already mounted. Unmounting...")
|
|
||||||
run_command("umount -R /mnt", check=False)
|
|
||||||
|
|
||||||
if resp == "skip":
|
|
||||||
print("Skipping formatting")
|
|
||||||
else:
|
|
||||||
run_command(f"mkfs.btrfs -f -L SEED {seed_device}", shell=True)
|
|
||||||
run_command(f"mkfs.btrfs -f -L SPROUT {sprout_device}", shell=True)
|
|
||||||
run_command(f"mkfs.fat -F 32 -n EFI {efi_device}", shell=True)
|
|
||||||
print("Filesystems created successfully.")
|
|
||||||
|
|
||||||
# Initial Mount
|
|
||||||
run_command(f"mount -o subvol=/ {seed_device} /mnt", shell=True)
|
|
||||||
|
|
||||||
# Check for @ subvolume
|
|
||||||
subvol_list = run_command("btrfs subvolume list /mnt", shell=True, capture_output=True).stdout
|
|
||||||
if any(line.endswith(" @") or line.endswith("path @") for line in subvol_list.splitlines()):
|
|
||||||
run_command("btrfs subvolume delete /mnt/@", shell=True)
|
|
||||||
|
|
||||||
run_command("btrfs su cr /mnt/@", shell=True)
|
|
||||||
run_command("umount -R /mnt", shell=True)
|
|
||||||
run_command(f"mount -o subvol=/@ {seed_device} /mnt", shell=True)
|
|
||||||
|
|
||||||
# Packages
|
# Packages
|
||||||
pkg_input = input("Enter packages to install (space-separated): ").strip()
|
pkg_input = input("Enter packages to install (space-separated): ").strip()
|
||||||
packages = pkg_input.split() if pkg_input else default_packages
|
packages = pkg_input.split() if pkg_input else list(default_packages)
|
||||||
|
|
||||||
if packages == default_packages:
|
if packages == default_packages:
|
||||||
print(f"No packages specified. Defaulting to: {' '.join(packages)}")
|
print(f"No packages specified. Defaulting to: {' '.join(packages)}")
|
||||||
else:
|
else:
|
||||||
print(f"The following packages will be installed: {' '.join(packages)}")
|
print(f"The following packages will be installed: {' '.join(packages)}")
|
||||||
|
|
||||||
cont = input("Continue with installation? (Yes/no): ").lower()
|
|
||||||
if cont == "no":
|
|
||||||
print("Aborting.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Pacstrap
|
|
||||||
run_command(["pacstrap", "-K", "/mnt"] + packages)
|
|
||||||
run_command(f"mount -m {efi_device} /mnt/efi", shell=True)
|
|
||||||
|
|
||||||
# fstab
|
|
||||||
with open("/mnt/etc/fstab", "w") as f:
|
|
||||||
subprocess.run(["genfstab", "-U", "/mnt"], stdout=f, check=True)
|
|
||||||
|
|
||||||
# User Configuration
|
# User Configuration
|
||||||
print("--- System Configuration ---")
|
print("--- System Configuration ---")
|
||||||
hostname = input("Enter hostname (default: arch-z): ").strip() or "arch-z"
|
hostname = input("Enter hostname (default: arch-z): ").strip() or "arch-z"
|
||||||
|
|
@ -214,6 +311,7 @@ def main():
|
||||||
timezone = input("Enter timezone (default: Europe/Helsinki): ").strip() or "Europe/Helsinki"
|
timezone = input("Enter timezone (default: Europe/Helsinki): ").strip() or "Europe/Helsinki"
|
||||||
|
|
||||||
print("Set root password:")
|
print("Set root password:")
|
||||||
|
root_pass = ""
|
||||||
while True:
|
while True:
|
||||||
p1 = getpass.getpass("Password: ")
|
p1 = getpass.getpass("Password: ")
|
||||||
p2 = getpass.getpass("Confirm Password: ")
|
p2 = getpass.getpass("Confirm Password: ")
|
||||||
|
|
@ -223,6 +321,7 @@ def main():
|
||||||
print("Passwords do not match. Try again.")
|
print("Passwords do not match. Try again.")
|
||||||
|
|
||||||
print(f"Set password for user {user}:")
|
print(f"Set password for user {user}:")
|
||||||
|
user_pass = ""
|
||||||
while True:
|
while True:
|
||||||
p1 = getpass.getpass("Password: ")
|
p1 = getpass.getpass("Password: ")
|
||||||
p2 = getpass.getpass("Confirm Password: ")
|
p2 = getpass.getpass("Confirm Password: ")
|
||||||
|
|
@ -231,65 +330,28 @@ def main():
|
||||||
break
|
break
|
||||||
print("Passwords do not match. Try again.")
|
print("Passwords do not match. Try again.")
|
||||||
|
|
||||||
# Chroot function
|
# Confirm
|
||||||
mkinitcpio_hooks = "base udev autodetect microcode modconf kms keyboard block btrfs filesystems"
|
resp = input("Confirm formatting and installation? (yes/no): ").lower()
|
||||||
grub_options = "--target=x86_64-efi --efi-directory=/efi --boot-directory=/boot --bootloader-id=GRUB"
|
if resp not in ["yes", "y"]:
|
||||||
|
print("Aborting.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
install_script = [
|
# Build Config
|
||||||
"hwclock --systohc",
|
config = InstallConfig(
|
||||||
f"echo '{hostname}' > /etc/hostname",
|
seed_device=seed_device,
|
||||||
"echo 'KEYMAP=us' > /etc/vconsole.conf",
|
sprout_device=sprout_device,
|
||||||
"sed -i 's/^#en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen",
|
efi_device=efi_device,
|
||||||
"locale-gen",
|
hostname=hostname,
|
||||||
"echo 'LANG=en_US.UTF-8' > /etc/locale.conf",
|
username=user,
|
||||||
f"ln -sf /usr/share/zoneinfo/{timezone} /etc/localtime",
|
timezone=timezone,
|
||||||
f'sed -i "s/^HOOKS=.*/HOOKS=({mkinitcpio_hooks})/" /etc/mkinitcpio.conf',
|
root_password=root_pass,
|
||||||
f"echo 'root:{root_pass}' | chpasswd",
|
user_password=user_pass,
|
||||||
f"useradd -m -G wheel -s /usr/bin/bash {user}",
|
packages=packages,
|
||||||
f"echo '{user}:{user_pass}' | chpasswd",
|
dry_run=False # CLI default is live
|
||||||
f"echo '{user} ALL=(ALL:ALL) ALL' > /etc/sudoers.d/{user}",
|
)
|
||||||
"systemctl enable systemd-timesyncd",
|
|
||||||
f"grub-install {grub_options}",
|
|
||||||
"echo 'GRUB_DISABLE_OS_PROBER=false' >> /etc/default/grub",
|
|
||||||
"grub-mkconfig -o /boot/grub/grub.cfg",
|
|
||||||
f"sed -i 's/root=UUID=[A-Fa-f0-9-]*/root=PARTUUID={sprout_partuuid}/g' /boot/grub/grub.cfg",
|
|
||||||
"passwd -l root",
|
|
||||||
"mkinitcpio -P"
|
|
||||||
]
|
|
||||||
|
|
||||||
full_script = "\n".join(install_script)
|
# Execute
|
||||||
# arch-chroot /mnt /usr/bin/bash -c "$cmd"
|
perform_installation(config)
|
||||||
# We pass the full script as one argument to bash -c
|
|
||||||
run_command(["arch-chroot", "/mnt", "/usr/bin/bash", "-c", full_script])
|
|
||||||
|
|
||||||
# Final cleanup
|
|
||||||
print("--- Finalizing Seed/Sprout setup ---")
|
|
||||||
print("Unmounting /mnt...")
|
|
||||||
run_command("umount -R /mnt", shell=True)
|
|
||||||
|
|
||||||
print(f"Converting {seed_device} to a seed device...")
|
|
||||||
run_command(f"btrfstune -S 1 {seed_device}", shell=True)
|
|
||||||
|
|
||||||
print("Mounting seed device to add sprout...")
|
|
||||||
run_command(f"mount -o subvol=/@ {seed_device} /mnt", shell=True)
|
|
||||||
|
|
||||||
print(f"Adding {sprout_device} as sprout device...")
|
|
||||||
run_command(f"btrfs device add -f {sprout_device} /mnt", shell=True)
|
|
||||||
|
|
||||||
print("Unmounting and remounting sprout device...")
|
|
||||||
run_command("umount -R /mnt", shell=True)
|
|
||||||
run_command(f"mount -o subvol=/@ {sprout_device} /mnt", shell=True)
|
|
||||||
|
|
||||||
print("Mounting EFI partition...")
|
|
||||||
run_command(f"mount -m {efi_device} /mnt/efi", shell=True)
|
|
||||||
|
|
||||||
print("Generating final fstab with PARTUUIDs...")
|
|
||||||
with open("/mnt/etc/fstab", "w") as f:
|
|
||||||
subprocess.run(["genfstab", "-t", "PARTUUID", "/mnt"], stdout=f, check=True)
|
|
||||||
|
|
||||||
print("\n################################################################")
|
|
||||||
print("# INSTALLATION COMPLETE #")
|
|
||||||
print("################################################################\n")
|
|
||||||
|
|
||||||
reboot_ans = input("Do you want to reboot now? (y/N): ").lower()
|
reboot_ans = input("Do you want to reboot now? (y/N): ").lower()
|
||||||
if reboot_ans in ["y", "yes"]:
|
if reboot_ans in ["y", "yes"]:
|
||||||
|
|
@ -298,6 +360,9 @@ def main():
|
||||||
print("You can reboot manually by typing 'reboot'.")
|
print("You can reboot manually by typing 'reboot'.")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
print("Warning: Not running as root. Operations may fail.", file=sys.stderr)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
main()
|
main()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|
|
||||||
314
z_tui.py
Normal file
314
z_tui.py
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import Container, Vertical, Horizontal
|
||||||
|
from textual.widgets import Header, Footer, Button, Static, Label, Input, Select, DataTable, Log, ListItem, ListView
|
||||||
|
from textual.screen import Screen, ModalScreen
|
||||||
|
from textual.message import Message
|
||||||
|
from textual import on, work
|
||||||
|
|
||||||
|
# Import logic from z.py
|
||||||
|
# We need to add the current directory to path if not already there
|
||||||
|
import os
|
||||||
|
sys.path.append(os.getcwd())
|
||||||
|
import z
|
||||||
|
|
||||||
|
class InstallWork(threading.Thread):
|
||||||
|
def __init__(self, config, log_callback):
|
||||||
|
super().__init__()
|
||||||
|
self.config = config
|
||||||
|
self.log_callback = log_callback
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
z.perform_installation(self.config, log_func=self.log_callback)
|
||||||
|
|
||||||
|
class DiskSelectScreen(Screen):
|
||||||
|
"""Screen to select the target disk."""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
yield Container(
|
||||||
|
Label("Select Storage Disk", classes="title"),
|
||||||
|
DataTable(id="disk_table"),
|
||||||
|
Button("Next", variant="primary", id="btn_next", disabled=True),
|
||||||
|
)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
table = self.query_one(DataTable)
|
||||||
|
table.cursor_type = "row"
|
||||||
|
table.add_columns("Name", "Size", "Model")
|
||||||
|
|
||||||
|
disks = z.get_disks()
|
||||||
|
self.disks_data = disks # keep reference
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for d in disks:
|
||||||
|
rows.append((d['name'], d['size'], d['model']))
|
||||||
|
|
||||||
|
table.add_rows(rows)
|
||||||
|
|
||||||
|
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||||
|
table = self.query_one(DataTable)
|
||||||
|
self.app.selected_disk = self.disks_data[event.cursor_row]['name']
|
||||||
|
self.query_one("#btn_next").disabled = False
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn_next":
|
||||||
|
self.app.push_screen("partition_select")
|
||||||
|
|
||||||
|
class PartitionSelectScreen(Screen):
|
||||||
|
"""Screen to select partitions for Seed, Sprout, and EFI."""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
yield Container(
|
||||||
|
Label(f"Select Partitions on {self.app.selected_disk}", classes="title"),
|
||||||
|
Vertical(
|
||||||
|
Label("Seed Partition (Read-Only Base):"),
|
||||||
|
Select([], id="sel_seed"),
|
||||||
|
Label("Sprout Partition (Writable Layer):"),
|
||||||
|
Select([], id="sel_sprout"),
|
||||||
|
Label("EFI Partition (Boot):"),
|
||||||
|
Select([], id="sel_efi"),
|
||||||
|
classes="form-group"
|
||||||
|
),
|
||||||
|
Button("Next", variant="primary", id="btn_next"),
|
||||||
|
)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
parts = z.get_partitions(self.app.selected_disk)
|
||||||
|
# Format for Select: (label, value)
|
||||||
|
options = [(p['display'], p['path']) for p in parts]
|
||||||
|
|
||||||
|
self.query_one("#sel_seed").set_options(options)
|
||||||
|
self.query_one("#sel_sprout").set_options(options)
|
||||||
|
self.query_one("#sel_efi").set_options(options)
|
||||||
|
|
||||||
|
# Try to set sensible defaults if standard layout
|
||||||
|
# (This is a simplified attempt matching z.py defaults logic)
|
||||||
|
for _, val in options:
|
||||||
|
if val == f"{self.app.selected_disk}1":
|
||||||
|
self.query_one("#sel_seed").value = val
|
||||||
|
elif val == f"{self.app.selected_disk}2":
|
||||||
|
self.query_one("#sel_sprout").value = val
|
||||||
|
elif val == f"{self.app.selected_disk}3":
|
||||||
|
self.query_one("#sel_efi").value = val
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn_next":
|
||||||
|
self.app.seed_device = self.query_one("#sel_seed").value
|
||||||
|
self.app.sprout_device = self.query_one("#sel_sprout").value
|
||||||
|
self.app.efi_device = self.query_one("#sel_efi").value
|
||||||
|
|
||||||
|
if not all([self.app.seed_device, self.app.sprout_device, self.app.efi_device]):
|
||||||
|
self.notify("Please select all partitions", severity="error")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.app.push_screen("config")
|
||||||
|
|
||||||
|
class ConfigScreen(Screen):
|
||||||
|
"""Screen for collecting user configuration."""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
yield Container(
|
||||||
|
Label("System Configuration", classes="title"),
|
||||||
|
Vertical(
|
||||||
|
Label("Hostname:"),
|
||||||
|
Input(value="arch-z", id="inp_hostname"),
|
||||||
|
Label("Username:"),
|
||||||
|
Input(value="zeev", id="inp_user"),
|
||||||
|
Label("Timezone:"),
|
||||||
|
Input(value="Europe/Helsinki", id="inp_timezone"),
|
||||||
|
|
||||||
|
Label("Root Password:"),
|
||||||
|
Input(password=True, id="inp_root_pass"),
|
||||||
|
Label("Root Password (Confirm):"),
|
||||||
|
Input(password=True, id="inp_root_pass_confirm"),
|
||||||
|
|
||||||
|
Label("User Password:"),
|
||||||
|
Input(password=True, id="inp_user_pass"),
|
||||||
|
Label("User Password (Confirm):"),
|
||||||
|
Input(password=True, id="inp_user_pass_confirm"),
|
||||||
|
|
||||||
|
classes="form-group"
|
||||||
|
),
|
||||||
|
Button("Next", variant="primary", id="btn_next"),
|
||||||
|
)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn_next":
|
||||||
|
# Validation
|
||||||
|
r1 = self.query_one("#inp_root_pass").value
|
||||||
|
r2 = self.query_one("#inp_root_pass_confirm").value
|
||||||
|
u1 = self.query_one("#inp_user_pass").value
|
||||||
|
u2 = self.query_one("#inp_user_pass_confirm").value
|
||||||
|
|
||||||
|
if r1 != r2:
|
||||||
|
self.notify("Root passwords do not match", severity="error")
|
||||||
|
return
|
||||||
|
if u1 != u2:
|
||||||
|
self.notify("User passwords do not match", severity="error")
|
||||||
|
return
|
||||||
|
if not r1 or not u1:
|
||||||
|
self.notify("Passwords cannot be empty", severity="error")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.app.conf_hostname = self.query_one("#inp_hostname").value
|
||||||
|
self.app.conf_username = self.query_one("#inp_user").value
|
||||||
|
self.app.conf_timezone = self.query_one("#inp_timezone").value
|
||||||
|
self.app.conf_root_pass = r1
|
||||||
|
self.app.conf_user_pass = u1
|
||||||
|
|
||||||
|
self.app.push_screen("packages")
|
||||||
|
|
||||||
|
class PackageScreen(Screen):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
yield Container(
|
||||||
|
Label("Select Packages", classes="title"),
|
||||||
|
Label("Edit the list of packages to install (space separated):"),
|
||||||
|
Input(value=" ".join(z.default_packages), id="inp_packages"),
|
||||||
|
Button("Review Summary", variant="primary", id="btn_next"),
|
||||||
|
)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn_next":
|
||||||
|
pkg_str = self.query_one("#inp_packages").value
|
||||||
|
self.app.packages = pkg_str.split() if pkg_str else z.default_packages
|
||||||
|
self.app.push_screen("summary")
|
||||||
|
|
||||||
|
class SummaryScreen(Screen):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
yield Container(
|
||||||
|
Label("Configuration Summary", classes="title"),
|
||||||
|
Static(id="summary_text"),
|
||||||
|
Horizontal(
|
||||||
|
Button("Install", variant="error", id="btn_install"),
|
||||||
|
Button("Quit", variant="default", id="btn_quit"),
|
||||||
|
classes="buttons"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self):
|
||||||
|
text = f"""
|
||||||
|
Disk: {self.app.selected_disk}
|
||||||
|
Seed: {self.app.seed_device}
|
||||||
|
Sprout: {self.app.sprout_device}
|
||||||
|
EFI: {self.app.efi_device}
|
||||||
|
|
||||||
|
Hostname: {self.app.conf_hostname}
|
||||||
|
User: {self.app.conf_username}
|
||||||
|
Timezone: {self.app.conf_timezone}
|
||||||
|
|
||||||
|
Packages: {len(self.app.packages)} selected
|
||||||
|
"""
|
||||||
|
self.query_one("#summary_text").update(text)
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn_quit":
|
||||||
|
self.app.exit()
|
||||||
|
elif event.button.id == "btn_install":
|
||||||
|
self.app.push_screen("install")
|
||||||
|
|
||||||
|
class InstallScreen(Screen):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
yield Container(
|
||||||
|
Label("Installing...", classes="title"),
|
||||||
|
Log(id="install_log"),
|
||||||
|
Button("Done", id="btn_done", disabled=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_mount(self):
|
||||||
|
log = self.query_one(Log)
|
||||||
|
|
||||||
|
config = z.InstallConfig(
|
||||||
|
seed_device=self.app.seed_device,
|
||||||
|
sprout_device=self.app.sprout_device,
|
||||||
|
efi_device=self.app.efi_device,
|
||||||
|
hostname=self.app.conf_hostname,
|
||||||
|
username=self.app.conf_username,
|
||||||
|
timezone=self.app.conf_timezone,
|
||||||
|
root_password=self.app.conf_root_pass,
|
||||||
|
user_password=self.app.conf_user_pass,
|
||||||
|
packages=self.app.packages,
|
||||||
|
# For testing safety, you might want to default to dry_run logic or prompt.
|
||||||
|
# But the user asked for the real deal. I'll add a safety switch.
|
||||||
|
dry_run=False
|
||||||
|
)
|
||||||
|
|
||||||
|
self.worker = InstallWork(config, self.write_log)
|
||||||
|
self.worker.start()
|
||||||
|
self.set_interval(0.5, self.check_done)
|
||||||
|
|
||||||
|
def write_log(self, message):
|
||||||
|
self.query_one(Log).write_line(message)
|
||||||
|
|
||||||
|
def check_done(self):
|
||||||
|
if not self.worker.is_alive():
|
||||||
|
self.query_one("#btn_done").disabled = False
|
||||||
|
self.query_one(Log).write_line("--- Process Finished ---")
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "btn_done":
|
||||||
|
self.app.exit()
|
||||||
|
|
||||||
|
class ZInstallerApp(App):
|
||||||
|
CSS = """
|
||||||
|
.title {
|
||||||
|
text-align: center;
|
||||||
|
text-style: bold;
|
||||||
|
margin: 1;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin: 1 2;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
DataTable {
|
||||||
|
height: 1fr;
|
||||||
|
}
|
||||||
|
.buttons {
|
||||||
|
align: center middle;
|
||||||
|
height: auto;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
margin: 1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
selected_disk = None
|
||||||
|
seed_device = None
|
||||||
|
sprout_device = None
|
||||||
|
efi_device = None
|
||||||
|
|
||||||
|
conf_hostname = None
|
||||||
|
conf_username = None
|
||||||
|
conf_timezone = None
|
||||||
|
conf_root_pass = None
|
||||||
|
conf_user_pass = None
|
||||||
|
packages = []
|
||||||
|
|
||||||
|
SCREENS = {
|
||||||
|
"disk_select": DiskSelectScreen,
|
||||||
|
"partition_select": PartitionSelectScreen,
|
||||||
|
"config": ConfigScreen,
|
||||||
|
"packages": PackageScreen,
|
||||||
|
"summary": SummaryScreen,
|
||||||
|
"install": InstallScreen
|
||||||
|
}
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.push_screen("disk_select")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = ZInstallerApp()
|
||||||
|
app.run()
|
||||||
Loading…
Reference in a new issue