diff --git a/install.sh b/install.sh index 0336b72..0f4a351 100755 --- a/install.sh +++ b/install.sh @@ -1,47 +1,79 @@ #!/usr/bin/env bash -# install.sh — guided NixOS T490 dual-boot installer. +# install.sh — NixOS installer for the T490 (and any other UEFI box). +# +# You tell it: which disk, which partition is the EFI System Partition, and +# which partition to use for NixOS. It does not guess. It does not pick the +# first NTFS as Windows. It does not hardcode /dev/nvme0n1. It does not +# silently shrink your existing partitions. # # Run this from a NixOS 26.05+ installer environment AFTER you've booted # the ISO and dropped into a root shell. # -# What it does: -# 1. Detects the T490's internal NVMe (/dev/nvme0n1) and existing partitions -# 2. Identifies the Windows EFI System Partition (ESP) and the Windows root -# 3. Shrinks the Windows root partition to make room -# 4. Creates a NixOS root + (optional) swap partition -# 5. Formats and mounts them -# 6. Runs nixos-install with this flake -# 7. Reboots into a dual-boot systemd-boot menu +# Required arguments (all explicit, no auto-detection): +# --disk PATH The physical disk to operate on (e.g. /dev/nvme0n1) +# --efi PATH The EFI System Partition (mounted at /mnt/boot) +# --nixos PATH The partition to format as ext4 and mount at /mnt # -# Prereqs: -# - Booted from the NixOS 26.05+ installer ISO (any of them) -# - Network connectivity (for fetching the flake + building) -# - UEFI boot mode (T490 default) -# - Win11 already installed (we share its ESP, we don't touch its partitions) +# Optional: +# --swap PATH Existing partition to use as swap (will mkswap + swapon) +# --new-size SIZE Create a NEW partition of this size (e.g. 200G) for NixOS. +# Use this when --nixos is a NEW partition that needs mkpart. +# The script will compute free space and shrink an +# existing partition only if you also pass --shrink. +# --shrink PATH The existing partition to shrink to make room for +# --new-size. Required if --new-size is set and there +# isn't already enough free space on --disk. The target +# partition is shrunk by AT LEAST (new-size - free-space). +# --no-shrink Hard error if --nixos needs space that isn't free. +# --bootloader MODE "systemd-boot" (default) or "grub" +# --host NAME nixosConfiguration name (default: laptop) +# --flake SPEC Flake spec to install. Default: the local repo if +# ./flake.nix exists next to this script; else +# git+https://os.scoble.me/forgejo/sscoble/laptop-config.git +# --no-fetch Refuse to fetch from network; require --flake to be a +# local path that exists. +# --dry-run, -n Print the plan, do not execute. Exits 0 on a valid plan. +# --yes, -y Skip the confirmation prompt (for scripted installs). +# --help, -h Show this help and exit. # -# Usage: -# curl -L https://os.scoble.me/forgejo/sscoble/laptop-config/raw/branch/main/install.sh | sudo bash +# Examples: +# # T490 with Win11 on /dev/nvme0n1. Create new NixOS root + swap, +# # shrinking the Windows partition to make room. +# install.sh --disk /dev/nvme0n1 \ +# --efi /dev/nvme0n1p1 \ +# --nixos /dev/nvme0n1p6 --new-size 200G --shrink /dev/nvme0n1p4 \ +# --swap /dev/nvme0n1p7 # -# Or, if you've cloned the repo: -# sudo ./install.sh +# # Use a partition you already prepared (no shrink, no new partition) +# install.sh --disk /dev/nvme0n1 \ +# --efi /dev/nvme0n1p1 \ +# --nixos /dev/nvme0n1p5 # -# Override defaults with env vars: -# DISK=/dev/nvme0n1 # internal NVMe (default: auto-detect) -# NIXOS_SIZE=200G # size of the new NixOS root partition (default: 200G) -# SWAP_SIZE=16G # size of swap partition (default: 16G, 0 to skip) -# FLAKE_URL=... # override the flake source -# HOST=laptop # nixosConfiguration name (default: laptop) +# # Dry-run: see what would happen +# install.sh --disk /dev/nvme0n1 --efi /dev/nvme0n1p1 \ +# --nixos /dev/nvme0n1p6 --new-size 200G --shrink /dev/nvme0n1p4 \ +# --dry-run +# +# Safety: +# * Refuses to run unless --efi is a vfat partition with the ESP GUID. +# * Refuses if --nixos == --efi or if partitions don't belong to --disk. +# * Refuses to shrink a partition that doesn't have a recognized filesystem. +# * Refuses to format --nixos if it's the same device as the running /boot. +# * Refuses to fetch the flake from network if --no-fetch is set and the +# local flake is missing. +# * Always shows a plan and asks for 'yes' unless --yes is given. set -euo pipefail # === Defaults === -DISK="${DISK:-}" -NIXOS_SIZE="${NIXOS_SIZE:-200G}" -SWAP_SIZE="${SWAP_SIZE:-16G}" -FLAKE_URL="${FLAKE_URL:-git+https://os.scoble.me/forgejo/sscoble/laptop-config.git}" -HOST="${HOST:-laptop}" +BOOTLOADER="systemd-boot" +HOST="laptop" +FLAKE_URL="" +NO_FETCH=0 +DRY_RUN=0 +YES=0 -# === Color output (no-op if not a TTY) === +# === Color output === if [[ -t 1 ]]; then RED=$'\033[0;31m'; YEL=$'\033[0;33m'; GRN=$'\033[0;32m'; CYN=$'\033[0;36m'; NC=$'\033[0m' else @@ -52,72 +84,148 @@ warn() { echo "${YEL}!!!${NC} $*" >&2; } fail() { echo "${RED}FAIL${NC} $*" >&2; exit 1; } ok() { echo "${GRN} OK ${NC} $*"; } -# === Root check === -[[ $EUID -eq 0 ]] || fail "must run as root (sudo ./install.sh)" +# === Help text === +usage() { + sed -n '2,46p' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +# === Argument parsing === +DISK="" ESP_PART="" NIXOS_PART="" SWAP_PART="" NEW_SIZE="" SHRINK_PART="" NO_SHRINK=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --disk) DISK="$2"; shift 2 ;; + --efi) ESP_PART="$2"; shift 2 ;; + --nixos) NIXOS_PART="$2"; shift 2 ;; + --swap) SWAP_PART="$2"; shift 2 ;; + --new-size) NEW_SIZE="$2"; shift 2 ;; + --shrink) SHRINK_PART="$2"; shift 2 ;; + --no-shrink) NO_SHRINK=1; shift ;; + --bootloader) BOOTLOADER="$2"; shift 2 ;; + --host) HOST="$2"; shift 2 ;; + --flake) FLAKE_URL="$2"; shift 2 ;; + --no-fetch) NO_FETCH=1; shift ;; + --dry-run|-n) DRY_RUN=1; shift ;; + --yes|-y) YES=1; shift ;; + --help|-h) usage 0 ;; + *) fail "unknown argument: $1 (try --help)" ;; + esac +done + +# === Root check (skip for --help / --dry-run with no destructive args) === +if [[ $EUID -ne 0 ]]; then + if [[ $DRY_RUN -eq 1 ]]; then + log "Not running as root, but --dry-run is set; continuing (no writes will be attempted)" + else + fail "must run as root (sudo ./install.sh)" + fi +fi + +# === Required args === +[[ -n "$DISK" ]] || fail "--disk is required (try --help)" +[[ -n "$ESP_PART" ]] || fail "--efi is required (try --help)" +[[ -n "$NIXOS_PART" ]] || fail "--nixos is required (try --help)" # === Sanity: this is a UEFI machine === -[[ -d /sys/firmware/efi ]] || fail "system is not booted in UEFI mode — T490 should always be UEFI" +[[ -d /sys/firmware/efi ]] || fail "system is not booted in UEFI mode" -# === Detect disk === -if [[ -z "$DISK" ]]; then - # T490 internal is /dev/nvme0n1; reject anything that's clearly removable - for candidate in /dev/nvme0n1 /dev/sda; do - if [[ -b "$candidate" ]] && lsblk -ndo TRAN "$candidate" 2>/dev/null | grep -qE "nvme|sata"; then - # Make sure it's the boot disk (has the ESP) - if lsblk -nlo PARTTYPE "$candidate"* 2>/dev/null | grep -qi "EFI System"; then - DISK="$candidate" - break - fi - fi - done -fi -[[ -n "$DISK" ]] || fail "could not auto-detect internal disk; set DISK=/dev/nvme0n1 explicitly" -log "Target disk: $DISK" +# === Validate that the partitions exist and are block devices === +[[ -b "$DISK" ]] || fail "--disk $DISK is not a block device" +[[ -b "$ESP_PART" ]] || fail "--efi $ESP_PART is not a block device" +[[ -b "$NIXOS_PART" ]] || fail "--nixos $NIXOS_PART is not a block device" +[[ -z "$SWAP_PART" || -b "$SWAP_PART" ]] || fail "--swap $SWAP_PART is not a block device" +[[ -z "$SHRINK_PART" || -b "$SHRINK_PART" ]] || fail "--shrink $SHRINK_PART is not a block device" -# === Identify partitions === -ESP_PART="" -WIN_PART="" -# Use lsblk to enumerate partitions, then filter by parttype -while IFS= read -r part; do - ptype=$(lsblk -ndo PARTTYPE "$part" 2>/dev/null || true) - if [[ "$ptype" == "c12a7328-f81f-11d2-ba4b-00a0c93ec93b" && -z "$ESP_PART" ]]; then - ESP_PART="$part" - elif [[ "$ptype" == "ebd0a0a2-b9e5-4433-87c0-68b6b72699c7" && -z "$WIN_PART" ]]; then - # First big NTFS partition = Windows root - WIN_PART="$part" - WIN_SIZE=$(lsblk -ndo SIZE "$part" 2>/dev/null || true) +# === Validate that --efi, --nixos, --swap, --shrink all belong to --disk === +# A partition belongs to a disk if lsblk shows it under the same parent. +# Walk: for each candidate, get its PKNAME (parent kernel name) and check +# it matches the basename of $DISK (e.g. nvme0n1, sda). +parent_of() { + lsblk -ndo PKNAME "$1" 2>/dev/null | head -1 +} +diskn=$(basename "$DISK") +for part in "$ESP_PART" "$NIXOS_PART" "$SWAP_PART" "$SHRINK_PART"; do + [[ -z "$part" ]] && continue + parent=$(parent_of "$part") + if [[ "$parent" != "$diskn" ]]; then + fail "$part is not a partition of $DISK (parent is '${parent:-none}')" fi -done < <(lsblk -nlo NAME "$DISK" 2>/dev/null | tail -n +2 | sed "s|^|/dev/|") +done -[[ -n "$ESP_PART" ]] || fail "no EFI System Partition found on $DISK" -[[ -n "$WIN_PART" ]] || fail "no Windows NTFS partition found on $DISK" -log "EFI System Partition: $ESP_PART" -log "Windows partition: $WIN_PART ($WIN_SIZE)" +# === No partition may be used twice === +seen="$DISK|$ESP_PART|$NIXOS_PART" +[[ -n "$SWAP_PART" ]] && seen="$seen|$SWAP_PART" +[[ -n "$SHRINK_PART" ]] && seen="$seen|$SHRINK_PART" +echo "$seen" | tr '|' '\n' | sort | uniq -d | grep -v '^$' >/dev/null \ + && fail "the same partition was specified for multiple roles" -# === Confirm with user === -cat </dev/null || true) +ESP_PTYPE=$(lsblk -ndo PARTTYPE "$ESP_PART" 2>/dev/null || true) +if [[ "$ESP_PTYPE" != "$ESP_GUID" ]]; then + fail "$ESP_PART is not an EFI System Partition (PARTTYPE=$ESP_PTYPE, expected $ESP_GUID)" +fi +if [[ "$ESP_FSTYPE" != "vfat" ]]; then + fail "$ESP_PART does not have a vfat filesystem (FSTYPE='$ESP_FSTYPE') — refusing to mount as ESP" fi -cat </dev/null | grep -qE '^/$'; then + fail "$NIXOS_PART is mounted as / — refusing to format the running root" +fi +if findmnt -n -o TARGET --source "$NIXOS_PART" 2>/dev/null | grep -qE '^/boot$'; then + fail "$NIXOS_PART is mounted as /boot — refusing to format it" +fi -# === Shrink Windows === -# Convert NIXOS_SIZE (e.g. "200G") to bytes for ntfsresize +# === Validate --swap is a real partition (not the same as NixOS root) === +if [[ -n "$SWAP_PART" && "$SWAP_PART" == "$NIXOS_PART" ]]; then + fail "--swap and --nixos are the same partition" +fi + +# === Resolve the flake spec === +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -z "$FLAKE_URL" ]]; then + if [[ -f "$SCRIPT_DIR/flake.nix" ]]; then + FLAKE_URL="path:$SCRIPT_DIR" + log "Using local flake: $FLAKE_URL" + else + FLAKE_URL="git+https://os.scoble.me/forgejo/sscoble/laptop-config.git" + log "No local flake.nix found; falling back to $FLAKE_URL" + fi +fi +if [[ "$NO_FETCH" -eq 1 ]]; then + if [[ "$FLAKE_URL" != path:* ]]; then + fail "--no-fetch is set but --flake '$FLAKE_URL' is not a local path" + fi + localpath="${FLAKE_URL#path:}" + [[ -f "$localpath/flake.nix" ]] || fail "--no-fetch: $localpath/flake.nix does not exist" +fi + +# === Validate --new-size / --shrink consistency === +if [[ -n "$NEW_SIZE" && -z "$SHRINK_PART" && "$NO_SHRINK" -eq 0 ]]; then + # NEW_SIZE set, no --shrink, no --no-shrink. Check free space; if insufficient, fail. + free_bytes=$(parted -s "$DISK" print free 2>/dev/null \ + | awk '/^Free Space/ {gsub(/[^0-9]/,"",$3); print $3; exit}' \ + | awk '{ printf "%d\n", $1 * 1024 }') # MiB → bytes + : "${free_bytes:=0}" + need_bytes=$(size_to_bytes "$NEW_SIZE") + if (( free_bytes < need_bytes )); then + fail "--new-size=$NEW_SIZE needs $need_bytes B but only $free_bytes B free on $DISK; pass --shrink or --no-shrink" + fi + log "Free space on $DISK: $free_bytes B ≥ $need_bytes B — no shrink needed" +fi +if [[ "$NO_SHRINK" -eq 1 && -n "$SHRINK_PART" ]]; then + fail "--no-shrink and --shrink are mutually exclusive" +fi + +# === Validate bootloader choice === +case "$BOOTLOADER" in + systemd-boot|grub) ;; + *) fail "--bootloader must be 'systemd-boot' or 'grub' (got '$BOOTLOADER')" ;; +esac + +# === size_to_bytes helper === size_to_bytes() { local s="$1" case "$s" in @@ -127,115 +235,164 @@ size_to_bytes() { *) echo "$s" ;; esac } -nixos_bytes=$(size_to_bytes "$NIXOS_SIZE") -# Same for SWAP_SIZE so we can shrink the Windows partition by the *total* of -# both new partitions (NixOS root + swap). Without this, the swap partition -# would never fit on disk. -if [[ "$SWAP_SIZE" == "0" ]]; then - shrink_bytes=$nixos_bytes -else - swap_bytes=$(size_to_bytes "$SWAP_SIZE") - shrink_bytes=$(( nixos_bytes + swap_bytes )) + +# === Build the plan === +plan_actions=() +[[ -n "$SHRINK_PART" ]] && plan_actions+=("shrink $SHRINK_PART by ~$NEW_SIZE") +[[ -n "$NEW_SIZE" ]] && plan_actions+=("create new partition on $DISK (size $NEW_SIZE) for NixOS root") +plan_actions+=("format $NIXOS_PART as ext4 (label: nixos)") +[[ -n "$SWAP_PART" ]] && plan_actions+=("mkswap + swapon on $SWAP_PART") +plan_actions+=("mount $NIXOS_PART at /mnt") +plan_actions+=("mount $ESP_PART at /mnt/boot") +plan_actions+=("nixos-generate-config --root /mnt") +plan_actions+=("nixos-install --flake $FLAKE_URL#$HOST --root /mnt --no-root-passwd") +plan_actions+=("reboot (manual, you'll be prompted)") + +# === Show the plan and ask for confirmation === +cat <} + Shrink target: ${SHRINK_PART:-} + New partition: ${NEW_SIZE:-} + Bootloader: $BOOTLOADER + Host config: $HOST + Flake: $FLAKE_URL + Dry run: $([[ $DRY_RUN -eq 1 ]] && echo "yes (no writes)" || echo "no (will execute)") + +${YEL}Actions:${NC} +EOF +for i in "${!plan_actions[@]}"; do + printf " %2d. %s\n" $((i+1)) "${plan_actions[$i]}" +done +cat </dev/null || true -# ntfsresize syntax: -s = perform the shrink, --size = new size in bytes -# We want the new Windows partition to be (current size - shrink_bytes). -win_size_bytes=$(lsblk -ndo SIZE -b "$WIN_PART") -new_win_bytes=$(( win_size_bytes - shrink_bytes )) -ntfsresize -s --size "$new_win_bytes" "$WIN_PART" 2>&1 | tail -5 \ - || fail "ntfsresize failed; aborting" -# === Recreate partition table entry === -# ntfsresize modified the partition in place; parted may need a re-read -partprobe "$DISK" -sleep 2 +if [[ $YES -ne 1 ]]; then + read -rp $'\nType \'yes\' to proceed: ' confirm + [[ "$confirm" == "yes" ]] || fail "aborted by user" +fi -# Snapshot the partition list BEFORE we create the new ones, so we can diff -# and identify which device nodes are new. Plain `sort | tail -1` is unsafe -# when more than one new partition is created (NixOS root + swap). -existing_parts=$(lsblk -nlo NAME "$DISK" 2>/dev/null | tail -n +2 | sort) +# === Pre-flight: ask for root password now (nixos-install --no-root-passwd +# means a password must be set post-install, which is a common stumble) === +if [[ $YES -ne 1 ]]; then + cat </dev/null || true) + case "$SHRINK_FSTYPE" in + ntfs) + log "Shrinking NTFS partition $SHRINK_PART (this can take several minutes)..." + ntfsfix -d "$SHRINK_PART" 2>/dev/null || true + nixos_bytes=$(size_to_bytes "$NEW_SIZE") + cur_bytes=$(lsblk -ndo SIZE -b "$SHRINK_PART") + new_bytes=$(( cur_bytes - nixos_bytes )) + ntfsresize -s --size "$new_bytes" "$SHRINK_PART" 2>&1 | tail -5 \ + || fail "ntfsresize failed; aborting" + partprobe "$DISK" + sleep 2 + ;; + ext4) + log "Shrinking ext4 partition $SHRINK_PART..." + e2fsck -f "$SHRINK_PART" || fail "e2fsck failed on $SHRINK_PART" + resize2fs "$SHRINK_PART" "$(size_to_bytes "$NEW_SIZE")" \ + || fail "resize2fs failed; aborting" + ;; + "") + fail "$SHRINK_PART has no filesystem; refusing to shrink" + ;; + *) + fail "$SHRINK_PART has FSTYPE=$SHRINK_FSTYPE; only ntfs and ext4 are supported for shrink" + ;; esac - swap_end_mib=$(( nix_end_mib + swap_size_mib )) - log "Creating swap partition (${SWAP_SIZE}) at ${nix_end_mib}MiB..${swap_end_mib}MiB..." - parted -s "$DISK" mkpart primary linux-swap "${nix_end_mib}MiB" "${swap_end_mib}MiB" \ - || fail "parted mkpart (swap) failed" +fi + +# === Create new partition (if requested) === +if [[ -n "$NEW_SIZE" ]]; then + # Find free space on the disk in MiB + free_mib=$(parted -s "$DISK" print free 2>/dev/null \ + | awk '/^Free Space/ {gsub(/[^0-9]/,"",$3); print $3; exit}') + : "${free_mib:=0}" + # Find the start of the free region + free_start_mib=$(parted -s "$DISK" print free 2>/dev/null \ + | awk '/^Free Space/ {print $1; exit}' | tr -d 'MiB') + : "${free_start_mib:=0}" + # Parse NEW_SIZE to MiB + nix_num=${NEW_SIZE%[GgMmKk]*}; nix_unit=${NEW_SIZE##*[0-9]} + case "$nix_unit" in + G|g) nix_size_mib=$(( nix_num * 1024 )) ;; + M|m) nix_size_mib=$nix_num ;; + *) fail "--new-size must be suffixed with M or G (got '$NEW_SIZE')" ;; + esac + end_mib=$(( free_start_mib + nix_size_mib )) + log "Creating new partition on $DISK: ${free_start_mib}MiB..${end_mib}MiB (${NEW_SIZE})" + parted -s "$DISK" mkpart primary ext4 "${free_start_mib}MiB" "${end_mib}MiB" \ + || fail "parted mkpart failed" partprobe "$DISK" sleep 1 + # If --nixos was a placeholder, we should warn that the user must re-run + # with the actual new partition path. For now, fail clearly. + if ! lsblk -nlo NAME "$DISK" 2>/dev/null | tail -n +2 | sed 's|^|/dev/|' \ + | grep -qx "$NIXOS_PART"; then + fail "new partition created, but $NIXOS_PART is not on $DISK. Re-run with the new partition path (check lsblk)." + fi fi -# Identify the new partitions by diffing the partition table. New partitions -# are the ones that didn't exist before we ran mkpart. -new_parts=$(lsblk -nlo NAME "$DISK" 2>/dev/null | tail -n +2 | sort) -NIXOS_PART=$(comm -13 <(echo "$existing_parts") <(echo "$new_parts") \ - | grep -v "$(basename "$DISK")" \ - | head -1 \ - | sed 's|^|/dev/|') -[[ -n "$NIXOS_PART" ]] || fail "could not identify the new NixOS partition" -log "New NixOS partition: $NIXOS_PART" -if [[ "$SWAP_SIZE" != "0" ]]; then - SWAP_PART=$(comm -13 <(echo "$existing_parts") <(echo "$new_parts") \ - | grep -v "$NIXOS_PART" \ - | grep -v "$(basename "$DISK")" \ - | head -1 \ - | sed 's|^|/dev/|') - [[ -n "$SWAP_PART" ]] || fail "could not identify the new swap partition" - log "New swap partition: $SWAP_PART" -fi - -# === Format and mount === +# === Format NixOS root === log "Formatting $NIXOS_PART as ext4..." mkfs.ext4 -F -L nixos "$NIXOS_PART" -if [[ "$SWAP_SIZE" != "0" ]]; then +# === Set up swap === +if [[ -n "$SWAP_PART" ]]; then log "Setting up swap on $SWAP_PART..." mkswap -L swap "$SWAP_PART" swapon "$SWAP_PART" fi +# === Mount === log "Mounting..." mount "$NIXOS_PART" /mnt mkdir -p /mnt/boot mount "$ESP_PART" /mnt/boot -ok "Mounted: $(lsblk -no MOUNTPOINTS $NIXOS_PART) and $(lsblk -no MOUNTPOINTS $ESP_PART)" +ok "Mounted: $NIXOS_PART → /mnt, $ESP_PART → /mnt/boot" # === Generate hardware config === log "Generating hardware-configuration.nix..." nixos-generate-config --root /mnt -# === nixos-install with the flake === +# === nixos-install === log "Installing NixOS from flake: $FLAKE_URL#$HOST" -log "(This fetches and builds the world; first run is slow.)" +log "(First run builds the world; this can take 30-60 minutes.)" nixos-install --flake "${FLAKE_URL}#${HOST}" --root /mnt --no-root-passwd ok "NixOS installed." @@ -243,17 +400,18 @@ ok "NixOS installed." cat <