Opinionated 🤓, batteries-included 🔋, extra-DRY 🤌 Nix ❄️ boilerplate.
nullkommanix [ɪn ˈnʊl ˌkɔma ˈnɪçt͡s] noun German colloquialism (translation: in next to time).
nullkomma is per-project Nix scaffolding for devshells, formatters, checks, tasks, CI and editor settings, packaged as composable flake-parts modules.
It is not:
| Concern | Lives in |
|---|---|
| Fleet placement, clan, networking, fleet secrets | dataheld/aoshima |
| Host baseline (boot, disk, hardening, host services) | dataheld/pads |
| User environment policy (home-manager, shell ergonomics) | dataheld/lap |
| Agentic coding app / runtime | dataheld/gittens |
This repo contributes to the broader IT infrastructure laid out in aoshima. When making changes, adhere to aoshima's charter.
- Install Nix; the Determinate Nix Installer is recommended. On Windows, use the Windows Subsystem for Linux (WSL).
- For automatic shell activation, install direnv and nix-direnv in your user environment,
e.g. with lap.
Set
hide_env_diff = trueto quiet it.
Everything else comes from your project's flake.
In your project, run:
nix flake init --template "https://flakehub.com/f/dataheld/nullkomma/0.1.*#default"
git add flake.nix .envrc
nix develop --command write-files
nix develop --command generate-files
direnv allowThe template adds flake.nix and an .envrc with use flake, keeping existing files;
write-files and generate-files add the files that depend on your configuration.
From now on, entering the directory loads the development shell;
the first time takes a while.
The template's nixpkgs.follows = "nullkomma/nixpkgs" uses nullkomma's tested Nixpkgs.
To use your own, declare nixpkgs.url and set nullkomma.inputs.nixpkgs.follows = "nixpkgs".
Run update: it updates flake.lock, then runs write-files and generate-files.
After any other nix flake update (e.g. by a bot), run those two yourself.
After editing flake.nix, run direnv reload.
nullkomma.lib.mkFlake is flake-parts' mkFlake with every plugin imported and the default ones enabled,
so your flake.nix lists only exceptions and options.
For example:
inputs.nullkomma.lib.mkFlake { inherit inputs; } {
# All plugins and their settings: see "Plugins" below.
nullkomma.quarto.enable = true; # opt in
nullkomma.neovim.enable = false; # opt out
nullkomma.github.ci.inputs.visibility = "public"; # publish to FlakeHub
perSystem =
{ pkgs, ... }:
{
nullkomma.devShell.packages = [ pkgs.hello ];
};
}Each plugin is one of nullkomma's flakeModules.<name>; each column is an output of your flake that it adds to.
All plugins are optional; those marked under Default are enabled unless you opt out.
Files and editor settings that plugins write into your repository are under Plugin details.
| Plugin | Default | devShells |
formatter1 |
checks |
packages |
apps2 |
taskRunners |
|---|---|---|---|---|---|---|---|
| framework | always | every task, tasks runner |
treefmt | treefmt |
check (when checks exist), format, write-files, generate-files |
||
nix |
✅ | nixd | nixfmt, deadnix, nixf-diagnose |
nix-unit |
update, flake-checker 3 |
||
markdown |
✅ | marksman | mdformat, rumdl |
||||
json |
✅ | JSON language server | jsonfmt |
||||
yaml |
✅ | yaml-language-server | yamlfmt, yamllint, actionlint |
||||
toml |
✅ | toml-sort |
|||||
shell |
✅ | bash-language-server | shfmt, shellcheck |
bats 4 | |||
github |
✅ | ||||||
keepSorted |
✅ | keep-sorted |
|||||
gitleaks |
✅ | gitleaks |
secret-history |
||||
typos |
✅ | typos |
|||||
jscpd |
✅ | jscpd |
|||||
git |
✅ | git |
|||||
direnv |
✅ | ||||||
vscode |
✅ | ||||||
neovim |
✅ | ||||||
flakehub |
✅ | fh |
|||||
quarto |
quarto |
site |
preview |
||||
cloudflare |
wrangler |
cloudflare (OpenTofu validation) |
deploy5 |
terraform, deploy |
|||
r |
R with languageserver, pkgload and pak |
air, jarl |
r-testthat, r-cmd-check, r-namespace, r-pkgdown |
site (pkgdown) |
r-lock, shiny (opt-in) |
r-document, r-pkgdown, r-coverage, r-lock |
|
r-non-nix (enables r) |
These are your project's flake outputs; a schemas output describes them to Determinate Nix's nix flake show.
Headings omit the system, which perSystem and commands like nix build .#site fill in: site is packages.<system>.site.
Use: direnv, nix develop.
The development shell holds:
- every task as a command, taking arguments (e.g.
write-files --accept <path>); - the
tasksrunner; - language servers and the formatter for editors;
- the plugins' and your
perSystem.nullkomma.devShell.packages.
Use: format, nix fmt, nix flake check.
format (or nix fmt) runs treefmt with every enabled plugin's formatters and linters;
nix flake check checks the same through checks.treefmt.
Editors format with treefmt too (VS Code: treefmt).
Generated files aren't formatted or linted: managed files and directories,
and R/pkgdown and Quarto build outputs.
Add other generated content to nullkomma.generatedPaths as treefmt globs, e.g. [ "generated/**" ].
Configure linters in Nix, not in config files. Native config files are ignored
(Jarl, which can't ignore them, fails instead); suppression comments such as # shellcheck disable=SC2086 still work.
Your settings override plugins' lib.mkDefault values, and lists concatenate (lib.mkForce replaces one).
| Linter | Nix configuration | Settings to use |
|---|---|---|
| yamllint | perSystem.treefmt.programs.yamllint.settings |
Attributes matching yamllint's YAML config |
| typos | nullkomma.lint.typos |
Attributes matching typos' TOML config |
| rumdl | nullkomma.lint.markdown |
Markdownlint rule IDs: MD013 = false; or rule settings |
| actionlint | nullkomma.lint.actionlint |
Attributes matching actionlint's YAML config |
| gitleaks | nullkomma.lint.gitleaks |
Attributes matching its TOML config; upstream rules are retained by default |
| jscpd | nullkomma.lint.jscpd |
Attributes matching its JSON config; threshold defaults to zero, ignores extend shared exclusions |
| ShellCheck | perSystem.treefmt.programs.shellcheck |
treefmt-nix options, e.g. severity and extra-checks |
| deadnix | perSystem.treefmt.programs.deadnix |
treefmt-nix switches, e.g. no-lambda-arg |
| nixf-diagnose | perSystem.treefmt.programs.nixf-diagnose |
treefmt-nix options, e.g. ignore |
| Jarl | perSystem.treefmt.settings.formatter.jarl.options |
CLI arguments, e.g. [ "--ignore=any_is_na" ]; file-only settings are not supported |
For example, inside your mkFlake module:
nullkomma.lint = {
typos.default.extend-words.foobaar = "foobaar";
markdown.MD013 = false;
jscpd.threshold = 5;
};
perSystem = { ... }: {
treefmt.programs.yamllint.settings.rules.quoted-strings.required = true;
treefmt.programs.deadnix.no-lambda-arg = true;
treefmt.settings.formatter.typos.excludes = [ "snapshots/**" ];
treefmt.settings.excludes = [ "vendor/**" ];
};treefmt.settings.excludes skips files for every tool, jscpd included;
see treefmt-nix's options for more.
When migrating, move linter settings here and delete the old config files and pyproject.toml sections.
Use: nix flake check.
Runs the formatter and fails on formatting changes or lint errors.
Use: check [<name>] [args], nix flake check.
Checks in perSystem.nullkomma.checks.<name>, e.g. test suites, run in your working tree with check,
and sandboxed against the flake's source in nix flake check and CI, with extra sandboxArgs.
Plugins add their own (see the checks column above); so can you:
perSystem = { pkgs, ... }: {
nullkomma.checks.greeting = {
packages = [ pkgs.hello ];
command = "hello | grep -q Hello";
};
};Use: check nix-unit.
With the nix plugin, the nix-unit tests in perSystem.nullkomma.nix.unit.tests,
exported as tests.systems.<system> (nix-unit's convention) and run by the nix-unit check.
Use: nix build .#site.
quarto or the r plugin's pkgdown builds packages.site;
if both are enabled, set perSystem.packages.site yourself.
To theme sites from a brand flake, declare its input and select it:
inputs.brand = {
url = "https://flakehub.com/f/dataheld/brand/0.1.*";
inputs.nullkomma.follows = "nullkomma";
};
# Inside mkFlake's module:
nullkomma.brand = inputs.brand;The brand provides lib.brand (a brand.yml file),
optionally lib.footer (HTML) and flakeModules.privacy.
Its privacy sections join every site's privacy policy.
Quarto uses its native brand installer to generate _brand/ with the theme and its assets;
pkgdown applies the theme and footer.
Plugins that process visitors' data add a section to nullkomma.privacy.sections
(cloudflare adds hosting and analytics); so can a brand or your project:
nullkomma.privacy.sections.booking = {
order = 500; # cloudflare is 200
markdown = {
en = ./booking.en.md;
de = ./booking.de.md;
};
};quarto renders them, by order, into privacy.qmd (nullkomma.quarto.privacy.page),
one tab per nullkomma.privacy.languages; pkgdown renders them to /privacy.html.
Link the page from your footer, or set it to null to write your own.
The sections are a starting point, not legal advice.
Use: nix run .#deploy.
With cloudflare, publishes packages.site.
Use: nix run .#r-lock.
With the r plugin, writes the pkg.lock the development shell needs.
Use: nix run .#shiny.
With nullkomma.r.shiny.enable = true, serves the project's Shiny app
at http://127.0.0.1:3838 using the pinned R environment.
Use: tasks.
up's runner for the tasks in perSystem.nullkomma.tasks.<name>, where you can add your own:
tasks # list tasks
tasks check # run one task
tasks all # run all tasks not marked skipTasks can change files: R checks regenerate documentation and NAMESPACE.
Set skip = true on custom tasks that should not run as part of tasks all.
nullkomma keeps files in your repository to a minimum; features live in Nix.
Some tools still need real files (Git needs .gitignore, GitHub needs workflows,
editors need settings). Each is one of two kinds:
| Source of truth (committed, yours to edit) | Generated from Nix (Git-ignored, don't edit) | |
|---|---|---|
nix flake init --template |
flake.nix, .envrc |
(not possible) |
write-files |
.gitignore, GitHub workflows |
|
generate-files |
.vscode/*.json, .nvim.lua, Quarto brand and extensions |
Templates can't take parameters, so anything that depends on your configuration
comes from write-files or generate-files.
write-files writes these once (nullkomma.files."<path>".mode = "source", the default);
then they are yours to edit and commit.
When nullkomma later changes a file, write-files updates it only if you haven't edited it.
Otherwise it keeps your version, saves an incoming copy in Git's metadata directory and fails,
printing the copy's path and pointing you to the resolve-file-conflicts skill.
After resolving, write-files --accept <path> marks the file as up to date.
A file can instead hold marked sections inside your own text (e.g. R's .Rbuildignore rules):
write-files replaces only those, wherever you moved them.
Once nullkomma no longer declares a file (say, you disabled its plugin),
write-files strips its sections, deletes it if unedited, or keeps your edited version and says so.
generate-files overwrites these (mode = "generated") on every run,
starts them with a "do not edit" comment, and the git plugin ignores them.
To change one, change the Nix options.
Directories (directory = true) are copied whole, without the comment.
generate-files refuses to overwrite a file tracked by Git,
and removes files it generated before that are no longer declared.
The repository root is a portable Agent Plugins 1.0 package
with the resolve-file-conflicts skill.
In Copilot, install it with /plugin install https://github.com/dataheld/nullkomma
and update it with /plugin update nullkomma; neither writes to your project.
What plugins write into your repository (source-of-truth or generated) and how to configure them, where the table doesn't say it all. Linter settings are under Customizing linting.
Ignores result in .gitignore.
Unit tests for your flake's Nix code go in perSystem.nullkomma.nix.unit.tests;
see tests.
Writes .github/workflows/push.yml, a stub calling nullkomma's reusable CI workflow,
which runs nix flake check on each supported system.
nullkomma.github.ci.inputs.visibility = "public" also publishes to FlakeHub;
nullkomma.github.ci.secrets passes repository secrets.
Writes .gitignore from the top-level nullkomma.gitignore lines that plugins and you add,
plus every generated file.
Writes .envrc (use flake), which loads the development shell on entering the directory,
and ignores .direnv/.
Plugins declare language servers once, editor-neutrally (perSystem.nullkomma.editor).
The vscode and neovim plugins render them, with recommended extensions,
into generated, Git-ignored .vscode/settings.json, .vscode/extensions.json and .nvim.lua,
so nobody commits editor config.
Neovim reads .nvim.lua only with :set exrc.
Its :Format command formats the current buffer through treefmt.
Installing editors and extensions, themes and keybindings belong to your user environment, e.g. lap, which sets up the same two editors.
Opt-in quarto renders the project into packages.site; preview serves it locally and reloads on edits.
It ignores .quarto/ and _site/.
Shared Quarto extensions, e.g. from a brand flake, go in nullkomma.quarto.extensions.<name>,
generated into _extensions/<name>; a selected brand themes rendering and preview.
The privacy policy is generated into privacy.qmd.
Preview refreshes generated files before starting.
Opt-in cloudflare serves whatever packages.site holds as Workers static assets,
deployed with Terranix, including custom domains, _headers and _redirects; workers.dev is disabled.
It adds hosting and analytics to the privacy policy and ignores .nullkomma/cloudflare/.
nullkomma.cloudflare = {
enable = true;
worker = "my-site";
domains."www.example.org" = "<zone ID>";
};Locally, run terraform to plan changes (or terraform <command> [args]) and deploy to publish;
in CI, run nix run .#deploy.
Both need an existing private R2 bucket for Terraform state; in GitHub Actions:
env:
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_STATE_BUCKET: ${{ vars.CLOUDFLARE_STATE_BUCKET }}
# Account API token: Workers Scripts Edit
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_WORKERS }}
# R2 API token: Object Read & Write on the state bucket only
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}- Add
nullkomma.r.enable = true;toflake.nix; for a package in a subdirectory, alsonullkomma.r.root = ./pkg;. - Run
nix run .#r-lock: the development shell needs thepkg.lockit writes. - Run
write-filesand commit the changes.
Declare dependencies in DESCRIPTION; pak locks them in pkg.lock.
Refresh the lock after changing dependencies or updating Nix:
r-lock
git add DESCRIPTION pkg.lockNix takes package names from pkg.lock, but versions and builds from the nixpkgs-r input
(which follows Nixpkgs; override inputs.nullkomma.inputs.nixpkgs-r for newer R).
r-lock resolves against the PPM snapshot of the same date (nullkomma.r.snapshot);
it writes the lockfile, not an installed library, and requires Nix.
Outside Nix, r-non-nix installs from that PPM snapshot.
Depends, Imports, LinkingTo, Suggests and all Config/Needs/* fields are locked,
e.g. Config/Needs/development: devtools; override nullkomma.r.fields for a runtime-only lockfile.
The development environment holds exactly these, plus pkgload, pak, languageserver and the tools of enabled workflows;
nullkomma.r.extraPackages adds more.
So a package with three dependencies gets those three, one with Shiny gets Shiny,
and system libraries come with the packages that need them.
Note
Packages from Remotes, or missing from nixpkgs-r, need nullkomma.r.allowOffNixpkgs = true.
Git sources use the locked commit;
URL sources also need nullkomma.r.hashes.<package> from nix store prefetch-file <url>.
Add missing system libraries with perSystem.nullkomma.r.buildInputs.<package>.
GitLab and local references are unsupported; use git:: for GitLab.
| Command | What it does | Opt out |
|---|---|---|
r-document |
Generate man/*.Rd and NAMESPACE with roxygen2 |
nullkomma.r.document.enable (for hand-written manuals) |
check r-cmd-check |
Build and run rcmdcheck | nullkomma.r.check.enable |
check r-testthat |
Run testthat on the source, loaded with pkgload; --installed tests an installed copy, as nix flake check does |
nullkomma.r.testthat.enable |
check r-namespace |
Fail if roxygen2 would change the committed NAMESPACE |
with r-document |
r-pkgdown |
Build the website into docs/ |
nullkomma.r.pkgdown.enable |
r-coverage |
Write a local Cobertura coverage.xml with covr |
off unless covr is locked or nullkomma.r.coverage.enable = true |
Checks, tests and site builds regenerate documentation first.
Generated man/*.Rd files are Git-ignored, but not man/ itself; commit NAMESPACE.
nullkomma.r.check.errorOn sets the failing severity (default "note").
The r plugin keeps its package-build exclusions in a marked section of .Rbuildignore,
and ignores R build outputs in .gitignore.
check r-testthat is quick: it loads the source with pkgload instead of installing it.
nix flake check and CI run check r-testthat --installed, which tests an installed copy, as R CMD check does.
Config/testthat/parallel: true in DESCRIPTION runs tests in parallel.
Locking chromote (e.g. via shinytest2) adds Chromium on Linux
(perSystem.nullkomma.r.browser); on macOS, which Nixpkgs' Chromium doesn't support, chromote finds your Chrome.
packages.site builds the pkgdown site in the sandbox,
also in nix flake check; r-pkgdown builds it locally.
Cloudflare can publish it.
- A patch skips pkgdown's online CRAN/Bioconductor lookup; add those links with
home.links. - Bootstrap 5's JavaScript dependencies are pinned in nullkomma. Other remote templates, web fonts and network-dependent articles must be local for the sandbox.
nullkomma.r.pkgdown.configis merged over_pkgdown.yml, overriding the brand.nullkomma.r.pkgdown.assetsis a directory generated into Git-ignoredpkgdown/assets/, which pkgdown copies to the site root; leave itnullto manage that directory yourself.- The privacy policy is generated into Git-ignored
.github/privacy.md, which pkgdown renders to/privacy.html; setnullkomma.r.pkgdown.privacy.page = nullto write your own.
Export a zero-argument function returning a shiny.appobj, e.g. app <- function() shiny::shinyApp(ui, server),
declare shiny in DESCRIPTION, refresh pkg.lock and enable the launcher:
nullkomma.r.shiny = {
enable = true;
function = "app"; # default
host = "0.0.0.0"; # default "127.0.0.1"; port defaults to 3838
};nix run .#shiny installs your package from the flake's source, calls the function and serves the app.
Its runtime is R, Shiny and your package's Depends/Imports closure: no Suggests, Config/Needs/*, extraPackages or pkgload.
Ship assets in inst/ (find them with system.file()) and write data outside the installed package.
Test it by passing the function to
shinytest2:
shinytest2::AppDriver$new(app).
Under check r-testthat, the app process loads your source with pkgload,
so there's nothing to install and no stale installed copy; with --installed, it uses the installed package.
Air formats R and Jarl lints and fixes it first, configured through Nix
(disable with nullkomma.r.jarl.enable = false).
R projects allow HTML and long lines in Markdown (MD033, MD013),
keep typos from "correcting" the author role fnd in DESCRIPTION,
and skip testthat snapshots and docs/.
The tools follow current r-lib practice: rcmdcheck, roxygen2, pkgdown and covr. Jarl replaces lintr: it needs no package load and fixes lints before Air formats. Coverage stays local, without an upload service. Everything runs on one locked R version; add a CI matrix if you need Windows or other R releases.
For collaborators without Nix, enable nullkomma.r-non-nix.enable = true (it enables r),
run write-files, and commit .Renviron, .Rprofile, .R/install.R, .Rbuildignore
and the R development without Nix section added to your README.
These are source-of-truth files.
The startup profile only acts in native R sessions, installing from the same PPM snapshot
(nullkomma.r.snapshot, nullkomma.r.ppmUrl); it leaves Nix shells, builds and tasks alone.
The Plugins table maps nullkomma's outputs onto yours:
each row is one of nullkomma's flakeModules.<name>, each column an output of your flake.
Every output is fed by framework options (in perSystem) that plugins fill in, and so can you:
| Your output | Options | Task |
|---|---|---|
devShells.default |
nullkomma.devShell.* |
|
formatter, checks.treefmt |
treefmt.programs |
format |
checks.<name> |
nullkomma.checks.<name> |
check |
packages.<name> |
packages.<name> |
|
apps.<name> |
apps.<name> |
|
taskRunners |
nullkomma.tasks.<name> |
tasks |
Formatting and linting are treefmt's job: plugins add treefmt.programs, not their own runner.
Two more framework options act on your working tree rather than on outputs:
nullkomma.files.<path> (and the top-level nullkomma.gitignore), written by write-files and generate-files;
and nullkomma.editor, whose language servers join the dev shell and which vscode and neovim render into files.
Independent plugins do not read each other's options. Values shared between plugins are
framework options that any plugin may set or read. For example, r relaxes Markdown
rules through nullkomma.lint.markdown, which markdown hands to rumdl; neither
knows about the other.
A companion plugin may import and enable its base plugin and reuse its options:
r-non-nix depends on r, but r does not depend on the compatibility layer.
Because plugins only meet through framework options, one could live in its own flake,
e.g. a nullkomma-r exporting flakeModules.r against flakeModules.framework,
keeping R-only inputs out of other projects' lockfiles.
Before building something, check flake.parts for an
existing module; see AGENTS.md. Inputs only nullkomma's own
development needs go in dev/flake.nix, so they stay out of
projects' flake.lock.
nullkomma is tested against small example projects ("fixtures") in
tests/fixtures, which use nullkomma the way a real project would.
- Unit tests (nix-unit) check the fixtures' evaluated configuration, e.g. that every plugin can be switched off.
They fetch dev-only inputs through the
nixplugin's network-enabled fixed-output derivation. - Docs test:
options-docrenders every option as flake.parts would, so each needs a description. - Build tests run the real tools on the fixtures in
nix flake check; Nix and Markdown files listed inrejectsmust fail. - End-to-end test:
check-fixturesrunsnix flake checkon a copy of a fixture against this checkout, as a user would, the only test of how real projects fetch nullkomma. CI runs it on every push. - Without Nix:
test-r-non-nixbuilds ther-non-nixfixture in Docker (Ubuntu 24.04,linux/amd64, no Nix), runs the documented R examples and builds the package; extra arguments go todocker build. CI runs it on manual dispatch.
nix flake check # unit, docs and build tests
check-fixtures # end-to-end test
test-r-non-nix # R without Nix, in DockerNew plugins should add a valid and a deliberately broken fixture, and check that the broken one fails with the real tool's error, not just any error.
pkgdown-dependencies refreshes the pinned pkgdown Bootstrap 5 dependencies.
Footnotes
-
nix fmtand theformattask fix what they can;checks.<system>.treefmtfailsnix flake check(and CI) on any remaining formatting or lint issue. ↩ -
packagesare what Nix builds (nix build),appswhat it runs (nix run): a program, e.g. a deploy script or a Shiny server, usually backed by a package. ↩ -
The
flake-checkertask checksflake.lockfor outdated or non-upstream Nixpkgs inputs. ↩ -
Tests in
test/(nullkomma.shell.bats.testDir), once that directory exists; add the tools they call withperSystem.nullkomma.shell.bats.packages.*.batsfiles are formatted and linted like other shell scripts. ↩ -
terranix also exports it as
packages.deploy, whose.init,.plan,.destroy,.terraformand.configattributesnix runandnix buildreach, e.g.nix run .#deploy.plan. ↩