bcherb2 0dfb304ffb test: bind the URL assertions to the statement, not its indentation
53022b8 pushed with the suite red at 111/2 and a message claiming 113. The two
failures were real and I did not see them: I grepped only the section I had just
added and read the total with `tail -1`, which returns a blank line.

The failures were in the extractor, not in dotup. Moving the normalisation into
the retry loop changed its indentation from one tab to two, and the sed anchored
on `^\t` stopped matching -- so the eval became a no-op and the assertions
reported the doubled path as if the feature had been removed.

That is the mutation-binding doing its job: the test is coupled to dotup's real
code and noticed it moved. But coupling to leading whitespace makes any
reindentation look like a regression, so the pattern now matches the statement
with leading space allowed.

113 passed, 0 failed. The end-to-end also passes against the pushed dotup with a
wrong password sent first, which is what actually exercises the retry.
2026-08-17 22:58:31 -04:00

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 detect runs 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-global ends up holding an AWS SDK whose type definitions contain a literal AKIA… and an Anthropic SDK whose docs contain ghp_…. Scope the scan to chezmoi managed, and tighten sk- to sk-[A-Za-z0-9_-]{20,} — the bare substring matches ask-, task-, risk- and disk-, and one of this manifest's own npm packages is called rpiv-ask-user-question.
  • No identity. No name, email, hostname, private IP or absolute home path. If something needs one, it belongs behind a seam.
  • .chezmoiignore is 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 @needs edge can pull one in — but never silently, and never unattended.
S
Description
No description provided
Readme 902 KiB
Languages
Shell 73.3%
Lua 15.5%
Go Template 8.1%
Python 2.7%
Dockerfile 0.4%