Context for working in this repository. Read this before making changes.
A single source of truth for provisioning Craig's development environment across several targets. It started as one bash script and is being extended into multiple provisioning mechanisms that must all converge on the same toolchain.
Existing:
bootstrap.sh- imperative bash provisioner for Ubuntu 26.04 on WSL2. Working and verified. Treat it as the reference definition of "what a correctly set up machine looks like".
To build:
- Nix flake plus
home-managerconfiguration (the intended long-term replacement forbootstrap.sh) - Dockerfile and
.devcontainer/definition - Ansible role
- Possibly a Vagrant box. See "Open questions" before starting this.
Primary target is Ubuntu 26.04 LTS (Resolute Raccoon) on WSL2, on a
personal Windows 11 machine (craig-pc, user craig). An Arch WSL distro
(archlinux) still exists and is the source system being migrated from.
There is also a work MacBook running corporate security agents, and a
corporate Windows build with WSL running Oracle Linux 9 behind a CNTLM
proxy. Anything written here should not assume unproxied egress or a
trusted default CA bundle. bootstrap.sh handles this with PROXY and
CORP_CA variables; new provisioners should expose equivalents.
Shell is bash. Locale is en_GB.UTF-8.
Anything you write must provision all of these. This list came from
pacman -Qqe plus brew leaves on the Arch box.
From the Ubuntu archive: build-essential, git, git-lfs, openssh-client, gnupg2, 7zip, zip, unzip, xz-utils, dos2unix, bind9-dnsutils, iputils-ping, net-tools, traceroute, htop, ncdu, tree, less, lsof, strace, jq, nano, neovim, man-db, manpages, podman, podman-compose, slirp4netns, fuse-overlayfs, uidmap, python3, graphviz, poppler-utils, pandoc, texlive (latex-base, latex-recommended, latex-extra, fonts-recommended, fonts-extra, xetex), fonts-crosextra-carlito, fonts-dejavu, fonts-liberation
From vendor apt repos: azure-cli, powershell, gh, dotnet-sdk, pdk, cloudflared, trivy
powershell and azure-cli are not optional extras. Every provisioner you
write, including the container image and the Ansible role, must install both
plus the PowerShell modules below. A target without pwsh and az is not
considered provisioned.
Standalone binaries: yq (mikefarah, not the Ubuntu package of the same name), tenv, glab, keybase, starship, uv, conftest, terraform-docs, just, shellcheck
shellcheck is here because "shellcheck clean at -S warning" is a hard
constraint below, and a constraint you cannot check on the machine is one that
only ever fails in CI. Its release asset is .tar.xz, not the .tar.gz that
tar_install handles, which is why bootstrap.sh installs it longhand.
Toolchain managers: goenv, nvm, sdkman
Via uv: pre-commit
Homebrew: cloudflared, conftest, gh, graphviz, just, nvm, poppler, starship, terraform-docs, trivy. Brew is installed deliberately even though almost everything above duplicates it. Do not "optimise" it away without asking.
Python venv at ~/.venvs/developer, Python 3.12, containing
ansible-core, ansible-lint, pre-commit, pyyaml, requests, azure-identity,
azure-mgmt-resource, msgraph-sdk, pandas, rich. Activated by the dev shell
function.
PowerShell modules (user scope, from PSGallery): Az, Microsoft.Graph,
Pester, LibreDevOpsHelpers. Az and Microsoft.Graph are meta-modules that
pull in dozens of sub-modules each, so a cold install is slow. This is
expected, not a hang. LibreDevOpsHelpers is Craig's own module; if it is
not resolvable from the public gallery, a feed needs registering with
PSGALLERY_TOKEN first.
bootstrap.sh installs these in its psmodules section, which runs after
repos because it needs a Linux pwsh to exist first. It trusts PSGallery
once up front, since Install-Module otherwise stops on the untrusted
repository prompt in a non-interactive shell. A failure to install
LibreDevOpsHelpers warns and continues rather than aborting, because the
private feed is the expected reason for it.
Deliberately excluded: firefox (GUI), yay (Arch-only), ex-vi-compat (superseded by vim), gcc as a brew formula (build-essential covers it), yt-dlp (dropped from the toolchain).
No secrets in this repository, ever. Secrets live in
~/.config/shell/secrets.env at mode 0600, sourced from .bashrc, and are
never committed. If you generate a template, it contains commented-out
variable names only. .gitignore must cover secrets.env, *.key,
*.asc, *.pem, .env.
Credentials that have appeared in this environment historically:
ARM_TENANT_ID, ARM_SUBSCRIPTION_ID, CLOUDFLARE_ACCOUNT_ID,
CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_TOKEN, PUPPET_FORGE_API_KEY,
PSGALLERY_TOKEN, GITHUB_TOKEN, TF_VAR_github_token, SNOW_*,
TF_VAR_SNOW_OAUTH_*. Reference them by name, never by value.
Work inside the WSL filesystem. Repos live under ~/src/. Never work
under /mnt/c - the 9p bridge makes git and node operations pathologically
slow, and it introduces CRLF line endings that break shebangs.
Everything must be idempotent. Re-running any provisioner on an already
provisioned machine must be a no-op that exits zero. bootstrap.sh does this
with per-item have/skip guards; keep that property.
Bash scripts: set -euo pipefail, shellcheck clean at -S warning,
LF line endings.
No em dashes or en dashes in any generated documentation or comments.
Do not rediscover these.
Third-party apt repos lag a new LTS. Microsoft, HashiCorp, Puppet and
others had not published a resolute suite for 26.04 at the time of writing.
bootstrap.sh pins them to noble via REPO_SUITE, which works. Any new
provisioner needs the same escape hatch. Set REPO_SUITE=auto once vendors
catch up.
GitHub CLI's keyring is githubcli-archive-keyring.gpg, binary, with no
.asc variant. Requesting .gpg.asc 404s.
Puppet's signing key filename contains a rotation date that changes.
Use puppet-tools-release-<suite>.deb rather than fetching the key directly.
A failing repo key must not abort provisioning. Warn, name the repo and URL, and continue.
Ubuntu's yq package is a different project from mikefarah's yq and
has incompatible syntax. Always install the GitHub release binary.
GPG import needs loopback pinentry on a fresh WSL instance. There is no
TTY bound and no graphical pinentry, so gpg --import fails with
error sending to agent: Bad secret key, which is misleading. Fix is
pinentry-curses plus allow-loopback-pinentry in ~/.gnupg/gpg-agent.conf,
and --pinentry-mode loopback on the import. GPG_TTY must be exported in
.bashrc or commit signing fails.
Git credential helpers must use absolute paths that survive package
changes. The Arch config pointed at /home/linuxbrew/.linuxbrew/bin/gh,
which would break on any brew reinstall. Prefer /usr/bin/gh and
/usr/bin/glab. Do not resolve with command -v at provision time, because
the answer depends on which sections have already modified PATH.
PATH precedence is deliberate. Brew's shellenv is evaluated first so
that apt, uv and direct binary installs shadow brew formulae of the same
name. Several binaries exist twice (gh, starship, just, conftest, trivy,
terraform-docs, cloudflared). This is intentional, not a bug.
shred does not work on /mnt/c. drvfs cannot do overwrite-in-place,
and uutils shred panics partway, leaving the file under a randomised name.
Use rm.
A presence check must reject /mnt/ resolutions. WSL appends the Windows
PATH to the Linux one, so command -v az happily returns
/mnt/c/Program Files/Microsoft SDKs/Azure/CLI2/wbin/az on a box with no
Linux az installed at all. verify reported that as ok for as long as it
used a bare have. A Windows binary is not a provisioned Linux toolchain.
The same trap applies to pwsh, gh and git, and to any check written in
any of the other provisioners.
Check for the binary, not the package. graphviz ships dot, neato
and friends but never a binary called graphviz, so verifying graphviz by
name reported it missing forever, including on machines where it was
correctly installed. poppler-utils is the same shape, which is why the
check is pdftotext.
gh_latest fails on an unauthenticated rate limit, not just when the
network is down, and an empty tag silently produces a malformed release URL.
Callers use "$(gh_latest owner/repo || true)" and then test for empty,
because a bare assignment from a failing command substitution aborts the
whole run under set -e. Resolve tags from the /releases/latest redirect
rather than api.github.com: the API allows 60 unauthenticated requests an
hour per IP and this script makes several.
glab is not released on GitHub. github.com/gitlab-org/cli is a
read-only mirror that publishes no releases, so a GitHub tag lookup returns
404 and can never work. Resolve it from
gitlab.com/gitlab-org/cli/-/releases/permalink/latest, which is what
gl_latest does, and download the deb from GitLab too.
sudo -v prompts for a password even under NOPASSWD:ALL, because it
matches no specific command. With no TTY it blocks forever instead of
failing, so anything calling it hangs the run rather than erroring out of it.
The starship installer does exactly this before writing to /usr/local/bin,
which is why it is pointed at ~/.local/bin instead. Real commands under
sudo are unaffected. Homebrew's installer is safe here only because
NONINTERACTIVE=1 makes it use sudo -n.
azure-cli is not in Microsoft's prod feed for noble. It has its own
repository at packages.microsoft.com/repos/azure-cli. Related and worse:
never name several vendor packages in one apt-get install. A single
unavailable package makes apt install none of them, so a missing azure-cli
silently took powershell and gh down with it and the warning blamed all
three. One package per call.
Microsoft retires dotnet SDK series per suite. dotnet-sdk-9.0 and
dotnet-sdk-8.0 no longer resolve for noble; dotnet-sdk-10.0 does. Try
newest first and take whichever resolves, rather than pinning a version that
will age out.
Claude Code runs on the Windows host, not inside WSL, and reaches the
Linux environment through pwsh invoking wsl.exe. This has consequences
you must account for on every command.
Invoke Linux work as a login shell so that PATH, brew, goenv, nvm and sdkman are all initialised:
wsl -d Ubuntu-26.04 -- bash -lc "cd ~/src/dotfiles && <command>"Without -l, none of the .bashrc PATH setup applies and half the toolchain
will appear to be missing.
Rules that follow from this:
- Never write files to the repo from the Windows side. Line endings and
file modes both get mangled. Write them from inside WSL, or write them on
Windows and immediately run
dos2unixpluschmod +xin WSL. - Paths in committed files are Linux paths.
~/src/dotfiles, notC:\Users\craig\...and not/mnt/c/.... - Do not assume the Windows and Linux toolchains are the same.
pwshexists on both;az,gitandghmay exist on both with different versions and different credentials. Work in WSL unless there is a specific reason not to. - Quoting nests three deep here (PowerShell, then
wsl, thenbash -lc). When a command gets hairy, write a script file in WSL and execute that instead of fighting the escaping. $varis expanded before it reaches WSL bash, even inside single quotes.$name,${name}and$(cmd)are all consumed by a layer above the shell you think you are writing for, sofor b in a b; do echo "$b"; donearrives with$balready empty and prints blank lines rather than failing. Write\$bto pass one through. This is silent, and it produces plausible-looking wrong output rather than an error, so anything with a variable in it needs a control case before you trust the result. For anything longer than one line, write the script to a file and run the file.pkill -fmatches your ownbash -lccommand line, because the pattern you are searching for is sitting in it. It will kill the shell running thepkill. Match on a pattern that cannot appear in your own invocation, or kill by PID.
-
Commit as Craig.
Craig Thacker <craigthackerx@gmail.com>, signed with82D2F9D5BA2AE757. The repo's git config already does this. -
No trailers or sidecars of any kind. No
Co-Authored-By, noGenerated-with, no tool attribution, no emoji markers. The commit body ends where the message ends. -
English. British spelling to match the rest of the repo.
-
No em dashes or en dashes, in commit messages as in everything else.
-
Imperative mood in the subject line, wrapped at 72 characters. Body only when the change needs explaining.
-
Git user:
Craig Thacker <craigthackerx@gmail.com> -
GPG signing key:
82D2F9D5BA2AE757(ed25519, created 2022-01-29) -
commit.gpgsign = trueis expected everywhere -
Git aliases:
a=add --all,c=commit,p=push -
Editor: nano
Note: the signing key currently has no passphrase. If provisioning touches GPG, do not make that worse; ideally flag it.
bootstrap.sh verify is the acceptance test. It checks every expected
binary resolves, the developer venv exists, the PowerShell modules are
installed, the GPG secret key is present, and reports shadowed duplicates.
Any new provisioner should be checkable against the same criteria. Extending
verify into a standalone script that all four mechanisms can share would be
a reasonable early move.
verify exits non-zero when anything is missing, so it can gate. It is the
only section allowed to do that: the driver runs it via || VERIFY_RC=1 so
its result becomes the script's exit status without aborting any section
queued behind it. A consequence worth knowing is that a full ./bootstrap.sh
run on a machine that is not yet complete now exits 1, which is deliberate.
Smoke tests:
ssh -T git@github.com
echo test | gpg --clearsign > /dev/null
git -C ~/src/<repo> commit --allow-empty -m 'signing test'Is Vagrant worth building? WSL2 is itself the VM, so there is no nested hypervisor available and a box would have to be driven from Windows against Hyper-V. Given Nix and a devcontainer both cover reproducibility, Vagrant may be redundant. Confirm with Craig before investing in it.
Nix scope. home-manager alone (user packages and dotfiles, layered on
Ubuntu) is a much smaller step than full NixOS-WSL, and would also work on
the work MacBook. Establish which is wanted before writing a flake.
Convergence. Once Nix exists, bootstrap.sh becomes redundant for the
personal machine but stays useful where Nix cannot be installed, which
includes locked-down corporate builds. Plan for both to coexist rather than
deleting the script.