Files
Nomarchy/pkgs/nomarchy-install/patch-template.py
Bernardo Magri d8e1a13d50
Some checks failed
Check / eval (push) Has been cancelled
refactor(#107): theme-state.json → state.json, theme-sync → state-sync
The machine flake's git-tracked settings file is system state, not
"theme" only — rename it to state.json. CLI becomes nomarchy-state-sync
with a nomarchy-theme-sync symlink for scripts and muscle memory.

Eval (mkFlake, doctor, lifecycle) still accepts theme-state.json; the
next write migrates to state.json and removes the legacy file.
Documented in MIGRATION.md; drop the CLI alias after release notes.
2026-07-15 11:26:59 +01:00

299 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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",
)
# ISO build bakes the ref it was built from (main vs v1). Must match the
# option surface the installer writes into system.nix (#124).
if v.get("flakeUrl"):
text, n = re.subn(
r'inputs\.nomarchy\.url = "[^"]*";',
f'inputs.nomarchy.url = "{nix_str(v["flakeUrl"])}";',
text,
count=1,
)
if n != 1:
sys.exit("patch-template: could not patch inputs.nomarchy.url")
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.",
]
# Auto-login is deliberately NOT emitted here — it is seeded into
# state.json instead (patch_state). A line in system.nix outranks
# the state default, which would make the System Auto-login toggle
# write JSON that nothing reads.
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 patch_state(text: str, v: dict) -> str:
"""Seed menu-owned settings into state.json.
These live in the state rather than system.nix precisely so the menu can
change them later: a baked Nix assignment would outrank the state default
and strand the toggle. Auto-login is on when the disk is encrypted — the
LUKS passphrase already gates the machine, so a greeter password is a
second prompt for the same thing; without LUKS it stays off, where the
greeter is the only thing standing between power-on and the desktop.
"""
state = json.loads(text)
settings = state.setdefault("settings", {})
if v.get("autoLogin"):
settings.setdefault("greeter", {})["autoLogin"] = v["username"]
return json.dumps(state, indent=2) + "\n"
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,
"state.json": patch_state,
}
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()