Files
platform/scripts/setup/machine-setup/lib/packages.sh
T
pastilhasandClaude Opus 5 22a4c65776 sharp is a declared dependency, and setup checks both it and ffmpeg
sharp does the images now; ffmpeg keeps the video. each does the job it is for —
sharp is libvips in-process, no process to spawn and no pipe, which is an order of
magnitude less work for the common case of a folder of photographs. a video has no
frame until something decodes one, and that is ffmpeg's and nobody else's.

added through the procedure bunfig.toml describes, since there is no
--no-frozen-lockfile flag in bun 1.3.14: package.json edited, frozenLockfile
commented out for ONE command, lockfile diff read, setting put back, and a frozen
install confirmed clean afterwards.

WHAT THE LOCKFILE DIFF ACTUALLY SAID, because the friction is only worth anything
if somebody reads it. everything is inside sharp's tree: sharp 0.33.5 -> 0.35.4 at
top level, @img/colour added, and the per-platform libvips binaries moved 1.2.4 ->
1.3.3. two SHARED packages moved with it — semver 7.7.4 -> 7.8.5 and
@emnapi/runtime 1.8.1 -> 1.11.3 — both forced by sharp's own ranges, and both
previous consumers (next, editorconfig) kept pinned copies of what they had. next
keeps its own sharp at 0.33.5 entirely. nothing outside that moved.

^0.35.3 to match what av-club declares. 0.x caret pins the minor, so ^0.33 and
^0.35 do not overlap — the plugin intersection would have resolved two native
libvips trees onto one machine for no reason.

THE IMAGE LIST GREW: libvips reads HEIC and SVG and ffmpeg does not, and neither is
exotic — HEIC is what every recent iPhone writes, and a folder of them drawing
generic file icons was the most visible gap this had.

`failOn: 'none'` and `limitInputPixels: false` are both deliberate. libvips rejects
a file with any warning in it, and a truncated JPEG — an interrupted copy, a phone
that died mid-write — is exactly that; refusing it would make this browser stricter
than the viewer somebody opens the file with. the pixel ceiling is a DoS guard for
an upload endpoint, and these are the owner's own files already on the owner's own
disk: a panorama is not an attack.

SETUP, both halves:

ffmpeg was never in the package list at all, on any of the four package managers,
despite this router shelling out to it for subtitles, audio extraction and audio
tracks long before thumbnails existed. it worked because every machine set up by
hand already had it. now declared.

sharp gets a LOAD CHECK beside node-pty's build check, and reported separately from
it. same failure shape — an install that succeeds while the native module does not
— but the opposite cause: node-pty compiles and can fail to, sharp downloads a
libvips binary for this exact platform and arch and can fail to resolve one. a
combined "native modules" verdict would send somebody to rebuild the wrong thing.
loading it is the only test worth running; a directory proves nothing, and
`versions.vips` proves libvips is bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 01:31:18 +01:00

332 lines
15 KiB
Bash

#!/bin/bash
# =============================================================================
# machine-setup — distro packages
# =============================================================================
#
# Definitions only, like lib/base.sh. Sourcing this installs nothing.
#
# ── The rule: install what is missing, never touch what is there ──
#
# `apt-get install <present-package>` is NOT a no-op — it upgrades the package if
# the repository has a newer one. On a machine somebody already uses, that can
# move a version they chose deliberately, and the setup script is the last thing
# that should be doing that behind their back.
#
# So every install here goes through pkg_install, which queries the package
# database first, installs only the subset that is genuinely absent, and prints
# both lists before doing it. A package already present is never named on a
# command line at all.
#
# ── Why per-package-manager lists rather than a translation table ──
#
# The names disagree across distributions (build-essential/base-devel/fd/fd-find)
# and some packages are not a package elsewhere at all: apt-transport-https,
# lsb-release and software-properties-common are apt concepts. A canonical-name
# table with per-manager overrides hides both of those behind indirection. A
# plain `case $PM` says what each system actually gets, in one place, and matches
# the shape scripts/setup-old/setup.sh already used.
[[ -n "${MACHINE_SETUP_PACKAGES_LOADED:-}" ]] && return 0
MACHINE_SETUP_PACKAGES_LOADED=1
# What the last pkg_install/tools_install actually put on the machine, as opposed
# to what it was asked for. Read by the caller to write an honest summary line:
# without it every section reports its whole list as installed, including the
# packages it deliberately left alone.
LAST_INSTALLED=()
LAST_KEPT=()
LAST_SKIPPED=()
# -----------------------------------------------------------------------------
# The sections
# -----------------------------------------------------------------------------
# Core: what this script itself would break without, plus the command-line tools
# that make a machine worth sitting at.
#
# The first six are load-bearing and each is used by a later step — curl fetches
# in nine of them, jq parses the lazygit release API, gnupg dearmors the Docker
# keyring, git clones the Neovim config, unzip opens anything that arrives as an
# archive, and ca-certificates is what makes any of the fetching work. The rest
# are the environment: nothing calls them, they are here because a box you use
# should have them.
#
# Four entries earn a note.
#
# python3 is not a tool anybody here calls — it is node-gyp's build dependency,
# and node-gyp is not optional on Linux. node-pty ships prebuilt binaries for
# darwin and win32 ONLY, so on Linux its install script always falls through to
# `node-gyp rebuild` and compiles from source. Without python3 that fails, and
# the failure surfaces as a broken terminal sidecar rather than as a missing
# package. build-essential below is the other half of the same requirement.
#
# unattended-upgrades installs updates on a timer with nobody watching. apt only:
# it is a Debian and Ubuntu package, dnf's equivalent is dnf-automatic and pacman
# has no equivalent at all, so it is not a name to translate. Installing the
# package is not by itself enough to switch it on — /etc/apt/apt.conf.d/20auto-upgrades
# is what the apt-daily timers read, and on this host no package owns that file.
# The section makes sure it is there.
#
# fail2ban is not a tool, it is a daemon: installing it starts it, and Ubuntu
# ships /etc/fail2ban/jail.d/defaults-debian.conf with `[sshd] enabled = true`.
# Verified on this host — maxretry 5, findtime 600, bantime 600 — so from the
# moment it installs, an address failing to log in five times in ten minutes is
# blocked for ten, including yours. That is the point of it and it is worth
# having by default, but it is why it belongs in this comment rather than being
# thought of as one more binary. An existing install with its own jails is
# untouched, because pkg_install never names a package that is already there.
#
# build-essential is the other: a meta-package (gcc, g++, make, libc6-dev,
# dpkg-dev), so on a machine where a specific gcc was pinned it pulls the
# distribution's default alongside it. It stays in core because anything that
# compiles a native module needs it, but it is the one to move out first if that
# ever bites.
#
# ffmpeg is not a convenience here, it is a hard dependency of the file browser.
# Subtitles, audio extraction, audio track listing and video thumbnails all shell
# out to it, so on a machine without it those features fail at the moment somebody
# opens a folder — which reads as Officer being broken rather than as a package
# being absent. It was missing from this list until 2026-08-29 and the features
# worked anyway, because every machine that had been set up by hand already had it.
#
# inotify-tools is `inotifywait`, and it is what lets a COMMANDER react the
# moment a file lands instead of polling for it. Without it a watcher can only
# ask "is there anything new?" on a timer, which is both slower and more work —
# and the commander has no way to know the tool is missing until it tries. macOS
# has no equivalent in the package sets here; a commander there polls.
pkgs_core() {
case "$PM" in
apt)
# apt-transport-https and lsb-release are not tools — they are what lets a
# later step add the Docker repository. They have no counterpart on the
# other systems.
#
# software-properties-common is still here and is no longer needed by
# anything: it provides `add-apt-repository`, and the fastfetch PPA was its
# only caller until that was removed on 2026-08-14 (Docker writes its own
# sources.list.d entry by hand). Left in deliberately rather than dropped
# in the same change — it is one small package, and pulling it is a
# separate decision from removing the tool that wanted it.
# poppler-utils is `pdftoppm`, and it is what makes an attached PDF readable.
# The agent reads a PDF by RENDERING its pages, not by extracting text — which
# is the only approach that works on a document made of diagrams and
# wireframes, where extraction returns almost nothing. Without it the attach
# button uploads happily and the read fails, which reads as the feature being
# broken rather than a package being absent.
echo curl ca-certificates gnupg git jq unzip \
apt-transport-https lsb-release software-properties-common \
wget zip brotli build-essential python3 btop tree tmux ripgrep fd-find net-tools eza \
inotify-tools poppler-utils ffmpeg fail2ban unattended-upgrades
;;
pacman)
echo curl ca-certificates gnupg git jq unzip \
wget zip brotli base-devel python btop tree tmux ripgrep fd net-tools eza \
inotify-tools poppler ffmpeg fail2ban
;;
dnf)
echo curl ca-certificates gnupg2 git jq unzip \
wget zip brotli python3 btop tree tmux ripgrep fd-find net-tools eza \
inotify-tools poppler-utils ffmpeg fail2ban
;;
brew)
# curl, unzip and the TLS roots ship with macOS; the compilers come from
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
# brotli is here because macOS ships the library but not the CLI.
#
echo gnupg git jq wget brotli btop tree ripgrep fd eza poppler ffmpeg
;;
esac
}
# -----------------------------------------------------------------------------
# Querying
# -----------------------------------------------------------------------------
# Is this package installed right now?
#
# dpkg-query on the status field rather than `dpkg -s`, which also succeeds for a
# package that was removed but left its config behind — that state would be read
# as "present" and the package would never be reinstalled.
pkg_is_installed() {
case "$PM" in
apt) [[ "$(dpkg-query -W -f='${db:Status-Status}' "$1" 2>/dev/null)" == "installed" ]] ;;
pacman) pacman -Qi "$1" &>/dev/null ;;
dnf) rpm -q "$1" &>/dev/null ;;
brew) brew list --formula "$1" &>/dev/null ;;
*) return 1 ;;
esac
}
# -----------------------------------------------------------------------------
# Acting
# -----------------------------------------------------------------------------
# Refresh the package index.
#
# DEBIAN_FRONTEND stops debconf opening a dialog on a machine with no terminal to
# draw it on, and NEEDRESTART_MODE=a stops needrestart — on by default since
# Ubuntu 22.04 — interrupting to ask which services to restart. Both belong here
# rather than at each call site, because forgetting one turns an unattended run
# into one that is silently waiting for a keypress.
pkg_refresh() {
case "$PM" in
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -y ;;
pacman) pacman -Sy --noconfirm ;;
dnf) dnf makecache ;;
brew) brew update ;;
esac
}
# What an upgrade would actually move, one package name per line.
#
# Asked before the upgrade runs so the section can name what it is about to
# change rather than asking to be trusted. Needs a refreshed index to be
# accurate, which is why pkg_refresh runs first.
#
# `apt-get upgrade -s` simulates and prints an "Inst <name> …" line per package,
# which is the same calculation the real run does — as opposed to
# `apt list --upgradable`, which also lists packages that are held back and
# would not actually move.
pkg_upgradable() {
case "$PM" in
apt) apt-get upgrade -s 2>/dev/null | awk '/^Inst /{print $2}' ;;
pacman) pacman -Qu 2>/dev/null | awk '{print $1}' ;;
dnf) dnf -q check-update 2>/dev/null | awk 'NF >= 3 && $1 !~ /^(Last|Obsoleting)/ {print $1}' ;;
brew) brew outdated --quiet 2>/dev/null ;;
esac
}
# Upgrade everything already installed. Separate from pkg_install on purpose:
# this one DOES move versions, so it is a deliberate step rather than something
# that happens as a side effect of installing a tool.
pkg_upgrade_all() {
case "$PM" in
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get upgrade -y ;;
pacman) pacman -Su --noconfirm ;;
dnf) dnf upgrade -y ;;
brew) brew upgrade ;;
esac
}
# The raw install, with no presence check. Use pkg_install instead.
pkg_install_now() {
case "$PM" in
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y "$@" ;;
pacman) pacman -S --noconfirm --needed "$@" ;;
dnf) dnf install -y "$@" ;;
brew) brew install "$@" ;;
esac
}
# Announce a section, then install only what is absent from it.
#
# pkg_install "Core packages" $(pkgs_core)
#
# Prints both lists before touching anything, so the run says what it is about to
# do to this machine and what it is deliberately leaving alone. Returns 0 when
# there was nothing to do.
pkg_install() {
local label="$1"
shift
local pkg
local -a missing=() present=()
LAST_SKIPPED=()
for pkg in "$@"; do
if pkg_is_installed "$pkg"; then present+=("$pkg"); else missing+=("$pkg"); fi
done
LAST_INSTALLED=("${missing[@]}")
LAST_KEPT=("${present[@]}")
announce_plan "$label" present missing || {
# Declining is a fact a reviewer wants: it explains a package being absent
# later without having to guess whether the script failed or was refused.
declare -F report_skipped >/dev/null && report_skipped "${label}: declined — ${#missing[@]} package(s) not installed"
return 0
}
if pkg_install_now "${missing[@]}"; then
declare -F report_installed >/dev/null && ((${#missing[@]})) && report_installed "${PM}: ${missing[*]}"
declare -F report_kept >/dev/null && ((${#present[@]})) && report_kept "already present, untouched: ${present[*]}"
else
declare -F report_failed >/dev/null && report_failed "${PM} install failed: ${missing[*]}"
return 1
fi
}
# Print what a section is about to do and ask permission for it.
#
# Takes the NAMES of the two arrays rather than their contents, because a list
# passed by value cannot be told apart from an empty one once it has been through
# word splitting.
#
# Copies by name via eval rather than `local -n` — namerefs are bash 4.3+, and macOS's
# stock bash is 3.2. This is the idiom namerefs replaced: build the local array by
# expanding the CALLER's array name inside a double-quoted eval, which bash 3.2 has always
# supported. `$2`/`$3` are trusted call-site literals (`present`, `missing`), never
# user input, so the eval is not taking anything untrusted.
#
# Returns non-zero when there is nothing to do, or when the answer was no — in
# both cases the caller should skip its action. LAST_INSTALLED is cleared on a
# refusal so the summary does not claim work that never happened.
announce_plan() {
local label="$1"
local -a _present _missing
eval "_present=(\"\${$2[@]}\")"
eval "_missing=(\"\${$3[@]}\")"
echo ""
info "${label} — installs what is missing, keeps what you already have"
((${#_present[@]})) && echo " already here: ${_present[*]}"
if ((${#_missing[@]} == 0)); then
echo " to install: nothing, all present"
return 1
fi
echo " to install: ${_missing[*]}"
if ! confirm "Proceed?"; then
warn "skipped by request"
LAST_INSTALLED=()
LAST_SKIPPED=("${_missing[@]}")
return 1
fi
return 0
}
# One summary line describing what a section actually did, from LAST_INSTALLED
# and LAST_KEPT. Call straight after pkg_install or tools_install.
summarise_last() {
local label="$1"
if ((${#LAST_SKIPPED[@]})); then
SUMMARY+=("$label: SKIPPED by request — ${LAST_SKIPPED[*]}")
elif ((${#LAST_INSTALLED[@]} == 0)); then
SUMMARY+=("$label: already present, nothing installed")
elif ((${#LAST_KEPT[@]} == 0)); then
SUMMARY+=("$label installed: ${LAST_INSTALLED[*]}")
else
SUMMARY+=("$label installed: ${LAST_INSTALLED[*]} (${#LAST_KEPT[@]} already present)")
fi
}
# -----------------------------------------------------------------------------
# The Xcode command line tools
# -----------------------------------------------------------------------------
#
# macOS's build-essential, and not installable as a formula. It matters here for
# one specific reason: node-pty ships no prebuilt binary for any platform, so
# `bun install` always falls through to node-gyp and needs a working compiler.
# Without this the platform install fails deep inside a dependency tree with an
# error that names neither Xcode nor node-pty.
#
# `xcode-select --install` opens a GUI dialogue and returns immediately — it does
# not block until the download finishes. So this asks, and then says to come back,
# rather than pretending to have waited.
xcode_clt_installed() { xcode-select -p &>/dev/null; }
xcode_clt_install() {
xcode-select --install 2>/dev/null || true
}