Files
dotfiles-public/README.md
T
bcherb2 b487b0e855 feat: public dotfiles tier — no credential, no identity, one installer
Fresh history. This is the repo a throwaway VM clones anonymously: it brings a
machine to a working baseline and carries nothing that makes it mine.

56 files. 50 land in $HOME, 3 are chezmoi metadata, 2 are repo documentation,
1 is the manifest, and a 15-file test harness stays behind in .tests/.

What did not travel, and why:

  encrypted_private_bws-token.age   a real credential; age is dropped entirely
  .chezmoidata/bws.toml             env-var -> secret-id map; belongs with the
                                    tier that can use it
  SECRETS.md                        documentation of the rules, not config
  finish-setup.sh.tmpl              superseded by dotup
  nvim/init.lua.backup              dead file
  dot_claude/**, dot_codex/**,      120 files of agent config, private tier
  dot_pi/**

De-identified rather than dropped:

  .gitconfig   [user], the GitHub ssh rewrite and both Gitea host rewrites are
               identity, not configuration. They move behind an [include] of
               ~/.config/git/config.local, which the private tier writes. Git
               treats a missing include as a no-op, so a public-only machine
               reads the file and stops.
  .zshrc       the two gitea aliases carried a personal domain and a LAN IP.
               They move behind a guarded source of ~/.config/zsh/local.zsh,
               the sibling of the secrets.zsh seam phase 2 established.
  nvim         a commented-out LM Studio endpoint naming a LAN address.
  ghostty      a stale auto-generated header naming an absolute home directory.

Newly captured, never tracked before: ~/.zshenv, ~/.config/gh/config.yml. The
former sourced ~/.cargo/env unguarded, so every zsh on a machine without rustup
printed an error -- the same shape as the unguarded oh-my-zsh source phase 2
fixed. It is guarded now.

.chezmoiexternal.toml grows from one entry to six. oh-my-zsh, powerlevel10k,
zsh-autosuggestions, zsh-ai and tpm were hand-installed and declared nowhere,
which is why `chezmoi init --apply` on a clean box produced a .zshrc that broke
the shell it configures. The theme and both plugins nest under
.oh-my-zsh/custom/, which is what $ZSH_CUSTOM resolves to.

dotup gains an install engine. It resolves each selected package to a channel
(apt, brew, npm, uv, snap, deb, flatpak, tarball, script, builtin) through one
function every consumer reads, probes apt-cache before batching so a name apt
does not know moves to brew instead of failing all thirty, and retries
individually if a batch still fails -- which earned its keep on the first real
container run, where mermaid-cli's puppeteer dependency failed and the other
twelve npm packages installed anyway. --unattended computes safe defaults fresh
from the manifest rather than inheriting a state file, and refuses private and
invasive rows outright even when a stale state file ticks them.

The manifest gains @spec, a second directive kind alongside @needs, carrying the
argument a channel needs but a package name cannot supply -- the scoped npm
name, the flatpak app id, the .deb source. The TSV stays five columns wide.

Three bugs the container runs found, all fixed here:

  * `apt install nodejs` gives you node WITHOUT npm on Ubuntu, so all thirteen
    npm packages failed on a fresh box. The manifest asks apt for both names.
  * A tool installed a moment ago is not on this process's PATH -- uv lands in
    ~/.local/bin, npm -g honours the ~/.npmrc prefix, linuxbrew is outside a
    non-login PATH. Resolved by looking in the places we just wrote to, never by
    exporting a modified PATH.
  * `A || { B && C; }` is one || list, so when `command -v sudo` failed the list
    failed and `set -e` killed dotup at load. On a non-root machine with no
    sudo it died before printing anything. There is a regression test.

.zshenv and .p10k.zsh are marked private_. Both are shell code the login shell
executes and both applied at 664, group-writable. Third occurrence of the class
of bug phase 1 found on .pi/agent/auth.json and phase 2 found on .zshrc; the
first one found on purpose rather than by accident.

Verification: 81 assertions, 81/81 on this box and in ubuntu:24.04, ubuntu:22.04
and debian:12. 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 package landing on the test machine.
`gitleaks detect` over the full history and the working tree: no leaks found,
with no allowlist and no .gitleaks.toml.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:24:41 -04:00

10 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

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.

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

81 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.

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.