Sleep and suspend is homelab only now. The two exclusions are for different
reasons and both are stated in the run rather than left implicit:
dev — a laptop should sleep; disabling it is a hot bag and a flat battery.
vps — not merely unnecessary, harmful. A virtual machine has no lid and no
power button, but the provider's Shut down control works by sending an ACPI
power button event. HandlePowerKey=ignore makes the VM ignore it, so graceful
shutdown requests silently do nothing and the instance is hard-killed instead.
systemd defaults that key to poweroff for exactly this reason.
The boot hang fix is everything except vps, where systemd-networkd genuinely
manages the network and the unit is load-bearing.
Rather than asking whether boot "feels slow" — a question people answer from
memory of the worst time it happened — the step prints what the unit actually
cost on this boot, from systemd's own accounting. On this host that is 14ms,
which ends the discussion. On a NetworkManager desktop it is two minutes, which
also ends it. On dev the wording says outright that a small number here means
there is nothing to do.
The original's live guard is kept and is what actually decides: NetworkManager
active and networkd not. Anything else, including "cannot tell", is left alone,
and the reason is printed. The warning against disabling systemd-networkd
outright is carried across into the library, where the alternative would be
attempted.
Verified all three roles on this host, which is a networkd machine: homelab and
dev both correctly refuse and explain, vps skips as not applicable, and the
timing helper reads 14ms out of systemd-analyze.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The original ran unconditionally, so a laptop that went through it stopped
suspending — a hot bag and a flat battery. It is a server concern: dev is skipped
with the reason printed, like the ballast.
Four things wrong with it beyond the role:
HandleLidSwitchDocked was never set. A laptop used as a homelab server, docked
and closed, still suspends — which is the exact machine this setting exists
for. Added.
RuntimeDirectorySize=10% was set alongside the sleep handlers. It is the size
of /run, has nothing to do with sleeping, and 10% is systemd's own default, so
the line never did anything. Dropped.
systemd-logind was restarted on every pass whether or not anything changed,
disturbing live sessions for nothing. The step now checks first and does not
reach the restart when the machine is already configured. (The platform's own
scripts/setup-old/setup.sh already had this guard; the machine script did not.)
The settings were sed'd into logind.conf in place. They are a drop-in at
/etc/systemd/logind.conf.d/99-machine-setup.conf now, so what this script set
is one file that can be read or removed on its own.
Current state is printed before anything is asked — whether the targets are
masked, and what the lid, idle and power-key handlers actually do. logind_effective
reads the main file and every drop-in and takes the last match, since a drop-in
overrides logind.conf; reading only the main file reports a configured machine as
unconfigured.
The power-button consequence is stated rather than left to be discovered: after
this, pressing power physically does nothing and a clean shutdown is
`sudo poweroff`.
WSL has no logind and cannot suspend, and says so.
Verified both roles on this host, which the old script had already configured —
correctly reports the targets masked and the handlers set, and correctly reports
itself not fully configured because HandleLidSwitchDocked is missing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Servers only now. On a machine you sit at, a filling disk announces itself — the
editor refuses to save, the browser complains — and you are there to deal with
it. The reserve is for the box nobody is watching, where the first sign is a
service that stopped working hours ago.
Skipped rather than asked, but said out loud with the reason and recorded in the
summary. A section that silently produces no output is indistinguishable from
one that failed.
The cron this section installs was already there and is unchanged: /etc/cron.d
runs the checker as root every ten minutes, and it deletes the ballast when free
space falls under the threshold.
What changed is where its message goes. Both alerts now run through one notify()
inside the generated checker rather than calling logger directly, so there is a
single place to add a second channel. Today it is still syslog only — the
message lands in the journal and nowhere else, so nobody learns about it until
they go looking, which is precisely the wrong moment. Push, mail or Officer's own
notify sidecar hook in there. It also echoes to stderr now, so running the
checker by hand shows the message instead of appearing to do nothing.
Verified: dev reports not-applicable and asks nothing, vps still asks and records
a refusal, and the regenerated checker parses and reports status.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three questions instead of one, because the two the original never asked are the
two that decide whether the thing is useful.
1. Do you want one, with the explanation first.
2. Where. Home (easiest to find again months from now), beside Officer, or a
path typed in. This is not tidiness: the checker measures its own directory,
so a ballast only protects the filesystem it sits on. Choosing where it goes
is choosing which mount is covered.
3. How much, as 5/10/20% — with the actual numbers, and with what would be LEFT
rather than only what is taken:
[1] 5% — reserves 2.9GB leaving 54.3GB free
[2] 10% — reserves 5.8GB leaving 51.4GB free
[3] 20% — reserves 11.5GB leaving 45.7GB free
A percentage on its own is unanswerable. The number that decides it is the
one on the right: the reserve has to be big enough to matter and small
enough not to be the thing that filled the disk.
The size is computed against the filesystem the chosen path lands on, after the
location is known, so the percentages are of the right disk. ballast_free_kb
walks up to a directory that exists, since nothing has created the target yet.
An existing ballast in either default location is found and left alone rather
than a second one being made beside it.
Verified end to end in a temp home: created at the chosen path, 2.9G for 5% of
57.1GB, checker installed and reporting it present.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
New first section, before anything else that changes the machine, because the
swapfile and the ballast both size themselves from free disk.
Ubuntu Server's installer on its defaults gives the root logical volume a fixed
size and leaves the rest of the drive as unallocated extents in the volume group.
On a 2TB disk that is a ~100G root with nothing to indicate a problem: lsblk
shows the whole drive, df shows 100G, and the two are never seen side by side
until the day it fills. Growing a virtual disk at a provider leaves the same
shape one layer down, and so does resizing a partition without telling the
filesystem inside it.
Three layers, any of which can be the short one, so all three are measured and
printed together:
drive: 76.3GB /dev/sda
volume: 76.1GB /dev/sda1
filesystem: 76.1GB ext4, mounted at /
Seeing them in one place is most of the value. The fix is then whichever layer is
short: lvextend for free extents, growpart for a partition that stops early
(followed by pvresize and lvextend when LVM is in the way), or resize2fs alone
when only the filesystem is behind.
Only ever grows. Nothing here shrinks, creates or deletes a partition, and ext4,
xfs and btrfs all grow while mounted — so no unmount, no reboot, and a failure
part-way leaves a smaller filesystem on a larger container, which is the state it
started in.
growpart is the authority on whether a partition can move — it exits 1 with
NOCHANGE when the partition already reaches the end — but it comes from
cloud-guest-utils, which is not on every image. Installing a package purely to
ask a question is too eager, so plain arithmetic on the device sizes decides
whether it is even worth looking, and only then is growpart fetched.
A gigabyte of slack before anything is reported: a filesystem is always slightly
smaller than its container, and reporting journal and reserved-block overhead as
reclaimable space would make this section cry wolf on every machine.
Verified on this host: plain ext4 partition filling its disk, correctly reports
nothing to reclaim, and every helper returns the right device and size.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Swap covers memory pressure. These are its neighbours:
8. Emergency disk ballast the same valve, for disk
9. earlyoom what happens when swap runs out too
10. inotify watch limit the silent one
All three follow the rule this script now works to: the role sets which way the
recommendation points, never whether the question is asked. A dev machine is
still offered the ballast, with the recommendation pointing the other way; a
server is still offered the inotify raise, because anything running `bun --watch`
or serving a file browser is a watcher too.
The ballast is section 22 of the original, moved up beside swap where it belongs
and moved out of the user's home. The original wrote the checker into
$USER_HOME/.local/bin and ran it from a root cron — a root cron executing a
script in a directory its owner can write is a privilege escalation waiting to be
noticed. Moot on a box where that user already has passwordless sudo, but wrong.
Both the checker and the file are in root-owned system paths now.
Two bugs found by running the generated checker rather than reading it:
It df'd the ballast's own directory, which does not exist before the ballast is
created — and with `set -euo pipefail` that meant cron mailing an error every
ten minutes. It now walks up to a directory that exists, and the installer
creates the directory itself rather than depending on the create step.
The inotify text claimed a default of 8192. This host is at 29461: Ubuntu
raised it, and stating a number the reader can see is wrong on their own screen
undermines the rest of the explanation. It now describes the failure instead
and prints the machine's actual value.
earlyoom is a distro package and a systemd unit, so it is checked with
`systemctl is-active` and reports honestly when it installs but fails to start.
Verified: checker --status and its no-op path both exit 0 with no directory
present, and the helpers report correctly against this host.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four things the original got wrong, all of which only show up on a machine that
is not this one:
It detected swap with `swapon --show | grep -q '/'` — a test for a swap FILE.
A machine using zram or a swap partition reports no swap at all, and the step
would add a swapfile beside working swap. Reads SwapTotal from /proc/meminfo
now, which covers every kind.
It never looked at free disk. On a VPS with 4G free and 16G of RAM it would
fallocate 8G, fail, and take the run down under `set -e`. The recommendation is
now capped by what is actually there, keeping 5G back, and refuses rather than
shrinking to something useless.
fallocate was assumed to work. It produces a file that btrfs and zfs will not
swap on, so dd is the fallback — slow, but it always works.
swappiness was written by sed'ing /etc/sysctl.conf in place, tangling it with
whatever else lives there. It is a drop-in at /etc/sysctl.d now, so what this
script set is visible as its own file.
Role-dependent, which is the first use of MACHINE_ROLE: swappiness 10 on a
server, where swapping is the emergency valve and a page fault on a request path
is latency somebody is waiting for; the kernel default of 60 on dev, where
swapping out an application nobody has touched in an hour is exactly what you
want.
WSL is left alone entirely — WSL2 runs its own managed swap inside the VM, and a
swapfile written here is wasted disk the kernel will not use.
Sizes are reported rounded rather than floored. A 4 GiB swapfile is 4194300 kB,
which floors to 3 and reads as though a gigabyte went missing. Free disk stays
floored, deliberately: it decides how much to allocate, and rounding up invents
space.
Verified on this host — 4G RAM, 4G existing swap correctly detected and left
alone — and with the disk check stubbed at 6G free (caps to 1G) and 3G free
(refuses).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The refresh lived inside "System update", which `step` skips when its name is
already in the progress file. So a resumed run — the common case, since that is
what the progress file is for — installed core utils, added the fastfetch PPA and
set up the Docker repo against whatever the index happened to say hours or days
earlier. On a box left overnight that is a stale index and a "package not found"
somewhere unrelated.
It now runs in pre-flight, unconditionally, before any step exists to skip it.
`apt-get upgrade` stays where it was and stays confirmable: refreshing the index
changes nothing on the machine, upgrading is the one thing that does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The original took whatever was typed and handed it straight to timedatectl. An
unknown zone — a typo, a guess at the spelling — fails there, and under `set -e`
that takes the whole run down four steps in. Names are now checked against
/usr/share/zoneinfo before use, and a bad one just re-asks.
It also never showed what the machine was already set to, and defaulted to option
1 (UTC) on Enter, so pressing return on a correctly-configured box silently moved
it. Now the current zone is printed, Enter keeps it, and a zone equal to the
current one reports nothing to do rather than setting it again.
timezone_current reads three sources — timedatectl, /etc/timezone, then the
/etc/localtime symlink — because they differ in availability rather than in
answer: timedatectl needs systemd, /etc/timezone is Debian's, and the symlink is
the one that is always there. timezone_set writes through timedatectl where
there is a systemd to talk to and the files directly otherwise, which is what it
would have written anyway; that is also the WSL path, where timedatectl exists
but does nothing.
Europe/Berlin added to the shortlist; TIMEZONE in the environment answers the
prompt ahead of time and is validated the same way, failing early with the bad
value named.
Verified detection (UTC here), validation of four names, and the env-var
rejection path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Was three unconditional lines that ran on every pass and reported success either
way. Now it checks, says what it found, and asks.
The original tracked one fact where there are two:
what a new login shell is told to use LANG in /etc/default/locale
whether that locale actually exists whether it has been generated
Setting the first without the second is what produces "setlocale: LC_ALL: cannot
change locale" on every ssh login and every perl invocation. They fail
differently, so the step names whichever one is actually missing rather than
reporting a flat "locale not set".
Also fixes two things the original would have hit on a minimal image:
locale-gen comes from the `locales` package, which cloud base images do not
ship and which is not in core utils. It is installed on demand rather than
assumed, instead of failing with "locale-gen: command not found".
The locale is uncommented in /etc/locale.gen rather than only passed to
locale-gen as an argument. A locale generated by argument alone disappears the
next time anything regenerates from that file.
`locale -a` prints en_US.utf8 where the configuration spells it en_US.UTF-8, so
both sides are folded before comparing — a literal match reports a working locale
as missing.
LOCALE in the environment overrides the default. pacman, dnf and brew branches
are written but unreachable while the pre-flight gate is apt-only; macOS has no
system locale to set and says so.
Verified both paths on this host: en_US.UTF-8 reports already set and generated,
pt_PT.UTF-8 correctly reports both facts missing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"the only step that changes software already on this machine" is true, and
reads like a warning about something dangerous rather than a description of
apt upgrade. The reasoning stays in the section comment, where it explains why
this is its own step; the prompt just says what it does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The section asked "Proceed?" without saying what it was proposing to change —
the one question in the script where the answer matters most, since it is the
only step that moves versions of software already on the machine.
pkg_upgradable now names them, from `apt-get upgrade -s`: the same calculation
the real run does, as opposed to `apt list --upgradable`, which also lists
packages held back that would not actually move.
Nothing to upgrade means no prompt at all, and the summary says so rather than
claiming an upgrade happened. The list is capped at 25 with a count of the rest,
because a box untouched for months lists hundreds and a wall of names is no more
informative than the number.
Verified against this host (0 upgradable, so it reports current and does not
ask) and with a stubbed 40-package list for the cap.
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>
The summary claimed credit for everything in a section's list, including the
packages it had just decided to leave alone — so a run that installed nothing
still ended with "Command-line tools: lazydocker lazygit starship fastfetch".
The announce above it said "nothing, all present" in the same breath.
pkg_install and tools_install now record LAST_INSTALLED and LAST_KEPT, and
summarise_last turns those into one honest line:
Core packages installed: btop tmux (17 already present)
Command-line tools: already present, nothing installed
Verified all three shapes — everything present, nothing present, and mixed.
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>
Your call, and the right one. Editing in place let mis-grouped code sit unnoticed
until it scrolled past in a live run — which is exactly how the four upstream
binaries buried in "System Update & Essentials" were found. Porting forces the
question of where each thing belongs before it runs, not after.
machine-setup.sh now contains only what has actually been worked through:
pre-flight, system update and core packages, command-line tools, and the summary.
1149 lines down to 172. The sections still to come are listed in a NOT PORTED YET
block, in order, and each arrives as its own commit.
The original is beside the other superseded scripts as
scripts/setup-old/setup-ubuntu.sh — verified byte-identical to the live
/root/ubuntu-setup copy — so porting reads from a file in the repo rather than
from root's home.
Two claims trimmed from the ported summary, because they were true of the old
script and not of this one yet: it reported the shell as "zsh (Oh My Zsh +
Starship)" unconditionally, and told you to reconnect as a user it had not
created. Replaced with what pre-flight actually knows — system, role, user.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lazydocker, lazygit, starship and fastfetch were buried inside "System Update &
Essentials", after the package install and with no announcement — so a run
appeared to be installing system packages and then started pulling tarballs and
printing a five-shell starship tutorial. They are a different thing: upstream
binaries on their own release cadence, not anything the distribution ships. Now
their own step, announced in the same shape as the package section.
Each is checked before it is fetched. The original re-ran every installer on
every run, which is why a machine that already had starship got it reinstalled
along with its "add this to your ~/.zshrc" instructions — advice this script
does not want followed, since it writes the shell config itself. Its output is
now dropped; errors still surface.
Two real bugs fixed on the way:
lazygit's asset name was hardcoded to x86_64, so on arm64 the download 404s
and tar fails partway through the run. It now maps ARCH, and spells the
architectures the way lazygit does rather than the way we do.
The version was extracted with `tr -d 'v'`, which deletes every v in the
string rather than the leading one. `${version#v}` instead.
fastfetch stays a package but stops assuming the PPA is needed: Ubuntu picked
it up in 24.10, so the repository is now checked first and the PPA added only
where the archive has nothing. Verified on this host — noble genuinely has no
candidate, so the PPA is still the only source here.
Verified both branches of tools_install by stubbing the presence check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A WIP boundary after section 2 so the finished part can be run start to finish
on its own, without the untouched sections below acting on the machine. It moves
down as each section is worked through and goes away when the walk ends.
Also ignores .setup-progress, which the script writes beside itself and is
per-machine. The exit message names it, because with it in place a second run
skips section 2 and the rewritten part cannot be re-felt from scratch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lib/packages.sh, and section 2 wired to it.
The rule it exists to enforce: `apt-get install <present-package>` is not a
no-op, it upgrades the package if the repository has a newer one. On a machine
somebody already uses that silently moves a version they chose, and a setup
script is the last thing that should do that behind their back. pkg_install
queries the package database first and names only the genuinely absent packages
on the command line — a package already installed is never passed to apt at all.
It also says so out loud, every time, because a provisioning run should not be
opaque about what it is doing to the machine:
:: Core packages — installs what is missing, keeps what you already have
already here: curl ca-certificates gnupg git jq …
to install: btop tmux
Section 2's flat list of 19 is now pkgs_core(), split per package manager rather
than through a canonical-name table with overrides. The names genuinely disagree
(build-essential/base-devel, fd-find/fd) and three of them are not packages
elsewhere at all — apt-transport-https, lsb-release and software-properties-common
are apt concepts that exist to let later steps add the Docker repo and the
fastfetch PPA. A `case $PM` shows what each system actually gets, in one place.
Of those 19, six are load-bearing and the rest are the environment. Only
build-essential reaches beyond itself: it is a meta-package, so on a box with a
pinned gcc it pulls the distribution default alongside. Noted where it is
declared; it is the first thing to move out of core if that ever bites.
apt-get upgrade stays, but as its own announced step — it is the one place that
deliberately moves versions, rather than something that happens as a side effect
of asking for a tool.
DEBIAN_FRONTEND=noninteractive and NEEDRESTART_MODE=a now live inside the
helpers. needrestart has been on by default since Ubuntu 22.04 and stops to ask
which services to restart, which is how an unattended run ends up silently
waiting for a keypress.
dpkg-query on the status field rather than `dpkg -s`, which also succeeds for a
package removed but leaving its config behind — that state would read as present
and never be reinstalled.
Verified against this host's real dpkg database: all 19 report present, and a
mixed list correctly passes only the absent ones through.
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>
scripts/setup/ is now what the new installer is being built in — machine-setup/
for the box, officer-setup.sh for the platform on top — and everything being
replaced moved to scripts/setup-old/. It still works and is still what to run.
Three things the move broke, and what each needed:
starship.toml is not an old-setup artifact. os-user-shell.ts reads it at
RUNTIME to seed a member's ~/.config/starship.toml when their Linux account is
provisioned, and line 125 reads it inside a try whose catch returns
"could not read the shell templates" — so account provisioning would have
failed outright, not degraded. Moved back to scripts/setup/, which is where it
belongs anyway (one file, both audiences) and which leaves the code correct
with no edit.
package.json's `setup` script pointed at a path that no longer exists. It now
points at officer-setup.sh, where the installer is going, rather than at
setup-old/ which is temporary.
officer-setup.sh was created empty. An empty script exits 0, so `bun setup`
would have reported success while doing nothing — worse than the broken path
it replaced. It now explains that it is not written yet and exits 1, naming
the setup-old script to run meanwhile.
Also brought .tmux.conf and ufw-docker-rules.conf in beside machine-setup.sh,
which reads both from SCRIPT_DIR and had been silently skipping them since the
script was vendored. ssh-keys.zip deliberately stays out: it is key material,
and *.zip is ignored.
Comments in os-user-claude.ts, app-store/preflight.ts and two docs still name the
old scripts/setup/setup.sh path. Left alone on purpose — repointing them at
setup-old/ only to repoint them again when officer-setup.sh lands is churn.
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>