From 4d14e11f6cf7517eff1bc8207dd309ca09786aac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 14 Aug 2026 06:46:48 +0000 Subject: [PATCH] --unattended: every question that has a default answers itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:-}` — 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 --- scripts/install.sh | 14 ++++ scripts/setup/machine-setup/lib/base.sh | 31 +++++++++ scripts/setup/machine-setup/machine-setup.sh | 67 ++++++++++---------- scripts/setup/officer-setup.sh | 9 ++- scripts/setup/officer-setup/lib/base.sh | 8 +++ 5 files changed, 94 insertions(+), 35 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index e4553710..74adbbcb 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -66,6 +66,11 @@ while [[ $# -gt 0 ]]; do 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 ]" say "" @@ -73,9 +78,16 @@ while [[ $# -gt 0 ]]; do say " --machine-only stop after the machine is provisioned" say " --officer-only the platform only, on a machine you already trust" say " --repo 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" ;; @@ -131,6 +143,8 @@ if [[ "$KERNEL" != "Darwin" && "$EUID" -ne 0 ]]; then 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 diff --git a/scripts/setup/machine-setup/lib/base.sh b/scripts/setup/machine-setup/lib/base.sh index 57caa162..cf5b804a 100644 --- a/scripts/setup/machine-setup/lib/base.sh +++ b/scripts/setup/machine-setup/lib/base.sh @@ -285,6 +285,37 @@ page() { # deliberate keystroke would train people to hold the y key down. # # 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:-}`, 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() { local message="${1:-Proceed?}" # Second argument flips the default. Most questions here are "do the thing you diff --git a/scripts/setup/machine-setup/machine-setup.sh b/scripts/setup/machine-setup/machine-setup.sh index 9913204f..b91b39ab 100755 --- a/scripts/setup/machine-setup/machine-setup.sh +++ b/scripts/setup/machine-setup/machine-setup.sh @@ -18,6 +18,12 @@ ANSWERS_FILE="$SCRIPT_DIR/.setup-answers" # still runs, because every section needs what it establishes — the system, the # role, the account and its home. 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 case "$1" in --only) @@ -32,12 +38,25 @@ while [[ $# -gt 0 ]]; do RE_ASK=1 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) grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}" exit 0 ;; -h | --help) - echo "usage: machine-setup.sh [--only ] [--reask] [--list]" + echo "usage: machine-setup.sh [--only ] [--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 ;; *) echo "unknown option: $1" >&2 && exit 2 ;; @@ -155,7 +174,9 @@ elif [[ "$EUID" -ne 0 ]]; then SETUP_USERNAME="${SETUP_USERNAME:-}" \ MACHINE_ROLE="${MACHINE_ROLE:-}" \ 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 # On macOS the account running the script IS the account, and there is nothing to @@ -761,10 +782,9 @@ if ! skip; then echo "" 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 - echo "" - fail "No answer. Set TIMEZONE= to answer this ahead of time." - fi + # Unattended keeps the current zone, which is what Enter does here. TIMEZONE= + # in the environment answers it ahead of time and skips this block entirely. + menu_answer TZ_CHOICE " Pick a number, or type a zone name — Enter keeps ${CURRENT_TZ:-the current one}: " if [[ -z "$TZ_CHOICE" ]]; then TIMEZONE="$CURRENT_TZ" @@ -918,10 +938,7 @@ elif ! skip; then BALLAST_FILE="" while [[ -z "$BALLAST_FILE" ]]; do - if ! read -rp " Which one? (1/2/3) [1]: " BALLAST_WHERE; then - echo "" - fail "No answer." - fi + menu_answer BALLAST_WHERE " Which one? (1/2/3) [1]: " case "${BALLAST_WHERE:-1}" in 1) BALLAST_FILE="${USER_HOME}/${BALLAST_NAME}" ;; 2) BALLAST_FILE="${OFFICER_ROOT}/${BALLAST_NAME}" ;; @@ -961,10 +978,7 @@ elif ! skip; then BALLAST_PCT="" while [[ -z "$BALLAST_PCT" ]]; do - if ! read -rp " Which one? (1/2/3) [2]: " BALLAST_SIZE_CHOICE; then - echo "" - fail "No answer." - fi + menu_answer BALLAST_SIZE_CHOICE " Which one? (1/2/3) [2]: " case "${BALLAST_SIZE_CHOICE:-2}" in 1) BALLAST_PCT=5 ;; 2) BALLAST_PCT=10 ;; @@ -1339,10 +1353,7 @@ if ! skip; then DNS_FALLBACK="" DNS_CHOSEN="" while [[ -z "$DNS_CHOSEN" ]]; do - if ! read -rp " Which one? (1-5) [1]: " DNS_CHOICE; then - echo "" - fail "No answer." - fi + menu_answer DNS_CHOICE " Which one? (1-5) [1]: " case "${DNS_CHOICE:-1}" in 1) DNS_CHOSEN="keep" ;; 2) @@ -1449,10 +1460,7 @@ elif ! skip; then NET_CHOICE="" while [[ -z "$NET_CHOICE" ]]; do - if ! read -rp " Which one? (1/2/3) [1]: " NET_ANSWER; then - echo "" - fail "No answer." - fi + menu_answer NET_ANSWER " Which one? (1/2/3) [1]: " case "${NET_ANSWER:-1}" in 1 | 2 | 3) NET_CHOICE="${NET_ANSWER:-1}" ;; *) warn "Pick 1, 2 or 3." ;; @@ -1817,10 +1825,7 @@ if ! skip; then DOCKER_ACCESS="" while [[ -z "$DOCKER_ACCESS" ]]; do - if ! read -rp " Which one? (1/2/3) [1]: " DOCKER_CHOICE; then - echo "" - fail "No answer." - fi + menu_answer DOCKER_CHOICE " Which one? (1/2/3) [1]: " case "${DOCKER_CHOICE:-1}" in 1 | 2 | 3) DOCKER_ACCESS="${DOCKER_CHOICE:-1}" ;; *) warn "Pick 1, 2 or 3." ;; @@ -1928,10 +1933,7 @@ if ! skip; then NVIM_REPO="" NVIM_PICK="" while [[ -z "$NVIM_PICK" ]]; do - if ! read -rp " Which one? (1/2/3) [1]: " NVIM_CHOICE; then - echo "" - fail "No answer." - fi + menu_answer NVIM_CHOICE " Which one? (1/2/3) [1]: " case "${NVIM_CHOICE:-1}" in 1) NVIM_REPO="https://github.com/LazyVim/starter" @@ -2337,10 +2339,7 @@ EOF EDITOR_PICK="" while [[ -z "$EDITOR_PICK" ]]; do - if ! read -rp " Which one? (1-${#EDITORS[@]}) [1]: " EDITOR_CHOICE; then - echo "" - fail "No answer." - fi + menu_answer EDITOR_CHOICE " Which one? (1-${#EDITORS[@]}) [1]: " EDITOR_CHOICE="${EDITOR_CHOICE:-1}" if [[ "$EDITOR_CHOICE" =~ ^[0-9]+$ ]] && ((EDITOR_CHOICE >= 1 && EDITOR_CHOICE <= ${#EDITORS[@]})); then EDITOR_PICK="${EDITORS[$((EDITOR_CHOICE - 1))]}" diff --git a/scripts/setup/officer-setup.sh b/scripts/setup/officer-setup.sh index 8ba22663..3402d782 100755 --- a/scripts/setup/officer-setup.sh +++ b/scripts/setup/officer-setup.sh @@ -45,14 +45,19 @@ while [[ $# -gt 0 ]]; do OFFICER_REPO="${1#*=}" shift ;; + --unattended | -y) + export UNATTENDED=1 ASSUME_YES=1 + shift + ;; -l | --list) grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}" exit 0 ;; -h | --help) - echo "usage: officer-setup.sh [--only ] [--list] [--repo ]" + echo "usage: officer-setup.sh [--only ] [--list] [--repo ] [--unattended]" echo "" echo " --only run one step; --list names them" + echo " --unattended take the default for every question that has one (-y)" echo " --repo clone from here instead of the default, which is a" echo " private Gitea over SSH and only authenticates on a" echo " machine whose key it already knows. Same as exporting" @@ -118,6 +123,8 @@ elif [[ "$EUID" -ne 0 ]]; then SETUP_USERNAME="${SETUP_USERNAME:-}" \ MACHINE_ROLE="${MACHINE_ROLE:-}" \ REPORT_FILE="${REPORT_FILE:-}" \ + UNATTENDED="${UNATTENDED:-}" \ + ASSUME_YES="${ASSUME_YES:-}" \ OFFICER_REPO="${OFFICER_REPO:-}" \ bash "$SCRIPT_DIR/officer-setup.sh" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"} fi diff --git a/scripts/setup/officer-setup/lib/base.sh b/scripts/setup/officer-setup/lib/base.sh index 4cbcff12..6f519e1c 100644 --- a/scripts/setup/officer-setup/lib/base.sh +++ b/scripts/setup/officer-setup/lib/base.sh @@ -113,6 +113,14 @@ confirm() { ask_required() { 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 if ! read -rp " ${message}${default:+ [$default]}: " answer; then echo ""