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>
272 lines
11 KiB
Bash
272 lines
11 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_get() { (cd / && sudo -H -u "$USERNAME" git config --global --get "$1" 2>/dev/null); }
|
|
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.
|
|
sudo -H -u "$USERNAME" sh -c \
|
|
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended >/dev/null 2>&1
|
|
}
|
|
|
|
# `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.
|
|
set_login_shell() {
|
|
local shell="$1"
|
|
grep -qxF "$shell" /etc/shells || echo "$shell" >>/etc/shells
|
|
chsh -s "$shell" "$USERNAME"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# 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 "$USERNAME" "${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 nineteen of the twenty pm2 apps.
|
|
# 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
|
|
}
|
|
|
|
# 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 one of the twenty ecosystem apps 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.
|
|
install_bun() {
|
|
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://bun.sh/install | bash') >/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" ]]
|
|
}
|