# 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 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply ~/.local/bin/dotup ``` The first line is also wrapped as `bootstrap.sh`, which defaults to this repo's own clone URL — the one place in the tier a real host appears, because fetching the file proves you already know it: ```sh curl -fsSL /bootstrap.sh | sh ``` 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: ```sh 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: ```tsv @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//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 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`. ```sh 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//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.