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>
382 lines
16 KiB
Bash
382 lines
16 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" ]]
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
current_editor() { (cd / && sudo -H -u "$USERNAME" bash -lc 'echo "${EDITOR:-}"' 2>/dev/null); }
|
|
|
|
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
|
|
}
|