Skip to content

Repository files navigation

dotfiles

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

How ~/.config is wired

~/.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:

  1. add a !/.config/path/to/file line to the allow-list in .gitignore
  2. add a "path/to/file".source = link "path/to/file"; entry to xdg.nix
  3. git add .config/path/to/file && just switch

git check-ignore -v <path> explains why any given path is ignored.

.gitignore still 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 install

Layout

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

Shared: these land in $HOME

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

Per platform

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 ~/.config is one symlink shared by every machine, anything put in .config/ shows up on macOS too. Symlink linux/config/* individually on Linux.

Repo infrastructure

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 by nix-darwin/modules/home-manager/programs/git.nix into ~/.config/git/config. The linux/, wsl/ and windows/ gitconfig files are for the machines without Nix, and each one includes ~/.gitconfig-global.


macOS: nix-darwin

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 and mutableTaps = false, so brew cannot drift.
  • home-manager (modules/home-manager/): per-user config for git, gnupg, direnv and zsh, plus activation/ 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.

Everyday commands

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 store

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

Two gotchas worth knowing

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

Zsh

One .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.nix and sourced straight out of /nix/store.
  • Everywhere else, .zshrc sources whatever the system package manager installed, searching the usual prefixes (/usr/share, /opt/share for 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.plugins is deliberately not used. That option materialises plugins under ~/.zsh/plugins, and ~/.zsh is a symlink into this repo, so it would write generated store symlinks into your working tree.

History: Atuin

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 auto

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

Other machines

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

On 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>' and ssh nas -t 'zsh -i' both skip /etc/profile, which is the only thing that puts /usr/local/bin and /usr/syno/bin on $PATH. Under those, roughly 250 installed SynoCli tools look missing and synopkg status reports packages as stopped when it merely lacked root. Use the $SHELL -l form above before concluding anything is absent.

Copying files

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 denied

ChrootDirectory 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 only

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

Installing Entware

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 /opt

If 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 -i

Then, 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/sh

At 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 update

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

zsh plugins

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

No 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-zsh

Nothing 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; done

CLI tools

Tools 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-exiftool

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

Containers

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 ports

DSM 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. sudo keeps its own secure_path, which holds neither /usr/local/bin nor /usr/syno/bin, so sudo docker and sudo synopkg both answer command not found inside a script or under ssh nas '<cmd>'. Write /usr/local/bin/docker and /usr/syno/bin/synopkg there.

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.

Nix

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 you

Then 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-daemon

Write ~/.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 flakes

The 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 /nix

zshrc_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 PATH contains only store paths. In practice x86_64-linux is almost entirely cache hits, so builds are rare.

home-manager on the NAS

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/activate

Before 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
done

Build it from the repo root, not from nix-darwin/. programs/zsh.nix reads .zshrc and .zshenv from the repo root, which is above the flake directory, so a flake reference that copies only nix-darwin/ fails with access 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).

Testing it

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 one

Each 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).


Neovim

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 toolchains

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 + components

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


Checks

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.

typos sets ignore-hidden = false. It skips hidden paths by default, which in a dotfiles repo means skipping .zshrc, .zsh/ and .config/, which is nearly everything.


New machine

git clone git@github.com:jbsilva/dotfiles.git ~/dotfiles
cd ~/dotfiles && just hooks

macOS: 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/gitconfig

Do not ln -s ~/dotfiles/.config ~/.config. That is what this repo used to do, and it is exactly what the ~/.config section above explains the move away from: every application then writes its runtime state inside the git repo. Link individual files.

About

Meus dotfiles

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages