Three files each appended /bootstrap.env to a value whose shape nobody had
pinned down, and they did not agree:
- the rotation scripts print the FULL file URL and say to store THAT in
Bitwarden, so that is what gets pasted
- dotup asked for the directory and appended /bootstrap.env itself
- .tests/e2e.sh independently duplicated dotup's assumption
Pasting the saved value made the request .../bootstrap.env/bootstrap.env. The
404 tripped curl --fail and surfaced as "endpoint refused the credentials. The
machine stays public-only" -- blaming the password for a URL shape, which is
about the most expensive wrong error message this path could produce.
dotup and the wrapper now both strip a trailing slash and a trailing
/bootstrap.env before building the request, so either form works and the
existing Bitwarden entry needs no editing.
Four assertions cover all four shapes -- directory and file URL, each with and
without a trailing slash. They eval the normalisation lifted straight out of
dotup rather than a copy, and that binding was mutation-tested: deleting the
line from dotup makes them fail with exactly the production symptom,
https://h/r/bootstrap.env/bootstrap.env. 101 -> 105.
The second instance, in e2e.sh, was found only by running the harness with the
file URL instead of the directory. Every earlier run passed because it was fed
the shape that happened to be in a variable, not the shape a person has in a
password manager. Both shapes now run end to end and pass.
The normalisation exists in two places because the harness cannot source
dotup's internals. That is the same duplication that caused this, and only
dotup's copy is covered by the suite.
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
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.
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 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, mermaid-cli and then neovim. The closure is
shown, not described — the counts move on the same keystroke.
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 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. The installer goes further and probes apt-cache before it batches: a
name apt does not know moves to the brew column rather than failing the whole
batch.
@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
101 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.