diff --git a/scripts/setup/machine-setup/lib/base.sh b/scripts/setup/machine-setup/lib/base.sh index cf5b804a..15f4f702 100644 --- a/scripts/setup/machine-setup/lib/base.sh +++ b/scripts/setup/machine-setup/lib/base.sh @@ -165,25 +165,39 @@ ONLY_STEP="${ONLY_STEP:-}" # Keyed on the step title, so the sections themselves stay Linux code with no # `if macos` branches threaded through them. The reason is printed, because a # silent skip and a missing step look identical. -declare -A MACOS_SKIP=( - ["User account"]="accounts are System Settings' business on a Mac, not a script's" - ["Disk space"]="ballast and swap tuning are server concerns" - ["Locale"]="macOS manages locale itself" - ["Timezone"]="macOS manages the timezone itself" - ["Swap"]="macOS sizes its own swap dynamically" - ["Emergency disk ballast"]="a server trick for a machine nobody is sitting at" - ["earlyoom"]="Linux OOM killer tuning; macOS has its own memory pressure handling" - ["inotify watch limit"]="Linux inotify; macOS watches files through FSEvents" - ["Sleep and suspend"]="a laptop SHOULD sleep — this stops a server from doing it" - ["Boot hang"]="a systemd boot ordering fix" - ["SSH access"]="hardening a door a dev machine should not be opening" - ["DNS"]="systemd-resolved" - ["Network address"]="netplan, and a laptop moves between networks by design" - ["fail2ban"]="brute-force protection for an exposed SSH port" - ["Unattended upgrades"]="apt; macOS updates through Software Update" - ["Firewall"]="ufw; macOS has its own application firewall" - ["Shell"]="zsh is already the default, and tmux is a choice you make yourself" -) +# +# A `case` rather than an associative array — `declare -A` is bash 4+, and macOS's own +# stock `/bin/bash` is 3.2 (Apple hasn't shipped a newer one since bash went GPLv3), with +# nothing newer installed on a fresh machine. An array keyed on the step title would parse- +# fail the instant this file is sourced, before a single step could run — which is exactly +# what happened here until it was measured. Prints the reason on stdout, so the caller reads +# it with `$(...)`; exit status says whether the title matched anything at all. +macos_skip_reason() { + case "$1" in + "User account") echo "accounts are System Settings' business on a Mac, not a script's" ;; + "Disk space") echo "ballast and swap tuning are server concerns" ;; + "Locale") echo "macOS manages locale itself" ;; + "Timezone") echo "macOS manages the timezone itself" ;; + "Swap") echo "macOS sizes its own swap dynamically" ;; + "Emergency disk ballast") echo "a server trick for a machine nobody is sitting at" ;; + "earlyoom") echo "Linux OOM killer tuning; macOS has its own memory pressure handling" ;; + "inotify watch limit") echo "Linux inotify; macOS watches files through FSEvents" ;; + "Sleep and suspend") echo "a laptop SHOULD sleep — this stops a server from doing it" ;; + "Boot hang") echo "a systemd boot ordering fix" ;; + "SSH access") echo "hardening a door a dev machine should not be opening" ;; + "DNS") echo "systemd-resolved" ;; + "Network address") echo "netplan, and a laptop moves between networks by design" ;; + "fail2ban") echo "brute-force protection for an exposed SSH port" ;; + "Unattended upgrades") echo "apt; macOS updates through Software Update" ;; + "Firewall") echo "ufw; macOS has its own application firewall" ;; + "Shell") echo "zsh is already the default, and tmux is a choice you make yourself" ;; + *) return 1 ;; + esac +} + +# `${var,,}` (case-insensitive compare below) is bash 4+ too — this is the portable +# pre-4 substitute, not a style choice. +lower() { tr '[:upper:]' '[:lower:]' <<<"$1"; } step() { CURRENT_STEP="$1" @@ -191,16 +205,19 @@ step() { # which one it is. Twenty-six sections, one place. declare -F report_section >/dev/null && report_section "$1" - if [[ "${OS:-}" == "macos" && -n "${MACOS_SKIP[$1]:-}" ]]; then - echo "" - echo -e "${BOLD}── $1 ──${NC}" - echo -e " ${GREEN}SKIP${NC}: not on macOS — ${MACOS_SKIP[$1]}" - SKIP_STEP=true - return + if [[ "${OS:-}" == "macos" ]]; then + local _skip_reason + if _skip_reason="$(macos_skip_reason "$1")"; then + echo "" + echo -e "${BOLD}── $1 ──${NC}" + echo -e " ${GREEN}SKIP${NC}: not on macOS — ${_skip_reason}" + SKIP_STEP=true + return + fi fi if [[ -n "$ONLY_STEP" ]]; then - if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then + if [[ "$(lower "$1")" == "$(lower "$ONLY_STEP")" ]]; then SKIP_STEP=false echo "" echo -e "${BOLD}── $1 ──${NC}" @@ -508,6 +525,24 @@ ask_officer_root() { # both of which abort the step. user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; } +# Run a command as the created account, e.g. `run_as_owner git clone ...`. +# +# On Linux this whole script runs as root — refused otherwise, see the pre-flight check +# above — so `sudo -u "$USERNAME"` from root needs no password; sudo lets root become +# anyone freely. On macOS the script deliberately never escalates (Homebrew refuses to run +# as root), so the process already IS "$USERNAME", and re-invoking sudo for the SAME +# account asks for a real password — potentially many times in one run, dev.sh calls this +# shape roughly ten times — or fails outright on an account with no sudo rights at all. +# `dev.sh`'s git/bun/deno/claude/opencode/editor helpers all route through this rather than +# hand-rolling `sudo -H -u "$USERNAME" ...` each time, so macOS behaviour lives in one place. +run_as_owner() { + if [[ "${OS:-}" == "macos" ]]; then + "$@" + else + sudo -H -u "$USERNAME" "$@" + fi +} + # Run a block as the created user (login shell, inherits HOME) as_user() { sudo -u "$USERNAME" -i bash -c "$1" diff --git a/scripts/setup/machine-setup/lib/dev.sh b/scripts/setup/machine-setup/lib/dev.sh index 0ce0f893..8333280a 100644 --- a/scripts/setup/machine-setup/lib/dev.sh +++ b/scripts/setup/machine-setup/lib/dev.sh @@ -47,8 +47,8 @@ MACHINE_SETUP_DEV_LOADED=1 # has never been configured — the fresh machine this script exists for — reading # the current value aborted the run before the section had printed anything. # Missing a value is an answer here, not a failure. -git_get() { (cd / && sudo -H -u "$USERNAME" git config --global --get "$1" 2>/dev/null) || true; } -git_set() { (cd / && sudo -H -u "$USERNAME" git config --global "$1" "$2"); } +git_get() { (cd / && run_as_owner git config --global --get "$1" 2>/dev/null) || true; } +git_set() { (cd / && run_as_owner git config --global "$1" "$2"); } # Is there anything configured at all? git_has_identity() { [[ -n "$(git_get user.name)" || -n "$(git_get user.email)" ]]; } @@ -102,7 +102,7 @@ install_oh_my_zsh() { # failed died here, silently, because the output is redirected: the run just # stopped after apt finished installing zsh, with nothing said. Observed on a # fresh Hetzner VPS, 2026-08-14. - sudo -H -u "$USERNAME" sh -c \ + run_as_owner sh -c \ "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended >/dev/null 2>&1 || true # Belt and braces: `|| true` above already makes the last command succeed, and @@ -187,11 +187,11 @@ nvim_clone_config() { local repo="$1" dest="${USER_HOME}/.config/nvim" install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "${USER_HOME}/.config" - (cd / && sudo -H -u "$USERNAME" git clone --depth 1 "$repo" "$dest" >/dev/null 2>&1) || return 1 + (cd / && run_as_owner git clone --depth 1 "$repo" "$dest" >/dev/null 2>&1) || return 1 # The starter is a template, not something to track. Left in place for a # config of the user's own, which they will want to keep pulling. - [[ "$repo" == *LazyVim/starter* ]] && sudo -u "$USERNAME" rm -rf "${dest}/.git" + [[ "$repo" == *LazyVim/starter* ]] && run_as_owner rm -rf "${dest}/.git" return 0 } @@ -230,6 +230,18 @@ node_installed_major() { node -v 2>/dev/null | sed 's/^v//; s/\..*//'; } install_node() { local major="$1" + + # NodeSource's setup script adds an apt repository — it is Debian/Ubuntu-only by name + # and by mechanism (checks for dpkg internally), so there is nothing to point it at on + # macOS. Homebrew's `node`/`nodejs` formula (both names resolve, confirmed) tracks only + # the current release rather than an arbitrary major the way NodeSource's per-major + # scripts do — same tradeoff this codebase already accepts for Docker and Xcode CLT on + # macOS: brew's own version, not a pinned one. + if [[ "${OS:-}" == "macos" ]]; then + pkg_install_now nodejs + return + fi + # NodeSource publishes one setup script per major. Checked before it is piped # into a shell, because a 404 page piped to bash is a confusing way to fail. curl -fsS -o /dev/null "https://deb.nodesource.com/setup_${major}.x" || { @@ -358,7 +370,7 @@ ensure_bun_symlink() { install_bun() { local tag="${1:-}" [[ -n "$tag" ]] || return 1 - (cd / && sudo -H -u "$USERNAME" bash -c "curl -fsSL https://bun.sh/install | bash -s '${tag}'") >/dev/null 2>&1 + (cd / && run_as_owner bash -c "curl -fsSL https://bun.sh/install | bash -s '${tag}'") >/dev/null 2>&1 [[ -x "${USER_HOME}/.bun/bin/bun" ]] } @@ -367,7 +379,7 @@ install_pm2() { npm install -g pm2 >/dev/null 2>&1; } deno_installed() { command -v deno &>/dev/null || [[ -x "${USER_HOME}/.deno/bin/deno" ]]; } install_deno() { - (cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://deno.land/install.sh | sh') >/dev/null 2>&1 + (cd / && run_as_owner bash -c 'curl -fsSL https://deno.land/install.sh | sh') >/dev/null 2>&1 [[ -x "${USER_HOME}/.deno/bin/deno" ]] } @@ -434,16 +446,16 @@ agent_is_npm_install() { [[ "$(readlink -f "$(agent_path "$1")" 2>/dev/null)" == agent_version() { local bin bin="$(agent_path "$1")" - [[ -n "$bin" ]] && (cd / && sudo -H -u "$USERNAME" "$bin" --version 2>/dev/null | head -1) + [[ -n "$bin" ]] && (cd / && run_as_owner "$bin" --version 2>/dev/null | head -1) } install_claude_code() { - (cd / && sudo -H -u "$USERNAME" bash -c "set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | bash") >/dev/null 2>&1 + (cd / && run_as_owner bash -c "set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | bash") >/dev/null 2>&1 [[ -x "$(agent_bin claude)" ]] } install_opencode() { - (cd / && sudo -H -u "$USERNAME" bash -c "set -e; curl -fsSL ${OPENCODE_INSTALL_URL} | bash") >/dev/null 2>&1 + (cd / && run_as_owner bash -c "set -e; curl -fsSL ${OPENCODE_INSTALL_URL} | bash") >/dev/null 2>&1 [[ -x "$(agent_bin opencode)" ]] } @@ -471,7 +483,7 @@ editor_candidates() { # `|| true` for the same reason git_get has it: "not set" is an answer, and an # assignment from a function that exits non-zero aborts the run under `set -e`. -current_editor() { (cd / && sudo -H -u "$USERNAME" bash -lc 'echo "${EDITOR:-}"' 2>/dev/null) || true; } +current_editor() { (cd / && run_as_owner bash -lc 'echo "${EDITOR:-}"' 2>/dev/null) || true; } set_system_editor() { local editor="$1" path diff --git a/scripts/setup/machine-setup/lib/packages.sh b/scripts/setup/machine-setup/lib/packages.sh index edd3f41b..aaa7b5c4 100644 --- a/scripts/setup/machine-setup/lib/packages.sh +++ b/scripts/setup/machine-setup/lib/packages.sh @@ -253,13 +253,20 @@ pkg_install() { # 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 -n _present="$2" - local -n _missing="$3" + local -a _present _missing + eval "_present=(\"\${$2[@]}\")" + eval "_missing=(\"\${$3[@]}\")" echo "" info "${label} — installs what is missing, keeps what you already have" diff --git a/scripts/setup/machine-setup/lib/tailscale.sh b/scripts/setup/machine-setup/lib/tailscale.sh index 916d1741..210887aa 100644 --- a/scripts/setup/machine-setup/lib/tailscale.sh +++ b/scripts/setup/machine-setup/lib/tailscale.sh @@ -245,7 +245,18 @@ tailscale_needs_logout() { # Routing has to be on before this machine can forward anyone else's packets, # whether as an exit node or as a subnet router. Written as a drop-in so it is # visible as this script's doing. +# Both this and install_exit_node_tuning below are Linux-only — sysctl.d, systemd's +# networkd-dispatcher, ethtool NIC offload flags, none of which macOS has an equivalent +# concept for. Tailscale itself DOES support acting as an exit node from macOS (that's the +# `--advertise-exit-node` flag on `tailscale up`, untouched by this guard) — what's +# unavailable is specifically this forwarding-throughput tuning, which is also a much +# narrower loss on a Mac: the file's own MACOS_SKIP already treats a laptop advertising as +# an exit node as an edge case, not the common path a homelab box is. enable_ip_forwarding() { + if [[ "${OS:-}" == "macos" ]]; then + warn "IP-forwarding tuning is Linux-only (sysctl.d) — skipping; the exit node still works, just untuned" + return 0 + fi cat >"$TS_EXIT_SYSCTL" <<'EOF' # Written by machine-setup: required to forward traffic for other tailnet nodes, # as an exit node or as a subnet router. @@ -265,6 +276,10 @@ EOF # once, because the settings are per-interface and do not survive the link going # down and back up. install_exit_node_tuning() { + if [[ "${OS:-}" == "macos" ]]; then + warn "NIC offload tuning is Linux-only (networkd-dispatcher, ethtool) — skipping" + return 0 + fi pkg_is_installed networkd-dispatcher || pkg_install_now networkd-dispatcher mkdir -p "$(dirname "$TS_DISPATCHER")" diff --git a/scripts/setup/machine-setup/lib/tools.sh b/scripts/setup/machine-setup/lib/tools.sh index b83088ac..c92bdf87 100644 --- a/scripts/setup/machine-setup/lib/tools.sh +++ b/scripts/setup/machine-setup/lib/tools.sh @@ -48,7 +48,15 @@ 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 } @@ -57,7 +65,16 @@ tool_install_lazydocker() { # 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" ;; @@ -82,7 +99,14 @@ tool_install_lazygit() { # 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 } diff --git a/scripts/setup/machine-setup/machine-setup.sh b/scripts/setup/machine-setup/machine-setup.sh index 37ac3edb..602cf401 100755 --- a/scripts/setup/machine-setup/machine-setup.sh +++ b/scripts/setup/machine-setup/machine-setup.sh @@ -46,7 +46,9 @@ while [[ $# -gt 0 ]]; do shift ;; -l | --list) - grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}" + # Portable ERE + sed rather than GNU grep's -P/\K — BSD grep on macOS has no -P at + # all and errors immediately ("invalid option -- P"), confirmed live. + grep -E '^step "' "${BASH_SOURCE[0]}" | sed -E 's/^step "([^"]+)".*/\1/' exit 0 ;; -h | --help) @@ -114,15 +116,17 @@ info "Machine: ${OS_NAME} (${ARCH})" info "Packages: ${PM:-none detected}" [[ "$IS_WSL" == true ]] && warn "WSL detected — the suspend, logind and boot-hang steps do not apply here" -# Everything below this line is written against apt and systemd. Detection above -# recognises pacman, dnf and brew so the branches have somewhere to hang, but -# nothing implements them yet — and running the apt path on Arch would half-build -# a machine and stop somewhere unhelpful. Refuse clearly instead, and relax this -# list one entry at a time as each package manager grows a real path. +# Everything below this line is written against apt and systemd, with real macOS/brew +# branches threaded through where a Mac needs different behaviour (MACOS_SKIP for whole +# steps that are server-only, `$OS == macos` for the rest). Detection above also recognises +# pacman and dnf so THEIR branches have somewhere to hang, but nothing implements those yet +# — running the apt path on Arch would half-build a machine and stop somewhere unhelpful. +# Refuse clearly instead, and relax this list one entry at a time as each package manager +# grows a real path. case "$PM" in - apt) ;; + apt | brew) ;; "") fail "Could not find a package manager for '${OS_NAME}'." ;; - *) fail "${OS_NAME} uses ${PM}, which this script does not implement yet — apt-based systems only, so far." ;; + *) fail "${OS_NAME} uses ${PM}, which this script does not implement yet — apt and brew only, so far." ;; esac ask_machine_role @@ -404,7 +408,9 @@ fi step "System update" if ! skip; then - mapfile -t UPGRADABLE < <(pkg_upgradable) + # `mapfile` is bash 4+; macOS's stock bash is 3.2. This is the portable substitute. + UPGRADABLE=() + while IFS= read -r _line; do UPGRADABLE+=("$_line"); done < <(pkg_upgradable) echo "" info "System update — upgrades installed packages to their latest versions" @@ -2479,7 +2485,9 @@ EOF echo " which is why git's own core.editor is deliberately not set: git" echo " falls back to \$EDITOR, so setting it here covers everything." echo "" - mapfile -t EDITORS < <(editor_candidates) + # mapfile is bash 4+; macOS's stock bash is 3.2. + EDITORS=() + while IFS= read -r _line; do EDITORS+=("$_line"); done < <(editor_candidates) if ((${#EDITORS[@]} == 0)); then warn "no editor found to offer — skipping" else diff --git a/scripts/setup/officer-setup.sh b/scripts/setup/officer-setup.sh index ae0f853e..547302a9 100755 --- a/scripts/setup/officer-setup.sh +++ b/scripts/setup/officer-setup.sh @@ -50,7 +50,9 @@ while [[ $# -gt 0 ]]; do shift ;; -l | --list) - grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}" + # Portable ERE + sed rather than GNU grep's -P/\K — BSD grep on macOS has no -P at + # all and errors immediately ("invalid option -- P"), confirmed live. + grep -E '^step "' "${BASH_SOURCE[0]}" | sed -E 's/^step "([^"]+)".*/\1/' exit 0 ;; -h | --help) @@ -211,8 +213,11 @@ fi echo "" info "What Officer needs from this machine" -mapfile -t MISSING < <(missing_tools) -mapfile -t MISSING_OPT < <(missing_optional_tools) +# mapfile is bash 4+; macOS's stock bash is 3.2, so this reads line-by-line instead. +MISSING=() +while IFS= read -r _line; do MISSING+=("$_line"); done < <(missing_tools) +MISSING_OPT=() +while IFS= read -r _line; do MISSING_OPT+=("$_line"); done < <(missing_optional_tools) for t in "${REQUIRED_TOOLS[@]}"; do if command -v "$t" &>/dev/null; then @@ -274,7 +279,9 @@ if ! skip; then echo " To put data/ on a bigger volume later, symlink it — that is a" echo " decision about storage rather than about how Officer is laid out." - mapfile -t WRONG_OWNER < <(layout_wrong_owner) + # mapfile is bash 4+; macOS's stock bash is 3.2. + WRONG_OWNER=() + while IFS= read -r _line; do WRONG_OWNER+=("$_line"); done < <(layout_wrong_owner) if ((${#WRONG_OWNER[@]} > 0)); then echo "" warn "these exist but do not belong to ${USERNAME}:" diff --git a/scripts/setup/officer-setup/lib/base.sh b/scripts/setup/officer-setup/lib/base.sh index 6f519e1c..8833ea49 100644 --- a/scripts/setup/officer-setup/lib/base.sh +++ b/scripts/setup/officer-setup/lib/base.sh @@ -47,10 +47,16 @@ fail() { 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 ` 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 [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then + if [[ "$(lower "$1")" == "$(lower "$ONLY_STEP")" ]]; then SKIP_STEP=false echo "" echo -e "${BOLD}── $1 ──${NC}" @@ -134,7 +140,39 @@ ask_required() { 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. -as_owner() { (cd "${2:-/}" && sudo -H -u "$USERNAME" bash -c "$1"); } +# +# 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 +} diff --git a/scripts/setup/officer-setup/lib/build.sh b/scripts/setup/officer-setup/lib/build.sh index 9144875d..33217ffb 100644 --- a/scripts/setup/officer-setup/lib/build.sh +++ b/scripts/setup/officer-setup/lib/build.sh @@ -24,15 +24,20 @@ OFFICER_SETUP_BUILD_LOADED=1 # attached. drizzle-kit then fails instead of waiting, which is the outcome you # want at 3am. push_schema() { - sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun db:push &1 + as_owner "cd '$(platform_dir)' && bun db:push &1 } # What tables the schema will create, read from the aggregator rather than # guessed. This is what makes the section able to say what it is about to do. +# +# Portable ERE + sed rather than GNU grep's -P/lookbehind: BSD grep on macOS has no -P at +# all (confirmed live — errors immediately), and its 2>/dev/null here would have hidden that +# and reported "0 tables" on every macOS run instead, cosmetic but silently wrong. schema_table_count() { local dir dir="$(platform_dir)/src/databases/officer_db/src" - grep -oP "^export \* from '\./\K[\w-]+(?=/schema')" "$dir/schema.ts" 2>/dev/null | while read -r f; do + grep -E "^export \* from '\./" "$dir/schema.ts" 2>/dev/null | + sed -E "s/^export \* from '\.\/([A-Za-z0-9_-]+)\/schema'.*/\1/" | while read -r f; do grep -c "pgTable(" "$dir/$f/schema.ts" 2>/dev/null || true done | awk '{s+=$1} END {print s+0}' } @@ -43,7 +48,7 @@ schema_table_count() { # Not optional and not cosmetic: without it the server has no page to serve. It # is gitignored, so a fresh clone never has one. gen_index() { - sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun gen:index '$ENV_PUBLIC_URL'" 2>&1 + as_owner "cd '$(platform_dir)' && bun gen:index '$ENV_PUBLIC_URL'" 2>&1 } gen_index_output() { echo "$(platform_dir)/src/apps/officer-web/index.gen.html"; } diff --git a/scripts/setup/officer-setup/lib/env.sh b/scripts/setup/officer-setup/lib/env.sh index d7a1b8e2..12e37bb1 100644 --- a/scripts/setup/officer-setup/lib/env.sh +++ b/scripts/setup/officer-setup/lib/env.sh @@ -111,7 +111,12 @@ ENVF # is any better a guess. tailnet_hostname() { local dns ip - dns="$(tailscale status --json 2>/dev/null | grep -oP '"DNSName":\s*"\K[^"]+' | head -1)" + # jq over grep -oP: this output is real JSON, and GNU grep's -P/\K doesn't exist on BSD + # grep at all (confirmed live — errors visibly), which used to leak straight to the + # terminal since only the `tailscale` call's stderr was suppressed, not the grep's. jq is + # already an unconditional dependency elsewhere in this script (proxy.sh), so this is + # strictly a correctness improvement even setting the portability problem aside. + dns="$(tailscale status --json 2>/dev/null | jq -r '.Self.DNSName // empty')" dns="${dns%.}" # MagicDNS reports it fully qualified, with a trailing dot dns="${dns%%.*}" # and we want the short name if [[ -n "$dns" ]]; then diff --git a/scripts/setup/officer-setup/lib/layout.sh b/scripts/setup/officer-setup/lib/layout.sh index fade90c7..f97250d2 100644 --- a/scripts/setup/officer-setup/lib/layout.sh +++ b/scripts/setup/officer-setup/lib/layout.sh @@ -39,6 +39,13 @@ layout_dirs() { echo "$(layout_items_dir)" } +# A file's owning username, portably: GNU stat (Linux) takes `-c %U`, BSD stat (macOS) has +# no `-c` at all and wants `-f %Su` instead — confirmed live, GNU form exits +# "illegal option -- c" under macOS's stat. Try GNU first since that is the common case, +# fall back to BSD rather than branching on an OS variable this file does not otherwise +# track. +file_owner() { stat -c %U "$1" 2>/dev/null || stat -f %Su "$1"; } + # Created owned by the account, because everything that writes into them runs as # the account: the platform under pm2, the app store's compose files, the item # store the agent authors into. @@ -58,7 +65,7 @@ layout_wrong_owner() { local dir while read -r dir; do [[ -d "$dir" ]] || continue - [[ "$(stat -c %U "$dir")" == "$USERNAME" ]] || echo "$dir" + [[ "$(file_owner "$dir")" == "$USERNAME" ]] || echo "$dir" done < <(layout_dirs) } diff --git a/scripts/setup/officer-setup/lib/postgres.sh b/scripts/setup/officer-setup/lib/postgres.sh index 17ee4882..dddd20c4 100644 --- a/scripts/setup/officer-setup/lib/postgres.sh +++ b/scripts/setup/officer-setup/lib/postgres.sh @@ -62,7 +62,7 @@ pg_client_installed() { # entry, no add-apt-repository. Non-fatal — an install without psql is a working platform, # just a more annoying one to operate. install_pg_client() { - local major codename + local major major="$(pg_client_major)" [[ -n "$major" ]] || { warn "could not read a major version out of PG_IMAGE=${PG_IMAGE} — skipping the client" @@ -74,6 +74,49 @@ install_pg_client() { return 0 fi + if [[ "$(uname -s)" == "Darwin" ]]; then + install_pg_client_macos "$major" + return $? + fi + + install_pg_client_linux "$major" +} + +# Homebrew's `postgresql@` matches the apt path's own logic: the version has to agree +# with the server's, not just be "a psql". It is keg-only — Homebrew never links more than one +# Postgres onto PATH by default, because a dev machine plausibly has several majors installed +# for different projects — so `brew install` alone does not make `psql` resolve. Symlinking +# only the four client binaries this platform actually uses, rather than `brew link --force`ing +# the whole keg, is the same caveat Homebrew's own postgresql formula prints; force-linking +# would silently steal `psql` from whatever other major was linked before this ran. +install_pg_client_macos() { + local major="$1" prefix + command -v brew &>/dev/null || { + warn "Homebrew is not installed — cannot install postgresql@${major}. Run machine-setup.sh first." + return 1 + } + prefix="$(brew --prefix)" + + brew install "postgresql@${major}" &>/dev/null || { + warn "brew install postgresql@${major} failed" + return 1 + } + + local bin + for bin in psql pg_dump pg_dumpall pg_restore; do + [[ -x "${prefix}/opt/postgresql@${major}/bin/${bin}" ]] || continue + ln -sf "${prefix}/opt/postgresql@${major}/bin/${bin}" "${prefix}/bin/${bin}" + done + + pg_client_installed || { + warn "psql is not version ${major} after installing — check: brew list postgresql@${major}" + return 1 + } + ok "psql $(psql --version | grep -oE '[0-9]+\.[0-9]+' | head -1) installed via Homebrew" +} + +install_pg_client_linux() { + local major="$1" codename codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")" [[ -n "$codename" ]] || { warn "could not work out this release's codename — cannot add the PostgreSQL repository" @@ -121,7 +164,12 @@ pg_container_running() { docker ps --filter "name=^${PG_CONTAINER}$" --format '{ # Is something already answering on the port? A Postgres the user runs their own # way is a perfectly good answer, and finding out by failing to bind is not. -pg_port_in_use() { ss -ltn 2>/dev/null | grep -qE "127\.0\.0\.1:${PG_PORT}\b|\*:${PG_PORT}\b|0\.0\.0\.0:${PG_PORT}\b"; } +# +# `ss` is Linux-only; `listening_addrs` (lib/base.sh) is the portable version, branching to +# `lsof` on macOS. Confirmed empirically to fail silently the old way: `ss` doesn't exist on +# macOS, its "command not found" was swallowed by `2>/dev/null`, and an empty grep result +# reported the port as free even when something WAS listening. +pg_port_in_use() { [[ -n "$(listening_addrs "$PG_PORT")" ]]; } # Bound to loopback, deliberately, and the reason is worth keeping next to the # line it explains. diff --git a/scripts/setup/officer-setup/lib/preflight.sh b/scripts/setup/officer-setup/lib/preflight.sh index d41b240a..1b5570f9 100644 --- a/scripts/setup/officer-setup/lib/preflight.sh +++ b/scripts/setup/officer-setup/lib/preflight.sh @@ -69,7 +69,19 @@ tool_why() { # 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/` 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="$(getent passwd "$USERNAME" 2>/dev/null | cut -d: -f6)" + USER_HOME="$(eval echo ~"$USERNAME" 2>/dev/null)" + [[ "$USER_HOME" == "~"* ]] && USER_HOME="" [[ -n "$USER_HOME" ]] || USER_HOME="/home/${USERNAME}" } diff --git a/scripts/setup/officer-setup/lib/proxy.sh b/scripts/setup/officer-setup/lib/proxy.sh index 4e216211..d2f6dd04 100644 --- a/scripts/setup/officer-setup/lib/proxy.sh +++ b/scripts/setup/officer-setup/lib/proxy.sh @@ -58,7 +58,9 @@ proxy_ask() { # reads like a firewall fault. So the bind ADDRESS is what gets checked. proxy_require_listening() { local port="$1" listen - listen="$(ss -ltnH "sport = :$port" 2>/dev/null | awk '{print $4}')" + # `ss` is Linux-only; `listening_addrs` (lib/base.sh) branches to `lsof` on macOS. Same + # silent-empty-result failure mode as postgres.sh's pg_port_in_use if this were left as-is. + listen="$(listening_addrs "$port" | head -1)" [[ -n "$listen" ]] || { warn "nothing is listening on port ${port} — start Officer first" return 1 @@ -170,7 +172,15 @@ proxy_wait_for_dns() { info "(Tailnet: the certificate can issue without this, but the name will not resolve until it exists.)" while ((elapsed < timeout)); do - got="$(getent hosts "$domain" 2>/dev/null | awk '{print $1}' | head -1)" + # `getent` is glibc/Linux-only — absent on macOS (confirmed: "command not found" on a + # real machine), silently swallowed by 2>/dev/null, which would have spun this through + # its full 30-minute timeout regardless of whether the record actually resolved. + # `dscacheutil` is macOS's own equivalent, consulting the same system resolver. + if [[ "$(uname -s)" == "Darwin" ]]; then + got="$(dscacheutil -q host -a name "$domain" 2>/dev/null | awk '/^ip_address:/ {print $2; exit}')" + else + got="$(getent hosts "$domain" 2>/dev/null | awk '{print $1}' | head -1)" + fi if [[ "$got" == "$want" ]]; then ok "${domain} resolves to ${want}" return 0 @@ -194,8 +204,18 @@ proxy_ensure_network() { # NPM binds its admin UI to the tailnet IP. If docker starts before tailscaled that # address does not exist yet and the WHOLE container fails to start, not just that port. +# +# Docker Desktop on macOS has no systemd unit to order — it is a GUI app the OS launches, not +# a service tailscaled could be declared a dependency of — and this account does not run as +# root there either, so `mkdir -p /etc/systemd/...` would fail on permissions rather than do +# something merely unnecessary. Same shape as machine-setup's MACOS_SKIP: skip with a reason +# rather than let a Linux-only action fail confusingly. proxy_order_docker_after_tailscaled() { [[ "$CHALLENGE" == "dns" ]] || return 0 + if [[ "$(uname -s)" == "Darwin" ]]; then + ok "Docker Desktop has no systemd unit to order — skipping" + return 0 + fi local f=/etc/systemd/system/docker.service.d/10-after-tailscaled.conf [[ -f "$f" ]] && return 0 mkdir -p "$(dirname "$f")" @@ -378,8 +398,20 @@ proxy_get_token() { # bypass ufw entirely. The symptom is a 504 that looks like a network fault. A container # upstream would need none of this, which is why container upstreams are preferable when # there is a choice. +# `ufw` is Debian/Ubuntu — no macOS equivalent through this interface (pf/pfctl is a +# different rule syntax entirely, and the Application Firewall machine-setup's own +# MACOS_SKIP already documents for "Firewall" is per-APP, not per-subnet/per-port). Skipped +# rather than guessed at: Docker Desktop for Mac also runs containers inside its own VM, so +# bridge-to-host traffic does not cross the real host firewall the same way it does when +# Docker runs directly on a Linux kernel — an unverified pf rule here could be solving a +# problem macOS's Docker networking model does not actually have. proxy_allow_bridge_to_host() { local port="$1" subnet + if [[ "$(uname -s)" == "Darwin" ]]; then + ok "no ufw on macOS — Docker Desktop's own networking model handles bridge-to-host, skipping" + BRIDGE_GATEWAY="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Gateway}}')" + return 0 + fi subnet="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Subnet}}')" if ufw status 2>/dev/null | grep -q "${port}.*${subnet%%/*}"; then ok "ufw already allows the bridge to reach port ${port}" diff --git a/scripts/setup/officer-setup/lib/secrets.sh b/scripts/setup/officer-setup/lib/secrets.sh index dabe11e2..358b1aef 100644 --- a/scripts/setup/officer-setup/lib/secrets.sh +++ b/scripts/setup/officer-setup/lib/secrets.sh @@ -35,7 +35,7 @@ bootstrap_secret_store() { # From the repo, because the module derives the install root as the parent of # the working directory — the same rule as src/servers/data-path.ts. - sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun --eval \" + as_owner "cd '$(platform_dir)' && bun --eval \" const { getKey } = await import('officerdb/secret-store'); getKey('jwt'); \"" >/dev/null 2>&1 || return 1 diff --git a/scripts/setup/officer-setup/lib/services.sh b/scripts/setup/officer-setup/lib/services.sh index a232d085..7fcbc75c 100644 --- a/scripts/setup/officer-setup/lib/services.sh +++ b/scripts/setup/officer-setup/lib/services.sh @@ -101,24 +101,36 @@ FOOTER } pm2_start() { - sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && pm2 startOrRestart '$(ecosystem_file)' --update-env" 2>&1 + as_owner "cd '$(platform_dir)' && pm2 startOrRestart '$(ecosystem_file)' --update-env" 2>&1 } -pm2_save() { sudo -u "$USERNAME" pm2 save 2>&1; } +pm2_save() { as_owner "pm2 save" 2>&1; } # Survive a reboot. `pm2 startup` PRINTS a command for root to run rather than # doing it — so this runs what it prints, which is the whole point of already # being root here. +# +# `systemd` is hardcoded because that IS the only path today — pm2's own "platform" argument +# has to be `launchd` on macOS instead, generating a LaunchAgent plist rather than a systemd +# unit, and running it is a separate mechanism (`launchctl load`, not "eval a printed sudo +# command"). That is real, separate work rather than a one-line swap, so this skips with a +# stated reason on macOS instead of pretending the systemd path could ever produce anything +# usable there — matches the same call this codebase already made for the Tailscale exit-node +# NIC tuning and the proxy's systemd docker-ordering fix. pm2_enable_startup() { + if [[ "$(uname -s)" == "Darwin" ]]; then + warn "pm2 boot-persistence needs a launchd plist on macOS, not a systemd unit — not implemented yet" + return 1 + fi local cmd - cmd="$(sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && pm2 startup systemd -u '$USERNAME' --hp '$USER_HOME'" 2>/dev/null | grep -E '^sudo ' | tail -1)" + cmd="$(as_owner "cd '$(platform_dir)' && pm2 startup systemd -u '$USERNAME' --hp '$USER_HOME'" 2>/dev/null | grep -E '^sudo ' | tail -1)" [[ -z "$cmd" ]] && return 1 eval "${cmd#sudo }" } # One line per process: name, status, restarts. pm2_status_lines() { - sudo -u "$USERNAME" pm2 jlist 2>/dev/null | + as_owner "pm2 jlist" 2>/dev/null | node -e ' let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => { let apps = []; try { apps = JSON.parse(s); } catch { }