Files
platform/scripts/setup/machine-setup/lib/dev.sh
T
pastilhas 681df1c8bc make machine-setup.sh and officer-setup.sh actually run on macOS
Both scripts had real macOS branches already — MACOS_SKIP, Xcode CLT, Docker Desktop
detection — but neither had ever run past its own library load on a real Mac, and
verifying that live is what this commit is. This machine is the Mac.

The root cause was the interpreter, not missing OS coverage: macOS's stock /bin/bash is
3.2.57 (Apple stopped shipping newer once bash went GPLv3) and this machine has no newer
one installed. Both scripts used bash 4+/4.3+ syntax with zero version guard, so a real
run died in seconds — declare -A (base.sh, associative array), local -n (packages.sh,
nameref), mapfile (5 sites), ${var,,} (both scripts' own step() — confirmed --only killed
the ENTIRE run), and machine-setup.sh's own pre-flight hard-refused brew outright ("this
script does not implement yet") even once the bash-4 crash was fixed. Each got rewritten
to its bash-3.2-safe equivalent rather than requiring a newer bash and re-executing under
it — nine call sites, a bounded fix, and one less thing that can go wrong before the
script can report what's wrong.

Past that gate, a live probe run (real account/path prompts, killed before Database/
Services could touch this machine's actual state) reproduced a second bug in the first
few seconds: resolve_user_home used getent (Linux/glibc, absent on macOS) with a
hardcoded /home/<user> fallback, so Layout tried `install -d /home/pastilhas` and failed
with "Operation not supported". Fixed with the portable `eval echo ~user` tilde-expansion
idiom, re-ran the same probe, and it correctly created /Users/pastilhas/officerdev/ and
started a real git clone.

Everything else follows the same shape — an unguarded Linux-only tool with a portable or
brew-based macOS equivalent:

  - grep -oP (4 sites: both --list flags, env.sh's tailnet lookup, build.sh's schema
    table count) — BSD grep has no -P at all. Portable ERE+sed where regex was enough;
    jq where the input was already JSON (tailnet lookup) — jq is already an unconditional
    dependency elsewhere in this script, so that's a correctness fix, not just a port.
  - ss (postgres.sh, proxy.sh — is anything listening on this port, and is it bound wide
    enough for NPM's container to reach) — both silently mis-reported on macOS, since the
    "command not found" was swallowed by 2>/dev/null and read as "nothing's listening".
    New shared `listening_addrs()` in officer-setup's base.sh, branching to lsof.
  - getent hosts (proxy.sh's DNS-wait loop) — same silent-failure shape, would have spun
    its full 30-minute timeout regardless of whether the record actually resolved. macOS
    branch uses dscacheutil.
  - stat -c (layout.sh's ownership check) — BSD stat has no -c, confirmed live ("illegal
    option -- c"), and the failure was VISIBLE and made every layout directory report as
    wrong-owner. Portable file_owner() tries -c then falls back to -f %Su.
  - apt-only Postgres client, NodeSource's Debian-only setup script, lazydocker/lazygit's
    installers (one literally named install_update_linux.sh, the other hardcoding
    "_Linux_" into the release asset URL) — each gets a brew branch, formula names
    verified against the live Homebrew catalog rather than assumed.
  - sudo -u "$USERNAME" to become the SAME account the process already is — real on
    Linux, where this whole script runs as root and dropping to the owner needs no
    password; on macOS the process never escalates in the first place, so re-invoking
    sudo for yourself asks for a real password on every call, or fails outright without
    sudo rights. machine-setup's dev.sh had ~10 inline call sites (git, bun, deno, claude,
    opencode, editor detection) rerouted through a new shared run_as_owner(); officer-
    setup's own as_owner() (already the shared entry point there) got the same branch,
    and six direct sudo call sites in build.sh/secrets.sh/services.sh that bypassed it
    were rerouted through it too.

Three things stay Linux-only rather than getting an unverified macOS implementation,
each skipped with a stated reason at the point it would otherwise run: pm2's boot-
persistence hook (needs a launchd plist generated and loaded, not a systemd unit
printed and eval'd — real, separate work), the Tailscale exit-node NIC/sysctl tuning
(no macOS equivalent concept, and advertising a laptop as an exit node is already the
edge case MACOS_SKIP treats it as), and ufw's bridge-to-host firewall rule (pf is a
different rule language entirely, and Docker Desktop's own VM-based networking may not
even have the problem this rule exists to solve — guessing at a firewall rule felt like
the wrong place to be wrong).

Verified: both --list flags print step names instead of a grep usage error. A bounded
officer-setup.sh run (killed before Database) resolved the real macOS home directory,
created the layout with correct ownership, and began a real git clone — cleaned up
after (rm -rf the test directory, the gitignored .setup-progress file, the stray
install-report). machine-setup.sh's pre-flight now reports "Packages: brew" and reaches
the account/path prompts instead of refusing the OS outright. bash -n clean on all 16
files under bash 3.2, the actual interpreter these scripts run under on a Mac.

Not run further live: Database, Secrets, Services and Proxy would mutate this machine's
real state (a live Postgres, a live set of pm2 processes this session runs through) and
were left for a deliberate, supervised run rather than folded into validation.
2026-08-26 21:10:08 +01:00

497 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 / && run_as_owner git config --global --get "$1" 2>/dev/null) || true; }
git_set() { (cd / && run_as_owner 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.
run_as_owner 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 / && run_as_owner 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* ]] && run_as_owner 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's setup script adds an apt repository — it is Debian/Ubuntu-only by name
# and by mechanism (checks for dpkg internally), so there is nothing to point it at on
# macOS. Homebrew's `node`/`nodejs` formula (both names resolve, confirmed) tracks only
# the current release rather than an arbitrary major the way NodeSource's per-major
# scripts do — same tradeoff this codebase already accepts for Docker and Xcode CLT on
# macOS: brew's own version, not a pinned one.
if [[ "${OS:-}" == "macos" ]]; then
pkg_install_now nodejs
return
fi
# 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 / && run_as_owner 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 / && run_as_owner 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 / && run_as_owner "$bin" --version 2>/dev/null | head -1)
}
install_claude_code() {
(cd / && run_as_owner bash -c "set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | bash") >/dev/null 2>&1
[[ -x "$(agent_bin claude)" ]]
}
install_opencode() {
(cd / && run_as_owner 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 / && run_as_owner 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
}