Personal configuration for macOS (nix-darwin + Homebrew), Arch Linux and WSL.
just # list every task
just switch # apply the nix-darwin configuration on macOS
just check # lint, spell-check and scan for secrets
~/.config is a real directory. Individual files are linked back into it from this repo by
nix-darwin/modules/home-manager/xdg.nix:
~/.config/nvim -> ~/dotfiles/.config/nvim
~/.config/Code/User/settings.json -> ~/dotfiles/.config/Code/User/settings.json
~/.config/gh/hosts.yml -> ~/dotfiles/.config/gh/hosts.yml
...
~/.zsh -> ~/dotfiles/.zsh (still a whole dir)
It used to be one symlink, ~/.config -> ~/dotfiles/.config, which meant every application wrote
its runtime state inside the git repo. Colima's VM image alone was 18 GB of it. Switching to
per-file links took the repo from 19 GB to 12 MB and stopped Linux-only files from appearing on
macOS.
The links use home-manager's mkOutOfStoreSymlink, not the usual home.file.source, because these
files have to stay writable and live: VS Code rewrites settings.json, gh rewrites
hosts.yml, lazy.nvim writes lazy-lock.json. A normal home.file would point at a read-only
/nix/store path, and editing a config would need a rebuild to take effect.
Granularity rule: link a whole directory only when it is entirely ours (nvim). Where an
application keeps its own state alongside our config (VS Code's globalStorage/, gh's
state.yml), link the individual files so that state stays out of the repo.
To track a new config file:
- add a
!/.config/path/to/fileline to the allow-list in.gitignore - add a
"path/to/file".source = link "path/to/file";entry toxdg.nix git add .config/path/to/file && just switch
git check-ignore -v <path> explains why any given path is ignored.
.gitignorestill uses an allow-list for.config/. It is no longer the only thing between an app and the repo, but the linked paths are still written to, so it stays the cheapest way to be sure only intended files get committed.
A git hook runs gitleaks on staged changes as a second line of defence, since git add -f
bypasses .gitignore. Enable it once per clone:
just hooks # prek installThe organising rule: a dotfile at the repo root maps into $HOME on every machine. Anything
that is specific to one platform lives in that platform's directory and is deployed by hand there.
| Path | Maps to |
|---|---|
.zshenv |
~/.zshenv, for the little that must be set before zsh touches the terminal |
.zshrc |
~/.zshrc, one file for every machine. Branches on $DOTFILES_PLATFORM |
.zsh/ |
~/.zsh, where every *.zsh is auto-sourced |
.zsh/zshrc_{macos,linux,wsl,synology} |
Per-platform sections, loaded by .zshrc |
.config/ |
Linked file-by-file into ~/.config (see above). Cross-platform only |
.config/nvim/ |
Neovim: lazy.nvim + native LSP |
.gitconfig-global, .gitignore-global |
The base git config every non-Nix machine includes |
| Path | What it is |
|---|---|
nix-darwin/ |
macOS (the MacBook, main machine). The flake: system + Homebrew + home-manager. Declarative, applied with just switch |
linux/ |
Arch. boot/, xorg.conf.d/, Xsetup (sddm), bin/, XCompose/ (vendored from kragen/xcompose), xbindkeysrc, gitconfig, and config/ for the XDG files that are Linux-only (KDE autostart, user-dirs.*) |
wsl/ |
Ubuntu 26.04 under WSL on the work machine. zshenv, gitconfig |
windows/ |
Windows Terminal settings, gitconfig |
scripts/ |
Cross-platform helpers and the container-based shell tests |
The Synology (RS2423+, DSM 7.x) has no directory of its own. It needs no files copied to it beyond
.zshenv, .zshrc and .zsh/. Its behaviour lives in .zsh/zshrc_synology.
Linux-only XDG files live in
linux/config/, not.config/. Because~/.configis one symlink shared by every machine, anything put in.config/shows up on macOS too. Symlinklinux/config/*individually on Linux.
Justfile (task runner), .pre-commit-config.yaml (git hooks, run by prek), scripts/,
.github/workflows/ (CI), renovate.json5, and the tool configs: typos.toml, statix.toml,
.gitleaks.toml, .mdformat.toml, .stylua.toml, cspell.json.
docs/ takes what is too long for this file: setups that live on a machine rather than in this
repo, where the value is the commands and the traps. The compose stacks on the NAS are their own
repository, nas-containers, since they deploy differently and carry credentials.
Renovate keeps the two sets of pins current: the SHA-pinned actions in .github/workflows/ and
the hook revs in .pre-commit-config.yaml. It is deliberately not pointed at flake.lock. That
is just update's job, and nixpkgs-unstable moves several times a day. renovate-check in
.zsh/renovate_check.zsh runs the same rules locally and shows what is being held back.
Two spell checkers, with different jobs: typos catches real misspellings anywhere and gates
commits, while cSpell (cspell.json, word list in .cspell/dotfiles.txt) covers prose and comments
and runs in VS Code only, with no hook and no CI. Its word list is the one thing here that can
rot unnoticed. Which word goes where is under "Spell checking" in AGENTS.md.
.vscode/extensions.json suggests nefrob.vscode-just-syntax for the Justfile. It is the only
actively maintained Just extension, and it downloads nothing: it uses the just and just-lsp
binaries from packages.nix.
macOS git config is not in
.gitconfig-*; it is generated bynix-darwin/modules/home-manager/programs/git.nixinto~/.config/git/config. Thelinux/,wsl/andwindows/gitconfigfiles are for the machines without Nix, and each oneincludes~/.gitconfig-global.
The flake lives in nix-darwin/ and the configuration is named after the host (M4). Package
management is split three ways on purpose:
- nixpkgs (
modules/packages.nix): CLI tooling, grouped by purpose. - Homebrew (
modules/homebrew.nix): GUI casks, and formulae that move faster than nixpkgs (yt-dlp) or are macOS-specific (pinentry-mac). Taps are pinned as flake inputs andmutableTaps = false, sobrewcannot drift. - home-manager (
modules/home-manager/): per-user config for git, gnupg, direnv and zsh, plusactivation/scripts for things macOS offers no API for (default apps, removing login items).
Homebrew 6 refuses formulae and casks from non-official taps unless they are trusted. nix-darwin
handles that declaratively, because every Brewfile entry it generates carries trusted: true.
Nothing here writes a trust.json. The one workaround left is mirroring nix-homebrew's taps into
homebrew.taps, because brew bundle cleanup still untaps anything the Brewfile does not mention,
and untapping homebrew/cask force-uninstalls every cask that came from it.
just build # build without activating -- always do this before switch
just switch # sudo darwin-rebuild switch
just diff # show what a rebuild would change (nvd)
just update # nix flake update
just gc # collect garbage older than 14d, optimise the storenh is installed as a friendlier front-end: just nhs wraps nh darwin switch, which shows a
package diff automatically.
Garbage collection and store optimisation also run on a timer (modules/nix.nix), so manual gc is
rarely needed.
1. The flake only sees git-tracked files.
The flake resolves to git+file:///Users/julio/dotfiles?dir=nix-darwin, so Nix copies the git
tree, not the working directory. A brand-new file is invisible until it is staged:
error: Path 'nix-darwin/modules/foo.nix' in the repository ... is not tracked by Git.
Fix with git add nix-darwin/modules/foo.nix. Modifications to already-tracked files are picked up
without staging.
2. .zshrc is baked into the Nix store.
modules/home-manager/programs/zsh.nix does initContent = builtins.readFile ../../../../.zshrc,
so ~/.zshrc is a read-only symlink into /nix/store. Editing .zshrc in this repo has no effect
until just switch. To iterate quickly, test in a throwaway ZDOTDIR:
mkdir -p /tmp/zt && cp .zshrc /tmp/zt/.zshrc
ZDOTDIR=/tmp/zt zsh -iOne .zshrc runs on every machine. It sets $DOTFILES_PLATFORM to macos, wsl, synology or
linux and loads the matching .zsh/zshrc_*.
There is no plugin manager anywhere. zplug and Prezto are gone: zplug cloned itself over the
network from inside .zshrc on first run, pinned nothing, and both have been unmaintained for
years.
- On macOS, plugins are Nix packages declared in
nix-darwin/modules/home-manager/programs/zsh.nixand sourced straight out of/nix/store. - Everywhere else,
.zshrcsources whatever the system package manager installed, searching the usual prefixes (/usr/share,/opt/sharefor Entware, Homebrew,~/.local/share). Anything missing is skipped, so a box with no plugins at all still gets a working shell.
# Arch
pacman -S zsh-autosuggestions zsh-syntax-highlighting zsh-history-substring-search
# Debian / Ubuntu / WSL
apt install zsh-autosuggestions zsh-syntax-highlighting
# Synology: no package exists for any of them, see "zsh plugins" below| Was (zplug) | Now |
|---|---|
Prezto autosuggestions / syntax-highlighting / history-substring-search |
home-manager's built-in options (it gets the load order right) |
Prezto editor (vi keys) |
defaultKeymap = "viins" |
Prezto completion |
enableCompletion + zsh-completions |
Prezto fasd |
zoxide |
Prezto terminal / archive / utility |
~40 lines of plain zsh in .zshrc |
djui/alias-tips |
zsh-you-should-use |
zdharma-continuum/history-search-multi-word |
dropped; Atuin owns Ctrl-R |
hlissner/zsh-autopair |
same, from nixpkgs |
seebi/dircolors-solarized |
vivid |
supercrabtree/k |
eza |
z-shell/zsh-diff-so-fancy |
delta |
b4b4r07/emoji-cli |
pinned fetchFromGitHub (unmaintained since 2017) |
Startup went from 1.71 s under zplug to 0.38 s (just bench-shell).
The remaining cost is mostly plugins. The two things that used to dominate are gone: uv, uvx and
pixi completions are cached onto $fpath instead of being evaled in every shell (~230 ms), and
brew no longer runs four times per shell (~40 ms). See the Completions section of .zshrc.
programs.zsh.pluginsis deliberately not used. That option materialises plugins under~/.zsh/plugins, and~/.zshis a symlink into this repo, so it would write generated store symlinks into your working tree.
Atuin replaces the flat ~/.zsh_history with SQLite, recording exit code, duration, cwd and
session for every command. Configured declaratively in
nix-darwin/modules/home-manager/programs/atuin.nix.
| Key | Does |
|---|---|
Ctrl-R |
Atuin fuzzy search over all history |
Up |
zsh-history-substring-search on what you have typed (deliberately not given to Atuin) |
Import the existing history once, after the first just switch:
atuin import autoenter_accept = false, so a selected command lands on the command line to be edited rather than
executing immediately. Sync is off, because it needs an account. See the comments in the module.
secrets_filter plus a history_filter list keep tokens out of the database.
Synology RS2423+ (DSM 7.x): .zsh/zshrc_synology, loaded when /etc/synoinfo.conf exists. Puts
Entware's /opt/bin ahead of DSM's older tools and adds opkg/synosystemctl/compose aliases.
Deploy by cloning the repo and symlinking ~/.zshenv, ~/.zshrc and ~/.zsh; nothing else is
needed.
Entware's terminfo reaches the shell through $TERMINFO_DIRS in .zshenv, not $TERMINFO here.
ncurses fixes its search path before .zshrc is read, so setting it at that point is already too
late for the shell's own lookup. Without it zellij and nvim misrender over SSH.
.zshenv names three directories: ~/.terminfo, the Nix profile's, and Entware's. A machine with
Nix gets xterm-ghostty from ghostty.terminfo and needs nothing in ~/.terminfo. A machine
without Nix needs the entry copied in by hand, which takes no root and no tic:
ssh HOST mkdir -p .terminfo/x
scp /Applications/Ghostty.app/Contents/Resources/terminfo/78/xterm-ghostty \
HOST:.terminfo/x/xterm-ghosttyOn SSH login the shell auto-attaches to a zellij session named after the host, so reconnecting lands
back in the same session. It deliberately does not exec zellij. If zellij or the terminfo were
broken, exec would kill the login shell and lock you out of a headless box. Skip it for one
connection with:
ssh nas -t 'DOTFILES_NO_ZELLIJ=1 $SHELL -l'The assignment has to be part of the remote command. DSM's sshd sets no AcceptEnv, so it drops
every forwarded variable, LANG included.
Probe this box with a login shell.
ssh nas '<cmd>'andssh nas -t 'zsh -i'both skip/etc/profile, which is the only thing that puts/usr/local/binand/usr/syno/binon$PATH. Under those, roughly 250 installed SynoCli tools look missing andsynopkg statusreports packages as stopped when it merely lacked root. Use the$SHELL -lform above before concluding anything is absent.
DSM jails both transfer tools, and it jails them into different namespaces. Copy the path style from the table rather than reasoning about it:
| Destination | scp |
rsync |
scp -O |
|---|---|---|---|
nas:/home/f, nas:/docker/f (shares) |
yes | no | no |
nas:/var/services/homes/julio/f (real) |
no | yes | yes |
nas:/volume3/docker/f (real) |
no | yes | yes |
nas:/tmp/f (rootfs) |
no | no | yes |
scp has spoken SFTP since OpenSSH 9.0, and DSM serves SFTP from a jailed server whose root is the
list of shared folders. So real paths do not exist for it, and its own root takes no writes:
scp file nas:/var/services/homes/julio/file # dest open: No such file or directory
scp file nas:file # dest open: Permission deniedChrootDirectory is none in DSM's sshd_config, so the jail lives inside Synology's
internal-sftp and no setting turns it off. DSM's /usr/bin/rsync is setuid root and patched the
same way, but it takes the real paths and treats the rootfs as a read-only module:
rsync -a file nas:/tmp/file # ERROR: module is read onlyUse rsync for daily work. It takes the paths that ssh nas shows you, it re-sends only what
changed, and it is current. Reach for -O only for the rootfs, which is rare. That flag selects the
pre-9.0 SCP protocol: scp(1) calls it legacy, and it has the remote shell expand globs, so
filenames then need careful quoting.
Entware lives in /volume1/@Entware/opt, bind-mounted onto /opt. The @ prefix makes it a DSM
system directory rather than a shared folder: invisible in File Station, never exported over
SMB/NFS, skipped by Media Indexing, and left out of DSM's shared-folder ACL model, so the POSIX
modes and setuid bits the packages set are the only thing governing it. DSM will not let you create
an @ name through the UI anyway.
A DSM upgrade wipes /opt, which is on the rootfs, but not /volume1/@Entware. So after an
upgrade, check whether this is only a lost bind mount before reinstalling anything:
sudo ls -la /volume1/@Entware/opt # bin/ etc/ lib/ share/ still there?
sudo mount -o bind /volume1/@Entware/opt /optIf that brings /opt/bin/opkg back, skip the rest of this section and go straight to the boot task.
A fresh install follows the
Entware wiki. x64-k3.2 is the
right feed for this box, since uname -m is x86_64 on kernel 4.4.
Every line below runs in a root shell. Only root can write at a volume root, and umask is a
shell builtin, so sudo per command would not carry it. DSM 7 disables direct root SSH, so:
sudo -iThen, as root:
umask 022 # root's umask is 077; 0700 on /opt locks every other user out
mkdir -p /volume1/@Entware/opt
chmod 755 /volume1/@Entware /volume1/@Entware/opt
# Not the wiki's `rm -rf /opt`: the bind mount hides what is under it, and
# Container Manager keeps an (empty) /opt/containerd there.
cp -a /opt/containerd /volume1/@Entware/opt/
mount -o bind /volume1/@Entware/opt /opt
wget -O - https://bin.entware.net/x64-k3.2/installer/generic.sh | /bin/shAt 0700 nothing under /opt runs at all, not even the loader at /opt/lib/ld-linux-x86-64.so.2.
Check ls -la /opt first, since anything else living there needs carrying across too.
The tree lives on the volume so it survives upgrades, but the bind mount does not survive a reboot.
Re-create it from a Triggered Task in Control Panel → Task Scheduler (event: Boot-up, user:
root):
mkdir -p /opt
mount -o bind /volume1/@Entware/opt /opt
/opt/etc/init.d/rc.unslung start
/opt/bin/opkg updateThe wiki's boot script also appends /opt/etc/profile to /etc/profile. That is not needed here:
zshrc_synology puts /opt/bin and /opt/sbin on $PATH itself, and /etc/profile is another
file DSM rewrites on upgrade.
Then opkg install zsh ncurses-bin terminfo. That zsh links against Entware's own ncurses instead
of baking in a static one, and ncurses-bin supplies tic and infocmp, so a terminfo entry DSM
lacks can be compiled in place rather than copied in, and Ghostty's ssh-terminfo shell integration
starts working on its own.
Entware packages none of them, and SynoCommunity's zsh-static is a lone binary. Clone them into
the last entry of _plug_dirs in .zshrc. The upstream repository names already match the files
the loader looks for, so no renaming:
mkdir -p ~/.local/share/zsh/plugins
cd ~/.local/share/zsh/plugins
git clone --depth 1 https://github.com/zsh-users/zsh-autosuggestions
git clone --depth 1 https://github.com/zsh-users/zsh-syntax-highlighting
git clone --depth 1 https://github.com/zsh-users/zsh-history-substring-searchNo sudo; this is all under $HOME. The third one is the easy one to skip: without it Up/Down and
vicmd k/j fall back to plain history, and just test-shell reports skip rather than fail.
The git aliases need oh-my-zsh as well. .zshrc sources only lib/git.zsh and the git plugin from
it, and looks in $DOTFILES_OMZ, then ~/.oh-my-zsh, then two system paths. $DOTFILES_OMZ is set
by the Nix machines alone, so without a checkout here gl, gst, gco and the rest are simply not
defined:
git clone --depth 1 https://github.com/ohmyzsh/ohmyzsh ~/.oh-my-zshNothing pins any of these: Renovate cannot see a git clone in $HOME, and only the Nix machines
get them from flake.lock. Update them by hand:
for d in ~/.local/share/zsh/plugins/*(/) ~/.oh-my-zsh(N/); do git -C "$d" pull --ff-only; doneTools come from three places. Prefer them in this order, because only the first two update themselves.
SynoCommunity. Add the repository in Package Center, then install the synocli-* bundles. They
put around 250 tools in /usr/local/bin, as symlinks into /var/packages/synocli-*/:
| Package | Gives you |
|---|---|
synocli-file |
bat, fzf, fd, eza, rg, less, mc, nnn, sd, lsd |
synocli-disk |
ncdu, duf, gdu |
synocli-net |
mtr, tmux, nmap, socat |
synocli-monitor |
procs, lsof, btop |
DSM itself already supplies htop, curl, wget, jq, rsync, python3, vim, gpg,
smartctl and tcpdump.
Entware. This covers what SynoCommunity does not package:
opkg install zoxide rclone pv progress perl-image-exiftoolperl-image-exiftool pulls Entware's own perl, so it is a heavier install than it looks. ~/bin
comes before /opt/bin on $PATH, so a binary you install by hand still wins over the packaged one
of the same name.
Neither repository always carries the newest release. When the version matters, check what is
packaged before you install, and take the tool by hand when the package is behind. .config/nvim is
one such case: it guards features behind a vim.fn.has('nvim-...') check, including the built-in
undotree, and those disappear without a word on an older build.
By hand. Neither repository packages these:
| Tool | Install |
|---|---|
rustup (and cargo, rustc, rust-analyzer) |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh |
uv |
curl -LsSf https://astral.sh/uv/install.sh | sh |
starship |
sh -c "$(curl -fsSL https://starship.rs/install.sh)" |
atuin, delta, difftastic, restic, zellij |
release binary into ~/bin |
nvim |
nightly or source build, into ~/bin |
The release binaries have no common command, because the asset names differ per project: musl or
gnu, x86_64 or amd64, .tar.gz or .bz2. Take each from its releases page.
A container is the fourth option, for a tool none of the three package. It costs an image pull rather than a binary, but the version is pinned in the compose file.
Most of what this NAS runs is a container. DSM calls the package Container Manager, but the CLI is
docker, with the v2 docker compose plugin.
Stacks created through the DSM UI get one directory each under <volume>/docker. The volume is
fixed when Container Manager is installed, so zshrc_synology defines cdstacks to find it instead
of naming it:
cdstacks # cd to <volume>/docker
dpst # docker ps, showing names, status and portsDSM keeps /var/run/docker.sock root-only, so every docker call needs sudo. Its docker group is
root-equivalent, which makes joining it a worse trade than typing the password. zshrc_synology
aliases docker to sudo docker, and zsh re-expands the first word of an alias body, so dps and
the dc* aliases work too.
That alias covers interactive shells only.
sudokeeps its ownsecure_path, which holds neither/usr/local/binnor/usr/syno/bin, sosudo dockerandsudo synopkgboth answercommand not foundinside a script or underssh nas '<cmd>'. Write/usr/local/bin/dockerand/usr/syno/bin/synopkgthere.
Two boot traps, neither of which announces itself. A container that borrows another's namespace with
network_mode: service:<name> dies with exit 128 and
cannot join network of a non running container when the daemon happens to start it first.
depends_on orders compose up alone, and a start that fails this way is never retried, so it
stays down. restart <name> and up -d --force-recreate <name> do the same damage by hand: the
borrowers keep a handle on a namespace that went away, lose the LAN and the internet, and
docker ps still calls them healthy, because each one answers itself on 127.0.0.1. Act on the
whole stack, never on the one service.
A container that reserves a static IP loses it to whichever container the daemon starts first, and
then fails with Address already in use. ip_range on the network is the pool that dynamic
addresses come from, so give it the upper half of the subnet and every static address below stays
reserved. Leaving it to cover the whole subnet is what creates the race. Docker fixes IPAM when it
creates the network, so a change here means recreating it: stop every attached container,
compose down the stack that defines the network, then up -d in dependency order.
WireGuard on this box runs in the kernel rather than in userspace, which is worth about 1.5 cores: docs/synology-wireguard.md.
Single-user, because DSM has no systemd to run the daemon. The store lives on a volume and is bind-mounted, for the same reason Entware does: the rootfs has a few GB free and a DSM upgrade wipes it.
As root:
umask 022 # root's umask is 077, and a 0700 store is unusable
mkdir -p /volume2/@Nix /nix
mount -o bind /volume2/@Nix /nix
chown julio:users /volume2/@Nix # single-user Nix wants the store owned by youThen as your own user. /tmp is noexec on DSM, so the installer cannot run the binary it unpacks
there, and TMPDIR has to point somewhere it can:
mkdir -p ~/.cache/nix-install
TMPDIR=$HOME/.cache/nix-install sh <(curl -L https://nixos.org/nix/install) --no-daemonWrite ~/.config/nix/nix.conf before running it, or the install fails at
unable to load seccomp BPF program. The DSM 4.4 kernel has neither seccomp BPF filtering nor
CONFIG_USER_NS, so both the syscall filter and the build sandbox have to be off:
filter-syscalls = false
sandbox = false
experimental-features = nix-command flakesThe bind mount does not survive a reboot. Add it to the same Boot-up task as Entware, or its own:
mkdir -p /nix
mount -o bind /volume2/@Nix /nixzshrc_synology sources ~/.nix-profile/etc/profile.d/nix.sh when it is readable, which puts the
Nix profile ahead of Entware and behind ~/bin. The installer also appends that line to
~/.profile and ~/.zshenv. Both are useless here: ~/.profile execs zsh before reaching it, and
~/.zshenv is a symlink into this repo, so the line lands in tracked config. Revert it if the
installer wrote there.
Builds are unsandboxed as a result, so a build could see the host filesystem. It still cannot use host tools, because the build
PATHcontains only store paths. In practicex86_64-linuxis almost entirely cache hits, so builds are rare.
homeConfigurations."julio@nas" in nix-darwin/flake.nix, with the profile in
modules/home-manager/nas.nix. Standalone, because there is no NixOS or nix-darwin there, but it
shares this flake and therefore flake.lock with the MacBook.
It imports programs/zsh.nix unchanged, so the NAS gets the same generated ~/.zshrc as macOS and
with it $DOTFILES_PLUGINS_FROM_NIX. The zsh plugins and oh-my-zsh come from flake.lock rather
than from checkouts in $HOME, and home.packages supplies the CLI tools. Apply it on the NAS:
rm ~/.zshrc ~/.zshenv # first activation only, see below
nix build ~/dotfiles/nix-darwin#homeConfigurations.\"julio@nas\".activationPackage
./result/activateBefore the first activation those two are symlinks into this repo, and home-manager will not clobber
them. Its backup mechanism does not apply: HOME_MANAGER_BACKUP_EXT is checked only for regular
files, so a symlink falls through to Existing file ... would be clobbered and activation aborts.
Delete the links rather than backing them up, since the repo copies are what they pointed at.
Git is configured by programs/git.nix too, so delete the ~/.gitconfig and ~/.gitconfig-global
symlinks on activation. Both are read after ~/.config/git/config and would win, which is how the
NAS kept using the libsecret helper from linux/gitconfig that does not exist there. The module
branches on stdenv.isDarwin: macOS keeps osxkeychain, and everything else gets git's cache
helper, which holds the token in memory rather than writing it to disk.
~/.profile should then hand over to the Nix zsh, which fixes the terminfo problem at its root:
unlike SynoCommunity's zsh-static it is not built --disable-home-terminfo, so it reads
~/.terminfo unaided. Keep the old one as a fallback for the window between a reboot and the
Boot-up task that mounts /nix:
for _shell in "$HOME/.nix-profile/bin/zsh" /usr/local/bin/zsh; do
if [ -x "$_shell" ]; then
SHELL="$_shell"
export SHELL
exec "$_shell"
fi
doneBuild it from the repo root, not from
nix-darwin/.programs/zsh.nixreads.zshrcand.zshenvfrom the repo root, which is above the flake directory, so a flake reference that copies onlynix-darwin/fails withaccess to absolute path '/nix/store/.zshenv' is forbidden. Inside the git clone the whole repo is copied, so the path resolves.
WSL (Ubuntu 26.04): .zsh/zshrc_wsl. Sets BROWSER=wslview, maps pbcopy/pbpaste onto
clip.exe/PowerShell so scripts stay portable, and strips the inherited Windows PATH entries that
otherwise slow every completion down and shadow Linux binaries with .exe ones (keep them with
DOTFILES_KEEP_WINDOWS_PATH=1).
The MacBook can't be any of those platforms, so they're tested in containers:
just test-shell # WSL, Synology and bare-Linux scenarios
just test-shell synology # just oneEach runs scripts/shell-selftest.zsh inside the image and asserts platform detection, the helper
functions, plugin loading and the PATH fixes. The same matrix runs in CI.
.zshrc stays self-contained so the non-Nix machines work with nothing but this repo cloned: it
holds its own history, options, aliases and keybindings. The only thing Nix changes is where
plugins come from, signalled by DOTFILES_PLUGINS_FROM_NIX (exported from ~/.zshenv by
home-manager).
lazy.nvim, with Neovim 0.11+ native LSP (vim.lsp.config/vim.lsp.enable). No lsp-zero, no
nvim-cmp. Servers are installed by Mason.
:Lazy plugin UI :Lazy profile startup cost per plugin
:Mason LSP/DAP installs :checkhealth diagnose problems
Set up for Python and JavaScript/TypeScript in particular:
| Python | pyright (types) + ruff (lint, imports), pytest via neotest, debugpy |
| JS/TS | vtsls + eslint (fix on save), jest via neotest, js-debug |
| Format | conform.nvim, <leader>F |
| Test | <leader>tn nearest, <leader>tt file, <leader>td debug |
| Debug | <leader>db breakpoint, <leader>dc continue, <leader>dt UI |
pyright and neotest both resolve the project virtualenv ($VIRTUAL_ENV, .venv/, venv/), so
imports resolve without extra configuration.
| Language | Managed by | Why |
|---|---|---|
| Python | uv |
per-project interpreter versions |
| Node | mise (Homebrew) |
.tool-versions / mise.toml; supersedes nvm |
| Rust | rustup |
see below |
| Go | nixpkgs | one version is enough here |
Rust deliberately does not use a pinned nixpkgs toolchain. Unlike Python, a newer rustc still
builds older crates, because breaking changes are gated behind editions. The need is toolchain
switching, not version pinning. Only rustup honours rust-toolchain.toml (nixpkgs cargo ignores
it silently, so a local build can differ from CI), provides nightly and extra targets, and keeps
clippy/rustfmt/rust-analyzer/rust-src on the same toolchain so rust-analyzer never drifts
out of sync with rustc.
Nix pins only the rustup binary. The toolchains live in ~/.rustup, outside Nix, the same trade
uv makes for Python interpreters. Bootstrap once:
just rust-setup # rustup default stable + componentsFor a genuinely reproducible build of one project, use a per-project flake (fenix/oxalica + crane) rather than the global toolchain.
LSP servers come from Mason except those nixpkgs or rustup already provide (nil for Nix,
rust-analyzer for Rust). Those are enabled directly when the binary is on $PATH. Asking Mason
for nil made it try to build from source with cargo, which failed on every startup.
nvim-treesitter tracks its main branch, which needs the tree-sitter CLI to build parsers. The
CLI is declared in packages.nix.
Formatting and linting run as git hooks, managed by prek. It is a drop-in
pre-commit replacement in Rust, so there is no Python environment to keep
alive. The config is the usual .pre-commit-config.yaml.
just hooks # install the hooks (once per clone)
just lint # run every hook over the whole repo
just fmt # same thing; the formatters rewrite in place
just check # lint + check-flake + scan
just check-flake # nix flake check (too slow for a per-file hook)
just scan # gitleaks over all history (the hook only sees staged changes)
just bench-shell # hyperfine 'zsh -i -c exit'
just test-shell # WSL/Synology/bare-Linux containers (needs docker)| Hook | Covers |
|---|---|
mdformat (+ gfm) |
Markdown, config in .mdformat.toml |
typos |
spelling, config in typos.toml |
nixfmt, statix, deadnix |
Nix |
shellcheck, zsh -n |
sh/bash and zsh respectively |
stylua |
Lua, config in .stylua.toml |
actionlint |
GitHub Actions workflows |
zizmor |
GitHub Actions workflows, security side |
renovate-config-validator |
renovate.json5 |
gitleaks |
secrets in staged changes |
| pre-commit-hooks | trailing whitespace, EOF, line endings, large files, YAML/TOML syntax |
CI runs prek run --all-files rather than a step per tool, so the hooks and CI cannot drift apart.
typossetsignore-hidden = false. It skips hidden paths by default, which in a dotfiles repo means skipping.zshrc,.zsh/and.config/, which is nearly everything.
git clone git@github.com:jbsilva/dotfiles.git ~/dotfiles
cd ~/dotfiles && just hooksmacOS: install Nix, then just switch. home-manager creates ~/.zshrc, ~/.zshenv and the
per-file ~/.config links itself; do not link anything by hand. .zshenv is read into
programs.zsh.envExtra there, the same way .zshrc is read into initContent.
Linux / WSL / Synology: there is no Nix here, so link the three shell paths and point git at the matching per-platform config:
ln -s ~/dotfiles/.zshenv ~/.zshenv
ln -s ~/dotfiles/.zshrc ~/.zshrc
ln -s ~/dotfiles/.zsh ~/.zsh
ln -s ~/dotfiles/.gitconfig-global ~/.gitconfig-global # included by the per-OS gitconfig
ln -s ~/dotfiles/linux/gitconfig ~/.gitconfig # or wsl/gitconfigDo not
ln -s ~/dotfiles/.config ~/.config. That is what this repo used to do, and it is exactly what the~/.configsection above explains the move away from: every application then writes its runtime state inside the git repo. Link individual files.