- Python 46.9%
- Shell 27.1%
- Go Template 13.8%
- Lua 9.7%
- CSS 1%
- Other 1.5%
|
Some checks failed
ci / lint (push) Failing after 16s
Verified after re-login: the fresh sway session carries memlock unlimited + rtprio 98 (Bitwig side done), but user@.service still reports LimitRTPRIO=0 — PAM limits don't reach systemd user units, and pipewire/wireplumber/ pipewire-pulse all remain TS. rtkit closes that gap: pipewire's module-rt requests RT over dbus from the rtkit daemon, which also watchdogs and demotes runaway RT threads. AI-assisted-by: ollama-cloud/glm-5.3 (via OpenCode) |
||
|---|---|---|
| .forgejo/workflows | ||
| ansible | ||
| home | ||
| nix | ||
| pkgs/bin | ||
| .gitignore | ||
| .secrets.baseline | ||
| AGENTS.md | ||
| LICENSE | ||
| mise.toml | ||
| packages.md | ||
| prek.toml | ||
| README.md | ||
dotfiles
Laptop configuration split across two tools by scope:
home/— chezmoi source state for everything in~(dotfiles,~/.config,~/bin, user systemd units). Secrets in~are GPG-encrypted chezmoi entries (see Secrets).ansible/— a lean single-host playbook for system provisioning (/etc, SMB mounts, packages, system systemd). Mirrors the homelab's local-install pattern (pyproject + uv.lock + .venv + local collections).pkgs/—pkg-audit.py, the package manifest/audit script (~/bin/pkg-audit.pysymlinks here).
Setup
# 1. pinned tool versions (prek, mise, chezmoi, uv, …) + git hooks
mise install
prek install
# 2. apply ~ dotfiles via chezmoi. `init` renders home/.chezmoi.toml.tmpl
# into ~/.config/chezmoi/chezmoi.toml (sourceDir, gpg recipient); then apply
chezmoi init -S ~/code/dotfiles/home
chezmoi apply
# 3. system provisioning via ansible
cd ansible && uv sync && uv run ansible-galaxy collection install -r requirements.yaml
uv run ansible-playbook laptop.yml --ask-become-pass
chezmoi apply is idempotent — run it after pulling changes to refresh ~.
User systemd units under ~/.config/systemd/user/ are reloaded + re-enabled
automatically by home/run_onchange_systemd-user-units.sh.tmpl whenever a unit
changes.
Ansible roles
ansible/laptop.yml (run with uv run ansible-playbook laptop.yml --ask-become-pass)
applies these roles in order:
pacman— enables the[multilib]repository in/etc/pacman.confby uncommenting the shipped block in place (idempotent; no templating to avoid clobbering local edits or fighting.pacnew). Runs first so 32-bit packages from the manifest can resolve.packages— declarative install of the official-repo and Flatpak package set fromroles/packages/vars/manifest.yml. The manifest splits packages intopackages(official repos — installed by the role, including each entry'sopt_deps) andaur_packages(AUR — installed by theaurrole below), classified at authoring time viapacman -Slqso the role needs no runtime classifier. Runs first so per-role package tasks below are idempotent no-ops. Machine-specific entries carry anonly_onlist of DMIsystem_vendorstrings (e.g.panafanpwr→Panasonic Corporation,nvidia-open→LENOVO); the playbook exposesansible_facts.system_vendorasdmi_vendorinpre_tasks, and thepackages/aurroles filter out entries whoseonly_ondoesn't match.pkg-audit.pyis kept reconciled with the manifest and applies the sameonly_ongate (reads/sys/class/dmi/id/system_vendordirectly). See AGENTS.md for details.locale— enablesen_GB.UTF-8/en_US.UTF-8vialocale_gen.makepkg— drops amakepkg.conf.doverride (debug -> !debug); config only.aur— installs the manifest'saur_packagesviayayrun as a dedicatedaur_builderuser (passwordlesspacmanin sudoers, since makepkg can't run as root but needs sudo to install). Bootstrapsyayitself viamakepkgwhen absent. Runs aftermakepkgso AUR builds inherit the!debugOPTIONS.smb_mounts— CIFS shares via systemd.mount/.automount, credentials from ansible-vault.lowbat— the99-lowbat.ruleshibernate-on-low-battery udev rule.timezone— sets the system timezone and enablessystemd-timesyncd.resolved— systemd-resolved DNS + avahi mDNS,/etc/resolv.confstub,nsswitch.conf.localwiring.realtime— adds the login user to therealtimegroup created byrealtime-privileges(packages manifest), granting the ArchWiki pro-audio pam_limits (memlock unlimited, rtprio 98). Fixes Bitwig's memlock warning; audio apps launched from the session can opt into SCHED_FIFO.tlp— TLP power-management drop-in (/etc/tlp.d) + service.bootbackup— two pacman hooks (ArchWikiSystem backuppattern) thatrsync /bootto/.bootbackup/<timestamp>_{pre,post}/on any transaction touching a kernelvmlinuz. Gives the ext4+LUKS root a rollback point for kernel upgrades without btrfs/LVM snapshots.linux-ltsstays the live fallback; these snapshots are for restoring a working/bootfrom a chroot or live USB.nix— installs nix (multi-user) and enablesnix-daemon. The store lives in/nix/store; profiles live under~/.local/state/nix/profiles/. The wine/yabridge stack is installed fromnix/flake.nixinto a named profile — see "Wine & yabridge via nix" below.
Wine & yabridge via nix
Windows VST/VST3/CLAP plugins run through yabridge on pinned wine-staging 9.20, installed from nix — none of it from pacman:
- Why nix: yabridge's Wine host is a winelib binary coupled to the Wine
line it was built against. Arch's
yabridgeis built for new-WoW64 wine 10/11 and cannot run under wine 9 — but wine 9 staging is the last-known-good line for some plugins. nixpkgsnixos-24.11is frozen withyabridge5.1.0 built againstwineWowPackages.staging(wine-staging 9.20, classic WoW64), sonix/flake.nixjust re-exports the coherent stack: host binaries and runtime wine from the same evaluation, bitbridge (32-bit plugin support) included. - Named profile: the stack composition is codified as the
yabridge-stackbuildEnv in the flake; the named profile~/.local/state/nix/profiles/yabridgeholds exactly that one entry — one generation history, never put onPATH.run_onchange_nix-profile.sh.tmplconverges the profile on everychezmoi apply: it hashesnix/flake.nix+nix/flake.lockinto its rendered content, so a composition change or pin bump makes apply upgrade the profile (nix profile upgrade; plaininstallsilently skips when the entry exists). Fresh machines bootstrap the same way — first apply installs. Rollback:nix profile rollback --profile ~/.local/state/nix/profiles/yabridge. - Exposure is selective (chezmoi-managed symlinks, so the surface is
reviewed in git):
~/bin/yabridgectlis the only command the shell gets; the yabridge host wrapper pairs live in~/.local/share/yabridge, where the DAW discovers them via the XDG data home — no profilebin/is ever exported toPATH. The host wrappers hardcode wine's store path, so noWINELOADERis needed. - Ad-hoc activation:
nix-yabridge(~/bin) gives a subshell with the profile's full bin — the single interactive entry, so prefix maintenance always uses the same wine the plugins run (no devShell: it evaluates from the flake on every entry and can desync from the profile after a lock bump). One-shots pass through:nix-yabridge -c wine64 setup.exe. The ❄️ prompt indicator detects these shells by store bins on PATH — plainnix shellsets noIN_NIX_SHELL. - Env:
~/.zprofileexports onlyNIX_PROFILES— the yabridge chainloader requires it to findlibyabridge-*.soin the profile's lib. - Plugins: live in the
~/.wineprefix;yabridgectl syncmirrors the yabridge.sowrappers into~/.vst{,3}/yabridge(Bitwig picks them up from there).
Secrets
Two stores, one GPG root of trust (the primary ed25519 key that also unlocks
pass):
~secrets — GPG-encrypted chezmoi entries (home/**/encrypted_*.asc). chezmoi decrypts them onapplyvia the recipient in~/.config/chezmoi/chezmoi.toml. Add one withchezmoi add --encrypt <file>./etc/ system secrets — ansible-vault. SMB credentials + the rest ofansible/group_vars/laptop.ymlare vault-encrypted; the vault password is sourced frompass(dotfiles/ansible-vault-password) viaansible/vault-password.sh.
~/.gnupg/gpg-agent.conf is chezmoi-managed
(home/private_dot_gnupg/private_gpg-agent.conf, plaintext — it holds no
secrets, just agent tuning) and raises the passphrase cache beyond gpg-agent's
defaults (10min idle / 2hr max) to default-cache-ttl 3600 (1hr idle) /
max-cache-ttl 28800 (8hr max), since both pass (feeding mbsync/aerc/
imapnotify) and chezmoi's encrypted entries prompt through the same cached
passphrase. After editing, reload the running agent with gpg-connect-agent reloadagent /bye.
USB secrets backup
Offline cold backup of GPG keys, SSH keys, and the Bitwarden vault to a
LUKS-encrypted USB stick — a physical copy that survives if the NAS
(/mnt/backup) is gone. The restic home backup covers the working tree;
this covers the root of trust.
One-time setup (per stick), run as root:
sudo ~/bin/usb-secrets-init.sh
Prompts for the device path (shows lsblk first), wipes it, and creates a
single LUKS2 partition formatted ext4 with label secrets-backup. Has guards
against formatting a mounted device or the system SSD.
Refresh the backup each time you want to update the stick:
~/bin/usb-secrets-backup.sh
Plug the stick in, unlock it via the udiskie polkit prompt, then run the
script. Each run writes a self-contained timestamped snapshot directory under
/run/media/$USER/secrets-backup/secrets/<timestamp>/:
- GPG —
gpg --export-secret-keys(primary + subkeys, ASCII-armoured, non-interactive) and ownertrust (the trust database, stored separately from the keys — without it GPG forgets which keys you vouch for after a restore). - SSH —
rsync -a --delete ~/.ssh/so rotated keys are removed, not just added. - Bitwarden —
bw export --format encrypted_json. Requires an activeBW_SESSION; the script runsbw unlock --rawif one isn't set. - pass —
rsync -a --deleteof~/.password-store/(or$PASSWORD_STORE_DIR) into the snapshot. Entries are already GPG-encrypted at rest by pass, so the copy is safe on the stick without LUKS..gitis excluded — snapshots are point-in-time, and a restore is a copy-back, not a clone.
A LAST-BACKUP file at the secrets root records the latest snapshot timestamp
for a quick "what's current" check. Snapshots accumulate; prune old ones with
rm -r secrets/<old-timestamp>/ when needed. Everything written is already
secret-protected at the file level (GPG exports and SSH keys are
passphrase-encrypted, the BW export is master-password-encrypted), so LUKS is
defence in depth, not the only layer.
Systemd user services
A chezmoi run_onchange hook (home/run_onchange_systemd-user-units.sh.tmpl)
reloads the user manager and re-enables the session services this repo wants
running. It covers two groups:
- chezmoi-managed sway-session units —
kanshi,swayidle,sway-power-key,sway-suspend-key,sway-hibernate-key,battery-notify.timer,wlsunset,update-check.timer,backup-nag.timer,udiskie,mbsync.timer: unit files ship in~/.config/systemd/user/via chezmoi. - package-provided services (files in
/usr/lib/systemd/user/) —mako(notifications),cliphist(clipboard history; runswl-paste --watch cliphist store),waybar,pipewire/pipewire-pulse/wireplumber(audio),gcr-ssh-agent.socket/gnome-keyring-daemon.socket/p11-kit-server.socket(auth/keyring),com.nextcloud.desktopclient.nextcloud,mpris-proxy,thunar, and the per-accountgoimapnotify@<account>.serviceinstances (one per mail account — see Mail sync).
thunar ships without an [Install] (D-Bus-activated), so
thunar.service.d/install.conf adds one before enabling. The hook enables with
--now, so services start immediately when run inside an active session and at
the next login otherwise.
Some apps start via XDG autostart (handled by sway-xdg-autostart.target) from
desktop files installed by their own packages — no manual configuration needed:
blueman-applet— frombluemannm-applet— fromnetwork-manager-appletbitwarden— from~/.config/autostart/bitwarden.desktop
Kanshi manages output profiles (resolution, scale, position). Switch profiles manually with:
kanshictl switch <profile-name>
# profiles: mobile, docked-laptop-only, docked-external-only, docked-both-screens
Sway session (no display manager)
Sway is the only session. Boot lands on the getty; logging in on VT1
auto-execs sway via the XDG_VTNR = 1 guard in ~/.zprofile
(home/dot_zprofile.tmpl). Other VTs give a plain shell for running labwc
or a second sway. LightDM and the XFCE X11 session are retired: their one
config hook, ~/.xprofile (Qt 2x scaling, sourced only by LightDM's
Xsession wrapper), is gone from chezmoi. chezmoi does not delete targets it
no longer manages, so remove the applied ~/.xprofile by hand, and remove
lightdm + lightdm-gtk-greeter by hand too (never provisioned).
Env vars live in two layers, each with one job:
~/.zprofile— all user/session env (PATH,EDITOR, the gcr SSH socket) plus the vendor-gatedSWAY_UNSUPPORTED_GPU=true(LENOVOonly, DMIsys_vendorchecked at chezmoi-apply time). Without it wlroots refuses to start sway on the ThinkPad's NVIDIA GPU; with it, sway logs a permanent "unsupported GPU" warning that is expected noise. Login shells source it and sway's children inherit everything via the VT1 exec.- systemd user manager — env for user services (waybar, kanshi, mako).
sway-systemd's
/etc/sway/config.d/10-systemd-session.confimports compositor vars (WAYLAND_DISPLAY,SWAYSOCK,XDG_CURRENT_DESKTOP) into the user manager and D-Bus activation once sway runs.
XDG_CURRENT_DESKTOP is deliberately sway:GNOME, not bare sway.
Chromium and Electron safeStorage select their secret-store backend by
desktop name; bare sway reads as unknown and falls back to basic_text,
so Signal refuses to decrypt its keyring-encrypted database key. The
GNOME token also makes the systemd XDG autostart generator match
GNOME-only entries, so the gnome-keyring and at-spi autostarts are
masked (the keyring is owned by gnome-keyring-daemon.socket, the a11y
bus comes up via D-Bus activation) and nm-applet is pinned — its
NotShowIn=GNOME would otherwise hide it. All four overrides are
chezmoi-managed in home/dot_config/autostart/.
One caveat to know about: this env story relies on sway being launched from
a login shell (the VT1 exec). If a session manager that starts sway without
one ever comes back (greetd, SDDM), SWAY_UNSUPPORTED_GPU must move to
/etc/environment (pam_env) first.
aerc
Terminal mail client, chezmoi-managed at home/dot_config/aerc/ (applied to
~/.config/aerc/). aerc.conf + binds.conf are plaintext; accounts.conf
is GPG-encrypted (encrypted_private_accounts.conf.asc) since it holds account
identities. No plaintext passwords are stored — each account pulls credentials
from pass via source-cred-cmd / outgoing-cred-cmd (e.g.
pass show email/<entry>).
Mail sync (isync/mbsync + goimapnotify)
mbsync (from isync) syncs IMAP mailboxes to local Maildirs that aerc reads.
~/.mbsyncrc is chezmoi-managed and GPG-encrypted
(encrypted_private_dot_mbsyncrc.asc), one IMAPAccount/Channel pair per
account, each pulling its password from pass via PassCmd.
goimapnotify watches each account over IMAP IDLE and runs mbsync <channel> on new/changed/deleted mail — near-instant sync instead of polling.
Its per-account configs live at home/dot_config/imapnotify/, GPG-encrypted
(encrypted_private_<account>.yaml.asc, applied to
~/.config/imapnotify/<account>.yaml). Each account runs as an instance of
the package-provided goimapnotify@.service template (enabled by
run_onchange_systemd-user-units.sh.tmpl). mbsync.timer (chezmoi-owned, at
home/dot_config/systemd/user/) is a periodic fallback that runs mbsync -a
every 15 minutes as a safety net in case an IDLE connection drops silently.
CI
Forgejo Actions workflow at .forgejo/workflows/ci.yml runs on push to main
and on pull requests. A single lint job in an alpine:3.20 container:
- chezmoi verify —
chezmoi execute-templateagainsthome/to confirm the source state parses. - templated shell syntax — renders every
*.tmplunderhome/and pipes it tosh -n. - shellcheck — lints all tracked
.shfiles and chezmoiexecutable_shell scripts (.pyand.tmplexcluded). Currently set to--severity=error; tighten towarningonce the pre-existing info-level nits are cleaned up. - yamllint — lints repo-owned ansible yaml (vendored collections under
ansible/.ansible/skipped).
Git hooks
Pre-commit hooks are managed by prek, with tool
versions pinned via mise. Hooks include secret scanning
(detect-secrets with a committed .secrets.baseline), private key detection,
and standard hygiene checks (trailing whitespace, EOF, large files, YAML/TOML/
JSON validation, etc.).
mise install # installs the pinned tool versions
mise exec -- prek install # installs the prek git hooks
The .secrets.baseline file is committed to the repo. The hook only fails on
secrets NOT in the baseline. To add a new safe-but-flagged value to the
baseline:
uvx detect-secrets scan --baseline .secrets.baseline
To allowlist a single line without regenerating the baseline:
secret = "hunter2" # pragma: allowlist secret