niwaniwa0.1.2
saru-id/niwa
niwaniwa

The config and its modules

Structure comes from the file layout, not from an API. What each directory is for, why modules are also groups, and why there is no profile system.

The config is a directory of files, and the layout is the only structure there is. No manifest declares what a module is, and no API call groups resources. Where a file sits is what it means.

The layout

niwa init does not hand you an empty file. It scans the machine, then writes a config that already describes it, with the sections you have not filled in yet labelled and commented. The whole skeleton exists from the first run, hosts/ included, because splitting that out later means rewriting files that already work.

The structure below is the example config the tool's own tests run against. state/ is the one directory init does not write; the first apply writes the first stamp.

~/.config/niwa

init.luau
.luaurc
niwa.lock
modules/
apps.luau
cli.luau
desktop.luau
dev.luau
inbox.luau
services.luau
shell.luau
system.luau
hosts/
airborne.luau
workhorse.luau
files/
bin/
notes-sync
repo-backup
nvim/
init.lua
ghostty.conf
starship.toml
zshrc
secrets/
github-token.age
state/
airborne.toml
workhorse.toml
EntryWhat it is for
init.luauThe entry point. It requires each module in turn, and the order of those lines is execution order.
.luaurcThe editor and analyzer settings, generated by init.
niwa.lockWhat every version-resolved declaration resolved to. Committed.
modules/The declarations, split by subject. Each file is also a group.
hosts/One file per machine, loaded last, for the differences between them.
files/Sources for niwa.file and niwa.link, referenced as @self/files/….
secrets/Sealed secrets, and the escrowed sealing key. Ciphertext only.
state/One committed stamp per machine: what it applied, and when.

The starter config writes eight modules: cli, apps, shell, dev, desktop, system, services, and inbox. The names are a starting point, not a schema. Rename them, split them, or add your own, and the only line that has to know is the require in init.luau.

inbox.luau is worth naming. An accepted proposal lands there when no other module is a clear match, and it is a permanent home rather than a queue. Moving a line out of it later is optional.

The entry point, and why niwa.host() exists

init.luau requires each module by a literal path, so the analyzer can resolve every one of them before anything runs. That is also why the last line is a call and not another require:

luau
niwa.host() -- hosts/<this machine>.luau, if it exists, loaded last

A machine's own file has a name known only at run time, which a static require cannot express. niwa.host() performs that lookup through niwa's own resolver, so the dynamic case stays typed and the static case stays analysable.

Modules are also groups

Because a module is a file, the tool can talk about it. On a converged machine niwa plan -v prints one count per module, and -vv lists every resource under the module that declared it. niwa apply --only desktop runs one module and leaves the rest as they stand. A module is a unit of reading, a unit of reporting, and a unit of running, and you got all three by naming a file.

Modules declare, hosts override

A host file overriding a module is the supported mechanism for per-machine variance: later declaration wins, merged per key. Two modules setting one key to different values is a lint error with both source locations, because there the order is an accident. Many machines has the worked pair.

Why there is no profile system

The config is a program, so the composition story is already answered.

luau
local function tool(name: string, config: string?)
  local formula = niwa.brew.formula(name)
  if config then
    niwa.file(`~/.config/{name}/{config}`, { source = `@self/files/{config}` })
  end
  return formula
end

tool("starship", "starship.toml")
tool("btop")

That function is a bundle. A module is a role. A shared recipe is a file that returns a function. A bundle primitive, a profile system, and a recipe format would be three concepts covering what one already covers, each with its own syntax, its own error messages, and its own page here.

Results pass through untouched, so tool("neovim").changed branches exactly like the call it wraps. Sugar you write and sugar niwa ships are the same kind of thing, which is why the built-in surface stays small.

Next

View as markdown

↑↓ navigate · enter opens · esc closes