Files
homelab/flake.nix
T
darmanandClaude Sonnet 5 6f24ab69ad docs: condense comments across the repo
Comments had drifted into multi-paragraph narrative (git commit
lineage, debugging stories, restated code) in several hot spots
(scripts/deploy, hermes-agent.nix, flake.nix, gitea.nix, headscale.nix).
Trim every comment to its load-bearing "why" — gotchas, safety
warnings, and non-obvious rationale survive verbatim in substance,
just tightened to 1-2 sentences; historical narrative and anything
already covered in CLAUDE.md is cut. No code/logic changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJqEmY1y3AYX3JoX4Y6b21
2026-09-18 21:36:30 +02:00

655 lines
31 KiB
Nix

{
description = "Homelab NixOS configuration";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
disko = {
url = "github:nix-community/disko";
inputs.nixpkgs.follows = "nixpkgs";
};
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
nixos-images = {
url = "github:nix-community/nixos-images";
inputs.nixos-stable.follows = "nixpkgs";
};
nixos-anywhere = {
url = "github:nix-community/nixos-anywhere";
inputs.nixpkgs.follows = "nixpkgs";
inputs.nixos-stable.follows = "nixpkgs"; # 26.05 already IS stable
inputs.disko.follows = "disko";
inputs.nixos-images.follows = "nixos-images";
};
home-manager = {
url = "github:nix-community/home-manager/release-26.05";
inputs.nixpkgs.follows = "nixpkgs";
};
mediamanager-nix = {
url = "github:strangeglyph/mediamanager-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
# Headless CouchDB<->filesystem sync for Obsidian LiveSync
# (hosts/mars/livesync-bridge.nix); not a flake/not in nixpkgs, so plain
# source pinned by flake.lock. Pin carefully — it's a small third-party
# project with open storage->couchdb bugs, so an unreviewed bump could
# silently change how notes get written back.
livesync-bridge = {
url = "github:vrtmrz/livesync-bridge";
flake = false;
};
authentik-nix.url = "github:nix-community/authentik-nix";
nix-flatpak.url = "github:gmodena/nix-flatpak";
# Own Hyprland plugin (border + title bar), public repo over https.
# `nixpkgs.follows` is required since Hyprland plugins are ABI-locked to
# the exact Hyprland build — it must share this flake's nixpkgs, not
# whatever hypr-chrome's own lock pins standalone.
hypr-chrome = {
url = "git+https://git.mgaction.town/darman/hypr-chrome.git";
inputs.nixpkgs.follows = "nixpkgs";
};
# Tome (formerly AudibleLibrary) — darman's own .NET/Photino desktop app.
# Private repo on our own gitea, fetched over ssh with darman's ambient
# key; plain source tree (`flake = false`), see pkgs/tome.nix.
#
# NOTE: the credential-less installer-iso can't fetch this, so
# `./scripts/deploy install terra localhost` fails at nixos-install
# (post-disko) while this input is present — a known tradeoff.
tome = {
url = "git+ssh://gitea@git.mgaction.town:2222/darman/TOME.git";
flake = false;
};
};
outputs = { self, nixpkgs, disko, nixos-anywhere, sops-nix, nixos-images, home-manager, mediamanager-nix, authentik-nix, ... }@inputs:
let
system = "x86_64-linux";
in
{
packages.${system} = {
# Re-exported so `./scripts/deploy` can run them as `nix run .#disko` /
# `nix run .#nixos-anywhere`, at the revision flake.lock pins. See the
# nixos-anywhere input above for why that matters.
disko = disko.packages.${system}.disko;
nixos-anywhere = nixos-anywhere.packages.${system}.nixos-anywhere;
};
nixosConfigurations = {
# Real host — install on the ZimaBlade.
# disko owns the OS-disk partitioning + filesystems (see disk-config.nix).
jupiter = nixpkgs.lib.nixosSystem {
inherit system;
specialArgs = { inherit inputs; };
modules = [
disko.nixosModules.disko
sops-nix.nixosModules.sops
home-manager.nixosModules.home-manager
./hosts/jupiter/configuration.nix
];
};
# netcup VPS — public reverse proxy + tailnet node.
neptun = nixpkgs.lib.nixosSystem {
inherit system;
specialArgs = { inherit inputs; };
modules = [
disko.nixosModules.disko
sops-nix.nixosModules.sops
home-manager.nixosModules.home-manager
./hosts/neptun/configuration.nix
];
};
# terra — Ryzen 9 5900X desktop (MSI MS-7A32). Replaces CachyOS on the
# OS SSD; Hyprland desktop + tailnet node. See hosts/terra/*.
terra = nixpkgs.lib.nixosSystem {
inherit system;
specialArgs = { inherit inputs; };
modules = [
disko.nixosModules.disko
sops-nix.nixosModules.sops
home-manager.nixosModules.home-manager
inputs.nix-flatpak.nixosModules.nix-flatpak
./hosts/terra/configuration.nix
];
};
# mars — on-site x86_64 box: Hermes Agent, plus the LAN web apps luna
# hosts herself (hosts/mars/luna-sites.nix). See hosts/mars/*.
mars = nixpkgs.lib.nixosSystem {
inherit system;
specialArgs = { inherit inputs; };
modules = [
disko.nixosModules.disko
sops-nix.nixosModules.sops
home-manager.nixosModules.home-manager
./hosts/mars/configuration.nix
];
};
# mercury — Raspberry Pi 3B+ (aarch64), DNS/DHCP; SD image via:
# nix build .#nixosConfigurations.mercury.config.system.build.sdImage
# Needs binfmt/qemu for the aarch64 build on this x86 host (or a remote aarch64 builder).
mercury = nixpkgs.lib.nixosSystem {
system = "aarch64-linux";
specialArgs = { inherit inputs; };
modules = [
(nixpkgs + "/nixos/modules/installer/sd-card/sd-image-aarch64.nix")
sops-nix.nixosModules.sops
home-manager.nixosModules.home-manager
./hosts/mercury/configuration.nix
];
};
# x86_64 QEMU VM to runtime-test mercury's DNS/DHCP stack (pihole +
# unbound) before flashing the aarch64 SD. Build + run:
# nix build .#nixosConfigurations.mercury-vm.config.system.build.vm
# ./result/bin/run-mercury-vm-vm
mercury-vm = nixpkgs.lib.nixosSystem {
inherit system; # x86_64-linux, fast to build/boot with KVM
modules = [
(nixpkgs + "/nixos/modules/virtualisation/qemu-vm.nix")
home-manager.nixosModules.home-manager
./common.nix
./services/network/unbound.nix
./services/network/pihole.nix
({ lib, ... }: {
networking.hostName = "mercury-vm";
networking.nameservers = [ "1.1.1.1" "9.9.9.9" ]; # host resolver (not pihole)
users.users.darman.initialPassword = "test";
users.users.root.initialPassword = "test";
services.openssh.settings.PasswordAuthentication = lib.mkForce true;
virtualisation.graphics = false;
virtualisation.memorySize = 2048;
virtualisation.forwardPorts = [
{ from = "host"; host.port = 2223; guest.port = 22; }
{ from = "host"; host.port = 8081; guest.port = 80; }
];
system.stateVersion = "26.05";
})
];
};
# VirtualBox test image. Build the OVA with:
# nix build .#nixosConfigurations.jupiter-vbox.config.system.build.virtualBoxOVA
# NOTE: no disko here — the virtualbox-image module supplies the disk.
jupiter-vbox = nixpkgs.lib.nixosSystem {
inherit system;
specialArgs = { inherit inputs; };
modules = [
home-manager.nixosModules.home-manager
./hosts/jupiter/vm.nix
];
};
# Custom kexec installer with our SSH key baked in, for headless install
# onto a box with a read-only root (ZimaOS) where nixos-anywhere can't
# ssh-copy-id. Build the tarball:
# nix build .#nixosConfigurations.kexec.config.system.build.kexecInstallerTarball
# then scp it to the target's writable /tmp and run kexec/run (see README).
kexec = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
nixos-images.nixosModules.kexec-installer
({ ... }: {
users.users.root.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILD5K6AQ0wYYHbNGzC4PyunUQsXbaD0iu1eaadLtv+Xp darman@terra"
];
})
];
};
# Bootable USB recovery installer with our SSH key + sshd + DHCP; clones
# the public homelab repo fresh at every boot to /root/homelab, so the
# same stick stays current without a rebuild. Reusable for any host's
# manual-USB install path.
# Build: nix build .#nixosConfigurations.installer-iso.config.system.build.isoImage,
# dd to USB, boot the target, ssh in, ./scripts/deploy install ...
installer-iso = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
(nixpkgs + "/nixos/modules/installer/cd-dvd/installation-cd-minimal.nix")
({ pkgs, lib, ... }: {
services.openssh.enable = true;
services.openssh.settings.PermitRootLogin = "prohibit-password";
users.users.root.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILD5K6AQ0wYYHbNGzC4PyunUQsXbaD0iu1eaadLtv+Xp darman@terra"
];
networking.hostName = "homelab-installer";
console.keyMap = "de"; # matches common.nix's real hosts
environment.systemPackages = [ pkgs.git ];
# The systemd initrd (default since 26.05) has no findiso= path — only
# the legacy script stage-1 does — so this install method needs it off.
boot.initrd.systemd.enable = false;
# installation-cd-minimal ships with experimental-features unset;
# without this, both `nix run .#disko` and `nixos-install --flake`
# die with "experimental Nix feature 'nix-command' is disabled".
nix.settings.experimental-features = [ "nix-command" "flakes" ];
# Fresh clone of the public repo (no credentials baked in) so
# scripts/deploy's require_tracked() sees a real checkout; retry
# with `systemctl restart homelab-checkout` if DHCP wasn't up yet.
systemd.services.homelab-checkout = {
description = "Clone the homelab repo to /root/homelab";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.git ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
rm -rf /root/homelab
git clone --depth 1 https://git.mgaction.town/darman/homelab.git /root/homelab
'';
};
# Completes an unattended local_install_prepare_and_reboot() run:
# re-runs `./scripts/deploy install <config> localhost`, now genuinely
# inside the installer so it takes the disko+nixos-install branch.
# No-op if homelab.install= isn't on the kernel cmdline.
systemd.services.homelab-auto-install = {
description = "Auto-run the homelab install if homelab.install= was passed on the kernel cmdline";
after = [ "homelab-checkout.service" ];
requires = [ "homelab-checkout.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig.Type = "oneshot";
# Needs the full system PATH: scripts/deploy execs bash then shells
# out to nix/nixos-install/git/sudo/efibootmgr, none of which a
# restricted `path = [...]` PATH provides. mkForce overrides NixOS's
# default PATH derivation from `path`.
#
# HOME too: systemd sets no $HOME without User= (SetLoginEnvironment=
# defaults false), and scripts/deploy runs under `set -u`, so a
# missing $HOME aborted with a confusing "unbound variable".
environment = {
HOME = "/root";
PATH = lib.mkForce "/run/current-system/sw/bin:/run/wrappers/bin";
};
script = ''
cfg=$(grep -o 'homelab\.install=[^ ]*' /proc/cmdline | cut -d= -f2 || true)
if [ -z "$cfg" ]; then
echo "no homelab.install= on the kernel cmdline — nothing to auto-install"
exit 0
fi
# Persist this run to a file that outlives the install: the journal
# dies with the reboot and disko wipes the OS disk before a failure
# can be read back. homelab.logpart= points at the staging partition
# (survives the wipe); every step here is best-effort so logging
# itself can't break an install.
logfile=""
logpart=$(grep -o 'homelab\.logpart=[^ ]*' /proc/cmdline | cut -d= -f2 || true)
if [ -n "$logpart" ]; then
dev="/dev/disk/by-partuuid/$logpart"
logdir=""
mkdir -p /run/homelab-log
if mount -o rw "$dev" /run/homelab-log 2>/dev/null; then
logdir=/run/homelab-log
elif where=$(findmnt -fno TARGET "$dev" 2>/dev/null) && [ -n "$where" ]; then
# stage-1's findiso already has this partition mounted (how it
# reached the iso) — reuse that mount instead of a second one.
mount -o remount,rw "$where" 2>/dev/null || true
logdir="$where"
fi
if [ -n "$logdir" ]; then
# Next to the iso: findiso= is its path on this partition.
iso=$(grep -o 'findiso=[^ ]*' /proc/cmdline | cut -d= -f2 || true)
dest="$logdir/$(dirname "$iso" 2>/dev/null || echo /)"
if mkdir -p "$dest" 2>/dev/null; then
logfile="$dest/homelab-install-$cfg.log"
else
logfile="$logdir/homelab-install-$cfg.log"
fi
echo "logging this install to $logfile (on the staging disk — survives the wipe)"
else
echo "warning: could not mount PARTUUID=$logpart to log to — continuing without a persistent log" >&2
fi
fi
do_install() {
# The host key (so sops can decrypt on first boot) can't live in
# this public-repo ISO; local_install_prepare_and_reboot() drops it
# on the boot partition instead and passes that PARTUUID here — the
# copy dies with disko's wipe minutes later.
keypart=$(grep -o 'homelab\.keypart=[^ ]*' /proc/cmdline | cut -d= -f2 || true)
if [ -n "$keypart" ]; then
mkdir -p /run/homelab-key
if mount -o ro "/dev/disk/by-partuuid/$keypart" /run/homelab-key; then
src=/run/homelab-key/homelab-installer
if [ -f "$src/ssh_host_ed25519_key" ]; then
echo "picking up $cfg's host key from PARTUUID=$keypart"
install -Dm600 "$src/ssh_host_ed25519_key" \
"/root/.config/homelab/$cfg/ssh_host_ed25519_key"
install -Dm644 "$src/ssh_host_ed25519_key.pub" \
"/root/.config/homelab/$cfg/ssh_host_ed25519_key.pub"
else
echo "warning: no host key at $src — the install will refuse" >&2
fi
umount /run/homelab-key
else
echo "warning: could not mount PARTUUID=$keypart for the host key" >&2
fi
fi
# On bootloaders with no one-shot (Limine on terra), scripts/deploy
# got here via a temporary UEFI entry + BootNext (arm_efi_bootnext);
# BootNext is spent but the entry would linger pointing at a
# partition disko is about to wipe, so remove it now.
for n in $(efibootmgr 2>/dev/null \
| sed -n 's/^Boot\([0-9A-Fa-f]\{4\}\)\*\?[[:space:]]Homelab Installer[[:space:]].*/\1/p'); do
echo "removing temporary UEFI entry Boot$n"
efibootmgr -q -B -b "$n" || true
done
echo "auto-installing $cfg (homelab.install= on the kernel cmdline)"
cd /root/homelab
./scripts/deploy install "$cfg" localhost --yes
}
# tee, not exec: we need the exit status back to sync the log
# to the platter before the box possibly drops to a shell.
if [ -n "$logfile" ]; then
{ echo "=== homelab auto-install: $cfg ($(date -u 2>/dev/null || true)) ==="; do_install; } 2>&1 | tee -a "$logfile"
status=''${PIPESTATUS[0]}
else
do_install
status=$?
fi
sync 2>/dev/null || true
exit "$status"
'';
};
})
];
};
};
# VM test for `./scripts/deploy kexec-local` (nix build .#checks.x86_64-linux.kexec-local -L)
# — the one command that can't be rehearsed on real hardware since it jumps
# the machine you're on. Regression-tests kexec-run.sh's backgrounded
# `sleep 6 && kexec -e`: cleaning up the staging dir on exit would delete
# the jump binary and the box would silently stay on the old kernel.
checks.${system} = {
kexec-local =
let
pkgs = nixpkgs.legacyPackages.${system};
tarball = self.nixosConfigurations.kexec.config.system.build.kexecInstallerTarball;
sshKey = nixos-images + "/nix/kexec-installer/ssh-keys/id_ed25519";
in
pkgs.testers.runNixOSTest {
name = "deploy-kexec-local";
nodes.machine = { modulesPath, ... }: {
imports = [ (modulesPath + "/profiles/minimal.nix") ];
virtualisation.vlans = [ ];
# kexec-local refuses to run if RAM < 3x the installer image, and
# the staging dir needs ~3x the tarball on /var/tmp.
virtualisation.memorySize = 4 * 1024;
virtualisation.diskSize = 12 * 1024;
virtualisation.forwardPorts = [{ host.port = 2222; guest.port = 22; }];
services.openssh.enable = true;
users.users.root.openssh.authorizedKeys.keyFiles = [ "${sshKey}.pub" ];
# Everything the script shells out to, minus nix — the test uses the
# HOMELAB_KEXEC_* hook so no build happens inside the VM.
environment.systemPackages = with pkgs; [
bash gnutar coreutils findutils util-linux cpio gzip
];
system.extraDependencies = [ tarball pkgs.cpio pkgs.gzip ];
environment.etc."deploy".source = ./scripts/deploy;
};
testScript = /* python */ ''
import os, shutil, subprocess, tempfile, time
start_all()
machine.wait_for_unit("sshd.service")
# ssh refuses a private key that is group/world readable, and nix
# store paths are 0444 — copy it out and tighten the mode.
keydir = tempfile.mkdtemp()
key = os.path.join(keydir, "id_ed25519")
shutil.copyfile("${sshKey}", key)
os.chmod(key, 0o600)
def ssh(cmd, check=True, stdout=None):
return subprocess.run(
[ "${pkgs.openssh}/bin/ssh",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=1",
"-i", key,
"-p", "2222", "root@127.0.0.1", "--" ] + cmd,
text=True, check=check, stdout=stdout)
machine.succeed("install -Dm755 /etc/deploy /root/deploy")
# systemd-run starts units with a bare PATH lacking
# /run/current-system/sw/bin, so bash (and tar/findmnt/nohup)
# can't resolve — set it explicitly.
env = (
" --setenv=PATH=/run/wrappers/bin:/run/current-system/sw/bin"
" --setenv=HOMELAB_KEXEC_TARBALL=${tarball}/nixos-kexec-installer-${system}.tar.gz"
" --setenv=HOMELAB_KEXEC_CPIO=${pkgs.cpio}/bin/cpio"
" --setenv=HOMELAB_KEXEC_GZIP=${pkgs.gzip}/bin/gzip"
)
# Same values for the foreground (non-systemd-run) invocation below.
envsh = (
"HOMELAB_KEXEC_TARBALL=${tarball}/nixos-kexec-installer-${system}.tar.gz"
" HOMELAB_KEXEC_CPIO=${pkgs.cpio}/bin/cpio"
" HOMELAB_KEXEC_GZIP=${pkgs.gzip}/bin/gzip"
)
# Marker on a tmpfs: it must NOT survive the jump, proving we really
# booted a new kernel rather than just restarting a service.
machine.succeed("touch /run/pre-kexec-marker")
host_key_before = machine.succeed("cat /etc/ssh/ssh_host_ed25519_key.pub").strip()
while ssh(["true"], check=False).returncode != 0:
time.sleep(1)
# Refuses without --yes when stdin isn't a tty; needs the same env to
# reach the confirmation prompt, else it dies early on the nix build
# and proves nothing.
out = machine.fail(f"{envsh} /root/deploy kexec-local </dev/null 2>&1")
assert "using prebuilt kexec installer" in out, \
f"never reached the prompt, so the refusal proves nothing:\n{out}"
# systemd-run so the call returns immediately: the script stays
# alive ~60s on purpose, outliving kexec-run.sh's `sleep 6`.
machine.succeed(f"systemd-run --collect --unit=kexec-local{env} /root/deploy kexec-local --yes")
print("waiting for the jump...")
deadline = time.time() + 300
while ssh(["true"], check=False).returncode == 0:
# Surface a dead unit immediately instead of stalling until the
# deadline and blaming "never left the old kernel".
st = ssh(["systemctl", "is-active", "kexec-local"],
check=False, stdout=subprocess.PIPE).stdout or ""
if st.strip() in ("failed", "inactive"):
# NB: not `log` — the driver already binds that name to its
# AbstractLogger and the type check rejects the shadowing.
unit_log = ssh(["journalctl", "-u", "kexec-local", "--no-pager"],
check=False, stdout=subprocess.PIPE).stdout or ""
raise AssertionError(
f"kexec-local.service ended ({st.strip()}) without jumping:\n{unit_log}")
assert time.time() < deadline, "machine never left the old kernel"
time.sleep(1)
print("waiting for the installer...")
deadline = time.time() + 300
while ssh(["true"], check=False).returncode != 0:
assert time.time() < deadline, "installer never came up"
time.sleep(1)
# It really is the RAM installer, not the old system.
host = ssh(["hostname"], stdout=subprocess.PIPE).stdout.strip()
assert host == "nixos-installer", f"hostname is {host}, not nixos-installer"
assert ssh(["ls", "/run/pre-kexec-marker"], check=False).returncode != 0, \
"old /run survived — this was not a fresh kernel"
# The host key is carried across (kexec-run.sh copies /etc/ssh into
# the appended initrd), which is why `kexec` does no ssh-keygen -R.
host_key_after = ssh(
["cat", "/etc/ssh/ssh_host_ed25519_key.pub"], stdout=subprocess.PIPE
).stdout.strip()
assert host_key_before == host_key_after, \
f"host key changed: {host_key_before} != {host_key_after}"
machine.crash()
'';
};
# VM test for hosts/mars/luna-sites.nix (header of luna-sites-test.nix):
# nix build .#checks.x86_64-linux.luna-sites -L
luna-sites = import ./hosts/mars/luna-sites-test.nix {
pkgs = nixpkgs.legacyPackages.${system};
};
};
# `nix develop` — hot-reload loop for dotfiles/quickshell.
#
# hosts/terra/home.nix ships the shell as a store copy (`xdg.configFile`),
# which only hot-reloads its own frozen files; pointing at the working
# tree with `qs -p` restores edit-save-see without a rebuild.
#
# quickshell keys instance identity on the config path, so the
# working-tree and store-backed shells are different instances that
# would both claim every output — hence a swap, not a second instance.
# The swap starts dev first and only kills the packaged shell once dev
# is confirmed up, so a QML error leaves you on your normal bar.
#
# Every kill is scoped to one config (`qs kill` = default, `qs kill -p
# <path>` = that path) since a blanket kill would also take out
# unrelated instances like pkgs/rishot.nix.
#
# Deliberately not wired to direnv: programs.direnv is enabled for this
# user, so a `use flake` would swap the desktop shell on every `cd`
# into the checkout, including over ssh.
devShells.${system}.default =
let
pkgs = nixpkgs.legacyPackages.${system};
# Same nixpkgs terra's home.nix takes pkgs.quickshell from, so the dev
# instance is the identical build to the packaged one.
qs = "${nixpkgs.legacyPackages.${system}.quickshell}/bin/qs";
git = "${nixpkgs.legacyPackages.${system}.git}/bin/git";
grep = "${nixpkgs.legacyPackages.${system}.gnugrep}/bin/grep";
# Resolved at RUN time, not build time: the entire point is to run the
# working tree, and `self` here is only a store snapshot of it.
preamble = ''
root="$(${git} rev-parse --show-toplevel 2>/dev/null || pwd)"
cfg="$root/dotfiles/quickshell"
if [ ! -f "$cfg/shell.qml" ]; then
echo "no shell.qml under $cfg — run this from the homelab checkout" >&2
exit 1
fi
# `qs list` exits 0 whether or not it found anything, and only emits
# json when it did — so "json came back" is the liveness test.
running() { ${qs} list -p "$1" -j 2>/dev/null | grep -q '"id"'; }
prod_running() { ${qs} list -j 2>/dev/null | grep -q '"id"'; }
'';
qs-dev = pkgs.writeShellScriptBin "qs-dev" ''
set -uo pipefail
${preamble}
if [ -z "''${WAYLAND_DISPLAY:-}" ]; then
echo "qs-dev: no WAYLAND_DISPLAY — refusing to swap the desktop shell" >&2
exit 1
fi
if running "$cfg"; then
echo "qs-dev: already running from $cfg"
exit 0
fi
${qs} -d -p "$cfg"
# Confirm it came up before touching the packaged shell.
for _ in $(seq 1 50); do
running "$cfg" && break
sleep 0.1
done
if ! running "$cfg"; then
echo "qs-dev: dev shell failed to start — packaged shell left alone" >&2
echo "qs-dev: run 'qs -p $cfg' in the foreground to see the QML error" >&2
exit 1
fi
${qs} kill || true
echo "qs-dev: live on $cfg — edits there now hot-reload"
'';
# qs log -f prints everything the instance logs; WARN/ERROR are what
# mean something is actually wrong with the QML (a binding loop or
# failed binding is a WARN, easy to miss in the reload chatter).
qs-log = pkgs.writeShellScriptBin "qs-log" ''
set -uo pipefail
${preamble}
filter='WARN|ERROR'
case "''${1:-}" in
-a|--all) filter='.' ;;
esac
# -t 1: `qs log -f` otherwise replays the whole backlog on shell entry.
# It also ends when the attached instance exits, and the dev shell
# outlives individual instances (a QML error kills one, qs-dev starts
# another) — so re-attach in a loop instead of going quiet for the session.
while :; do
if running "$cfg"; then
${qs} log -p "$cfg" -t 1 -f 2>/dev/null | ${grep} --line-buffered -E "$filter" >&2
fi
sleep 1
done
'';
qs-prod = pkgs.writeShellScriptBin "qs-prod" ''
set -uo pipefail
${preamble}
running "$cfg" && ${qs} kill -p "$cfg" || true
prod_running || ${qs} -d
echo "qs-prod: back on ~/.config/quickshell"
'';
in
pkgs.mkShell {
packages = [ pkgs.quickshell qs-dev qs-prod qs-log ];
# Swap on entry, swap back on exit. Three guards:
# - interactive only ($- has i). `nix develop --command X` EXECs X,
# replacing the shell that set the trap, so the restore would
# never run and you'd be left on the dev instance. Non-interactive
# use gets the explicit `nix develop -c qs-dev` instead.
# - WAYLAND_DISPLAY, so entering the shell over ssh cannot kill the
# desktop's bar and leave nothing in its place.
# - a sentinel, so a nested `nix develop` does not swap (and then
# restore) a second time.
shellHook = ''
if [[ $- == *i* ]] && [ -n "''${WAYLAND_DISPLAY:-}" ] && [ -z "''${HOMELAB_QS_DEV:-}" ]; then
export HOMELAB_QS_DEV=1
if qs-dev; then
# Stream the dev instance's warnings and errors into this
# terminal, and take the follower down with the shell.
qs-log & HOMELAB_QS_LOG=$!
trap 'kill "$HOMELAB_QS_LOG" 2>/dev/null; qs-prod' EXIT
fi
fi
echo "homelab devshell — qs-dev (working tree) / qs-prod (packaged); exit restores"
echo "homelab devshell — quickshell WARN/ERROR stream here; qs-log -a for everything"
'';
};
};
}