Dotfiles manager: one static binary, modules configured in Lua 5.5, secrets encrypted with age, history in git.
qd status # what push would do
qd push [module...] # repo → machine, runs setup hooks on first run
qd pull [module...] # machine → repo
qd pull -s "message" # …then commit and push
qd undo # revert the last apply from the trash
qd init --url ssh://… # new machine
qd add NAME PATH # start keeping a directory as module NAME
Every module is a directory in the dotfiles repo with a qd.lua that returns a
table; see contrib/qd.d.lua for the schema and DESIGN.md for how it works.
A root qd.lua holds shared ignore, encrypt and include entries, plus
whatever the plugins read from it.
local qd = require("qd")
return {
path = qd.host.windows and qd.path.local_appdata("nvim") or qd.path.config("nvim"),
brew = { "neovim", "ripgrep" },
encrypt = { "**/*.p12" },
ignore = { "**/lazy-lock.json" },
nushell = { source = { "config.nu" }, env_source = { "env.nu" } },
setup = { version = 1, after = function(m) qd.run("fnm", "install", "--lts") end },
}brew, scoop and nushell are not core fields: they belong to plugins. The
core syncs files and runs setup hooks; a plugin owns one key in every qd.lua
and turns it into files to write (compile) or commands to run (packages),
both pure, so --dry-run and undo cover them. The three built-ins load
unless the root file declares its own list:
-- root qd.lua
return {
plugins = { require("qd.nushell"), require("qd.brew"), require("plugins.mine") },
}Plugins get qd.fail for errors, qd.check with qd.schema for validating
their key, and
qd.warn / qd.debug for output. All plugin and config output goes to
stderr, print included, so qd show --format json stays parseable. See the
Plugins section of DESIGN.md for the contract and contrib/apt.lua for a
worked example.
setup.push runs after every push of its module, not only the first, for
anything derived from the machine rather than the repo, such as a cache of
brew --prefix.
Files a setup hook generates into path belong in ignore; otherwise the next
pull copies them into the repo and the next push on another machine treats
them as strays.
qd state path shows the directory holding state.toml (tags, repo and
identity paths, per-module setup versions), journal.jsonl, and trash/.
Override it with QD_STATE, the home that destinations hang off with
QD_HOME, and where self-update looks with QD_UPDATE_BASE_URL. Removed and overwritten files go to the trash;
qd trash prune --older 30d cleans up.
Tags gate enabled in a config. qd tag add <name> records one in state.toml,
which is what you want on a machine you own; QD_TAGS=a,b adds tags for one
command, which is handy for trying a config out (QD_TAGS=work qd show version-control). DOTFILES_TAGS is read too, for the Nushell tool this
replaces. Detected facts are not tags — a config asks qd.host.wsl or
qd.host.ubuntu for those.
master.rec (recipients) is committed in the repo root; master.key is not
and lives next to it or wherever qd state set-identity points.
The crate is qdot (qd is another crate's name on crates.io); the
library and the binary are qd. Without its default cli feature it is
the library alone — no clap, ureq or self-replace — and qd::Session
does what the binary does, returning data and printing nothing:
qdot = { version = "0.3", default-features = false }let mut s = qd::Session::open(None)?; // QD_STATE, the recorded repo
let plans = s.status(&[], qd::plan::Direction::Push)?;
let done = s.sync(&["helix".into()], qd::plan::Direction::Pull, qd::SyncOpts::default())?;
s.add_module("kawoosh", &home.join(".config/kawoosh"), &["fonts/**".into()])?;A program linking it shares state.toml, the journal and the trash with
the qd on the PATH, so it should hold to qd::VERSION matching that
binary's, and run the binary when they differ. kawoosh's dotfiles pane
does.
cargo build --release
.github/workflows/ci.yml runs fmt, clippy and the tests on Linux, macOS
and Windows for every push and pull request.
Releases are cut by pushing a vX.Y.Z tag matching Cargo.toml;
.github/workflows/release.yml builds each target on a runner of its own
kind, attaches the five binaries plus checksums.sha256, version.txt and
install.nu to the GitHub release, and publishes the crate to crates.io.
qd self-update [--dry-run]
It reads three files from a base URL — version.txt, checksums.sha256
and the binary for this platform — verifies the SHA-256 and swaps the
executable. No forge API is involved, so a mirror, or a new home after a
move, needs only to serve those three files:
qd self-update --base-url https://somewhere/else
Override the default permanently with QD_UPDATE_BASE_URL, or bake it into
a build with the same variable. If even the base URL has gone, install one
named binary directly:
qd self-update --url https://host/qd-macos-aarch64 --sha256 <hash>
which is never installed unverified. Binaries at 0.3.0 and earlier read the
old Forgejo package registry and cannot update across the move to GitHub —
those need one manual install, after which self-update works again.
contrib/install.nu picks the right binary for the machine, verifies its
SHA-256 against the published checksums, and installs it into ~/.local/bin
(or %LOCALAPPDATA%\qd\bin):
nu contrib/install.nu
nu contrib/install.nu --version 0.3.0 --dest ~/bin
nu contrib/install.nu --base-url https://somewhere/else
nu contrib/install.nu --init-url ssh://git@drydock9-port1.qxuken.dev/qxuken/dotfiles.git
It reads the same three files qd self-update does.
It is published beside the binaries, so a machine with nushell but no clone can fetch it first:
curl -fLO https://github.com/qxuken/qdot/releases/latest/download/install.nu
Without nushell, download the asset for the platform directly
(qd-linux-x86_64, qd-macos-aarch64, qd-windows-x86_64.exe, …):
curl -fLO https://github.com/qxuken/qdot/releases/latest/download/qd-macos-aarch64
and chmod +x it.