Some checks failed
Check / eval (push) Has been cancelled
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.
299 lines
11 KiB
Python
299 lines
11 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",
|
||
)
|
||
# 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()
|