From b075f1f88299c63442356890c80def4e430a8cb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 13 Aug 2026 02:29:56 +0000 Subject: [PATCH] macOS is a dev machine, and machine-setup now treats it as one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Officer on a Mac is a dev helper on a laptop somebody sits at. It is never the homelab or VPS case, so the role is not asked for there — it is `dev`, and every section that exists to make a machine a good server is skipped. Seventeen of twenty-six sections skip, listed once in MACOS_SKIP in lib/base.sh with a reason each, rather than an `if macos` threaded through each section. Most would simply fail — no systemd, no ufw, no netplan, no useradd, no /etc/ssh/sshd_config.d — but a few would SUCCEED and be wrong, which is worse: stopping a laptop from sleeping, or freezing the address of a machine that moves between networks daily. Nine run: System update, Core utils, Tailscale, Command-line tools, Git, Docker, Neovim, JavaScript runtimes, Agent CLIs. The blocker was root. Linux needs it for nearly everything; Homebrew REFUSES to run as root and says so, so the whole script under sudo would have failed at the first brew install having already taken a password. It is now required on Linux and refused on macOS, which works precisely because the macOS path skips everything that needed it. Docker is checked, not installed. Docker Desktop is a GUI app that wants opening, permissions and a running window — not a shell script's business — and colima and lima both cost an evening the first time something does not resolve. So the step reports whether the daemon answers and points at the download otherwise. The group-vs-rootless choice below it is Linux only: Desktop runs containers in a VM owned by whoever is logged in, so there is no group to join. Added the Xcode command line tools as a macOS-only step, before anything that builds. node-pty ships no prebuilt binary on any platform and always falls through to node-gyp, so `bun install` cannot finish without a compiler — and it fails deep in a dependency tree naming neither Xcode nor node-pty. `xcode-select --install` opens a dialogue and returns immediately, so the step says to come back rather than pretending to have waited. Tailscale takes the cask, not install.sh — that script is a Linux package-manager wrapper. The cask ships a usable CLI; the Mac App Store build is sandboxed and does not. Not run on a Mac. There isn't one here, so this is read from the code and from what each tool documents, not observed. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/setup/machine-setup/lib/base.sh | 52 +++++++++ scripts/setup/machine-setup/lib/docker.sh | 5 + scripts/setup/machine-setup/lib/packages.sh | 24 ++++- scripts/setup/machine-setup/lib/tailscale.sh | 13 ++- scripts/setup/machine-setup/machine-setup.sh | 106 +++++++++++++++++-- scripts/setup/officer-setup.sh | 83 ++++++++++++++- scripts/setup/officer-setup/lib/build.sh | 49 +++++++++ 7 files changed, 316 insertions(+), 16 deletions(-) create mode 100644 scripts/setup/officer-setup/lib/build.sh diff --git a/scripts/setup/machine-setup/lib/base.sh b/scripts/setup/machine-setup/lib/base.sh index 4cdceec3..0284571e 100644 --- a/scripts/setup/machine-setup/lib/base.sh +++ b/scripts/setup/machine-setup/lib/base.sh @@ -150,9 +150,52 @@ load_answers() { # file — the point of asking for a single step is to run that step. ONLY_STEP="${ONLY_STEP:-}" +# ── Steps that do not exist on macOS ── +# +# A Mac running Officer is a DEV MACHINE, never a server. That is not a +# simplification to revisit: nobody puts a laptop behind a public hostname and +# hands it a tailnet exit node, and the sections below are all about being a +# server that is on all the time. +# +# Most would fail rather than misbehave — there is no systemd, no ufw, no +# netplan, no useradd, no /etc/ssh/sshd_config.d. But a few would SUCCEED and be +# wrong, which is worse: stopping a laptop from sleeping, or freezing its address +# on a network it moves between every day. +# +# 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" +) + step() { CURRENT_STEP="$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 + fi + if [[ -n "$ONLY_STEP" ]]; then if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then SKIP_STEP=false @@ -585,6 +628,15 @@ default_iface() { # firewall open on one. Every branch downstream is about what this machine is # exposed to, so it is worth one deliberate keystroke rather than an Enter. ask_machine_role() { + # Not a question on a Mac. Officer on macOS is a dev helper on a machine + # somebody sits at — there is no homelab or VPS answer that would make sense, + # and every section that branches on the role branches toward "server". + if [[ "${OS:-}" == "macos" && -z "$MACHINE_ROLE" ]]; then + MACHINE_ROLE="dev" + info "macOS — treated as a dev machine. The server-only sections are skipped." + return + fi + if [[ -n "$MACHINE_ROLE" ]]; then case "$MACHINE_ROLE" in homelab | vps | dev) return ;; diff --git a/scripts/setup/machine-setup/lib/docker.sh b/scripts/setup/machine-setup/lib/docker.sh index 44237f70..ff1e885a 100644 --- a/scripts/setup/machine-setup/lib/docker.sh +++ b/scripts/setup/machine-setup/lib/docker.sh @@ -49,6 +49,11 @@ docker_repo_distro() { } install_docker_engine() { + # Linux only, and never reached on macOS: the Docker step there checks for + # Docker Desktop and tells the owner to install it rather than doing it — a GUI + # app that wants opening, permissions and a running window is not a shell + # script's job, and colima/lima are not worth the evening they cost. + local distro codename distro="$(docker_repo_distro)" codename="$(docker_repo_codename)" diff --git a/scripts/setup/machine-setup/lib/packages.sh b/scripts/setup/machine-setup/lib/packages.sh index f4613d73..d6c40f98 100644 --- a/scripts/setup/machine-setup/lib/packages.sh +++ b/scripts/setup/machine-setup/lib/packages.sh @@ -104,8 +104,8 @@ pkgs_core() { ;; brew) # curl, unzip and the TLS roots ship with macOS; the compilers come from - # the Xcode command line tools, which is not a formula. - echo gnupg git jq wget btop htop tree tmux ripgrep fd + # the Xcode command line tools, which is not a formula — see xcode_clt_*. + echo gnupg git jq wget btop htop tree ripgrep fd ;; esac } @@ -262,3 +262,23 @@ summarise_last() { 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 +} diff --git a/scripts/setup/machine-setup/lib/tailscale.sh b/scripts/setup/machine-setup/lib/tailscale.sh index e2264ea2..3df7a3ec 100644 --- a/scripts/setup/machine-setup/lib/tailscale.sh +++ b/scripts/setup/machine-setup/lib/tailscale.sh @@ -190,7 +190,18 @@ tailscale_control_url() { tailscale debug prefs 2>/dev/null | awk -F'"' '/"ControlURL"/ { print $4; exit }' } -tailscale_install() { curl -fsSL https://tailscale.com/install.sh | sh; } +# The official install.sh is a Linux package-manager script. macOS gets the same +# daemon wrapped in a GUI app, and the cask is the version with a CLI at +# /Applications/Tailscale.app/Contents/MacOS/Tailscale — the Mac App Store build +# is sandboxed and ships no usable `tailscale` binary, which is the difference +# that matters to a script. +tailscale_install() { + if [[ "${OS:-}" == "macos" ]]; then + brew install --cask tailscale + return + fi + curl -fsSL https://tailscale.com/install.sh | sh +} # Tailscale's own coordination server, spelled out. # diff --git a/scripts/setup/machine-setup/machine-setup.sh b/scripts/setup/machine-setup/machine-setup.sh index 4f2f5d70..97a40974 100755 --- a/scripts/setup/machine-setup/machine-setup.sh +++ b/scripts/setup/machine-setup/machine-setup.sh @@ -119,15 +119,39 @@ else echo " so coming back costs nothing." fi -if [[ "$EUID" -ne 0 ]]; then - fail "Please run as root: sudo ./machine-setup.sh" +# ── root on Linux, NOT root on macOS ── +# +# The two are opposites and it is not a preference. On Linux nearly every section +# needs root — apt, systemd units, useradd, netplan, ufw. On macOS Homebrew +# REFUSES to run as root and says so; running the whole script under sudo there +# would fail at the first `brew install` having already asked for a password. +# +# It works out because the macOS path skips everything that needed root in the +# first place (see MACOS_SKIP in lib/base.sh). What is left — brew, the Xcode +# command line tools, the agent CLIs, bun — is all per-user by design. +if [[ "$OS" == "macos" ]]; then + if [[ "$EUID" -eq 0 ]]; then + fail "Do not run this with sudo on macOS — Homebrew refuses to run as root. Run it as yourself." + fi +else + if [[ "$EUID" -ne 0 ]]; then + fail "Please run as root: sudo ./machine-setup.sh" + fi fi -ask_username -if id "$USERNAME" &>/dev/null; then - info "Account: ${USERNAME} (exists, home ${USER_HOME})" +# On macOS the account running the script IS the account, and there is nothing to +# create — the User account step is skipped entirely. +if [[ "$OS" == "macos" ]]; then + USERNAME="$(id -un)" + USER_HOME="$HOME" + info "Account: ${USERNAME} (you — macOS creates no accounts here)" else - info "Account: ${USERNAME} (will be created, home ${USER_HOME})" + ask_username + if id "$USERNAME" &>/dev/null; then + info "Account: ${USERNAME} (exists, home ${USER_HOME})" + else + info "Account: ${USERNAME} (will be created, home ${USER_HOME})" + fi fi ask_officer_root @@ -374,6 +398,34 @@ fi # What the distribution provides: the six this script would break without, and # the command-line tools that make a machine worth sitting at. +# macOS's compilers, before anything that might need to build a native module. +if [[ "$OS" == "macos" ]]; then + step "Xcode command line tools" + if ! skip; then + echo "" + if xcode_clt_installed; then + ok "already installed ($(xcode-select -p))" + SUMMARY+=("Xcode CLT: already installed") + else + info "Xcode command line tools — macOS's compilers" + echo " Needed because node-pty ships no prebuilt binary and compiles" + echo " from source on every machine, so 'bun install' cannot finish" + echo " without a compiler." + echo "" + if confirm "Start the install?"; then + xcode_clt_install + warn "a macOS dialogue has opened — finish it there, then re-run this step" + echo " ./machine-setup.sh --only 'Xcode command line tools'" + SUMMARY+=("Xcode CLT: install started in a GUI dialogue — finish it, then re-run") + else + warn "skipped — 'bun install' will fail on node-pty without it" + SUMMARY+=("Xcode CLT: SKIPPED by request") + fi + fi + step_ok + fi +fi + step "Core utils" if ! skip; then # shellcheck disable=SC2046 # word splitting is how the list is passed @@ -1644,9 +1696,42 @@ if ! skip; then info "Docker — containers, and how ${USERNAME} is allowed to talk to them" echo " engine: $(docker_is_installed && docker --version 2>/dev/null | cut -d, -f1 || echo 'not installed')" echo " daemon: $(docker_daemon_ok && echo 'reachable' || echo 'not reachable from here')" - echo " ${USERNAME}: $(user_in_docker_group && echo 'in the docker group' || echo 'not in the docker group')" + if [[ "$OS" != "macos" ]]; then + echo " ${USERNAME}: $(user_in_docker_group && echo 'in the docker group' || echo 'not in the docker group')" + fi - if ! docker_is_installed; then + # ── macOS: we do not install Docker, we check for it ── + # + # Docker Desktop is the only thing that works here without a fight. Lima and + # colima both technically run containers on a Mac and both cost an evening the + # first time something does not resolve, so this asks for Desktop by name + # rather than installing an alternative that will disappoint later. + # + # Not installed by the script either: it is a GUI app that wants to be opened, + # granted permissions and left running, none of which a shell script should be + # doing on somebody's laptop. + if [[ "$OS" == "macos" ]]; then + if docker_daemon_ok; then + ok "Docker Desktop is running" + SUMMARY+=("Docker: Docker Desktop running") + elif docker_is_installed; then + warn "the docker CLI is here but the daemon is not answering" + echo " Open Docker Desktop from Applications and let it finish starting." + SUMMARY+=("Docker: installed but not running — open Docker Desktop") + else + warn "Docker is not installed" + echo "" + echo " Officer needs it for Postgres and for anything the app store" + echo " installs. Get Docker Desktop:" + echo "" + echo " https://www.docker.com/products/docker-desktop/" + echo "" + echo " Open it once after installing, then run this step again:" + echo " ./machine-setup.sh --only Docker" + SUMMARY+=("Docker: NOT installed — install Docker Desktop, then re-run this step") + fi + step_ok + elif ! docker_is_installed; then echo "" echo " to install: docker-ce, the CLI, containerd, buildx and compose," echo " from Docker's own repository" @@ -1665,7 +1750,10 @@ if ! skip; then fi fi - if docker_is_installed; then + # The group-vs-rootless choice below is Linux only: Docker Desktop runs + # containers in a VM owned by whoever is logged in, so there is no group to + # join and no rootless variant to pick. + if [[ "$OS" != "macos" ]] && docker_is_installed; then # ── how this account reaches the daemon ── if user_in_docker_group || docker_rootless_installed; then echo "" diff --git a/scripts/setup/officer-setup.sh b/scripts/setup/officer-setup.sh index 124c4ed8..277cc15d 100755 --- a/scripts/setup/officer-setup.sh +++ b/scripts/setup/officer-setup.sh @@ -50,6 +50,8 @@ source "$SCRIPT_DIR/officer-setup/lib/postgres.sh" source "$SCRIPT_DIR/officer-setup/lib/env.sh" # shellcheck source=officer-setup/lib/secrets.sh source "$SCRIPT_DIR/officer-setup/lib/secrets.sh" +# shellcheck source=officer-setup/lib/build.sh +source "$SCRIPT_DIR/officer-setup/lib/build.sh" trap 'echo ""; echo -e "${RED}╔══════════════════════════════════════════════════╗${NC}"; echo -e "${RED}║ OFFICER SETUP FAILED${NC}"; echo -e "${RED}║ Step: ${CURRENT_STEP:-unknown}${NC}"; echo -e "${RED}║ Line: $LINENO${NC}"; echo -e "${RED}║ Command: $BASH_COMMAND${NC}"; echo -e "${RED}╚══════════════════════════════════════════════════╝${NC}"' ERR @@ -554,13 +556,86 @@ if ! skip; then step_ok fi +# ============================================================================= +# 8. Schema +# ============================================================================= + +step "Schema" +if ! skip; then + echo "" + info "Database schema" + echo " ${SCHEMA_TABLES:-?} tables, applied with 'bun db:push' — drizzle-kit" + echo " diffs the schema code against Postgres and alters it directly. There" + echo " are no migration files and no migration table; the code is the source" + echo " of truth." + echo "" + echo " Only the CORE tables. Every plugin's tables are commented out in" + echo " src/databases/officer_db/src/schema.ts and get created when the" + echo " plugin is installed." + echo "" + + if confirm "Push it?"; then + if OUT="$(push_schema)"; then + ok "schema applied" + SUMMARY+=("Schema: ${SCHEMA_TABLES:-?} tables pushed") + else + warn "db:push failed" + echo "$OUT" | tail -12 | sed 's/^/ /' + SUMMARY+=("Schema: FAILED — see the output above") + fi + else + warn "skipped by request — the platform will not start without it" + SUMMARY+=("Schema: SKIPPED by request") + fi + step_ok +fi + +# ============================================================================= +# 9. Build +# ============================================================================= + +step "Build" +if ! skip; then + echo "" + info "index.gen.html" + echo " 'bun gen:index' substitutes your public URL into index.html and" + echo " writes index.gen.html, which is the file the server imports. It is" + echo " gitignored, so a fresh clone never has one and the server has no page" + echo " to serve until this runs." + echo "" + echo " URL: ${ENV_PUBLIC_URL:-}" + echo "" + echo " To change it later: bun gen:index https://your.new.url" + echo "" + + if [[ -z "${ENV_PUBLIC_URL:-}" ]]; then + ENV_PUBLIC_URL="$(env_get PUBLIC_URL)" + fi + + if [[ -z "$ENV_PUBLIC_URL" ]]; then + warn "PUBLIC_URL is not in $(env_file) — run the Environment section, then this one" + SUMMARY+=("Build: SKIPPED — no PUBLIC_URL") + elif confirm "Generate it?"; then + if OUT="$(gen_index)"; then + ok "$(gen_index_output)" + SUMMARY+=("Build: index.gen.html for ${ENV_PUBLIC_URL}") + else + warn "gen:index failed" + echo "$OUT" | tail -8 | sed 's/^/ /' + SUMMARY+=("Build: FAILED — see the output above") + fi + else + warn "skipped by request — the server has no page to serve without it" + SUMMARY+=("Build: SKIPPED by request") + fi + step_ok +fi + # ============================================================================= # NOT BUILT YET # ============================================================================= -# 6 Schema db:push -# 7 Build gen:index -# 8 Services pm2 startOrRestart · save · startup -# 9 Verify are the processes actually up +# 10 Services pm2 startOrRestart · save · startup +# 11 Verify are the processes actually up echo "" echo -e "${BOLD} Pre-flight complete.${NC} The remaining sections are not built yet." diff --git a/scripts/setup/officer-setup/lib/build.sh b/scripts/setup/officer-setup/lib/build.sh new file mode 100644 index 00000000..9144875d --- /dev/null +++ b/scripts/setup/officer-setup/lib/build.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# ============================================================================= +# officer-setup — schema and build +# ============================================================================= +# +# Definitions only. +# +# Both run AS the owner, from the repo. Neither is idempotent in the sense of +# "does nothing the second time" — both are safe to repeat, which is not the same +# thing and is the property that matters for a script people re-run. + +[[ -n "${OFFICER_SETUP_BUILD_LOADED:-}" ]] && return 0 +OFFICER_SETUP_BUILD_LOADED=1 + +# `bun db:push` — drizzle-kit diffs the schema code against the live database. +# +# No migrations here and no __drizzle_migrations table: the schema code IS the +# source of truth (src/databases/CLAUDE.md). On the empty database section 5 just +# created there is nothing to drop, so the prompt drizzle-kit shows for a +# destructive change cannot appear. +# +# It can still appear on a RE-RUN against a database with data, and a prompt +# nobody sees would hang the script forever — so stdin is closed rather than left +# 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 +} + +# 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. +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 -c "pgTable(" "$dir/$f/schema.ts" 2>/dev/null || true + done | awk '{s+=$1} END {print s+0}' +} + +# `bun gen:index` — substitutes PUBLIC_URL into index.html and writes +# index.gen.html, which is what the server actually imports. +# +# 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 +} + +gen_index_output() { echo "$(platform_dir)/src/apps/officer-web/index.gen.html"; }