#!/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 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. sudo -H -u "$USERNAME" sh -c \ "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended >/dev/null 2>&1 # Returns 0 whatever happens. This is an optional improvement, and a # function that ends on a failing command is fatal under `set -e` when it # is called as a plain command — which would abort the remaining sections # over something the run could simply report. The caller checks the outcome. 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. set_login_shell() { local shell="$1" grep -qxF "$shell" /etc/shells || echo "$shell" >>/etc/shells chsh -s "$shell" "$USERNAME" # Returns 0 whatever happens. This is an optional improvement, and a # function that ends on a failing command is fatal under `set -e` when it # is called as a plain command — which would abort the remaining sections # over something the run could simply report. The caller checks the outcome. return 0 } # ----------------------------------------------------------------------------- # 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 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 || 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 }