OFFICER_ROOT, defaulting to <user home>/officerdev. One directory holding the
four things Officer is made of, per docs/sidecar-app-store.md — the app, its
data, the item store, and any containers the app store provisions — so the whole
installation can be moved, backed up or deleted as a unit.
Asked at the start with the other questions rather than at the point it is first
needed. It decides the shape of several later steps: where the repository is
cloned, where DATA_PATH sits beside it, and which filesystem the app store's
bind mounts come out of. Asking once up front also means the run can be described
before it starts rather than discovered as it goes.
A leading ~ is expanded explicitly. It arrives as a literal from a read or an
environment variable — nothing expands it there — and would otherwise create a
directory actually named "~" in whatever the working directory happened to be.
Relative paths are refused with the value named, and a trailing slash is trimmed
so the path composes cleanly with what gets appended to it.
Nothing creates the directory yet; that belongs to officer-setup. This records
the answer and reports it, including whether it already exists.
Verified: Enter takes the default, ~ expands, trailing slash trims, OFFICER_ROOT
in the environment skips the prompt, and a relative path fails with the value
named.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three sections where there were two, and none of them touches the machine until
you say so:
2. System update upgrades what is already installed
3. Core utils what the distribution provides
4. Command-line tools lazydocker, lazygit, starship, fastfetch
The split matters because these are different kinds of change and deserve
separate answers. System update is the only step in the whole script that moves
versions of software already on the machine; core utils only ever adds what is
absent; and the four tools are upstream binaries the distribution does not ship
at all. Previously the update and the core packages were one step and the tools
were tacked onto the end of it, so agreeing to "essentials" meant agreeing to all
three at once.
Every section now prints what it will install and what it is leaving alone, then
asks. Enter means yes — unlike the machine-role question, which has no default,
because these are "do the thing you already asked for" and making twenty of them
require a deliberate keystroke would train people to hold the y key down.
ASSUME_YES=1 answers all of them for an unattended run, and EOF fails with that
named rather than spinning.
Refusing is recorded rather than glossed: LAST_SKIPPED feeds the summary, so a
declined section reads "Core utils: SKIPPED by request — cowsay neofetch" instead
of quietly reporting nothing installed.
Nothing to install means no prompt at all — there is nothing to agree to.
announce_plan takes the array NAMES rather than their contents, because once a
list has been through word splitting an empty one cannot be told from a missing
one.
Verified all three paths: accept, refuse, and nothing-to-do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No default, and it is the only question in the script like that. A guessed
default is right often enough to be trusted and wrong in exactly the case that
costs the most — pinning a static IP on a rented box, or leaving the 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.
Empty and unrecognised answers re-ask rather than aborting; a failed read means
EOF rather than a wrong answer, and fails with the environment variable named,
because otherwise the loop spins forever the first time this runs unattended.
Drops guess_machine_role, which existed only to supply that default. default_iface
stays — the static IP section needs it when it is ported.
MACHINE_ROLE in the environment still answers it ahead of time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MACHINE_ROLE is homelab, vps or dev, and several steps have a different right
answer per role with no way to work it out themselves: whether the address is
yours to pin (static IP), whether the box faces the open internet (fail2ban, SSH
hardening, UFW), and whether it is allowed to sleep (suspend, logind).
Asked in pre-flight rather than at each point of use. The steps that care run
from swap through to the firewall, and being asked "is this a VPS?" for the
fourth time halfway down a provisioning run is how people start answering
without reading.
The default offered is guessed from whether this machine's own address is in
RFC1918 space, which beats asking whether it is virtualised — a homelab is very
often a VM on Proxmox and would be misread as rented — and is the same fact most
of the branches turn on anyway. A graphical session means dev; so does macOS.
It is only ever a suggestion the user confirms.
MACHINE_ROLE in the environment answers it ahead of time for an unattended run,
which is why it is declared with :- rather than a plain assignment. The first
version wiped the caller's value before ask_machine_role ever saw it; caught by
running with MACHINE_ROLE=vps and watching the menu appear anyway.
Verified: guesses vps on this host (public IPv4, no DISPLAY, no display
manager), env override takes, and a bad value fails with the three valid ones
named. Nothing consumes the role yet — the steps get wired as each is worked
through.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Structure before the work rather than during it: scripts/machine-setup.sh becomes
scripts/setup/machine-setup/, with the script itself as the entry point and
lib/base.sh holding what every part of it needs.
machine-setup.sh pre-flight and the numbered sections, for now
lib/base.sh shared state, output, the step/resume machine, prompts,
and OS detection
The rule for lib/ is definitions only — nothing there installs, writes or
restarts anything, so sourcing it is safe from anywhere. That is why the ERR
trap stayed in the entry point: a trap is a side effect on whoever sources it.
Behaviour is unchanged. Verified by diffing the moved region against the previous
commit: identical set of functions, and the only differences are added comments,
section banners, fail() reformatted onto three lines, and one new line — a guard
against double-sourcing, which matters because steps will source this directly
once they move out, and a second pass would reset SUMMARY.
The sections are still one 1111-line block below pre-flight; they move into
steps/ as each is worked through. The script also still reads ssh-keys.zip,
.tmux.conf and ufw-docker-rules.conf from SCRIPT_DIR, which is now this
directory, so those three steps warn and skip until the files follow it here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>