From 9bdd8bd9d5927fe79d249d93b01926eaec95a8d7 Mon Sep 17 00:00:00 2001 From: Alex Maldonado Date: Tue, 12 May 2026 10:26:56 -0400 Subject: [PATCH] feat: Automate new NixOS installations --- justfile | 104 ++++++++++ scripts/make-usb.sh | 405 +++++++++++++++++++++++++++++++++++++++ scripts/register-host.py | 325 +++++++++++++++++++++++++++++++ 3 files changed, 834 insertions(+) create mode 100644 scripts/make-usb.sh create mode 100644 scripts/register-host.py diff --git a/justfile b/justfile index 5f47852..9fabf75 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,12 @@ +set positional-arguments + host := `hostname` key_services := "github gradescope" +# Default: list available recipes. +default: + @just --list + # Rebuild the system and switch immediately rebuild: sudo nixos-rebuild switch --flake .#{{host}} @@ -33,3 +39,101 @@ gen-keys: ssh-keygen -t ed25519 -f "$file" -N "" -C "$service@$(hostname -s)" done +# Build a bootable NixOS installer USB (writes ISO + optional extras partition). +# Pass arguments through to scripts/make-usb.sh, e.g.: +# just make-usb --iso ~/Downloads/nixos.iso +# Re-executes itself under sudo if not already root. Quoting is preserved +# via `set positional-arguments` + "$@". +make-usb *args: + #!/usr/bin/env bash + set -euo pipefail + if [[ ! -x scripts/make-usb.sh ]]; then + chmod +x scripts/make-usb.sh + fi + if (( EUID == 0 )); then + exec ./scripts/make-usb.sh "$@" + else + exec sudo --preserve-env=PATH ./scripts/make-usb.sh "$@" + fi + +# Register a new host: scaffold hosts// and add a flake.nix entry. +# Example: just register-host blackbear +register-host name: + @python3 scripts/register-host.py --repo-root . "{{name}}" + +# Capture the current machine's hardware config into hosts//hardware.nix. +# Run this on the *new* machine. Two contexts work: +# 1. Already-installed NixOS: just runs nixos-generate-config against /. +# 2. Booted from installer with target mounted at /mnt: detected +# automatically and --root /mnt is used. +# Example: just capture-hardware blackbear +capture-hardware name: + #!/usr/bin/env bash + set -euo pipefail + + name="{{name}}" + target="hosts/$name/hardware.nix" + + if [[ ! -d "hosts/$name" ]]; then + echo "[-] hosts/$name does not exist. Run \`just register-host $name\` first." >&2 + exit 1 + fi + + if ! command -v nixos-generate-config >/dev/null 2>&1; then + echo "[-] nixos-generate-config not found." >&2 + echo " Run this command on a NixOS machine (or inside the installer)." >&2 + exit 1 + fi + + # Detect whether we're in the installer with a target mounted at /mnt. + extra_args=() + if mountpoint -q /mnt 2>/dev/null && [[ -d /mnt/etc ]]; then + echo "[+] Detected mounted target at /mnt — using --root /mnt" + extra_args+=(--root /mnt) + elif mountpoint -q /mnt 2>/dev/null; then + echo "[!] /mnt is mounted but doesn't look like a NixOS target (no /mnt/etc)." + echo " Continuing as if on a running system. Pass --no-filesystems by editing" + echo " hardware.nix afterwards if disks aren't permanent." + fi + + # Confirm before overwriting a non-placeholder hardware.nix. + if [[ -f "$target" ]] && ! grep -q 'PLACEHOLDER hardware.nix' "$target"; then + echo "[!] $target already exists and doesn't look like a placeholder." >&2 + read -r -p "Overwrite? (yes/[no]) " confirm + [[ "$confirm" == "yes" ]] || { echo "aborted."; exit 1; } + fi + + echo "[+] Generating hardware config..." + # The redirect runs in the user's shell BEFORE sudo, so the file is + # owned by you, not root. sudo is needed because nixos-generate-config + # reads /proc, /sys, and may probe mounted filesystems. + sudo nixos-generate-config --show-hardware-config "${extra_args[@]}" > "$target" + echo "[+] Wrote $target" + if command -v git >/dev/null && git -C . rev-parse >/dev/null 2>&1; then + echo "[+] git diff:" + git --no-pager diff -- "$target" || true + fi + +# Show which hosts are currently registered in flake.nix (parses the file directly). +hosts: + #!/usr/bin/env python3 + import re, sys + src = open('flake.nix').read() + m = re.search(r'nixosConfigurations\s*=\s*\{', src) + if not m: + sys.exit(0) + # Brace-match to find the closing }. + depth, i, close = 0, m.end() - 1, None + while i < len(src): + c = src[i] + if c == '{': depth += 1 + elif c == '}': + depth -= 1 + if depth == 0: + close = i + break + i += 1 + block = src[m.end():close] if close else '' + for name in re.findall(r'^[ \t]*([A-Za-z_][\w-]*)\s*=\s*nixpkgs\.lib\.nixosSystem', block, re.M): + print(name) + diff --git a/scripts/make-usb.sh b/scripts/make-usb.sh new file mode 100644 index 0000000..2a90c02 --- /dev/null +++ b/scripts/make-usb.sh @@ -0,0 +1,405 @@ +#!/usr/bin/env bash +# make-usb.sh +# +# Write a NixOS installer ISO to a USB stick (raw dd) +# and optionally append a writable "extras" partition with files +# (this repo, SSH keys, etc.) that you want available during install. + +set -euo pipefail + + + +### Styling ### + +if [[ -t 1 ]]; then + GREEN=$'\e[32m'; YELLOW=$'\e[33m'; RED=$'\e[31m'; BLUE=$'\e[34m' + BOLD=$'\e[1m'; RESET=$'\e[0m' +else + GREEN=""; YELLOW=""; RED=""; BLUE=""; BOLD=""; RESET="" +fi +log() { printf '%s[+]%s %s\n' "$GREEN" "$RESET" "$*"; } +warn() { printf '%s[!]%s %s\n' "$YELLOW" "$RESET" "$*" >&2; } +err() { printf '%s[-]%s %s\n' "$RED" "$RESET" "$*" >&2; } +info() { printf '%s[i]%s %s\n' "$BLUE" "$RESET" "$*"; } +die() { err "$1"; exit "${2:-1}"; } + +ISO_PATH="" +DEVICE="" +SKIP_EXTRAS=0 +ALLOW_INTERNAL=0 +ASSUME_YES=0 +REPO_ROOT="" + +while (($#)); do + case "$1" in + --iso) ISO_PATH="${2:?missing path}"; shift 2 ;; + --device|--dev) DEVICE="${2:?missing path}"; shift 2 ;; + --no-extras) SKIP_EXTRAS=1; shift ;; + --allow-internal) ALLOW_INTERNAL=1; shift ;; + --yes|-y) ASSUME_YES=1; shift ;; + --repo-root) REPO_ROOT="${2:?missing path}"; shift 2 ;; + -h|--help) + sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) die "unknown arg: $1" 2 ;; + esac +done + + + +### Preflight checks ### + +(( EUID == 0 )) || die "run as root: sudo $0 $*" 1 + +need_tty=0 +[[ -z "$DEVICE" || -z "$ISO_PATH" ]] && need_tty=1 +(( ASSUME_YES )) || need_tty=1 +if (( need_tty )) && ! [[ -t 0 ]]; then + die "stdin is not a tty; pass --iso, --device, and --yes for non-interactive use" 2 +fi + +# Required commands. We try to be helpful about missing ones on NixOS. +REQUIRED=(lsblk dd sync wipefs sgdisk partprobe blockdev mount umount mountpoint mkfs.exfat awk) +missing=() +for cmd in "${REQUIRED[@]}"; do + command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd") +done +if ((${#missing[@]})); then + err "missing tools: ${missing[*]}" + cat >&2 <<'EOF' + +On NixOS / with Nix installed, re-run inside a shell that has them: + nix shell nixpkgs#util-linux nixpkgs#coreutils nixpkgs#gptfdisk \ + nixpkgs#exfatprogs nixpkgs#parted -c sudo ./scripts/make-usb.sh + +On Debian/Ubuntu: + sudo apt install util-linux coreutils gdisk exfatprogs parted +EOF + exit 3 +fi + +# Locate repo root if not provided (used as a copy candidate for extras). +if [[ -z "$REPO_ROOT" ]]; then + if REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null)"; then + : + else + REPO_ROOT="" + fi +fi + + + +### Cleanup trap ### + +TMP_MNT="" +cleanup() { + set +e + if [[ -n "$TMP_MNT" && -d "$TMP_MNT" ]]; then + mountpoint -q "$TMP_MNT" && umount "$TMP_MNT" + rmdir "$TMP_MNT" 2>/dev/null + fi +} +trap cleanup EXIT INT TERM + + + +### Helpers ### + +human() { numfmt --to=iec --suffix=B "$1" 2>/dev/null || echo "$1"; } + +is_removable() { + # /sys/block//removable: 1 = removable (USB stick, SD via USB reader), + # 0 = fixed (internal disk, many "USB" enclosures lie though). + local name; name="$(basename "$1")" + [[ "$(cat "/sys/block/$name/removable" 2>/dev/null || echo 0)" == "1" ]] +} + +contains_root_or_boot() { + # Refuse if any partition on this disk holds /, /boot, or active swap. + local dev="$1" + lsblk -lnpo MOUNTPOINT,NAME "$dev" | awk -v d="$dev" ' + $1=="/" || $1=="/boot" || $1=="[SWAP]" { found=1 } + END { exit !found } + ' +} + +select_device() { + log "Available whole disks:" + printf ' %-3s %-14s %-8s %-6s %-16s %s\n' "Idx" "Device" "Size" "Bus" "Removable" "Model" + + # Use lsblk's pairs mode for robust parsing (handles spaces in MODEL). + # lsblk -P emits lines of KEY="value" KEY="value" ... we sanity-check + # the line matches that strict shape before eval'ing it. + local -a CANDS=() + local line NAME SIZE MODEL TRAN RM TYPE + while IFS= read -r line; do + [[ "$line" =~ ^([A-Z]+=\"[^\"]*\"[[:space:]]*)+$ ]] || continue + NAME=""; SIZE=""; MODEL=""; TRAN=""; RM=""; TYPE="" + eval "$line" + [[ "${TYPE:-}" == "disk" ]] || continue + # Skip optical, loop, ram, zram, dm. + case "$NAME" in + /dev/sr*|/dev/loop*|/dev/ram*|/dev/zram*|/dev/dm-*) continue ;; + esac + CANDS+=("$NAME"$'\t'"${SIZE:-?}"$'\t'"${TRAN:-?}"$'\t'"${RM:-0}"$'\t'"${MODEL:-?}") + done < <(lsblk -dpP -o NAME,SIZE,MODEL,TRAN,RM,TYPE) + + ((${#CANDS[@]})) || die "no candidate disks found" 4 + + local i=1 + for c in "${CANDS[@]}"; do + IFS=$'\t' read -r n s t r m <<<"$c" + local marker="" + if [[ "$r" == "1" ]]; then marker="${GREEN}yes${RESET}"; else marker="${YELLOW}no${RESET}"; fi + if contains_root_or_boot "$n"; then + marker="${RED}SYSTEM DISK${RESET}" + fi + printf ' %-3s %-14s %-8s %-6s %-16b %s\n' "$i" "$n" "$s" "$t" "$marker" "$m" + ((i++)) + done + echo + echo "Enter 0 to abort." + echo + + local idx + while :; do + read -r -p "Select device [1-${#CANDS[@]}]: " idx + [[ "$idx" =~ ^[0-9]+$ ]] || { warn "not a number"; continue; } + (( idx == 0 )) && die "aborted by user" 1 + (( idx >= 1 && idx <= ${#CANDS[@]} )) || { warn "out of range"; continue; } + IFS=$'\t' read -r n _ _ r _ <<<"${CANDS[idx-1]}" + if contains_root_or_boot "$n"; then + err "$n is the running system disk — refusing"; continue + fi + if [[ "$r" != "1" ]] && (( ! ALLOW_INTERNAL )); then + err "$n is not removable; pass --allow-internal if you really mean it" + continue + fi + DEVICE="$n" + return 0 + done +} + + + +### Pick device ### + +if [[ -z "$DEVICE" ]]; then + select_device +else + [[ -b "$DEVICE" ]] || die "$DEVICE is not a block device" 2 + # Reject partitions. We want whole disks only. + if [[ "$(lsblk -dno TYPE "$DEVICE" 2>/dev/null)" != "disk" ]]; then + die "$DEVICE is not a whole disk (partition or other type)" 4 + fi + if contains_root_or_boot "$DEVICE"; then + die "$DEVICE holds /, /boot, or active swap — refusing" 4 + fi + if ! is_removable "$DEVICE" && (( ! ALLOW_INTERNAL )); then + die "$DEVICE is not removable; pass --allow-internal to override" 4 + fi +fi + +DEV_BYTES="$(blockdev --getsize64 "$DEVICE")" +log "Target: $DEVICE ($(human "$DEV_BYTES"))" + + + +### Pick ISO ### + +if [[ -z "$ISO_PATH" ]]; then + while :; do + read -r -e -p "Path to NixOS ISO: " ISO_PATH + ISO_PATH="${ISO_PATH/#\~/$HOME}" + [[ -f "$ISO_PATH" ]] && break + warn "not a file: $ISO_PATH" + done +fi +[[ -f "$ISO_PATH" ]] || die "ISO not found: $ISO_PATH" 2 + +ISO_BYTES="$(stat -c '%s' "$ISO_PATH")" +log "ISO: $ISO_PATH ($(human "$ISO_BYTES"))" + +# Sanity: file should look like an ISO 9660 image. +if command -v file >/dev/null 2>&1; then + if ! file -b "$ISO_PATH" | grep -qiE 'iso 9660|udf'; then + warn "$(file -b "$ISO_PATH")" + warn "file(1) doesn't recognise this as ISO 9660. Continuing anyway." + fi +fi + +# Sanity: ISO must fit on disk with room for an extras partition. +MIN_EXTRAS_MB=64 +if (( ISO_BYTES + MIN_EXTRAS_MB*1024*1024 > DEV_BYTES )); then + if (( ISO_BYTES > DEV_BYTES )); then + die "ISO is larger than the disk" 4 + fi + warn "Less than ${MIN_EXTRAS_MB}MiB free after ISO; extras partition will be skipped." + SKIP_EXTRAS=1 +fi + +# Optional sha256 sidecar verification. +if [[ -f "$ISO_PATH.sha256" ]]; then + log "Verifying $ISO_PATH.sha256..." + ( cd "$(dirname "$ISO_PATH")" && sha256sum -c "$(basename "$ISO_PATH").sha256" ) \ + || die "sha256 verification failed" 5 +fi + + + +### Final confirmation ### + +cat </dev/null || umount -l "$part" 2>/dev/null || warn "could not umount $part" +done < <(lsblk -lnpo NAME,MOUNTPOINT "$DEVICE" | awk '$2!=""') + +### Wipe & write ### +log "Wiping signatures..." +wipefs -a "$DEVICE" >/dev/null + +log "Writing ISO with dd (this can take a few minutes)..." +dd if="$ISO_PATH" of="$DEVICE" bs=4M status=progress conv=fsync oflag=direct \ + || die "dd failed" 5 +sync +log "ISO written." + +partprobe "$DEVICE" 2>/dev/null || true +sleep 1 + + + +### Extras partition ### + +if (( SKIP_EXTRAS )); then + log "Skipping extras partition." +else + log "Preparing extras partition in trailing free space..." + + # The NixOS ISO uses an isohybrid GPT. The backup GPT header still sits at + # the *original ISO end*, not the actual disk end. `sgdisk -e` relocates it + # so we can add a partition in the freed trailing region. + # `sgdisk -n` with 0:0:0 creates a new partition starting at the first free + # sector and ending at the last free sector (i.e., filling the rest). + sgdisk -e "$DEVICE" >/dev/null + sgdisk -n 0:0:0 -t 0:0700 -c 0:"EXTRAS" "$DEVICE" >/dev/null + partprobe "$DEVICE" 2>/dev/null || true + sleep 1 + + # Find the new partition (highest-numbered one on the disk). + EXTRAS_PART="$(lsblk -lnpo NAME,TYPE "$DEVICE" \ + | awk '$2=="part"{print $1}' | tail -n1)" + [[ -b "$EXTRAS_PART" ]] || die "extras partition didn't appear" 5 + log "Extras partition: $EXTRAS_PART" + + log "Formatting $EXTRAS_PART as exFAT..." + mkfs.exfat -L EXTRAS "$EXTRAS_PART" >/dev/null 2>&1 \ + || die "mkfs.exfat failed" 5 + + TMP_MNT="$(mktemp -d)" + mount "$EXTRAS_PART" "$TMP_MNT" + + echo + log "What to copy onto the extras partition? (space-separated numbers; Enter for none)" + echo + echo " 1) This repo ${REPO_ROOT:-}" + echo " 2) Your public SSH keys ~/.ssh/*.pub" + echo " 3) An arbitrary file/dir (you'll be prompted for the path)" + echo " 4) A custom message/README (you type it inline)" + echo + + read -r -p "Selection: " sel || sel="" + for tok in $sel; do + case "$tok" in + 1) + if [[ -z "$REPO_ROOT" ]]; then + warn "no repo detected; pass --repo-root or run from a git checkout" + continue + fi + log "Copying repo -> /repo (git tracked files only)..." + # Use `git archive` if possible, it respects .gitignore and skips .git. + if git -C "$REPO_ROOT" rev-parse >/dev/null 2>&1; then + mkdir -p "$TMP_MNT/repo" + git -C "$REPO_ROOT" archive --format=tar HEAD \ + | tar -x -C "$TMP_MNT/repo" + else + mkdir -p "$TMP_MNT/repo" + cp -aT "$REPO_ROOT" "$TMP_MNT/repo" + fi + ;; + 2) + # Run as the invoking user, not root, so ~ resolves correctly. + user_home="$(getent passwd "${SUDO_USER:-$USER}" | cut -d: -f6)" + if compgen -G "$user_home/.ssh/*.pub" >/dev/null; then + mkdir -p "$TMP_MNT/ssh-keys" + cp "$user_home"/.ssh/*.pub "$TMP_MNT/ssh-keys/" + log "Copied $(ls "$user_home"/.ssh/*.pub | wc -l) public key(s)." + else + warn "no .pub keys found in $user_home/.ssh" + fi + ;; + 3) + read -r -e -p "Path to copy: " extra_path + extra_path="${extra_path/#\~/$HOME}" + if [[ -e "$extra_path" ]]; then + cp -a "$extra_path" "$TMP_MNT/" + log "Copied $extra_path" + else + warn "not found: $extra_path" + fi + ;; + 4) + info "Type your note. End with a line containing only EOF:" + : >"$TMP_MNT/NOTE.txt" + while IFS= read -r line; do + [[ "$line" == "EOF" ]] && break + printf '%s\n' "$line" >>"$TMP_MNT/NOTE.txt" + done + ;; + *) warn "unknown option: $tok" ;; + esac + done + + sync + umount "$TMP_MNT" + rmdir "$TMP_MNT" + TMP_MNT="" +fi + + + +### Done ### + +log "Final sync..." +sync +blockdev --flushbufs "$DEVICE" 2>/dev/null || true + +echo +log "${BOLD}Done.${RESET} You can unplug $DEVICE now." +echo +info "Boot the new machine from this USB. Once at the installer shell:" +info " - If you copied the repo, it's on the EXTRAS partition (mountable as exFAT)." +info " - To install: partition the target disk, mount at /mnt, then" +info " nixos-install --flake /mnt/extras/repo#" +echo diff --git a/scripts/register-host.py b/scripts/register-host.py new file mode 100644 index 0000000..a02ace9 --- /dev/null +++ b/scripts/register-host.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 + +""" +Scaffold a new NixOS host and add it to flake.nix. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path +from textwrap import dedent + + +### Color helpers ### + +def _supports_color() -> bool: + return sys.stderr.isatty() + + +_C = { + "green": "\033[32m" if _supports_color() else "", + "yellow": "\033[33m" if _supports_color() else "", + "red": "\033[31m" if _supports_color() else "", + "bold": "\033[1m" if _supports_color() else "", + "reset": "\033[0m" if _supports_color() else "", +} + + +def log(msg: str) -> None: + print(f"{_C['green']}[+]{_C['reset']} {msg}", file=sys.stderr) + + +def warn(msg: str) -> None: + print(f"{_C['yellow']}[!]{_C['reset']} {msg}", file=sys.stderr) + + +def die(msg: str, code: int = 1) -> None: + print(f"{_C['red']}[-]{_C['reset']} {msg}", file=sys.stderr) + sys.exit(code) + + + +### Validation ### + +# RFC 1123 hostname: 1-63 chars, [a-z0-9-], no leading/trailing dash. +HOST_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") + + +def valid_hostname(name: str) -> bool: + return bool(HOST_RE.match(name)) + + + +### Templates ### + +DEFAULT_NIX_TEMPLATE = dedent("""\ + {{ pkgs, ... }}: {{ + imports = [ + ./hardware.nix + ../../modules/core.nix + ../../modules/plasma.nix + ../../modules/security.nix + ]; + + networking.hostName = "{name}"; + + boot.loader.systemd-boot.enable = true; + boot.loader.efi.canTouchEfiVariables = true; + boot.kernelPackages = pkgs.linuxPackages_latest; + + networking.networkmanager.enable = true; + + programs.zsh.enable = true; + + users.users.alex = {{ + isNormalUser = true; + shell = pkgs.zsh; + extraGroups = [ "wheel" "networkmanager" ]; + }}; + + system.stateVersion = "25.11"; + }} +""") + + +PLACEHOLDER_HARDWARE = dedent("""\ + # PLACEHOLDER hardware.nix for host "{name}". + # + # Replace this file with the output of `nixos-generate-config --show-hardware-config` + # run on the target machine. The justfile has a helper for this: + # + # just capture-hardware {name} + # + # Until then, this file intentionally does nothing useful and the host + # will not build. + {{ lib, ... }}: {{ + # nixpkgs hostPlatform — change if not x86_64-linux. + nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; + + # Intentionally empty. See note above. + }} +""") + + + +### Helpers ### + +def add_to_flake(flake_path: Path, name: str, *, template_host: str | None = None) -> None: + """ + Insert a new ` = nixpkgs.lib.nixosSystem { ... };` entry inside + the `nixosConfigurations = { ... };` block of flake.nix. + + Strategy: locate the literal `nixosConfigurations = {` (allowing inline + or next-line `{`), find the matching closing `}` by brace counting, + then insert the new entry just before it. If `template_host` is set, + clone that host's block; otherwise emit a sensible default. + """ + src = flake_path.read_text() + + # Find `nixosConfigurations = {`; be flexible about whitespace. + m = re.search(r"nixosConfigurations\s*=\s*\{", src) + if not m: + die(f"could not find `nixosConfigurations = {{` in {flake_path}") + + open_pos = m.end() - 1 # index of the '{' + + # Brace-match to find the closing '}' of this attrset. + depth = 0 + close_pos = None + in_string = False + i = open_pos + while i < len(src): + c = src[i] + # Cheap string skipping. flake.nix shouldn't have weird escapes inside + # nixosConfigurations, but be polite about double-quoted strings. + if c == '"' and (i == 0 or src[i - 1] != "\\"): + in_string = not in_string + elif not in_string: + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + close_pos = i + break + i += 1 + + if close_pos is None: + die("could not find the closing `}` of nixosConfigurations") + + block = src[open_pos + 1 : close_pos] + + # Already present? Bail. + if re.search(rf"(^|\W){re.escape(name)}\s*=\s*nixpkgs\.lib\.nixosSystem", block): + die(f"host `{name}` already exists in {flake_path}") + + # If we can clone an existing entry, do it (preserves user customizations). + new_entry: str + cloned = False + if template_host: + # Find ` = nixpkgs.lib.nixosSystem { ... };` inside block. + tmpl_re = re.compile( + rf"(^[ \t]*){re.escape(template_host)}\s*=\s*nixpkgs\.lib\.nixosSystem\s*\{{", + re.MULTILINE, + ) + tm = tmpl_re.search(block) + if tm: + tmpl_indent = tm.group(1) + tmpl_start = tm.start() + # Brace-match the template entry to find its terminating `};`. + d = 0 + j = tm.end() - 1 # index of opening '{' + in_str = False + entry_end = None + while j < len(block): + cc = block[j] + if cc == '"' and (j == 0 or block[j - 1] != "\\"): + in_str = not in_str + elif not in_str: + if cc == "{": + d += 1 + elif cc == "}": + d -= 1 + if d == 0: + # Expect a trailing ';' after the closing '}'. + semi = block.find(";", j) + if semi != -1 and block[j + 1 : semi].strip() == "": + entry_end = semi + 1 + else: + entry_end = j + 1 + break + j += 1 + if entry_end is None: + die(f"could not parse template host `{template_host}` in flake.nix") + + entry_text = block[tmpl_start:entry_end] + # Rename the binding and rewrite ./hosts/ paths. + new_entry_body = re.sub( + rf"^([ \t]*){re.escape(template_host)}(\s*=\s*nixpkgs\.lib\.nixosSystem)", + rf"\1{name}\2", + entry_text, + count=1, + flags=re.MULTILINE, + ) + new_entry_body = new_entry_body.replace( + f"./hosts/{template_host}", f"./hosts/{name}" + ) + new_entry = "\n" + new_entry_body + cloned = True + + if not cloned: + # Best-effort default block. Indent two extra spaces if we can guess + # the indentation by looking at existing content; otherwise 6 spaces. + indent_match = re.search(r"^([ \t]+)\S", block, re.MULTILINE) + indent = indent_match.group(1) if indent_match else " " + new_entry = ( + f"\n{indent}{name} = nixpkgs.lib.nixosSystem {{\n" + f"{indent} inherit system;\n" + f"{indent} specialArgs = {{ inherit inputs; }};\n" + f"{indent} modules = [\n" + f"{indent} ./hosts/{name}\n" + f"{indent} home-manager.nixosModules.home-manager\n" + f"{indent} {{\n" + f"{indent} home-manager = {{\n" + f"{indent} useGlobalPkgs = true;\n" + f"{indent} useUserPackages = true;\n" + f"{indent} users.alex = import ./home/alex;\n" + f"{indent} extraSpecialArgs = {{ inherit inputs; }};\n" + f"{indent} }};\n" + f"{indent} }}\n" + f"{indent} ];\n" + f"{indent}}};\n" + ) + + # Insert new_entry just before the line containing the closing brace, + # so we never produce `};};` on a single line. + line_start = src.rfind("\n", 0, close_pos) + if line_start == -1: + line_start = 0 + if not new_entry.endswith("\n"): + new_entry += "\n" + new_src = src[: line_start + 1] + new_entry + src[line_start + 1 :] + + # Write atomically. + tmp = flake_path.with_suffix(flake_path.suffix + ".tmp") + tmp.write_text(new_src) + tmp.replace(flake_path) + + +def host_in_flake(flake_path: Path, name: str) -> bool: + """Cheap textual check; does flake.nix already define this host?""" + src = flake_path.read_text() + return bool( + re.search(rf"(^|\W){re.escape(name)}\s*=\s*nixpkgs\.lib\.nixosSystem", src) + ) + + +def find_template_host(flake_path: Path) -> str | None: + """Return the name of the first existing nixosConfigurations entry, if any.""" + src = flake_path.read_text() + m = re.search(r"nixosConfigurations\s*=\s*\{", src) + if not m: + return None + tail = src[m.end():] + m2 = re.search(r"^[ \t]*([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*nixpkgs\.lib\.nixosSystem", + tail, re.MULTILINE) + return m2.group(1) if m2 else None + + +def main() -> int: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("name", help="hostname for the new host (lowercase, RFC 1123)") + p.add_argument("--repo-root", default=".", help="path to the repo root (default: cwd)") + p.add_argument("--dry-run", action="store_true", help="describe actions without writing") + args = p.parse_args() + + if not valid_hostname(args.name): + die(f"invalid hostname: {args.name!r} (must match {HOST_RE.pattern})") + + repo = Path(args.repo_root).resolve() + flake = repo / "flake.nix" + hosts_dir = repo / "hosts" / args.name + + if not flake.is_file(): + die(f"flake.nix not found at {flake}") + if hosts_dir.exists(): + die(f"hosts/{args.name} already exists") + if host_in_flake(flake, args.name): + die(f"host `{args.name}` already defined in {flake} — refusing to touch anything") + + template_host = find_template_host(flake) + log(f"repo: {repo}") + log(f"new host: {args.name}") + log(f"template host: {template_host or ''}") + + if args.dry_run: + warn("dry-run: nothing written") + return 0 + + # Scaffold filesystem. + hosts_dir.mkdir(parents=True) + (hosts_dir / "default.nix").write_text(DEFAULT_NIX_TEMPLATE.format(name=args.name)) + (hosts_dir / "hardware.nix").write_text(PLACEHOLDER_HARDWARE.format(name=args.name)) + log(f"created hosts/{args.name}/default.nix") + log(f"created hosts/{args.name}/hardware.nix (placeholder)") + + # Edit flake.nix. + add_to_flake(flake, args.name, template_host=template_host) + log(f"added `{args.name}` entry to flake.nix") + + print() + log(f"{_C['bold']}Next steps:{_C['reset']}") + print(f" 1. On the new machine (booted from installer or already running):") + print(f" just capture-hardware {args.name}") + print(f" This overwrites hosts/{args.name}/hardware.nix with the real config.") + print(f" 2. Review hosts/{args.name}/default.nix — tweak modules, users, etc.") + print(f" 3. git add hosts/{args.name} flake.nix && git commit") + print(f" 4. Build: just rebuild (when running ON the new host)") + return 0 + + +if __name__ == "__main__": + sys.exit(main())