30 Commits

Author SHA1 Message Date
bcherb2 170db93f35 fix: nvim first boot on a fresh box — tree-sitter CLI, no luarocks, guarded local plugin
nvim-treesitter (main) hard-requires the tree-sitter CLI: new manifest
row via npm. hererocks failed compiling lua 5.1 (readline headers) for
oxocarbon's incidental rockspec: rocks disabled, the colorscheme works
without. The ~/code/nvim-ai-assist local plugin gets a cond so boxes
without the checkout skip it silently. All three found live, phase 5 VM.
2026-08-22 22:45:47 -04:00
bcherb2 e52d1384ca fix: unattended install on a never-chosen box seeds the defaults
A fresh VM running `dotup install --unattended` got an empty plan and a
silent 'done'. -f $SEL cannot detect this (load-time setup creates the
file); choice is $PICKED or a non-empty selection, and only a box with
neither gets the defaults. A cron re-run of a saved selection and an
accepted chose-none both keep their meaning.
2026-08-22 22:42:39 -04:00
bcherb2 607745ecfd test: lab asserts GitHub reads stay https after the private tier applies 2026-08-22 22:33:32 -04:00
bcherb2 e682ed8a1d test: lab fixture mirrors the pushInsteadOf fix 2026-08-22 22:28:43 -04:00
bcherb2 926a71266e feat: bootstrap.sh defaults to its own clone URL — one URL to type 2026-08-22 21:58:59 -04:00
bcherb2 ea10ba9d4a feat: bootstrap.sh wraps the first command; the URL stays an argument 2026-08-22 21:52:28 -04:00
bcherb2 4043787a58 fix: close the four known installer bugs (DU-H1, DU-H2, BUG-1, BUG-2)
- DU-H1: flags are parsed wherever they sit, so `install --unattended`
  and `--unattended install` are the same run; any unknown flag, word,
  or subcommand exits 2 to stderr before a package manager is touched.
- DU-H2: every download lands in one private mktemp -d (mode 700)
  workdir per run, is checked non-empty before sudo tar sees it, and an
  EXIT/INT/TERM trap cleans up. No fixed /tmp paths remain.
- BUG-1: ^t is now toggle-shown — it ticks only the rows the active
  filter is showing, and @needs expansion stops at the first invasive
  row, so an invasive package can never be ticked off-screen.
- BUG-2: ^t journals what it added, so a second ^t over the same shown
  set unticks exactly that set; the bind no longer clears the query.
- lab: the type verb polls fzf's reported query to a deadline instead
  of a fixed sleep; marks_settled retries within its deadline.

Suite 256/0 host, 214/0 docker (ubuntu:24.04), mutations 24/24 killed
(six new mutants re-introduce each bug and all die), lab 6/6 green.
2026-08-22 13:25:21 -04:00
bcherb2 084fb7a730 fix: the public tier names no real host (SEC-1)
The e2e script hardcoded the live Gitea hostname as the public tier's clone
URL. This repo is world-readable, so that published the host, and the same
host serves the secret-protected bootstrap route.

It now comes in as E2E_PUBLIC_URL, prompted for by the wrapper alongside the
bootstrap URL and passed to the container the same way. The lab already did
this with PUB_URL; this is the live path catching up. The :? form means an
unset variable aborts at that line with a message, rather than surfacing
later as an unattributable clone failure.
2026-08-21 23:16:04 -04:00
bcherb2 7b28415cc6 test: refresh three picker assertions against the fixed behaviour
1ea6b49 changed what the picker does on purpose. Three assertions here were
still holding the old behaviour and failing for it; none of them were finding
a bug. The product is untouched.

