fafc1adbfd45c3810d2f4d6f25c346cdeda8f7ad
83
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
22a4c65776 |
sharp is a declared dependency, and setup checks both it and ffmpeg
sharp does the images now; ffmpeg keeps the video. each does the job it is for — sharp is libvips in-process, no process to spawn and no pipe, which is an order of magnitude less work for the common case of a folder of photographs. a video has no frame until something decodes one, and that is ffmpeg's and nobody else's. added through the procedure bunfig.toml describes, since there is no --no-frozen-lockfile flag in bun 1.3.14: package.json edited, frozenLockfile commented out for ONE command, lockfile diff read, setting put back, and a frozen install confirmed clean afterwards. WHAT THE LOCKFILE DIFF ACTUALLY SAID, because the friction is only worth anything if somebody reads it. everything is inside sharp's tree: sharp 0.33.5 -> 0.35.4 at top level, @img/colour added, and the per-platform libvips binaries moved 1.2.4 -> 1.3.3. two SHARED packages moved with it — semver 7.7.4 -> 7.8.5 and @emnapi/runtime 1.8.1 -> 1.11.3 — both forced by sharp's own ranges, and both previous consumers (next, editorconfig) kept pinned copies of what they had. next keeps its own sharp at 0.33.5 entirely. nothing outside that moved. ^0.35.3 to match what av-club declares. 0.x caret pins the minor, so ^0.33 and ^0.35 do not overlap — the plugin intersection would have resolved two native libvips trees onto one machine for no reason. THE IMAGE LIST GREW: libvips reads HEIC and SVG and ffmpeg does not, and neither is exotic — HEIC is what every recent iPhone writes, and a folder of them drawing generic file icons was the most visible gap this had. `failOn: 'none'` and `limitInputPixels: false` are both deliberate. libvips rejects a file with any warning in it, and a truncated JPEG — an interrupted copy, a phone that died mid-write — is exactly that; refusing it would make this browser stricter than the viewer somebody opens the file with. the pixel ceiling is a DoS guard for an upload endpoint, and these are the owner's own files already on the owner's own disk: a panorama is not an attack. SETUP, both halves: ffmpeg was never in the package list at all, on any of the four package managers, despite this router shelling out to it for subtitles, audio extraction and audio tracks long before thumbnails existed. it worked because every machine set up by hand already had it. now declared. sharp gets a LOAD CHECK beside node-pty's build check, and reported separately from it. same failure shape — an install that succeeds while the native module does not — but the opposite cause: node-pty compiles and can fail to, sharp downloads a libvips binary for this exact platform and arch and can fail to resolve one. a combined "native modules" verdict would send somebody to rebuild the wrong thing. loading it is the only test worth running; a directory proves nothing, and `versions.vips` proves libvips is bound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
52f209c69f |
caldav leaves the platform
calendar and contacts are a plugin now — plugins/caldav, its own repository like every other one. a fresh machine runs five processes instead of seven and gets a calendar by installing one plugin, which is the whole point: the platform stops carrying a python calendar server for people who may not want one. what left: the radicale sidecar, four routers, the panel apps, the dav_app_passwords table, the two screens, the two dock tiles, the permission registry entry, the settings section, and radicale from every package list. three platform things had to change first, each committed on its own: |
||
|
|
8a3047b06b |
fix_ssh_permissions never checked the home directory itself
Asked to look at user provisioning and .ssh dir creation — the recurring pain point was "the .ssh permissions are never right even when I do everything correctly", and tracing it: fix_ssh_permissions() only ever touched ~/.ssh and its contents. sshd's StrictModes (on by default; nothing here turns it off) also refuses authorized_keys if the HOME DIRECTORY is group- or world-writable — not just .ssh. An account created by this script's own adduser call gets a safe 0755 home by default, but one that already existed some other way (cloud-init, a prior useradd, LDAP, or simply an account that was already there) might not, and the script never checked or reported on it. From the user's side that looks exactly like the complaint: .ssh is 0700, the key is present and correctly formatted, and login still fails, with nothing in the script's own output pointing at why. Fixed by extending fix_ssh_permissions() — called on every path that adds or verifies a key — to check the home directory's mode first and clear only the group/other write bits if set (go-w), reported with a warn rather than done silently, since it's genuinely surprising when it fires. Deliberately not forced to a fixed mode: 0755 vs 0750 is a real choice some setups make on purpose, and this only removes the specific bits sshd refuses to see. Verified with two simulations: a 775 home directory gets warned about and corrected to 755 (.ssh and authorized_keys already-wrong modes fixed too, as before); a 750 home directory is left untouched with no warning. The stat call tries GNU's -c first (this step only runs on Linux) with a BSD -f fallback, same pattern officer-setup's layout.sh already uses for the same reason — not a live concern here since MACOS_SKIP keeps this step off macOS entirely, but consistent rather than a call that would silently do nothing if that ever changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2e7fb84454 |
fix three officer-setup bugs found on a general review, unrelated to macOS
env_get() never matched its own file. write_env() always writes OFFICERDEV_-prefixed keys, but env_get() compared against the bare name — verified against a real written .env: `env_get PORT` -> [], `env_get OFFICERDEV_PORT` -> [9000]. Every one of its 5 call sites uses the bare name, so this always came back empty. Two consequences: the "recover what earlier runs already decided" block never recovers anything — its own comment says it exists to fix "Build announced PUBLIC_URL not set on a machine whose .env had been written twenty minutes earlier", and that exact bug was still live — and re-running the Environment step showed the wrong default (9000, tailnet URL) instead of the machine's actual current config. Fixed to check the prefixed key first, falling back to the bare one for an old unprefixed file, matching the runtime's own `OFFICERDEV_X ?? X` priority. pg_password_from_env_file() returned success with an empty string when the compose .env existed but had no POSTGRES_PASSWORD= line. The caller (`pg_compose_exists && PG_EXISTING_PASSWORD="$(pg_password_from_env_file)"`) would then treat that as "reuse this password" and build POSTGRES_URL with a blank credential rather than falling through to provision fresh. Only reachable via a hand-edited or corrupted file — write_pg_compose always writes the line — but "silently reuse a blank password" was the wrong direction to fail in. Now returns 1 when the line is missing or empty. SETUP_DOCKER_NETWORK was read independently by both scripts, each defaulting to "services", but never actually persisted across the machine-setup -> officer-setup boundary — not one of the three keys load_machine_answers recognised. A customised bridge name from machine-setup would silently not carry over, and Proxy would land back on "services" regardless of what Docker was actually told to use. Now saved by machine-setup's save_answers(), recognised by officer-setup's load_machine_answers(), and PROXY_NET is re-derived from it right after that load — proxy.sh's own top-level assignment runs at source time, before the answers file could have populated the variable, same ordering issue ENV_PORT/ENV_PUBLIC_URL/POSTGRES_URL already had to solve. All three verified with isolated reproductions before and after: env_get against a real prefixed .env and a legacy bare one, pg_password_from_env_file against a normal and a corrupted compose .env, and the full save/load/re-derive chain for SETUP_DOCKER_NETWORK. bash -n clean on every touched file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5dc9f2d5f7 |
neovim had no macOS install path at all — it always tried the Linux tarball
Asked to check how the script installs Neovim on Mac, and the answer was: it doesn't. nvim_install() had zero `$OS` branching, so on macOS it unconditionally fetched nvim-linux-<arch>.tar.gz — a URL that 404s there. Not a hard crash: the caller's `if nvim_install; then … else warn …` catches it and reports "Neovim: install failed" rather than aborting the run, which is exactly the shape that's easy to miss in a long scrollback. brew has a current, real neovim formula (0.12.5), so that's the macOS branch — no keg-only linking needed, unlike the Postgres client fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d02aac1eb8 |
drop htop from the core package list, unused next to btop
Nothing references it — btop is the one the System Monitor app actually embeds (docs/navigation-audit.md, /system-monitor/btop). htop was just sitting beside it on all four package managers with no caller anywhere, and its own first-run message asks for sudo to be useful at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e37fe8330e |
skip radicale and officer-caldav on a macOS dev install, not the platform
Hit live: `brew install radicale` fails outright, "No available formula with the name radicale" — it is a pure-Python package (pip/pipx), not something Homebrew carries, unlike every other name in that list. apt/pacman/dnf all package it for real and are untouched. Narrower than removing the feature: officer-caldav (Calendar/Contacts) stays CORE on Linux. On macOS the sidecar has nowhere to find Radicale — resolveRadicale() failing is exactly the "took a whole sidecar down on a fresh install" case its own test already guards against — so CORE_PROCESSES leaves it out on Darwin rather than let it crash-loop. Calendar/Contacts is the one core feature not available on a Mac dev install; nothing else changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
681df1c8bc |
make machine-setup.sh and officer-setup.sh actually run on macOS
Both scripts had real macOS branches already — MACOS_SKIP, Xcode CLT, Docker Desktop
detection — but neither had ever run past its own library load on a real Mac, and
verifying that live is what this commit is. This machine is the Mac.
The root cause was the interpreter, not missing OS coverage: macOS's stock /bin/bash is
3.2.57 (Apple stopped shipping newer once bash went GPLv3) and this machine has no newer
one installed. Both scripts used bash 4+/4.3+ syntax with zero version guard, so a real
run died in seconds — declare -A (base.sh, associative array), local -n (packages.sh,
nameref), mapfile (5 sites), ${var,,} (both scripts' own step() — confirmed --only killed
the ENTIRE run), and machine-setup.sh's own pre-flight hard-refused brew outright ("this
script does not implement yet") even once the bash-4 crash was fixed. Each got rewritten
to its bash-3.2-safe equivalent rather than requiring a newer bash and re-executing under
it — nine call sites, a bounded fix, and one less thing that can go wrong before the
script can report what's wrong.
Past that gate, a live probe run (real account/path prompts, killed before Database/
Services could touch this machine's actual state) reproduced a second bug in the first
few seconds: resolve_user_home used getent (Linux/glibc, absent on macOS) with a
hardcoded /home/<user> fallback, so Layout tried `install -d /home/pastilhas` and failed
with "Operation not supported". Fixed with the portable `eval echo ~user` tilde-expansion
idiom, re-ran the same probe, and it correctly created /Users/pastilhas/officerdev/ and
started a real git clone.
Everything else follows the same shape — an unguarded Linux-only tool with a portable or
brew-based macOS equivalent:
- grep -oP (4 sites: both --list flags, env.sh's tailnet lookup, build.sh's schema
table count) — BSD grep has no -P at all. Portable ERE+sed where regex was enough;
jq where the input was already JSON (tailnet lookup) — jq is already an unconditional
dependency elsewhere in this script, so that's a correctness fix, not just a port.
- ss (postgres.sh, proxy.sh — is anything listening on this port, and is it bound wide
enough for NPM's container to reach) — both silently mis-reported on macOS, since the
"command not found" was swallowed by 2>/dev/null and read as "nothing's listening".
New shared `listening_addrs()` in officer-setup's base.sh, branching to lsof.
- getent hosts (proxy.sh's DNS-wait loop) — same silent-failure shape, would have spun
its full 30-minute timeout regardless of whether the record actually resolved. macOS
branch uses dscacheutil.
- stat -c (layout.sh's ownership check) — BSD stat has no -c, confirmed live ("illegal
option -- c"), and the failure was VISIBLE and made every layout directory report as
wrong-owner. Portable file_owner() tries -c then falls back to -f %Su.
- apt-only Postgres client, NodeSource's Debian-only setup script, lazydocker/lazygit's
installers (one literally named install_update_linux.sh, the other hardcoding
"_Linux_" into the release asset URL) — each gets a brew branch, formula names
verified against the live Homebrew catalog rather than assumed.
- sudo -u "$USERNAME" to become the SAME account the process already is — real on
Linux, where this whole script runs as root and dropping to the owner needs no
password; on macOS the process never escalates in the first place, so re-invoking
sudo for yourself asks for a real password on every call, or fails outright without
sudo rights. machine-setup's dev.sh had ~10 inline call sites (git, bun, deno, claude,
opencode, editor detection) rerouted through a new shared run_as_owner(); officer-
setup's own as_owner() (already the shared entry point there) got the same branch,
and six direct sudo call sites in build.sh/secrets.sh/services.sh that bypassed it
were rerouted through it too.
Three things stay Linux-only rather than getting an unverified macOS implementation,
each skipped with a stated reason at the point it would otherwise run: pm2's boot-
persistence hook (needs a launchd plist generated and loaded, not a systemd unit
printed and eval'd — real, separate work), the Tailscale exit-node NIC/sysctl tuning
(no macOS equivalent concept, and advertising a laptop as an exit node is already the
edge case MACOS_SKIP treats it as), and ufw's bridge-to-host firewall rule (pf is a
different rule language entirely, and Docker Desktop's own VM-based networking may not
even have the problem this rule exists to solve — guessing at a firewall rule felt like
the wrong place to be wrong).
Verified: both --list flags print step names instead of a grep usage error. A bounded
officer-setup.sh run (killed before Database) resolved the real macOS home directory,
created the layout with correct ownership, and began a real git clone — cleaned up
after (rm -rf the test directory, the gitignored .setup-progress file, the stray
install-report). machine-setup.sh's pre-flight now reports "Packages: brew" and reaches
the account/path prompts instead of refusing the OS outright. bash -n clean on all 16
files under bash 3.2, the actual interpreter these scripts run under on a Mac.
Not run further live: Database, Secrets, Services and Proxy would mutate this machine's
real state (a live Postgres, a live set of pm2 processes this session runs through) and
were left for a deliberate, supervised run rather than folded into validation.
|
||
|
|
33e8e70e06 |
inotify-tools in the core package set
`inotifywait` is what lets a commander react the moment a file lands instead of polling for it, and the commander template now tells them to use it. it was on this machine by accident and missing on alpha, which is exactly the shape of thing that works for whoever wrote it and fails for everyone else. all three Linux package managers. macOS has no equivalent in these sets, so a commander there polls — stated in the comment rather than left to be discovered. installed on alpha by hand as well, since setup is not re-run on a live host for one package. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
879edbdfb0 |
boot hang: "is networkd running" was the wrong question
This section did nothing on the machine it was written for, and the machine was losing two minutes on every boot the whole time. Both stacks can be ACTIVE at once. Here NetworkManager owned enp1s0 and wlp2s0, systemd-networkd was up and managing not one of eighty links, and systemd-networkd-wait-online still burned its full timeout — the journal has `Timeout occurred while waiting for network connectivity`, 18:31:05 to 18:33:05. The old predicate required networkd to be INACTIVE, so it reported "nothing to fix — networkd is in charge" and moved on. What decides it is whether networkd CONFIGURED a link, which networkctl will say: a link it owns reads `configured`, one it ignores reads `unmanaged`. If every link is unmanaged the wait can never be satisfied, and masking is safe. A missing networkctl counts as "yes it does". An unanswerable question must not read as permission to mask — the failure mode on the other side of this is a VPS that comes up with no network and no ssh. `network_manager_name` had the same blind spot and printed "NetworkManager" for the both-running case, which is exactly the case that was being got wrong. It now describes the machine instead of picking a winner. Verified against this box: configures-a-link no, spurious yes, reported as "NetworkManager (networkd is up but configures nothing)". Masked here by hand; NetworkManager-wait-online still provides network-online.target, so nothing that orders after the network lost its ordering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b |
||
|
|
a356656a60 |
hold bun at the 1.3 series until 1.4 has been watched
bun 1.4.0 was published 2026-08-20 and https://bun.sh/install serves whatever is newest, so machine-setup provisioning a box today would put a two-day-old release under the platform and every pm2 app except officer-pty. The owner asked for a temporary hold on 1.3. The SERIES is pinned, not a patch — BUN_SERIES=1.3, resolved to the newest 1.3.x at install time so bug fixes inside the series still land, and no 1.3.14 to rot into "the version we happened to pick" the way setup_22.x did for Node. The version reaches the installer as its documented first positional argument (a release tag), and install_bun refuses to run with an empty one: taking whatever bun.sh serves is the single outcome the pin exists to prevent, so no bun at all is the better failure. Loud, and officer-setup's pre-flight already stops on a missing bun. Resolved from the npm registry rather than GitHub's releases API, which is the obvious source and the wrong one — 60 unauthenticated requests an hour per IP, and it returned 504 twice while this was written. An empty answer installs nothing, so where it comes from is load-bearing. The section now reads four states instead of two. "Installed" was one answer covering a machine inside the pin, one behind it, and one past it; past it is the case worth catching, since that is what bun upgrade leaves and nothing else reports it. Above and below get different prose and both offer to move; nothing is corrected silently. What a setup script cannot cover: bun upgrade takes no version argument — latest stable or --canary, verified against its own --help — so it always jumps past the pin without saying so. Hence the doctor check, which reads engines.bun rather than repeating the number, because a second hardcoded copy is a second thing to drift. bun ignores engines (verified: an install declaring 1.9.x ran clean under 1.3.14), so the field is the declaration and doctor is the enforcement. Confirmed bun install --dry-run still resolves frozen with the field present and bun.lock unchanged. This host is already on 1.3.14 and inside the pin, so nothing moved here. Also fixes three more stale process counts in the same block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9c309c5ab6 |
calendar and contacts are core again, with radicale as a plain sidecar
Reverses the 2026-08-13 decision to make caldav an app-store entry. Three reasons it does not belong there. It is not an integration. Photos-via-Immich or torrents-via-Transmission assume you already run the thing; a calendar assumes nothing, and a personal server without one is missing a baseline rather than an optional app. It needs TWO top-level routes, /calendar and /contacts, and a plugin gets one — derived from its appName, which is what makes it structurally impossible for a plugin to shadow a core route. Nobody thinks "I'll open my dav", so collapsing them under /dav was the wrong trade, and weakening the namespace guarantee to allow two was a worse one. As core routes the question does not arise. And it never actually needed Docker. Radicale is a ~2MB Python package; sidecar/caldav/radicale.ts already spawns it directly on loopback with http_x_remote_user. The container came from the app store making everything a container, not from anything Radicale requires. So: the routers are mounted again (/caldav for the UI's JSON door, /dav for app passwords, the top-level /dav sync door and the two .well-known redirects), officer-caldav joins CORE_PROCESSES, radicale joins the machine-setup package list for all four package managers, and the catalogue entry is gone. members.test.ts re-anchored a fifth time — caldav was one of two `accounts` services and memos is now the last. Noted in the test that the sixth time should be a literal fixture instead. Verified: assertPermissionTotality accepts /caldav and /dav, tsgo clean, 1085 pass. NOT yet verified against a real client — radicale is not installed on this machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e8eea2b677 |
setup installs poppler, or an attached PDF cannot be read
Reading a PDF attachment needs `pdftoppm`. It was not installed on this machine and is not in any package list, so the first real PDF attachment failed at the last step — uploaded fine, landed on disk fine, unreadable. That failure reads as the feature being broken rather than a package being absent, which is the worst shape for something a fresh install would hit on its first try. Added to all four managers: `poppler-utils` on apt and dnf, `poppler` on pacman and brew. Worth recording WHY it is needed, because it is not obvious: the agent reads a PDF by RENDERING its pages, not by extracting text. On the document that prompted this — a sitemap of tree diagrams and wireframes — extraction returns almost nothing and rendering returns everything. The dependency is on the approach that works. Installed on this machine by hand already; this is so the next one has it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0230e62a71 |
the platform stops explaining what Headscale is
Correcting my own reasoning from the last commit. I left "Headscale's API", "the Headscale admin credential" and similar alone on the grounds that they name the thing the plugin drives, so renaming them would make the sentences false. True as far as it goes, and it misses the point: those sentences are in the PLATFORM. It has no business knowing what Headscale is at all. Correct language in the wrong repository is still a remnant. Ten of them, each replaced with the platform's own vocabulary rather than the plugin's domain: - `crypto.ts` and `secret-store.ts` listed "the Headscale admin credential" among what one key used to protect. It is "the tailnet's admin credential" — the purpose, which is a word this repository owns. - `catalogue.ts` and `availability.ts` explained `alsoServes` — a LIVE mechanism — entirely through a Headscale example that left the repository. Restated as the general shape, with one parenthesis saying which case produced it. - The photos types file explained another plugin's sidecar. - `App.tsx` cited `/headscale/:section` as the example of a route pair; that route does not exist. `/photos/:section` does. - `plugins/router.ts` pointed at `useCompanionLogStream`, a hook that lives in a plugin now. - `registry.ts` said in the present tense that `headscale` is `admin` — the permission is the plugin's and it declares its own, so a member-facing enrolment surface would be the PLUGIN's to add, not this file's. - `proxy.sh` cited `headscale/schema.ts` as a style reference. That file is not in this repository. - machine-setup said "Its Headscale app" — the app is the Offscale plugin. `machine-setup/lib/tailscale.sh` keeps all ten of its mentions: it exists to explain tailscale, headscale and offscale to somebody choosing between them, on a core install, before any plugin exists. That is machine setup's subject, not the plugin's domain leaking upward. What survives elsewhere is only dated history — "was here until 2026-08-15", "It was: music, vault, slskd, headscale, …" — plus the notes about the real `headscale_servers` orphan and the secret-store key removed yesterday. Those record that something called headscale used to be here, which is a different claim from explaining what it does. tsgo clean, frontend builds, 873 pass / 7 fail unchanged, setup scripts parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0701aba902 |
brotli in the core package set
installed on this host already; verified round-tripping from a member shell. same package name on apt, pacman and dnf. on brew it is there because macOS ships the library but not the CLI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1d95ad3d1b |
install the rootless docker prerequisites with docker itself
Reported from a member's daemon failing: "rootless Docker needs these packages on the host: uidmap". They were being installed — but only inside branch [2] "rootless Docker for <owner>" in section 22. The owner's choice is not the only one that matters: every Developer account the platform provisions gets its own rootless daemon whatever the owner picked for themselves. So on a machine where the owner chose the docker group, the host never got them and every member's daemon failed. Moved into install_docker_engine, so they arrive with Docker rather than with one particular answer to a question about the owner. Three packages, not the one in the error. checkDockerPrerequisites in os-user-docker.ts is the authority and wants uidmap (newuidmap, newgidmap) AND docker-ce-rootless-extras (dockerd-rootless-setuptool.sh); dbus-user-session is what keeps a member's systemd --user alive without a login session. rootless-extras is only RECOMMENDED by docker-ce — installed by default, so usually there by luck, and absent on any host configured with --no-install-recommends. Named explicitly. Reproduced on this machine while checking: rootless-extras present via Recommends, uidmap absent, newuidmap and newgidmap missing. Exactly the reported failure, on a box that chose the docker group. The rootless branch still installs uidmap and dbus-user-session behind its pkg_is_installed guard. Redundant now, kept deliberately: it is the only thing that fixes a machine whose Docker was installed by an older run of this script. Verified: bash -n on both files, and all three packages present in the noble archive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3862558b92 |
real aliases for the owner, and eza to go with them
The owner's `aliases` block was one line — `alias sz`. Members got a full set from shell-skel/zshrc and the owner got that. Replaced with the eza ls family, the oh-my-zsh standards, and n/vim/sz/ld/httpserver. eza added to all four core package lists. It is in the noble archive at 0.18.2-1, so this is a package rather than a binary fetch, and Core utils is section 5 — well before Shell at 26, so `command -v eza` is already true when the block is written. The eza aliases are GUARDED behind `command -v eza` and the rest are not, and the asymmetry is deliberate: these replace `ls`. Unguarded, a machine where eza failed to install has no working `ls` in any new shell, which reads as a broken machine rather than a missing package. `alias ld=lazydocker` without lazydocker is one command-not-found when you type it — that can degrade honestly. Same principle shell-skel/zshrc already holds to. python3, not python, for httpserver: Ubuntu ships no `python` binary at all, so as given it would have been a command-not-found on every machine this targets. Checked the editor block first — it only exports EDITOR/VISUAL/SUDO_EDITOR, so n and vim do not collide with anything already appended. KNOWN: append_once returns 1 when its marker is already present, so a machine that has already run this keeps the old one-line block and gets none of the above. That is the function working as designed — it exists so a second run does not duplicate its work, and it cannot tell a stale block from one the owner edited. Fix by hand: delete the `# >>> machine-setup: aliases >>>` block from ~/.zshrc and re-run `machine-setup.sh --only Shell`. Verified: bash -n, zsh -n on the block, the eza guard leaving ls unset when eza is absent, and vim resolving through n to nvim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4d14e11f6c |
--unattended: every question that has a default answers itself
51 yes/no prompts and ~20 free-text ones, of which about six actually need a human.
The line drawn is "a question with a default answers itself; a question with no
possible default still asks", so it stays attended without being a conversation.
Half of it already existed: ASSUME_YES=1 was implemented and honoured by confirm()
in both scripts, returning each question's OWN default — so a "do the thing you
asked for" question goes yes and a genuine extra goes no. --unattended sets it.
The new part is menu_answer(), for the eight numbered menus. It sets the variable
EMPTY rather than passing a default in, because every menu already consumes its
choice as `${CHOICE:-<n>}` — the default lives next to the options it selects
between, which is the right place, and a second copy in the helper could drift from
the one the prompt advertises. Verified all eight consume that way before touching
them. `read <<<''` rather than eval or `declare -g`, which is bash 4.2+ and rules
out the bash 3.2 macOS still ships.
officer-setup's ask_required takes its default too, except where there is none — the
owning account on a machine machine-setup never ran on, where a guess would install
as the wrong user.
STILL ASKS, deliberately: the username; the Tailscale control plane, login server
and auth key; the git identity; and an SSH public key when the account has none.
That last one is a trap I nearly walked into — on a fresh VPS KEY_COUNT==0 forces
ADD_KEY=true with no confirm, and the menu's default is "[1] paste a public key",
which then prompts with no default at all. Auto-answering that menu would hang or
fail, so it is excluded by name. adduser also still asks for a password; that is
the tool, not us.
Two pre-existing bugs fixed on the way: machine-setup's sudo re-exec passed "$@"
after `shift` had emptied it, so --only and --reask stopped existing the moment it
escalated — same bug as officer-setup had. And UNATTENDED/ASSUME_YES are named in
all three sudo lists, because env_reset would otherwise drop the flag at
escalation, which is now the fourth variable lost that way.
Verified: bash -n on five files, --help on all three, and menu_answer + confirm
under the flag showing a menu resolving to its default and a no-default confirm
correctly answering no.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
4c33ef7206 |
fix the silent death after installing zsh
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> |
||
|
|
081920c61f |
drop fastfetch from machine-setup
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> |
||
|
|
77f1284925 |
install report: first cut, generated by the helpers
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> |
||
|
|
61a3ae720f |
install the tmux config where tmux actually reads it
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> |
||
|
|
b075f1f882 |
macOS is a dev machine, and machine-setup now treats it as one
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> |
||
|
|
f063fc0c08 |
remove origin validation
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> |
||
|
|
e120dfa36e |
full read: fix the set -e footguns a full run would have hit
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> |
||
|
|
30052e3295 |
port the firewall, last, and bind the Docker rules to the real interface
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> |
||
|
|
d6a78d7b5a |
put the shell configuration in one place, after everything it configures
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> |
||
|
|
607a962115 |
port the agent CLIs, using Anthropic's installer rather than npm
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> |
||
|
|
cb3e7062b9 |
ensure the bun symlink on every run, not only after installing it
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> |
||
|
|
00e58931ff |
port the JS runtimes: node, bun and pm2 installed rather than offered
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>
|
||
|
|
068a310bd3 |
add python3 to core utils — node-gyp needs it, and node-gyp is not optional
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> |
||
|
|
0286cc6db6 |
port the Neovim section
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> |
||
|
|
2e70a6dd21 |
ask before replacing a config the user already has, and show the difference
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> |
||
|
|
2b9e16c11e |
port the shell section, and make one starship config serve both audiences
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> |
||
|
|
4fcc34de18 |
frame the public server as common to all three, not a cost of offscale
"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> |
||
|
|
a989f8fbfa |
say where offscale runs, which matters more than how it installs
"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> |
||
|
|
c629a849d9 |
make all four network options work, including having no network at all
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>
|
||
|
|
06492c3297 |
name the control server explicitly, and log out before moving between them
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> |
||
|
|
22a661131d |
page the ? output
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> |
||
|
|
85d8fa62f1 |
mention that Officer administers the network it just told you to set up
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> |
||
|
|
766c8ee655 |
say what the mobile app actually is, rather than overclaiming it
"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> |
||
|
|
2d544db07e |
write the offscale copy from officer.dev, and link it in both places
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> |
||
|
|
46ce58dd12 |
correct the client claim: the mobile apps are ours
"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> |
||
|
|
2803bc34b7 |
draft the offscale copy: same protocol, different amount of work
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> |
||
|
|
538a2d3b6d |
give every network option its own exposition, not just offscale
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> |
||
|
|
ff7035a47a |
correct the mechanism recorded for the set -e failure
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> |
||
|
|
4d478cc0f1 |
add the offscale line, and fix a set -e bug the test exposed
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>
|
||
|
|
6a29c39b74 |
restructure the Tailscale network choice, with offscale left to be written
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> |
||
|
|
059f0f6df2 |
lead the Tailscale section with the question, not the explanation
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> |
||
|
|
a63a327065 |
remember the pre-flight answers between runs
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> |