rebuild:构建和世代记录管理

常用命令

nixos-rebuild build --dry-run     # 求值层,不碰系统
nixos-rebuild test                # 切进运行时,不写引导,重启即回
systemd-analyze verify <unit>     # unit 语法
nixos-rebuild build-vm            # 关键服务真起一台 VM 验

rebuild list           列出所有世代及其标签
rebuild <序号>          切换到指定世代 (1-3 位数字)
rebuild <描述>          创建新世代(描述字符集: A-Z a-z 0-9 . _ - : ,空格自动转 -)

/etc/nixos/modules/system/boot-menu-names.nix

    # 交互式 shell 里启用 rebuild 函数(nix 世代管理);import 本模块即生效
    programs.bash.interactiveShellInit = ''
      [ -r ${./boot-menu-rebuild.sh} ] && source ${./boot-menu-rebuild.sh}
    '';

/etc/nixos/modules/system/boot-menu-rebuild.sh

#!/usr/bin/env bash
# rebuild —— 一个命令管「列世代 / 切世代 / 建世代(带label) / 同步命名池 / 透传 nixos-rebuild」
# 由 ~/.bashrc source;随 /etc/nixos 一起迁移到下台机器(拷 modules/ 即可)
#
# 与 boot-menu-names.nix 的关系:
#   - 本函数的 <描述> 经 NIXOS_LABEL 写进 config.system.nixos.label(可在 rebuild list / boot.json 看到)
#   - 启动菜单条目标题由 modules/system/boot-menu-names.nix 决定(explicit 表 + auto_label),本函数不参与
_rebuild_label() {
  # 构造标准 label:时间戳前缀 + 描述;并做字符集校验(rebuild 与 nixos-rebuild-guard 共用,单一规则)
  local desc; desc=$(printf '%s' "$1" | tr ' ' '-')
  if [ -z "$desc" ]; then echo "rebuild: 描述不能为空" >&2; return 1; fi
  if printf '%s' "$desc" | LC_ALL=C grep -q '[^ -~]'; then
    echo "rebuild: 描述含非 ASCII 字符(启动菜单是点阵字体,画不出中文/全角字符)" >&2; return 1
  fi
  if printf '%s' "$desc" | LC_ALL=C grep -q '[^A-Za-z0-9._:-]'; then
    echo "rebuild: 描述含系统不允许的字符(label 允许集: A-Z a-z 0-9 . _ - : ;空格会自动转成 -)" >&2
    printf '%s' "$desc" | LC_ALL=C grep -o '[^A-Za-z0-9._:-]' | sort -u | tr '\n' ' ' | sed 's/^/         不允许的字符: /' >&2; echo >&2
    echo "         你的输入: $desc" >&2
    return 1
  fi
  printf '%s-%s' "$(date '+%Y-%m-%d_%H-%M-%S')" "$desc"
}

