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.
88 lines
3.6 KiB
Bash
88 lines
3.6 KiB
Bash
#!/bin/bash
|
|
# =============================================================================
|
|
# officer-setup — is this machine ready
|
|
# =============================================================================
|
|
#
|
|
# Definitions only.
|
|
#
|
|
# ── Inherited, not re-asked ──
|
|
#
|
|
# machine-setup saves the account, the Officer path and the machine role beside
|
|
# itself. This reads the same file, so a normal run — machine-setup, then this —
|
|
# asks nothing at all. It only prompts on a machine where machine-setup never
|
|
# ran, which is a supported case rather than an error: somebody may have
|
|
# provisioned the box their own way.
|
|
|
|
[[ -n "${OFFICER_SETUP_PREFLIGHT_LOADED:-}" ]] && return 0
|
|
OFFICER_SETUP_PREFLIGHT_LOADED=1
|
|
|
|
# Where machine-setup keeps what it was told. Beside this script, one directory
|
|
# across.
|
|
MACHINE_ANSWERS="${MACHINE_ANSWERS:-${SCRIPT_DIR}/machine-setup/.setup-answers}"
|
|
|
|
# Read as assignments rather than sourced: the file is read by a root run and
|
|
# sourcing it would make it executable content.
|
|
load_machine_answers() {
|
|
[[ -r "$MACHINE_ANSWERS" ]] || return 1
|
|
local key value
|
|
while IFS='=' read -r key value; do
|
|
[[ "$key" =~ ^[A-Z_]+$ ]] || continue
|
|
[[ -n "$value" ]] || continue
|
|
case "$key" in
|
|
MACHINE_ROLE) if [[ -z "$MACHINE_ROLE" ]]; then MACHINE_ROLE="$value"; fi ;;
|
|
SETUP_USERNAME) if [[ -z "$USERNAME" ]]; then USERNAME="$value"; fi ;;
|
|
OFFICER_ROOT) if [[ -z "$OFFICER_ROOT" ]]; then OFFICER_ROOT="$value"; fi ;;
|
|
esac
|
|
done <"$MACHINE_ANSWERS"
|
|
return 0
|
|
}
|
|
|
|
# What Officer needs to already be here, and what installs it.
|
|
#
|
|
# Checked together and reported together: finding out about a missing bun three
|
|
# sections in, after the repository has been cloned and a database started, is a
|
|
# worse way to learn it than being told at the start.
|
|
REQUIRED_TOOLS=(git node bun pm2)
|
|
OPTIONAL_TOOLS=(docker)
|
|
|
|
missing_tools() {
|
|
local t
|
|
for t in "${REQUIRED_TOOLS[@]}"; do command -v "$t" &>/dev/null || echo "$t"; done
|
|
}
|
|
|
|
missing_optional_tools() {
|
|
local t
|
|
for t in "${OPTIONAL_TOOLS[@]}"; do command -v "$t" &>/dev/null || echo "$t"; done
|
|
}
|
|
|
|
tool_why() {
|
|
case "$1" in
|
|
git) echo "to clone and update the platform" ;;
|
|
node) echo "pm2 runs on it, and the terminal sidecar builds node-pty against it" ;;
|
|
bun) echo "the platform itself and every process except the pty sidecar" ;;
|
|
pm2) echo "supervises every process; the ecosystem files are written for it" ;;
|
|
docker) echo "Postgres, and anything the app store provisions" ;;
|
|
*) echo "" ;;
|
|
esac
|
|
}
|
|
|
|
# The account has to exist before anything is written to its home.
|
|
owner_exists() { id "$USERNAME" &>/dev/null; }
|
|
|
|
# `getent` is Linux/glibc — not present on stock macOS at all (confirmed: "command not
|
|
# found" on a real machine; it only appeared to work in earlier testing because this dev
|
|
# session's shell profile shims it, which a fresh install does not have). Reproduced live:
|
|
# this returned the WRONG, hardcoded `/home/<user>` fallback on a real macOS run just now,
|
|
# and `install -d` then failed on it outright ("Operation not supported") since /home isn't
|
|
# a real, writable path on macOS.
|
|
#
|
|
# `eval echo ~"$USERNAME"` is the portable replacement — tilde expansion resolves through
|
|
# whatever the OS's own user database is (NSS on Linux, Directory Services on macOS)
|
|
# without naming either. A nonexistent user leaves the tilde word unexpanded (`~name`
|
|
# rather than a path), which is how the fallback below still triggers for that case.
|
|
resolve_user_home() {
|
|
USER_HOME="$(eval echo ~"$USERNAME" 2>/dev/null)"
|
|
[[ "$USER_HOME" == "~"* ]] && USER_HOME=""
|
|
[[ -n "$USER_HOME" ]] || USER_HOME="/home/${USERNAME}"
|
|
}
|