Files
platform/scripts/setup/machine-setup/lib/packages.sh
T
pastilhas 681df1c8bc make machine-setup.sh and officer-setup.sh actually run on macOS
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.
2026-08-26 21:10:08 +01:00

324 lines
14 KiB
Bash

#!/bin/bash
# =============================================================================
# machine-setup — distro packages
# =============================================================================
#
# Definitions only, like lib/base.sh. Sourcing this installs nothing.
#
# ── The rule: install what is missing, never touch what is there ──
#
# `apt-get install <present-package>` is NOT a no-op — it upgrades the package if
# the repository has a newer one. On a machine somebody already uses, that can
# move a version they chose deliberately, and the setup script is the last thing
# that should be doing that behind their back.
#
# So every install here goes through pkg_install, which queries the package
# database first, installs only the subset that is genuinely absent, and prints
# both lists before doing it. A package already present is never named on a
# command line at all.
#
# ── Why per-package-manager lists rather than a translation table ──
#
# The names disagree across distributions (build-essential/base-devel/fd/fd-find)
# and some packages are not a package elsewhere at all: apt-transport-https,
# lsb-release and software-properties-common are apt concepts. A canonical-name
# table with per-manager overrides hides both of those behind indirection. A
# plain `case $PM` says what each system actually gets, in one place, and matches
# the shape scripts/setup-old/setup.sh already used.
[[ -n "${MACHINE_SETUP_PACKAGES_LOADED:-}" ]] && return 0
MACHINE_SETUP_PACKAGES_LOADED=1
# What the last pkg_install/tools_install actually put on the machine, as opposed
# to what it was asked for. Read by the caller to write an honest summary line:
# without it every section reports its whole list as installed, including the
# packages it deliberately left alone.
LAST_INSTALLED=()
LAST_KEPT=()
LAST_SKIPPED=()
# -----------------------------------------------------------------------------
# The sections
# -----------------------------------------------------------------------------
# Core: what this script itself would break without, plus the command-line tools
# that make a machine worth sitting at.
#
# The first six are load-bearing and each is used by a later step — curl fetches
# in nine of them, jq parses the lazygit release API, gnupg dearmors the Docker
# keyring, git clones the Neovim config, unzip opens anything that arrives as an
# archive, and ca-certificates is what makes any of the fetching work. The rest
# are the environment: nothing calls them, they are here because a box you use
# should have them.
#
# Four entries earn a note.
#
# python3 is not a tool anybody here calls — it is node-gyp's build dependency,
# and node-gyp is not optional on Linux. node-pty ships prebuilt binaries for
# darwin and win32 ONLY, so on Linux its install script always falls through to
# `node-gyp rebuild` and compiles from source. Without python3 that fails, and
# the failure surfaces as a broken terminal sidecar rather than as a missing
# package. build-essential below is the other half of the same requirement.
#
# unattended-upgrades installs updates on a timer with nobody watching. apt only:
# it is a Debian and Ubuntu package, dnf's equivalent is dnf-automatic and pacman
# has no equivalent at all, so it is not a name to translate. Installing the
# package is not by itself enough to switch it on — /etc/apt/apt.conf.d/20auto-upgrades
# is what the apt-daily timers read, and on this host no package owns that file.
# The section makes sure it is there.
#
# fail2ban is not a tool, it is a daemon: installing it starts it, and Ubuntu
# ships /etc/fail2ban/jail.d/defaults-debian.conf with `[sshd] enabled = true`.
# Verified on this host — maxretry 5, findtime 600, bantime 600 — so from the
# moment it installs, an address failing to log in five times in ten minutes is
# blocked for ten, including yours. That is the point of it and it is worth
# having by default, but it is why it belongs in this comment rather than being
# thought of as one more binary. An existing install with its own jails is
# untouched, because pkg_install never names a package that is already there.
#
# build-essential is the other: a meta-package (gcc, g++, make, libc6-dev,
# dpkg-dev), so on a machine where a specific gcc was pinned it pulls the
# distribution's default alongside it. It stays in core because anything that
# compiles a native module needs it, but it is the one to move out first if that
# ever bites.
#
# inotify-tools is `inotifywait`, and it is what lets a COMMANDER react the
# moment a file lands instead of polling for it. Without it a watcher can only
# ask "is there anything new?" on a timer, which is both slower and more work —
# and the commander has no way to know the tool is missing until it tries. macOS
# has no equivalent in the package sets here; a commander there polls.
pkgs_core() {
case "$PM" in
apt)
# apt-transport-https and lsb-release are not tools — they are what lets a
# later step add the Docker repository. They have no counterpart on the
# other systems.
#
# software-properties-common is still here and is no longer needed by
# anything: it provides `add-apt-repository`, and the fastfetch PPA was its
# only caller until that was removed on 2026-08-14 (Docker writes its own
# sources.list.d entry by hand). Left in deliberately rather than dropped
# in the same change — it is one small package, and pulling it is a
# separate decision from removing the tool that wanted it.
# poppler-utils is `pdftoppm`, and it is what makes an attached PDF readable.
# The agent reads a PDF by RENDERING its pages, not by extracting text — which
# is the only approach that works on a document made of diagrams and
# wireframes, where extraction returns almost nothing. Without it the attach
# button uploads happily and the read fails, which reads as the feature being
# broken rather than a package being absent.
echo curl ca-certificates gnupg git jq unzip \
apt-transport-https lsb-release software-properties-common \
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
inotify-tools poppler-utils radicale fail2ban unattended-upgrades
;;
pacman)
echo curl ca-certificates gnupg git jq unzip \
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
inotify-tools poppler radicale fail2ban
;;
dnf)
echo curl ca-certificates gnupg2 git jq unzip \
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
inotify-tools poppler-utils radicale fail2ban
;;
brew)
# curl, unzip and the TLS roots ship with macOS; the compilers come from
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
# brotli is here because macOS ships the library but not the CLI.
echo gnupg git jq wget brotli btop htop tree ripgrep fd eza poppler radicale
;;
esac
}
# -----------------------------------------------------------------------------
# Querying
# -----------------------------------------------------------------------------
# Is this package installed right now?
#
# dpkg-query on the status field rather than `dpkg -s`, which also succeeds for a
# package that was removed but left its config behind — that state would be read
# as "present" and the package would never be reinstalled.
pkg_is_installed() {
case "$PM" in
apt) [[ "$(dpkg-query -W -f='${db:Status-Status}' "$1" 2>/dev/null)" == "installed" ]] ;;
pacman) pacman -Qi "$1" &>/dev/null ;;
dnf) rpm -q "$1" &>/dev/null ;;
brew) brew list --formula "$1" &>/dev/null ;;
*) return 1 ;;
esac
}
# -----------------------------------------------------------------------------
# Acting
# -----------------------------------------------------------------------------
# Refresh the package index.
#
# DEBIAN_FRONTEND stops debconf opening a dialog on a machine with no terminal to
# draw it on, and NEEDRESTART_MODE=a stops needrestart — on by default since
# Ubuntu 22.04 — interrupting to ask which services to restart. Both belong here
# rather than at each call site, because forgetting one turns an unattended run
# into one that is silently waiting for a keypress.
pkg_refresh() {
case "$PM" in
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -y ;;
pacman) pacman -Sy --noconfirm ;;
dnf) dnf makecache ;;
brew) brew update ;;
esac
}
# What an upgrade would actually move, one package name per line.
#
# Asked before the upgrade runs so the section can name what it is about to
# change rather than asking to be trusted. Needs a refreshed index to be
# accurate, which is why pkg_refresh runs first.
#
# `apt-get upgrade -s` simulates and prints an "Inst <name> …" line per package,
# which is the same calculation the real run does — as opposed to
# `apt list --upgradable`, which also lists packages that are held back and
# would not actually move.
pkg_upgradable() {
case "$PM" in
apt) apt-get upgrade -s 2>/dev/null | awk '/^Inst /{print $2}' ;;
pacman) pacman -Qu 2>/dev/null | awk '{print $1}' ;;
dnf) dnf -q check-update 2>/dev/null | awk 'NF >= 3 && $1 !~ /^(Last|Obsoleting)/ {print $1}' ;;
brew) brew outdated --quiet 2>/dev/null ;;
esac
}
# Upgrade everything already installed. Separate from pkg_install on purpose:
# this one DOES move versions, so it is a deliberate step rather than something
# that happens as a side effect of installing a tool.
pkg_upgrade_all() {
case "$PM" in
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get upgrade -y ;;
pacman) pacman -Su --noconfirm ;;
dnf) dnf upgrade -y ;;
brew) brew upgrade ;;
esac
}
# The raw install, with no presence check. Use pkg_install instead.
pkg_install_now() {
case "$PM" in
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y "$@" ;;
pacman) pacman -S --noconfirm --needed "$@" ;;
dnf) dnf install -y "$@" ;;
brew) brew install "$@" ;;
esac
}
# Announce a section, then install only what is absent from it.
#
# pkg_install "Core packages" $(pkgs_core)
#
# Prints both lists before touching anything, so the run says what it is about to
# do to this machine and what it is deliberately leaving alone. Returns 0 when
# there was nothing to do.
pkg_install() {
local label="$1"
shift
local pkg
local -a missing=() present=()
LAST_SKIPPED=()
for pkg in "$@"; do
if pkg_is_installed "$pkg"; then present+=("$pkg"); else missing+=("$pkg"); fi
done
LAST_INSTALLED=("${missing[@]}")
LAST_KEPT=("${present[@]}")
announce_plan "$label" present missing || {
# Declining is a fact a reviewer wants: it explains a package being absent
# later without having to guess whether the script failed or was refused.
declare -F report_skipped >/dev/null && report_skipped "${label}: declined — ${#missing[@]} package(s) not installed"
return 0
}
if pkg_install_now "${missing[@]}"; then
declare -F report_installed >/dev/null && ((${#missing[@]})) && report_installed "${PM}: ${missing[*]}"
declare -F report_kept >/dev/null && ((${#present[@]})) && report_kept "already present, untouched: ${present[*]}"
else
declare -F report_failed >/dev/null && report_failed "${PM} install failed: ${missing[*]}"
return 1
fi
}
# Print what a section is about to do and ask permission for it.
#
# Takes the NAMES of the two arrays rather than their contents, because a list
# passed by value cannot be told apart from an empty one once it has been through
# word splitting.
#
# Copies by name via eval rather than `local -n` — namerefs are bash 4.3+, and macOS's
# stock bash is 3.2. This is the idiom namerefs replaced: build the local array by
# expanding the CALLER's array name inside a double-quoted eval, which bash 3.2 has always
# supported. `$2`/`$3` are trusted call-site literals (`present`, `missing`), never
# user input, so the eval is not taking anything untrusted.
#
# Returns non-zero when there is nothing to do, or when the answer was no — in
# both cases the caller should skip its action. LAST_INSTALLED is cleared on a
# refusal so the summary does not claim work that never happened.
announce_plan() {
local label="$1"
local -a _present _missing
eval "_present=(\"\${$2[@]}\")"
eval "_missing=(\"\${$3[@]}\")"
echo ""
info "${label} — installs what is missing, keeps what you already have"
((${#_present[@]})) && echo " already here: ${_present[*]}"
if ((${#_missing[@]} == 0)); then
echo " to install: nothing, all present"
return 1
fi
echo " to install: ${_missing[*]}"
if ! confirm "Proceed?"; then
warn "skipped by request"
LAST_INSTALLED=()
LAST_SKIPPED=("${_missing[@]}")
return 1
fi
return 0
}
# One summary line describing what a section actually did, from LAST_INSTALLED
# and LAST_KEPT. Call straight after pkg_install or tools_install.
summarise_last() {
local label="$1"
if ((${#LAST_SKIPPED[@]})); then
SUMMARY+=("$label: SKIPPED by request — ${LAST_SKIPPED[*]}")
elif ((${#LAST_INSTALLED[@]} == 0)); then
SUMMARY+=("$label: already present, nothing installed")
elif ((${#LAST_KEPT[@]} == 0)); then
SUMMARY+=("$label installed: ${LAST_INSTALLED[*]}")
else
SUMMARY+=("$label installed: ${LAST_INSTALLED[*]} (${#LAST_KEPT[@]} already present)")
fi
}
# -----------------------------------------------------------------------------
# The Xcode command line tools
# -----------------------------------------------------------------------------
#
# macOS's build-essential, and not installable as a formula. It matters here for
# one specific reason: node-pty ships no prebuilt binary for any platform, so
# `bun install` always falls through to node-gyp and needs a working compiler.
# Without this the platform install fails deep inside a dependency tree with an
# error that names neither Xcode nor node-pty.
#
# `xcode-select --install` opens a GUI dialogue and returns immediately — it does
# not block until the download finishes. So this asks, and then says to come back,
# rather than pretending to have waited.
xcode_clt_installed() { xcode-select -p &>/dev/null; }
xcode_clt_install() {
xcode-select --install 2>/dev/null || true
}