51 yes/no prompts and ~20 free-text ones, of which about six actually need a human.
The line drawn is "a question with a default answers itself; a question with no
possible default still asks", so it stays attended without being a conversation.
Half of it already existed: ASSUME_YES=1 was implemented and honoured by confirm()
in both scripts, returning each question's OWN default — so a "do the thing you
asked for" question goes yes and a genuine extra goes no. --unattended sets it.
The new part is menu_answer(), for the eight numbered menus. It sets the variable
EMPTY rather than passing a default in, because every menu already consumes its
choice as `${CHOICE:-<n>}` — the default lives next to the options it selects
between, which is the right place, and a second copy in the helper could drift from
the one the prompt advertises. Verified all eight consume that way before touching
them. `read <<<''` rather than eval or `declare -g`, which is bash 4.2+ and rules
out the bash 3.2 macOS still ships.
officer-setup's ask_required takes its default too, except where there is none — the
owning account on a machine machine-setup never ran on, where a guess would install
as the wrong user.
STILL ASKS, deliberately: the username; the Tailscale control plane, login server
and auth key; the git identity; and an SSH public key when the account has none.
That last one is a trap I nearly walked into — on a fresh VPS KEY_COUNT==0 forces
ADD_KEY=true with no confirm, and the menu's default is "[1] paste a public key",
which then prompts with no default at all. Auto-answering that menu would hang or
fail, so it is excluded by name. adduser also still asks for a password; that is
the tool, not us.
Two pre-existing bugs fixed on the way: machine-setup's sudo re-exec passed "$@"
after `shift` had emptied it, so --only and --reask stopped existing the moment it
escalated — same bug as officer-setup had. And UNATTENDED/ASSUME_YES are named in
all three sudo lists, because env_reset would otherwise drop the flag at
escalation, which is now the fourth variable lost that way.
Verified: bash -n on five files, --help on all three, and menu_answer + confirm
under the flag showing a menu resolving to its default and a no-default confirm
correctly answering no.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
186 lines
7.5 KiB
Bash
Executable File
186 lines
7.5 KiB
Bash
Executable File
#!/bin/bash
|
|
# =============================================================================
|
|
# Officer — install
|
|
# =============================================================================
|
|
#
|
|
# One command, blank machine to running platform. It runs the two halves in
|
|
# order and does nothing else itself:
|
|
#
|
|
# setup/machine-setup/machine-setup.sh a usable machine — packages, tailnet,
|
|
# runtimes, docker, shell
|
|
# setup/officer-setup.sh the platform on top of it — repo,
|
|
# dependencies, postgres, .env, secret
|
|
# store, schema, build, pm2
|
|
#
|
|
# They stay two scripts because they answer two different questions and are worth
|
|
# running separately: a machine you already trust needs only the second, and a
|
|
# machine you are rebuilding needs only the first. This is the wrapper for the
|
|
# case where you want both, which is most first runs.
|
|
#
|
|
# Both are re-runnable. Each remembers the steps it finished and skips them, so
|
|
# stopping halfway and coming back costs nothing.
|
|
#
|
|
# Run it as yourself — it asks for administrator rights when it needs them.
|
|
#
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
MACHINE="$SCRIPT_DIR/setup/machine-setup/machine-setup.sh"
|
|
OFFICER="$SCRIPT_DIR/setup/officer-setup.sh"
|
|
|
|
BOLD='\033[1m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
NC='\033[0m'
|
|
|
|
say() { echo -e "$*"; }
|
|
die() {
|
|
echo -e "${YELLOW}error:${NC} $*" >&2
|
|
exit 1
|
|
}
|
|
|
|
[[ -r "$MACHINE" ]] || die "missing $MACHINE"
|
|
[[ -r "$OFFICER" ]] || die "missing $OFFICER"
|
|
|
|
# Which halves to run. Both by default.
|
|
RUN_MACHINE=true
|
|
RUN_OFFICER=true
|
|
|
|
# Kept before the loop below eats them: this script re-executes itself through sudo
|
|
# further down, and `shift` would otherwise leave it re-running with no arguments —
|
|
# silently dropping --officer-only and turning a platform-only run into a full one.
|
|
#
|
|
# The `${x[@]+"${x[@]}"}` form is for `set -u`: expanding an empty array unquoted-safe
|
|
# is an error on bash before 4.4, and this runs on whatever the machine came with.
|
|
ORIGINAL_ARGS=(${@+"$@"})
|
|
|
|
# A `while`/`shift` loop rather than `for arg in "$@"`, because --repo takes a value
|
|
# and a for-loop cannot consume the argument after it.
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--machine-only) RUN_OFFICER=false ;;
|
|
--officer-only) RUN_MACHINE=false ;;
|
|
--repo)
|
|
[[ -n "${2:-}" ]] || die "--repo needs a URL"
|
|
OFFICER_REPO="$2"
|
|
shift
|
|
;;
|
|
--repo=*) OFFICER_REPO="${1#--repo=}" ;;
|
|
# Every question that HAS a default answers itself. The ones with no possible
|
|
# default still ask — see the note above the run below.
|
|
--unattended | -y)
|
|
export UNATTENDED=1 ASSUME_YES=1
|
|
;;
|
|
-h | --help)
|
|
say "usage: install.sh [--machine-only | --officer-only] [--repo <url>]"
|
|
say ""
|
|
say " no flags both halves, machine first"
|
|
say " --machine-only stop after the machine is provisioned"
|
|
say " --officer-only the platform only, on a machine you already trust"
|
|
say " --repo <url> clone the platform from here instead of the default"
|
|
say " --unattended take the default for every question that has one (-y)"
|
|
say ""
|
|
say " The default is a private Gitea over SSH, which only authenticates on a"
|
|
say " machine whose key it already knows. Pass an https URL on a fresh box."
|
|
say ""
|
|
say " --unattended still asks the questions that have no possible default:"
|
|
say " the username, the Tailscale control plane / login server / auth key,"
|
|
say " an SSH public key when the account has none, and the git identity."
|
|
say " Answer those ahead of time with SETUP_USERNAME, TS_LOGIN_SERVER,"
|
|
say " TS_AUTHKEY and TIMEZONE to reduce it further."
|
|
exit 0
|
|
;;
|
|
*) die "unknown option: $1" ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
# Exported so `officer-setup.sh` reads it from the environment and this script does
|
|
# not have to forward arguments it does not own. `lib/repo.sh` takes it as
|
|
# `${OFFICER_REPO:-<default>}`, so unset here still means the default there.
|
|
[[ -n "${OFFICER_REPO:-}" ]] && export OFFICER_REPO
|
|
|
|
KERNEL="$(uname -s)"
|
|
case "$KERNEL" in
|
|
Darwin)
|
|
[[ "$EUID" -eq 0 ]] && die "do not run this with sudo on macOS — Homebrew refuses to run as root. Run it as yourself."
|
|
;;
|
|
Linux) ;;
|
|
*) die "unsupported system: $KERNEL. Officer installs on Linux and macOS." ;;
|
|
esac
|
|
|
|
SELF="$SCRIPT_DIR/install.sh"
|
|
|
|
# One report for the whole run, not one per half. Both scripts append to this
|
|
# file, so the person reviewing it sees a single account of what happened rather
|
|
# than two they have to stitch together and hope are complete.
|
|
#
|
|
# Exported before either half starts, and timestamped once here — if each script
|
|
# made its own name they would differ by however long the first one took.
|
|
export REPORT_FILE="${REPORT_FILE:-${HOME}/officer-install-report-$(date '+%Y%m%d-%H%M%S').md}"
|
|
|
|
# ── Privileges: asked for, not demanded ──
|
|
#
|
|
# Run this as YOURSELF. On Linux it needs root for apt, systemd units, useradd,
|
|
# netplan, ufw and for creating directories owned by the service account — so it
|
|
# asks, once, through sudo, and re-executes itself. Typing `sudo` yourself works
|
|
# too and changes nothing, but it should not be the price of starting.
|
|
#
|
|
# Variables are passed to sudo explicitly rather than with -E. `env_reset` is the
|
|
# sudoers default and strips the environment, which is how DATA_PATH was lost
|
|
# once already; naming them on the command line survives it.
|
|
#
|
|
# macOS never escalates. Homebrew refuses to run as root, and nothing in the
|
|
# macOS path needs it — the account running this IS the owner, so there is
|
|
# nothing to chown and nothing to drop privileges to.
|
|
if [[ "$KERNEL" != "Darwin" && "$EUID" -ne 0 ]]; then
|
|
command -v sudo >/dev/null 2>&1 || die "this needs root and sudo is not installed — run it as root"
|
|
say ""
|
|
say " This needs administrator rights. You will be asked for your password."
|
|
say ""
|
|
exec sudo \
|
|
OFFICER_ROOT="${OFFICER_ROOT:-}" \
|
|
SETUP_USERNAME="${SETUP_USERNAME:-}" \
|
|
MACHINE_ROLE="${MACHINE_ROLE:-}" \
|
|
REPORT_FILE="${REPORT_FILE:-}" \
|
|
UNATTENDED="${UNATTENDED:-}" \
|
|
ASSUME_YES="${ASSUME_YES:-}" \
|
|
OFFICER_REPO="${OFFICER_REPO:-}" \
|
|
bash "$SELF" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}
|
|
fi
|
|
|
|
|
|
say ""
|
|
say "${BOLD}Officer install${NC}"
|
|
say " system: $KERNEL"
|
|
$RUN_MACHINE && say " 1/2 machine setup"
|
|
$RUN_OFFICER && say " $($RUN_MACHINE && echo 2/2 || echo 1/1) officer setup"
|
|
say ""
|
|
say " Either half can be run on its own later:"
|
|
say " scripts/setup/machine-setup/machine-setup.sh"
|
|
say " scripts/setup/officer-setup.sh"
|
|
say ""
|
|
|
|
# Not `set -e`'s job: a half that exits non-zero should say which half, and stop
|
|
# before the next one starts on a machine that is not ready for it.
|
|
# ── Who says "you are still root" ──
|
|
#
|
|
# Both halves end as root and both need to say so, but only the LAST one to run
|
|
# should — otherwise a full install says it twice, once in the middle where it is
|
|
# wrong, because officer-setup is about to run and still needs the privilege.
|
|
#
|
|
# So the rule is "say it if nothing follows you", and this is the only place that
|
|
# knows whether anything does.
|
|
if $RUN_MACHINE; then
|
|
$RUN_OFFICER && export OFFICER_SETUP_FOLLOWS=1
|
|
bash "$MACHINE" || die "machine setup did not finish — fix what it reported, then run this again"
|
|
unset OFFICER_SETUP_FOLLOWS
|
|
fi
|
|
|
|
if $RUN_OFFICER; then
|
|
bash "$OFFICER" || die "officer setup did not finish — fix what it reported, then run this again"
|
|
fi
|
|
|
|
say ""
|
|
say "${GREEN}Done.${NC}"
|