From 921fc1402a8159110589962ade4ab0ee8be26340 Mon Sep 17 00:00:00 2001 From: Hermes Date: Fri, 5 Jun 2026 19:53:56 -0700 Subject: [PATCH 1/3] T490 + Windows 11 dual-boot support - modules/laptop.nix: add systemd-boot extraEntries for Windows Boot Manager Chainloads $ESP/EFI/Microsoft/Boot/bootmgfw.efi. sort-key 'z_windows' puts it after NixOS generations in the menu. Explicit entry works on all systemd-boot versions; NixOS 26.05 'Yarara' may also autodetect on shared ESP, in which case the duplicate is harmless. - install.sh: guided dual-boot installer. Shrinks Win11 NTFS with ntfsresize (no data loss), creates NixOS ext4 root, mounts shared ESP, runs nixos-install with this flake. Idempotent except for the partition shrink. - MIGRATION.md: new T490 + Windows 11 dual-boot section, with manual fallback and NixOS 26.05 compatibility notes. --- MIGRATION.md | 58 ++++++++++++++ install.sh | 191 +++++++++++++++++++++++++++++++++++++++++++++ modules/laptop.nix | 22 +++++- 3 files changed, 270 insertions(+), 1 deletion(-) create mode 100755 install.sh diff --git a/MIGRATION.md b/MIGRATION.md index acf4f9b..4bcb2b3 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,63 @@ # Laptop Migration: MacBook → x86_64 NixOS +## T490 + Windows 11 dual-boot (recommended flow) + +The T490 already has Windows 11 installed. To add NixOS alongside it without +losing Windows: + +### 1. Boot the NixOS installer from a Ventoy stick +From the live installer environment: +```bash +# Network access is required; either wifi-menu or plug in ethernet +# Then run the guided installer — it handles partitioning: +curl -L https://os.scoble.me/forgejo/sscoble/laptop-config/raw/branch/main/install.sh | sudo bash +``` + +The script: +- Detects `/dev/nvme0n1` and the existing ESP +- Shrinks the Windows NTFS partition with `ntfsresize` (no data loss) +- Creates a NixOS ext4 root partition in the freed space +- Mounts the existing Windows ESP at `/mnt/boot` (shared, not reformatted) +- Runs `nixos-install` with this flake +- Reboots into a systemd-boot menu with both NixOS and Windows entries + +### 2. After install +First boot → systemd-boot menu shows: +- `NixOS (default)` — most recent generation +- Older NixOS generations (up to 50, configurable in `modules/laptop.nix`) +- `Windows Boot Manager` — chainloads the standard Microsoft EFI binary at + `$ESP/EFI/Microsoft/Boot/bootmgfw.efi` + +If you ever want to skip the menu and boot straight to Windows, hold F12 at +the Lenovo splash and pick "Windows Boot Manager" from the firmware menu. + +### 3. Fall back to manual partition if the script fails +The script handles the common case. For unusual layouts (multiple disks, LVM, +existing NixOS partitions, etc.), do it by hand: +```bash +# Identify partitions +lsblk -f +# Target the existing Windows ESP for /boot +mount /dev/nvme0n1p1 /mnt/boot # adjust to your partition +# Create NixOS root +mkfs.ext4 -L nixos /dev/nvme0n1pX +mount /dev/nvme0n1pX /mnt +nixos-generate-config --root /mnt +nixos-install --flake git+https://os.scoble.me/forgejo/sscoble/laptop-config.git#laptop --root /mnt +``` + +### 4. NixOS 26.05 ("Yarara") compatibility +The flake pins `nixos-unstable`, which as of June 2026 tracks NixOS 26.05+. +Key 26.05 changes that affect this config: +- `boot.initrd.systemd.enable` is now default true. Most things Just Work. +- `boot.supportedFilesystems` is now an attrset (not a list) — nix warns but + the existing config still parses. +- `systemd.extraConfig` is removed; use `systemd.settings.Manager` instead. + We don't use `systemd.extraConfig` anywhere, so this is a no-op for us. +- The new `boot.loader.systemd-boot.windows` first-class option exists for + multi-drive dual-boot, but we don't need it — sharing a single ESP means + our explicit `extraEntries` works on all systemd-boot versions. + ## Order of operations ### 1. Install NixOS diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..6d6070a --- /dev/null +++ b/install.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# install.sh — guided NixOS T490 dual-boot installer. +# +# 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 +# +# 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) +# +# Usage: +# curl -L https://os.scoble.me/forgejo/sscoble/laptop-config/raw/branch/main/install.sh | sudo bash +# +# Or, if you've cloned the repo: +# sudo ./install.sh +# +# 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) + +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}" + +# === Color output (no-op if not a TTY) === +if [[ -t 1 ]]; then + RED=$'\033[0;31m'; YEL=$'\033[0;33m'; GRN=$'\033[0;32m'; CYN=$'\033[0;36m'; NC=$'\033[0m' +else + RED=''; YEL=''; GRN=''; CYN=''; NC='' +fi +log() { echo "${CYN}>>>${NC} $*"; } +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)" + +# === Sanity: this is a UEFI machine === +[[ -d /sys/firmware/efi ]] || fail "system is not booted in UEFI mode — T490 should always be UEFI" + +# === 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" + +# === 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) + fi +done < <(lsblk -nlo NAME "$DISK" 2>/dev/null | tail -n +2 | sed "s|^|/dev/|") + +[[ -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)" + +# === Confirm with user === +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 - nixos_bytes). +win_size_bytes=$(lsblk -ndo SIZE -b "$WIN_PART") +new_win_bytes=$(( win_size_bytes - nixos_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 + +# === Find next available partition number === +existing=$(lsblk -nlo NAME "$DISK" 2>/dev/null | wc -l) +log "Existing partitions on $DISK: $existing" + +# Create NixOS root partition in the freed space +log "Creating NixOS root partition ($NIXOS_SIZE)..." +parted -s -a opt "$DISK" mkpart primary ext4 -- -"$NIXOS_SIZE" 100% || fail "parted mkpart failed" +partprobe "$DISK" +sleep 2 + +# Find the new partition +NIXOS_PART=$(lsblk -nlo NAME "$DISK" 2>/dev/null | sed "s|^|/dev/|" | sort | tail -1) +log "New partition: $NIXOS_PART" + +# === Format and mount === +log "Formatting $NIXOS_PART as ext4..." +mkfs.ext4 -F -L nixos "$NIXOS_PART" + +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)" + +# === Generate hardware config === +log "Generating hardware-configuration.nix..." +nixos-generate-config --root /mnt + +# === nixos-install with the flake === +log "Installing NixOS from flake: $FLAKE_URL#$HOST" +log "(This fetches and builds the world; first run is slow.)" +nixos-install --flake "${FLAKE_URL}#${HOST}" --root /mnt --no-root-passwd + +ok "NixOS installed." + +cat < Date: Sat, 6 Jun 2026 05:48:45 -0700 Subject: [PATCH 2/3] install.sh: fix swap creation and partition-range bugs Review of branch t490-dualboot (laptop-config#1) found three correctness issues in the new install.sh. This commit fixes them: 1. parted mkpart range used '-NIXOS_SIZE 100%' which on a T490 with a recovery partition at the end of the disk would overlap that recovery partition and fail. The new code computes the start of the freed space as the new end of the (now shrunken) Windows partition and uses explicit MiB offsets, so the new partition lands in the space ntfsresize actually freed. 2. SWAP_SIZE was advertised in the user-facing plan (lines 100-106 of the original) but no swap partition was ever created and swapon was never run. The new code creates a linux-swap partition after the NixOS root, runs mkswap, and swapon. The total shrink applied to the Windows partition is now nixos_bytes + swap_bytes. 3. The 'sort | tail -1' trick for finding the new partition breaks when more than one new partition is created (NixOS root + swap would both qualify) and is fragile for disks with 10+ existing partitions. The new code snapshots the partition list before mkpart and uses comm -13 to diff, then picks the lowest-numbered new partition for the NixOS root and the next one for swap. Reported-by: agent-engineering review of sscoble/laptop-config#1 --- install.sh | 104 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 86 insertions(+), 18 deletions(-) diff --git a/install.sh b/install.sh index 6d6070a..0336b72 100755 --- a/install.sh +++ b/install.sh @@ -118,17 +118,31 @@ read -rp "Type 'yes' to proceed: " confirm # === Shrink Windows === # Convert NIXOS_SIZE (e.g. "200G") to bytes for ntfsresize -case "$NIXOS_SIZE" in - *G|*g) nixos_bytes=$(( ${NIXOS_SIZE%[Gg]} * 1024 * 1024 * 1024 )) ;; - *M|*m) nixos_bytes=$(( ${NIXOS_SIZE%[Mm]} * 1024 * 1024 )) ;; - *) nixos_bytes="$NIXOS_SIZE" ;; -esac -log "Shrinking Windows partition by $NIXOS_SIZE ($nixos_bytes bytes) — this may take a few minutes..." +size_to_bytes() { + local s="$1" + case "$s" in + *G|*g) echo $(( ${s%[Gg]} * 1024 * 1024 * 1024 )) ;; + *M|*m) echo $(( ${s%[Mm]} * 1024 * 1024 )) ;; + *K|*k) echo $(( ${s%[Kk]} * 1024 )) ;; + *) 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 )) +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 - nixos_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 - nixos_bytes )) +new_win_bytes=$(( win_size_bytes - shrink_bytes )) ntfsresize -s --size "$new_win_bytes" "$WIN_PART" 2>&1 | tail -5 \ || fail "ntfsresize failed; aborting" @@ -137,24 +151,78 @@ ntfsresize -s --size "$new_win_bytes" "$WIN_PART" 2>&1 | tail -5 \ partprobe "$DISK" sleep 2 -# === Find next available partition number === -existing=$(lsblk -nlo NAME "$DISK" 2>/dev/null | wc -l) -log "Existing partitions on $DISK: $existing" +# 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) -# Create NixOS root partition in the freed space -log "Creating NixOS root partition ($NIXOS_SIZE)..." -parted -s -a opt "$DISK" mkpart primary ext4 -- -"$NIXOS_SIZE" 100% || fail "parted mkpart failed" +# === Create NixOS root + swap in the freed space === +# Compute the start = end of the (now smaller) Windows partition. +# We deliberately do NOT use `-NIXOS_SIZE 100%` because on a T490 the recovery +# partition sits at the end of the disk and that range would overlap it. +# We work in 1MiB units and let parted align. +win_new_bytes=$(lsblk -ndo SIZE -b "$WIN_PART") +win_end_mib=$(( (win_new_bytes + 1024*1024 - 1) / (1024*1024) )) + +# Extract the numeric prefix of NIXOS_SIZE for the parted range calculation. +nix_num=${NIXOS_SIZE%[GgMmKk]*}; nix_unit=${NIXOS_SIZE##*[0-9]} +case "$nix_unit" in + G|g) nix_size_mib=$(( nix_num * 1024 )) ;; + M|m) nix_size_mib=$nix_num ;; + *) fail "NIXOS_SIZE must be suffixed with M or G (got '$NIXOS_SIZE')" ;; +esac +nix_start_mib=$win_end_mib +nix_end_mib=$(( nix_start_mib + nix_size_mib )) +log "Creating NixOS root partition (${NIXOS_SIZE}) at ${nix_start_mib}MiB..${nix_end_mib}MiB..." +parted -s "$DISK" mkpart primary ext4 "${nix_start_mib}MiB" "${nix_end_mib}MiB" \ + || fail "parted mkpart (nixos) failed" partprobe "$DISK" -sleep 2 +sleep 1 -# Find the new partition -NIXOS_PART=$(lsblk -nlo NAME "$DISK" 2>/dev/null | sed "s|^|/dev/|" | sort | tail -1) -log "New partition: $NIXOS_PART" +if [[ "$SWAP_SIZE" != "0" ]]; then + swap_num=${SWAP_SIZE%[GgMmKk]*}; swap_unit=${SWAP_SIZE##*[0-9]} + case "$swap_unit" in + G|g) swap_size_mib=$(( swap_num * 1024 )) ;; + M|m) swap_size_mib=$swap_num ;; + *) fail "SWAP_SIZE must be suffixed with M or G (got '$SWAP_SIZE')" ;; + 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" + partprobe "$DISK" + sleep 1 +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 === log "Formatting $NIXOS_PART as ext4..." mkfs.ext4 -F -L nixos "$NIXOS_PART" +if [[ "$SWAP_SIZE" != "0" ]]; then + log "Setting up swap on $SWAP_PART..." + mkswap -L swap "$SWAP_PART" + swapon "$SWAP_PART" +fi + log "Mounting..." mount "$NIXOS_PART" /mnt mkdir -p /mnt/boot From ea46ee2c93843d862d89485170d007369b8dc371 Mon Sep 17 00:00:00 2001 From: Scott Scoble Date: Sat, 6 Jun 2026 07:20:24 -0700 Subject: [PATCH 3/3] install.sh: explicit --disk/--efi/--nixos CLI args, no auto-detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- install.sh | 504 +++++++++++++++++++++++++++++++++++------------------ 1 file changed, 331 insertions(+), 173 deletions(-) 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 <