The setup wrote /etc/sudoers.d/officer-service with tee and then chmod'd it. Two problems, both with the same worst case: a malformed or wrongly-permissioned file there breaks sudo completely, and you cannot sudo to repair it — on a remote machine that means physical access or a rescue boot. Generate into a temp file, gate on `visudo -c`, and only then install. Use install(1) rather than tee+chmod so the content and the 0440 mode land in one step; tee creates at the default umask first, and sudo refuses to read a sudoers file with loose permissions, so the old ordering had a window where sudo could reject its own configuration. The re-run guard also grepped for the username anywhere in the file, so a comment mentioning it counted as configured. Match the actual rule instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1064 lines
36 KiB
Bash
Executable File
1064 lines
36 KiB
Bash
Executable File
#!/bin/bash
|
|
# Officer — full host dependency setup
|
|
# Run once on a fresh Ubuntu/Debian host before launching the server.
|
|
# Usage: bash scripts/setup.sh
|
|
|
|
set -e
|
|
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
RED='\033[0;31m'
|
|
NC='\033[0m'
|
|
|
|
ok() { echo -e " ${GREEN}✓${NC} $1"; }
|
|
warn() { echo -e " ${YELLOW}!${NC} $1"; }
|
|
fail() { echo -e " ${RED}✗${NC} $1"; }
|
|
skip() { echo -e " - $1 (already installed)"; }
|
|
|
|
has() { command -v "$1" &>/dev/null; }
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
|
|
|
# Resolve the real user's home even when running under sudo
|
|
if [[ -n "${SUDO_USER:-}" ]]; then
|
|
REAL_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6)
|
|
else
|
|
REAL_HOME="$HOME"
|
|
fi
|
|
|
|
# ─── detect package manager ────────────────────────────────────────────────────
|
|
if has apt; then
|
|
PM=apt
|
|
elif has pacman; then
|
|
PM=pacman
|
|
elif has brew; then
|
|
PM=brew
|
|
else
|
|
fail "No supported package manager found (apt, pacman, brew)"
|
|
exit 1
|
|
fi
|
|
|
|
install_pkg() {
|
|
case $PM in
|
|
apt) sudo apt install -y "$@" ;;
|
|
pacman) sudo pacman -S --noconfirm "$@" ;;
|
|
brew) brew install "$@" ;;
|
|
esac
|
|
}
|
|
|
|
echo ""
|
|
echo "═══════════════════════════════════════════"
|
|
echo " Officer — dependency setup ($PM)"
|
|
echo "═══════════════════════════════════════════"
|
|
|
|
# ─── 1. core system packages ───────────────────────────────────────────────────
|
|
echo ""
|
|
echo "── Core system packages ──"
|
|
|
|
CORE_PKGS=()
|
|
|
|
# git
|
|
if has git; then skip "git"; else CORE_PKGS+=(git); fi
|
|
|
|
# zip / unzip
|
|
if has zip; then skip "zip"; else CORE_PKGS+=(zip); fi
|
|
if has unzip; then skip "unzip"; else CORE_PKGS+=(unzip); fi
|
|
|
|
# curl / wget
|
|
if has curl; then skip "curl"; else CORE_PKGS+=(curl); fi
|
|
if has wget; then skip "wget"; else CORE_PKGS+=(wget); fi
|
|
|
|
# zsh
|
|
if has zsh; then skip "zsh"; else CORE_PKGS+=(zsh); fi
|
|
|
|
# psmisc (fuser) and procps (pgrep)
|
|
if has fuser; then skip "fuser (psmisc)"; else
|
|
case $PM in
|
|
apt|pacman) CORE_PKGS+=(psmisc) ;;
|
|
brew) skip "fuser (not needed on macOS)" ;;
|
|
esac
|
|
fi
|
|
if has pgrep; then skip "pgrep (procps)"; else
|
|
case $PM in
|
|
apt) CORE_PKGS+=(procps) ;;
|
|
pacman) CORE_PKGS+=(procps-ng) ;;
|
|
brew) skip "pgrep (built-in on macOS)" ;;
|
|
esac
|
|
fi
|
|
|
|
# script (bsdutils on apt, util-linux on pacman, built-in on macOS)
|
|
if has script; then skip "script (bsdutils)"; else
|
|
case $PM in
|
|
apt) CORE_PKGS+=(bsdutils) ;;
|
|
pacman) CORE_PKGS+=(util-linux) ;;
|
|
brew) skip "script (built-in on macOS)" ;;
|
|
esac
|
|
fi
|
|
|
|
# build tools (make, gcc, g++) — needed for native npm modules like node-pty
|
|
if has make && has gcc; then skip "build tools (make, gcc, g++)"; else
|
|
case $PM in
|
|
apt) CORE_PKGS+=(build-essential) ;;
|
|
pacman) CORE_PKGS+=(base-devel) ;;
|
|
brew) warn "Install Xcode command line tools: xcode-select --install" ;;
|
|
esac
|
|
fi
|
|
|
|
# pkg-config — needed by cgo-based Go packages (e.g. ebitengine/oto for cliamp)
|
|
if has pkg-config; then skip "pkg-config"; else
|
|
case $PM in
|
|
apt) CORE_PKGS+=(pkg-config) ;;
|
|
pacman) CORE_PKGS+=(pkgconf) ;;
|
|
brew) CORE_PKGS+=(pkg-config) ;;
|
|
esac
|
|
fi
|
|
|
|
# python3 + pip + venv
|
|
if has python3; then skip "python3"; else
|
|
case $PM in
|
|
apt) CORE_PKGS+=(python3 python3-pip python3-venv) ;;
|
|
pacman) CORE_PKGS+=(python python-pip) ;;
|
|
brew) CORE_PKGS+=(python3) ;;
|
|
esac
|
|
fi
|
|
# ensure pip/venv even if python3 already exists (apt splits them)
|
|
if has python3 && [ "$PM" = "apt" ]; then
|
|
if ! dpkg -s python3-pip &>/dev/null 2>&1; then CORE_PKGS+=(python3-pip); fi
|
|
if ! dpkg -s python3-venv &>/dev/null 2>&1; then CORE_PKGS+=(python3-venv); fi
|
|
fi
|
|
|
|
# shell utilities
|
|
for tool in tree btop tmux jq htop lsof duf; do
|
|
if has "$tool"; then skip "$tool"; else CORE_PKGS+=("$tool"); fi
|
|
done
|
|
|
|
# sqlite3
|
|
if has sqlite3; then skip "sqlite3"; else
|
|
case $PM in
|
|
apt) CORE_PKGS+=(sqlite3) ;;
|
|
pacman) CORE_PKGS+=(sqlite) ;;
|
|
brew) CORE_PKGS+=(sqlite) ;;
|
|
esac
|
|
fi
|
|
|
|
# isync (provides mbsync for Gmail IMAP sync)
|
|
if has mbsync; then skip "isync (mbsync)"; else
|
|
case $PM in
|
|
apt) CORE_PKGS+=(isync) ;;
|
|
pacman) CORE_PKGS+=(isync) ;;
|
|
brew) CORE_PKGS+=(isync) ;;
|
|
esac
|
|
fi
|
|
|
|
# ripgrep
|
|
if has rg; then skip "ripgrep"; else
|
|
case $PM in
|
|
apt) CORE_PKGS+=(ripgrep) ;;
|
|
pacman) CORE_PKGS+=(ripgrep) ;;
|
|
brew) CORE_PKGS+=(ripgrep) ;;
|
|
esac
|
|
fi
|
|
|
|
# fd-find
|
|
if has fd || has fdfind; then skip "fd-find"; else
|
|
case $PM in
|
|
apt) CORE_PKGS+=(fd-find) ;;
|
|
pacman) CORE_PKGS+=(fd) ;;
|
|
brew) CORE_PKGS+=(fd) ;;
|
|
esac
|
|
fi
|
|
|
|
# net-tools, less, file, man-db
|
|
case $PM in
|
|
apt)
|
|
for pkg in net-tools less file man-db; do
|
|
if dpkg -s "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else CORE_PKGS+=("$pkg"); fi
|
|
done
|
|
;;
|
|
pacman)
|
|
for pkg in net-tools less file man-db; do
|
|
if pacman -Qi "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else CORE_PKGS+=("$pkg"); fi
|
|
done
|
|
;;
|
|
brew)
|
|
skip "net-tools, less, file, man (built-in on macOS)"
|
|
;;
|
|
esac
|
|
|
|
# locales
|
|
case $PM in
|
|
apt)
|
|
if dpkg -s locales &>/dev/null 2>&1; then skip "locales"; else CORE_PKGS+=(locales); fi
|
|
;;
|
|
esac
|
|
|
|
# ca-certificates
|
|
case $PM in
|
|
apt)
|
|
if dpkg -s ca-certificates &>/dev/null 2>&1; then skip "ca-certificates"; else CORE_PKGS+=(ca-certificates); fi
|
|
;;
|
|
esac
|
|
|
|
if [ ${#CORE_PKGS[@]} -gt 0 ]; then
|
|
install_pkg "${CORE_PKGS[@]}"
|
|
ok "Installed: ${CORE_PKGS[*]}"
|
|
fi
|
|
|
|
# locale generation (ensure en_US.UTF-8)
|
|
case $PM in
|
|
apt)
|
|
if ! locale -a 2>/dev/null | grep -q "en_US.utf8"; then
|
|
sudo sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen
|
|
sudo locale-gen
|
|
ok "Generated en_US.UTF-8 locale"
|
|
else
|
|
skip "en_US.UTF-8 locale"
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
# symlink fdfind → fd (apt installs as fdfind)
|
|
if has fdfind && ! has fd; then
|
|
sudo ln -sf "$(command -v fdfind)" /usr/local/bin/fd
|
|
ok "Symlinked fdfind → fd"
|
|
fi
|
|
|
|
# ─── 2. archive extras (optional but useful) ──────────────────────────────────
|
|
echo ""
|
|
echo "── Archive utilities (optional) ──"
|
|
|
|
ARCHIVE_PKGS=()
|
|
|
|
# p7zip
|
|
if has 7z; then skip "7z (p7zip)"; else
|
|
case $PM in
|
|
apt) ARCHIVE_PKGS+=(p7zip-full) ;;
|
|
pacman) ARCHIVE_PKGS+=(p7zip) ;;
|
|
brew) ARCHIVE_PKGS+=(p7zip) ;;
|
|
esac
|
|
fi
|
|
|
|
# unrar
|
|
if has unrar; then skip "unrar"; else
|
|
case $PM in
|
|
apt) ARCHIVE_PKGS+=(unrar) ;;
|
|
pacman) ARCHIVE_PKGS+=(unrar) ;;
|
|
brew) ARCHIVE_PKGS+=(unrar) ;;
|
|
esac
|
|
fi
|
|
|
|
if [ ${#ARCHIVE_PKGS[@]} -gt 0 ]; then
|
|
install_pkg "${ARCHIVE_PKGS[@]}" || warn "Some archive packages may need non-free repos"
|
|
ok "Installed: ${ARCHIVE_PKGS[*]}"
|
|
fi
|
|
|
|
# ─── 3. ffmpeg ─────────────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "── FFmpeg ──"
|
|
|
|
if has ffmpeg; then
|
|
skip "ffmpeg ($(ffmpeg -version 2>&1 | head -1 | awk '{print $3}'))"
|
|
else
|
|
install_pkg ffmpeg
|
|
ok "ffmpeg installed"
|
|
fi
|
|
|
|
# ─── 4. sudoers for officer service user ──────────────────────────────────────
|
|
echo ""
|
|
echo "── Sudoers (Linux user isolation) ──"
|
|
|
|
SERVICE_USER="$(whoami)"
|
|
SUDOERS_FILE="/etc/sudoers.d/officer-service"
|
|
|
|
# Match the actual rule, not just the username appearing somewhere in the file — a comment mentioning
|
|
# the user would otherwise read as "configured".
|
|
if [ -f "$SUDOERS_FILE" ] && grep -qE "^${SERVICE_USER}[[:space:]]+ALL=" "$SUDOERS_FILE" 2>/dev/null; then
|
|
skip "sudoers entry for $SERVICE_USER"
|
|
else
|
|
case $PM in
|
|
apt|pacman)
|
|
# Validate BEFORE this lands in /etc/sudoers.d. A malformed file there breaks sudo COMPLETELY,
|
|
# and you cannot sudo to repair it — on a remote machine that is unrecoverable short of physical
|
|
# access or a rescue boot. `visudo -c` is the standard gate and costs nothing.
|
|
#
|
|
# install(1) rather than tee+chmod: it writes the content and the 0440 mode in one step. tee
|
|
# creates the file at the default umask first, and sudo refuses to read a sudoers file with
|
|
# loose permissions, so that ordering leaves a window where sudo can reject its own config.
|
|
SUDOERS_TMP="$(mktemp)"
|
|
echo "$SERVICE_USER ALL=(ALL) NOPASSWD: ALL" > "$SUDOERS_TMP"
|
|
if sudo visudo -c -f "$SUDOERS_TMP" >/dev/null 2>&1; then
|
|
sudo install -m 0440 -o root -g root "$SUDOERS_TMP" "$SUDOERS_FILE"
|
|
ok "Created sudoers entry for $SERVICE_USER at $SUDOERS_FILE"
|
|
else
|
|
fail "visudo rejected the sudoers entry for '$SERVICE_USER' — not installing it"
|
|
fi
|
|
rm -f "$SUDOERS_TMP"
|
|
;;
|
|
brew)
|
|
warn "Sudoers setup is Linux-only — skipping on macOS"
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
# ─── 4b. Disable auto-suspend (server doesn't need to sleep) ──────────────────
|
|
echo ""
|
|
echo "── Auto-suspend (disable for server) ──"
|
|
|
|
# Disable system suspend/hibernate
|
|
if systemctl is-enabled sleep.target 2>/dev/null | grep -q "masked"; then
|
|
skip "sleep targets already masked"
|
|
else
|
|
sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target 2>/dev/null
|
|
ok "Masked sleep/suspend targets"
|
|
fi
|
|
|
|
# Configure logind to ignore idle — patch individual keys, don't overwrite the file
|
|
LOGIND_CHANGED=0
|
|
set_logind_key() {
|
|
local key="$1" val="$2" file="/etc/systemd/logind.conf"
|
|
# Already set (uncommented) to the desired value → nothing to do.
|
|
if grep -qE "^${key}=${val}$" "$file" 2>/dev/null; then
|
|
return
|
|
fi
|
|
if grep -qE "^${key}=" "$file" 2>/dev/null; then
|
|
sudo sed -i "s|^${key}=.*|${key}=${val}|" "$file"
|
|
elif grep -qE "^#${key}=" "$file" 2>/dev/null; then
|
|
sudo sed -i "s|^#${key}=.*|${key}=${val}|" "$file"
|
|
else
|
|
echo "${key}=${val}" | sudo tee -a "$file" > /dev/null
|
|
fi
|
|
LOGIND_CHANGED=1
|
|
}
|
|
|
|
set_logind_key HandleLidSwitch ignore
|
|
set_logind_key HandleLidSwitchExternalPower ignore
|
|
set_logind_key HandlePowerKey ignore
|
|
set_logind_key IdleAction none
|
|
set_logind_key RuntimeDirectorySize 10%
|
|
|
|
# Only restart logind when something actually changed — a needless restart can disrupt live sessions.
|
|
if [ "$LOGIND_CHANGED" = "1" ]; then
|
|
sudo systemctl restart systemd-logind
|
|
ok "Configured logind to disable auto-suspend"
|
|
else
|
|
skip "logind auto-suspend settings"
|
|
fi
|
|
|
|
# ─── 5. Node.js 22 (system-wide) ─────────────────────────────────────────────
|
|
echo ""
|
|
echo "── Node.js 22 (system-wide) ──"
|
|
|
|
# Always check the canonical system path, not `which node` (which may resolve nvm).
|
|
SYSTEM_NODE="/usr/bin/node"
|
|
|
|
install_node22_apt() {
|
|
echo " Setting up NodeSource repository..."
|
|
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
|
echo " Installing nodejs..."
|
|
sudo apt-get install -y nodejs
|
|
}
|
|
|
|
case $PM in
|
|
apt)
|
|
NEED_INSTALL=0
|
|
if [ -f "$SYSTEM_NODE" ]; then
|
|
SYS_MAJOR=$("$SYSTEM_NODE" -v 2>/dev/null | cut -d. -f1 | sed 's/^v//')
|
|
if [ "$SYS_MAJOR" = "22" ]; then
|
|
skip "node v$("$SYSTEM_NODE" -v) (system-wide at $SYSTEM_NODE)"
|
|
else
|
|
warn "System node v$("$SYSTEM_NODE" -v) at $SYSTEM_NODE is not v22 — upgrading..."
|
|
NEED_INSTALL=1
|
|
fi
|
|
else
|
|
if has node; then
|
|
warn "node found at $(which node) (not system-wide, likely nvm) — installing Node 22 system-wide..."
|
|
else
|
|
echo " Node.js not found — installing v22..."
|
|
fi
|
|
NEED_INSTALL=1
|
|
fi
|
|
if [ "$NEED_INSTALL" = "1" ]; then
|
|
install_node22_apt
|
|
if [ -f "$SYSTEM_NODE" ] && [ "$("$SYSTEM_NODE" -v 2>/dev/null | cut -d. -f1 | sed 's/^v//')" = "22" ]; then
|
|
ok "node v$("$SYSTEM_NODE" -v) installed at $SYSTEM_NODE"
|
|
if has node && [ "$(command -v node)" != "$SYSTEM_NODE" ]; then
|
|
warn "Shell resolves 'node' to $(command -v node) — system node is at $SYSTEM_NODE"
|
|
warn "nvm may shadow it in interactive shells; systemd services will use $SYSTEM_NODE"
|
|
fi
|
|
else
|
|
fail "Node.js 22 install failed — $SYSTEM_NODE not found or wrong version"
|
|
exit 1
|
|
fi
|
|
fi
|
|
;;
|
|
pacman)
|
|
if has node && [ "$(node -v 2>/dev/null | cut -d. -f1 | sed 's/^v//')" = "22" ]; then
|
|
skip "node v$(node -v)"
|
|
else
|
|
install_pkg nodejs npm
|
|
if has node; then ok "node v$(node -v) installed"; else fail "node install failed"; exit 1; fi
|
|
fi
|
|
;;
|
|
brew)
|
|
if has node && [ "$(node -v 2>/dev/null | cut -d. -f1 | sed 's/^v//')" = "22" ]; then
|
|
skip "node v$(node -v)"
|
|
else
|
|
install_pkg node
|
|
if has node; then ok "node v$(node -v) installed"; else fail "node install failed"; exit 1; fi
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
# ─── 6. Bun ───────────────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "── Bun ──"
|
|
|
|
export BUN_INSTALL="$HOME/.bun"
|
|
export PATH="$BUN_INSTALL/bin:$PATH"
|
|
|
|
if [ -f "$BUN_INSTALL/bin/bun" ]; then
|
|
skip "bun ($(bun --version 2>/dev/null))"
|
|
else
|
|
curl -fsSL https://bun.sh/install | bash
|
|
if [ ! -f "$BUN_INSTALL/bin/bun" ]; then fail "bun install failed"; exit 1; fi
|
|
ok "bun $(bun --version) installed"
|
|
fi
|
|
|
|
# Symlink to system-wide path so all users and systemd services can access it
|
|
if [ ! -L /usr/local/bin/bun ] || [ "$(readlink /usr/local/bin/bun)" != "$BUN_INSTALL/bin/bun" ]; then
|
|
sudo ln -sf "$BUN_INSTALL/bin/bun" /usr/local/bin/bun
|
|
ok "bun symlinked to /usr/local/bin/bun"
|
|
else
|
|
skip "bun symlink at /usr/local/bin/bun"
|
|
fi
|
|
|
|
# ─── 7. Go ─────────────────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "── Go ──"
|
|
|
|
echo " Fetching latest Go version..."
|
|
GOLANG_VERSION=$(curl -fsSL "https://go.dev/dl/?mode=json" | jq -r '.[0].version' | sed 's/^go//')
|
|
if [ -z "$GOLANG_VERSION" ]; then
|
|
warn "Could not fetch latest Go version — falling back to 1.23.6"
|
|
GOLANG_VERSION=1.23.6
|
|
fi
|
|
echo " Latest Go: $GOLANG_VERSION"
|
|
|
|
install_go() {
|
|
case $PM in
|
|
apt|pacman)
|
|
ARCH=$(uname -m)
|
|
case $ARCH in
|
|
x86_64) GO_ARCH=amd64 ;;
|
|
aarch64) GO_ARCH=arm64 ;;
|
|
*) GO_ARCH=amd64 ;;
|
|
esac
|
|
echo " Installing Go ${GOLANG_VERSION} from official tarball..."
|
|
curl -fsSL "https://go.dev/dl/go${GOLANG_VERSION}.linux-${GO_ARCH}.tar.gz" -o /tmp/go.tar.gz
|
|
mkdir -p "$HOME/.local"
|
|
# Only remove old installation after successful download
|
|
rm -rf "$HOME/.local/go"
|
|
tar -C "$HOME/.local" -xzf /tmp/go.tar.gz
|
|
rm /tmp/go.tar.gz
|
|
export GOPATH="$HOME/.local/go-path"
|
|
export PATH="$HOME/.local/go/bin:$GOPATH/bin:$PATH"
|
|
;;
|
|
brew)
|
|
brew install go
|
|
;;
|
|
esac
|
|
}
|
|
|
|
if has go; then
|
|
INSTALLED_GO=$(go version 2>/dev/null | awk '{print $3}' | sed 's/^go//')
|
|
if [ "$INSTALLED_GO" = "$GOLANG_VERSION" ]; then
|
|
skip "go $INSTALLED_GO"
|
|
else
|
|
warn "go $INSTALLED_GO installed but latest is $GOLANG_VERSION — upgrading..."
|
|
install_go
|
|
if has go; then ok "go $(go version | awk '{print $3}') installed"; else warn "go upgrade failed"; fi
|
|
fi
|
|
else
|
|
install_go
|
|
if has go; then ok "go $(go version | awk '{print $3}') installed"; else warn "go not found — install manually from https://go.dev/dl/"; fi
|
|
fi
|
|
|
|
# ─── 8. Rust ──────────────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "── Rust ──"
|
|
|
|
if has rustc && has cargo; then
|
|
skip "rust ($(rustc --version 2>/dev/null | awk '{print $2}'))"
|
|
else
|
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
|
export PATH="$HOME/.cargo/bin:$PATH"
|
|
if has rustc; then ok "rust installed"; else warn "rust install failed"; fi
|
|
fi
|
|
|
|
# ─── 9. PulseAudio (headless audio for cliamp) ────────────────────────────────
|
|
echo ""
|
|
echo "── PulseAudio (headless audio) ──"
|
|
|
|
PULSE_PKGS=()
|
|
|
|
if has pulseaudio; then skip "pulseaudio"; else
|
|
case $PM in
|
|
apt) PULSE_PKGS+=(pulseaudio) ;;
|
|
pacman) PULSE_PKGS+=(pulseaudio) ;;
|
|
brew) warn "PulseAudio: brew install pulseaudio (cliamp audio won't work without it)" ;;
|
|
esac
|
|
fi
|
|
|
|
# pulseaudio-utils provides parec and pactl
|
|
if has parec && has pactl; then skip "pulseaudio-utils (parec, pactl)"; else
|
|
case $PM in
|
|
apt) PULSE_PKGS+=(pulseaudio-utils) ;;
|
|
pacman) ;; # included in pulseaudio package
|
|
brew) ;; # included in pulseaudio formula
|
|
esac
|
|
fi
|
|
|
|
# ALSA dev headers (needed to compile cliamp's Go audio library)
|
|
case $PM in
|
|
apt)
|
|
if dpkg -s libasound2-dev &>/dev/null 2>&1; then skip "libasound2-dev"; else PULSE_PKGS+=(libasound2-dev); fi
|
|
;;
|
|
pacman)
|
|
if pacman -Qi alsa-lib &>/dev/null 2>&1; then skip "alsa-lib"; else PULSE_PKGS+=(alsa-lib); fi
|
|
;;
|
|
brew) ;; # not needed on macOS
|
|
esac
|
|
|
|
# Vorbis/OGG/FLAC dev headers (needed by cliamp's Go dependencies)
|
|
case $PM in
|
|
apt)
|
|
for pkg in libvorbis-dev libogg-dev libflac-dev; do
|
|
if dpkg -s "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else PULSE_PKGS+=("$pkg"); fi
|
|
done
|
|
;;
|
|
pacman)
|
|
for pkg in libvorbis libogg flac; do
|
|
if pacman -Qi "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else PULSE_PKGS+=("$pkg"); fi
|
|
done
|
|
;;
|
|
brew) ;; # not needed on macOS
|
|
esac
|
|
|
|
if [ ${#PULSE_PKGS[@]} -gt 0 ]; then
|
|
install_pkg "${PULSE_PKGS[@]}"
|
|
ok "Installed: ${PULSE_PKGS[*]}"
|
|
fi
|
|
|
|
# ─── 10. cliamp (music player) ────────────────────────────────────────────────
|
|
echo ""
|
|
echo "── cliamp ──"
|
|
|
|
# Export GOPATH (not just PATH) so `go install` lands in GOPATH_BIN — even when Go was already present
|
|
# this run and install_go (which sets GOPATH) never ran. Otherwise go uses its default ~/go/bin and the
|
|
# check below wrongly reports a build failure.
|
|
export GOPATH="${GOPATH:-$HOME/.local/go-path}"
|
|
GOPATH_BIN="$GOPATH/bin"
|
|
export PATH="$GOPATH_BIN:$PATH"
|
|
|
|
if has cliamp; then
|
|
skip "cliamp ($(command -v cliamp))"
|
|
else
|
|
if ! has go; then
|
|
warn "Go not installed — skipping cliamp build"
|
|
else
|
|
echo " Building cliamp from source..."
|
|
TMPDIR=$(mktemp -d)
|
|
git clone --depth=1 https://github.com/bjarneo/cliamp.git "$TMPDIR/cliamp"
|
|
(cd "$TMPDIR/cliamp" && go install .)
|
|
rm -rf "$TMPDIR"
|
|
if [ -f "$GOPATH_BIN/cliamp" ]; then
|
|
ok "cliamp installed at $GOPATH_BIN/cliamp"
|
|
else
|
|
warn "cliamp build failed"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# ─── 11. Neovim ──────────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "── Neovim ──"
|
|
|
|
if has nvim; then
|
|
skip "neovim ($(nvim --version 2>/dev/null | head -1))"
|
|
else
|
|
case $PM in
|
|
apt)
|
|
echo " Installing Neovim from GitHub releases..."
|
|
ARCH=$(uname -m)
|
|
case $ARCH in
|
|
x86_64) NVIM_ARCH=x86_64 ;;
|
|
aarch64) NVIM_ARCH=aarch64 ;;
|
|
*) NVIM_ARCH=x86_64 ;;
|
|
esac
|
|
curl -fsSL "https://github.com/neovim/neovim/releases/latest/download/nvim-linux-${NVIM_ARCH}.tar.gz" -o /tmp/nvim.tar.gz
|
|
sudo tar -C /opt -xzf /tmp/nvim.tar.gz
|
|
sudo ln -sf "/opt/nvim-linux-${NVIM_ARCH}/bin/nvim" /usr/local/bin/nvim
|
|
rm /tmp/nvim.tar.gz
|
|
;;
|
|
pacman) install_pkg neovim ;;
|
|
brew) install_pkg neovim ;;
|
|
esac
|
|
if has nvim; then ok "neovim installed"; else warn "neovim install failed"; fi
|
|
fi
|
|
|
|
# LazyVim starter config
|
|
if [ -d "$HOME/.config/nvim" ]; then
|
|
skip "nvim config (already exists at ~/.config/nvim)"
|
|
else
|
|
echo " Installing LazyVim starter config..."
|
|
git clone --depth 1 https://github.com/LazyVim/starter "$HOME/.config/nvim"
|
|
rm -rf "$HOME/.config/nvim/.git"
|
|
ok "LazyVim starter installed at ~/.config/nvim"
|
|
fi
|
|
|
|
# ─── 12. Terminal tools ──────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "── Terminal tools (starship, oh-my-zsh, eza, lazygit) ──"
|
|
|
|
# Starship prompt
|
|
if has starship; then
|
|
skip "starship"
|
|
else
|
|
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin
|
|
if has starship; then ok "starship installed"; else warn "starship install failed"; fi
|
|
fi
|
|
|
|
# Deploy starship config. Unconditionally cp'ing here overwrote a customised ~/.config/starship.toml on
|
|
# every run, silently — the nvim step below already gets this right by guarding on the config's
|
|
# existence, so this was just inconsistent. Converge when there is nothing to lose, keep what the user
|
|
# wrote when there is.
|
|
mkdir -p "$HOME/.config"
|
|
STARSHIP_DEST="$HOME/.config/starship.toml"
|
|
if [ ! -f "$STARSHIP_DEST" ]; then
|
|
cp "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"
|
|
ok "starship config deployed"
|
|
elif cmp -s "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"; then
|
|
skip "starship config"
|
|
else
|
|
warn "starship config kept — yours differs (cp scripts/starship.toml ~/.config/ to take this one)"
|
|
fi
|
|
|
|
# Oh-My-Zsh
|
|
if [ -d "$HOME/.oh-my-zsh" ]; then
|
|
skip "oh-my-zsh (already at ~/.oh-my-zsh)"
|
|
else
|
|
git clone --depth 1 https://github.com/ohmyzsh/ohmyzsh.git "$HOME/.oh-my-zsh"
|
|
ok "oh-my-zsh installed at ~/.oh-my-zsh"
|
|
fi
|
|
|
|
# eza
|
|
if has eza; then
|
|
skip "eza"
|
|
else
|
|
case $PM in
|
|
apt)
|
|
echo " Fetching latest eza version..."
|
|
EZA_VERSION=$(curl -fsSL "https://api.github.com/repos/eza-community/eza/releases/latest" | jq -r '.tag_name' | sed 's/^v//')
|
|
if [ -z "$EZA_VERSION" ]; then warn "Could not fetch eza version — skipping"; else
|
|
ARCH=$(uname -m)
|
|
case $ARCH in
|
|
x86_64) EZA_ARCH=x86_64 ;;
|
|
aarch64) EZA_ARCH=aarch64 ;;
|
|
*) EZA_ARCH=x86_64 ;;
|
|
esac
|
|
curl -fsSL "https://github.com/eza-community/eza/releases/download/v${EZA_VERSION}/eza_${EZA_ARCH}-unknown-linux-gnu.tar.gz" -o /tmp/eza.tar.gz
|
|
tar -xzf /tmp/eza.tar.gz -C /tmp
|
|
sudo mv /tmp/eza /usr/local/bin/eza
|
|
sudo chmod +x /usr/local/bin/eza
|
|
rm -f /tmp/eza.tar.gz
|
|
fi
|
|
;;
|
|
pacman) install_pkg eza ;;
|
|
brew) install_pkg eza ;;
|
|
esac
|
|
if has eza; then ok "eza installed"; else warn "eza install failed"; fi
|
|
fi
|
|
|
|
# lazygit
|
|
if has lazygit; then
|
|
skip "lazygit"
|
|
else
|
|
case $PM in
|
|
apt)
|
|
echo " Fetching latest lazygit version..."
|
|
LAZYGIT_VERSION=$(curl -fsSL "https://api.github.com/repos/jesseduffield/lazygit/releases/latest" | jq -r '.tag_name' | sed 's/^v//')
|
|
if [ -z "$LAZYGIT_VERSION" ]; then warn "Could not fetch lazygit version — skipping"; else
|
|
ARCH=$(uname -m)
|
|
case $ARCH in
|
|
x86_64) LG_ARCH=x86_64 ;;
|
|
aarch64) LG_ARCH=arm64 ;;
|
|
*) LG_ARCH=x86_64 ;;
|
|
esac
|
|
curl -fsSL "https://github.com/jesseduffield/lazygit/releases/download/v${LAZYGIT_VERSION}/lazygit_${LAZYGIT_VERSION}_Linux_${LG_ARCH}.tar.gz" -o /tmp/lazygit.tar.gz
|
|
tar -xzf /tmp/lazygit.tar.gz -C /tmp
|
|
sudo mv /tmp/lazygit /usr/local/bin/lazygit
|
|
sudo chmod +x /usr/local/bin/lazygit
|
|
rm -f /tmp/lazygit.tar.gz /tmp/LICENSE /tmp/README.md
|
|
fi
|
|
;;
|
|
pacman) install_pkg lazygit ;;
|
|
brew) install_pkg lazygit ;;
|
|
esac
|
|
if has lazygit; then ok "lazygit installed"; else warn "lazygit install failed"; fi
|
|
fi
|
|
|
|
# ─── 13. yt-dlp (optional — video/audio download) ────────────────────────────
|
|
echo ""
|
|
echo "── yt-dlp (optional) ──"
|
|
|
|
# Always install/upgrade via pip to get the latest version (apt repos are outdated).
|
|
# Remove apt version first if present, then install via pip to /usr/local/bin.
|
|
if has pip3; then
|
|
# Remove outdated apt version if installed
|
|
case $PM in
|
|
apt)
|
|
if dpkg -s yt-dlp &>/dev/null 2>&1; then
|
|
echo " Removing outdated apt version..."
|
|
sudo apt remove -y yt-dlp > /dev/null 2>&1
|
|
fi
|
|
;;
|
|
esac
|
|
echo " Installing/upgrading yt-dlp via pip..."
|
|
sudo pip3 install --break-system-packages --upgrade yt-dlp 2>/dev/null
|
|
if has yt-dlp; then ok "yt-dlp $(yt-dlp --version) installed"; else warn "yt-dlp pip install failed"; fi
|
|
else
|
|
case $PM in
|
|
apt) install_pkg yt-dlp 2>/dev/null && ok "yt-dlp installed (apt — may be outdated)" || warn "yt-dlp not available" ;;
|
|
pacman) install_pkg yt-dlp && ok "yt-dlp installed" ;;
|
|
brew) install_pkg yt-dlp && ok "yt-dlp installed" ;;
|
|
esac
|
|
fi
|
|
|
|
# ─── 14. npm global packages (user-local) ───────────────────────────────────
|
|
echo ""
|
|
echo "── npm global packages (user-local) ──"
|
|
|
|
# Ensure ~/.local/bin is in PATH for this session
|
|
export PATH="$HOME/.local/bin:$PATH"
|
|
|
|
if ! has npm; then
|
|
warn "npm not found — skipping global package installs"
|
|
else
|
|
# Set npm prefix to user-local so no sudo is needed for installs/updates
|
|
echo " Configuring npm global prefix to ~/.local..."
|
|
npm config set prefix "$HOME/.local"
|
|
ok "npm prefix set to $HOME/.local"
|
|
|
|
# Claude Code (uses Anthropic's own installer for auto-update support)
|
|
if has claude; then
|
|
skip "claude (claude-code)"
|
|
else
|
|
echo " Installing claude-code via Anthropic installer..."
|
|
curl -fsSL https://claude.ai/install.sh | sh
|
|
if has claude; then ok "claude-code installed"; else warn "claude-code install failed"; fi
|
|
fi
|
|
|
|
# The platform's Claude sidecar execs /usr/local/bin/claude (a stable, sandbox-visible path). The
|
|
# installer only drops the CLI in ~/.local/bin, so symlink it there — pointing at the ~/.local/bin
|
|
# launcher so it keeps tracking Claude's self-updates. Without this, claude chat ENOENTs on a fresh host.
|
|
CLAUDE_LOCAL="$HOME/.local/bin/claude"
|
|
if [ -e "$CLAUDE_LOCAL" ]; then
|
|
if [ "$(readlink -f /usr/local/bin/claude 2>/dev/null)" != "$(readlink -f "$CLAUDE_LOCAL")" ]; then
|
|
sudo ln -sf "$CLAUDE_LOCAL" /usr/local/bin/claude
|
|
ok "claude symlinked to /usr/local/bin/claude"
|
|
else
|
|
skip "claude symlink at /usr/local/bin/claude"
|
|
fi
|
|
else
|
|
warn "claude not found at $CLAUDE_LOCAL — /usr/local/bin/claude symlink skipped"
|
|
fi
|
|
|
|
# pm2 (process manager)
|
|
if has pm2; then
|
|
skip "pm2"
|
|
else
|
|
echo " Installing pm2..."
|
|
npm install -g pm2
|
|
if has pm2; then ok "pm2 installed"; else warn "pm2 install failed"; fi
|
|
fi
|
|
fi
|
|
|
|
# ─── 15. bun install (project dependencies) ──────────────────────────────────
|
|
echo ""
|
|
echo "── Project dependencies ──"
|
|
|
|
if has bun && [ -f "$PROJECT_DIR/package.json" ]; then
|
|
echo " Running bun install..."
|
|
(cd "$PROJECT_DIR" && bun install)
|
|
ok "Project dependencies installed"
|
|
else
|
|
warn "Skipping bun install (bun not found or not in project dir)"
|
|
fi
|
|
|
|
# ─── 16. environment (.env) ──────────────────────────────────────────────────
|
|
echo ""
|
|
echo "── Environment (.env) ──"
|
|
|
|
GENERATE_ENV=true
|
|
|
|
if [ ! -t 0 ]; then
|
|
# Non-interactive shell: the prompts below would hit EOF and abort the whole script under `set -e`.
|
|
GENERATE_ENV=false
|
|
if [ -f "$PROJECT_DIR/.env" ]; then
|
|
skip ".env (kept existing — non-interactive shell)"
|
|
else
|
|
warn "No .env and not a terminal — re-run setup.sh interactively to generate it"
|
|
fi
|
|
elif [ -f "$PROJECT_DIR/.env" ]; then
|
|
echo -n " .env already exists. Regenerate? (y/n) [n]: "
|
|
read -r REGEN
|
|
if [[ "$REGEN" != "y" && "$REGEN" != "Y" ]]; then
|
|
GENERATE_ENV=false
|
|
skip ".env (kept existing)"
|
|
fi
|
|
fi
|
|
|
|
if [ "$GENERATE_ENV" = true ]; then
|
|
# Run setup-dockers.sh and capture its stdout output
|
|
echo " Setting up Docker Compose services..."
|
|
DOCKER_OUTPUT=$(bash "$SCRIPT_DIR/setup-dockers.sh")
|
|
|
|
# Parse output from setup-dockers.sh
|
|
COMPOSE_DIR=$(echo "$DOCKER_OUTPUT" | grep '^COMPOSE_DIR=' | cut -d= -f2-)
|
|
POSTGRES_URL=$(echo "$DOCKER_OUTPUT" | grep '^POSTGRES_URL=' | cut -d= -f2-)
|
|
DOCKER_MAIL_TRANSPORT=$(echo "$DOCKER_OUTPUT" | grep '^MAIL_TRANSPORT=' | cut -d= -f2-)
|
|
|
|
# Prompt for remaining values
|
|
echo ""
|
|
echo -n " PORT [9010]: "
|
|
read -r ENV_PORT
|
|
ENV_PORT="${ENV_PORT:-9010}"
|
|
|
|
echo -n " PUBLIC_URL (required): "
|
|
read -r ENV_PUBLIC_URL
|
|
while [ -z "$ENV_PUBLIC_URL" ]; do
|
|
warn "PUBLIC_URL is required"
|
|
echo -n " PUBLIC_URL: "
|
|
read -r ENV_PUBLIC_URL
|
|
done
|
|
|
|
echo -n " DATA_PATH [$REAL_HOME/.local/data]: "
|
|
read -r ENV_DATA_PATH
|
|
ENV_DATA_PATH="${ENV_DATA_PATH:-$REAL_HOME/.local/data}"
|
|
|
|
# The file-based item store (skills/tools/tasks/…). Defaults to a sibling of the repo; without it the
|
|
# server falls back to <repo>/officer-items and boots with an empty store.
|
|
ITEMS_DEFAULT="$(dirname "$PROJECT_DIR")/officer-items"
|
|
echo -n " OFFICER_ITEMS_DIR [$ITEMS_DEFAULT]: "
|
|
read -r ENV_OFFICER_ITEMS_DIR
|
|
ENV_OFFICER_ITEMS_DIR="${ENV_OFFICER_ITEMS_DIR:-$ITEMS_DEFAULT}"
|
|
|
|
MAIL_DEFAULT="${DOCKER_MAIL_TRANSPORT:-smtp://127.0.0.1:1025}"
|
|
echo -n " MAIL_TRANSPORT [$MAIL_DEFAULT]: "
|
|
read -r ENV_MAIL_TRANSPORT
|
|
ENV_MAIL_TRANSPORT="${ENV_MAIL_TRANSPORT:-$MAIL_DEFAULT}"
|
|
|
|
echo -n " DISCORD_BUG_REPORT_WEBHOOK []: "
|
|
read -r ENV_DISCORD_WEBHOOK
|
|
|
|
if [ -z "$POSTGRES_URL" ]; then
|
|
echo -n " POSTGRES_URL: "
|
|
read -r POSTGRES_URL
|
|
fi
|
|
|
|
# Auto-generate values
|
|
JWT_SECRET=$(openssl rand -base64 48 | tr -d '/+=' | head -c 48)
|
|
|
|
# Write .env
|
|
cat > "$PROJECT_DIR/.env" <<ENVFILE
|
|
PORT="$ENV_PORT"
|
|
JWT_SECRET="$JWT_SECRET"
|
|
MAIL_TRANSPORT="$ENV_MAIL_TRANSPORT"
|
|
PUBLIC_URL="$ENV_PUBLIC_URL"
|
|
PUBLIC_BUILD_ENV="production"
|
|
DATA_PATH="$ENV_DATA_PATH"
|
|
OFFICER_ITEMS_DIR="$ENV_OFFICER_ITEMS_DIR"
|
|
HOME_DIR="$HOME"
|
|
POSTGRES_URL="$POSTGRES_URL"
|
|
DISCORD_BUG_REPORT_WEBHOOK="$ENV_DISCORD_WEBHOOK"
|
|
ENVFILE
|
|
|
|
ok ".env written to $PROJECT_DIR/.env"
|
|
fi
|
|
|
|
# ─── 17. remote desktop (Ubuntu Desktop + VNC) ───────────────────────────────
|
|
echo ""
|
|
echo "── Remote Desktop (Ubuntu Desktop + VNC) ──"
|
|
|
|
# No "already installed" guard here on purpose. This used to skip on `dpkg -s ubuntu-desktop`, which
|
|
# treats one package being present as proof the whole remote desktop is configured — and those are very
|
|
# different things. A host can have ubuntu-desktop and still be missing every part that makes the mirror
|
|
# work: GDM auto-login, the forced Xorg session, the captured EDID and its kernel command line, the
|
|
# login-time mode setter. That was not hypothetical; it was this machine on 2026-08-02, where the guard
|
|
# reported "skip" while five of setup-desktop.sh's steps had never run and /desktop could not survive a
|
|
# reboot. setup-desktop.sh is idempotent throughout — every step either no-ops or is individually
|
|
# guarded — so letting it run each time converges a partially configured host instead of trusting a
|
|
# proxy for state it never actually checked.
|
|
case $PM in
|
|
apt)
|
|
bash "$SCRIPT_DIR/setup-desktop.sh"
|
|
;;
|
|
*)
|
|
warn "Remote desktop setup is Ubuntu/Debian only — skipping"
|
|
;;
|
|
esac
|
|
|
|
# ─── 18. project initialization ──────────────────────────────────────────────
|
|
echo ""
|
|
echo "── Project initialization ──"
|
|
|
|
if ! has bun || [ ! -f "$PROJECT_DIR/.env" ]; then
|
|
warn "Skipping project initialization (bun or .env missing)"
|
|
else
|
|
# index.gen.html is gitignored and built from .env, so it does not exist on a fresh clone.
|
|
echo " Generating index.gen.html from PUBLIC_URL..."
|
|
if (cd "$PROJECT_DIR" && bun run gen:index); then
|
|
ok "index.gen.html generated"
|
|
else
|
|
fail "gen:index failed — the app will not serve until this succeeds"
|
|
fi
|
|
|
|
# Officer applies its schema with push; there are no migrations to run.
|
|
echo " Applying database schema..."
|
|
if (cd "$PROJECT_DIR" && bun db:push); then
|
|
ok "database schema applied"
|
|
else
|
|
fail "db:push failed — check POSTGRES_URL in .env and that Postgres is reachable"
|
|
fi
|
|
fi
|
|
|
|
# ─── 19. start the services ──────────────────────────────────────────────────
|
|
echo ""
|
|
echo "── Services (pm2) ──"
|
|
|
|
# Installing pm2 is not the same as running anything with it. Without this the setup finishes with
|
|
# every dependency in place and nothing actually listening — and the sidecars matter beyond the web
|
|
# app: /desktop returns 503 until officer-vnc is connected, and chat needs officer-claude.
|
|
if ! has pm2 || [ ! -f "$PROJECT_DIR/ecosystem.config.cjs" ]; then
|
|
warn "Skipping service start (pm2 or ecosystem.config.cjs missing)"
|
|
else
|
|
# startOrRestart also picks up apps added to the ecosystem since the last run. These are all
|
|
# fork-mode apps, so reload would buy nothing over restart.
|
|
echo " Starting Officer and its sidecars..."
|
|
if (cd "$PROJECT_DIR" && pm2 startOrRestart ecosystem.config.cjs); then
|
|
ok "services started"
|
|
else
|
|
fail "pm2 could not start the services — check 'pm2 logs'"
|
|
fi
|
|
|
|
# Persist the process list so the boot unit has something to resurrect.
|
|
pm2 save >/dev/null 2>&1 && ok "process list saved" || warn "pm2 save failed"
|
|
|
|
# Boot persistence. pm2 startup writes a systemd unit; it needs root, and re-running it when the
|
|
# unit already exists is harmless.
|
|
if systemctl list-unit-files 2>/dev/null | grep -q "^pm2-$(whoami)\.service"; then
|
|
skip "pm2 boot service (pm2-$(whoami).service)"
|
|
else
|
|
echo " Enabling start on boot..."
|
|
if sudo env PATH="$PATH" pm2 startup systemd -u "$(whoami)" --hp "$HOME" >/dev/null 2>&1; then
|
|
pm2 save >/dev/null 2>&1
|
|
ok "services will start on boot"
|
|
else
|
|
warn "Could not enable boot startup — run: pm2 startup (and follow its instructions)"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# ─── verification ─────────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "═══════════════════════════════════════════"
|
|
echo " Verification"
|
|
echo "═══════════════════════════════════════════"
|
|
echo ""
|
|
|
|
check() {
|
|
if has "$1"; then ok "$1"; else fail "$1 — NOT FOUND"; fi
|
|
}
|
|
|
|
echo "Required:"
|
|
check git
|
|
check node
|
|
check bun
|
|
check npm
|
|
check ffmpeg
|
|
check zip
|
|
check script
|
|
check python3
|
|
check make
|
|
check gcc
|
|
|
|
echo ""
|
|
echo "Dev tools:"
|
|
check go
|
|
check rustc
|
|
check cargo
|
|
check nvim
|
|
check zsh
|
|
check starship
|
|
check lazygit
|
|
check eza
|
|
check rg
|
|
check fd
|
|
check jq
|
|
check htop
|
|
check tmux
|
|
check tree
|
|
check btop
|
|
check sqlite3
|
|
|
|
echo ""
|
|
echo "Audio (cliamp):"
|
|
check pulseaudio
|
|
check parec
|
|
check pactl
|
|
check cliamp
|
|
|
|
echo ""
|
|
echo "AI agents:"
|
|
check claude
|
|
|
|
echo ""
|
|
echo "Process manager:"
|
|
check pm2
|
|
|
|
echo ""
|
|
echo "Optional:"
|
|
check unzip
|
|
check 7z
|
|
check unrar
|
|
check pgrep
|
|
check fuser
|
|
check yt-dlp
|
|
|
|
echo ""
|
|
echo "═══════════════════════════════════════════"
|
|
echo " Setup complete!"
|
|
echo "═══════════════════════════════════════════"
|
|
# Services actually running is a better signal than the binaries being present. The list comes from
|
|
# ecosystem.config.cjs so it cannot drift as sidecars are added.
|
|
if has pm2 && [ -f "$PROJECT_DIR/ecosystem.config.cjs" ]; then
|
|
echo ""
|
|
echo "Services:"
|
|
for app in $(grep -oE "name: *'[^']+'" "$PROJECT_DIR/ecosystem.config.cjs" | sed "s/.*'\(.*\)'/\1/"); do
|
|
if pm2 pid "$app" >/dev/null 2>&1 && [ -n "$(pm2 pid "$app" 2>/dev/null | tr -d '[:space:]')" ]; then
|
|
ok "$app"
|
|
else
|
|
fail "$app — not running (pm2 logs $app)"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
echo ""
|
|
echo "Notes:"
|
|
echo " • PulseAudio null sink starts automatically with the server"
|
|
echo " • Make sure ~/.local/go/bin and ~/.local/go-path/bin are in your PATH for Go tools"
|
|
echo " • Make sure ~/.cargo/bin is in your PATH for Rust tools"
|
|
echo " • sharp, whisper-cpp, mlx-audio can be installed from Settings > Applications"
|
|
echo ""
|