The suite is rewritten against the sealed harness: a fresh $HOME per case, a recorded call log per box, and assertions about what dotup DID rather than about which words appear in the file. ptydrive.py drives a command on a real pty from a small expect/send script, so the private tier's password prompt, its retry loop, the token file and the clone can be exercised at all — `expect` is not installed everywhere and python3 is. New guard for DU-C1: `dotup pick` is actually run, on a pty, against an fzf fake that dumps its argv one argument per line. It must exit 0, hand fzf every --bind the source writes (counted against the source, not a hard-coded 7), bind all seven keys, and leave the completed-pick marker behind. Every other test here greps the source for bind strings, which is exactly why all of them passed while the picker was dead. A static check alongside it rejects any comment sitting between continued lines, anywhere in the file.
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.