#!/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 ` 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 }