15 KiB
dotfiles — public tier
Everything a machine needs to become a working machine, and nothing that makes it mine. Clone it anonymously, apply it to a throwaway VM, and you get the editor, the shell, the terminal and the tools. You do not get my name, my email, my hosts, my agent config, or any credential — because none of that is in here.
The other half lives in a private repo you cannot clone without a password. That
split is the whole design, and it is structural rather than a policy: the gate
is which repo a machine can obtain, not a .chezmoiignore rule that could be
misconfigured once and leak.
Why the repo is shaped this way: DESIGN.md. How it got here: PLAN.md.
Both live in the working repo, not this one.
Install
sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply <this repo url>
~/.local/bin/dotup
The first line is also wrapped as bootstrap.sh in this repo, for anyone who
can fetch it raw but cannot remember chezmoi's incantation:
curl -fsSL <this repo raw url>/bootstrap.sh | sh -s -- <this repo url>
Two commands. The first lays down the files and clones the six externals; the second picks and installs the software. There is no third step, and nothing here prompts for anything.
Type the second path in full. Bare dotup will not be found in the shell
that ran the install, and that is not a bug in either command: Ubuntu's own
~/.profile adds ~/.local/bin to PATH only if the directory already exists
when you log in. On a fresh box it does not, the install then creates it, and
the shell you are sitting in never re-reads .profile. A new login shell finds
it. Nothing a child process can do changes the PATH of its parent, so the
install prints the working path rather than pretending otherwise.
git does not have to be installed first. It used to: chezmoi is a static
download and installs fine, but all six git-repo externals need git, and
Ubuntu cloud images, WSL and the docker image all ship curl and no git — so the
documented first command failed on the most ordinary kind of fresh box there
is. .chezmoiscripts/run_before_00-require-git.sh installs it across
apt/dnf/yum/pacman/zypper/apk/brew, and explains itself where it cannot (no
sudo, or macOS, where git is a stub that opens a GUI installer).
For a machine with nobody at the keyboard:
dotup --unattended # safe defaults, no UI, no prompt, no private tier
dotup --print # resolve everything and print the commands, install nothing
What lands where
| Path | What |
|---|---|
~/.zshrc |
Plain file, no template, no secrets. One guarded source is the entire secret surface. |
~/.zshenv, ~/.p10k.zsh |
Shell environment and prompt |
~/.config/nvim/** |
LazyVim, 36 files. lua/plugins/image.lua is the fragile one — see DESIGN §3 before touching it. |
~/.config/{gh,ghostty,git,btop,herdr} |
Terminal and tooling config |
~/.tmux.conf → ~/.tmux/.tmux.conf |
Symlink into the gpakosz external |
~/.gitconfig |
No [user]. See "the seams" below. |
~/.local/bin/{dotup,sysjournal} |
The installer and the journal CLI |
~/.local/share/dotup/packages.tsv |
The manifest dotup reads |
The seams
Three files this tier reads and never writes. Each is absent on a public-only machine, and each absence is a silent no-op rather than an error.
| File | Written by | Carries |
|---|---|---|
~/.config/git/config.local |
private tier | [user], signing key, Gitea host rewrites |
~/.config/zsh/local.zsh |
private tier | aliases naming hosts you own |
~/.config/zsh/secrets.zsh |
dotsecrets, from bws |
the seven API keys, mode 600, in no repo |
If git commit says it does not know who you are, the private tier has not been
applied. That is the correct complaint, not a bug.
Externals
Six git-repo externals, none of which were declared anywhere before this repo
existed. That omission is why a clean bootstrap used to produce a broken login
shell: .zshrc sourced $ZSH/oh-my-zsh.sh with nothing having installed it.
.tmux, oh-my-zsh, powerlevel10k, zsh-autosuggestions, zsh-ai, tpm. The theme
and both plugins are nested under .oh-my-zsh/custom/, which is what
$ZSH_CUSTOM resolves to; chezmoi processes externals in path order, so the
parent is cloned first.
dotup
One screen. Groups and packages are the same kind of row, both carry a tick box, and every group opens into the packages underneath it. Group boxes are a tri-state summary of their children, never an independent switch.
▸ [x] core 31/31 safe
▾ [~] media 2/3 safe
[x] ffmpeg apt ffmpeg
[ ] sox apt sox
[x] p7zip apt p7zip-full
▸ [ ] gpu 0/3 invasive kernel modules; a bad driver can bre…
▸ [ ] private 0/2 private one password, typed after the install…
space tick tab open ^t tick all shown ^a defaults ^x none ^o open all enter install
One toggle rule, everywhere. Expand the row to the packages it covers; if
every one is on, turn them all off, otherwise turn them all on. That single rule
covers a group row, a package row, and a bulk toggle over a filtered set — type
nvidia, press ^t, and exactly the three rows you can see flip.
The risk model. safe is pre-ticked; gui is pre-ticked only where there
is a display; invasive is never ticked for you, and its reason is on the row;
private needs a password you type after the install finishes. A group's flag
is derived as the worst flag among its children, so a group can never look
safer than something inside it.
Dependencies tick themselves. @needs closure runs in both directions:
ticking xrdp ticks the desktop group it is useless without, and unticking
core/node drops codex, pi, pi-plugins, mermaid-cli and then neovim
— six rows including node itself. The closure is shown, not described: the
counts move on the same keystroke. ^t is the one place the closure is
clipped: it follows @needs only as far as the first invasive row, so a bulk
toggle over a filter can never tick a root-equivalent daemon you were not
looking at — filter for nvidia and the docker group stays off. Press ^t
again over the same rows and it hands back exactly what it took.
^o is a toggle, not "open all": press it again and everything closes. And on
the default collapsed screen every group row counts as "shown", so a single
^t there reaches all 69 packages — including the invasive and private ones.
That is the documented rule working as specified, but it is worth knowing
before you press it.
The manifest
Five tab-separated columns, greppable by hand. Anything that would have been a
sixth column is an @ directive instead:
@needs core/neovim core/imagemagick core/mermaid-cli
@spec agents/codex @openai/codex
core neovim safe -tarball neovim apt ships 0.9.5 — tarball to /opt/nvim
core node safe -tarball node apt ships 18.19 and the npm rows need 20+
core fd safe fd-find fd binary is fdfind on ubuntu
A leading - in the apt or brew column means "not from this package manager":
-tarball -npm -script -snap -deb -flatpak -uv -builtin -xcode. A bare -
means unavailable there — and Linux resolves apt-then-brew, not
apt-instead-of-brew, because omp, herdr and lazygit have no apt package
at all — and core/brew is itself a script row that those three @needs, so
ticking lazygit brings Homebrew with it. On Linux that means linuxbrew, under
/home/linuxbrew/.linuxbrew; /usr/local is not touched. Channel order is a
dependency graph and not a preference: script runs before brew, because
otherwise brew would still be missing when the rows needing it are attempted.
The installer probes apt-cache policy before it batches, and asks for the
installation candidate rather than merely whether the name is known: on stock
Ubuntu apt-cache show docker-ce prints nothing and exits 0, so an
existence test kept it in the batch, apt refused all thirty packages at once,
and the brew fallback was never reached. One unavailable name took thirty
installable ones down with it. A name with no candidate now moves to the brew
column instead.
@spec carries the argument a channel needs but the package name cannot supply
— the scoped npm name, the flatpak app id, the .deb source. It defaults to the
package name.
Where fzf comes from
The picker cannot get fzf from the manifest: it needs fzf to draw the
manifest. So it brings its own copy into ~/.cache/dotup/ and invokes it by
absolute path. PATH is never modified and ~/.local/bin is never written
— a tool that refuses to tick invasive for you has no business quietly
shadowing your distro's fzf for Ctrl-R and the oh-my-zsh plugin. Your own fzf
wins whenever it clears the floor; nothing is replaced merely for being old.
Floor is 0.44.0, which is exactly where verification stops rather than where
compatibility breaks: every release from 0.29 up parses every binding used here,
but cursor-on-reload can only be measured from 0.44.1, where fzf's --listen
API began reporting state.
The private tier
Ticking a private row schedules work, it does not do it — git, chezmoi and
bws have to exist first, and a password typed at picker time would sit in
memory through ten minutes of package downloads. So it happens at the end, and
never without a human:
prompt: URL · username · password the address is in no repo
│
├── blank ─▶ done. public-only machine. nothing was asked for.
├── 401 ─▶ say so, leave the machine public-only
└── 200 ─▶ two-line blob ─┬─ private repo → chezmoi init --apply
└─ bws token → ~/.config/bitwarden/bws-token (600)
The endpoint returns data, never a script: two KEY=VALUE lines,
PRIVATE_REPO_URL= and BWS_ACCESS_TOKEN=. Compromising it discloses two
revocable read-only credentials rather than executing arbitrary code as you on
every machine you ever build. The password is spent, never stored, and never
reaches argv — curl -K - reads it from stdin, so it never reaches ps
either.
The clone URL that comes back carries a read-only token inline, and it gets the
same treatment. The token is split out into
~/.config/dotfiles/private-credentials (mode 600, git-credential-store
format); chezmoi init receives a clean URL, and the helper is written into the
clone's own config so a later chezmoi update still authenticates. Not printing
it was never sufficient: /proc/<pid>/cmdline is world-readable, so passing the
credential-bearing URL to chezmoi init disclosed the token to every account on
the box for the length of the clone, and then persisted it in .git/config
until the tree was deleted. The redaction that predates this only ever covered
the trace line.
dotup --unattended cannot reach any of this. It has nobody to type a password,
and it refuses private and invasive rows outright even if a stale state file
ticks them.
Tests
sh .tests/test.sh # here
sh .tests/test.sh --docker # clean ubuntu:24.04
DOTUP_TEST_IMAGE=ubuntu:22.04 sh .tests/test.sh --docker
DOTUP_TEST_IMAGE=debian:12 sh .tests/test.sh --docker
202 assertions covering the toggle rule, the @needs closure both ways, the risk
model as invariants rather than prose, match confinement (two-sided: --exact
confines nvidia to three rows and fuzzy still over-matches, so removing
--exact fails loudly), the fzf preflight, channel resolution on both
platforms, the installer, and that dotup runs at all as a non-root user with no
sudo on PATH.
The installer is driven against a directory of fake package managers that record what they were asked to do and install nothing, so the engine is exercised end to end without a single package landing on the machine running the suite.
The version spread is the point of the containers — this box runs fzf 0.72, 24.04 ships 0.44.1, 22.04 ships 0.29 and bookworm 0.38.
The macOS half of the manifest has no CI anywhere, so a fake uname stands in
for it. That is not theatre: it caught the apt/brew fallback running in both
directions, which resolved Linux-only packages to apt install davfs2 on a
machine that has never had apt. The fallback is one-directional now.
End to end
The suite above installs nothing. .tests/e2e.sh does the opposite — a stock
ubuntu:24.04 container, the public tier fetched from the anonymous URL, then
the private tier through a real bootstrap endpoint, then bws, then a rendered
secrets.zsh.
bash .tests/e2e.sh # prompts for endpoint URL, username, password
The password is never written to disk and never placed on a command line: it
reaches the container as docker exec -e BOOT_PW with a bare name, which
inherits from the calling shell. -e BOOT_PW=… would put it in docker's own
argv, and /proc/<pid>/cmdline is world-readable — the same bug this repo was
fixed to stop committing itself.
It asserts what the unit suite structurally cannot: that a machine which has
only ever seen the public repo can authenticate, clone the private tier, and end
up with no credential in its logs and none in .git/config, with the token
in a 600-mode credential file that a later fetch still authenticates from.
Without an endpoint password, .tests/e2e-mock-endpoint.py stands in for
Caddy — 200 with the blob, 401 on bad credentials, and a closed connection on
any other path, which is what handle { abort } does and why a retired route
reports 000 rather than 404. Everything downstream stays real. That is how
the argv fix was verified, and running it that way immediately caught a rotation
script whose route regex had stopped matching.
Rules this repo lives by
- Zero credentials, forever.
gitleaks detectruns over the full history with no allowlist. The private repo gets exactly one allowlisted path; this one gets none, and that asymmetry is the point. - Grep the files this repo owns, not
$HOME. A wholesale scan of a finished machine is not a test of this repo:~/.npm-globalends up holding an AWS SDK whose type definitions contain a literalAKIA…and an Anthropic SDK whose docs containghp_…. Scope the scan tochezmoi managed, and tightensk-tosk-[A-Za-z0-9_-]{20,}— the bare substring matchesask-,task-,risk-anddisk-, and one of this manifest's own npm packages is calledrpiv-ask-user-question. - No identity. No name, email, hostname, private IP or absolute home path. If something needs one, it belongs behind a seam.
.chezmoiignoreis gitignore syntax. A#mid-line becomes part of the pattern and silently matches nothing. Every comment goes on its own line.- Nothing invasive without an action of yours. Not "never", since a
@needsedge can pull one in — but never silently, and never unattended.