A CLI tool for managing Linux configuration through git-backed profiles.
- Presence = managed: every
modules/<id>/module.tomlis selected; no enable list. - Apply always resumes:
dotdrift applycontinues from the first incomplete step. - Mise owns files: symlink, copy, template, and partial-edit operations are performed by mise.
- Selection precedence: module → host → user; user wins,
disableis unioned across layers.
Run the installer with curl | bash. It resolves the latest release, verifies the SHA-256 checksum, and installs the binary to ~/.local/bin:
curl -fsSL https://raw.githubusercontent.com/thedataflows/dotdrift/main/install.sh | bashPin a specific release, or install somewhere else:
curl -fsSL https://raw.githubusercontent.com/thedataflows/dotdrift/main/install.sh | bash -s v0.4.0
curl -fsSL https://raw.githubusercontent.com/thedataflows/dotdrift/main/install.sh | DOTDRIFT_BINDIR=/usr/local/bin bashFrom a clone, run it directly: ./install.sh [version]. It needs curl, tar, and sha256sum, and supports Linux on amd64/arm64. Each release ships prebuilt binaries with an SHA-256 checksum.
With Go 1.26 or newer:
go install github.com/thedataflows/dotdrift@latestdotdrift requires mise ≥ 2026.8.2 on PATH (needed for mise bootstrap declarative convergence). When missing or too old, it installs a user-local copy via https://mise.run.
# Create a profile
dotdrift init ./my-profile
cd ./my-profile
# Onboard an existing config path into a module
dotdrift onboard ~/.bashrc
# See what would change
dotdrift plan
# Apply the profile (always resumes)
dotdrift apply --yes
# Show current state
dotdrift statusprofile/
├── dotdrift.toml
├── modules/
│ └── <id>/
│ ├── module.toml
│ └── home/... # files referenced by dotfile entries
├── hosts/<hostname>/
│ ├── dotdrift.toml # host layer: disable list unioned
│ └── modules/<id>/... # host overlays
└── users/<username>/
├── dotdrift.toml # user layer: disable list unioned
└── modules/<id>/... # user overlays (highest precedence)
Each layer's dotdrift.toml may carry a [modules] disable list. Disables are unioned across the base, host, and user layers.
module.toml is validated strictly: an unknown key (typo, misplaced table) fails every command at load with <path>/module.toml:<line>: unknown key "<key>", never a silent zero value. The full attribute set is documented in profile layout.
A module may declare shell commands to run around the apply pipeline:
# modules/<id>/module.toml
[hooks]
pre = ["echo about to apply"]
post = ["systemctl --user daemon-reload"]- When they run —
preruns as thehooks-prestep, before packages are installed;postruns ashooks-post, after dotfiles. - How they run — commands execute as mise tasks from the profile root, with
DOTDRIFT_PROFILE,DOTDRIFT_HOSTNAME,DOTDRIFT_USERNAME,DOTDRIFT_OS, andDOTDRIFT_BACKENDin the environment. - Interactive commands — when
dotdrift applyhas a terminal on stdin, hook tasks are generated withinteractive = true, so an interactive command in a hook (e.g.sudo) connects to the terminal and can disable echo on its password prompt instead of echoing it. With no terminal (piped/CI), hooks run normally. - How they merge — unlike other sections, hooks are appended across layers (base → host → user) and modules, in selection order.
- Failure and resume — a failing hook fails its step, and resume re-runs it. Write
posthooks to be idempotent. - Visibility — hooks are listed in
dotdrift plan. Skip them withdotdrift apply --no-hooksorDOTDRIFT_NO_HOOKS=1.
A module applies to the home directory by default. Set scope = "system" in its module.toml to manage system dotfiles (e.g. targets under /etc):
# modules/<id>/module.toml
scope = "system"
[dotfiles]
"/etc/demo.conf" = { source = "demo.conf", mode = "copy" }A single dotdrift apply covers both scopes. User dotfiles apply as usual; system dotfiles converge in a dotfiles-system step that pre-checks whether every system target is user-writable, and elevates via sudo when any target is not writable.
- At most one password prompt per apply (sudo timestamp cache) — none at all when already running as root or when the system files are already converged.
- System-scope entries are marked
[system]indotdrift plan. - Packages self-elevate via the distro backend, and hooks carry their own inline privilege, so neither needs scope machinery.
A [dotfiles] entry can be a partial edit to a file something else owns — keyed by <file-path>/<edit-id> (the last slash splits file path from edit id). Four forms:
# modules/<id>/module.toml
[dotfiles]
# Ensure an exact line exists (idempotent)
"~/.profile/mise" = { line = 'eval "$(mise activate bash)"' }
# A marker-delimited block, re-applied in place; comment is optional (default #)
"~/.zshrc/aliases" = { block = "alias ll='ls -l'", comment = "#" }
# A block loaded from a source file (raw contents, no rendering).
# Avoids multi-line inline content in module.toml.
"~/.zshrc/aliases" = { source = "aliases.sh", mode = "edit" }
# A rendered block (mise renders the template at apply time)
"~/.gitconfig/user" = { source = "git-user.tmpl", template = "tera" }lineensures an exact line exists in the file.blockwraps content in mise marker delimiters (>>> mise:<id> >>>/<<< mise:<id> <<<); re-apply updates the block in place.commentsets the comment prefix.source+mode = "edit"reads the source file's raw contents and uses them as a block (dotdrift convenience — keeps multi-line snippets out ofmodule.toml; resolved across layers at plan time).source+templaterenders the source via the named engine (e.g.tera) and inserts it as a block (mise renders at apply time).- Either scope — like whole-file entries. User-scope edits apply as the invoking user; system-scope edits (e.g.
/etc/hosts/dev) apply via elevatedmise dotfiles apply(sudo). statusdrift —lineis checked by exact match,block(includingmode = "edit") by content between markers,templateby marker presence only (rendering needs mise's engine).
See docs/product/profile-layout.md for the full validation rules and examples/simple/ for a worked block-edit example.
dotdrift generate renders a derived module from declarations in module.toml:
generate mounts— writes a system-scoped module holding systemd.mountunits (plus.service/.timerwhen--startatis set).generate smb— writes a module holdingshares.confand a one-timesmb.confseed.
Mounts and shares are declared as [mounts.<name>] and [smb] / [smb.shares.<name>] tables in the module's module.toml, merged whole-entry by name across layers. The rendered files are derived artifacts.
- Interactive vs strict mode — on a terminal with no input flags, an interactive wizard runs (byte-identical result to the flag mode); with any input flag, strict CLI mode applies.
- Layer selection —
--layer base|host|userpicks where the module lands. A host-layer-only module needs no base stub. - Batching — one CLI run writes one mount, since the
[mounts]section is replaced wholesale. Use the wizard's "add another mount?" loop, or one--moduleper mount, for several mounts.--shareis repeatable. - Activation — the regular apply pipeline places the files via mise and activates them in the conditional
mounts/smbsteps (unit enablement, timers, samba group/users/service).
See docs/product/cli-surface.md for the full flag reference, and docs/product/migrate-pimp-my-cachyos.md for a worked migration. See examples/simple/ for a minimal single-module profile, and examples/profile/ for a multi-layer example with host and user overlays.
Note:
dotdrift applystores the resume cursor while applying (and generated mise config) under the XDG state directory ($XDG_STATE_HOME/dotdrift/, defaulting to~/.local/state/dotdrift/), so the profile directory is never polluted with runtime state. The cursor file is deleted on completion, so a successful apply leaves no state file.dotdrift onboarddoes the same (.../profiles/<hash>/onboard/mise.toml); pass--yesto answer mise prompts non-interactively.
sudo warning:
dotdriftresolves the username from the OS account, not$USER. Runningsudo dotdrift applyselects root's overlays and writes into root'sHOME. To manage your own dotfiles, rundotdriftas your normal user; usesudoonly if you intentionally maintain ausers/root/overlay.
| Command | Purpose |
|---|---|
dotdrift init [path|git-url] |
Create a new profile (git-initialized) or clone a profile repo. |
dotdrift detect |
Print host/user/os/kernel/distro/gpu/backend facts. |
dotdrift modules [modules...] |
List selected and skipped modules (optionally limited to the listed modules). |
dotdrift plan [--json] [modules...] |
Print the effective plan without side effects (--json for machine-readable output; optionally limited to the listed modules). |
dotdrift apply [--yes] [--no-hooks] [--verbose] [--diff[=tool]] [modules...] |
Run the full pipeline and resume from the last successful step (the cursor file is deleted on completion; optionally limited to the listed modules). --diff shows colored unified diffs for differing copy-mode dotfiles before applying; bare = internal diff, --diff=delta uses the named tool with <target> <source> args. |
dotdrift status [-v] [-j N] [--diff[=tool]] [modules...] |
Show drift between the profile and the live system (packages, tools, dotfiles, mounts, smb), plus the resume cursor. Probes run concurrently (-j N workers; default: CPUs); -v streams per-probe progress to stderr; --diff shows colored unified diffs after the report. Exits 0 even when drift is found. |
dotdrift onboard [--verbose] <path>... |
Copy live paths into a module and apply; re-running updates the module (refresh files, merge module.toml). |
dotdrift generate mounts|smb |
Generate a mounts module (systemd units) or smb module (samba shares) into a profile layer; interactive wizard on a terminal, strict flag mode otherwise. |
-v / --verbose (also DD_VERBOSE=1) streams package manager and mise output live on apply and onboard, echoing each command line set -x-style to stderr immediately before it runs — e.g. + paru -S --needed --noconfirm jq — and runs mise itself in verbose mode (MISE_VERBOSE=1) so its DEBUG logging streams alongside; without it child-process output is captured and only surfaced in errors.
--no-color (or the NO_COLOR environment variable, per no-color.org) disables ANSI colors in all dotdrift output and propagates NO_COLOR=1 to child processes (mise, paru). On a TTY, the status report colors findings by issue type (orange = missing, red = not-a-symlink/unknown, yellow = content/version diff, green = all-OK) and diffs are colored (green additions, red deletions); piped output is always plain.
# Generate an NFS mount module with a nightly timer, then two samba shares
dotdrift generate mounts --name syn01 --source nas:/volume1/syn01 \
--destination /mnt/synology/syn01 --type nfs --startat "*-*-* 18:05:00"
dotdrift generate smb --share media=/srv/media --share data=/mnt/data --no-avahimodules, plan, apply, and status accept optional positional module ids that limit the command's scope to those modules. Ids are space- or comma-separated (both forms mix freely):
dotdrift apply vim git # only vim and git
dotdrift apply vim,git # same
dotdrift plan shell --json # plan for shell only
dotdrift status vim # drift for vim only
With no ids the behavior is unchanged.
- **Unknown id** — a loud error naming the unknown ids and listing the valid module ids.
- **Selected but skipped** — naming a module that exists but is not selected (disabled via `[modules] disable` or excluded by a `when` filter) is also an error, naming it and its skip reason. The filter never resurrects skipped modules.
> **Resume caveat:** the resume cursor is a bare step name. A filtered apply that resumes from another run's cursor cannot detect the scope change, so it may skip steps the filtered run would have executed — delete the state file (path shown by `dotdrift status`) to force a full run.
## Testing
```bash
go test ./...Unit tests run offline. Integration tests against real tools can be added with //go:build integration.
./tests/e2e/run.shBuilds and runs a Docker end-to-end suite across three distros — the debian family (debian:bookworm-slim, ubuntu:24.04) and CachyOS (cachyos/cachyos). It requires Docker and network access.
Each container builds dotdrift from this repo, onboards a live file with a real mise.run bootstrap, and runs dotdrift apply against a fixture profile. The suite then asserts:
- a real package install (
apton the debian family,pacman/paruon CachyOS) of a leaf package (jq); - dotfile symlinking;
pre/posthooks executed as mise tasks;- a second apply runs the full pipeline again;
- no state file left on disk; and
- no runtime pollution inside the profile.
The CachyOS image is the first coverage of dotdrift's Arch backend (paru -S install + pacman -Q idempotency); it ships paru from the CachyOS binary repo, so no AUR build is needed. The suite runs on push to main via .github/workflows/e2e.yml; the offline go test ./... gate is unchanged.