All checks were successful
Check / eval (push) Successful in 3m18s
Treat the picker-only (none) row as an empty XKB variant at the shared input boundary and again in the template patcher. Guard the real downstream output and console keymap, and carry the sentinel through the offline installer VM. Verified: V0 full flake evaluation plus shell/Python/Nix/diff checks; V1 installer-keyboard, installer-safety, and template-SoT builds; V2 full KVM offline LUKS+swap install and themed first boot. No V3 required.
272 lines
9.5 KiB
Python
272 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Patch a copied templates/downstream machine flake with install-time values.
|
|
|
|
The template is the single source of truth for commented opt-ins and the
|
|
starter app suite. The installer copies it, then this script only:
|
|
|
|
* replaces known placeholders (hostname, username, locale, keyboard, …)
|
|
* fills the __NOMARCHY_INSTALLER__ region with detected/active config
|
|
* sets hardwareProfile on flake.nix
|
|
|
|
Usage:
|
|
patch-template.py <flake-dir> # reads a JSON object from stdin
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
BEGIN = " # __NOMARCHY_INSTALLER_BEGIN__"
|
|
END = " # __NOMARCHY_INSTALLER_END__"
|
|
|
|
|
|
def nix_str(s: str) -> str:
|
|
"""Escape a string for a Nix double-quoted literal."""
|
|
return (
|
|
s.replace("\\", "\\\\")
|
|
.replace('"', '\\"')
|
|
.replace("${", "\\${")
|
|
.replace("\n", "\\n")
|
|
)
|
|
|
|
|
|
def keyboard_variant(v: dict) -> str:
|
|
"""Return the XKB value, never the picker's display-only sentinel."""
|
|
variant = v.get("keyboardVariant") or ""
|
|
return "" if variant == "(none)" else variant
|
|
|
|
|
|
def replace_once(text: str, old: str, new: str, label: str) -> str:
|
|
if old not in text:
|
|
sys.exit(f"patch-template: missing placeholder for {label}: {old!r}")
|
|
return text.replace(old, new, 1)
|
|
|
|
|
|
def patch_flake(text: str, v: dict) -> str:
|
|
text = replace_once(
|
|
text,
|
|
'description = "My Nomarchy machine";',
|
|
f'description = "{nix_str(v["hostname"])} — my Nomarchy machine";',
|
|
"flake description",
|
|
)
|
|
text = replace_once(
|
|
text,
|
|
'username = "me"; # <- your login name',
|
|
f'username = "{nix_str(v["username"])}"; # <- your login name',
|
|
"flake username",
|
|
)
|
|
profiles = v.get("hardwareProfiles") or []
|
|
if profiles:
|
|
items = " ".join(f'"{nix_str(p)}"' for p in profiles)
|
|
hw_line = f" hardwareProfile = [ {items} ];"
|
|
else:
|
|
hw_line = " # hardwareProfile = null; # no nixos-hardware profiles selected"
|
|
# Replace the optional hardwareProfile comment block with the install choice.
|
|
text, n = re.subn(
|
|
r"\n # Optional: a nixos-hardware module name for your machine, e\.g\.\n"
|
|
r" # hardwareProfile = \"framework-13-7040-amd\";\n"
|
|
r" # Names: https://github.com/NixOS/nixos-hardware\n"
|
|
r" # \(the future installer fills this in automatically from DMI data\)\n",
|
|
f"\n{hw_line}\n"
|
|
f" # Names: https://github.com/NixOS/nixos-hardware\n",
|
|
text,
|
|
count=1,
|
|
)
|
|
if n != 1:
|
|
sys.exit("patch-template: could not patch hardwareProfile block in flake.nix")
|
|
return text
|
|
|
|
|
|
def patch_home(text: str, v: dict) -> str:
|
|
layout = nix_str(v["keyboardLayout"])
|
|
variant = nix_str(keyboard_variant(v))
|
|
text = replace_once(
|
|
text,
|
|
' nomarchy.keyboard.layout = "us";',
|
|
f' nomarchy.keyboard.layout = "{layout}";',
|
|
"home keyboard layout",
|
|
)
|
|
text = replace_once(
|
|
text,
|
|
' nomarchy.keyboard.variant = "";',
|
|
f' nomarchy.keyboard.variant = "{variant}";',
|
|
"home keyboard variant",
|
|
)
|
|
return text
|
|
|
|
|
|
def build_installer_region(v: dict) -> str:
|
|
lines: list[str] = [
|
|
BEGIN,
|
|
" # Written by nomarchy-install from live detection. Safe defaults are",
|
|
" # active; heavier opt-ins stay in the commented catalog below.",
|
|
]
|
|
|
|
if v.get("autoLogin"):
|
|
user = nix_str(v["username"])
|
|
lines += [
|
|
" # LUKS passphrase already gates this machine — skip the greeter password.",
|
|
f' nomarchy.system.greeter.autoLogin = "{user}";',
|
|
]
|
|
|
|
if v.get("laptop"):
|
|
lines += [
|
|
" # Laptop power (PPD + menu/Waybar). Uncomment to cap charge at 80%.",
|
|
" nomarchy.system.power.laptop = true;",
|
|
" # nomarchy.system.power.batteryChargeLimit = 80;",
|
|
]
|
|
if v.get("thermald"):
|
|
lines.append(
|
|
" nomarchy.system.power.thermal.enable = true; # thermald (Intel)"
|
|
)
|
|
|
|
hw = v.get("hardware") or {}
|
|
if any(
|
|
hw.get(k)
|
|
for k in ("intel", "amd", "fingerprint", "cameraIr", "npu", "nvidia")
|
|
):
|
|
lines.append(" # Hardware enablement (auto-detected).")
|
|
if hw.get("intel"):
|
|
lines.append(
|
|
" nomarchy.hardware.intel.enable = true; # GuC/HuC (i915)"
|
|
)
|
|
if hw.get("intelGucOff"):
|
|
lines.append(
|
|
" nomarchy.hardware.intel.guc = false; # xe driver → GuC default-on"
|
|
)
|
|
lines.append(
|
|
" # nomarchy.hardware.intel.computeRuntime = true; # OpenCL/oneVPL (opt-in)"
|
|
)
|
|
if hw.get("amd"):
|
|
lines += [
|
|
" nomarchy.hardware.amd.enable = true; # amd-pstate + VA-API",
|
|
" # nomarchy.hardware.amd.rocm.enable = true; # ROCm (multi-GB, opt-in)",
|
|
' # nomarchy.hardware.amd.rocm.gfxOverride = ""; # e.g. "11.0.0" for unlisted iGPU',
|
|
]
|
|
if hw.get("fingerprint"):
|
|
lines += [
|
|
" nomarchy.hardware.fingerprint.enable = true; # fprintd (enroll: fprintd-enroll)",
|
|
" # nomarchy.hardware.fingerprint.pam = true; # login + sudo (opt-in)",
|
|
]
|
|
if hw.get("cameraIr"):
|
|
lines.append(
|
|
" nomarchy.hardware.camera.hideIrSensor = true; # dual-sensor: hide IR node"
|
|
)
|
|
if hw.get("npu"):
|
|
vendor = nix_str(hw["npu"])
|
|
lines += [
|
|
f" # nomarchy.hardware.npu.enable = true; # {vendor} NPU (experimental; userspace BYO)",
|
|
" # nomarchy.hardware.latestKernel = true; # if the NPU driver needs a newer kernel",
|
|
]
|
|
# NVIDIA: profile is in flake.nix (common-gpu-nvidia). Hybrid/PRIME
|
|
# knobs are plain NixOS — comment-only guidance, same pattern as ROCm.
|
|
if hw.get("nvidia"):
|
|
lines += [
|
|
" # NVIDIA: common-gpu-nvidia is in hardwareProfile (flake.nix).",
|
|
" # Hybrid/PRIME, power, open-module — plain NixOS; see docs/HARDWARE.md §6",
|
|
" # and https://wiki.nixos.org/wiki/Nvidia (bus IDs are machine-specific).",
|
|
" # hardware.nvidia.prime = { ... }; # offload/sync",
|
|
" # hardware.nvidia.powerManagement.enable = true; # suspend/resume",
|
|
" # hardware.nvidia.open = false; # true = open module (newer cards)",
|
|
]
|
|
|
|
if v.get("resumeOffset") is not None:
|
|
root_uuid = nix_str(v["rootUuid"])
|
|
offset = v["resumeOffset"]
|
|
lines += [
|
|
" # Swapfile (hibernation-ready: resume points into it).",
|
|
' swapDevices = [{ device = "/swap/swapfile"; }];',
|
|
f' boot.resumeDevice = "/dev/disk/by-uuid/{root_uuid}";',
|
|
f' boot.kernelParams = [ "resume_offset={offset}" ];',
|
|
]
|
|
|
|
# Always on for installer layout (BTRFS + @snapshots).
|
|
lines += [
|
|
" # Hourly/daily BTRFS timeline snapshots + nixos-rebuild-snap.",
|
|
" nomarchy.system.snapper.enable = true;",
|
|
END,
|
|
]
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def patch_system(text: str, v: dict) -> str:
|
|
text = replace_once(
|
|
text,
|
|
' networking.hostName = "my-nomarchy";',
|
|
f' networking.hostName = "{nix_str(v["hostname"])}";',
|
|
"hostName",
|
|
)
|
|
text = replace_once(
|
|
text,
|
|
' time.timeZone = "UTC";',
|
|
f' time.timeZone = "{nix_str(v["timezone"])}";',
|
|
"timeZone",
|
|
)
|
|
text = replace_once(
|
|
text,
|
|
' i18n.defaultLocale = "en_US.UTF-8";',
|
|
f' i18n.defaultLocale = "{nix_str(v["locale"])}";',
|
|
"locale",
|
|
)
|
|
text = replace_once(
|
|
text,
|
|
' services.xserver.xkb.layout = "us";',
|
|
f' services.xserver.xkb.layout = "{nix_str(v["keyboardLayout"])}";',
|
|
"xkb layout",
|
|
)
|
|
text = replace_once(
|
|
text,
|
|
' services.xserver.xkb.variant = "";',
|
|
f' services.xserver.xkb.variant = "{nix_str(keyboard_variant(v))}";',
|
|
"xkb variant",
|
|
)
|
|
|
|
# Inject password into the user attrset (template has no password for flake-init).
|
|
user_block = """ users.users.${username} = {
|
|
isNormalUser = true;
|
|
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
|
|
};"""
|
|
# HASHED_PASSWORD is sha-512 crypt; alphabet is safe in Nix double quotes.
|
|
hashed = nix_str(v["hashedPassword"])
|
|
user_patched = f""" users.users.${{username}} = {{
|
|
isNormalUser = true;
|
|
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
|
|
initialHashedPassword = "{hashed}";
|
|
}};"""
|
|
text = replace_once(text, user_block, user_patched, "user password")
|
|
|
|
if BEGIN not in text or END not in text:
|
|
sys.exit("patch-template: system.nix missing __NOMARCHY_INSTALLER__ markers")
|
|
region = build_installer_region(v)
|
|
text = re.sub(
|
|
re.escape(BEGIN) + r".*?" + re.escape(END) + r"\n?",
|
|
region,
|
|
text,
|
|
count=1,
|
|
flags=re.DOTALL,
|
|
)
|
|
return text
|
|
|
|
|
|
def main() -> None:
|
|
if len(sys.argv) != 2:
|
|
sys.exit("usage: patch-template.py <flake-dir>")
|
|
flake_dir = Path(sys.argv[1])
|
|
vals = json.load(sys.stdin)
|
|
|
|
mapping = {
|
|
"flake.nix": patch_flake,
|
|
"home.nix": patch_home,
|
|
"system.nix": patch_system,
|
|
}
|
|
for name, fn in mapping.items():
|
|
path = flake_dir / name
|
|
path.write_text(fn(path.read_text(), vals))
|
|
print(f"patch-template: patched {', '.join(mapping)} in {flake_dir}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|