mdots — declarative dotfiles and package manager#
mdots is a declarative Arch Linux package + dotfiles manager that lives in the margo workspace. It lets you describe the exact set of packages (pacman/AUR, Flatpak, Nix) and SOPS/age-encrypted secrets you want on each machine, then drives paru/yay/flatpak/home-manager to make reality match the declaration.
Key properties:
- Host files declare what a specific machine should have.
- Modules group logically related packages (e.g.
window-managers/margo,dev/rust) that can be toggled per host. - Lua modules add runtime intelligence — query CPU vendor, detect battery, test whether a service is running — and conditionally add packages from the same file.
- SOPS/age secrets are decrypted at sync time and copied (never symlinked) to their plaintext targets outside the repo.
- Nix / home-manager runs as a first-class sync step when enabled.
Config home#
Default: ~/.config/mdots
Override: MDOTS_CONFIG_DIR=/path/to/repo mdots …
Legacy fallback: ~/.config/arch-config (also accepted via ARCH_CONFIG_DIR). A fresh mdots init always creates ~/.config/mdots.
Directory layout#
~/.config/mdots/
config.yaml # top-level pointer: names the active host
config.lua # alternative: single-file Lua config (or pointer)
hosts/
<hostname>.yaml # full host config (or .lua / .nix)
modules/
base.yaml # packages common to every host (or base.lua / base.nix)
dev/rust.yaml # per-topic modules
dev/rust.lua # Lua module with runtime detection
services/ # service profile Lua files
home-manager/ # home.nix and related files (when nix.home_manager_enabled)
secrets/ # SOPS-encrypted blobs (committed; never plaintext)
.sops.yaml # age recipient list (SOPS meta-file)
state/ # mdots-managed runtime state (do not hand-edit)
Config model#
Host file#
A host file (YAML, Lua, or Nix) is the root of the declaration for one machine. The key fields (actual Rust field names, not aliases):
host: myhostname # required — drives module/host file resolution
description: "workstation"
# Package management
packages:
- vim
- flatpak:com.spotify.Client # Flatpak prefix syntax
- { name: neovim, type: native }
exclude:
- gedit # remove a package pulled in by a module
enabled_modules:
- base
- dev/rust
- window-managers/margo
flatpak_scope: user # "user" (default) or "system"
flatpak_remote: flathub # remote Flatpaks install from (default: flathub); a package's `remote:` wins
auto_prune: false # remove untracked packages on sync
# AUR / pacman
package_manager: pacman # auto-detected; only "pacman" supported today
aur_helper: paru # paru, yay, …
# Multi-host reuse
import:
- hosts/shared/laptop-common.yaml
# Git
auto_commit: false # commit after a successful sync
# Nix / home-manager
nix:
enabled: false
home_manager_enabled: false
flake_enabled: false
nixpkgs_channel: nixpkgs-unstable
home_manager_channel: release-25.05
# SOPS secrets (see Secrets section)
sops_key_path: ~/.config/sops/age/keys.txt
secrets:
- source: secrets/ssh.conf.sops
target: ~/.ssh/config
mode: "0600"
name: ssh-config
# Systemd services
services:
enabled: [NetworkManager, bluetooth]
disabled: [avahi-daemon]
scope: system # or "user"
Modules#
A module is a reusable package group. Three formats are supported:
YAML module (modules/dev/rust.yaml):
description: Rust development toolchain
packages:
- rustup
- cargo-edit
pre_install_hook: scripts/setup-rust.sh
hook_behavior: once # "ask" | "always" | "once" | "skip" | "never"
conflicts: [dev/go]
Directory module (modules/dev/rust/):
modules/dev/rust/
module.yaml # manifest (description, hooks, conflicts, …)
packages.yaml # package list(s)
scripts/ # hook scripts
dotfiles/ # optional dotfiles to sync
The manifest (module.yaml) supports the same fields as a YAML module plus package_files (explicit list), dotfiles_sync, dotfiles (explicit source/target pairs), and metadata (author, version, category, tags).
Lua module (modules/dev/rust.lua): dynamic — see the Lua API section below.
Package entry formats#
packages:
- vim # native (pacman)
- flatpak:com.spotify.Client # Flatpak via prefix
- nix:ripgrep # Nix package
- { name: code, type: native } # explicit type
- { name: com.visualstudio.code, type: flatpak }
- { name: org.gnome.Builder, type: flatpak, remote: gnome-nightly } # from another remote
Lua API#
Lua modules (.lua files for host configs and modules) receive a pre-populated global mdots.* table hierarchy. Each sub-table groups related detection functions:
| Table | Purpose |
|---|---|
mdots.hardware |
CPU vendor, GPU list, laptop/battery/chassis detection |
mdots.security |
Secure boot, TPM, disk encryption status |
mdots.package |
Query whether a package is installed |
mdots.service |
Query systemd service state |
mdots.power |
Power management profiles and AC state |
mdots.desktop |
Running desktop environment / compositor detection |
mdots.boot |
Bootloader detection (systemd-boot, GRUB, …) |
mdots.network |
Network interface / connectivity state |
mdots.audio |
PipeWire / PulseAudio detection |
mdots.storage |
Filesystem and disk info |
mdots.file |
Filesystem helpers — exists, is_dir, is_file, read, read_lines |
mdots.system |
hostname, kernel version, arch, OS, distro / name / version, total memory, CPU cores |
mdots.log |
Logging from within a Lua module |
mdots.env |
Read environment variables |
mdots.util |
String / path utilities |
Example Lua module:
-- modules/dev/nvidia-drivers.lua
local m = {}
m.description = "NVIDIA proprietary drivers"
if mdots.hardware.has_nvidia() then
m.packages = { "nvidia", "nvidia-utils", "cuda" }
else
m.packages = {}
end
return m
A Lua host file (hosts/myhostname.lua) returns a table with the same fields as a YAML host file: host, enabled_modules, packages, exclude, secrets, nix, etc.
Secrets workflow#
mdots delegates all crypto to sops; it only manages file placement and key lookup.
1. Generate an age key#
Print the public key:
2. Register the recipient in .sops.yaml#
3. Encrypt a file#
sops --encrypt --age age1xxxxxxx… ~/.ssh/config > \
~/.config/mdots/secrets/ssh.conf.sops
git -C ~/.config/mdots add secrets/ssh.conf.sops
4. Declare the secret in the host file#
sops_key_path: ~/.config/sops/age/keys.txt # where mdots finds the private key
secrets:
- source: secrets/ssh.conf.sops # relative to the config repo root
target: ~/.ssh/config # MUST be outside the config repo
mode: "0600" # octal; default is "0600"
name: ssh-config # display name; defaults to target filename
Critical guard: the plaintext target must be outside ~/.config/mdots (or wherever MDOTS_CONFIG_DIR points). mdots refuses to write decrypted content inside the config repo to prevent leaking plaintext into git.
5. Operate on secrets#
mdots secrets status # check which secrets are decrypted / pending / broken
mdots secrets list # list declared secrets (no filesystem access)
mdots secrets edit ssh-config # open the encrypted file in $EDITOR via sops
mdots secrets sync # decrypt all declared secrets into place (without a full sync)
mdots sync runs secrets sync automatically as part of the sync pipeline.
Secrets are decrypted and copied at the target path — never symlinked. The copy is atomic (write to a sibling temp file, rename over the target) and starts 0600 before the requested mode is applied, so the plaintext is never world-readable at any point.
If sops_key_path points to a missing file, every secret in that host is skipped with a warning. Per-secret failures are warned and skipped — never fatal — so a broken secret cannot abort mdots sync.
Nix / home-manager integration#
Enable in the host file:
nix:
enabled: true # nix is installed on this machine
home_manager_enabled: true # run `home-manager switch` during sync
flake_enabled: false # use channels (default) or flakes
nixpkgs_channel: nixpkgs-unstable
home_manager_channel: release-25.05
mdots nix install # install Nix and Home Manager (one-time setup)
mdots nix switch # run Home Manager switch
mdots nix update # update channels + run Home Manager switch
mdots nix search <pkg> # search Nixpkgs
mdots nix status # show Nix and Home Manager status
home-manager configuration lives in ~/.config/mdots/home-manager/home.nix (or the directory mdots points at). When home_manager_enabled: true, mdots sync runs home-manager switch as part of the sync pipeline.
Operator commands#
| Command | What it does |
|---|---|
mdots init |
Create the ~/.config/mdots directory skeleton and a starter config.yaml. |
mdots status |
Show current config, active host, enabled modules, package counts, and a drift summary (how many packages are declared-but-not-installed vs installed-but-not-declared). |
mdots diff |
Compute declared-vs-installed package diff and print it — read-only, no changes applied. Equivalent to the drift section of status but always machine-readable with --json. |
mdots doctor |
Run environment health checks (age key, sops, AUR helper, nix, home-manager, git repo) and report each item as PASS / WARN / FAIL. |
mdots validate [--check-packages] |
Parse and validate the config structure and all enabled modules. --check-packages additionally queries the package repos (slower). |
mdots sync [--dry-run] |
Install declared packages, enable/disable services, decrypt secrets, run home-manager — everything in one pass. --dry-run previews changes without applying. Other flags: --prune (remove untracked packages), --force (skip confirmation prompts), --no-backup, --no-hooks, --force-dotfiles (re-sync dotfiles even if already synced), --auto-commit. |
mdots find <PACKAGE> |
Search all host files, modules, and the base set for where a package name is declared. |
mdots module list\|enable\|disable\|run-hook\|create |
List available modules, toggle them in the host file, manually run a module's hook, or scaffold a new module. |
mdots service list\|enable\|disable\|show |
Manage systemd service profiles (from the services/ directory). |
mdots secrets status\|sync\|list\|edit\|keygen |
SOPS/age secrets management — see the Secrets workflow section. |
mdots nix install\|switch\|update\|search\|status |
Nix / home-manager integration — see the Nix section. |
mdots tui |
Launch the interactive ratatui TUI (overview, modules, packages, sync-preview screens). |
mdots install <PACKAGE> |
Install a package with the AUR helper and add it to the declared package list. |
mdots remove <PACKAGE> |
Remove a package and untrack it from mdots. |
mdots forget <PACKAGE> |
Remove a package from mdots tracking without uninstalling it. |
mdots update |
Update the system (respects version constraints; runs pre/post hooks if configured). |
mdots update --check |
List the pacman, AUR and Flatpak updates first (read-only: checkupdates, <aur helper> -Qua, flatpak remote-ls --updates), then ask Continue Update? [Y/n]. With --json it prints the report (package_manager, flatpak, total_updates) and exits without updating. |
mdots search |
Interactive TUI package search. |
mdots edit |
Interactive TUI config-file selector (opens the chosen file in $EDITOR). |
mdots merge |
Add currently installed but untracked packages into system-packages.yaml. |
mdots migrate |
Migrate an old packages/-layout repo to the new hosts/ + modules/ structure. |
mdots generate |
Generate derived config files (completions, man page, declared-packages snapshot). |
mdots completion <SHELL> |
Print a shell completion script (bash, zsh, fish, …). |
mdots man |
Print the man page to stdout (roff format). |
All commands accept -j / --json for machine-readable output. This guide covers the common commands — run mdots --help for the full command surface (which also includes save-config, restore-config, hooks, repo, backup, restore, self-update, and source).
Quick-start example#
# 1. Initialise
mdots init
# 2. Edit the host file for this machine
mdots edit
# 3. Validate
mdots validate
# 4. Preview what sync would do
mdots sync --dry-run
# 5. Apply
mdots sync
# 6. Check drift after the fact
mdots diff
mdots doctor
See also#
- Config conventions — covers the
margo-config(compositor) andmshell-config(shell YAML profiles) config worlds that mdots sits alongside. mdots --helpandmdots <subcommand> --helpalways reflect the actual CLI.mdots manprints the generated man page.