/home/cat/.bashrc
# nix 世代管理函数
[ -r /etc/nixos/scripts/rebuild.sh ] && source /etc/nixos/scripts/rebuild.sh
/etc/nixos/scripts/rebuild.sh
#!/usr/bin/env bash
# rebuild —— 一个命令管「列世代 / 切世代 / 建世代(带label) / 透传 nixos-rebuild」
# 由 ~/.bashrc source;随 /etc/nixos 一起迁移到下台机器(拷 scripts/ 即可)
#
# 与 boot-menu-names.nix 的关系:
# - 本函数的 <描述> 经 NIXOS_LABEL 写进 config.system.nixos.label(可在 rebuild list / boot.json 看到)
# - 启动菜单条目标题由 modules/boot-menu-names.nix 决定(explicit 表 + auto_label),本函数不参与
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 --<选项> 透传给 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/scripts/boot-menu-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+=("$gen $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
# --- 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 desc=$(printf '%s' "$arg" | tr ' ' '-')
if [ -z "$desc" ]; then
echo "rebuild: 描述不能为空" >&2
return 1
fi
# 强校验:按系统真实约束(/etc/nixos 里 label.nix 的 types.strMatching "[a-zA-Z0-9:_\.-]*")
# 另:启动菜单是点阵字体,非 ASCII 会画成方块 → 一并拒绝
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
local label="$(date '+%Y-%m-%d_%H-%M-%S')-$desc"
# 执行前记录当前最大世代 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
}
# 世代菜单名保持模块(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),见 ~/.hermes/notes/max2-egpu-780m-4060.md 第 10 节。
{ config, lib, pkgs, ... }:
let
cfg = config.local.bootMenuNames;
# ===== 人工标签表:gen = "<描述>"(纯 ASCII,可自行增删;日期时间由改名时按世代 mtime 现取)=====
explicit = {
"1" = "fresh-install";
"21" = "hermes-desktop";
"24" = "rime-ime";
"26" = "kernel-6.18.54";
"34" = "dual-gpu-ok";
"35" = "wifi-cfg-restored";
"36" = "boot-menu-names";
"37" = "config-change";
"38" = "config-change";
"39" = "amdgpu-mst";
"40" = "amdgpu-mst-bpc";
"41" = "amdgpu-mst-bpc";
"42" = "rebuild-script-auto-append-to-explicit";
};
mapFile = pkgs.writeText "boot-menu-labels"
(lib.concatStrings (lib.mapAttrsToList (n: l: "${n}|${l}\n") explicit));
# 用 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
}
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' "$stamp" "$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' "$(stamp_of "$n")" "$slug"
;;
*) printf "" ;;
esac
}
# 改一个世代条目(title + version 两行;断言不含非 ASCII)
apply_one() {
local n="$1" label="$2" f="$ENT/nixos-generation-$1.conf" k
[ -f "$f" ] || return 0
k=$(grep -o 'linux-[0-9][0-9.]*' "$f" | head -1 | cut -d- -f2-)
python3 - "$f" "$n" "$label" "$k" <<'PY'
import re, sys
p, n, label, kern = sys.argv[1:5]
stamp = " ".join(label.split()[:2]) if len(label.split()) > 1 and label.split()[1].count(':') == 2 else label.split(':')[0].strip()
s = open(p, encoding='utf-8').read()
s = re.sub(r'^title .*$', f'title {n} {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 表)
# 导出「世代 -> 菜单名」映射(world-readable,供 rebuild list 免 root 读取)
MAP=/etc/nixos/scripts/boot-menu-titles.tsv
mkdir -p /etc/nixos/scripts
: > "$MAP"
# ① 表内世代(表里只有 <描述>,日期时间现取)
while IFS='|' read -r n desc; do
[ -n "''${n:-}" ] || continue
[ -e "$PROF/system-$n-link" ] || continue # 世代谢已删除 → 跳过
label="$(stamp_of "$n") $desc"
apply_one "$n" "$label"
echo "$n|$label" >> "$MAP"
done < ${mapFile}
# ② 表外世代(自动起名)
for n in $(ls -d $PROF/system-*-link 2>/dev/null | sed 's#.*/system-##;s#-link##'); do
grep -q "^$n|" ${mapFile} && continue
lbl=$(custom_title "$n")
if [ -n "$lbl" ]; then CUSTOM="$CUSTOM $n"; fi
[ -n "$lbl" ] || lbl=$(auto_label "$n")
apply_one "$n" "$lbl"
echo "$n|$lbl" >> "$MAP"
done
chmod 644 "$MAP"
if [ -n "''${CUSTOM// /}" ]; then
MOD=/etc/nixos/modules/boot-menu-names.nix
if [ -f "$MOD" ]; then
python3 - "$MOD" "$MAP" "$CUSTOM" <<'PY'
import sys
mod, mapf, gens = sys.argv[1], sys.argv[2], sys.argv[3].split()
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().startswith('explicit = {'):
start = i
break
end = -1
if start >= 0:
for i in range(start + 1, len(lines)):
if lines[i].strip() == '};':
end = i
break
add = []
if start >= 0 and end > start:
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:
open(mod, 'w', encoding='utf-8').write("".join(lines[:end] + add + lines[end:]))
print('[boot-menu-names] explicit 表已自动登记 ' + str(len(add)) + ' 条')
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
{
options.local.bootMenuNames.enable =
lib.mkEnableOption "systemd-boot 世代菜单名保持(每次 rebuild 后自动重放命名,纯 ASCII)";
config = lib.mkIf cfg.enable {
# 也装进系统 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";
};
}