Development
nix develop # or `nix develop -c zsh`
The shell carries everything: nixfmt, statix, sops, age, nixos-anywhere,
nixos-rebuild, opentofu, deploy-rs, pre-commit, gitleaks, markdownlint-cli2 and
mdbook. .#ci is a deliberately smaller subset — see CI.
Running it locally
Tests:
nix run github:nix-community/nixos-anywhere -- --flake .#vps --vm-test
The actual system (infinitely more useful):
QEMU_OPTS="-vnc :0" nix run .#default
And ssh into it from another terminal — ssh -p 2222 hutao@127.0.0.1 (there’s
no place like 127.0.0.1). Port 2222 on both ends, because sshd moved off 22 so
forgejo could publish it.
The VM disk is 32G (virtualisation.vmVariantWithDisko). The disko default of 2G
leaves ~987M for / once the ESP takes its gigabyte, which cannot hold the sixteen
declared images — the VM fills up mid-boot and every service that needs disk fails
in a way that reads like a bug in that service. This applies to nix run . only;
the Hetzner disk is sized by the provider.
Working from a Mac
The devShell is built for all four mainstream systems — x86_64-linux,
aarch64-linux, aarch64-darwin, x86_64-darwin. Every tool in it,
nixos-anywhere, nixos-rebuild and deploy-rs included, exists on each.
Secrets, formatting, statix and the hooks work unchanged.
What does not carry over is building the system closure. The outputs that
describe the box — nixosConfigurations, packages, apps — are
x86_64-linux only, so nix build ., nix run . (the QEMU VM) and
nix run .#install fail on anything else: a Mac has no Linux builder at all,
and an aarch64-linux workstation is the wrong architecture. Deploys work, but
only if the build happens somewhere else:
deploy -s --remote-build .#vps # build on the VPS itself
nixos-rebuild switch --flake .#vps-hetzner \
--target-host hutao@vps --build-host hutao@vps --use-remote-sudo
-s is not optional here. deploy-rs runs nix flake check first, and every
check in this flake reaches nixosConfigurations.*.system.build.toplevel, so
the check itself is an x86_64-linux build:
error: build of '…-10-acme.conf.drv^*' failed: platform mismatch
Required system: 'x86_64-linux' Current system: 'aarch64-darwin'
There is nothing to keep by skipping selectively — checks.aarch64-darwin
exists but both entries depend on the same Linux closure, so none of them
build here either.
--remote-build then evaluates locally (which darwin does fine), copies the
.drv with nix copy --to ssh-ng://hutao@vps --derivation, and realises it on
the box. That copy needs the ssh user to be a trusted nix user; hutao is in
wheel and modules/nix.nix trusts @wheel, so it already is.
The alternative is a Linux remote builder in /etc/nix/machines (or
nix-darwin’s nix.linux-builder), after which the plain commands above work
as written — including nix run .#install, which is otherwise Linux-only and so
still the reason a first install is done from a Linux machine.
Formatting
Formatting is nixfmt, not nixpkgs-fmt — every .nix file here conforms to
it and the two disagree on multi-argument lambdas, so the wrong one reformats the
whole tree.
nixfmt $(git ls-files '*.nix') && statix check .
tofu -chdir=tofu fmt -recursive && tofu -chdir=tofu validate
Hooks
nix develop -c pre-commit install # once per clone
nix develop -c pre-commit run --all-files
.pre-commit-config.yaml is the single definition — the local commit hook and CI
run the same file, so a check cannot pass here and fail there. It covers nixfmt,
statix, tofu fmt, markdownlint-cli2, the usual whitespace/YAML/merge-conflict
hooks, and gitleaks over the staged diff.
See below for what the markdown hook does and does not do.
gitleaks scans the staged diff rather than the working directory on purpose:
gitleaks dir reads gitignored files, and tofu/terraform.tfvars legitimately
holds live tokens — scanning it would fail the hook forever over a file git will
never accept. History scanning is a CI step instead.
Writing documentation
This book is docs/. mdbook and mdbook-mermaid are in the dev shell:
nix run .#docs # install assets, then serve
nix develop -c mdbook build docs # what CI does
nix run .#docs exists because the build has a prerequisite that is easy to
forget: mdbook-mermaid install docs writes mermaid.min.js and
mermaid-init.js next to book.toml, and book.toml references them. Those
two files are gitignored — 2.6 MB of vendored minified JS whose version is
already pinned by flake.lock — so a fresh clone does not have them and
mdbook build fails until they are written. The app does both steps; the
workflow does the same two commands explicitly.
create-missing = false in book.toml, so a link to a page that does not
exist fails the build rather than publishing a 404.
Diagrams are mermaid, and that is the point
Every diagram here is a ```mermaid fence in the markdown. Forgejo bundles
mermaid 11.16.1, so the same source renders in three places with no build
step: this book, the Forgejo web UI when browsing docs/src/, and a pull
request that changes one.
That is the whole reason they are not SVGs, D2, or the ASCII art they replaced. A picture that only exists after a build is a picture nobody sees while reviewing the change that invalidates it.
Markdown is linted, not formatted, by the hook
markdownlint-cli2 reports a code fence with no language and a paragraph past
80 columns. It rewrites nothing.
Formatting is prettier’s, run by an editor on save rather than by a hook, and
.markdownlint-cli2.yaml is tuned so prettier’s output passes untouched. The
config lists every rule that is off and why. Prettier is deliberately not in
the dev shell: nixpkgs-26.05 carries 3.8.3, which mangles a paragraph
containing both an intraword underscore and an emphasis span.
docs/superpowers/ is not linted — those are agent-generated plan and spec
records, kept as history.
Checking the Minecraft mod lists
nix run .#minecraft-mod-check
It checks every declared Modrinth slug against the exact Minecraft version and
loader each world runs, read from the evaluated config rather than a copy of
it. CI builds the same thing as .#minecraft-mods, which needs
--option sandbox false because it uses the network.