this reverts e36c6bb4. the rule was added to fix gitea ssh on one box, but this
file provisions every machine and most will never run gitea. opening 22 to
containers by default is the wrong trade — the box that needs it can add the
line deliberately.
also restores the accuracy of the prompt in machine-setup.sh, which tells the
operator the rules allow "only 80 and 443".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the DOCKER-USER chain is the only thing gating docker-published ports from
the internet — docker writes its own DNAT/FORWARD rules and bypasses ufw, so
`ufw allow <port>` has no effect on a published container port. the allowlist
permitted only 80 and 443, so a machine provisioned from this template dropped
gitea ssh silently.
the failure is hard to spot: the port looks open locally and docker ps shows it
published, but external clients hang at TCP connect with no refusal. local tests
pass because they arrive via lo and match the loopback RETURN before reaching the
DROP. comments added so the next person recognises it faster.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Reported from a fresh Hetzner VPS: the run stopped dead right after apt finished
installing zsh, printing nothing at all — just install.sh's "machine setup did not
finish".
install_oh_my_zsh carried a comment saying it "Returns 0 whatever happens". It did
not. Under `set -e` a failing command inside a function aborts the SHELL at that
line when the function is called plainly; `return 0` underneath is never reached.
The command is also `>/dev/null 2>&1`, so the cause was invisible — which is why
the transcript just ends.
`|| true` is what actually makes it non-fatal. The file already uses that idiom
correctly in four other places, so this was a slip rather than a misunderstanding.
set_login_shell had the identical bug on `chsh`, which the same run would have hit
on the very next question. Fixed differently and deliberately: `|| true` there
would let the caller announce a login shell that was never set, so it returns
chsh's real status and the CALLER guards the call — which is also what keeps set -e
out of it. A refusal now reports, names the manual chsh command, and carries on,
because a machine with zsh installed and bash at login still works.
Does not explain WHY oh-my-zsh failed on that host — the output was discarded. It
will now say "oh-my-zsh did not install" and continue, which is enough to see it.
Verified: bash -n on both files, and a reduced case proving broken() exits 1 while
fixed() survives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neither script ever becomes the user it sets the machine up for — a process cannot
change its own uid, so both run as root and drop privileges per command instead.
Everything Officer owns ends up belonging to that user and every pm2 process runs
as them, but the session you are left holding is root's.
Two things that fixes are invisible until they bite: group membership is fixed at
LOGIN, so the `docker` group just granted is not in the current session, and the
shell configuration was written into their home and is not loaded in root's. Both
present as "the machine is broken" rather than "log in again".
Printed by whichever half runs LAST. The first attempt put it at the end of both,
which says it twice on a full install — and the first time it is wrong, because
officer-setup is about to run and still needs the root session it tells you to
leave. install.sh is the only thing that knows whether anything follows, so it
sets OFFICER_SETUP_FOLLOWS and machine-setup stays quiet.
Also drops "Pre-flight complete. The remaining sections are not built yet." from
the end of officer-setup. All 11 sections exist; that line last made sense when 6
did.
Verified: bash -n on all three, the set -e behaviour of `$RUN_OFFICER && export`
under --machine-only, and the suppression across all five ways in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It stopped a real install. The guard covered the wrong half: a PPA that fails to
ADD is caught and skipped, but one that adds cleanly while carrying no package for
the running codename gets past that and dies on `pkg_install_now fastfetch`.
It was also the only tool in the set with no source but a third-party PPA on Ubuntu
24.04 and older. A neofetch clone is not worth a branch in a script whose whole job
is to survive machines nobody has seen.
Removed from tools_default, the tool_command mapping and its installer. No shell
config invoked it, so nothing is left calling a missing binary.
software-properties-common stays in the core apt list for now, with a note: it
provides add-apt-repository, the fastfetch PPA was its only caller, and Docker
writes its own sources.list.d entry by hand — so it is now dead weight. Left as a
separate decision rather than folded into this one.
Verified: bash -n on all four scripts, and no live reference remains.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every run writes a timestamped install-report.md recording what was installed,
changed, kept, skipped, started and run as root. Written for an adversarial read:
the person who just ran a setup script off the internet hands it to an agent of
their choosing and asks whether it did anything it should not have.
Recorded by the HELPERS rather than by the sections. pkg_install and
install_config report themselves, so anything installed or written through them
appears whether or not a section author remembered — a section that has to
remember is a section that will forget, and an incomplete report is worse than
none because it reads as a full account.
"Kept" is recorded as carefully as "changed". Leaving somebody's .zshrc alone is
the claim a reviewer most wants substantiated, and it is invisible unless stated.
Secrets are redacted at the moment of recording rather than filtered at render,
so a credential never sits in memory formatted for printing. Verified against a
POSTGRES_URL and an api_key/password pair.
REPORT_FILE is passed through the sudo re-exec. It was not, first time, and the
report silently vanished — the third variable this evening lost to env_reset.
Unfinished on purpose, paused mid-task at the owner's request: machine-setup's 26
sections still only report through the two shared helpers, so the sections that
change system state directly — systemd units, netplan, ufw, sshd drop-ins — are
not yet recorded. That is the half a reviewer would care most about.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old name said nothing about what the process runs, and it sits directly
beside officer-anthropic-proxy — a different process doing a different job — so
"the agent" was ambiguous exactly where it mattered. CLAUDE.md already had to
spend a paragraph insisting the two are not the same thing. It spawns `claude`;
the name says so now.
Only two references were functional: the generator's CORE_PROCESSES and the CORE
list in catalogue.test.ts. Everything else was prose or comments.
Left alone deliberately: `x-officer-agent-token`. It looks like the same string
and is not — it is the agent-handoff HTTP header, naming a per-panel bearer
token, unrelated to any pm2 process. Renaming it would have changed a wire
protocol to tidy a label.
Historical docs keep the old name. claude-sidecar-isolation.md and
open-threads-after-per-user-claude.md are dated investigations that record the
PREVIOUS rename, from officer-claude to officer-agent, and rewriting them would
make that history unreadable. CLAUDE.md notes the change instead, where somebody
reading those will be looking.
Also worth recording, from the owner: merging this with officer-anthropic-proxy
into one sidecar was investigated tonight and rejected. They stay separate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Run any of the three as yourself. On Linux they now ask through sudo and
re-execute, rather than refusing until you type it. Typing `sudo` still works and
changes nothing — it just stops being the price of starting.
This also fixes a trap that had nothing to do with taste. `sudo` strips the
environment by default (`env_reset`), so `OFFICER_ROOT=/somewhere sudo ./install.sh`
silently loses the variable and installs to the default path instead. The
re-exec passes OFFICER_ROOT, SETUP_USERNAME and MACHINE_ROLE to sudo BY NAME
rather than relying on -E, which env_reset ignores. This project has already lost
a variable to that once — see the DATA_PATH commit.
The invoking account is recovered the way it always was: sudo sets SUDO_USER,
which lib/base.sh already reads, including the check for a SUDO_USER that is
itself uid 0 on providers whose default account is root under an ordinary name.
macOS never escalates, in any of the three. Homebrew refuses to run as root, the
account running the script IS the owner, and the sections that needed root are
the ones the macOS path skips.
--help and argument errors still work with no privileges at all, since arguments
are parsed before any of this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
starship.toml, tmux.conf and zshrc side by side. tmux.conf and zshrc moved up out
of machine-setup/.
starship.toml could not have moved down to join them: the PLATFORM reads it, at
src/servers/os-user-shell.ts:34, to deploy to every member's Linux account. That
is a runtime path rather than an import, so moving it would have broken member
provisioning silently — no build error, members simply get no starship config.
So the templates collect where the shared one already had to be.
Left as a marker for tomorrow rather than resolved: there are now TWO zshrc
templates, this one for the owner and shell-skel/zshrc for members, while
starship.toml is deliberately one file for both. Either the owner needs different
shell config from a member or they should be the same file. tmux.conf has the
same question waiting, since it is going into provisioning too. Noted in the
zshrc header where whoever picks it up will be looking.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Somewhere to put what the owner actually wants, to be filled in and wired up
tomorrow. Not referenced by the Shell section yet.
The header records the decision that has to be made when it is: the section does
not install a .zshrc today, it appends four marker-wrapped blocks — starship,
agent, aliases, editor — through append_once. A template that is installed AND
appended to ends up with the same lines twice, so those blocks either move into
this file or stay out of it, not both.
zshrc, no leading dot: a template in a repository, not a dotfile in a home
directory, matching tmux.conf beside it and src/servers/shell-skel/zshrc which
has been spelled that way all along.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It is a template in the repository, not a dotfile in a home directory — and the
destination it is increasingly installed to, ~/.config/tmux/tmux.conf, has no dot
either. Naming the source after a path it may not be written to is how the wrong
file gets read.
The two remaining dotted references are correct and stay: they name the
DESTINATION ~/.tmux.conf, which does have a dot when that is where tmux looks.
.setup-answers and .setup-progress keep theirs too. They are runtime state in a
directory, not templates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tmux 3.1 added an XDG location and it takes PRECEDENCE over ~/.tmux.conf.
Verified on 3.4 here by writing a different marker into each and asking tmux
which one it ended up with:
both present -> ~/.config/tmux/tmux.conf
only ~/.tmux.conf -> ~/.tmux.conf
only the XDG one -> the XDG one
So the Shell section writing ~/.tmux.conf on a machine that already has the XDG
file produced a file tmux will never read, and reported "tmux config installed"
having changed nothing anybody could observe. That is the worst shape a config
step can have: it looks done.
tmux_config_target now picks the path tmux will actually load — the existing XDG
file if there is one, otherwise ~/.tmux.conf, which is still what every guide
names and what a machine with neither should get. When both exist the section
says so out loud before targeting the winner, because "your other file wins" is
not something anyone infers from a success message.
Also removed an untracked duplicate at scripts/setup/.tmux.conf. The one the
script installs is scripts/setup/machine-setup/.tmux.conf — SCRIPT_DIR is the
machine-setup directory — and two identical copies with only one of them read is
the drift this whole evening has been about.
Nothing to change about the config itself: the tracked copy is already byte-for-
byte the owner's own ~/.tmux.conf.
Not yet wired into per-user provisioning. src/servers/shell-skel/ seeds a zshrc
for a member and has no tmux config beside it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Officer on a Mac is a dev helper on a laptop somebody sits at. It is never the
homelab or VPS case, so the role is not asked for there — it is `dev`, and every
section that exists to make a machine a good server is skipped.
Seventeen of twenty-six sections skip, listed once in MACOS_SKIP in lib/base.sh
with a reason each, rather than an `if macos` threaded through each section. Most
would simply fail — no systemd, no ufw, no netplan, no useradd, no
/etc/ssh/sshd_config.d — but a few would SUCCEED and be wrong, which is worse:
stopping a laptop from sleeping, or freezing the address of a machine that moves
between networks daily.
Nine run: System update, Core utils, Tailscale, Command-line tools, Git, Docker,
Neovim, JavaScript runtimes, Agent CLIs.
The blocker was root. Linux needs it for nearly everything; Homebrew REFUSES to
run as root and says so, so the whole script under sudo would have failed at the
first brew install having already taken a password. It is now required on Linux
and refused on macOS, which works precisely because the macOS path skips
everything that needed it.
Docker is checked, not installed. Docker Desktop is a GUI app that wants opening,
permissions and a running window — not a shell script's business — and colima and
lima both cost an evening the first time something does not resolve. So the step
reports whether the daemon answers and points at the download otherwise. The
group-vs-rootless choice below it is Linux only: Desktop runs containers in a VM
owned by whoever is logged in, so there is no group to join.
Added the Xcode command line tools as a macOS-only step, before anything that
builds. node-pty ships no prebuilt binary on any platform and always falls
through to node-gyp, so `bun install` cannot finish without a compiler — and it
fails deep in a dependency tree naming neither Xcode nor node-pty. `xcode-select
--install` opens a dialogue and returns immediately, so the step says to come
back rather than pretending to have waited.
Tailscale takes the cask, not install.sh — that script is a Linux package-manager
wrapper. The cask ships a usable CLI; the Mac App Store build is sandboxed and
does not.
Not run on a Mac. There isn't one here, so this is read from the code and from
what each tool documents, not observed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ALLOW_ANY_ORIGIN, ALLOW_ANY_ORIGIN_MUSIC, and everything they gated. The flag
defaulted to ON, so none of it ran on a real install — what comes out is
documented defence in depth that was already switched off. The file said so
itself: "Both flags and their call sites come out once the tailnet is the
perimeter."
Origin was never authentication here in any case. An app's `officer://<hex>`
origin is chosen by the client, forgeable outside a browser, and extractable from
a shipped binary.
Gone: the two flags, isOriginAllowed, isOriginCheckDisabled, isMusicOriginExempt,
originValidationMiddleware, ORIGIN_RULES and the whole OFFICER_<APP>_ORIGIN
scheme, PUBLIC_URL's origin/host derivation, and origin-validation.test.ts, which
existed only to pin them. CORS now echoes whatever Origin it is given, which is
what every install already did.
What SURVIVES is the reason this needed care. origin-validation.ts held two
unrelated things, and the second was the global authorization gate — a valid
non-owner token reaches only what its role grants, deliberately NOT under the
flag because it is account-based rather than origin-based. Its own comment called
it "the airtight half". Deleting the file wholesale would have deleted
authorization.
So it moves to _middlewares/capability-gate.ts as capabilityGateMiddleware, with
the name matching what it does: nothing in it reads an Origin header any more.
hono.ts mounts it in the same position, ahead of every router.
origin-middleware.ts stays and is untouched — it extracts the Origin for six auth
handlers that log it, and for passkeys. Extraction, not validation.
Also updates every claim that rested on the old model: CLAUDE.md's security
section and repo map, docs/secret-store.md, docs/mobile-api-keys.md, and five
messages in machine-setup's Tailscale section which told the owner to set
ALLOW_ANY_ORIGIN=false when declining a tailnet. That advice is now impossible to
follow, and the honest version is different: with no tailnet the token is the
whole lock, so put a proxy in front and restrict who can reach it.
Not typechecked (empty node_modules, frozen installs). Every changed file parses;
the setup section was run and writes four variables now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Read the whole thing — 2392 lines of entry point and 2700 of libraries — looking
for what shellcheck cannot see. shellcheck itself is clean at error level; its
warnings are cross-file false positives and one deliberate tilde in a display
string. Everything below is a real defect.
── The Git section aborted on any machine where git was not already configured ──
`git config --global --get <key>` exits NON-ZERO when the key is simply unset,
and `VAR="$(git_get …)"` propagates that under `set -e`. So on a fresh machine —
the case this script exists for — the section died at its first assignment,
before printing anything, and took the remaining nine sections with it.
It passed every earlier test because those harnesses sourced the section under a
`bash -c` with no `set -e`. Verified now against a genuinely fresh account with
the real script: the section completes and writes a correct .gitconfig.
── An optional step failing aborted the whole run ──
Twelve functions ended on a command that can fail — `systemctl enable --now
earlyoom`, `systemctl restart systemd-logind`, `chsh`, `sysctl -w`, `chown -R`,
the oh-my-zsh installer, and others. Called as plain commands under `set -e`, any
one of them failing ends the script, so a masked unit or a container without
systemd would abort a 28-section run over an optional improvement.
They now return 0 explicitly and the callers verify the outcome instead — which
also fixed a lie: the sleep section printed "sleep disabled, logind reloaded"
whether or not the restart had worked. It now checks the targets and the logind
values and reports honestly.
── chown user:user assumed the primary group is named after the user ──
True on Debian and Ubuntu, which create a group per user. Not true for an account
from LDAP, or made with `useradd -g users`, or on an image with a shared group —
there `install -g <user>` fails with "invalid group" and the step aborts. Proved
it against an account whose primary group is `oddgroup`: the old form fails, the
new one gets ownership right. Eight call sites now ask `id -gn`.
── Also hardened ──
agent_path and current_editor gained `|| true` for the same reason git_get needed
it: "nothing is set" is an answer, not a failure.
Verified afterwards: shellcheck clean at error level, every section runs
standalone without aborting, and the two apparent failures in that sweep are
correct behaviour — Timezone and Git refusing an empty answer from /dev/null.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Last in the run for the reason the original gave: enabling a firewall is the one
step that can cut the connection it is running over.
The security bug is in the shipped rules. ufw-docker-rules.conf hardcodes eth0 in
all three of its rules. Docker publishes container ports by writing its own
iptables rules underneath ufw — DOCKER-USER is the hook that lets ufw have a say
at all — so on a machine with predictable interface names (ens18, enp1s0, most
VPS images) none of those rules match, the final DROP never fires, and every
published port is open to the internet while `ufw status` reports active. A
firewall that says it is working and is not is worse than no firewall. The rules
are now substituted with the interface the machine actually uses, verified by
applying them against a stubbed ens18.
Order inside the section is the other thing that matters: OpenSSH is allowed
BEFORE anything is enabled, unconditionally, because a firewall enabled without
an ssh rule on a machine reached over ssh needs a console to fix. The prompt says
so, and says to open a second session before closing the current one.
tailscale0 is checked and offered, because the default is deny inbound and the
tailnet is an inbound interface like any other — without that rule Officer is
unreachable over the tailnet while Tailscale reports itself connected.
A correction to something I said while writing this: I reported that this host was
missing its tailscale0 rule. It is not. I had run `ufw status verbose | head -8`,
which cut the output above the rule list. The full status shows it allowed, and
nothing was wrong.
That leaves the NOT PORTED list empty.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Shell section moves to 26, after Neovim, the runtimes and the agent CLIs.
Everything that writes to .zshrc now happens there and only there: the starship
init, the PATH for the agent CLIs (moved out of that section), the aliases, and
the editor.
That ordering is what the editor choice needs — it offers whichever of nvim, vim
and nano are actually present, so it has to run after Neovim is installed rather
than naming an editor that is not there. Which was the original's mistake in the
other direction: it set core.editor to nvim four sections before installing it.
The default editor is the setting git's core.editor was deliberately left out in
favour of. EDITOR, VISUAL and SUDO_EDITOR go in the account's shell, and the
Debian `editor` alternative is set too — an account's shell config cannot reach
root or sudoedit, and those are exactly the cases where the wrong editor is most
annoying.
Recorded a limitation of append_once while cleaning up after it: renaming a
marker orphans the block that used the old name, and changing a block's content
does nothing because the marker is still found. Both need the old block removed
by hand. This run left exactly that — a `local-bin` block superseded by
`agent-clis` — in the dev box's .zshrc, now removed.
UFW is deliberately still unported and will be last, for the reason the original
gave: it is the one step that can cut the connection the run is happening over.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude Code goes in through https://claude.ai/install.sh, matching what the
platform already does for members in os-user-claude.ts and chosen there for the
auto-update npm does not give. The comment at os-user-claude.ts:28 claiming
setup.sh already did this was simply wrong — setup-ubuntu.sh used
`npm install -g @anthropic-ai/claude-code`.
Two things that installer insists on, both of which a naive port gets wrong and
both of which os-user-claude.ts had already found:
It REFUSES to run under sudo from a regular user's shell — it checks for uid 0
with SUDO_USER set, because everything it writes goes under $HOME and under
sudo that is root's. This script runs as root, so the install has to be done AS
the account.
It declares #!/bin/bash and uses [[ … =~ … ]], so it must be piped to bash. On
Ubuntu /bin/sh is dash and `| sh` fails.
Two bugs found by running it rather than reading it:
opencode does not install to ~/.local/bin. It goes to ~/.opencode/bin, which is
what sidecar/opencode/index.ts:22 hardcodes. The first version looked in the
wrong place, reported a working install as missing, and installed it again —
the run said "did not complete" while the installer had plainly succeeded.
claude on this machine came from npm, so `command -v claude` found it and the
section would have left a copy that never updates. It now detects an npm
install by resolving the binary into node_modules, says so, and offers to
reinstall through the official installer — naming the npm copy and how to
remove it rather than deleting something it did not put there.
~/.local/bin and ~/.opencode/bin are both added to the account's PATH. The
sidecars do not need it — they check the exact paths — but a user who cannot run
`claude` in their own terminal reasonably concludes it was never installed.
PI stays optional and says outright that nothing in the platform spawns it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The link was made as part of install_bun, so a machine that already had bun never
got one. That is this machine: bun 1.3.14 in ~/.bun/bin, no /usr/local/bin/bun,
and `bun` resolving to nothing at all for root. Nothing has broken yet only
because `pm2 startup` has never been run here — the moment boot persistence is
enabled, all twenty ecosystem apps that say `script: 'bun'` fail at boot and work
perfectly when started by hand.
ensure_bun_symlink now runs whether or not this script did the install, and says
which of the three things happened: made it, found it already correct, or could
not find bun to link. The last records an error, since a missing link is a
reboot-shaped failure rather than a cosmetic one.
Safe across upgrades, which was the question: a symlink resolves by path, not by
inode, and `bun upgrade` replaces the file at $BUN_INSTALL/bin/bun rather than
moving it. Demonstrated by replacing a target with a new file — new inode, link
still resolves. It breaks only if the home directory goes, which breaks bun
anyway.
Also fixed the status line, which reported "not installed" on a machine with bun
in the user's home: it asked root's PATH, which is exactly what has no bun before
the link exists. bun_version now asks whichever copy is there.
This run created the link on this machine — /usr/local/bin/bun -> the account's
copy, and root can now run bun.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Not a choice. Officer does not run without them, so asking would be asking
whether to install Officer — which was settled by running the script. They are
installed and reported, with the reason each is load-bearing stated once:
node pm2 is a Node application, and officer-pty compiles node-pty against
whatever Node is installed. There is no Linux prebuild, so this is not
an ABI question — it is a build dependency on every machine.
bun the platform itself and nineteen of the twenty pm2 apps.
pm2 supervises all of them, and the ecosystem files are written for it.
Node now tracks the current LTS, asked of nodejs.org, rather than the pinned
setup_22.x the original used — which ages into "the version we happened to pick"
the moment a new LTS lands. Resolves to v24.19.0 (Krypton) today, and NodeSource
publishes setup_24.x, checked with a HEAD request before anything is piped into a
shell.
Deno is the one genuine choice and stays optional, defaulting to no. Nothing in
Officer imports it — verified across the whole tree, the only references left are
in the old setup script — so the prompt says that outright and offers it for the
user's own work rather than pretending it is part of the platform.
bun is installed as the account and then symlinked into /usr/local/bin. pm2
started at boot by systemd has no login shell and therefore no ~/.bun/bin on
PATH; without the symlink every bun-based sidecar fails on reboot and works when
started by hand, which is a miserable thing to debug.
Every install is verified after it runs rather than trusting an exit status. A
NodeSource run can succeed while apt holds an older nodejs back, and reporting
the version asked for instead of the one present is how a machine ends up
disagreeing with its own setup log. Tested with an installer stubbed to succeed
and change nothing: both report failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
node-pty ships prebuilt binaries for darwin-arm64, darwin-x64, win32-arm64 and
win32-x64. That is the complete list — there are no Linux prebuilds. So on Linux
its install script always falls through to `node-gyp rebuild` and compiles from
source, every time, on every machine.
node-gyp needs Python 3. python3 was in the old setup.sh and I dropped it when
rewriting the package list as "what the script itself would break without" —
which missed that the thing it breaks is not this script but `bun install`, later,
with an error about a Python that was never mentioned. The terminal sidecar then
does not come up, and the reason is three steps removed from the symptom.
build-essential was already there and is the other half of the same requirement;
they are now noted together where they are declared.
Found while answering whether node-pty constrains the Node version. It does not —
with no prebuilt binary there is no ABI to mismatch, so it builds against whatever
Node is installed, including 24. The exact-22 pin in package.json:11 is the
platform's own choice, not node-pty's requirement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kept as it worked — upstream tarball, symlink, a config repo cloned into the
account's ~/.config/nvim — with the defects fixed rather than the design changed.
The one that mattered: the asset name. Neovim publishes nvim-linux-x86_64.tar.gz
and nvim-linux-arm64.tar.gz. The original mapped aarch64 to "aarch64", which is
not a name Neovim has ever published, so on an arm machine it downloaded a 404
and handed the HTML error page to tar. Verified against the release API — same
class of bug as lazygit's hardcoded x86_64, and the second one this port has
found in an arch mapping. The tarball is now checked with `tar -tzf` before
anything is removed, so a bad download says what is wrong instead of failing
inside tar.
The rest:
The tarball went to the working directory, via `curl -LO`, and stayed there if
tar failed. It goes to /tmp and is cleaned up.
The old /opt install was removed before the new one was known to be good. The
download and its sanity check now come first, so a failed fetch leaves the
working copy alone.
The custom-repo option defaulted to git@gogs:andrepadez/nvim-config.git — a
private repository nobody else can clone, and the same mistake as defaulting
the login server to a personal headscale. No default now.
git clone runs from /, for the reason git config does: the script's working
directory is usually under the invoking user's home at 0750, which the target
account cannot stat.
The ~/.config/nvim/.git removal is now conditional on it being the starter.
That is a template and dropping its history is right; a config of the user's
own is something they will want to keep pulling.
An existing config is left alone and said so, rather than moved to a .bak that
silently overwrote the previous .bak.
The PATH line the original appended to .zshrc is gone. /usr/local/bin/nvim is
symlinked and already on PATH, so it was doing nothing except growing the file on
every run.
Verified on this host (already current, existing config left alone) and against a
fresh account (LazyVim starter cloned, owned correctly, .git dropped).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Now section 6, with the tools at 7. No dependency in either direction: Tailscale
needs curl, which core utils installs at 5, and nothing in it touches lazydocker,
lazygit, starship or fastfetch.
Same reasoning as putting it early in the first place — it is a second way into
the machine, so it should exist before anything that can go wrong does, and
fetching four upstream binaries is a longer gap than it needs to sit behind.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keeping theirs silently was safe but unhelpful: they never learn a newer version
exists, and the only hint was a cp command printed in a warning. Now it asks.
[1] keep yours — nothing changes
[2] use ours — yours is kept as <file>.before-machine-setup
[3] show me the difference first
The diff is labelled "yours" and "ours" rather than by path, so - is what you
would lose and + is what you would gain, and it goes through the pager because a
config diff is routinely longer than a screen. Choosing to replace always keeps
the old file beside the new one; nothing is destroyed.
An unattended run — ASSUME_YES, or no terminal on stdin — keeps theirs and says
so. "Yes to everything" cannot sensibly mean "overwrite configuration nobody was
present to defend", so this is the one prompt ASSUME_YES answers conservatively
rather than affirmatively.
Applies to every file that goes through install_config, which is .tmux.conf and
starship.toml today and is where any other dotfile should go.
On the starship question: there is one file now, scripts/setup/starship.toml, and
the inline copy is gone. Owner and members get the same prompt, which is what
os-user-shell.ts always claimed.
Verified through a pty, since the -t 0 guard correctly makes the interactive path
untestable over a pipe: the diff renders, replacing writes the backup, and the
live file ends up byte-identical to ours.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
os-user-shell.ts:33 calls scripts/setup/starship.toml "the prompt config the
owner's own install uses — one file, both audiences". It was not: the original
machine script wrote a DIFFERENT config inline, so the owner got a prompt that
only disabled language modules while every member got the repo file with its
custom format. Two prompts, one comment claiming otherwise.
This deploys the same file the platform does, which makes the comment true.
Verified with cmp against a fresh account: byte-identical to what a member gets.
Nothing overwrites any more:
.config/starship.toml and .tmux.conf go through install_config, so they are
written when absent, skipped when identical, and KEPT when they differ — with
the cp printed, so taking ours stays the reader's decision. On this host that
is what happens: the existing config differs and is left alone.
The starship line in .zshrc is marker-wrapped by append_once. Verified over
three consecutive runs: one block, not three. The original appended it
unguarded every time.
The login shell is now its own question. Having zsh on the machine and being
handed it at every login are different decisions, and `chsh` made the second one
silently. It also adds the shell to /etc/shells first, which chsh requires.
.tmux.conf lives here now, with the rest of the dotfiles, rather than in user
creation where the original put it only because that is where $USER_HOME first
exists.
One bug found by running it: install_config returns 2 for "kept yours", which is
an outcome rather than a failure — but still non-zero, so calling it as a plain
command under `set -e` ended the run before `case $?` could read it. Captured with
&& / || at both call sites, and the contract is documented where the function is
defined so the next caller does not repeat it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Choosing option 1 means leaving to set up a server elsewhere, which could take an
afternoon. The run now says outright that Ctrl-C is fine and that returning picks
up here — completed steps skipped, answers kept — so nobody feels they have to
finish in one sitting or start over.
The same promise once at the top, where the resume notice already was. That
notice is now phrased as what it means rather than as file paths: "2 step(s)
already done, and they will be skipped", with the command to start over instead
of a bare mention of the file.
On the question of why pre-flight kept re-asking: it does not, and I caused what
you saw. Nearly every test command I have run today ended with
`sudo rm -f .setup-answers`, so the file was deleted between your runs. Proved the
round trip — a run with the variables set writes all three, and a second run with
no environment at all asks nothing and prints what it remembered. The file is
gitignored, so there was never a reason to be deleting it. Stopped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"offscale runs on a publicly reachable server" read as a demand offscale makes
and the easy route does not. It is not. Tailscale's coordination server is
publicly reachable too — they run it for you, and that is the entire difference
between option 3 and hosting it yourself.
Said that way round, the requirement stops being a reason not to self-host and
becomes what self-hosting means. Same sentence in all three places: the menu
entry, the branch taken when 1 is chosen, and the long answer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"One command to install" was the wrong emphasis and read as though it happens
here. It does not: a coordination server has to be reachable by every device that
joins, including phones on mobile data and laptops in other buildings, so it
needs an address that resolves from anywhere. It goes on a small public VPS of its
own — not this machine, and not behind a home router.
That is the thing people get wrong, and getting it wrong produces a private
network unreachable from exactly the devices it exists to reach. It is a property
of being the thing everyone checks in with, so it is true of headscale too, and
the long answer now says so.
Choosing option 1 leads with it, links the install anchor rather than the page,
and says plainly that nothing below will work until that server is up and
answering — so somebody who has not done it stops here instead of typing an
address that does not exist yet.
"Installs in one command" survives in the goodies list, where it belongs: it is
one command ON THAT SERVER, and the sentence now says so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Option 1 no longer refuses. It assumes offscale is already running — installing
it is one command, documented on the site — and asks for its address and a key,
which is mechanically what option 2 does. The two share a branch because the
difference between them is what to say, not what to do: one is "you already have
a server", the other is "set one up first, here is where".
Option 4 is new: no private network. Presented as a real choice rather than a
failure to choose, with what it costs stated before it is taken and paged so it
is read rather than scrolled past:
· anything reachable remotely has to be published deliberately and kept closed
otherwise
· TLS certificates are yours to obtain and renew
· every exposed service needs its own authentication, since there is no longer
a boundary in front of it
· the machine will be found — anything on a public address is scanned within
minutes
And the one that is specific to this platform rather than general advice:
ALLOW_ANY_ORIGIN defaults ON, which is deliberate and only defensible because
the tailnet is the perimeter. With no tailnet it must be set to false with an
HTTPS proxy in front, or Officer runs with a check disabled on an assumption that
is no longer true. The summary line says so, so it survives the run.
Declining option 4 redraws the menu rather than dropping to a bare prompt.
Tailscale is still installed when 4 is chosen, and the run says how to connect it
later.
Verified all four end to end with tailscale stubbed, plus the decline-and-choose-
again path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gaps found by tracing the assembled command rather than assuming it, and the
first meant option 3 did not work at all on a machine like this one.
`tailscale up` with no --login-server keeps whatever ControlURL is already
stored. So on a node already pointed at a self-hosted server — which this host is
— choosing "the easy route" left it exactly where it was. No error, no message,
and a summary line claiming it had connected. The URL is now passed explicitly in
both cases, TS_DEFAULT_CONTROL_URL for Tailscale's own service.
And a node logged in to one coordination server cannot simply be pointed at
another; it has to be logged out first. That is now detected by comparing the
stored URL with the target, and offered rather than done quietly — the tailnet
drops while it happens, and the run says so, because on a machine reached over
the tailnet that is the session you are reading this in. Declining leaves the
node where it is and records that.
Verified all three paths with tailscale stubbed: switching logs out then connects
to controlplane.tailscale.com, declining leaves it on offscale, and reconnecting
to the SAME server offers no logout at all.
Also noted while tracing: lan_cidr correctly finds nothing on this host, since a
/32 with host routes has no subnet to advertise. That means the homelab
subnet-router prompt is the one path here that has not been exercised on real
hardware.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The long answer is now well past a screen, so it scrolls the question off the top
and the reader lands at a prompt having lost what they were choosing between.
Piped through a pager, so it is read a screen at a time and the menu is redrawn
underneath it afterwards.
`more` rather than `less`: it exits at the end of the file instead of sitting
there waiting to be quit, which is right for something asked for once.
Only when stdout is a terminal. Redirected or piped — a transcript, a log, the
test harness — it comes through whole, since a pager there either blocks or
mangles the output.
Applied to confirm()'s help hook as well, so every ? in the script pages, not
just this one.
Verified both ways: driven through a pty it shows --More--, and with output
piped all four help sections come through in full.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The section explains three ways to get a coordination server and then says
nothing about running one afterwards, which is the part that decides whether
self-hosting is a good idea. Officer's Headscale app is the answer to it, and it
works against headscale and offscale alike.
Written from what the app actually does rather than from the pitch: several
servers registered and switched between, each PROBED rather than remembered — the
comment in ServersView.tsx is explicit that a "not checked" dot is the one thing
that list must never show — and, on the active one, nodes, users, pre-auth keys,
invites and the ACL policy with an assistant, plus a console and diagnostics.
Placed in FOR OFFICER rather than under offscale, because it is true of either
self-hosted option and is the reason picking one is not a commitment to
administering it over ssh.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"our implementation of the same protocol rather than a wrapper around theirs"
was too strong. It is the Tailscale client with our branding, and one real
difference: it takes an invite from the server directly.
The corrected version is not a weaker claim, it is a more specific one. "Our own
implementation" invites the question of whether it is trustworthy and whether it
keeps up; "the Tailscale client, our branding, and it takes an invite directly"
answers both — it is their client, so it is as good as their client, and the
thing it adds is the thing that was hard.
The reason it is hard stays in, since it is what makes the difference worth
naming: the official app has to be talked into using a server that is not
Tailscale's, and that is where people abandon self-hosted headscale. And it still
says there are no desktop apps of our own, now with what to do instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Read https://officer.dev/infrastructure/offscale.html and used its own words for
the protocol claim — "the protocol on the wire is Tailscale's, the encryption is
WireGuard's" — which is stronger than my paraphrase and is the sentence that
stops "our own distribution" reading as a fork.
The goodies are named rather than gestured at. No placeholders left:
· installs in one command, with the certificates handled
· health, logs, restarts and access policies from the app, instead of a config
file and a CLI
· enrolling a device is a link and a tap — the key is minted and handed over
for you
· several networks at once, and services reachable across them
The URL appears twice, as asked: in the menu entry, where somebody deciding
between three options can reach it without typing ?, and at the end of the long
answer for somebody who read the whole thing and wants more.
The mobile-app paragraph stays and is not from the page — the page does not name
its client platforms. It is your account of them, kept because it answers the
obvious objection to self-hosting, and it still says plainly that there are no
desktop apps of our own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"talking to stock Tailscale clients" was wrong, and wrong in a way that gave
away the strongest thing offscale has. On computers it is the stock client. On
iPhone, iPad and Android it is our own app — our implementation of the same
protocol, not a wrapper around theirs. No desktop app of our own yet, and the
copy says so.
Stated as a differentiator rather than a footnote, because it is the specific
that answers the obvious objection to self-hosting. Getting the official mobile
app to talk to a self-hosted server is the part of running headscale people give
up at; having an app that simply does is worth more than any sentence about
extras.
The protocol claim is unchanged and still leads, because it is what makes the
mobile app reassuring rather than alarming: our own client is our implementation
of Tailscale's protocol, not a private one. "Our own distribution" plus "our own
app" reads as a fork unless the first thing said is that there is nothing to fork.
Marker narrowed from "goodies" to "more goodies" — one is now named.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things said plainly, because they are the two a reader needs before choosing:
Nothing about the protocol changes. offscale is headscale's open-source code
speaking to stock Tailscale clients, so a machine on an offscale network
behaves exactly as it would on either of the others. Worth stating outright —
"our own distribution" reads as a fork, and a fork of a network protocol is
something to be wary of. There is no offscale protocol to be locked into,
because there is no offscale protocol.
What changes is the work. Running headscale yourself is a project: install it,
put TLS in front of it, keep it upgraded, administer it through a config file
and a CLI. offscale makes that a step in a setup script.
"our own sugar on top" is gone. One marker left, in both the menu and the long
answer: the goodies are unnamed. Two or three specifics would be worth more than
the sentence they replace — every product claims extras, and the claim is only
interesting when it says which.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each of the three now carries a couple of lines under it, separated by a blank
line, so the choice can be made from the menu itself rather than by typing ? and
reading a page. 2 and 3 are written; offscale's is a marked placeholder with your
one-liner standing in until the longer copy arrives.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit blamed the loop body. That is wrong, and I only found out by
trying to reproduce it: the same `[[ … ]] && assign` inside a case inside a while
loop survives `set -e` perfectly well at top level.
What actually happened is one level further out. The failing assignment was the
last thing the case ran, the case was the last thing the loop body ran, and the
loop was the last thing THE FUNCTION ran — so load_answers returned non-zero, and
calling a function that returns non-zero is a plain command failure, which does
end the script.
Worth getting right because the general rule is different from the one I wrote: it
is not "avoid && in loops", it is "a function whose last statement can return
non-zero fails when it is called, however innocuous the statement looks".
Scanned the libraries for that shape. The only hit is lan_cidr, which ends in an
awk pipeline and returns 0. Predicate functions ending in a bare test —
ballast_exists, has_authorized_key and the rest — are meant to return non-zero
and are only ever called in conditions, which set -e exempts.
The fix itself was already correct and is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The menu is one function now rather than being written out twice — it is shown
again after ? prints the long answer — and option 1 carries your line: offscale is
just tailscale and headscale, with our own sugar on top.
The bug it surfaced is the more useful half. load_answers used
[[ -z "${MACHINE_ROLE:-}" ]] && MACHINE_ROLE="$value"
as the last statement in a while-read loop body. When the variable is already set
the test is false, the compound returns non-zero, and as the final statement in a
loop body under `set -e` that ends the script. The failure is silent about its
cause: the trap prints "Step: unknown" and a line number inside the library,
before pre-flight has run.
It needed both conditions to appear — an answers file on disk AND the variables
already set in the environment — which is why every earlier test missed it and
running with env overrides hit it immediately. Written as if/then now, and the
other lib files scanned for the same shape.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four options, in the order you gave:
1 set up your own network (offscale)
2 use a network you already run (headscale, offscale)
3 the easy route (tailscale.com)
? what are tailscale, headscale and offscale?
? prints the long answer and then shows the options again, rather than dropping
the reader back at a bare prompt having forgotten what they were choosing
between.
The explanation frames all three as one question — who keeps the list of your
machines and hands out the keys — and says plainly that the coordination server
never carries traffic, since that is the thing people assume it does. Tailscale
and headscale are written. OFFSCALE is a marked placeholder, and so is what
option 1 actually does; both are yours to fill in and the run says so rather than
pretending.
Option 3 is the plain flow: no --login-server at all, and the auth-key prompt
says what that means — leave it blank and Tailscale prints a link that either
creates the account or adds this machine to an existing one. Option 2 keeps the
"no suggested URL" rule, because a coordination server URL is somebody's private
infrastructure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ten lines of prose before the first prompt assumed the reader had never heard of
Tailscale. Anyone already running it does not need to be told what it is, and
having to scroll past it every run is the cost of writing for the other reader.
The prompt comes first now, and `?` is an answer. Typing it prints the full
description and asks again; not typing it costs nothing.
confirm() takes an optional help function as its third argument. Where one is
given the prompt becomes [Y/n/?], so the explanation announces that it is
available without taking up room. The same hook is there for any other section
that wants it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The --only flag worked, in that it reached the section — but reaching it meant
answering four pre-flight questions first, every time, which is not usable for
working on one section. The same problem was already there without --only: a
resumed run re-asked the role, the account and the Officer path that it had been
told on the previous pass.
Answers are saved beside the progress file and loaded before anything is asked.
The environment still wins over what was saved, so SETUP_USERNAME=x on the
command line overrides it, and --reask throws the file away and asks again.
Read as assignments rather than sourced. The file sits next to the script and is
read by a run that is already root; sourcing it would make it executable content
in a place nothing guards.
Second run now goes straight through pre-flight, printing what it remembered, to
the one step asked for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moved to position 7 — after core utils, which give it curl, and well before SSH
hardening, which is the step that can lock you out. The argument is that
Tailscale is a second way into the machine, so it wants to exist before anything
that can go wrong does.
── Why the original hung, and what stops it now ──
Its prompt accepted an empty auth key and passed it anyway. `tailscale up
--authkey ""` falls back to the interactive flow: it prints a URL and blocks,
with no timeout, forever. From the outside that is a script that has frozen.
Nothing here passes an empty key — the flag is omitted entirely, and the run says
in advance that a URL is coming and that it will wait. Every call carries
--timeout=60s, and a timeout is reported with the command to run by hand rather
than left as silence. State is read with `tailscale status --json` before
anything is run, so a node that is already up is offered a reconfigure instead of
having `up` fired at it blindly.
Diagnosed on this host rather than guessed at, and honestly the diagnosis is
partial: the exit-node branch left no trace at all — no /etc/sysctl.d file,
networkd-dispatcher present but with zero mentions in apt history, so it came
with the image. ip_forward=1 came from the unconditional part of the section, not
the branch. That points at `tailscale up` as where it stopped, and the empty-key
path is the candidate that fits, but I could not reproduce it to be certain.
── What the section now covers ──
control plane Tailscale's own service by default; a self-hosted headscale as
an explicit choice with NO suggested URL. The original defaulted
to headscale.pastilhas.eu, so a stranger running it pointed
their machine at somebody else's control plane.
auth key, or the browser flow, stated as an equal option
Tailscale SSH ssh over the tailnet with no keys, governed by tailnet ACLs —
and pointed out as a way back in if the sshd hardening later in
the run goes wrong
subnet router homelab only, defaulting to this machine's actual LAN CIDR
exit node with what it means for whose traffic goes where
forwarding sysctls and Tailscale's recommended NIC offload settings, and
only when an exit node or a route actually needs them
Approval is mentioned: an advertised route or exit node does nothing until it is
approved in the admin console, which is otherwise a silent non-event.
Also added --only <step> and --list, because this section in particular needs to
be run on its own while it is being worked on. A step run that way ignores the
progress file and does not record itself — asking for one step is not progress
through the script.
One bash quirk fixed on the way: "${VAR:-Tailscale's own service}" does not
parse. An apostrophe inside a ${:-} default opens a quoted section that swallows
the closing brace, and the error surfaces as "unexpected EOF" 400 lines away.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three options, each explained rather than named, because the difference between
them is a security posture and the default is the one that sounds harmless.
1 docker group, the default. The text says what the group actually is: anyone
in it can run `docker run -v /:/host -it alpine chroot /host` and have a root
shell. It is not "access to Docker", it is root by a longer route — the same
framing os-user-docker.ts already uses for why members never get it.
Whether that matters is conditional, and the run works it out rather than
asserting either way: on an account that already has sudo it is a shorter
path to something they can reach anyway, and it says so; on an account that
does not, it is a real escalation, and it says that instead. Caught in
testing, where the reassuring sentence was being printed for a throwaway
account with no sudo at all — the exact case where it is untrue.
2 rootless, with the thing nobody would find out stated at the prompt:
Officer's app store cannot provision containers with it. compose.ts,
preflight.ts and system-monitor all spawn `docker` with no environment of
their own, so they reach /var/run/docker.sock; DOCKER_HOST is set only for
member commands, in os-user-docker.ts. pm2 started at boot by systemd has no
session either, so exporting it in a shell rc does not reach the process
that matters. The consequence is recorded in the summary, not just spoken.
3 neither, and what that costs.
Also fixed in the port: the repository codename came from `lsb_release -cs`, which
is wrong on every derivative — Mint reports "vanessa", Pop reports its own, and
Docker publishes neither, so `apt update` fails against a repository that does not
exist. os-release carries UBUNTU_CODENAME on exactly those systems for exactly
this reason; it is preferred now, with VERSION_CODENAME as the fallback, and the
ubuntu/debian half of the URL comes from ID_LIKE rather than being hardcoded.
The shared `services` network is created only when missing, checked with
`docker network inspect` rather than by running create and discarding the error.
Verified on this host, and against a throwaway account both with and without
sudo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Asks for nano, vim or nvim and sets EDITOR/VISUAL in the shell config, plus the
Debian `editor` alternative so root and anything reading the system default agree
with it.
Has to come after the Neovim section, or nvim cannot honestly be offered as one
of the choices — which is the same ordering mistake the original made by setting
core.editor to nvim four sections before installing it.
This is the setting core.editor was left out in favour of: one preference that
git, crontab -e, visudo and systemctl edit all follow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pull.rebase true, which is the setting whose absence stopped this very repository
mid-session today: git refuses to pull when branches have diverged and asks which
of three things you meant, every time, until told once. Rebasing replays local
commits on top of what was fetched rather than adding a merge commit that records
nothing but the fact that you had not pulled yet.
core.editor stays out, deliberately, and the run says so rather than leaving its
absence to look like an oversight. Git's fallback chain is GIT_EDITOR →
core.editor → $VISUAL → $EDITOR → system default, so core.editor is a git-only
override sitting above $EDITOR. The original set both it and `export EDITOR` in
the shell section — two settings for one preference, which drift apart the moment
either is changed and leave git using an editor nothing else does. Setting only
$EDITOR means git, crontab -e, visudo and systemctl edit all follow one answer.
Verified on a throwaway account: .gitconfig comes out with user, init.defaultBranch
master and pull.rebase true.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Detects first, then asks. If there is a name and email configured it shows them
and offers to change them, defaulting to no — the common reason to re-run this
script is everything except this. If there is nothing configured it just asks.
Default branch is master rather than main, unless the answer says otherwise.
Three problems in the original, beyond running unconditionally:
prompt_value accepts an empty answer, so pressing Enter wrote `user.name = ""`.
An empty name is worse than none: unset makes git refuse to commit and say why,
empty makes it commit with a blank author and never mention it. ask_required
re-asks instead.
core.editor was set to nvim four sections before Neovim is installed, so
anything invoking the editor in between failed. Dropped for now rather than
moved — it is a preference, and worth deciding separately.
Nothing checked whether the writes worked.
That last one was not theoretical. Testing against a throwaway account, all three
writes failed and the section still printed "OK: written". `git config --global`
needs no repository, but git stats the working directory on the way, looking for
one — and the script runs from under the invoking user's home, which is 0750, so
the target account cannot stat it:
fatal: failed to stat '<cwd>': Permission denied
The wrappers now run in a subshell from /, which every account can stat, and the
caller checks the exit status and reads the value back before claiming success.
Also recorded where it is written: docs/agent-git-identity.md says every agent
Officer runs commits as the owner, because it runs as the owner. This is not only
the human's identity, it is what git log attributes agent commits to — which is
worth knowing while choosing it.
Verified both paths: this host's existing identity is shown and left alone by
default, and a fresh account gets a correct .gitconfig owned by that account with
defaultBranch master.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
apt only. It is a Debian and Ubuntu package — dnf's equivalent is dnf-automatic
and pacman has no equivalent at all — so it is not a name to translate across the
other lists.
Installing the package is not by itself enough to switch it on. The apt-daily
timers read /etc/apt/apt.conf.d/20auto-upgrades, and on this host no package owns
that file: `dpkg -S` says it came from nothing, which means the original script
wrote it. So the section checks for it and offers to write it, rather than
assuming the install did.
Beyond that it only reports, because the interesting facts about unattended
upgrades are not whether it installed:
It never reboots on its own, deliberately. A kernel or libc update is installed
and then not used, and the machine keeps running the old one until it restarts.
Nothing announces that except /var/run/reboot-required, which nobody reads. The
section prints it, names the packages waiting, and puts it in the summary — it
is the failure people do not notice for months.
Ubuntu's Allowed-Origins includes plain ${distro_codename} as well as
-security, so this takes ordinary updates too, not only security ones.
Verified both paths: this host reports enabled with no reboot pending, and
pointing AUTO_UPGRADES at a temp file exercises the enable path and writes the
three periodic settings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit hedged on the ban policy because I thought fail2ban was not
installed here. It is — the earlier ubuntu-setup run installed it — so the claim
could be checked rather than remembered.
Checked, and it was right: /etc/fail2ban/jail.d/defaults-debian.conf ships
`[sshd] enabled = true`, and the running jail reports maxretry 5, findtime 600,
bantime 600. This host has banned 6 addresses already.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>