Files
platform/scripts/setup/machine-setup/lib/dev.sh
T
pastilhasandClaude Fable 5 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>
2026-08-22 18:45:26 +00:00

485 lines
21 KiB
Bash

#!/bin/bash
# =============================================================================
# machine-setup — the development environment
# =============================================================================
#
# Definitions only, like the other lib/ files.
[[ -n "${MACHINE_SETUP_DEV_LOADED:-}" ]] && return 0
MACHINE_SETUP_DEV_LOADED=1
# -----------------------------------------------------------------------------
# git
# -----------------------------------------------------------------------------
#
# Read and written as the account, not as root. `git config --global` writes to
# $HOME/.gitconfig, so running it under sudo without -H would write root's.
#
# ── Why this asks before touching an existing identity ──
#
# The original set all four values unconditionally on every run. Re-running it on
# a machine somebody already uses replaces the name and email they had with
# whatever is typed — and prompt_value accepts an empty answer, so pressing
# Enter twice wrote `user.name = ""`. An empty name is worse than none at all:
# unset makes git refuse to commit and say why, empty makes it commit with a
# blank author and never mention it.
#
# ── And why it is worth being careful about here in particular ──
#
# docs/agent-git-identity.md: every agent Officer runs commits AS THE OWNER,
# because it runs as the owner. So this is not only the human's identity — it is
# what `git log` will attribute every agent commit on this machine to.
# Run from / rather than wherever the script was launched.
#
# `git config --global` reads and writes $HOME/.gitconfig and needs no repository
# — but git still stats the working directory on the way, looking for one. The
# script is typically launched from somewhere under the invoking user's home,
# which is 0750, so the target account cannot stat it and every call dies with
#
# fatal: failed to stat '<cwd>': Permission denied
#
# Found because the writes failed silently: the section reported "written" while
# nothing had been. Both wrappers now run in a subshell from /, which every
# account can stat, and their exit status is checked by the caller.
# `git config --get` exits NON-ZERO when the key is simply unset, and
# `VAR="$(git_get …)"` propagates that under `set -e`. So on a machine where git
# has never been configured — the fresh machine this script exists for — reading
# the current value aborted the run before the section had printed anything.
# Missing a value is an answer here, not a failure.
git_get() { (cd / && sudo -H -u "$USERNAME" git config --global --get "$1" 2>/dev/null) || true; }
git_set() { (cd / && sudo -H -u "$USERNAME" git config --global "$1" "$2"); }
# Is there anything configured at all?
git_has_identity() { [[ -n "$(git_get user.name)" || -n "$(git_get user.email)" ]]; }
# Ask for a value that must not be empty. The original's prompt accepted empty
# and wrote it; this re-asks.
ask_required() {
local __var="$1" message="$2" default="$3" answer=""
while [[ -z "$answer" ]]; do
if ! read -rp " ${message}${default:+ [$default]}: " answer; then
echo ""
fail "No answer."
fi
answer="${answer:-$default}"
[[ -z "$answer" ]] && warn "This one cannot be left blank."
done
printf -v "$__var" '%s' "$answer"
}
# -----------------------------------------------------------------------------
# Shell
# -----------------------------------------------------------------------------
#
# ── One starship config, not two ──
#
# The platform deploys scripts/setup/starship.toml into every member's home
# (os-user-shell.ts), and the comment there calls it "the prompt config the
# owner's own install uses — one file, both audiences". That was not true: the
# original machine script wrote a DIFFERENT config inline, so the owner got one
# prompt and every member got another. This deploys the same file the platform
# does, which makes the comment true rather than aspirational.
#
# It lives one directory up because it is shared with the platform, not owned by
# this script.
STARSHIP_SRC="${STARSHIP_SRC:-$SCRIPT_DIR/../starship.toml}"
user_login_shell() { getent passwd "$USERNAME" | cut -d: -f7; }
oh_my_zsh_installed() { [[ -d "${USER_HOME}/.oh-my-zsh" ]]; }
install_oh_my_zsh() {
# The installer refuses to run unattended over an existing install, so this is
# only ever called when there is none.
#
# ── `|| true` is what makes this non-fatal, NOT the `return 0` below ──
#
# It used to be `return 0` alone, with a comment claiming the function returned
# zero whatever happened. 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` is never reached. So a machine where this curl or the installer
# failed died here, silently, because the output is redirected: the run just
# stopped after apt finished installing zsh, with nothing said. Observed on a
# fresh Hetzner VPS, 2026-08-14.
sudo -H -u "$USERNAME" sh -c \
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended >/dev/null 2>&1 ||
true
# Belt and braces: `|| true` above already makes the last command succeed, and
# this states the contract for anyone adding a line beneath it.
return 0
}
# `chsh` is what actually changes the login shell. Asked separately from
# installing zsh, because having a shell available and being handed it at every
# login are different decisions.
# Reports whether chsh worked, rather than swallowing it. The same `set -e` trap as
# install_oh_my_zsh applies — a bare `chsh` that fails kills the run at this line —
# but here the answer matters: the caller announces the new login shell, and `|| true`
# would have it announce one that was never set. So the status comes back and the
# CALLER guards the call, which is also what keeps set -e out of it.
set_login_shell() {
local shell="$1"
grep -qxF "$shell" /etc/shells || echo "$shell" >>/etc/shells
chsh -s "$shell" "$USERNAME" >/dev/null 2>&1
}
# -----------------------------------------------------------------------------
# Neovim
# -----------------------------------------------------------------------------
#
# From the upstream tarball rather than the distribution, which ships Neovim
# years behind — Ubuntu 24.04 has 0.9 where upstream is on 0.12, and LazyVim
# requires 0.9+ with most plugins wanting newer.
#
# The asset names are x86_64 and arm64. The original mapped aarch64 to
# "aarch64", which is not a name Neovim publishes: on an arm machine it
# downloaded a 404 and tar failed on the HTML error page.
nvim_asset() {
case "$ARCH" in
amd64) echo x86_64 ;;
arm64) echo arm64 ;;
esac
}
nvim_installed_version() { nvim --version 2>/dev/null | awk 'NR == 1 { print $2 }'; }
nvim_latest_version() {
curl -fsSL https://api.github.com/repos/neovim/neovim/releases/latest 2>/dev/null |
jq -r '.tag_name // empty'
}
# Downloaded to /tmp, not to whatever directory the script was launched from —
# the original used `curl -LO`, which drops the tarball beside the script and
# leaves it there if tar fails.
#
# The old install is removed only after the download has succeeded, so a failed
# fetch leaves the working copy alone.
nvim_install() {
local asset tarball dest
asset="$(nvim_asset)"
tarball="/tmp/nvim-linux-${asset}.tar.gz"
dest="/opt/nvim-linux-${asset}"
curl -fsSL -o "$tarball" \
"https://github.com/neovim/neovim/releases/latest/download/nvim-linux-${asset}.tar.gz" || return 1
# A 404 comes back as an HTML page, and tar's failure on it is unhelpful.
# Checking here names the real problem.
tar -tzf "$tarball" >/dev/null 2>&1 || {
rm -f "$tarball"
warn "the download is not a tarball — the release asset may have been renamed"
return 1
}
rm -rf "$dest"
tar -C /opt -xzf "$tarball"
rm -f "$tarball"
ln -sf "${dest}/bin/nvim" /usr/local/bin/nvim
}
# Clone a Neovim config into the account's ~/.config/nvim.
#
# From `cd /` for the same 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, and git fails there before it does anything useful.
nvim_clone_config() {
local repo="$1" dest="${USER_HOME}/.config/nvim"
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "${USER_HOME}/.config"
(cd / && sudo -H -u "$USERNAME" git clone --depth 1 "$repo" "$dest" >/dev/null 2>&1) || return 1
# The starter is a template, not something to track. Left in place for a
# config of the user's own, which they will want to keep pulling.
[[ "$repo" == *LazyVim/starter* ]] && sudo -u "$USERNAME" rm -rf "${dest}/.git"
return 0
}
# -----------------------------------------------------------------------------
# JavaScript runtimes
# -----------------------------------------------------------------------------
#
# Three of these are not optional, and it is worth being precise about why,
# because "we run on Bun" suggests Node could go and it cannot:
#
# node pm2 is a Node application (#!/usr/bin/env node), and pm2 supervises
# every process here. officer-pty imports node-pty, a native addon with
# no Linux prebuild — it compiles against the installed Node on every
# machine. Either one alone makes Node load-bearing.
# bun the platform itself, and every pm2 app except officer-pty.
# pm2 the process manager the ecosystem files are written for.
#
# Deno is not. Nothing in the platform imports it — checked across the whole
# tree — and it is offered only because it was in the original script and
# somebody may still want it.
# The current LTS major, asked of nodejs.org rather than hardcoded. The original
# pinned setup_22.x, which ages into "the version we happened to pick" the moment
# a new LTS lands.
node_lts_major() {
curl -fsSL https://nodejs.org/dist/index.json 2>/dev/null |
jq -r '[.[] | select(.lts != false)][0].version // empty' | sed 's/^v//; s/\..*//'
}
node_lts_label() {
curl -fsSL https://nodejs.org/dist/index.json 2>/dev/null |
jq -r '[.[] | select(.lts != false)][0] | "\(.version) (\(.lts))" // empty'
}
node_installed_major() { node -v 2>/dev/null | sed 's/^v//; s/\..*//'; }
install_node() {
local major="$1"
# NodeSource publishes one setup script per major. Checked before it is piped
# into a shell, because a 404 page piped to bash is a confusing way to fail.
curl -fsS -o /dev/null "https://deb.nodesource.com/setup_${major}.x" || {
warn "NodeSource has no setup script for Node ${major}"
return 1
}
curl -fsSL "https://deb.nodesource.com/setup_${major}.x" | bash - >/dev/null 2>&1
pkg_install_now nodejs
# Global installs land in /usr/local rather than in a path only root can write,
# so `npm i -g` works the same for the owner and for root.
npm config set prefix /usr/local >/dev/null 2>&1 || true
}
# ── Held at the 1.3 series, deliberately and temporarily ──
#
# bun 1.4.0 landed on 2026-08-20 and https://bun.sh/install takes whatever is
# newest, so a machine provisioned two days later puts a brand-new release under
# the platform and every pm2 app it runs. Held at 1.3 until 1.4 has been watched
# for a while. That is the owner's call and it is meant to be revisited in a few
# months, not a permanent ceiling — raise BUN_SERIES when 1.4 has earned it.
#
# The SERIES is pinned, not a patch. 1.3.x fixes still land and should still be
# taken; a hardcoded 1.3.14 would rot into "the version we happened to pick"
# exactly the way setup_22.x did for Node.
#
# This covers the INSTALL path only, and cannot cover the other one: `bun upgrade`
# takes no version argument — checked against `bun upgrade --help` on 1.3.14, which
# offers latest-stable or --canary and nothing else — so it always jumps to the
# newest release and will walk a pinned machine onto 1.4 on its own. Nothing in a
# setup script can prevent that. `bun run doctor` is where that drift is caught.
BUN_SERIES="${BUN_SERIES:-1.3}"
# The newest stable release inside the series.
#
# Asked of the npm registry rather than GitHub's releases API, which is the obvious
# source and the wrong one: it rate-limits unauthenticated callers to 60 an hour per
# IP, and it returned 504 twice while this was being written. An empty answer here
# means no bun gets installed at all (see install_bun), so where it comes from is
# load-bearing rather than a matter of taste. bun publishes every release to npm.
#
# `sort -V` rather than trusting the order the registry returns them in, and the
# pattern is anchored so 1.3.1 cannot be mistaken for the newest when 1.3.14 exists.
bun_series_latest() {
curl -fsSL https://registry.npmjs.org/bun 2>/dev/null |
jq -r '.versions | keys[]' 2>/dev/null |
grep -E "^${BUN_SERIES//./\\.}\.[0-9]+$" |
sort -V | tail -1
}
# The major.minor of a full version — "1.3.14" is in series "1.3".
bun_series_of() { sed -E 's/^([0-9]+\.[0-9]+).*/\1/' <<<"${1:-}"; }
# Where an installed bun sits relative to the pin: in, above, below, or unknown.
#
# Above and below are different problems and get different answers — above is a
# machine that has upgraded itself past the pin, below is one that is simply old —
# so this reports which rather than a yes/no. `sort -V` does the comparing, so
# nothing here has to parse a version.
bun_series_position() {
local now
now="$(bun_series_of "${1:-}")"
[[ -n "$now" ]] || {
echo unknown
return
}
[[ "$now" == "$BUN_SERIES" ]] && {
echo in
return
}
if [[ "$(printf '%s\n%s\n' "$now" "$BUN_SERIES" | sort -V | tail -1)" == "$now" ]]; then
echo above
else
echo below
fi
}
# Present anywhere: on PATH for this root shell, or in the account's own
# ~/.bun/bin, which is where the installer puts it and where root cannot see it.
bun_installed() { command -v bun &>/dev/null || [[ -x "${USER_HOME}/.bun/bin/bun" ]]; }
# Asked of whichever copy exists. Before the symlink is made, root's PATH has no
# bun at all, so `bun --version` reports nothing on a machine that plainly has it.
bun_version() {
if command -v bun &>/dev/null; then
bun --version
elif [[ -x "${USER_HOME}/.bun/bin/bun" ]]; then
"${USER_HOME}/.bun/bin/bun" --version
fi
}
# The system-wide link, ensured on every run rather than only after an install.
#
# pm2 started at boot by systemd has no login shell, so ~/.bun/bin is not on its
# PATH — and every ecosystem app that says `script: 'bun'` then fails to start on
# reboot while working perfectly when started by hand. A
# machine that already had bun before this script ran would never get the link if
# it were only made as part of installing.
#
# Safe across upgrades: a symlink resolves by path, and `bun upgrade` replaces
# the file at $BUN_INSTALL/bin/bun rather than moving it. The link only breaks if
# the home directory goes, which breaks bun anyway.
ensure_bun_symlink() {
local bin="${USER_HOME}/.bun/bin/bun"
[[ -x "$bin" ]] || return 1
[[ "$(readlink -f /usr/local/bin/bun 2>/dev/null)" == "$(readlink -f "$bin")" ]] && return 1
ln -sf "$bin" /usr/local/bin/bun
return 0
}
# Installed as the account, then symlinked system-wide. 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 to start on reboot and works fine when
# started by hand, which is a miserable thing to debug.
#
# The version goes in as the installer's first positional argument, which it
# documents as "a specific tag of bun to install (e.g. bun-v0.1.4)" and turns into a
# releases/download/<tag>/ URL. `bash -s` is what carries an argument through a pipe.
# The installer handles being pointed at an older tag on purpose — it re-probes AVX2
# rather than assuming the baseline the newest release was built for — so this both
# installs and pins back down.
#
# It REFUSES to run unpinned. An empty tag means bun_series_latest could not reach
# the registry, and installing whatever is newest is the single outcome the pin
# exists to prevent, so a machine with no bun is the better failure: it is loud, and
# officer-setup's pre-flight stops on a missing bun rather than building on 1.4.
install_bun() {
local tag="${1:-}"
[[ -n "$tag" ]] || return 1
(cd / && sudo -H -u "$USERNAME" bash -c "curl -fsSL https://bun.sh/install | bash -s '${tag}'") >/dev/null 2>&1
[[ -x "${USER_HOME}/.bun/bin/bun" ]]
}
pm2_installed() { command -v pm2 &>/dev/null; }
install_pm2() { npm install -g pm2 >/dev/null 2>&1; }
deno_installed() { command -v deno &>/dev/null || [[ -x "${USER_HOME}/.deno/bin/deno" ]]; }
install_deno() {
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://deno.land/install.sh | sh') >/dev/null 2>&1
[[ -x "${USER_HOME}/.deno/bin/deno" ]]
}
# -----------------------------------------------------------------------------
# Agent CLIs
# -----------------------------------------------------------------------------
#
# Claude Code goes in through Anthropic's own installer rather than npm, matching
# what the platform does for members (os-user-claude.ts) and chosen there for the
# auto-update the npm package does not do.
#
# Two things that installer insists on, both of which a naive port gets wrong:
#
# It REFUSES to run under sudo from a regular user's shell — it checks for uid 0
# with SUDO_USER set, because everything it installs goes under $HOME and under
# sudo that is root's home. So it must run AS the account, not as root.
#
# It declares #!/bin/bash and uses [[ … =~ … ]], so it must be piped to bash.
# `| sh` fails on a dash-based /bin/sh, which is Ubuntu's.
#
# Both are recorded in os-user-claude.ts too, which found them first.
CLAUDE_INSTALL_URL="https://claude.ai/install.sh"
OPENCODE_INSTALL_URL="https://opencode.ai/install"
# Where each installer actually puts its binary. They disagree, and the platform
# depends on the difference:
#
# claude ~/.local/bin/claude — claude-manager.ts tries Bun.which then
# that exact path
# opencode ~/.opencode/bin/opencode — sidecar/opencode/index.ts:22 hardcodes
# join(homedir(), '.opencode', 'bin', …)
#
# Looking for opencode in ~/.local/bin, as an earlier version of this did,
# reports a perfectly good install as missing and then installs it again.
agent_bin() {
case "$1" in
claude) echo "${USER_HOME}/.local/bin/claude" ;;
opencode) echo "${USER_HOME}/.opencode/bin/opencode" ;;
*) echo "${USER_HOME}/.local/bin/$1" ;;
esac
}
# The directories those live in, for the account's PATH.
agent_bin_dirs() { echo "${USER_HOME}/.local/bin" "${USER_HOME}/.opencode/bin"; }
agent_installed() { [[ -x "$(agent_bin "$1")" ]] || command -v "$1" &>/dev/null; }
# Which copy answers, so the run can say where it came from. Claude installed
# from npm sits in /usr/local/lib/node_modules and does NOT auto-update, which is
# the whole reason the platform prefers Anthropic's installer.
agent_path() {
local name="$1" bin
bin="$(agent_bin "$name")"
[[ -x "$bin" ]] && {
echo "$bin"
return
}
command -v "$name" 2>/dev/null || true
}
agent_is_npm_install() { [[ "$(readlink -f "$(agent_path "$1")" 2>/dev/null)" == */node_modules/* ]]; }
agent_version() {
local bin
bin="$(agent_path "$1")"
[[ -n "$bin" ]] && (cd / && sudo -H -u "$USERNAME" "$bin" --version 2>/dev/null | head -1)
}
install_claude_code() {
(cd / && sudo -H -u "$USERNAME" bash -c "set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | bash") >/dev/null 2>&1
[[ -x "$(agent_bin claude)" ]]
}
install_opencode() {
(cd / && sudo -H -u "$USERNAME" bash -c "set -e; curl -fsSL ${OPENCODE_INSTALL_URL} | bash") >/dev/null 2>&1
[[ -x "$(agent_bin opencode)" ]]
}
install_pi() { npm install -g @mariozechner/pi-coding-agent >/dev/null 2>&1; }
# -----------------------------------------------------------------------------
# Default editor
# -----------------------------------------------------------------------------
#
# One preference, two mechanisms, and both are needed:
#
# EDITOR / VISUAL what the account's own shell hands to git, crontab -e,
# systemctl edit and anything else that opens an editor
# update-alternatives the system-wide `editor` command, which is what root and
# `sudoedit` use — an account's shell config cannot reach
# those
#
# This is the setting core.editor was deliberately left out in favour of: set it
# here and git follows, along with everything else.
editor_candidates() {
local e
for e in nvim vim nano; do command -v "$e" &>/dev/null && echo "$e"; done
}
# `|| true` for the same reason git_get has it: "not set" is an answer, and an
# assignment from a function that exits non-zero aborts the run under `set -e`.
current_editor() { (cd / && sudo -H -u "$USERNAME" bash -lc 'echo "${EDITOR:-}"' 2>/dev/null) || true; }
set_system_editor() {
local editor="$1" path
path="$(command -v "$editor")" || return 1
# Only where the alternatives system is in use. Absent on non-Debian systems,
# where there is nothing to set.
command -v update-alternatives &>/dev/null || return 0
update-alternatives --install /usr/bin/editor editor "$path" 100 >/dev/null 2>&1
update-alternatives --set editor "$path" >/dev/null 2>&1
}