install.sh: explicit --disk/--efi/--nixos CLI args, no auto-detection

Following the 'the install script assumed too much' rule (Scott 2026-06-06):
- Required --disk, --efi, --nixos — no more auto-detect of partitions
- --new-size + --shrink replace implicit 'shrink Windows, mkpart at end'
- --no-shrink for installs into pre-prepared free space
- --no-fetch requires local flake (refuses network fetch)
- --dry-run prints plan and exits (validates pre-flight checks)
- Safety: refuses if --efi isn't GUID c12a7328-..., if partitions don't
  belong to --disk, if --nixos is mounted as / or /boot, if --swap
  duplicates --nixos
- Local flake preferred: ./flake.nix next to script beats network URL
- Help text via --help extracted from the script header
- Same idempotency for mount/hardware-config/nixos-install; only --shrink
  is the one-time non-idempotent step

Tested: bash -n, --help, dry-run with invalid/missing args all pass.
This commit is contained in:
Scott Scoble 2026-06-06 07:20:24 -07:00
parent 060194b734
commit ea46ee2c93

View file

@ -1,47 +1,79 @@
#!/usr/bin/env bash #!/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 # Run this from a NixOS 26.05+ installer environment AFTER you've booted
# the ISO and dropped into a root shell. # the ISO and dropped into a root shell.
# #
# What it does: # Required arguments (all explicit, no auto-detection):
# 1. Detects the T490's internal NVMe (/dev/nvme0n1) and existing partitions # --disk PATH The physical disk to operate on (e.g. /dev/nvme0n1)
# 2. Identifies the Windows EFI System Partition (ESP) and the Windows root # --efi PATH The EFI System Partition (mounted at /mnt/boot)
# 3. Shrinks the Windows root partition to make room # --nixos PATH The partition to format as ext4 and mount at /mnt
# 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
# #
# Prereqs: # Optional:
# - Booted from the NixOS 26.05+ installer ISO (any of them) # --swap PATH Existing partition to use as swap (will mkswap + swapon)
# - Network connectivity (for fetching the flake + building) # --new-size SIZE Create a NEW partition of this size (e.g. 200G) for NixOS.
# - UEFI boot mode (T490 default) # Use this when --nixos is a NEW partition that needs mkpart.
# - Win11 already installed (we share its ESP, we don't touch its partitions) # 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: # Examples:
# curl -L https://os.scoble.me/forgejo/sscoble/laptop-config/raw/branch/main/install.sh | sudo bash # # 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: # # Use a partition you already prepared (no shrink, no new partition)
# sudo ./install.sh # install.sh --disk /dev/nvme0n1 \
# --efi /dev/nvme0n1p1 \
# --nixos /dev/nvme0n1p5
# #
# Override defaults with env vars: # # Dry-run: see what would happen
# DISK=/dev/nvme0n1 # internal NVMe (default: auto-detect) # install.sh --disk /dev/nvme0n1 --efi /dev/nvme0n1p1 \
# NIXOS_SIZE=200G # size of the new NixOS root partition (default: 200G) # --nixos /dev/nvme0n1p6 --new-size 200G --shrink /dev/nvme0n1p4 \
# SWAP_SIZE=16G # size of swap partition (default: 16G, 0 to skip) # --dry-run
# FLAKE_URL=... # override the flake source #
# HOST=laptop # nixosConfiguration name (default: laptop) # 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 set -euo pipefail
# === Defaults === # === Defaults ===
DISK="${DISK:-}" BOOTLOADER="systemd-boot"
NIXOS_SIZE="${NIXOS_SIZE:-200G}" HOST="laptop"
SWAP_SIZE="${SWAP_SIZE:-16G}" FLAKE_URL=""
FLAKE_URL="${FLAKE_URL:-git+https://os.scoble.me/forgejo/sscoble/laptop-config.git}" NO_FETCH=0
HOST="${HOST:-laptop}" DRY_RUN=0
YES=0
# === Color output (no-op if not a TTY) === # === Color output ===
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
RED=$'\033[0;31m'; YEL=$'\033[0;33m'; GRN=$'\033[0;32m'; CYN=$'\033[0;36m'; NC=$'\033[0m' RED=$'\033[0;31m'; YEL=$'\033[0;33m'; GRN=$'\033[0;32m'; CYN=$'\033[0;36m'; NC=$'\033[0m'
else else
@ -52,72 +84,148 @@ warn() { echo "${YEL}!!!${NC} $*" >&2; }
fail() { echo "${RED}FAIL${NC} $*" >&2; exit 1; } fail() { echo "${RED}FAIL${NC} $*" >&2; exit 1; }
ok() { echo "${GRN} OK ${NC} $*"; } ok() { echo "${GRN} OK ${NC} $*"; }
# === Root check === # === Help text ===
[[ $EUID -eq 0 ]] || fail "must run as root (sudo ./install.sh)" 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 === # === 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 === # === Validate that the partitions exist and are block devices ===
if [[ -z "$DISK" ]]; then [[ -b "$DISK" ]] || fail "--disk $DISK is not a block device"
# T490 internal is /dev/nvme0n1; reject anything that's clearly removable [[ -b "$ESP_PART" ]] || fail "--efi $ESP_PART is not a block device"
for candidate in /dev/nvme0n1 /dev/sda; do [[ -b "$NIXOS_PART" ]] || fail "--nixos $NIXOS_PART is not a block device"
if [[ -b "$candidate" ]] && lsblk -ndo TRAN "$candidate" 2>/dev/null | grep -qE "nvme|sata"; then [[ -z "$SWAP_PART" || -b "$SWAP_PART" ]] || fail "--swap $SWAP_PART is not a block device"
# Make sure it's the boot disk (has the ESP) [[ -z "$SHRINK_PART" || -b "$SHRINK_PART" ]] || fail "--shrink $SHRINK_PART is not a block device"
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"
# === Identify partitions === # === Validate that --efi, --nixos, --swap, --shrink all belong to --disk ===
ESP_PART="" # A partition belongs to a disk if lsblk shows it under the same parent.
WIN_PART="" # Walk: for each candidate, get its PKNAME (parent kernel name) and check
# Use lsblk to enumerate partitions, then filter by parttype # it matches the basename of $DISK (e.g. nvme0n1, sda).
while IFS= read -r part; do parent_of() {
ptype=$(lsblk -ndo PARTTYPE "$part" 2>/dev/null || true) lsblk -ndo PKNAME "$1" 2>/dev/null | head -1
if [[ "$ptype" == "c12a7328-f81f-11d2-ba4b-00a0c93ec93b" && -z "$ESP_PART" ]]; then }
ESP_PART="$part" diskn=$(basename "$DISK")
elif [[ "$ptype" == "ebd0a0a2-b9e5-4433-87c0-68b6b72699c7" && -z "$WIN_PART" ]]; then for part in "$ESP_PART" "$NIXOS_PART" "$SWAP_PART" "$SHRINK_PART"; do
# First big NTFS partition = Windows root [[ -z "$part" ]] && continue
WIN_PART="$part" parent=$(parent_of "$part")
WIN_SIZE=$(lsblk -ndo SIZE "$part" 2>/dev/null || true) if [[ "$parent" != "$diskn" ]]; then
fail "$part is not a partition of $DISK (parent is '${parent:-none}')"
fi 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" # === No partition may be used twice ===
[[ -n "$WIN_PART" ]] || fail "no Windows NTFS partition found on $DISK" seen="$DISK|$ESP_PART|$NIXOS_PART"
log "EFI System Partition: $ESP_PART" [[ -n "$SWAP_PART" ]] && seen="$seen|$SWAP_PART"
log "Windows partition: $WIN_PART ($WIN_SIZE)" [[ -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 === # === Validate --efi is actually an ESP ===
cat <<EOF ESP_GUID="c12a7328-f81f-11d2-ba4b-00a0c93ec93b"
ESP_FSTYPE=$(lsblk -ndo FSTYPE "$ESP_PART" 2>/dev/null || true)
${YEL}Plan:${NC} ESP_PTYPE=$(lsblk -ndo PARTTYPE "$ESP_PART" 2>/dev/null || true)
1. Shrink $WIN_PART to make room if [[ "$ESP_PTYPE" != "$ESP_GUID" ]]; then
2. Create new partition for NixOS root: $NIXOS_SIZE fail "$ESP_PART is not an EFI System Partition (PARTTYPE=$ESP_PTYPE, expected $ESP_GUID)"
EOF fi
if [[ "$SWAP_SIZE" != "0" ]]; then if [[ "$ESP_FSTYPE" != "vfat" ]]; then
echo " 3. Create new swap partition: $SWAP_SIZE" fail "$ESP_PART does not have a vfat filesystem (FSTYPE='$ESP_FSTYPE') — refusing to mount as ESP"
fi fi
cat <<EOF
4. Mount $ESP_PART at /mnt/boot (shared with Windows)
5. Mount new NixOS root at /mnt
6. nixos-install with flake $FLAKE_URL#$HOST
7. Reboot — systemd-boot menu will show NixOS and Windows
${RED}All data on the disk remains intact. We do not touch the Windows # === Validate --nixos isn't mounted as the running / or /boot ===
partition; we only shrink it. The recovery partition (if present) is preserved.${NC} if findmnt -n -o TARGET --source "$NIXOS_PART" 2>/dev/null | grep -qE '^/$'; then
EOF fail "$NIXOS_PART is mounted as / — refusing to format the running root"
read -rp "Type 'yes' to proceed: " confirm fi
[[ "$confirm" == "yes" ]] || fail "aborted by user" 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 === # === Validate --swap is a real partition (not the same as NixOS root) ===
# Convert NIXOS_SIZE (e.g. "200G") to bytes for ntfsresize 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 <part> 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() { size_to_bytes() {
local s="$1" local s="$1"
case "$s" in case "$s" in
@ -127,115 +235,164 @@ size_to_bytes() {
*) echo "$s" ;; *) echo "$s" ;;
esac esac
} }
nixos_bytes=$(size_to_bytes "$NIXOS_SIZE")
# Same for SWAP_SIZE so we can shrink the Windows partition by the *total* of # === Build the plan ===
# both new partitions (NixOS root + swap). Without this, the swap partition plan_actions=()
# would never fit on disk. [[ -n "$SHRINK_PART" ]] && plan_actions+=("shrink $SHRINK_PART by ~$NEW_SIZE")
if [[ "$SWAP_SIZE" == "0" ]]; then [[ -n "$NEW_SIZE" ]] && plan_actions+=("create new partition on $DISK (size $NEW_SIZE) for NixOS root")
shrink_bytes=$nixos_bytes plan_actions+=("format $NIXOS_PART as ext4 (label: nixos)")
else [[ -n "$SWAP_PART" ]] && plan_actions+=("mkswap + swapon on $SWAP_PART")
swap_bytes=$(size_to_bytes "$SWAP_SIZE") plan_actions+=("mount $NIXOS_PART at /mnt")
shrink_bytes=$(( nixos_bytes + swap_bytes )) 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 <<EOF
${YEL}=== Install plan ===${NC}
Target disk: $DISK
EFI partition: $ESP_PART
NixOS root: $NIXOS_PART
Swap: ${SWAP_PART:-<none>}
Shrink target: ${SHRINK_PART:-<none>}
New partition: ${NEW_SIZE:-<none>}
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 <<EOF
${RED}All existing data on the specified partitions will be DESTROYED:
- $NIXOS_PART will be formatted (ext4)
EOF
[[ -n "$SWAP_PART" ]] && echo " - $SWAP_PART will be re-created as swap"
[[ -n "$SHRINK_PART" ]] && echo " - $SHRINK_PART will be SHRUNK (run a filesystem check first if it's NTFS)"
cat <<EOF
Partitions NOT touched:
- $ESP_PART (mounted at /mnt/boot, never reformatted)
- any other partition on $DISK
EOF
if [[ $DRY_RUN -eq 1 ]]; then
echo
ok "DRY RUN: plan validated. No changes made."
exit 0
fi fi
log "Shrinking Windows partition by $NIXOS_SIZE (nixos) ${SWAP_SIZE:+ + $SWAP_SIZE (swap)} ($shrink_bytes bytes total) — this may take a few minutes..."
ntfsfix -d "$WIN_PART" 2>/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 === if [[ $YES -ne 1 ]]; then
# ntfsresize modified the partition in place; parted may need a re-read read -rp $'\nType \'yes\' to proceed: ' confirm
partprobe "$DISK" [[ "$confirm" == "yes" ]] || fail "aborted by user"
sleep 2 fi
# Snapshot the partition list BEFORE we create the new ones, so we can diff # === Pre-flight: ask for root password now (nixos-install --no-root-passwd
# and identify which device nodes are new. Plain `sort | tail -1` is unsafe # means a password must be set post-install, which is a common stumble) ===
# when more than one new partition is created (NixOS root + swap). if [[ $YES -ne 1 ]]; then
existing_parts=$(lsblk -nlo NAME "$DISK" 2>/dev/null | tail -n +2 | sort) cat <<EOF
# === Create NixOS root + swap in the freed space === ${YEL}About nixos-install:${NC}
# Compute the start = end of the (now smaller) Windows partition. The script uses --no-root-passwd (declarative config sets the password).
# We deliberately do NOT use `-NIXOS_SIZE 100%` because on a T490 the recovery Make sure modules/laptop.nix (or wherever you set users.users.root.password)
# partition sits at the end of the disk and that range would overlap it. has a hashed password, OR run 'passwd' in the nixos-enter shell after
# We work in 1MiB units and let parted align. install completes.
win_new_bytes=$(lsblk -ndo SIZE -b "$WIN_PART") EOF
win_end_mib=$(( (win_new_bytes + 1024*1024 - 1) / (1024*1024) )) fi
# Extract the numeric prefix of NIXOS_SIZE for the parted range calculation. # === Shrink existing partition (if requested) ===
nix_num=${NIXOS_SIZE%[GgMmKk]*}; nix_unit=${NIXOS_SIZE##*[0-9]} if [[ -n "$SHRINK_PART" ]]; then
case "$nix_unit" in log "Validating that $SHRINK_PART can be safely shrunk..."
G|g) nix_size_mib=$(( nix_num * 1024 )) ;; SHRINK_FSTYPE=$(lsblk -ndo FSTYPE "$SHRINK_PART" 2>/dev/null || true)
M|m) nix_size_mib=$nix_num ;; case "$SHRINK_FSTYPE" in
*) fail "NIXOS_SIZE must be suffixed with M or G (got '$NIXOS_SIZE')" ;; ntfs)
esac log "Shrinking NTFS partition $SHRINK_PART (this can take several minutes)..."
nix_start_mib=$win_end_mib ntfsfix -d "$SHRINK_PART" 2>/dev/null || true
nix_end_mib=$(( nix_start_mib + nix_size_mib )) nixos_bytes=$(size_to_bytes "$NEW_SIZE")
log "Creating NixOS root partition (${NIXOS_SIZE}) at ${nix_start_mib}MiB..${nix_end_mib}MiB..." cur_bytes=$(lsblk -ndo SIZE -b "$SHRINK_PART")
parted -s "$DISK" mkpart primary ext4 "${nix_start_mib}MiB" "${nix_end_mib}MiB" \ new_bytes=$(( cur_bytes - nixos_bytes ))
|| fail "parted mkpart (nixos) failed" ntfsresize -s --size "$new_bytes" "$SHRINK_PART" 2>&1 | tail -5 \
partprobe "$DISK" || fail "ntfsresize failed; aborting"
sleep 1 partprobe "$DISK"
sleep 2
if [[ "$SWAP_SIZE" != "0" ]]; then ;;
swap_num=${SWAP_SIZE%[GgMmKk]*}; swap_unit=${SWAP_SIZE##*[0-9]} ext4)
case "$swap_unit" in log "Shrinking ext4 partition $SHRINK_PART..."
G|g) swap_size_mib=$(( swap_num * 1024 )) ;; e2fsck -f "$SHRINK_PART" || fail "e2fsck failed on $SHRINK_PART"
M|m) swap_size_mib=$swap_num ;; resize2fs "$SHRINK_PART" "$(size_to_bytes "$NEW_SIZE")" \
*) fail "SWAP_SIZE must be suffixed with M or G (got '$SWAP_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 esac
swap_end_mib=$(( nix_end_mib + swap_size_mib )) fi
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" \ # === Create new partition (if requested) ===
|| fail "parted mkpart (swap) failed" 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" partprobe "$DISK"
sleep 1 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 fi
# Identify the new partitions by diffing the partition table. New partitions # === Format NixOS root ===
# 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 ===
log "Formatting $NIXOS_PART as ext4..." log "Formatting $NIXOS_PART as ext4..."
mkfs.ext4 -F -L nixos "$NIXOS_PART" 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..." log "Setting up swap on $SWAP_PART..."
mkswap -L swap "$SWAP_PART" mkswap -L swap "$SWAP_PART"
swapon "$SWAP_PART" swapon "$SWAP_PART"
fi fi
# === Mount ===
log "Mounting..." log "Mounting..."
mount "$NIXOS_PART" /mnt mount "$NIXOS_PART" /mnt
mkdir -p /mnt/boot mkdir -p /mnt/boot
mount "$ESP_PART" /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 === # === Generate hardware config ===
log "Generating hardware-configuration.nix..." log "Generating hardware-configuration.nix..."
nixos-generate-config --root /mnt nixos-generate-config --root /mnt
# === nixos-install with the flake === # === nixos-install ===
log "Installing NixOS from flake: $FLAKE_URL#$HOST" 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 nixos-install --flake "${FLAKE_URL}#${HOST}" --root /mnt --no-root-passwd
ok "NixOS installed." ok "NixOS installed."
@ -243,17 +400,18 @@ ok "NixOS installed."
cat <<EOF cat <<EOF
${GRN}Next steps:${NC} ${GRN}Next steps:${NC}
1. Set a root password now (or after first boot): 1. Set a root password (the flake uses --no-root-passwd; check that
users.users.root.password is set in your config OR run interactively):
nixos-enter --root /mnt -- /bin/sh nixos-enter --root /mnt -- /bin/sh
# passwd # passwd
# exit # exit
2. Unmount and reboot: 2. Unmount and reboot:
umount -R /mnt umount -R /mnt
reboot reboot
3. In the T490's UEFI boot menu (F12), pick the Linux entry. 3. On the T490, hit F12 at the Lenovo splash and pick the Linux entry.
systemd-boot menu will show "NixOS" and "Windows Boot Manager". systemd-boot will show "NixOS" and "Windows Boot Manager".
If you get stuck, drop into a shell from the NixOS installer ISO If something failed, you can re-run this script from the installer ISO.
and re-run this script — it's idempotent except for the partition It's idempotent for steps 1-3 (mount, hardware-config, nixos-install);
shrink (which has already been done). the only non-idempotent step is --shrink (which is a one-time operation).
EOF EOF