diff --git a/.gitignore b/.gitignore index 3902d152..3522b43b 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ public/plugins/ # Written by machine-setup.sh to record completed steps; per-machine, never shared. .setup-progress .setup-answers + +# Written by officer-setup.sh; per-machine. +scripts/setup/officer-setup/.setup-progress diff --git a/scripts/setup/officer-setup.sh b/scripts/setup/officer-setup.sh index ecfe0ab1..3f876e6f 100755 --- a/scripts/setup/officer-setup.sh +++ b/scripts/setup/officer-setup.sh @@ -5,26 +5,167 @@ set -e # officer-setup — the platform, on a machine that is already provisioned # # The second half of the install. machine-setup/ brings a blank box up to a -# usable machine; this puts Officer on top of it: dependencies, .env, the -# database schema, and the pm2 process list. +# usable machine; this puts Officer on top of it. # -# Run: bash scripts/setup/officer-setup.sh +# Run as root: sudo scripts/setup/officer-setup.sh +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROGRESS_FILE="$SCRIPT_DIR/officer-setup/.setup-progress" + +ONLY_STEP="" +while [[ $# -gt 0 ]]; do + case "$1" in + --only) + ONLY_STEP="${2:-}" + shift 2 + ;; + --only=*) + ONLY_STEP="${1#*=}" + shift + ;; + -l | --list) + grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}" + exit 0 + ;; + -h | --help) + echo "usage: officer-setup.sh [--only ] [--list]" + exit 0 + ;; + *) echo "unknown option: $1" >&2 && exit 2 ;; + esac +done + +# shellcheck source=officer-setup/lib/base.sh +source "$SCRIPT_DIR/officer-setup/lib/base.sh" +# shellcheck source=officer-setup/lib/preflight.sh +source "$SCRIPT_DIR/officer-setup/lib/preflight.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 + +# ============================================================================= +# 1. Pre-flight # ============================================================================= -# -# NOT WRITTEN YET. It is being rebuilt from scripts/setup-old/setup.sh, which -# still works and is still the one to run. -# -# This file exists ahead of its contents so that `bun setup` points at where the -# installer is going rather than at a path that is about to move again. It fails -# loudly rather than sitting empty, because an empty script exits 0 and would -# make `bun setup` report success while doing nothing at all. echo "" -echo " officer-setup.sh is not written yet." +echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ Officer Setup ║${NC}" +echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}" + +if [[ "$EUID" -ne 0 ]]; then + fail "Please run as root: sudo ./officer-setup.sh" +fi + +# ── what machine-setup already established ── echo "" -echo " It is being rebuilt from scripts/setup-old/setup.sh, which still works:" +if load_machine_answers; then + info "Read from machine-setup: ${MACHINE_ANSWERS}" +else + warn "machine-setup has not run on this machine" + echo " That is fine if you provisioned it another way — the questions it" + echo " would have answered are asked below instead." +fi + +# ── the account ── +# +# A remembered answer can go stale: the account it names may have been renamed or +# removed since machine-setup ran. That is a reason to ask again, not a reason to +# stop — so the remembered value is checked before it is trusted, and a bad one +# is reported and replaced rather than ending the run. +if [[ -n "$USERNAME" ]] && ! owner_exists; then + warn "the remembered account '${USERNAME}' does not exist on this machine any more" + USERNAME="" +fi + +while [[ -z "$USERNAME" ]] || ! owner_exists; do + echo "" + info "Which account owns this Officer install?" + echo " Its files, its node_modules and its pm2 process list all belong to" + echo " this account rather than to root." + echo "" + ask_required USERNAME "Username" "${SUDO_USER:-}" + owner_exists || warn "There is no account called '${USERNAME}' on this machine." +done + +resolve_user_home + +# ── where it goes ── +if [[ -z "$OFFICER_ROOT" ]]; then + echo "" + info "Where should Officer be installed?" + echo " One directory holding the app, its data, the item store and any" + echo " containers the app store provisions." + echo "" + ask_required OFFICER_ROOT "Path" "${USER_HOME}/officerdev" +fi +OFFICER_ROOT="${OFFICER_ROOT/#\~/$USER_HOME}" +[[ "$OFFICER_ROOT" == /* ]] || fail "That needs to be an absolute path — got '${OFFICER_ROOT}'" +OFFICER_ROOT="${OFFICER_ROOT%/}" + +info "Account: ${USERNAME} (home ${USER_HOME})" +info "Officer: ${OFFICER_ROOT}" +[[ -n "$MACHINE_ROLE" ]] && info "Role: ${MACHINE_ROLE}" + +# ── is the machine actually ready ── +# +# Checked and reported together. Finding out about a missing bun three sections +# in, after a repository has been cloned and a database started, is a worse way +# to learn it. echo "" -echo " bash scripts/setup-old/setup.sh # full install" -echo " OFFICER_PROFILE=light bash scripts/setup-old/setup.sh # light install" +info "What Officer needs from this machine" + +mapfile -t MISSING < <(missing_tools) +mapfile -t MISSING_OPT < <(missing_optional_tools) + +for t in "${REQUIRED_TOOLS[@]}"; do + if command -v "$t" &>/dev/null; then + printf ' %-6s %-10s %s\n' "$t" "ok" "$(tool_why "$t")" + else + printf ' %-6s %-10s %s\n' "$t" "MISSING" "$(tool_why "$t")" + fi +done +for t in "${OPTIONAL_TOOLS[@]}"; do + if command -v "$t" &>/dev/null; then + printf ' %-6s %-10s %s\n' "$t" "ok" "$(tool_why "$t")" + else + printf ' %-6s %-10s %s\n' "$t" "absent" "$(tool_why "$t") — optional" + fi +done + +if ((${#MISSING[@]} > 0)); then + echo "" + fail "Missing: ${MISSING[*]}. Run scripts/setup/machine-setup/machine-setup.sh first, or install them yourself." +fi + +if ((${#MISSING_OPT[@]} > 0)); then + echo "" + warn "No Docker. Postgres will have to be one you already run, and the app" + echo " store cannot provision anything until Docker is installed." +fi + +if [[ -f "$PROGRESS_FILE" ]]; then + echo "" + info "Resuming — $(wc -l <"$PROGRESS_FILE") step(s) already done, and they will be skipped" + echo " To start over instead: sudo rm ${PROGRESS_FILE}" +else + echo "" + echo " This can be stopped at any point and run again later. Completed" + echo " steps are remembered and skipped." +fi + +# ============================================================================= +# NOT BUILT YET +# ============================================================================= +# +# 2 Repository clone or locate the platform at $OFFICER_ROOT/platform +# 3 Dependencies bun install +# 4 Database Postgres in docker, or one you already run +# 5 Environment .env +# 6 Schema db:push +# 7 Build gen:index +# 8 Services pm2 startOrRestart · save · startup +# 9 Verify are the processes actually up + +echo "" +echo -e "${BOLD} Pre-flight complete.${NC} The remaining sections are not built yet." echo "" -exit 1 diff --git a/scripts/setup/officer-setup/lib/base.sh b/scripts/setup/officer-setup/lib/base.sh new file mode 100644 index 00000000..4cbcff12 --- /dev/null +++ b/scripts/setup/officer-setup/lib/base.sh @@ -0,0 +1,132 @@ +#!/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:-}" + +step() { + CURRENT_STEP="$1" + if [[ -n "$ONLY_STEP" ]]; then + if [[ "${1,,}" == "${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="" + 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}"; } + +# 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"); } diff --git a/scripts/setup/officer-setup/lib/preflight.sh b/scripts/setup/officer-setup/lib/preflight.sh new file mode 100644 index 00000000..4f359ac6 --- /dev/null +++ b/scripts/setup/officer-setup/lib/preflight.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# ============================================================================= +# officer-setup — is this machine ready +# ============================================================================= +# +# Definitions only. +# +# ── Inherited, not re-asked ── +# +# machine-setup saves the account, the Officer path and the machine role beside +# itself. This reads the same file, so a normal run — machine-setup, then this — +# asks nothing at all. It only prompts on a machine where machine-setup never +# ran, which is a supported case rather than an error: somebody may have +# provisioned the box their own way. + +[[ -n "${OFFICER_SETUP_PREFLIGHT_LOADED:-}" ]] && return 0 +OFFICER_SETUP_PREFLIGHT_LOADED=1 + +# Where machine-setup keeps what it was told. Beside this script, one directory +# across. +MACHINE_ANSWERS="${MACHINE_ANSWERS:-${SCRIPT_DIR}/machine-setup/.setup-answers}" + +# Read as assignments rather than sourced: the file is read by a root run and +# sourcing it would make it executable content. +load_machine_answers() { + [[ -r "$MACHINE_ANSWERS" ]] || return 1 + local key value + while IFS='=' read -r key value; do + [[ "$key" =~ ^[A-Z_]+$ ]] || continue + [[ -n "$value" ]] || continue + case "$key" in + MACHINE_ROLE) if [[ -z "$MACHINE_ROLE" ]]; then MACHINE_ROLE="$value"; fi ;; + SETUP_USERNAME) if [[ -z "$USERNAME" ]]; then USERNAME="$value"; fi ;; + OFFICER_ROOT) if [[ -z "$OFFICER_ROOT" ]]; then OFFICER_ROOT="$value"; fi ;; + esac + done <"$MACHINE_ANSWERS" + return 0 +} + +# What Officer needs to already be here, and what installs it. +# +# Checked together and reported together: finding out about a missing bun three +# sections in, after the repository has been cloned and a database started, is a +# worse way to learn it than being told at the start. +REQUIRED_TOOLS=(git node bun pm2) +OPTIONAL_TOOLS=(docker) + +missing_tools() { + local t + for t in "${REQUIRED_TOOLS[@]}"; do command -v "$t" &>/dev/null || echo "$t"; done +} + +missing_optional_tools() { + local t + for t in "${OPTIONAL_TOOLS[@]}"; do command -v "$t" &>/dev/null || echo "$t"; done +} + +tool_why() { + case "$1" in + git) echo "to clone and update the platform" ;; + node) echo "pm2 runs on it, and the terminal sidecar builds node-pty against it" ;; + bun) echo "the platform itself and nineteen of the twenty processes" ;; + pm2) echo "supervises every process; the ecosystem files are written for it" ;; + docker) echo "Postgres, and anything the app store provisions" ;; + *) echo "" ;; + esac +} + +# The account has to exist before anything is written to its home. +owner_exists() { id "$USERNAME" &>/dev/null; } + +resolve_user_home() { + USER_HOME="$(getent passwd "$USERNAME" 2>/dev/null | cut -d: -f6)" + [[ -n "$USER_HOME" ]] || USER_HOME="/home/${USERNAME}" +}