Files
marfrit 162d5cdd10 deploy: install every executable in bin/, not just bullpen-* (#98 follow-up)
install.sh promises "the running fleet == the repo, always; editing the live file IS
editing the tracked file". Its bin/ loop globbed `bin/bullpen-*`, so five entrypoints were
never deployed by it at all: `bp`, `room-ask`, `room_tail`, `news-de` and `deus-seize` —
the commands a human types, plus the emergency stop. They had been hand-copied once and
then drifted silently. Measured on 2026-08-02: the room host was running a `bp` from
2026-07-21 (61 lines behind the repo, which is how `bp --route` came back as "no reply
from @--route"), and deus-seize had never reached any host, so tonight's portability work
on it was live nowhere.

The hole is invisible to inspection: the glob reads as "the bullpen commands", and every
file it misses is sitting right beside the ones it catches. tests/test_install_covers_bin.py
compares the two sets instead of reading the pattern — red under the old glob, green now.

Directories and non-executables are skipped explicitly, so bin/__pycache__ (which appears
the moment anything in bin/ is imported) cannot be symlinked into /usr/local/bin.
2026-08-02 09:58:00 +02:00

102 lines
5.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# deploy/install.sh — point this host's live bullpen AT this working copy, so the repo is the
# source of truth and `git pull` is the whole deploy.
#
# The drift problem: the fleet used to run hand-edited loose copies (scripts in ~/.local/bin,
# unit files copied into /etc/systemd/system), which silently diverged from the repo. This
# replaces those copies with SYMLINKS into the checked-out repo, so:
# * the running fleet == the repo, always; editing the live file IS editing the tracked file;
# * to update a host: `git pull` here (+ a daemon-reload / service restart to pick it up).
#
# Two layouts:
# install.sh user layout — bin/ -> ~/.local/bin, units -> ~/.config/systemd/user
# install.sh --system system layout — bin/ -> /usr/local/bin, lib/ -> /usr/local/lib/bullpen,
# contrib/lmcp-tool on PATH, units -> /etc/systemd/system (run as root)
#
# It deploys: bin/bullpen-* + the lurker, lib/*.py (--system), contrib/lmcp-tool (--system),
# and every systemd/ + lurker/ unit file. It does NOT touch per-nick lurker config dirs or the
# lmcp room tool (fleet state / load-bearing — deploy those deliberately).
#
# The units carry NO usernames and need no per-host editing. Because the SAME tracked file is
# symlinked into either layout, a literal ExecStart path is necessarily wrong in one of them —
# which is why it flip-flopped twice (6905f4e -> 933dd86) and crash-looped bullpen-triage. The
# units instead say `Environment=PATH=%h/.local/bin:/usr/local/bin:…` + `ExecStart=/usr/bin/env
# <prog>`: in user scope %h is the user's home and ~/.local/bin wins; in system scope %h is
# /root, that entry misses, and /usr/local/bin wins. tests/test_units_portable.py holds the line.
#
# Idempotent and reversible: a pre-existing NON-symlink file is moved to <name>.predeploy-bak
# before the symlink is created. Re-running is safe.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SYSTEM=0
case "${1:-}" in
--system) SYSTEM=1 ;;
""|--user) SYSTEM=0 ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) echo "usage: install.sh [--system]" >&2; exit 2 ;;
esac
if [ "$SYSTEM" = 1 ]; then
BINDEST="/usr/local/bin"; LIBDEST="/usr/local/lib/bullpen"; UNITDEST="/etc/systemd/system"
[ -w /usr/local/bin ] || { echo "install.sh --system needs write to /usr/local — run as root/sudo." >&2; exit 1; }
else
BINDEST="${HOME}/.local/bin"; LIBDEST=""; UNITDEST="${HOME}/.config/systemd/user"
fi
mkdir -p "$BINDEST" "$UNITDEST"; [ -n "$LIBDEST" ] && mkdir -p "$LIBDEST"
link() { # link <path-relative-to-repo> <dest-dir> <name>
local src="$REPO/$1" dest="$2" name="$3" tgt="$2/$3"
[ -f "$src" ] || { echo " skip $name (no $1 in repo)"; return; }
if [ -L "$tgt" ] && [ "$(readlink -f "$tgt")" = "$(readlink -f "$src")" ]; then
echo " ok $name (already linked)"; return
fi
if [ -e "$tgt" ] && [ ! -L "$tgt" ]; then
mv -- "$tgt" "$tgt.predeploy-bak"
echo " bak $name -> $name.predeploy-bak"
fi
ln -sfn "$src" "$tgt"
echo " link $name -> $src"
}
echo "bullpen deploy ($([ "$SYSTEM" = 1 ] && echo system || echo user)): symlinking -> $REPO"
# EVERY executable in bin/, not just bullpen-*. The old glob was `bin/bullpen-*`, which
# silently excluded `bp`, `room-ask`, `room_tail`, `news-de` and `deus-seize` — i.e. the
# commands a human actually types, plus the emergency stop. They were hand-copied once and
# then drifted: on 2026-08-02 hertz still ran a `bp` from 2026-07-21, and deus-seize had
# never been deployed at all. A deploy whose promise is "the running fleet == the repo"
# cannot have a name-shaped hole in it.
for f in "$REPO"/bin/*; do
[ -f "$f" ] || continue # skips __pycache__/ and any other directory
[ -x "$f" ] || continue # data files in bin/ are not entrypoints
link "bin/$(basename "$f")" "$BINDEST" "$(basename "$f")"
done
# … plus the lurker (lives under lurker/, not bin/)
link "lurker/bullpen-lurker" "$BINDEST" "bullpen-lurker"
if [ "$SYSTEM" = 1 ]; then
# the config/worker/participant libs the entrypoints import from /usr/local/lib/bullpen
for f in "$REPO"/lib/*.py; do
[ -e "$f" ] || continue
link "lib/$(basename "$f")" "$LIBDEST" "$(basename "$f")"
done
# the lmcp-tool client the coordinators call by name (room host)
link "contrib/lmcp-tool" "$BINDEST" "lmcp-tool"
fi
# systemd units — symlink service/timer files so `git pull` + daemon-reload deploys unit edits
units=0
for f in "$REPO"/systemd/*.service "$REPO"/systemd/*.timer "$REPO"/lurker/*.service; do
[ -e "$f" ] || continue
case "$f" in "$REPO"/systemd/*) rel="systemd/$(basename "$f")";; *) rel="lurker/$(basename "$f")";; esac
link "$rel" "$UNITDEST" "$(basename "$f")"; units=1
done
if [ "$units" = 1 ]; then
if [ "$SYSTEM" = 1 ]; then systemctl daemon-reload; else systemctl --user daemon-reload 2>/dev/null || true; fi
echo " systemd daemon-reload ($UNITDEST)"
fi
echo "done. scripts are byte-identical to what already ran (no restart needed);"
echo "changed unit files take effect after the daemon-reload above + a service restart."