dotdrift

command module
v0.21.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 13, 2026 License: MIT Imports: 4 Imported by: 0

README

dotdrift

A CLI tool for managing Linux configuration through git-backed profiles.

Principles

  • Presence = managed: every modules/<id>/module.toml is selected; no enable list.
  • Apply always resumes: dotdrift apply continues 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, disable is unioned across layers.

Installation

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 | bash

Pin 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 bash

From 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.

From source

With Go 1.26 or newer:

go install github.com/thedataflows/dotdrift@latest
Runtime dependency

dotdrift 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.

Quick start

# 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 status

Profile layout

profile/
├── 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.

Hooks

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 runpre runs as the hooks-pre step, before packages are installed; post runs as hooks-post, after dotfiles.
  • How they run — commands execute as mise tasks from the profile root, with DOTDRIFT_PROFILE, DOTDRIFT_HOSTNAME, DOTDRIFT_USERNAME, DOTDRIFT_OS, and DOTDRIFT_BACKEND in the environment.
  • Interactive commands — when dotdrift apply has a terminal on stdin, hook tasks are generated with interactive = 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 post hooks to be idempotent.
  • Visibility — hooks are listed in dotdrift plan. Skip them with dotdrift apply --no-hooks or DOTDRIFT_NO_HOOKS=1.

Scope

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] in dotdrift plan.
  • Packages self-elevate via the distro backend, and hooks carry their own inline privilege, so neither needs scope machinery.

Edit entries

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" }
  • line ensures an exact line exists in the file.
  • block wraps content in mise marker delimiters (>>> mise:<id> >>> / <<< mise:<id> <<<); re-apply updates the block in place. comment sets 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 of module.toml; resolved across layers at plan time).
  • source + template renders 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 elevated mise dotfiles apply (sudo).
  • status driftline is checked by exact match, block (including mode = "edit") by content between markers, template by 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.

Generate

dotdrift generate renders a derived module from declarations in module.toml:

  • generate mounts — writes a system-scoped module holding systemd .mount units (plus .service/.timer when --startat is set).
  • generate smb — writes a module holding shares.conf and a one-time smb.conf seed.

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|user picks 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 --module per mount, for several mounts. --share is repeatable.
  • Activation — the regular apply pipeline places the files via mise and activates them in the conditional mounts / smb steps (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 apply stores 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 onboard does the same (.../profiles/<hash>/onboard/mise.toml); pass --yes to answer mise prompts non-interactively.

sudo warning: dotdrift resolves the username from the OS account, not $USER. Running sudo dotdrift apply selects root's overlays and writes into root's HOME. To manage your own dotfiles, run dotdrift as your normal user; use sudo only if you intentionally maintain a users/root/ overlay.

Commands

Command Purpose
dotdrift init [path&#124;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&#124;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-avahi

Module filter

modules, 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.

Integration tests
./tests/e2e/run.sh

Builds 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 (apt on the debian family, pacman/paru on CachyOS) of a leaf package (jq);
  • dotfile symlinking;
  • pre/post hooks 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.


License

MIT License

Documentation

The Go Gopher

There is no documentation for this package.

Directories

Path Synopsis
Package cmd implements the dotdrift command-line interface and its subcommands.
Package cmd implements the dotdrift command-line interface and its subcommands.
internal
apply
Package apply orchestrates the dotdrift pipeline with always-resume semantics.
Package apply orchestrates the dotdrift pipeline with always-resume semantics.
detect
Package detect gathers system facts.
Package detect gathers system facts.
drift
Package drift compares a resolved plan against the live system.
Package drift compares a resolved plan against the live system.
executil
Package executil holds tiny helpers shared by the exec runners.
Package executil holds tiny helpers shared by the exec runners.
facts
Package facts holds the system facts used for module selection.
Package facts holds the system facts used for module selection.
generate
Package generate renders systemd units from dotdrift configuration.
Package generate renders systemd units from dotdrift configuration.
mise
Package mise: bootstrap config translation — emits [bootstrap.*] sections for mise bootstrap convergence.
Package mise: bootstrap config translation — emits [bootstrap.*] sections for mise bootstrap convergence.
onboard
Package onboard materializes live paths into a module.
Package onboard materializes live paths into a module.
packages
Package packages provides package backend operations.
Package packages provides package backend operations.
paru
Package paru implements the dotdrift mise package-plugin backend for the `paru` manager (pacman + AUR).
Package paru implements the dotdrift mise package-plugin backend for the `paru` manager (pacman + AUR).
profile
Package profile loads and selects modules from a profile.
Package profile loads and selects modules from a profile.
resolve
Package resolve merges profile layers into an execution plan.
Package resolve merges profile layers into an execution plan.
smb
Package smb implements the apply step that activates the Samba server: group and user membership, avahi, config validation, service enablement, and samba account passwords.
Package smb implements the apply step that activates the Samba server: group and user membership, avahi, config validation, service enablement, and samba account passwords.
state
Package state persists the apply resume cursor.
Package state persists the apply resume cursor.
tui
Package tui implements the interactive `dotdrift generate` wizard.
Package tui implements the interactive `dotdrift generate` wizard.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL