#!/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 # 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 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(v.get("keyboardVariant") or "") 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")): 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", ] 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(v.get("keyboardVariant") or "")}";', "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 = 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()