#!/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 '': 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 } bun_installed() { command -v bun &>/dev/null; } # 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 local bin="${USER_HOME}/.bun/bin/bun" [[ -x "$bin" ]] || return 1 ln -sf "$bin" /usr/local/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" ]] }