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"; }