setup.sh installed pm2 but never ran anything with it, so a fresh install finished with every dependency in place and nothing listening. That is not cosmetic: /desktop returns 503 until officer-vnc is connected, and chat needs officer-claude. Adds a step that runs `pm2 startOrRestart ecosystem.config.cjs`, saves the process list, and enables the boot unit when it is not already there. Using startOrRestart rather than start means apps added to the ecosystem since the last run get picked up — officer-music is in the ecosystem on this box but was never running, for exactly that reason. The verification block now reports which services are up, with the names read from ecosystem.config.cjs so the list cannot drift as sidecars are added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1034 lines
34 KiB
Bash
Executable File
1034 lines
34 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"
|
|
|
|
if [ -f "$SUDOERS_FILE" ] && grep -q "$SERVICE_USER" "$SUDOERS_FILE" 2>/dev/null; then
|
|
skip "sudoers entry for $SERVICE_USER"
|
|
else
|
|
case $PM in
|
|
apt|pacman)
|
|
echo "$SERVICE_USER ALL=(ALL) NOPASSWD: ALL" | sudo tee "$SUDOERS_FILE" > /dev/null
|
|
sudo chmod 0440 "$SUDOERS_FILE"
|
|
ok "Created sudoers entry for $SERVICE_USER at $SUDOERS_FILE"
|
|
;;
|
|
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
|
|
mkdir -p "$HOME/.config"
|
|
cp "$SCRIPT_DIR/starship.toml" "$HOME/.config/starship.toml"
|
|
ok "starship config deployed"
|
|
|
|
# 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 (XFCE + VNC) ────────────────────────────────────────
|
|
echo ""
|
|
echo "── Remote Desktop (XFCE + VNC) ──"
|
|
|
|
if dpkg -s ubuntu-desktop &>/dev/null 2>&1; then
|
|
skip "remote desktop (ubuntu-desktop already installed)"
|
|
else
|
|
case $PM in
|
|
apt)
|
|
bash "$SCRIPT_DIR/setup-desktop.sh"
|
|
;;
|
|
*)
|
|
warn "Remote desktop setup is Ubuntu/Debian only — skipping"
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
# ─── 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 ""
|