- hostile 3 stacked two faults, no terminal AND no fetchable fzf, and asserted
  the fzf message. The /dev/tty probe was deliberately moved ahead of
  ensure_fzf (a run in a pipe used to print fzf's raw error having already
  overwritten the selection with the defaults preset), so with no tty the fzf
  message is now unreachable. Split in two: the no-tty arm asserts the ORDER,
  and a second arm runs the same missing fzf under a pty, via `script -e`,
  which is the only way to reach ensure_fzf's error at all now. Both arms
  still exit 2, and the fallback it names is still resolved for real.

- hostile 4's hand-edited state file was not being read: seeding the defaults
  preset is now decided by $STATE/picked ("a pick has completed") rather than
  by "$SEL is empty", so the fixture's junk was overwritten with 43 default
  rows before anything drew, and the session was testing the preset. core/tree
  is a default, so the space under test unticked it and the assertion read
  "space stopped working". The fixture now records a completed pick, as every
  machine that has pressed enter does. The CRLF row consequently reports its
  real result — silently ignored — where before it falsely read as tolerated,
  and the pass that followed the failure is no longer printed unconditionally.

- the `perm` session drove a picker that no longer draws. $SEL being readable
  and writable is now a precondition of drawing, because every bind is
  execute-silent and throws its child's status away (that was BUG-7). There is
  no session left to drive: the assertion is that nothing draws, that it exits
  2, and that the refusal names the file and a way out. Through a pty again,
  since the tty probe would otherwise answer first.

Deferred, and still failing: BUG-1/BUG-2 in `promise 3`, three assertions. ^t
over an --exact filter widens along @needs and ticks invasive rows that are
not on screen, and ^t ^t is not its own undo. There is no expected-fail
mechanism in this harness and none was invented, so the scenario exits 3 and
the header says why.

Verified: two consecutive runs of `run.sh scenarios/20-picker.sh` at exit 3
with exactly those three failures, and `scenarios/00-smoke.sh` still at 0.
2026-08-21 23:13:40 -04:00
bcherb2 805c407764 docs: README matches the shipped installer
Documents what the code now does: git is installed by the bootstrap rather than
assumed; the full path to dotup is what to type in the session that installed
it, and why; brew is a script row that lazygit, omp and herdr @needs, so
channel order is script-before-brew; apt resolution probes for an installation
candidate, not mere existence; ^o toggles and ^t clears the query; and the
header line carries ^o.
2026-08-21 22:34:53 -04:00
bcherb2 286c3281b5 test: the e2e can no longer pass on a failed install
Two assertions could not fail. `dotup --yes install | tail -8` threw the status
away — no set -e, no pipefail, and $? was tail's — so a run in which 36
packages failed to install still reached "E2E PASS". And the zsh login check
was a `|| echo WARN` that fell straight through. Both now fail the run.
2026-08-21 22:34:53 -04:00
bcherb2 a001406a33 test: container lab for the two-tier apply
A disposable ubuntu container, a fake private tier and a fake bootstrap
endpoint, so the whole documented path — chezmoi init --apply, dotup pick,
dotup private, cmp apply, dotsecrets — can run end to end without touching a
real machine or a real credential. The fake tier mirrors the real one's
structure (seven secrets and one alias) because dotsecrets is copied verbatim
and the "8 exports, not 7" assertion depends on that cardinality; its ids are
sequential and obviously synthetic.

check-verbatim.sh keeps the fake tier's copies of shipped files honest, and
snapshot.sh records file modes so a 644 where a 600 belongs is a diff.
2026-08-21 22:34:53 -04:00
bcherb2 d1e3f8bce2 test: mutation testing — break dotup on purpose and check the suite notices
Each file in .tests/mutations/ is one deliberate bug: what it breaks, why that
matters, the assertion meant to catch it, and an OLD/NEW pair applied to a copy
of the tree. A mutation is KILLED only when the suite fails AND the named
assertion is among the failures — failing for an unrelated reason is reported
as WRONG-TEST, because that is luck, and luck is lost the next time the
unrelated test moves. A mutation whose OLD block no longer matches is STALE
rather than quietly skipped.

