Both scripts had real macOS branches already — MACOS_SKIP, Xcode CLT, Docker Desktop
detection — but neither had ever run past its own library load on a real Mac, and
verifying that live is what this commit is. This machine is the Mac.
The root cause was the interpreter, not missing OS coverage: macOS's stock /bin/bash is
3.2.57 (Apple stopped shipping newer once bash went GPLv3) and this machine has no newer
one installed. Both scripts used bash 4+/4.3+ syntax with zero version guard, so a real
run died in seconds — declare -A (base.sh, associative array), local -n (packages.sh,
nameref), mapfile (5 sites), ${var,,} (both scripts' own step() — confirmed --only killed
the ENTIRE run), and machine-setup.sh's own pre-flight hard-refused brew outright ("this
script does not implement yet") even once the bash-4 crash was fixed. Each got rewritten
to its bash-3.2-safe equivalent rather than requiring a newer bash and re-executing under
it — nine call sites, a bounded fix, and one less thing that can go wrong before the
script can report what's wrong.
Past that gate, a live probe run (real account/path prompts, killed before Database/
Services could touch this machine's actual state) reproduced a second bug in the first
few seconds: resolve_user_home used getent (Linux/glibc, absent on macOS) with a
hardcoded /home/<user> fallback, so Layout tried `install -d /home/pastilhas` and failed
with "Operation not supported". Fixed with the portable `eval echo ~user` tilde-expansion
idiom, re-ran the same probe, and it correctly created /Users/pastilhas/officerdev/ and
started a real git clone.
Everything else follows the same shape — an unguarded Linux-only tool with a portable or
brew-based macOS equivalent:
- grep -oP (4 sites: both --list flags, env.sh's tailnet lookup, build.sh's schema
table count) — BSD grep has no -P at all. Portable ERE+sed where regex was enough;
jq where the input was already JSON (tailnet lookup) — jq is already an unconditional
dependency elsewhere in this script, so that's a correctness fix, not just a port.
- ss (postgres.sh, proxy.sh — is anything listening on this port, and is it bound wide
enough for NPM's container to reach) — both silently mis-reported on macOS, since the
"command not found" was swallowed by 2>/dev/null and read as "nothing's listening".
New shared `listening_addrs()` in officer-setup's base.sh, branching to lsof.
- getent hosts (proxy.sh's DNS-wait loop) — same silent-failure shape, would have spun
its full 30-minute timeout regardless of whether the record actually resolved. macOS
branch uses dscacheutil.
- stat -c (layout.sh's ownership check) — BSD stat has no -c, confirmed live ("illegal
option -- c"), and the failure was VISIBLE and made every layout directory report as
wrong-owner. Portable file_owner() tries -c then falls back to -f %Su.
- apt-only Postgres client, NodeSource's Debian-only setup script, lazydocker/lazygit's
installers (one literally named install_update_linux.sh, the other hardcoding
"_Linux_" into the release asset URL) — each gets a brew branch, formula names
verified against the live Homebrew catalog rather than assumed.
- sudo -u "$USERNAME" to become the SAME account the process already is — real on
Linux, where this whole script runs as root and dropping to the owner needs no
password; on macOS the process never escalates in the first place, so re-invoking
sudo for yourself asks for a real password on every call, or fails outright without
sudo rights. machine-setup's dev.sh had ~10 inline call sites (git, bun, deno, claude,
opencode, editor detection) rerouted through a new shared run_as_owner(); officer-
setup's own as_owner() (already the shared entry point there) got the same branch,
and six direct sudo call sites in build.sh/secrets.sh/services.sh that bypassed it
were rerouted through it too.
Three things stay Linux-only rather than getting an unverified macOS implementation,
each skipped with a stated reason at the point it would otherwise run: pm2's boot-
persistence hook (needs a launchd plist generated and loaded, not a systemd unit
printed and eval'd — real, separate work), the Tailscale exit-node NIC/sysctl tuning
(no macOS equivalent concept, and advertising a laptop as an exit node is already the
edge case MACOS_SKIP treats it as), and ufw's bridge-to-host firewall rule (pf is a
different rule language entirely, and Docker Desktop's own VM-based networking may not
even have the problem this rule exists to solve — guessing at a firewall rule felt like
the wrong place to be wrong).
Verified: both --list flags print step names instead of a grep usage error. A bounded
officer-setup.sh run (killed before Database) resolved the real macOS home directory,
created the layout with correct ownership, and began a real git clone — cleaned up
after (rm -rf the test directory, the gitignored .setup-progress file, the stray
install-report). machine-setup.sh's pre-flight now reports "Packages: brew" and reaches
the account/path prompts instead of refusing the OS outright. bash -n clean on all 16
files under bash 3.2, the actual interpreter these scripts run under on a Mac.
Not run further live: Database, Secrets, Services and Proxy would mutate this machine's
real state (a live Postgres, a live set of pm2 processes this session runs through) and
were left for a deliberate, supervised run rather than folded into validation.
140 lines
5.5 KiB
Bash
140 lines
5.5 KiB
Bash
#!/bin/bash
|
|
# =============================================================================
|
|
# machine-setup — command-line tools that do not come from the distribution
|
|
# =============================================================================
|
|
#
|
|
# Definitions only, like the other lib/ files.
|
|
#
|
|
# These four were buried inside "System Update & Essentials", after the package
|
|
# install and with no announcement, so a run appeared to be installing system
|
|
# packages and then started downloading tarballs and printing a shell tutorial.
|
|
# They are their own concern: upstream binaries, fetched from upstream, on their
|
|
# own release cadence.
|
|
#
|
|
# Each one is checked before it is fetched. The original re-ran every installer
|
|
# on every run — which is how a machine that already had starship got it
|
|
# reinstalled, along with its "add this to your ~/.zshrc" instructions, which we
|
|
# do not want because this script writes the shell config itself.
|
|
|
|
[[ -n "${MACHINE_SETUP_TOOLS_LOADED:-}" ]] && return 0
|
|
MACHINE_SETUP_TOOLS_LOADED=1
|
|
|
|
# The set installed on every machine, in the order they are fetched.
|
|
#
|
|
# fastfetch was here until 2026-08-14 and was removed after it stopped a real
|
|
# install. It is the only one of these with no source but a third-party PPA on
|
|
# Ubuntu 24.04 and older, and the failure was in the half that was not guarded:
|
|
# a PPA that ADDS cleanly but carries no package for the running codename gets
|
|
# past the `|| skip` and dies on the install instead. A neofetch clone is not
|
|
# worth a branch in a script that has to survive on machines nobody has seen.
|
|
tools_default() { echo lazydocker lazygit starship; }
|
|
|
|
# The command that proves a tool is already here. Same as the tool name for all
|
|
# three today, but kept as a mapping because that is not a rule — a package and
|
|
# the binary it provides disagree often enough (fd-find/fdfind) to be worth the
|
|
# indirection.
|
|
tool_command() {
|
|
case "$1" in
|
|
lazydocker) echo lazydocker ;;
|
|
lazygit) echo lazygit ;;
|
|
starship) echo starship ;;
|
|
*) echo "$1" ;;
|
|
esac
|
|
}
|
|
|
|
tool_is_installed() { command -v "$(tool_command "$1")" &>/dev/null; }
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# The installers
|
|
# -----------------------------------------------------------------------------
|
|
|
|
# The installer script this used is literally named install_update_linux.sh — there is no
|
|
# upstream equivalent for macOS to swap in, so this is brew there instead of a raw fetch.
|
|
# All three tools are real Homebrew formulas (confirmed against the live catalogue), which
|
|
# also means macOS gets `brew upgrade` maintenance the raw-fetch path never had.
|
|
tool_install_lazydocker() {
|
|
if [[ "${OS:-}" == "macos" ]]; then
|
|
brew install lazydocker
|
|
return
|
|
fi
|
|
curl -fsSL https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh |
|
|
DIR=/usr/local/bin bash
|
|
}
|
|
|
|
# The one that was actually broken on arm64: the asset name was hardcoded to
|
|
# x86_64, so an arm machine downloaded a 404 and tar failed halfway through the
|
|
# run. lazygit spells the architectures x86_64 and arm64, which is neither of the
|
|
# two spellings ARCH uses, hence the mapping.
|
|
#
|
|
# The URL was also hardcoded to `_Linux_` — brew handles macOS instead of this needing a
|
|
# second literal-string branch, see tool_install_lazydocker above for why brew and not a
|
|
# Darwin asset name here.
|
|
tool_install_lazygit() {
|
|
if [[ "${OS:-}" == "macos" ]]; then
|
|
brew install lazygit
|
|
return
|
|
fi
|
|
|
|
local version asset url
|
|
case "$ARCH" in
|
|
amd64) asset="x86_64" ;;
|
|
arm64) asset="arm64" ;;
|
|
esac
|
|
|
|
version="$(curl -fsSL https://api.github.com/repos/jesseduffield/lazygit/releases/latest | jq -r '.tag_name')"
|
|
# Strip only the leading v. The original used `tr -d 'v'`, which deletes every
|
|
# v in the string and would mangle any tag that had one anywhere else.
|
|
version="${version#v}"
|
|
[[ -n "$version" ]] || {
|
|
warn "could not read the latest lazygit version — skipping"
|
|
return 0
|
|
}
|
|
|
|
url="https://github.com/jesseduffield/lazygit/releases/download/v${version}/lazygit_${version}_Linux_${asset}.tar.gz"
|
|
curl -fsSLo /tmp/lazygit.tar.gz "$url"
|
|
tar -C /usr/local/bin -xzf /tmp/lazygit.tar.gz lazygit
|
|
rm -f /tmp/lazygit.tar.gz
|
|
}
|
|
|
|
# Quiet on purpose. The installer ends by printing how to add starship to bash,
|
|
# zsh, ion, tcsh and xonsh — five shells' worth of instructions for a step that
|
|
# already writes the zsh config itself. Errors still come through.
|
|
#
|
|
# starship's own installer IS cross-platform (unlike the two above), but brew on macOS
|
|
# keeps it consistent with the other two tools and gets it `brew upgrade` maintenance.
|
|
tool_install_starship() {
|
|
if [[ "${OS:-}" == "macos" ]]; then
|
|
brew install starship
|
|
return
|
|
fi
|
|
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin >/dev/null
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Acting
|
|
# -----------------------------------------------------------------------------
|
|
|
|
# Announce the section, then fetch only what is absent — same contract and same
|
|
# output shape as pkg_install, so the two read alike in a transcript.
|
|
tools_install() {
|
|
local label="$1"
|
|
shift
|
|
|
|
local tool
|
|
local -a missing=() present=()
|
|
LAST_SKIPPED=()
|
|
for tool in "$@"; do
|
|
if tool_is_installed "$tool"; then present+=("$tool"); else missing+=("$tool"); fi
|
|
done
|
|
|
|
LAST_INSTALLED=("${missing[@]}")
|
|
LAST_KEPT=("${present[@]}")
|
|
|
|
announce_plan "$label" present missing || return 0
|
|
|
|
for tool in "${missing[@]}"; do
|
|
info " installing ${tool}..."
|
|
"tool_install_${tool}"
|
|
done
|
|
}
|