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 nk-write-files
nix develop --command nk-generate-files
direnv allowThe template adds flake.nix and an .envrc with use flake, keeping existing files;
nk-write-files and nk-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 nk-write-files and nk-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
perSystem =
{ pkgs, ... }:
{
nullkomma.devShell.packages = [ pkgs.hello ];
};
}Each plugin is an independently selectable integration exported as flakeModules.<name>;
each column is an output of your flake that it adds to. Optional features belong to their plugin,
not to separately selected companion plugins.
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 |
processTrees3 |
|---|---|---|---|---|---|---|---|---|
| framework | always | every task, tasks runner |
treefmt | treefmt |
task outputs |
check (when checks exist), format, nk-write-files, nk-generate-files, watch-<task>, pc-up-<tree> |
watched/process tasks and groups | |
nix |
β | nixd | nixfmt, deadnix, nixf-diagnose |
nix-unit |
update, flake-checker 4 |
|||
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 5 | ||||
github |
β | |||||||
agents |
β | |||||||
keepSorted |
β | keep-sorted |
||||||
gitleaks |
β | gitleaks |
secret-history |
|||||
typos |
β | typos |
||||||
jscpd |
β | jscpd |
||||||
git |
β | git |
||||||
direnv |
β | |||||||
vscode |
β | |||||||
neovim |
β | |||||||
flakehub |
β | fh |
||||||
quarto (includes nkdocs; optional brand) |
quarto |
site |
build-site, preview, pc-up-preview |
preview |
||||
cloudflare |
wrangler |
cloudflare (OpenTofu validation) |
deploy6 |
terraform, deploy |
||||
r (optional non-Nix collaboration, pkgdown brand) |
R, radian, Air; languageserver, pkgload, pak, httpgd, later, rstudioapi |
air, jarl |
r-testthat, r-cmd-check, r-namespace, r-pkgdown |
site (pkgdown) |
r-lock, shiny (opt-in) |
r-namespace, r-document, r-test, r-check, r-pkgdown, r-pkgdown-preview, r-coverage, r-lock, shiny (opt-in); pc-up-<tree> |
r-namespace, r-document, r-test, r-check, r-pkgdown, r-pkg; r-coverage, shiny, r-shiny (opt-in) |
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.
The development shell holds:
- every task as a command, taking arguments (e.g.
nk-write-files --accept <path>); - the
tasksrunner; - language servers and the formatter for editors;
- the plugins' and your
perSystem.nullkomma.devShell.packages.
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.
Runs the formatter and fails on formatting changes or lint errors.
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";
};
};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.
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 exposing lib.brand (a brand.yml), declare its input and select it:
inputs.brand = {
url = "github:example/brand";
inputs.nullkomma.follows = "nullkomma";
};
# Inside mkFlake's module:
nullkomma.brand = inputs.brand;Brand owns the declarations; nullkomma owns their translation and placement. R and Quarto keep their native themes unless their branding feature is enabled. Enable the owning plugin and then its feature:
nullkomma.quarto = {
enable = true;
brand.enable = true;
};
# Or, for an R package:
nullkomma.r = {
enable = true;
pkgdown.brand.enable = true;
};quarto.brand generates _nullkomma-brand.yml; list it in _quarto.yml's metadata-files.
r.pkgdown.brand generates a pkgdown template package and selects it with template.package.
Both use nullkomma.brand, but setting that shared data does not enable either plugin.
An optional defaults.nullkomma.footer record supplies footer facts to both builders:
meta:
name: Example
link:
home: https://example.org
imprint: https://example.org/imprint
email: mailto:[email protected]
defaults:
nullkomma:
footer:
tagline: A short description
links:
- text: Imprint
link: imprint
- text: Privacy
privacy: true
social:
- text: Email
link: email
icon: envelopeEach link names an entry of meta.link; privacy: true links to your
privacy policy, if there is one. icon is a
Bootstrap Icon name. Write plain text only:
Markdown or HTML fails the build.
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.
Technology-specific disclosures belong to the plugin using that technology, not the
brand; the framework's nullkomma.privacy.policy is also usable without either site builder.
For example, cloudflare adds its own sections on Cloudflare hosting and analytics.
With cloudflare, publishes packages.site.
With the r plugin, writes the pkg.lock the development shell needs.
With nullkomma.r.shiny.enable = true, serves the project's Shiny app
at http://127.0.0.1:3838 using the pinned R environment.
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.
tasks all includes formatting and linting through treefmt.
Set skip = true on custom tasks that should not run as part of tasks all.
The pinned up runner does not forward optional arguments; invoke the listed
task as a shell command for those, e.g. preview --port 4200 or check r-cmd-check.
nullkomma.tasks is the workflow definition, not a second build recipe.
Language plugins declare their defaults next to their implementation:
R documentation, tests and pkgdown in modules/lang/r/workflow.nix,
R CMD check in modules/lang/r/checks.nix, Shiny in modules/lang/r/shiny.nix,
and Quarto in modules/lang/quarto/quarto.nix.
Enabling the plugin supplies those tasks and processes; tasks lists them.
A task's after lists tasks whose steps run first, transitively and once each,
wherever it runs: as a command, through tasks, in builds, in watch-<task> and in process trees.
For example, r-test, r-coverage and shiny run after r-namespace;
r-document also runs after r-namespace, while r-pkgdown and r-check
run after r-document. Each therefore sees the generated files it needs,
through the same command you'd run by hand.
Outside process trees, steps run one after another and stop at the first failure.
perSystem.nullkomma.tasks.report = {
description = "Render the report";
after = [ "r-document" ];
working_dir = ""; # checkout root, or the same root in the sandbox
packages = [ pkgs.quarto ];
command = ''
quarto render report.qmd --output-dir "''${NULLKOMMA_OUTPUT:-report}"
'';
output = "report"; # adds packages.report; omit for development-only tasks
watch.outputs = [ "/report" "/report/**" ]; # watch the tree, excluding this output
};This adds report, tasks report, watch-report, pc-up-report and nix build .#report
from one definition. Builds run the same executable and DAG on a writable nullkomma.src,
with NULLKOMMA_ROOT pointing to that copy and NULLKOMMA_OUTPUT to the Nix output.
working_dir is relative to that root; if omitted, tasks keep the caller's directory.
sandboxArgs explicitly selects any necessary build-only behavior: R tests use
--installed in the sandbox, versus pkgload locally. There is no separate build command.
Extend defaults using ordinary flake-parts merging: add a task, append after prerequisites,
or use lib.mkBefore/lib.mkAfter for extra command lines and lib.mkForce to replace a command.
Builds, checks and processes then use the changed task too.
before is also supported. Up schedules the runner; nullkomma expands transitive prerequisites
because the pinned runner otherwise runs only direct prerequisites for a named task.
These watches refresh artifacts derived from the working tree, such as roxygen2's
NAMESPACE and manuals. Files generated from Nix (editor settings, brand assets and
Quarto extensions) are separate: the dev shell refreshes them on entry with
nk-generate-files, and sandboxed builds use nullkomma.src.
Tasks and process trees don't regenerate those files.
Choose the tool's own dependency knowledge before adding another watcher:
watch.native = truerecords that the command already watches and reloads, as Quarto'spreviewdoes. This addspc-up-preview, not a redundantwatch-preview.watch.manifest = ./inputs.txtaccepts a tool-produced, immutable file of newline-separated input paths, relative to the checkout root. It replaces the broad default; the tool must include every dependency. Watch containing directories for atomic saves and new files. Restart the watcher when the manifest changes.- Otherwise,
watch = { };watches the whole checkout, including new and Git-ignored inputs.watch.outputsdeclares output globs excluded throughout that process tree or standalone task's graph;watch.ignoreadds local exclusions. Avoid narrow input allowlists unless the tool guarantees their completeness.
Native watching only covers dependencies the tool knows. Quarto, for example,
does not discover arbitrary files read with io.open in Lua filters. Prefer
declaring shared YAML through metadata-files and reading it from the filter's
metadata; otherwise an external watch must cover those extra inputs.
Do not add private inputs as site resources merely to watch them: resources
are copied into the published site.
The fallback uses watchexec with changes queued while busy and Git/project ignore
discovery disabled. Its built-in ignores still apply, as do .direnv/ and
.nullkomma/; explicitly watched dependency stamps remain observable.
Arbitrary external files, environment or network changes are not discovered.
Generated outputs must be declared to avoid feedback loops.
watch.paths can override the default; paths missing from the flake source are skipped.
watch-<task> runs the task and its after steps initially and on input changes.
Every watched task automatically supplies a process tree containing its dependency graph.
Use process = { }; for a service without any watcher.
process also accepts native up process settings such as readiness_probe and shutdown;
the workflow owns command and merges its dependency edges into depends_on.
Additional groups, perSystem.nullkomma.processTrees.<tree>, keep several tasks current
in the background, as process-compose processes; pc-up-<tree> (also nix run .#processTrees.<system>.<tree>)
starts them with a dashboard showing which currently fail:
perSystem.nullkomma.processTrees.docs = {
description = "Keep the report current";
tasks = [ "report" ]; # plus its `after` tasks
};- Tasks run in
afterorder, independent ones in parallel. - Fallback-watched steps run again on input changes or successful prerequisite reruns; native-watched services handle changes themselves.
- A failing task doesn't stop the tree. Only successful reruns notify dependents, so running dependents keep their last good state until it's fixed.
- Steps that write files (e.g. roxygen2) run once per tree, for every process that needs them;
state of a single R session (e.g. pkgload in
shiny) stays in that process. - Successes are recorded as Git-ignored stamps in
.nullkomma/processes/<tree>/, while each run uses its own process-compose socket in the temporary directory.
Trees don't start with the dev shell (#217);
run pc-up-<tree> in a spare terminal.
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) |
nk-write-files |
.gitignore, GitHub workflows, AGENTS.md sections |
|
nk-generate-files |
.vscode/*.json, .nvim.lua, .agents/skills/*, Quarto brand and extensions |
Templates can't take parameters, so anything that depends on your configuration
comes from nk-write-files or nk-generate-files.
nk-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, nk-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 nullkomma-resolve-file-conflicts skill.
After resolving, nk-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):
nk-write-files replaces only those, wherever you moved them.
Once nullkomma no longer declares a file (say, you disabled its plugin),
nk-write-files strips its sections, deletes it if unedited, or keeps your edited version and says so.
nk-generate-files overwrites these (mode = "generated") when they differ from Nix,
starts them with a "do not edit" comment, and the git plugin ignores them.
They follow your Nix configuration, not your working tree:
entering the dev shell runs nk-generate-files if they changed, while tasks such as preview never do.
direnv re-enters when flake.nix or flake.lock change (say, after update);
if local Nix modules declare files too, add e.g. watch_dir nix to your .envrc.
To change one, change the Nix options.
Directories (directory = true) are copied whole, without the comment.
nk-generate-files refuses to overwrite a file tracked by Git,
and removes files it generated before that are no longer declared.
Coding agents can resolve conflicts with the
nullkomma-resolve-file-conflicts skill,
which the agents plugin writes into 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.
Instructs coding agents, through two kinds of files:
- An
agentssection inAGENTS.md, written bynk-write-filesand committed: run commands in the dev shell, and runtasks allbefore reporting a change as done. The rest ofAGENTS.mdis yours. - Agent Skills in
.agents/skills/<name>, generated from Nix, so they always match yourflake.lock. Plugins and you add them with the top-levelnullkomma.agents.skills.<name>, a directory with aSKILL.md; nullkomma's own are namednullkomma-*, for examplenullkomma-upgradefrom thenixplugin.
Projects that don't use nullkomma yet can install this repository as an
Agent Plugin with the adopt-nullkomma skill,
for example with /plugin install https://github.com/dataheld/nullkomma in Copilot.
Writes .github/workflows/push.yml, a stub calling nullkomma's reusable CI workflow,
which runs nix flake check on each supported system.
After successful checks, pushes to the default branch and tags publish to FlakeHub:
Flake visibility always matches the repository: public repositories publish public, listed flakes;
private repositories publish private flakes. There is no visibility override.
Forks do not publish. Private flakes require a FlakeHub paid plan.
Remove any existing nullkomma.github.ci.inputs settings and with.visibility workflow arguments.
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/, _site/ and Quarto's generated .luarc.json.
Shared Quarto extensions, e.g. from a brand flake, go in nullkomma.quarto.extensions.<name>,
generated into _extensions/<name>.
The privacy policy is generated into privacy.qmd.
For a documentation site, enable nullkomma.quarto.enable = true and select
nkdocs, a Quarto extension nullkomma generates, as the project type in your _quarto.yml:
project:
type: nkdocs
website:
title: My project
repo-url: https://github.com/example/projectnkdocs only switches on stock Quarto features and knows nothing of brands;
configure everything else in _quarto.yml as usual,
e.g. add pages to website.sidebar.contents. Set repo-url for repository actions.
The optional nullkomma.quarto.brand.enable feature styles websites, nkdocs or plain website, through generated
_nullkomma-brand.yml: branded light/dark themes, navbar, footer and privacy link,
on the standard Quarto theme rather than a separate Bootswatch theme.
Mermaid diagrams automatically use the brand's Bootstrap colors in both modes,
with distinct subgraph backgrounds and readable labels. Explicit Mermaid themes
(e.g. format.html.mermaid.theme: forest) keep their own colors.
All generated paths are relative to nullkomma.quarto.root, including nested docs
directories; they are the same files packages.site builds with.
The build task renders the working tree into Quarto's local output directory.
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;
domain = "example.com";
# name = "other-name"; # Override the repository-derived subdomain and Worker name.
# worker = "existing-worker"; # Override only the Worker and Terraform state identity.
};For a repository named foo, this publishes https://foo.example.com, including
DNS and HTTPS certificates managed by Cloudflare. domain is the name of an
existing active Cloudflare zone in your account.
Without name, deployment uses the repository name from GITHUB_REPOSITORY
(GitHub Actions), or the local Git origin remote (HTTPS or SSH, without .git).
The plugin only deploys to subdomains. It leaves the parent domain (for example,
example.com) and redirects alone. To serve www.example.com, set
domain = "example.com" and name = "www".
Cloudflare creates the subdomain's DNS record and HTTPS certificate as part of deployment; no Zone ID or separate DNS setup is needed.
If Cloudflare reports an existing CNAME DNS record at an address you want to use, remove that record only if it no longer serves another site, then deploy again.
The old domains option is no longer accepted because it also allowed deploying
to the parent domain. Existing users must review their Terraform state migration
before switching to domain and name; do not simply remove the old setting and
apply, as the custom-domain resource address has changed.
Terraform state remains keyed as <worker>.tfstate in the shared R2 bucket.
Existing explicit Worker names and state keys are unchanged. Pin name before
renaming a repository to keep its inferred hostname and Worker/state identity;
pin worker separately if it differs. Worker names must be unique within the
account, and state keys within the bucket, including across repositories with
the same name. Changing these settings does not automatically migrate state.
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 }}
# R2 API token: Object Read & Write on the state bucket only
CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
CLOUDFLARE_R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}The plugin maps the R2 credentials internally to the AWS_* environment names
required by OpenTofu's S3-compatible backend; no AWS account is involved.
Use the same Cloudflare environment names for local deployment.
One-time setup for this repository:
- Allow this repo access to the existing
CLOUDFLARE_API_TOKENand bothCLOUDFLARE_R2_*organization secrets. - Set
CLOUDFLARE_ACCOUNT_IDandCLOUDFLARE_STATE_BUCKETActions variables to the existing account and private state bucket. - Ensure the existing API token has Account / Workers Scripts / Edit; Custom Domains need no separate DNS token. Keep the R2 token scoped to Object Read & Write on the state bucket.
The parent zone must already be active in that account. This repo deploys assets
and DNS only after successful CI on pushes to main, never from PRs or tags.
- 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
nk-write-filesand commit the changes; runnk-generate-filesfor editor configuration.
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's non-Nix collaboration feature 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, httpgd, later, rstudioapi 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, build by default from their
lockfile-pinned sources against nixpkgs' versions of their dependencies.
This broadens the build surface beyond packages provided by nixpkgs-r.
Set nullkomma.r.allowOffNixpkgs = false to require packages from nixpkgs-r only.
Git sources use the locked commit; URL sources and CRAN packages missing from
nixpkgs-r 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.
tasks describes the available workflows and lists the checks.
Disable optional checks and site builds with nullkomma.r.{check,testthat,pkgdown}.enable.
r-test, r-coverage and shiny regenerate NAMESPACE first.
r-pkgdown and r-check regenerate documentation and NAMESPACE first.
pc-up-r-pkg keeps documentation, tests, the pkgdown site and R CMD check current while you work,
and pc-up-r-shiny (with the Shiny app) also serves the app, reloading it in the browser
whenever NAMESPACE is regenerated (see Keeping generated files current).
Generated man/*.Rd files are Git-ignored, but not man/ itself; commit NAMESPACE.
The fallback broadly watches the checkout through r-namespace and r-document;
downstream R steps follow their successful reruns rather than racing roxygen2.
Known outputs are excluded:
NAMESPACE, man/*.Rd, compiler products, coverage and the pkgdown destination.
This follows the plugin's convention that man/*.Rd are generated; for handwritten
manuals, explicitly add their paths to r-document.watch.paths (explicit file watches
override output ignores). Files such as man/figures/ remain inputs.
Declare any extra outputs written by custom roclets or project code with watch.outputs.
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, and r-pkgdown-preview also opens it.
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.
_pkgdown.ymloverrides template packages, e.g. R's branding feature's, whichnullkomma.r.pkgdown.librariesprovides;nullkomma.r.pkgdown.configis merged over both.nullkomma.r.pkgdown.config.destinationdefaults to"docs"and overrides a YAML destination, so local builds and watcher exclusions agree. Set custom destinations here rather than in_pkgdown.yml, as a normalized package-relative directory (no leading slash,.or..components).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 published at
/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.
The shiny task instead loads the working-tree package with pkgload, without installing it.
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.nonNix.enable = true alongside nullkomma.r.enable = true,
run nk-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.
Nullkomma always provides two self-documenting entrypoints:
- The consumer's flake outputs. These are already self-documenting.
tasksexpose a selection of useful builds and checks. This does not mean thattasksaliases every subcommand and flag. As a heuristic, if some command is run to produce a flake output, it is also exposed as atasks. In addition,tasksinclude important development-only workflows, especially previews.
As much as possible, the flake outputs and tasks reuse implementation,
to make interactive development work in the devShell as close as possible to the flake outputs.
Exceptions include:
- Tasks run tools directly on the working tree, rather than recursively invoking Nix builds for speed and simplicity.
- Tasks run on the uncommitted working tree for faster turnaround.
- Sandboxed builds and checks use
nullkomma.src, a writable copy of the flake's source with Nix-generated files. - R tests use pkgload locally and install the package in the sandbox; Shiny's published app also uses an installed package without development dependencies.
The non-Nix R compatibility layer necessarily documents its own bootstrap.
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.site |
packages.site |
build-site / r-pkgdown |
apps.<name> |
apps.<name> |
plugin-specific |
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 nk-write-files and nk-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.
Features within a plugin may use its options directly. Separate modules contribute to
the same plugins.r or plugins.quarto: non-Nix collaboration, pkgdown branding and
Quarto branding are isolated implementations, not peer plugins. Their options are always
available when their owner is imported; their effects require both the owner and feature
to be enabled. pkgdown branding also requires pkgdown to be enabled.
nkdocs is a bundled Quarto extension selected by native project.type: nkdocs, not a second Nix switch.
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's own website is this README, built as a quarto nkdocs
site into packages.site styled by Quarto's branding feature with Max Held's brand and
privacy policy. tasks lists how to preview and check it.
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. - Legal tests:
site-legalandr-pkgdown-legalbuild this site and a pkgdown fixture with the real brand, and check that every page links an imprint and the privacy policy, which covers GDPR Art. 13 in English and German and every footer email address. - 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.
New 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.
refresh-vendored refreshes nullkomma's vendored third-party assets and dependency
manifests. Currently it runs refresh-vendored-pkgdown, which regenerates
modules/lang/r/_vendored-pkgdown.json from the pinned pkgdown package's own
metadata. Nix fetches the assets using that manifest, so sandboxed site builds
need no CDN access; the asset files are not committed to this repository.
checks.vendored-pkgdown detects a stale manifest after a pkgdown upgrade.
Add another refresh task through the existing task DAG, not a separate registry:
perSystem.nullkomma.tasks.refresh-vendored-example = {
description = "Refresh vendored Example assets";
before = [ "refresh-vendored" ];
working_dir = ".";
command = "...";
};These are nullkomma's own maintenance tasks, not tasks added to consumer projects.
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. β© -
up process trees, each also the
pc-up-<tree>task; see Keeping generated files current. β© -
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. β©