19 reproduces the DU-C1 shape: a comment moved back inside the fzf
line-continuation. It is killed by "dotup pick exits 0".
2026-08-21 22:34:53 -04:00
bcherb2 8448ff551a test: run the code under test, not grep over its source
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.
2026-08-21 22:34:39 -04:00
bcherb2 042320208e test: seal the fake PATH instead of prepending it
The old harness put .tests/fakebin at the front of $PATH, which fails OPEN: a
package manager with no fake fell through to the real one, and an audit of this
suite invoked the host's actual `brew`. The twelve one-line fakes are replaced
by a single dispatcher every fake symlinks to, plus seal.sh, which builds the
WHOLE of $PATH for a run — real binaries for the pure tools, fakes for anything
that installs, downloads or needs root, and command-not-found for everything
else. Runs go under `env -i` so they inherit nothing.

Two audits ship with it: any `have`/`find_tool` probe the seal has never heard
of is an error, and the four absolute paths find_tool probes are written to
.shadowed so a test needing a tool to be genuinely absent can say so.

The dispatcher also fails on demand (FAKE_FAIL), which is what makes the
installers' failure paths reachable at all — the apt and npm one-at-a-time
retries, note_fail, and cmd_install's non-zero exit had all been unreachable,
and all three survived being deleted outright.

_curl stands in for every remote the installer talks to and for nothing else:
an unrecognised URL is a failed download, so a typo'd host shows up as the
failure it would really be. The fzf fake records its argv, which is what makes
the picker's bindings testable.
2026-08-21 22:34:30 -04:00
bcherb2 1ea6b492eb fix: dotup — installer, picker and private-tier correctness pass
Installer:
- channel order is a dependency graph: `script` now runs before `brew`, so
  core/brew exists by the time lazygit, omp and herdr are attempted. Those
  three have no apt package at all and failed on every fresh Linux box.
- core/brew and core/node are real script/tarball rows now. apt's nodejs is
  18.19 while four npm rows declare node>=20; npm only warns on a failed
  engines check, so the install "succeeded" and `pi` then died at parse time.
- apt_known asks `apt-cache policy` for an installation candidate rather than
  `apt-cache show` for existence: docker-ce exists in the cache with
  `Candidate: (none)` and exits 0, which kept it in the batch and made apt
  refuse all thirty packages at once.
- failures are reported by manifest key, not by install argument, so the name
  in the summary is one you can type at the picker or pass to `dotup explain`.
- flatpak installs the tool, adds the flathub remote, and uses --user, which
  is the only scope that works without a session bus.
- gh: deb assets fill %a and %v, because a distro-targeted deb names the
  release as well as the arch (ghostty-ubuntu).
- ensure_bws downloads into a `mktemp -d` instead of fixed /tmp paths.
- the endpoint password prompt says so out loud when stty cannot turn echo off,
  instead of silently leaving the password in the scrollback.
- cmd_toggle writes tmp-then-rename, matching cmd_expand.

