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..0f4a351 --- /dev/null +++ b/install.sh @@ -0,0 +1,417 @@ +#!/usr/bin/env bash +# 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. +# +# 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 +# +# 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. +# +# 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 +# +# # Use a partition you already prepared (no shrink, no new partition) +# install.sh --disk /dev/nvme0n1 \ +# --efi /dev/nvme0n1p1 \ +# --nixos /dev/nvme0n1p5 +# +# # 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 === +BOOTLOADER="systemd-boot" +HOST="laptop" +FLAKE_URL="" +NO_FETCH=0 +DRY_RUN=0 +YES=0 + +# === 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 + 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} $*"; } + +# === 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" + +# === 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" + +# === 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 + +# === 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" + +# === Validate --efi is actually an ESP === +ESP_GUID="c12a7328-f81f-11d2-ba4b-00a0c93ec93b" +ESP_FSTYPE=$(lsblk -ndo FSTYPE "$ESP_PART" 2>/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 + +# === Validate --nixos isn't mounted as the running / or /boot === +if findmnt -n -o TARGET --source "$NIXOS_PART" 2>/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 + +# === 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 + *G|*g) echo $(( ${s%[Gg]} * 1024 * 1024 * 1024 )) ;; + *M|*m) echo $(( ${s%[Mm]} * 1024 * 1024 )) ;; + *K|*k) echo $(( ${s%[Kk]} * 1024 )) ;; + *) echo "$s" ;; + esac +} + +# === 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) + 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 +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 + +# === Format NixOS root === +log "Formatting $NIXOS_PART as ext4..." +mkfs.ext4 -F -L nixos "$NIXOS_PART" + +# === 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: $NIXOS_PART → /mnt, $ESP_PART → /mnt/boot" + +# === Generate hardware config === +log "Generating hardware-configuration.nix..." +nixos-generate-config --root /mnt + +# === nixos-install === +log "Installing NixOS from flake: $FLAKE_URL#$HOST" +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." + +cat <