bootstrap v2026.09
Windows 1.8.1
Fixed
-
-ShowVersioncould not run anywhere but Windows, which is a problem
because the release workflow runs it on a Linux runner to prove the script
can start at all. The check found this on its first real release, which is
the entire reason it exists.The version block sat after the path resolution, and three lines of that
resolution read$env:LOCALAPPDATA,$env:ProgramFilesand$env:WINDIR.
All three are null off Windows, soJoin-Paththrew "Cannot bind argument
to parameter 'Path' because it is null" long before the version was printed.The early return is now the first thing the script does after
Set-StrictMode, so nothing about the host has been assumed by the time it
answers. Same shape as the$PSScriptRootbug the file already documents:
it parses, every line is right on its own, and only one invocation shows it.
Linux 1.2.0
Added
-
A Nerd Font, which powerlevel10k has needed since day one. The Windows
manifest has installed Meslo since its first release; this side installed the
theme that requires it and no font at all, so the prompt rendered as boxes.Desktop machines only, and that is not a size argument: the glyphs are drawn
by the terminal you are typing at. SSH into a headless box from a terminal
that already has Meslo and the prompt is correct with no font on the server;
install one there and nothing anywhere looks different. -
RELEASES, a third category of software alongside apt packages and git
clones: a single static binary from a GitHub release, into~/.local/bin.
For things that are in neither the Debian archive nor a repository you can
clone and run.Unlike the
TOOLSclones these are version-checked properly — the binary is
asked what it is, the newest release tag is fetched, and a run where they
already agree downloads nothing and sayscurrent. -
tflint and terraform-docs, as the first two
RELEASESentries. Neither
is packaged by Debian in any release. They are the Terraform counterpart to
theansible-lintandyamllintpair that was already here. -
Claude Code, installed once by Anthropic's script into
~/.local/binand
then left alone, because it updates itself. Reinstalling it every run would
be the second installer in a fight it cannot win. -
~/.local/binon PATH in the managed zsh fragment. The stock~/.profile
on Debian adds it, but zsh never reads.profile— so without this the three
things above install correctly and none of their commands exist.
Changed
-
btop replaces htop and tmux replaces screen in the
cligroup. -
{UNAME_ARCH}is substituted into release URLs, alongside the{ARCH}
that was already there. The same machine has two names — dpkg saysamd64
andarm64,uname -msaysx86_64andaarch64— and upstream projects
are split about evenly over which they name their assets for. Both words are
now available to aRELEASESentry, off one definition that the AWS CLI
phase shares, so the two phases cannot drift on what the word means.Not named
GOARCH, which was the obvious suggestion and is wrong: Go's own
GOARCHvalues areamd64andarm64— the dpkg spelling — so the name
would have pointed at the wrong one of the two while sounding decisive.
Fixed
-
The
cligroup installed the wrongyq. Debian'syqpackage is
kislyuk/yq — the distribution's own description calls it a "jq wrapper for
YAML documents", a Python script that transcodes YAML and shells out to jq.
The Windows manifest installsMikeFarah.yqand Homebrew'syqformula is
mikefarah's too: the Go program, a different project that answers to the same
command name.That is the whole point of the
cligroup inverted. It exists so muscle
memory transfers between the three shells, and a simple read is spelled
identically on both —yq '.a.b' fileworks either way — which is exactly
what let this survive unnoticed. Everything past a simple read diverges:
yq eval,-iin place,-o=json. A snippet written on the other two
platforms failed here in a way that reads like a typo rather than a different
program.yqis now aRELEASESentry taking mikefarah's static binary, which needed
no new code — its asset has no extension, andunpack_assetalready treats
that shape as a plain binary rather than guessing at an archive.Nothing uninstalls the old one. A machine that ran an earlier manifest
keeps Debian'syqat/usr/bin/yq; it is shadowed rather than removed,
because the managed zsh fragment prepends~/.local/bintoPATH. Run
sudo apt remove yqby hand if you would rather not have both on disk. -
set -einside a subshell used as anifcondition does nothing, and
the first draft of the release-binary downloader relied on it. Bash
suppresses the abort for the whole condition context, so a failed download
went on to unpack nothing, find nothing, and report whatever the last command
thought of being handed an empty path. Both new download paths chain their
steps with&&instead, so the exit status means what it looks like it means. -
A release whose tag moved without its asset changing reported
upgraded 0.60.0 -> 0.60.0— an arrow saying nothing happened in the colour
that says something did. It reportscurrentnow.
macOS 1.0.0
First release. A Homebrew counterpart to the Windows and Linux bootstraps,
built on the same idea: one command that both builds a fresh machine and
updates an existing one, a manifest that says what rather than how, and one
printed line per decision so a run that changes nothing says so.
Added
-
An architecture check that survives Rosetta, which is the reason the
Homebrew prefix is ever right. Homebrew lives at/opt/homebrewon Apple
silicon and/usr/localon Intel, and installing into the wrong one produces
no error — just a second, parallel Homebrew the shell never picks up.uname -mis the obvious signal and it lies: under Rosetta it reports
x86_64on an Apple silicon Mac, because that is what a translated process
is entitled to believe, and a Terminal with Open using Rosetta ticked is
enough to trigger it.sysctl -n sysctl.proc_translatedis asked instead —
the kernel sets it to1when this process is translated, and it is absent
on a real Intel Mac, so a missing value and a0both mean native. A run
from a Rosetta shell says so and targets the native prefix anyway. -
Formulae and casks looked up separately, never with a bare
brew list.
The same name can be both.dockeris a formula — the CLI client on its own,
with no engine behind it — anddocker-desktopis the cask with the engine
and the app. A check that does not say which kind it means reports one as
installed when the other is, and the symptom is a workingdockercommand
that cannot reach a daemon. -
--no-gui, as a flag rather than a probe. Groups marked
GROUP_<name>_GUI=yesare skipped for a headless build agent. This is
deliberately not auto-detected the way the Linux script detects a desktop:
every Mac has a window server, and the signals that do differ — a console
user,$SSH_CONNECTION— describe the session rather than the machine, which
is the exact mistake the Linux check exists to avoid. Groups are kept
homogeneous so the gate cannot take the linters down with Docker Desktop. -
A refusal to run as root. The inverse of the Linux script, which calls
sudofor the steps that need it. Homebrew refuses to operate as root, and a
run that got far enough would leave root-owned files in the prefix and in
$HOMEthat later normal runs cannot write. -
Upgrades that report what actually moved.
brew upgradeexits 0 whether
it moved forty packages or none, sobrew outdatedis asked first and the
count is both the decision and the detail printed. Nothing outdated means
current, and the upgrade is not run at all. -
No
--greedyon cask upgrades, on purpose. Casks that declare
auto_updates— VS Code, Docker Desktop, Chrome — keep themselves current,
and--greedymakes Homebrew download and reinstall them on top of an app
that has already updated itself. That is two installers fighting over one
.app, and the visible symptom is a large download on every run for
something that was never out of date. They are named in the output instead.
An app already in/Applicationsthat Homebrew did not put there is likewise
reported aspresentand left alone. -
The managed zsh fragment, compared before it is replaced. oh-my-zsh,
powerlevel10k and the plugin list, written to~/.zshrc.bootstrapand
sourced from a.zshrcthis script never rewrites. The fragment is rendered
to a temp file beside the target and compared, so a run that changes nothing
reportscurrentrather than claiming an install.brew shellenvgoes first and unconditionally: nothing below it can find a
Homebrew-installed binary until the prefix is on PATH, and on a first run the
calling shell has not read a profile since Homebrew appeared. -
Xcode Command Line Tools checked, never installed by force.
xcode-select --installopens a modal dialog and waits for a human, which
would hang an unattended run with no output explaining why. Homebrew's own
installer brings them in, so the check only has to be fatal when Homebrew is
already present. -
The Meslo Nerd Font, which powerlevel10k needs and which the Windows
manifest has installed since its first release. Without it the prompt is not
merely plain — it is boxes, because p10k draws from the private-use area. -
Ghostty as the terminal. Windows gets Windows Terminal and a merged
settings file; macOS was being left with Terminal.app. Ghostty over iTerm2
because its config is a plain text file this script could manage the same way
it manages the zsh fragment, where iTerm2 keeps its settings in a plist.Worth knowing before you SSH anywhere: ghostty sets
TERM=xterm-ghostty, and
a Debian box that has never heard of that terminfo entry will complain until
you use ghostty's SSH integration or forceTERM=xterm-256color. -
Claude Code, as the
claude-codecask in thedevgroup — not the
vendor script the Linux side uses. Homebrew carries it, so it is installed,
version-checked and reported by the phase that already does that for every
other package, rather than by a phase of its own that could only ever say
present.The "two installers fighting" objection does not apply here the way it first
appears. Claude Code still updates itself, andbrew upgradeis not greedy —
this script never passes--greedy, which is the flag that would let brew
reach past a self-updating package and stamp on it. So brew installs it once
and then defers, which is exactly what the hand-rolled phase was doing. -
tflint and terraform-docs, the Terraform counterpart to the
ansible-lint
andyamllintpair.
Notes
-
Three names in this manifest were wrong, and all three were found by
machine rather than by a failed install.verify-manifests.ymlchecks every
formula and cask against the Homebrew index, and its first run caught:google-cloud-sdk→gcloud-cli. The cask was renamed and the old
token resolves to nothing.mpv→stolendata-mpv. Also renamed. Homebrew records this in the
cask'sold_tokens, which is how the new name was recovered rather than
merely the absence noticed.tflintis not in homebrew-core at all and needs the
terraform-linters/tapnow declared inTAPS. Without it the infra group
failed with "No available formula".
Each was a
brew installthat could only ever fail. The check reads the
whole index once instead of asking per name, which is what lets it tell a
rename from a disappearance — and what stops it callingpython3and
sqlite3bugs, since both are aliases with no page of their own. -
GNU make installs as
gmake./usr/bin/makeis BSD make and Homebrew
will not shadow a system binary, so the formula lands under a different name
— the macOS counterpart to Debian'sfdfindandbatcat. The managed zsh
fragment aliasesmaketo it, guarded oncommand -v. -
batandfdkeep their real names, unlike on Debian, so the fragment
is simpler here. Every alias is still guarded, because the same fragment has
to work on a machine where one of those installs failed. -
Temurin rather than the
openjdkformula.openjdkis keg-only on
macOS: it installs where/usr/libexec/java_homecannot see it, and
Homebrew's own caveat tells you to finish the job with asudosymlink. The
cask puts a real JDK bundle where every Java launcher already looks. -
Ansible is not installed by Homebrew. It comes from a per-project
virtualenv instead, so its version is a decision the playbook repository
makes rather than one brew makes for every project on the machine at once.
The Linux manifest does install it from the distribution, where it is the
control node for a fleet — a different job.ansible-lintis still here and
does pullansible-corein behind it; that copy sits in the brew prefix and
does not shadow a virtualenv, and the manifest says how to drop it too. -
No creative group, and no Telegram, qBittorrent or Unity Hub. The Linux
manifest carries a creative group for Blender, GIMP, Inkscape and OBS, plus
those three elsewhere; this one carries none of them. -
Homebrew's zsh is not in the manifest. macOS has shipped zsh as the
default login shell since Catalina, and installing a second one that a login
shell will not use withoutchshis worse than useless. The system zsh is
reported and used as-is.