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.
179 lines
6.0 KiB
Bash
179 lines
6.0 KiB
Bash
#!/bin/bash
|
|
# =============================================================================
|
|
# officer-setup — shared foundation
|
|
# =============================================================================
|
|
#
|
|
# Sourced by officer-setup.sh before anything runs. DEFINITIONS ONLY, the same
|
|
# rule machine-setup/lib holds to: nothing here installs, writes or restarts.
|
|
#
|
|
# ── Why this is a separate script from machine-setup ──
|
|
#
|
|
# They answer different questions. machine-setup asks what a MACHINE should be —
|
|
# users, ssh, firewall, runtimes — and is worth running on a box that will never
|
|
# see Officer. This one puts Officer on a machine that is already ready, and
|
|
# assumes nothing about how it got that way.
|
|
#
|
|
# The split also means the failure modes stay apart: a broken firewall rule and a
|
|
# failed database migration are not the same kind of problem and should not be
|
|
# in the same run.
|
|
|
|
[[ -n "${OFFICER_SETUP_BASE_LOADED:-}" ]] && return 0
|
|
OFFICER_SETUP_BASE_LOADED=1
|
|
|
|
SUMMARY=()
|
|
ERRORS=()
|
|
CURRENT_STEP=""
|
|
SKIP_STEP=false
|
|
|
|
USERNAME="${SETUP_USERNAME:-}"
|
|
USER_HOME=""
|
|
OFFICER_ROOT="${OFFICER_ROOT:-}"
|
|
MACHINE_ROLE="${MACHINE_ROLE:-}"
|
|
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
CYAN='\033[0;36m'
|
|
BOLD='\033[1m'
|
|
NC='\033[0m'
|
|
|
|
info() { echo -e "${CYAN}::${NC} $*"; }
|
|
ok() { echo -e " ${GREEN}OK${NC}: $*"; }
|
|
warn() { echo -e " ${YELLOW}WARN${NC}: $*"; }
|
|
fail() {
|
|
echo -e " ${RED}FAIL${NC}: $*"
|
|
exit 1
|
|
}
|
|
|
|
ONLY_STEP="${ONLY_STEP:-}"
|
|
|
|
# `${var,,}` (case-insensitive --only compare below) is bash 4+. macOS's stock /bin/bash is
|
|
# 3.2 — confirmed live: "bad substitution" — and under this script's `set -e` that means
|
|
# `--only <step>` killed the ENTIRE run on macOS, not just the comparison. This is the
|
|
# portable pre-4 substitute.
|
|
lower() { tr '[:upper:]' '[:lower:]' <<<"$1"; }
|
|
|
|
step() {
|
|
CURRENT_STEP="$1"
|
|
if [[ -n "$ONLY_STEP" ]]; then
|
|
if [[ "$(lower "$1")" == "$(lower "$ONLY_STEP")" ]]; then
|
|
SKIP_STEP=false
|
|
echo ""
|
|
echo -e "${BOLD}── $1 ──${NC}"
|
|
else
|
|
SKIP_STEP=true
|
|
fi
|
|
return
|
|
fi
|
|
if grep -qxF "$1" "$PROGRESS_FILE" 2>/dev/null; then
|
|
echo -e " ${GREEN}SKIP${NC}: $1 (already done)"
|
|
SKIP_STEP=true
|
|
return
|
|
fi
|
|
SKIP_STEP=false
|
|
echo ""
|
|
echo -e "${BOLD}── $1 ──${NC}"
|
|
}
|
|
|
|
skip() { [[ "$SKIP_STEP" == true ]]; }
|
|
|
|
step_ok() {
|
|
[[ -n "$ONLY_STEP" ]] && return 0
|
|
echo "$CURRENT_STEP" >>"$PROGRESS_FILE"
|
|
}
|
|
|
|
page() {
|
|
if [[ -t 1 ]] && command -v more &>/dev/null; then more; else cat; fi
|
|
}
|
|
|
|
confirm() {
|
|
local message="${1:-Proceed?}" default="${2:-y}" help_fn="${3:-}" answer prompt
|
|
|
|
[[ "${ASSUME_YES:-}" == "1" ]] && { [[ "$default" == "y" ]] && return 0 || return 1; }
|
|
|
|
if [[ "$default" == "y" ]]; then prompt="[Y/n]"; else prompt="[y/N]"; fi
|
|
[[ -n "$help_fn" ]] && prompt="${prompt%]}/?]"
|
|
|
|
while true; do
|
|
if ! read -rp " ${message} ${prompt}: " answer; then
|
|
echo ""
|
|
fail "No answer. Set ASSUME_YES=1 to run without prompts."
|
|
fi
|
|
[[ -z "$answer" ]] && answer="$default"
|
|
case "$answer" in
|
|
y | Y | yes | Yes) return 0 ;;
|
|
n | N | no | No) return 1 ;;
|
|
"?")
|
|
if [[ -n "$help_fn" ]]; then
|
|
echo ""
|
|
"$help_fn" | page
|
|
echo ""
|
|
else
|
|
warn "Answer y or n."
|
|
fi
|
|
;;
|
|
*) warn "Answer y or n${help_fn:+, or ? for what this is}." ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
ask_required() {
|
|
local __var="$1" message="$2" default="$3" answer=""
|
|
# Unattended takes the default where there IS one. Where there is not — the owning
|
|
# account on a machine that machine-setup never ran on — it still asks, because
|
|
# there is nothing to fall back to and a guess would install as the wrong user.
|
|
if [[ "${UNATTENDED:-}" == "1" && -n "$default" ]]; then
|
|
printf ' %s [%s] — unattended, taking the default\n' "$message" "$default"
|
|
printf -v "$__var" '%s' "$default"
|
|
return 0
|
|
fi
|
|
while [[ -z "$answer" ]]; do
|
|
if ! read -rp " ${message}${default:+ [$default]}: " answer; then
|
|
echo ""
|
|
fail "No answer."
|
|
fi
|
|
answer="${answer:-$default}"
|
|
[[ -z "$answer" ]] && warn "This one cannot be left blank."
|
|
done
|
|
printf -v "$__var" '%s' "$answer"
|
|
}
|
|
|
|
user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; }
|
|
|
|
# The bound address(es) of whatever is LISTENing on a TCP port — one per line, formatted
|
|
# like `127.0.0.1:5432` or `*:5432`. Used both to ask "is anything on this port at all" and
|
|
# "is it bound wide enough for a container to reach" (Postgres and the Proxy section ask
|
|
# opposite versions of that question).
|
|
#
|
|
# `ss` (iproute2) is Linux-only — no equivalent ships with it on macOS. `lsof` is the
|
|
# portable answer: present on every macOS install by default, and common enough on Linux
|
|
# (already a REQUIRED_TOOL nowhere here, so this still prefers `ss` there since that is the
|
|
# surface this was written and tested against) that branching by OS rather than picking one
|
|
# tool for both keeps each path exactly as capable as it always was.
|
|
listening_addrs() {
|
|
local port="$1"
|
|
if [[ "$(uname -s)" == "Darwin" ]]; then
|
|
lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk -v p=":${port}\$" '$9 ~ p {print $9}'
|
|
else
|
|
ss -ltn 2>/dev/null | awk -v p=":${port}\$" '$4 ~ p {print $4}'
|
|
fi
|
|
}
|
|
|
|
# Run something as the account that owns the install. Officer's files, its
|
|
# node_modules and its pm2 process list all belong to that account, not to root —
|
|
# a repository cloned as root is one the owner cannot pull.
|
|
#
|
|
# On Linux this script runs as root (that's the whole point of sudo -H -u here: dropping
|
|
# from root down to the owning account). On macOS it never escalates — officer-setup.sh
|
|
# refuses to even start under sudo there, "the account running this IS the owner" — so the
|
|
# process already IS $USERNAME, and re-invoking sudo for the SAME account asks for a real
|
|
# password on every single call (Dependencies, Schema, Build, Secrets, Services, Repository
|
|
# all go through this) or fails outright without sudo rights at all.
|
|
as_owner() {
|
|
if [[ "$(uname -s)" == "Darwin" ]]; then
|
|
(cd "${2:-/}" && bash -c "$1")
|
|
else
|
|
(cd "${2:-/}" && sudo -H -u "$USERNAME" bash -c "$1")
|
|
fi
|
|
}
|