Picker:
- DU-C1: comments had been inserted between the continued lines of the fzf
  invocation. The `\`-newline is stripped first, so the comment's own newline
  terminated the command — fzf ran with two binds and the lines below it ran
  as a command named `--bind`, so `dotup pick` returned 1 having drawn a dead
  picker. The comment now sits above the pipeline.
- the header is built with a plain variable: `$'...'` is a bashism and /bin/sh
  on Ubuntu is dash, the one platform this is written for.
- the /dev/tty probe runs BEFORE the defaults preset, so a run in a pipe no
  longer overwrites the selection with 43 rows before failing to draw.
- a completed pick is recorded in $STATE/picked, so "install nothing" survives
  the next run and a prior `dotup plan` no longer counts as having chosen.
- $SEL must be readable and writable before anything draws: every bind is
  execute-silent, which throws its child's status away.
- ensure_fzf resolves system-first, cache-second, as the README promises.
- ^t clears the query, so rows pulled in along @needs are on screen.
2026-08-21 22:34:22 -04:00
bcherb2 94d6acadb3 feat: bootstrap installs git, and prints a next step that works
run_before_00-require-git.sh: the documented first command died on the most
ordinary kind of fresh machine there is. chezmoi is a static download and
installs fine, but all six git-repo externals need git, and Ubuntu cloud
images, the Ubuntu docker image and WSL Ubuntu all ship curl and no git.
Installs it across apt/dnf/yum/pacman/zypper/apk/brew, and explains itself
where it cannot (no sudo, or macOS where `git` is a GUI-installer stub).

run_after_99-next-step.sh: `dotup` is command-not-found in the very session
that installed it, because Ubuntu's ~/.profile adds ~/.local/bin to PATH only
if the directory existed at login. No child process can fix its parent's PATH,
so print the absolute path instead of letting the user guess. Silent once PATH
is sorted out.
2026-08-21 22:34:06 -04:00
bcherb2 f30dddce15 fix: resolve chezmoi instead of assuming it is on PATH
The private tier died with `chezmoi: not found` on a real install, AFTER the
bws token had been written -- half-configured, at the last step, password
already spent.

dotup arrives VIA chezmoi, so "it must be here already" is the natural
assumption. It is wrong: get.chezmoi.io installs to ./bin relative to the CWD
when -b is not given, which is exactly what the README's one-liner does. Run it
from $HOME and the binary is ~/bin/chezmoi; run it from /workspace, as anyone
in a container does, and it is /workspace/bin/chezmoi. Neither is on PATH, and
~/.local/bin is not either in bash -- the same gap that makes bare `dotup` fail
on a fresh box.

find_tool exists for precisely this and the call site bypassed it.

  - find_tool now also searches $HOME/bin
  - ensure_chezmoi resolves it, and installs to ~/.local/bin only if it truly
    is absent, mirroring ensure_bws
  - the init uses "$CHEZMOI", never the bare name
  - resolution happens BEFORE the credential prompt, so this fails while it is
    still free rather than after the password is spent

Verified by reproducing the exact scenario: installer run from /workspace, so
chezmoi lands in /workspace/bin and nothing on PATH can see it. Old code:
`chezmoi: not found`. New: detected, installed, prompt reached. Also confirmed
~/bin/chezmoi is found WITHOUT re-downloading, so the common case costs nothing.

113 -> 117.
2026-08-17 23:09:03 -04:00
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
bcherb2 53022b8146 fix: a wrong password no longer costs an entire reinstall
The endpoint credentials are asked for at the very end of a run, after every
package is installed -- correct, because a password typed at picker time would
sit in memory through ten minutes of downloads. But any non-200 was fatal, so
one mistyped character meant repeating the whole install to get back to a
three-line prompt.

Now it retries, up to five attempts. URL and username persist across them and
blank keeps them, shown as `Bootstrap URL [https://...]:`, so only the password
is retyped -- retyping an address that was already pasted correctly is its own
source of error. `q` at the URL prompt leaves the machine public-only.

The message also names the fault. Every failure used to print "endpoint refused
the credentials", including a 404 and an unreachable host -- which is precisely
how a URL-shape bug reads as a password problem. The status is now captured
alongside the body rather than relying on --fail:

  401  wrong username or password
  404  reached the host, but no bootstrap.env is there -- check the route
  000  could not reach that address (DNS, TLS, or the host is down)

The body is used only when the code is 200, so an error page is still never
parsed as a blob; that was --fail's job and an explicit check is stronger.

`dotup private` already re-ran only this step, leaving installed packages
alone -- it was simply missing from --help, so the cheap way back in was
undiscoverable. Documented, and the failure path now points at it.

The end-to-end sends a wrong password first on purpose and asserts both the
message and that blank-blank-correct works, which is the only way to test a
recovery path. Note that harness clones the PUBLISHED repo, so it validates
what a user gets, not the working tree -- these invariants are covered in the
unit suite, which reads the tree directly. 105 -> 113.
2026-08-17 22:57:08 -04:00
bcherb2 2afdfd093b fix: accept either bootstrap URL shape, in dotup and in the harness
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.
2026-08-17 22:52:11 -04:00
bcherb2 2a93bfd5b1 test: assert dotup's absolute path before touching PATH
The check was `export PATH="$HOME/.local/bin:$PATH"` followed by
`command -v dotup`, which proves the export worked and nothing else. It passed
on every run while a real operator on a fresh box hit
`dotup: command not found`.

~/.local/bin is put on PATH only by the .zshrc this tier ships, for a zsh that
dotup itself installs. So on any new machine the first invocation must be
~/.local/bin/dotup -- which the README already documents -- and bare `dotup`
cannot work until zsh is installed and logged into.

Assert `-x ~/.local/bin/dotup` first, then export for the rest of the script's
convenience. Reproduced the original failure in a clean root container before
changing anything, and re-ran the end-to-end after: PASS.
2026-08-17 22:30:55 -04:00
bcherb2 cebb38b97a test: move the end-to-end here, and fix the race that made it flaky
The e2e harness and its mock endpoint were living in the private repo, justified
by "they name the endpoint host". That was true when written and false two
commits later, once the hardcoded URL came out of the wrapper so the route would
live only in Bitwarden. Rechecked: all three name the endpoint zero times -- the
URL, username and password are supplied at runtime -- and the only host they
mention is this repo. They belong next to the dotup they exercise and the unit
suite that covers the rest of it.

The move surfaced a worse problem than the misplacement. The harness was flaky,
and an earlier PASS was partly luck.

chezmoi writes "git user.email?" with the tty still in cooked mode and only then
switches to raw mode -- with TCSAFLUSH, which discards whatever is already
buffered. Answering on the prompt TEXT races that switch. One run answered all
seven prompts; the next lost the Enter after user.email, leaving the field
unsubmitted. Every later expect then waited out its own timeout and the whole
thing surfaced at the 600s ceiling as an unattributed "a prompt went
unanswered".

Two fixes:

  - each answer now waits for \033[?2004h, bracketed-paste-on, which the TUI
    emits only AFTER raw mode is established. "Probably ready" becomes
    "demonstrably ready", and each prompt emits its own, so it is per-answer.
  - a bare `expect -re {pat} {...}` treats timeout as "carry on", which is what
    turned one lost keystroke into a ten-minute mystery. Prompts now fail
    immediately naming which one was missed, and distinguish EOF (dotup exited
    early) from timeout.

Also moved red(), the redactor, above the run. It was defined below the new
early-abort path that calls it, so the one branch that most needs redaction
would have hit an undefined function.

Verified by running it twice end to end against a mock endpoint, both passing
identically, including the three assertions the argv fix exists for.

README: the suite is 101 assertions, not 81, and the end-to-end is documented.
2026-08-17 22:14:35 -04:00
bcherb2 24744997eb docs: record where the private-tier clone credential goes, and why 2026-08-17 21:01:19 -04:00
bcherb2 63bed1d32d fix: keep the gitea token out of chezmoi init's argv and .git/config
The endpoint hands back a clone URL with the read-only token inline, and that
URL went straight onto `chezmoi init`'s command line. Two durable exposures
followed, neither of which the existing redaction touched -- it only kept the
token out of the trace line:

  - /proc/<pid>/cmdline is world-readable, so any account on the box could read
    the live token for as long as the clone ran
  - the resulting .git/config recorded the credential-bearing remote and kept
    it until the tree was deleted

Split the credential out of the URL before anything executes. The token goes to
$STATE/private-credentials at 600 in git-credential-store format; the URL that
reaches argv and .git/config is clean. The helper is passed via GIT_CONFIG_*
for the clone and then written into the clone's own config, so a later
`chezmoi update` still authenticates without the token being stored.

Verified: clone succeeds with the clean URL, .git/config contains no token, and
a subsequent fetch authenticates from the credential file alone. Suite 101/101.
2026-08-17 20:21:39 -04:00
bcherb2 68e16ad1ff fix: pin the bws version instead of resolving it
ensure_bws resolved the version from the GitHub releases list API. That
worked on the dev box and failed in a bare container with "could not
resolve the current bws version" — because the dev shell had `gh`
authenticated and the container did not. Unauthenticated, that endpoint
returns an empty array for this repo.

The obvious fallback does not work either: sdk-sm is a monorepo with
per-component tags, so /releases/latest redirects to python-v2.1.0 rather
than any bws release.

Release-download URLs need no auth, so BWS_PIN=2.1.0 and the published
sha256 makes bumping it safe. Same pattern as FZF_PIN.
2026-08-17 11:23:16 -04:00
bcherb2 400bd9b9f1 fix: install bws from the private tier, not the manifest
The previous commit put the bws install on the private/bws-secrets row as
a tarball channel. That row can never install anything: selected_packages
drops every row flagged `private`, which is the invariant that makes
--unattended safe to run. The container proved it — unzip installed, bws
did not, and the row still could not deliver on its promise.

Nor can it be a safe row: the defaults preset ticks every safe package, so
that would put a Bitwarden binary and a 12 MB GitHub download on every
throwaway public VM, for a tool those machines have no credential to use.

So ensure_bws lives in dotup and is called from cmd_private immediately
after the token is written. Nothing installs bws unless something is about
to hand it a token, and the manifest keeps its invariant.

core/unzip stays a real package with the @needs edge — the release is a
.zip and 24.04 minimal has no unzip.
2026-08-17 11:20:45 -04:00
bcherb2 90bca39396 fix: install bws, isolate the private config, keep credentials out of logs
Three defects the private tier could not survive a fresh machine with.

bws was never installed. The private/bws-secrets row promised seven API
keys and shipped no way to fetch them: dotsecrets shells out to `bws
secret get`, and nothing put that binary on the box. Added as a tarball
channel with checksum verification, musl rather than gnu so it does not
pin a glibc newer than an older LTS carries, plus core/unzip as a real
@needs dependency since the release is a .zip and 24.04 minimal has no
unzip. The releases API needs filtering by tag prefix: sdk-sm is a
monorepo and `latest` usually points at a python SDK, not bws.

Both tiers rendered their config to ~/.config/chezmoi/chezmoi.toml, so
re-running the public installer overwrote the private config and took its
seven promptStringOnce answers with it. Silently -- the templates degrade
politely when data is missing, so the symptom was `git commit` not knowing
who you are, days later. The private tier now renders to private.toml and
the cmp alias carries the matching -c.

`run` echoes its argv to stderr, and the private init argv ends in
https://user:TOKEN@host -- into scrollback, any `dotup 2>log`, and any
agent transcript. Traced through redact_url instead, in both the live and
dry-run branches. The clone is also chmod -R go-rwx afterwards: it lands
at the caller umask, and .git/config stores that same credential URL.

Tests: 90 passing, 9 new covering all three.
2026-08-17 11:15:48 -04:00
bcherb2 e1332c67d8 feat: carry CLAUDE.md and AGENTS.md in the public tier
Phase 3 excluded all of dot_claude/, but the plan always intended CLAUDE.md
to be the one agent file that ships publicly. Adds it plus the codex and pi
AGENTS.md, so a throwaway VM gets sane agent behaviour with no credential.

Specialized agents, skills, hooks and the language rule sets are deliberately
absent -- they live in a separate repository now. The Active Rule Sets section
is rewritten to say so rather than dangle a reference to a rules/ tree this
repo does not carry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:32:39 -04:00
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