--unattended: every question that has a default answers itself

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>
This commit is contained in:
2026-08-14 06:46:48 +00:00
co-authored by Claude Opus 5
parent 4c33ef7206
commit 4d14e11f6c
5 changed files with 94 additions and 35 deletions
+14
View File
@@ -66,6 +66,11 @@ while [[ $# -gt 0 ]]; do
shift shift
;; ;;
--repo=*) OFFICER_REPO="${1#--repo=}" ;; --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) -h | --help)
say "usage: install.sh [--machine-only | --officer-only] [--repo <url>]" say "usage: install.sh [--machine-only | --officer-only] [--repo <url>]"
say "" say ""
@@ -73,9 +78,16 @@ while [[ $# -gt 0 ]]; do
say " --machine-only stop after the machine is provisioned" say " --machine-only stop after the machine is provisioned"
say " --officer-only the platform only, on a machine you already trust" 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 " --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 ""
say " The default is a private Gitea over SSH, which only authenticates on a" 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 " 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 exit 0
;; ;;
*) die "unknown option: $1" ;; *) die "unknown option: $1" ;;
@@ -131,6 +143,8 @@ if [[ "$KERNEL" != "Darwin" && "$EUID" -ne 0 ]]; then
SETUP_USERNAME="${SETUP_USERNAME:-}" \ SETUP_USERNAME="${SETUP_USERNAME:-}" \
MACHINE_ROLE="${MACHINE_ROLE:-}" \ MACHINE_ROLE="${MACHINE_ROLE:-}" \
REPORT_FILE="${REPORT_FILE:-}" \ REPORT_FILE="${REPORT_FILE:-}" \
UNATTENDED="${UNATTENDED:-}" \
ASSUME_YES="${ASSUME_YES:-}" \
OFFICER_REPO="${OFFICER_REPO:-}" \ OFFICER_REPO="${OFFICER_REPO:-}" \
bash "$SELF" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"} bash "$SELF" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}
fi fi
+31
View File
@@ -285,6 +285,37 @@ page() {
# deliberate keystroke would train people to hold the y key down. # deliberate keystroke would train people to hold the y key down.
# #
# ASSUME_YES=1 answers all of them, for an unattended run. # ASSUME_YES=1 answers all of them, for an unattended run.
# A numbered menu's answer, or its own default when running unattended.
#
# menu_answer DNS_CHOICE " Which one? (1-5) [1]: "
#
# ── Why empty, rather than a default passed in ──
#
# Every menu in this script reads its choice and then consumes it as
# `${CHOICE:-<n>}`, so the default already lives at the point of use — which is the
# right place, next to the options it selects between. Setting the variable EMPTY is
# therefore exactly what pressing Enter does, and it cannot drift from the default
# the prompt advertises the way a second copy passed in here would.
#
# `read <<<''` rather than `eval` or `declare -g`: no eval, and `declare -g` is bash
# 4.2+, which rules out the bash 3.2 that macOS still ships.
#
# The prompt is still printed, with the reason, because a transcript that silently
# skips a question reads as a question that was never asked.
menu_answer() {
local var="$1" prompt="$2"
if [[ "${UNATTENDED:-}" == "1" ]]; then
printf '%s%s\n' "$prompt" "— unattended, taking the default"
read -r "$var" <<<''
return 0
fi
read -rp "$prompt" "$var" || {
echo ""
fail "No answer."
}
}
confirm() { confirm() {
local message="${1:-Proceed?}" local message="${1:-Proceed?}"
# Second argument flips the default. Most questions here are "do the thing you # Second argument flips the default. Most questions here are "do the thing you
+33 -34
View File
@@ -18,6 +18,12 @@ ANSWERS_FILE="$SCRIPT_DIR/.setup-answers"
# still runs, because every section needs what it establishes — the system, the # still runs, because every section needs what it establishes — the system, the
# role, the account and its home. # role, the account and its home.
ONLY_STEP="" ONLY_STEP=""
# Kept before the loop consumes them: this script re-executes itself through sudo
# below and was passing `"$@"`, which `shift` had already emptied — so `--only` and
# `--reask` silently stopped existing the moment it escalated.
ORIGINAL_ARGS=(${@+"$@"})
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--only) --only)
@@ -32,12 +38,25 @@ while [[ $# -gt 0 ]]; do
RE_ASK=1 RE_ASK=1
shift shift
;; ;;
# Every question that HAS a default answers itself; the ones with none still ask.
# ASSUME_YES drives confirm(), UNATTENDED drives the numbered menus and the
# free-text prompts that carry a default.
--unattended | -y)
export UNATTENDED=1 ASSUME_YES=1
shift
;;
-l | --list) -l | --list)
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}" grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
exit 0 exit 0
;; ;;
-h | --help) -h | --help)
echo "usage: machine-setup.sh [--only <step>] [--reask] [--list]" echo "usage: machine-setup.sh [--only <step>] [--reask] [--list] [--unattended]"
echo ""
echo " --unattended take the default for every question that has one (-y)."
echo " Still asks the ones with no possible default: the"
echo " username, the Tailscale control plane / login server /"
echo " auth key, an SSH public key when the account has none,"
echo " and the git identity."
exit 0 exit 0
;; ;;
*) echo "unknown option: $1" >&2 && exit 2 ;; *) echo "unknown option: $1" >&2 && exit 2 ;;
@@ -155,7 +174,9 @@ elif [[ "$EUID" -ne 0 ]]; then
SETUP_USERNAME="${SETUP_USERNAME:-}" \ SETUP_USERNAME="${SETUP_USERNAME:-}" \
MACHINE_ROLE="${MACHINE_ROLE:-}" \ MACHINE_ROLE="${MACHINE_ROLE:-}" \
REPORT_FILE="${REPORT_FILE:-}" \ REPORT_FILE="${REPORT_FILE:-}" \
bash "$SCRIPT_DIR/machine-setup.sh" "$@" UNATTENDED="${UNATTENDED:-}" \
ASSUME_YES="${ASSUME_YES:-}" \
bash "$SCRIPT_DIR/machine-setup.sh" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}
fi fi
# On macOS the account running the script IS the account, and there is nothing to # On macOS the account running the script IS the account, and there is nothing to
@@ -761,10 +782,9 @@ if ! skip; then
echo "" echo ""
while [[ -z "${TIMEZONE:-}" ]]; do while [[ -z "${TIMEZONE:-}" ]]; do
if ! read -rp " Pick a number, or type a zone name — Enter keeps ${CURRENT_TZ:-the current one}: " TZ_CHOICE; then # Unattended keeps the current zone, which is what Enter does here. TIMEZONE=<zone>
echo "" # in the environment answers it ahead of time and skips this block entirely.
fail "No answer. Set TIMEZONE=<zone> to answer this ahead of time." menu_answer TZ_CHOICE " Pick a number, or type a zone name — Enter keeps ${CURRENT_TZ:-the current one}: "
fi
if [[ -z "$TZ_CHOICE" ]]; then if [[ -z "$TZ_CHOICE" ]]; then
TIMEZONE="$CURRENT_TZ" TIMEZONE="$CURRENT_TZ"
@@ -918,10 +938,7 @@ elif ! skip; then
BALLAST_FILE="" BALLAST_FILE=""
while [[ -z "$BALLAST_FILE" ]]; do while [[ -z "$BALLAST_FILE" ]]; do
if ! read -rp " Which one? (1/2/3) [1]: " BALLAST_WHERE; then menu_answer BALLAST_WHERE " Which one? (1/2/3) [1]: "
echo ""
fail "No answer."
fi
case "${BALLAST_WHERE:-1}" in case "${BALLAST_WHERE:-1}" in
1) BALLAST_FILE="${USER_HOME}/${BALLAST_NAME}" ;; 1) BALLAST_FILE="${USER_HOME}/${BALLAST_NAME}" ;;
2) BALLAST_FILE="${OFFICER_ROOT}/${BALLAST_NAME}" ;; 2) BALLAST_FILE="${OFFICER_ROOT}/${BALLAST_NAME}" ;;
@@ -961,10 +978,7 @@ elif ! skip; then
BALLAST_PCT="" BALLAST_PCT=""
while [[ -z "$BALLAST_PCT" ]]; do while [[ -z "$BALLAST_PCT" ]]; do
if ! read -rp " Which one? (1/2/3) [2]: " BALLAST_SIZE_CHOICE; then menu_answer BALLAST_SIZE_CHOICE " Which one? (1/2/3) [2]: "
echo ""
fail "No answer."
fi
case "${BALLAST_SIZE_CHOICE:-2}" in case "${BALLAST_SIZE_CHOICE:-2}" in
1) BALLAST_PCT=5 ;; 1) BALLAST_PCT=5 ;;
2) BALLAST_PCT=10 ;; 2) BALLAST_PCT=10 ;;
@@ -1339,10 +1353,7 @@ if ! skip; then
DNS_FALLBACK="" DNS_FALLBACK=""
DNS_CHOSEN="" DNS_CHOSEN=""
while [[ -z "$DNS_CHOSEN" ]]; do while [[ -z "$DNS_CHOSEN" ]]; do
if ! read -rp " Which one? (1-5) [1]: " DNS_CHOICE; then menu_answer DNS_CHOICE " Which one? (1-5) [1]: "
echo ""
fail "No answer."
fi
case "${DNS_CHOICE:-1}" in case "${DNS_CHOICE:-1}" in
1) DNS_CHOSEN="keep" ;; 1) DNS_CHOSEN="keep" ;;
2) 2)
@@ -1449,10 +1460,7 @@ elif ! skip; then
NET_CHOICE="" NET_CHOICE=""
while [[ -z "$NET_CHOICE" ]]; do while [[ -z "$NET_CHOICE" ]]; do
if ! read -rp " Which one? (1/2/3) [1]: " NET_ANSWER; then menu_answer NET_ANSWER " Which one? (1/2/3) [1]: "
echo ""
fail "No answer."
fi
case "${NET_ANSWER:-1}" in case "${NET_ANSWER:-1}" in
1 | 2 | 3) NET_CHOICE="${NET_ANSWER:-1}" ;; 1 | 2 | 3) NET_CHOICE="${NET_ANSWER:-1}" ;;
*) warn "Pick 1, 2 or 3." ;; *) warn "Pick 1, 2 or 3." ;;
@@ -1817,10 +1825,7 @@ if ! skip; then
DOCKER_ACCESS="" DOCKER_ACCESS=""
while [[ -z "$DOCKER_ACCESS" ]]; do while [[ -z "$DOCKER_ACCESS" ]]; do
if ! read -rp " Which one? (1/2/3) [1]: " DOCKER_CHOICE; then menu_answer DOCKER_CHOICE " Which one? (1/2/3) [1]: "
echo ""
fail "No answer."
fi
case "${DOCKER_CHOICE:-1}" in case "${DOCKER_CHOICE:-1}" in
1 | 2 | 3) DOCKER_ACCESS="${DOCKER_CHOICE:-1}" ;; 1 | 2 | 3) DOCKER_ACCESS="${DOCKER_CHOICE:-1}" ;;
*) warn "Pick 1, 2 or 3." ;; *) warn "Pick 1, 2 or 3." ;;
@@ -1928,10 +1933,7 @@ if ! skip; then
NVIM_REPO="" NVIM_REPO=""
NVIM_PICK="" NVIM_PICK=""
while [[ -z "$NVIM_PICK" ]]; do while [[ -z "$NVIM_PICK" ]]; do
if ! read -rp " Which one? (1/2/3) [1]: " NVIM_CHOICE; then menu_answer NVIM_CHOICE " Which one? (1/2/3) [1]: "
echo ""
fail "No answer."
fi
case "${NVIM_CHOICE:-1}" in case "${NVIM_CHOICE:-1}" in
1) 1)
NVIM_REPO="https://github.com/LazyVim/starter" NVIM_REPO="https://github.com/LazyVim/starter"
@@ -2337,10 +2339,7 @@ EOF
EDITOR_PICK="" EDITOR_PICK=""
while [[ -z "$EDITOR_PICK" ]]; do while [[ -z "$EDITOR_PICK" ]]; do
if ! read -rp " Which one? (1-${#EDITORS[@]}) [1]: " EDITOR_CHOICE; then menu_answer EDITOR_CHOICE " Which one? (1-${#EDITORS[@]}) [1]: "
echo ""
fail "No answer."
fi
EDITOR_CHOICE="${EDITOR_CHOICE:-1}" EDITOR_CHOICE="${EDITOR_CHOICE:-1}"
if [[ "$EDITOR_CHOICE" =~ ^[0-9]+$ ]] && ((EDITOR_CHOICE >= 1 && EDITOR_CHOICE <= ${#EDITORS[@]})); then if [[ "$EDITOR_CHOICE" =~ ^[0-9]+$ ]] && ((EDITOR_CHOICE >= 1 && EDITOR_CHOICE <= ${#EDITORS[@]})); then
EDITOR_PICK="${EDITORS[$((EDITOR_CHOICE - 1))]}" EDITOR_PICK="${EDITORS[$((EDITOR_CHOICE - 1))]}"
+8 -1
View File
@@ -45,14 +45,19 @@ while [[ $# -gt 0 ]]; do
OFFICER_REPO="${1#*=}" OFFICER_REPO="${1#*=}"
shift shift
;; ;;
--unattended | -y)
export UNATTENDED=1 ASSUME_YES=1
shift
;;
-l | --list) -l | --list)
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}" grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
exit 0 exit 0
;; ;;
-h | --help) -h | --help)
echo "usage: officer-setup.sh [--only <step>] [--list] [--repo <url>]" echo "usage: officer-setup.sh [--only <step>] [--list] [--repo <url>] [--unattended]"
echo "" echo ""
echo " --only <step> run one step; --list names them" echo " --only <step> run one step; --list names them"
echo " --unattended take the default for every question that has one (-y)"
echo " --repo <url> clone from here instead of the default, which is a" echo " --repo <url> clone from here instead of the default, which is a"
echo " private Gitea over SSH and only authenticates on a" echo " private Gitea over SSH and only authenticates on a"
echo " machine whose key it already knows. Same as exporting" echo " machine whose key it already knows. Same as exporting"
@@ -118,6 +123,8 @@ elif [[ "$EUID" -ne 0 ]]; then
SETUP_USERNAME="${SETUP_USERNAME:-}" \ SETUP_USERNAME="${SETUP_USERNAME:-}" \
MACHINE_ROLE="${MACHINE_ROLE:-}" \ MACHINE_ROLE="${MACHINE_ROLE:-}" \
REPORT_FILE="${REPORT_FILE:-}" \ REPORT_FILE="${REPORT_FILE:-}" \
UNATTENDED="${UNATTENDED:-}" \
ASSUME_YES="${ASSUME_YES:-}" \
OFFICER_REPO="${OFFICER_REPO:-}" \ OFFICER_REPO="${OFFICER_REPO:-}" \
bash "$SCRIPT_DIR/officer-setup.sh" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"} bash "$SCRIPT_DIR/officer-setup.sh" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}
fi fi
+8
View File
@@ -113,6 +113,14 @@ confirm() {
ask_required() { ask_required() {
local __var="$1" message="$2" default="$3" answer="" 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 while [[ -z "$answer" ]]; do
if ! read -rp " ${message}${default:+ [$default]}: " answer; then if ! read -rp " ${message}${default:+ [$default]}: " answer; then
echo "" echo ""