feat(install): copy templates/downstream and patch install values
All checks were successful
Check / eval (push) Successful in 3m4s

Make the downstream template the single source of truth for machine
flakes: nomarchy-install copies flake/system/home/theme-state and
patch-template.py only fills hostname, user, keyboard, detected
hardware, snapper, resume, and password. Install and flake-init users
now share the same commented opt-ins and starter home.packages.

Also: BACKLOG boreal-as-default proposal; installer audit notes;
HARDWARE.md from prior work if uncommitted.
This commit is contained in:
Bernardo Magri
2026-07-09 09:23:50 +01:00
parent 896b41faa3
commit caaac88da9
10 changed files with 496 additions and 196 deletions

View File

@@ -50,7 +50,11 @@ stdenvNoCC.mkDerivation {
# Empty flake registry: no network lookups for indirect refs.
echo '{"flakes":[],"version":2}' > "$share/registry.json"
mkdir -p "$share/template"
cp ${templateDir}/home.nix ${templateDir}/theme-state.json "$share/template/"
# Full downstream template is the SoT; install script copies + patches.
cp ${templateDir}/flake.nix ${templateDir}/system.nix \
${templateDir}/home.nix ${templateDir}/theme-state.json \
"$share/template/"
install -Dm644 patch-template.py "$share/patch-template.py"
# nixos-install / nixos-generate-config / nixos-enter / nix / systemd
# tools come from the live system on purpose they must match it.

View File

@@ -330,21 +330,14 @@ rm -f "$LUKS_KEY_PATH" "$disko_log"
success "Disk partitioned and mounted at /mnt"
# Hibernation plumbing: the swapfile's physical offset goes into the
# kernel cmdline. Deactivate swap first so nixos-generate-config doesn't
# also emit a swapDevices entry (we write our own, with resume wiring).
RESUME_CONFIG=""
# kernel cmdline (patched into system.nix). Deactivate swap first so
# nixos-generate-config doesn't also emit a swapDevices entry.
resume_offset=""
root_uuid=""
if [[ "$SWAP_GB" != "0" ]]; then
swapoff -a 2>/dev/null || true
resume_offset=$(btrfs inspect-internal map-swapfile -r /mnt/swap/swapfile)
root_uuid=$(findmnt -no UUID /mnt)
RESUME_CONFIG=$(cat <<NIX
# Swapfile (hibernation-ready: resume points into it).
swapDevices = [{ device = "/swap/swapfile"; }];
boot.resumeDevice = "/dev/disk/by-uuid/$root_uuid";
boot.kernelParams = [ "resume_offset=$resume_offset" ];
NIX
)
success "Swapfile created (resume offset $resume_offset)"
fi
@@ -358,183 +351,75 @@ nixos-generate-config --root /mnt
mv /mnt/etc/nixos/hardware-configuration.nix "$FLAKE_DIR/"
rm -rf /mnt/etc/nixos
cp "$SHARE/template/theme-state.json" "$FLAKE_DIR/"
# templates/downstream is the single source of truth (same files as
# `nix flake init -t`). Copy, then patch install-time values only.
cp "$SHARE/template/flake.nix" \
"$SHARE/template/system.nix" \
"$SHARE/template/home.nix" \
"$SHARE/template/theme-state.json" \
"$FLAKE_DIR/"
# home.nix is generated (not copied from the template) so the chosen
# keyboard layout reaches the Hyprland session — standalone HM cannot
# read system.nix.
cat > "$FLAKE_DIR/home.nix" <<EOF
# Your user environment. The Nomarchy desktop (Hyprland, Waybar,
# Ghostty, theming engine, Stylix) comes from homeModules.nomarchy;
# tune it via the nomarchy.* options, add your own packages and
# programs below.
{ pkgs, ... }:
# Detected hardware → flags for the patcher (safe defaults active).
has_intel=false; has_amd=false; has_fp=false
intel_guc_off=false; has_camera_ir=false
if [[ ${#HW_NOMARCHY[@]} -gt 0 ]]; then
for nm in "${HW_NOMARCHY[@]}"; do
case "$nm" in
hardware.intel.enable=true) has_intel=true ;;
hardware.intel.guc=false) intel_guc_off=true ;;
hardware.amd.enable=true) has_amd=true ;;
hardware.fingerprint.enable=true) has_fp=true ;;
hardware.camera.hideIrSensor=true) has_camera_ir=true ;;
esac
done
fi
is_laptop=false
[[ " ${HW_PROFILES[*]:-} " == *" common-pc-laptop "* ]] && is_laptop=true
thermald=false
[[ $is_laptop == true ]] && grep -q GenuineIntel /proc/cpuinfo 2>/dev/null && thermald=true
{
# Keyboard for the desktop session; console + LUKS prompt get the
# same layout from system.nix (xkb + console.useXkbConfig).
nomarchy.keyboard.layout = "$KB_LAYOUT";
nomarchy.keyboard.variant = "$KB_VARIANT";
# Examples:
# nomarchy.terminal = "kitty"; # swap the default terminal
# nomarchy.waybar.enable = false; # bring your own bar
# nomarchy.stylix.enable = false; # opt out of GTK/Qt theming
home.packages = with pkgs; [
# firefox
];
}
EOF
hw_nix=""
# JSON for patch-template.py (stdin). Hardware profiles as a JSON array.
hw_json="["
first=1
for p in "${HW_PROFILES[@]:-}"; do
[[ -n "$p" ]] && hw_nix+=" \"$p\""
[[ -z "$p" ]] && continue
if [[ $first -eq 1 ]]; then first=0; else hw_json+=","; fi
hw_json+=$(printf '%s' "$p" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))')
done
hw_json+="]"
cat > "$FLAKE_DIR/flake.nix" <<EOF
resume_json="null"
root_uuid_json="null"
if [[ -n "$resume_offset" && "$SWAP_GB" != "0" ]]; then
resume_json=$(printf '%s' "$resume_offset" | python3 -c 'import json,sys; print(json.dumps(int(sys.stdin.read().strip())))')
root_uuid_json=$(printf '%s' "$root_uuid" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))')
fi
python3 "$SHARE/patch-template.py" "$FLAKE_DIR" <<PYJSON
{
description = "$HOSTNAME_ — my Nomarchy machine";
# The only input. nixpkgs, home-manager etc. come pinned through it —
# tested together upstream. Generated by nomarchy-install; your machine
# lives in system.nix and home.nix, this file is never hand-edited.
inputs.nomarchy.url = "${NOMARCHY_FLAKE_URL}";
outputs = { nomarchy, ... }:
nomarchy.lib.mkFlake {
src = ./.;
username = "$USERNAME";
hardwareProfile = [$hw_nix ];
};
"hostname": $(printf '%s' "$HOSTNAME_" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"username": $(printf '%s' "$USERNAME" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"timezone": $(printf '%s' "$TIMEZONE" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"locale": $(printf '%s' "$LOCALE" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"keyboardLayout": $(printf '%s' "$KB_LAYOUT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"keyboardVariant": $(printf '%s' "$KB_VARIANT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"hashedPassword": $(printf '%s' "$HASHED_PASSWORD" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'),
"autoLogin": $([[ $WITH_LUKS == true ]] && echo true || echo false),
"laptop": $is_laptop,
"thermald": $thermald,
"hardwareProfiles": $hw_json,
"hardware": {
"intel": $has_intel,
"intelGucOff": $intel_guc_off,
"amd": $has_amd,
"fingerprint": $has_fp,
"cameraIr": $has_camera_ir,
"npu": $(if [[ -n "$NPU_VENDOR" ]]; then printf '%s' "$NPU_VENDOR" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().rstrip("\n")))'; else echo null; fi)
},
"resumeOffset": $resume_json,
"rootUuid": $root_uuid_json
}
EOF
AUTOLOGIN_CONFIG=""
if [[ $WITH_LUKS == true ]]; then
AUTOLOGIN_CONFIG=$(cat <<NIX
# The LUKS passphrase already gates this machine — skip the second
# password prompt and boot straight into the desktop.
nomarchy.system.greeter.autoLogin = "$USERNAME";
NIX
)
fi
# Laptop power: power-profiles-daemon ships by default; mark this a laptop
# so battery-only features apply, and enable thermald on Intel. Keyed off
# the same battery probe that chose the common-pc-laptop hardware profile.
POWER_CONFIG=""
if [[ " ${HW_PROFILES[*]:-} " == *" common-pc-laptop "* ]]; then
power_lines=" # Laptop power management (power-profiles-daemon + menu/Waybar
# switcher). Uncomment to stop charging at 80% to extend battery life.
nomarchy.system.power.laptop = true;
# nomarchy.system.power.batteryChargeLimit = 80;"
if grep -q GenuineIntel /proc/cpuinfo 2>/dev/null; then
power_lines+="
nomarchy.system.power.thermal.enable = true; # thermald (Intel)"
fi
POWER_CONFIG=$(cat <<NIX
$power_lines
NIX
)
fi
# Hardware enablement (nomarchy.hardware.*): what hardware-db.sh detected
# above the nixos-hardware commons. Safe defaults active; the heavier or
# experimental opt-ins written commented for the user to flip on.
HARDWARE_CONFIG=""
if [[ ${#HW_NOMARCHY[@]} -gt 0 || -n "$NPU_VENDOR" ]]; then
has_intel=0; has_amd=0; has_fp=0; intel_guc_off=0; has_camera_ir=0
if [[ ${#HW_NOMARCHY[@]} -gt 0 ]]; then
for nm in "${HW_NOMARCHY[@]}"; do
case "$nm" in
hardware.intel.enable=true) has_intel=1 ;;
hardware.intel.guc=false) intel_guc_off=1 ;;
hardware.amd.enable=true) has_amd=1 ;;
hardware.fingerprint.enable=true) has_fp=1 ;;
hardware.camera.hideIrSensor=true) has_camera_ir=1 ;;
esac
done
fi
hw_lines=" # Hardware enablement (auto-detected). Safe defaults are active;
# the heavier opt-ins are commented — uncomment to turn them on."
if [[ $has_intel -eq 1 ]]; then
hw_lines+="
nomarchy.hardware.intel.enable = true; # GuC/HuC firmware (i915.enable_guc=3)"
if [[ $intel_guc_off -eq 1 ]]; then
hw_lines+="
nomarchy.hardware.intel.guc = false; # GPU on the xe driver → GuC is default-on"
fi
hw_lines+="
# nomarchy.hardware.intel.computeRuntime = true; # OpenCL/oneVPL GPU compute (opt-in)"
fi
if [[ $has_amd -eq 1 ]]; then
hw_lines+="
nomarchy.hardware.amd.enable = true; # amd-pstate EPP + radeonsi VA-API
# nomarchy.hardware.amd.rocm.enable = true; # ROCm GPU compute (multi-GB, opt-in)
# nomarchy.hardware.amd.rocm.gfxOverride = \"\"; # e.g. \"11.0.0\" for an unlisted iGPU"
fi
if [[ $has_fp -eq 1 ]]; then
hw_lines+="
nomarchy.hardware.fingerprint.enable = true; # fprintd (enroll: fprintd-enroll)
# nomarchy.hardware.fingerprint.pam = true; # use it for login + sudo (opt-in)"
fi
if [[ $has_camera_ir -eq 1 ]]; then
hw_lines+="
nomarchy.hardware.camera.hideIrSensor = true; # dual-sensor webcam: hide the IR node (color cam only)"
fi
if [[ -n "$NPU_VENDOR" ]]; then
hw_lines+="
# nomarchy.hardware.npu.enable = true; # $NPU_VENDOR NPU driver (experimental; userspace runtime BYO)
# nomarchy.hardware.latestKernel = true; # newest kernel if the NPU driver isn't in the shipped one"
fi
HARDWARE_CONFIG=$(cat <<NIX
$hw_lines
NIX
)
fi
# initialHashedPassword is safe to template: mkpasswd's alphabet is
# [a-zA-Z0-9./$] — no Nix string metacharacters.
cat > "$FLAKE_DIR/system.nix" <<EOF
# Your machine: hostname, users, services. The distro itself comes from
# Nomarchy (via flake.nix); override its defaults here with plain NixOS
# options, or the nomarchy.system.* toggles.
{ pkgs, username, ... }:
{
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
networking.hostName = "$HOSTNAME_";
time.timeZone = "$TIMEZONE";
i18n.defaultLocale = "$LOCALE";
# One keyboard layout everywhere: xkb is the source of truth, and the
# distro defaults (console.useXkbConfig + the systemd initrd, set in
# the nomarchy module) derive the virtual console and the LUKS
# passphrase prompt from it — so only the chosen layout is written here.
# The Hyprland session reads the same layout from nomarchy.keyboard.* in
# home.nix.
services.xserver.xkb.layout = "$KB_LAYOUT";
services.xserver.xkb.variant = "$KB_VARIANT";
# Your login user — \`username\` flows in from flake.nix automatically.
users.users.\${username} = {
isNormalUser = true;
extraGroups = [ "wheel" "networkmanager" "video" "input" ];
initialHashedPassword = "$HASHED_PASSWORD";
};
$AUTOLOGIN_CONFIG$POWER_CONFIG$HARDWARE_CONFIG$RESUME_CONFIG
# Hourly/daily BTRFS timeline snapshots + nixos-rebuild-snap.
nomarchy.system.snapper.enable = true;
system.stateVersion = "26.05";
}
EOF
PYJSON
# The flake.lock: composed offline — nomarchy is path-locked to the very
# source the ISO carries (original stays the forge URL, so a later

View File

@@ -0,0 +1,251 @@
#!/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 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>")
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()