dotfiles
Declarative dotfiles management for Ward scripts.
A dotfiles definition is an ordered list of explicit step records.
Ordering is always preserved because steps is a plain array.
Design goals:
- Deterministic: steps run in the exact order you provide.
- Safe by default: existing destinations are not modified unless you pass
force = true. - Best-effort revert: changes are recorded in a manifest under the target root.
Quick start
local dotfiles = require("wardlib.tools.dotfiles")
local tpl = require("ward.template.minijinja").render
local username = "alex"
local def = dotfiles.define("My dotfiles", {
description = "Minimal personal config",
steps = {
dotfiles.content(
".config/git/config",
tpl(
[[
[user]
name = {{ username }}
]],
{ username = username }
)
),
dotfiles.link(
".config/fish",
"~/.dotfiles/fish",
{ recursive = true }
),
dotfiles.assert(function(base)
-- guardrails
return base ~= "/"
end, "refusing to apply dotfiles into /"),
},
})
def:apply("/home/alex", { force = true })
-- def:revert("/home/alex")
Definitions
Create a definition with:
local dotfiles = require("wardlib.tools.dotfiles")
local def = dotfiles.define("Preset name", {
description = "Optional description",
defaults = { ... }, -- reserved for future use
steps = { ... }, -- required; must be a non-empty array
})
Notes:
nameis used for identification in the manifest.descriptionis recorded in the manifest; it does not affect behavior.defaultsis accepted and stored on the definition, but is not currently used by the engine.stepsmust be a non-empty array. Only step records (created by the functions below) and nested definitions are allowed.
Path rules
For any destination rel_path:
- Must be relative (no leading
/,\, orC:\...). - Must not contain parent traversal (
..). - Path separators are normalized internally.
For any source path in dotfiles.link():
~and~/...are expanded using$HOMEwhen available.- Both absolute and relative paths are allowed.
Conditional execution (common option)
Most step constructors accept opts and support conditional execution:
opts.when(base) -> booleanoropts.conditions(base) -> boolean
If provided and returns false, the step is skipped.
If the function errors, the apply operation errors.
base is the root path you passed to def:apply(base, ...).
Steps reference
dotfiles.content(rel_path, content, opts?)
Writes file content to base/rel_path.
-
contentmay be:- a
string - a function
(base, abs) -> string
- a
Overwrite behavior:
-
If destination does not exist: it is created (parent directories are created automatically).
-
If destination is a directory: the step errors.
-
If destination exists:
- without
force=trueondef:apply, the step errors - with
force=true, an existing file is overwritten; an existing symlink is unlinked first
- without
The tool never writes through an existing destination symlink.
There are no dotfiles-specific file mode/permissions options; use your existing
Ward FS tooling in a custom step if you need chmod/chown.
dotfiles.link(rel_path, source, opts?)
Creates a symlink at base/rel_path pointing to source.
Options:
-
recursive = true- If
sourceis a directory, the destination is ensured to be a directory. - The tool walks the source tree and creates symlinks for all non-directory entries.
- Intermediate directories are created under the destination.
- Source directory entries are processed in sorted order for determinism.
- If
Replacement policy flags (only relevant with def:apply(..., { force = true })):
-
replace_file(defaulttrue)- Allows replacing an existing regular file at the destination.
-
replace_symlink(defaulttrue)- Allows replacing an existing symlink at the destination.
-
replace_dir(defaultfalse)- Allows replacing an existing directory at the destination, but only if the directory is empty.
Notes:
- Without
force=true, any existing destination causes an error. - Destination symlinks are never followed. In force mode, an existing symlink is removed before creating the new link.
- If
recursive=true, directories in the source that are symlinks are treated as non-directories (a symlink is created for them).
dotfiles.custom(rel_path_or_nil, fn, opts?)
Runs custom logic.
Signature:
- If
rel_path_or_nilis a string,fn(base, abs)is called whereabs = base/rel_path_or_nil. - If
rel_path_or_nilisnil,fn(base, nil)is called.
Return values:
-
nil- Treated as an imperative step. It is recorded in the manifest as
kind = "exec"and is not revertable.
- Treated as an imperative step. It is recorded in the manifest as
-
string- Only valid when a path was provided. The string is treated as file content
and written to
abs(using the same overwrite rules asdotfiles.content).
- Only valid when a path was provided. The string is treated as file content
and written to
-
DotfilesDefinition- Applied as a nested definition.
-
table- If it has
steps = {...}: treated as a meta table and converted to a definition. - Otherwise: treated as a
steps[]array.
- If it has
Nested application rules:
- If a path was provided: nested steps are applied under that path (i.e.
absbecomes the nested base). - If no path was provided: nested steps are applied under
base.
dotfiles.include(prefix_or_nil, def_or_meta, opts?)
Includes another definition under an optional prefix.
-
def_or_metacan be:- a
DotfilesDefinitionreturned bydotfiles.define() - a meta table
{ name?, description?, defaults?, steps = {...} }
- a
-
If
prefix_or_nilis non-nil, the included definition is applied underbase/prefix_or_nil.
dotfiles.group(name, steps, opts?)
Pure organizational wrapper that preserves ordering.
stepsis a steps array.opts.when/conditionscan be used to gate the entire group.- The group name is for readability in code; it is not currently persisted in the manifest.
dotfiles.assert(predicate, message, opts?)
Fail-fast precondition check.
predicate(base) -> boolean- If it returns false, the apply operation errors with
message. - Assertions are recorded in the manifest but are not reverted.
Applying and reverting
def:apply(base, opts?)
Applies the definition into base.
Options:
-
force = true- Allows replacing existing files/symlinks and (with
replace_dir=true) empty directories. - Without
force=true, any destination that already exists causes an error.
- Allows replacing existing files/symlinks and (with
-
manifest_path = "..."- Override the manifest location.
- Default:
<base>/.ward/dotfiles-manifest.json.
Side effects and caveats:
- Parent directories are created automatically using
ward.fs.mkdir(..., { recursive = true }). - The dotfiles tool does not attempt atomic writes or compare content hashes; a write in force mode always overwrites the file.
- There is no built-in backup strategy.
Return value:
- Returns the manifest table that was written.
def:revert(base, opts?)
Reverts using the stored manifest.
Options:
manifest_path = "..."(must match what was used on apply).
Revert rules:
-
Only manifest entries that have
{ path = ..., prev = ... }are reverted.exec(imperative custom) andassertentries are not reverted.
-
If a path previously did not exist, revert removes it.
- Directories are removed only if they are empty.
-
If a path previously was a symlink, revert restores the previous symlink target.
-
If a path previously was a file, revert restores the previous content.
-
If a path previously was a directory, revert ensures the directory exists.
At the end of revert, the manifest file is removed. The <base>/.ward
directory is removed only if it becomes empty.
Manifest
By default the manifest is written to:
<base>/.ward/dotfiles-manifest.json
The manifest contains:
- definition metadata (
name, optionaldescription) applied_attimestampbase- a list of
entriesin apply order
Each revertable entry records a snapshot of the previous state:
absentfilewith previous contentsymlinkwith previous targetdir
Limitations
- Revert is best-effort and intentionally conservative; it does not delete non-empty directories.
- Recursive linking can create many manifest entries (one per created directory and one per linked leaf).
- Destination conflicts are treated as errors; there is no “plan” or “diff” mode yet.