rebuild() {
  # --- 帮助信息 ---
  _rebuild_help() {
    cat <<'EOF'
rebuild 使用说明

支持:
  rebuild list            列出所有世代及其标签
  rebuild <序号>          切换到指定世代 (1-3 位数字)
  rebuild <描述>          创建新世代(描述字符集: A-Z a-z 0-9 . _ - : ,空格自动转 -)
                          启动菜单标题 = {序号} {日期时间} {描述},如 42 2026-10-08 15:12:03 amdgpu-mst-bpc
  rebuild sync            只保留命名池里「未注释」的世代,其余全部删除
                          (删世代记录 = nix-env;同步 boot 菜单 = switch-to-configuration boot)
  rebuild --<选项>        透传给 nixos-rebuild switch,例如 --upgrade

暂不支持:
  无参数调用
  多个参数调用
EOF
  }

  # --- 无参数 → 提示 ---
  if [ "$#" -eq 0 ]; then
    echo "暂不支持无参数调用。"
    _rebuild_help
    return 1
  fi

  # --- 多参数 → 提示 ---
  if [ "$#" -gt 1 ]; then
    echo "暂不支持多个参数调用。"
    _rebuild_help
    return 1
  fi

  # --- 到这里只有一个参数 ---
  local arg="$1"

  # --- list:列出世代 / 日期 / 标签(启动菜单名) / 内核 ---
  if [ "$arg" = "list" ]; then
    local -a G=() D=() L=() K=()
    local gen date kern label maxlab=0 maxgen=0 gen_link
    for gen_link in $(ls -d /nix/var/nix/profiles/system-*-link 2>/dev/null | sort -V); do
      gen=$(basename "$gen_link" | sed 's/system-\([0-9]*\)-link/\1/')
      date=$(stat -c %y "$gen_link" | cut -d. -f1)
      # 标签源①:启动菜单名(boot-menu-names 模块导出的映射,= 开机时你看到的 title)
      label=$(sed -n "s/^$gen|//p" /etc/nixos/modules/system/boot-menu-pools/boot-menu-names-titles.tsv 2>/dev/null | head -1)
      # 源②:闭包内只读 boot.json 的 bootspec.label(免 root、免 jq)
      [ -n "$label" ] || label=$(sed -n 's/.*"label"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$gen_link/boot.json" 2>/dev/null | head -1)
      [ -n "$label" ] || label=$("$gen_link/sw/bin/nixos-version" 2>/dev/null || echo "-")
      # 内核版本:世代的 kernel 软链指向 ...-linux-<版本>/bzImage
      kern=$(readlink -f "$gen_link/kernel" 2>/dev/null | sed -n 's#.*linux-\([0-9][0-9.]*\)/bzImage#\1#p')
      G+=("$gen"); D+=("$date"); L+=("$label"); K+=("${kern:--}")
      (( ${#L[-1]} > maxlab )) && maxlab=${#L[-1]}
      (( ${#gen} > maxgen )) && maxgen=${#gen}
    done
    printf '%*s  %-19s  %-*s  %s\n' "$maxgen" "ID" "Date" "$maxlab" "Label" "Kernel"
    local i
    for (( i=0; i<${#G[@]}; i++ )); do
      printf '%*s  %-19s  %-*s  %s\n' "$maxgen" "${G[$i]}" "${D[$i]}" "$maxlab" "${L[$i]}" "${K[$i]}"
    done
    return
  fi

  # --- sync:只保留命名池里「未注释」的世代,其余全部删除 ---
  # 删除用系统命令完成:nix-env 管世代记录(profile 一致性),
  # switch-to-configuration boot 重跑引导器安装(builder 的 remove_old_entries 清掉
  # 陈旧 boot 菜单条目 + 孤儿内核,随后 boot-menu-names 重放命名)—— 不新增世代。
  if [ "$arg" = "sync" ]; then
    # 本机命名池:路径按 /boot 的 ESP UUID 派生(与 boot-menu-names.nix 的 machineKey 同源)
    local uuid pool
    uuid=$(findmnt -no UUID /boot 2>/dev/null)
    pool="/etc/nixos/modules/system/boot-menu-pools/${uuid}.nix"
    if [ -z "$uuid" ] || [ ! -f "$pool" ]; then
      echo "rebuild sync: 找不到本机命名池(ESP UUID='$uuid' → $pool)" >&2
      return 1
    fi
    # 保留集 = 池里「未注释」的世代号(打开注释即纳入,注释掉即排除)
    local -a keep=()
    local k
    while IFS= read -r k; do [ -n "$k" ] && keep+=("$k"); done < <(
      grep -oE '^[[:space:]]*"[0-9]+"' "$pool" | tr -dc '0-9\n' | sort -n)
    if [ "${#keep[@]}" -eq 0 ]; then
      echo "rebuild sync: 池里没有未注释的世代(保留集为空)→ 拒绝执行(否则会删光全部世代)" >&2
      return 1
    fi
    # 待删 = 当前存在的世代 − 保留集
    local -a all=() del=()
    local g iskeep
    while IFS= read -r g; do [ -n "$g" ] && all+=("$g"); done < <(
      ls -d /nix/var/nix/profiles/system-*-link 2>/dev/null | sed 's#.*system-##;s#-link##' | sort -n)
    for g in "${all[@]}"; do
      iskeep=0
      for k in "${keep[@]}"; do [ "$g" = "$k" ] && { iskeep=1; break; }; done
      [ "$iskeep" -eq 0 ] && del+=("$g")
    done
    echo "rebuild sync: 保留(池) = ${keep[*]}"
    if [ "${#del[@]}" -eq 0 ]; then
      echo "rebuild sync: 无待删世代,已同步。"
      return 0
    fi
    echo "rebuild sync: 待删 ${#del[@]} 个世代 = ${del[*]}"
    # ① 删世代记录(系统命令;不会删当前世代,profile 一致性由 nix-env 负责)
    sudo /run/current-system/sw/bin/nix-env -p /nix/var/nix/profiles/system --delete-generations "${del[@]}" || return
    # ② 重跑引导器安装(系统命令)→ 同步 boot 菜单(清陈旧条目/孤儿内核 + 重放命名)
    sudo /run/current-system/bin/switch-to-configuration boot
    return
  fi

  # --- 1-3 位数字 → 切换世代 ---
  if [[ "$arg" =~ ^[0-9]{1,3}$ ]]; then
    sudo nix-env -p /nix/var/nix/profiles/system --switch-generation "$arg" || return
    sudo /nix/var/nix/profiles/system/bin/switch-to-configuration switch
    return
  fi

  # --- 以 - 开头 → 透传给 nixos-rebuild switch ---
  if [[ "$arg" == -* ]]; then
    sudo nixos-rebuild switch "$arg"
    return
  fi

  # --- 其他 → 创建带「时间戳+描述」label 的新世代 ---
  # label 字符集被 label.nix 限制为 [a-zA-Z0-9:_.-],不能有空格 → 用下划线/短横线排版;
  # 启动菜单标题(含序号)由 boot-menu-names 钩子按这个 label 反解生成
  local label
  label=$(_rebuild_label "$arg") || return 1
  # 执行前记录当前最大世代 id(钩子用它做冗余校验,精确定位本次产生的世代)
  local prev_max=$(ls -d /nix/var/nix/profiles/system-*-link 2>/dev/null | sed 's#.*system-##;s#-link##' | sort -n | tail -1)
  sudo env NIXOS_LABEL="$label" NIXOS_BOOT_PREV_MAX="${prev_max:-0}" nixos-rebuild switch
}

/etc/nixos/modules/system/boot-menu-names.nix

# 契约(2026-10-11):import 即生效 —— 在 configuration.nix 的清单里打开/注释本行即可,
#                    参数写在本文件顶部「参数」区(原 local.*.enable 开关已取消)。
# 世代菜单名保持模块(boot-menu-names)
#
# 作用:nixos-rebuild 时 systemd-boot-builder 会按闭包里的只读 label **重写所有** /boot/loader/entries/*.conf
#      (内建行为,无法关闭)→ 本模块用 boot.loader.systemd-boot.extraInstallCommands 在 builder 之后重放命名。
# 名字为什么必须纯 ASCII:systemd-boot 用内置 8x16 点阵字体(只有拉丁字形),二进制里没有任何字体加载路径
#      (strings 只有 "Unknown glyph"),NixOS 的 systemd-boot 模块也没有 font 选项 → 中文只会显示成方块。
#      真要在菜单里显示中文需换 GRUB(boot.loader.grub.font = 自制 pf2),见 /etc/nixos/modules/hardware/nvidia-4060-egpu-display.md 第 10 节。
# 改名的正确姿势(2026-10-10 定):编辑 modules/system/boot-menu-pools/<本机ESP-UUID>.nix,
#   然后跑一次 `sudo boot-menu-names` 即生效 —— **不需要 nixos-rebuild**,也不会多出世代。
#   (脚本在运行时用 nix-instantiate 读池文件;此前那套“烘进世代的映射”已删除,
#     因为命名是运维动作,不是“用户构建世代”的行为,不该记账。)
#
# 设计:一份机制 + 每机一份命名池
#   modules/system/boot-menu-names.nix            机制(共享;不含任何机器数据、不写共享文件)
#   modules/system/boot-menu-pools/<ESP-UUID>.nix 命名池(每机一份;只由本机自动改写,绝不交叉)
# 为什么池必须分机:池的键是世代号,而克隆机从新序列开始 ⇒ 同一个号在两台机器上含义不同,
#   共享会导致「盘2 的新 gen5 继承盘1 老 gen5 的名字」,且两台各自追加会在同一文件里撞出重复键。
{ config, lib, pkgs, ... }:
let
  # ── 参数(本模块自管;import 即生效,不引入即失效)──────────────────
  # 原选项 local.bootMenuNames.* 已取消,值就写在这里
  numberFill = "0";
  # ===== 机器身份 = 本机 ESP 的 UUID =====
  # 取自机器专属的 hardware-configuration.nix(fileSystems."/boot".device),克隆后必然不同
  # ⇒ 天然把主机与各僚机的命名池分开。备选为何不行:hostname 克隆后相同(两台都叫 max2-nixos);
  # /etc/machine-id 是普通文件,dd 克隆会连它一起复制 ⇒ 不唯一。
  machineKey = builtins.baseNameOf config.fileSystems."/boot".device;

  # 本机命名池(一份机制 + 每机一份池;池文件只由本机自动改写,另一台机器不碰)
  # 池文件的键是世代号 —— 而克隆机从新序列开始,世代号含义与源机完全不同,
  # 所以池绝不能跨机共享。新增机器 = 新建 <ESP-UUID>.nix。
  poolFile = ./boot-menu-pools + "/${machineKey}.nix";

  explicit =
    if builtins.pathExists poolFile then import poolFile
    else lib.warn ''
      boot-menu-names: 本机 ESP UUID = ${machineKey} 还没有命名池
      请新建 modules/system/boot-menu-pools/${machineKey}.nix(照抄同目录其它池文件的格式)。
      本次 rebuild 不套用任何手工命名(这是缺池的正常语义,不是错误)。
    '' { };

  # 用 writeShellScriptBin(writeShellScript 产出单文件,无法进 buildEnv;
  # writeShellApplication 会引入 shellcheck 构建依赖,故不用)
  rename = pkgs.writeShellScriptBin "boot-menu-names" ''
    export PATH=${lib.makeBinPath (with pkgs; [ coreutils gnugrep gnused python3 nix ])}
      # 本次 rebuild 是否由 rebuild <描述> 发起(空 = 普通 rebuild)
      LABEL_IN='${lib.maybeEnv "NIXOS_LABEL" ""}'
      PREV_MAX='${lib.maybeEnv "NIXOS_BOOT_PREV_MAX" ""}'   # 执行前的最大世代 id(冗余校验)
    set -euo pipefail
      ENT=/boot/loader/entries
      PROF=/nix/var/nix/profiles
      if [ ! -d "$ENT" ]; then
        echo "boot-menu-names: $ENT 不存在(非 systemd-boot 引导?),跳过" >&2
        exit 0
      fi

      # 表外世代自动起名:时间取世代链接 mtime,说明从包级 diff 关键字映射(词表全 ASCII)
      # 世代链接 mtime → "YYYY-MM-DD HH:MM:SS"(表内/表外统一用它)
      stamp_of() {
        stat -c %y "$PROF/system-$1-link" | cut -c1-19
      }

      # 显示用短时间戳:2026-10-12 08:37:49 -> 10-12 08:37(纯规则 ✓ 所有标签统一用它 ✓)
      short_stamp() {
        stamp_of "$1" | cut -c6-16
      }

      auto_label() {
        local n="$1" link="$PROF/system-$n-link" prev="" diff="" tag="" stamp=""
        [ -e "$link" ] || { printf '?'; return; }
        # 2026-10-07 22:00 -> 20261007-2200(保留分钟)
        stamp=$(stamp_of "$n")
        prev="$PROF/system-$((n-1))-link"
        diff=""
        if [ -e "$prev" ]; then
          if ! diff=$(nix store diff-closures "$prev" "$link" 2>&1); then
            echo "boot-menu-names: gen$n 的 diff-closures 失败,说明列用 config-change" >&2
            diff=""
          fi
        fi
        while IFS='|' read -r kw name; do
          [ -n "$kw" ] || continue
          if printf '%s' "$diff" | grep -qiE "$kw"; then tag="$name"; break; fi
        done <<'KW'
linux-[0-9]|kernel-switch
nvidia|nvidia-driver
xf86-video-amdgpu|igpu-ddx
bluez|bluetooth|bluetooth
fprintd|libfprint|fingerprint
plasma|kde|desktop
iwlwifi|firmware|wifi-firmware
docker|docker
fcitx5|ime
gcc|binutils|toolchain
boot-menu|boot-menu-names
git|git
KW
        if [ -z "$tag" ] && [ -n "$diff" ]; then
          tag=$(printf '%s' "$diff" | head -1 | cut -d: -f1 | tr -d ' ')
        fi
        [ -n "$tag" ] || tag="config-change"
        printf '%s %s' "$(short_stamp "$n")" "$tag"
      }

      # 自定义标题:rebuild <描述> 会把 label 写成 <YYYY-MM-DD>_<HH-MM-SS>-<描述>
      #   boot.json 的 label 形如 "NixOS <codeName> <label> (Linux <ver>)"(见 nixpkgs bootspec.nix:29)
      #   序号只有这里(build 之后、root 上下文)才知道 → 标题格式: <序号> <日期> <时:分:秒> <描述>
      custom_title() {
        local n="$1" link="$PROF/system-$n-link" raw="" stamp="" rest="" hh="" mm="" ss="" slug=""
        [ -e "$link/boot.json" ] || { printf ""; return; }
        raw=$(grep -o '"label"[[:space:]]*:[[:space:]]*"[^"]*"' "$link/boot.json" | head -1 | cut -d'"' -f4)
        raw=$(printf '%s' "$raw" | sed 's/^NixOS //; s/ (Linux .*)$//')
        raw=$(printf '%s' "$raw" | sed 's/.* //')
        case "$raw" in
          [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]_[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*)
            stamp=$(printf '%s' "$raw" | cut -d_ -f1)
            rest=$(printf '%s' "$raw" | cut -d_ -f2-)
            hh=$(printf '%s' "$rest" | cut -d- -f1)
            mm=$(printf '%s' "$rest" | cut -d- -f2)
            ss=$(printf '%s' "$rest" | cut -d- -f3)
            slug=$(printf '%s' "$rest" | cut -d- -f4-)
            printf '%s %s' "$(short_stamp "$n")" "$slug"
            ;;
          *) printf "" ;;
        esac
      }

      # 改一个世代条目(title + version 两行;断言不含非 ASCII)
      apply_one() {
        local n="$1" label="$2" f="$ENT/nixos-generation-$1.conf" k nw="''${NW:-1}"
        [ -f "$f" ] || return 0
        k=$(grep -o 'linux-[0-9][0-9.]*' "$f" | head -1 | cut -d- -f2-)
        python3 - "$f" "$n" "$label" "$k" "''${NW:-1}" "${numberFill}" <<'PY'
import re, sys
p, n, label, kern, nw, fill = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], int(sys.argv[5]), sys.argv[6]
stamp = " ".join(label.split()[:2])
s = open(p, encoding='utf-8').read()
s = re.sub(r'^title .*$',   f'title {label}', s, count=1, flags=re.M)
s = re.sub(r'^version .*$', f'version NixOS Generation {n} - {stamp} - Linux {kern}', s, count=1, flags=re.M)
bad = sorted({c for c in s if ord(c) > 127})
if bad:
    raise SystemExit(f'!! {p} 含非 ASCII 字符 {bad}(systemd-boot 会画成方块)')
open(p, 'w', encoding='utf-8').write(s)
print(f'[boot-menu-names] gen{n} -> {n} {label}')
PY
      }

      CUSTOM=""    # 由 rebuild <描述> 产生的世代(稍后登记回 explicit 表)
      # 标题等长化:先收集 (世代, 标签),全部收集完再按最长项动态定宽(宽度不写死)
      declare -a GENS=() LABELS=()

      # 导出「世代 -> 菜单名」映射(world-readable,供 rebuild list 免 root 读取)
      MAP=/etc/nixos/modules/system/boot-menu-pools/boot-menu-names-titles.tsv
      mkdir -p /etc/nixos/modules/system/boot-menu-pools
      : > "$MAP"

      # 池文件(本机专属,路径按 ESP UUID 派生)
      POOL=/etc/nixos/modules/system/boot-menu-pools/${machineKey}.nix
      echo "boot-menu-names: 池 = $POOL(烘焙时常量条目数 ${toString (builtins.length (builtins.attrNames explicit))};以运行时文件为准)"

      # ① 表内世代:**运行时**读池文件,而不是读烘进世代的映射
      #    理由(2026-10-10 定):命名是**运维动作**,不是“用户构建世代”的行为,
      #    不该为了改一个名字而记账出一个新世代。所以改池后只要跑一次
      #      sudo boot-menu-names
      #    就生效,零新世代。(mapFile 那套烘进世代的映射已删除。)
      #    池语法错会让 nix-instantiate 直接报错停下 —— 不静默兜底。
      POOLJSON=$(mktemp); POOLTSV=$(mktemp)
      trap 'rm -f "$POOLJSON" "$POOLTSV"' EXIT
      if [ -f "$POOL" ]; then
        nix-instantiate --eval --strict --raw --expr "builtins.toJSON (import $POOL)" > "$POOLJSON"
        python3 -c 'import json,sys;d=json.load(open(sys.argv[1]));print("\n".join(f"{k}|{v}" for k,v in sorted(d.items(),key=lambda x:int(x[0]))))' "$POOLJSON" > "$POOLTSV"
      else
        : > "$POOLTSV"
      fi
      while IFS='|' read -r n desc; do
        [ -n "''${n:-}" ] || continue
        [ -e "$PROF/system-$n-link" ] || continue    # 世代谢已删除 → 跳过
        label="$(short_stamp "$n") $desc"
        GENS+=("$n"); LABELS+=("$label")
        echo "$n|$label" >> "$MAP"
      done < "$POOLTSV"

      # ② 表外世代(自动起名)
      for n in $(ls -d $PROF/system-*-link 2>/dev/null | sed 's#.*/system-##;s#-link##'); do
        grep -q "^$n|" "$POOLTSV" && continue
        lbl=$(custom_title "$n")
        if [ -n "$lbl" ]; then CUSTOM="$CUSTOM $n"; fi
        [ -n "$lbl" ] || lbl=$(auto_label "$n")
        GENS+=("$n"); LABELS+=("$lbl")
        echo "$n|$lbl" >> "$MAP"
      done
      chmod 644 "$MAP"

      # ③ 只给「世代号」定宽:NW = 最长世代号宽度,用显式填充字符补齐(默认 0 → 01/57)
      #    不补标签尾部 —— 2026-10-10 实测:尾部空格虽写进条目文件,它右边没有任何列,
      #    视觉上恒等于零(纯死代码,已删)。见本文件头部 numberFill 的说明。
      NW=1
      for i in "''${!GENS[@]}"; do
        [ "''${#GENS[$i]}" -gt "$NW" ] && NW="''${#GENS[$i]}"
      done
      for i in "''${!GENS[@]}"; do
        apply_one "''${GENS[$i]}" "''${LABELS[$i]}"
      done
      echo "boot-menu-names: 世代号定宽 NW=$NW(填充字符 '${numberFill}')"

      if [ -n "''${CUSTOM// /}" ]; then
        if [ -f "$POOL" ]; then
          python3 - "$POOL" "$MAP" "$CUSTOM" "''${GENS[*]}" <<'PY'
import sys, re
mod, mapf, gens = sys.argv[1], sys.argv[2], sys.argv[3].split()
existing = set(sys.argv[4].split()) if len(sys.argv) > 4 else set()
have = {}
for line in open(mapf, encoding='utf-8'):
    if '|' in line:
        g, l = line.rstrip().split('|', 1)
        have[g] = l
lines = open(mod, encoding='utf-8').read().splitlines(keepends=True)
start = -1
for i, l in enumerate(lines):
    if l.strip() == '{' and start < 0:
        start = i
        break
end = -1
if start >= 0:
    for i in range(start + 1, len(lines)):
        if lines[i].strip() == '}':
            end = i
            break
add = []
stale = 0
if start >= 0 and end > start:
    # 先把「世代已不存在」的条目就地注释掉(保留原文,供追溯;绝不删除)
    body = lines[start + 1:end]
    newbody = []
    for l in body:
        m = re.match(r'^(\s*)"(\d+)"\s*=\s*"([^"]*)"\s*;\s*$', l)
        if m and existing and m.group(2) not in existing:
            newbody.append(m.group(1) + '# "' + m.group(2) + '" = "' + m.group(3)
                           + '";  # 世代已不存在(自动注释,留作追溯)' + chr(10))
            stale += 1
        else:
            newbody.append(l)
    lines = lines[:start + 1] + newbody + lines[end:]
    end = start + 1 + len(newbody)          # end 因注释重排而失效,按新长度重算
    known = set()
    for l in lines[start + 1:end]:
        if '=' in l and '"' in l:
            known.add(l.split('"')[1])
    for n in gens:
        if n in known:
            continue
        lab = have.get(n, "")
        parts = lab.split()
        desc = ' '.join(parts[2:]) if len(parts) > 2 else lab
        if not desc:
            continue
        pad = ' ' if len(n) >= 2 else '  '
        add.append('  "' + n + '"' + pad + '= "' + desc + '";' + chr(10))
if add or stale:
    open(mod, 'w', encoding='utf-8').write("".join(lines[:end] + add + lines[end:]))
    print('[boot-menu-names] explicit 表:新登记 ' + str(len(add)) + ' 条,'
          + '失效注释 ' + str(stale) + ' 条(注释保留供追溯)')
else:
    print('[boot-menu-names] explicit 表无需变更')
PY
        fi
      fi

      # 精确定位「本次 rebuild 产生的世代」:execution stamp 匹配 + id > 执行前最大 id(冗余校验)
      if [ -n "$LABEL_IN" ]; then
        STAMP_IN=$(printf '%s' "$LABEL_IN" | cut -c1-19)
        NEWGEN=""
        for n in $(ls -d $PROF/system-*-link 2>/dev/null | sed 's#.*/system-##;s#-link##'); do
          if [ -n "$PREV_MAX" ] && [ "$n" -le "$PREV_MAX" ]; then continue; fi
          raw=$(grep -o '"label"[[:space:]]*:[[:space:]]*"[^"]*"' "$PROF/system-$n-link/boot.json" 2>/dev/null | head -1 | cut -d'"' -f4)
          case "$raw" in *"$STAMP_IN"*) NEWGEN="$n" ;; esac
        done
        if [ -z "$NEWGEN" ]; then
          echo "boot-menu-names: 本次未产生新世代(配置无变化),描述 [$LABEL_IN] 未登记到任何世代" >&2
        else
          echo "boot-menu-names: 本次新世代 = gen$NEWGEN(mtime $(stamp_of "$NEWGEN"))"
        fi
      fi

      echo "boot-menu-names: 完成(共 $(ls $ENT/*.conf 2>/dev/null | wc -l) 个条目),映射 -> $MAP"
  '';
in
{

  config = {
    # 也装进系统 PATH,便于手动重放/排查(boot-menu-names)
    environment.systemPackages = [ rename ];
    # 在 systemd-boot builder 装完条目之后立刻重放命名(root 上下文,/boot 就是真实 ESP)
    boot.loader.systemd-boot.extraInstallCommands = lib.mkAfter "${rename}/bin/boot-menu-names";
  };
}